From c08b860b7de10d78aebf0204d5a9719f56d6287f Mon Sep 17 00:00:00 2001
From: babblebey
Date: Tue, 14 Jul 2026 18:44:31 +0100
Subject: [PATCH 1/9] feat(performance): enable prefetch by adding loading
skeletons for gallery, app route, members, notifications, profile, and
settings
---
src/app/(app)/gallery/loading.tsx | 27 ++++++++++++++
src/app/(app)/loading.tsx | 29 +++++++++++++++
src/app/(app)/members/loading.tsx | 46 +++++++++++++++++++++++
src/app/(app)/notifications/loading.tsx | 31 ++++++++++++++++
src/app/(app)/profile/loading.tsx | 47 ++++++++++++++++++++++++
src/app/(app)/settings/layout.tsx | 7 ++++
src/app/(app)/settings/loading.tsx | 41 +++++++++++++++++++++
src/components/nav/desktop-sidebar.tsx | 19 +++++++---
src/components/nav/mobile-bottom-nav.tsx | 13 ++++++-
src/components/nav/mobile-header.tsx | 18 +++++++--
10 files changed, 268 insertions(+), 10 deletions(-)
create mode 100644 src/app/(app)/gallery/loading.tsx
create mode 100644 src/app/(app)/loading.tsx
create mode 100644 src/app/(app)/members/loading.tsx
create mode 100644 src/app/(app)/notifications/loading.tsx
create mode 100644 src/app/(app)/profile/loading.tsx
create mode 100644 src/app/(app)/settings/loading.tsx
diff --git a/src/app/(app)/gallery/loading.tsx b/src/app/(app)/gallery/loading.tsx
new file mode 100644
index 0000000..2e77bd7
--- /dev/null
+++ b/src/app/(app)/gallery/loading.tsx
@@ -0,0 +1,27 @@
+import { Skeleton } from "~/components/ui/skeleton";
+
+export default function GalleryLoading() {
+ return (
+
+
+ {/* Header skeleton */}
+
+
+ {/* Gallery grid skeleton */}
+
+
+ {Array.from({ length: 8 }).map((_, index) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/app/(app)/loading.tsx b/src/app/(app)/loading.tsx
new file mode 100644
index 0000000..a2f51b7
--- /dev/null
+++ b/src/app/(app)/loading.tsx
@@ -0,0 +1,29 @@
+import { Skeleton } from "~/components/ui/skeleton";
+
+export default function AppRouteLoading() {
+ return (
+
+
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/(app)/members/loading.tsx b/src/app/(app)/members/loading.tsx
new file mode 100644
index 0000000..5c8ba56
--- /dev/null
+++ b/src/app/(app)/members/loading.tsx
@@ -0,0 +1,46 @@
+import { Skeleton } from "~/components/ui/skeleton";
+
+export default function MembersLoading() {
+ return (
+
+ {/* Header skeleton */}
+
+
+ {/* Filters and search skeleton */}
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+
+
+ {/* Grid skeleton */}
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/app/(app)/notifications/loading.tsx b/src/app/(app)/notifications/loading.tsx
new file mode 100644
index 0000000..21dd2bc
--- /dev/null
+++ b/src/app/(app)/notifications/loading.tsx
@@ -0,0 +1,31 @@
+import { Skeleton } from "~/components/ui/skeleton";
+
+export default function NotificationsLoading() {
+ return (
+
+ {/* Filters skeleton */}
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+
+ {/* Notifications list skeleton */}
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/(app)/profile/loading.tsx b/src/app/(app)/profile/loading.tsx
new file mode 100644
index 0000000..5d6d550
--- /dev/null
+++ b/src/app/(app)/profile/loading.tsx
@@ -0,0 +1,47 @@
+import { Skeleton } from "~/components/ui/skeleton";
+
+export default function ProfileLoading() {
+ return (
+
+ {/* Header skeleton */}
+
+
+ {/* Edit button skeleton */}
+
+
+
+
+ {/* Tabs skeleton */}
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+ {/* Posts list skeleton */}
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/app/(app)/settings/layout.tsx b/src/app/(app)/settings/layout.tsx
index 3c58110..4073970 100644
--- a/src/app/(app)/settings/layout.tsx
+++ b/src/app/(app)/settings/layout.tsx
@@ -107,6 +107,12 @@ export default function SettingsLayout({
const activeSettingsLabel =
visibleSettingsNav.find((item) => item.href === activeSettingsHref)?.label ?? "Settings";
+ useEffect(() => {
+ for (const item of visibleSettingsNav) {
+ void router.prefetch(item.href);
+ }
+ }, [router, visibleSettingsNav]);
+
function navigateToSettings(href: string) {
if (href === pathname) return;
beginNavigationProgress();
@@ -162,6 +168,7 @@ export default function SettingsLayout({
+
+
+
+
+
+
+ {/* Mobile dropdown skeleton */}
+
+
+
+
+ {/* Settings sidebar skeleton - desktop only */}
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+
+
+ {/* Content area skeleton */}
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/nav/desktop-sidebar.tsx b/src/components/nav/desktop-sidebar.tsx
index 89c86c8..75e3a99 100644
--- a/src/components/nav/desktop-sidebar.tsx
+++ b/src/components/nav/desktop-sidebar.tsx
@@ -1,8 +1,8 @@
"use client";
-import { useMemo } from "react";
+import { useEffect, useMemo } from "react";
import Link from "next/link";
-import { usePathname } from "next/navigation";
+import { usePathname, useRouter } from "next/navigation";
import { Bell, House, Image, Plus, Settings, Sparkles, User, Users } from "~/components/ui/icons";
import { FamilyLogotypeLockup } from "~/components/branding/family-logotype-lockup";
@@ -43,6 +43,7 @@ function isSettingsPath(pathname: string) {
export function DesktopSidebar({ primaryLockup }: { primaryLockup: string }) {
const pathname = usePathname();
+ const router = useRouter();
const { openComposer } = useGlobalComposer();
const shouldPollUnread = !pathname.startsWith("/notifications");
@@ -94,10 +95,18 @@ export function DesktopSidebar({ primaryLockup }: { primaryLockup: string }) {
const unreadLabel = formatUnreadBadgeCount(unreadCountQuery.data?.count ?? 0);
+ useEffect(() => {
+ for (const item of items) {
+ void router.prefetch(item.href);
+ }
+
+ void router.prefetch("/settings");
+ }, [items, router]);
+
return (
) : null}
+ {shareFeedback === "error" ? (
+
+ Unable to share this post right now. Please try again.
+
+ ) : null}
+
Date: Wed, 15 Jul 2026 01:12:53 +0100
Subject: [PATCH 9/9] feat(members): add bulk member creation
---
src/app/(app)/members/new/page.tsx | 905 +++++++++++++++++++----------
1 file changed, 614 insertions(+), 291 deletions(-)
diff --git a/src/app/(app)/members/new/page.tsx b/src/app/(app)/members/new/page.tsx
index aad9b9c..9fdc0d5 100644
--- a/src/app/(app)/members/new/page.tsx
+++ b/src/app/(app)/members/new/page.tsx
@@ -3,7 +3,7 @@
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
-import { AlertCircle, Camera, Check, CheckCircle2, Copy, Link2, Loader, Send, TriangleAlert, User, UserRoundPlus } from "~/components/ui/icons";
+import { AlertCircle, Camera, Check, CheckCircle2, Copy, Link2, Loader, Plus, Send, TriangleAlert, User, UserRoundPlus, X } from "~/components/ui/icons";
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
@@ -19,6 +19,21 @@ type UploadIntentItem = {
readUrl: string;
};
+type AddMode = "single" | "bulk";
+
+type BulkMemberRow = {
+ id: string;
+ name: string;
+ error: string | null;
+};
+
+type BulkCreationResult = {
+ id: string;
+ name: string;
+ status: "created" | "failed";
+ message?: string;
+};
+
const ACCEPTED_AVATAR_MIME_TYPES = new Set([
"image/jpeg",
"image/png",
@@ -64,8 +79,23 @@ function uploadFileWithProgress(
});
}
+function createBulkRow(): BulkMemberRow {
+ const id =
+ typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
+ ? crypto.randomUUID()
+ : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+
+ return {
+ id,
+ name: "",
+ error: null,
+ };
+}
+
export default function AddMemberPage() {
const router = useRouter();
+ const trpcUtils = api.useUtils();
+ const [addMode, setAddMode] = useState("single");
const [isSubmitted, setIsSubmitted] = useState(false);
const [memberName, setMemberName] = useState("");
const [memberNickname, setMemberNickname] = useState("");
@@ -88,6 +118,11 @@ export default function AddMemberPage() {
message?: string;
} | null>(null);
const [claimInviteId, setClaimInviteId] = useState(null);
+ const [bulkRows, setBulkRows] = useState(() => [createBulkRow(), createBulkRow()]);
+ const [bulkError, setBulkError] = useState(null);
+ const [bulkResults, setBulkResults] = useState([]);
+ const [isBulkSaving, setIsBulkSaving] = useState(false);
+ const [bulkProgress, setBulkProgress] = useState<{ completed: number; total: number } | null>(null);
const fileInputRef = useRef(null);
const avatarPreviewSelectionRef = useRef(0);
@@ -104,6 +139,10 @@ export default function AddMemberPage() {
!managementContext.isLoading && selectedFamilyId && !canManageMembers;
const hasEmail = memberEmail.trim().length > 0;
const shouldGenerateInviteNow = hasEmail || shouldGenerateInvite;
+ const isContextBlocked =
+ managementContext.isLoading ||
+ !selectedFamilyId ||
+ !canManageMembers;
useEffect(() => {
if (showPermissionDenied) {
@@ -292,6 +331,159 @@ export default function AddMemberPage() {
}
};
+ const updateBulkRow = (rowId: string, value: string) => {
+ setBulkRows((currentRows) =>
+ currentRows.map((row) =>
+ row.id === rowId
+ ? {
+ ...row,
+ name: value,
+ error: null,
+ }
+ : row,
+ ),
+ );
+ };
+
+ const handleAddBulkRow = () => {
+ setBulkRows((currentRows) => [...currentRows, createBulkRow()]);
+ };
+
+ const handleRemoveBulkRow = (rowId: string) => {
+ setBulkRows((currentRows) => {
+ if (currentRows.length <= 1) return currentRows;
+ return currentRows.filter((row) => row.id !== rowId);
+ });
+ };
+
+ const handleBulkSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ if (isBulkSaving) return;
+
+ if (!selectedFamilyId) {
+ setBulkError("No family context was found for your account.");
+ return;
+ }
+
+ const preparedRows: Array<{ id: string; name: string }> = bulkRows.map((row) => ({
+ id: row.id,
+ name: row.name.trim(),
+ }));
+
+ const hasAnyName = preparedRows.some((row) => row.name.length > 0);
+ if (!hasAnyName) {
+ setBulkRows((currentRows) =>
+ currentRows.map((row) => ({
+ ...row,
+ error: "Member name is required.",
+ })),
+ );
+ setBulkError("Add at least one member name before creating.");
+ return;
+ }
+
+ const validationErrors = new Map();
+
+ preparedRows.forEach((row) => {
+ if (!row.name) {
+ return;
+ }
+
+ if (row.name.length > 120) {
+ validationErrors.set(row.id, "Name must be 120 characters or fewer.");
+ }
+ });
+
+ if (validationErrors.size > 0) {
+ setBulkRows((currentRows) =>
+ currentRows.map((row) => ({
+ ...row,
+ error: validationErrors.get(row.id) ?? null,
+ })),
+ );
+ setBulkError("One or more names need to be corrected.");
+ return;
+ }
+
+ const rowsToCreate: Array<{ id: string; name: string }> = preparedRows.filter(
+ (row) => row.name.length > 0,
+ );
+
+ setBulkError(null);
+ setBulkResults([]);
+ setIsBulkSaving(true);
+ setBulkProgress({ completed: 0, total: rowsToCreate.length });
+
+ const nextResults: BulkCreationResult[] = [];
+
+ try {
+ let completed = 0;
+
+ for (const row of rowsToCreate) {
+ if (!row) {
+ continue;
+ }
+
+ try {
+ await createMember.mutateAsync({
+ familyId: selectedFamilyId,
+ name: row.name,
+ });
+
+ nextResults.push({
+ id: row.id,
+ name: row.name,
+ status: "created",
+ });
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "An unexpected error occurred. Please try again.";
+
+ nextResults.push({
+ id: row.id,
+ name: row.name,
+ status: "failed",
+ message,
+ });
+ } finally {
+ completed += 1;
+ setBulkProgress({
+ completed,
+ total: rowsToCreate.length,
+ });
+ }
+ }
+
+ setBulkResults(nextResults);
+
+ const failedResults = nextResults.filter((result) => result.status === "failed");
+ setBulkRows(
+ failedResults.length > 0
+ ? failedResults.map((result) => ({
+ id: result.id,
+ name: result.name,
+ error: result.message ?? "Unable to create this member.",
+ }))
+ : [createBulkRow(), createBulkRow()],
+ );
+
+ if (nextResults.some((result) => result.status === "created")) {
+ await trpcUtils.familyMember.listFamilyMembers.invalidate({
+ familyId: selectedFamilyId,
+ });
+ }
+
+ if (failedResults.length > 0) {
+ setBulkError(
+ `${failedResults.length} ${failedResults.length === 1 ? "member" : "members"} could not be created. Fix and retry.`,
+ );
+ }
+ } finally {
+ setIsBulkSaving(false);
+ setBulkProgress(null);
+ }
+ };
+
const handleAddAnother = () => {
avatarPreviewSelectionRef.current += 1;
@@ -350,331 +542,462 @@ export default function AddMemberPage() {
- ) : isSubmitted ? (
-
-
-
-
-
-
-
Member profile created
-
- {memberName || "This person"} was added as an unclaimed family member profile.
-
-
- They can claim this profile later using a claim invite.
-
- {autoClaimInvite && autoClaimUrl ? (
-
-
Claim invite created automatically
-
-
-
{autoClaimUrl}
-
-
- {autoClaimInvite.invitedEmail ? (
-
- This link is email-bound to {autoClaimInvite.invitedEmail}.
-
- ) : null}
- {emailDelivery?.status === "sent" ? (
-
-
-
Invite email sent to {autoClaimInvite.invitedEmail}.
-
- ) : emailDelivery?.status === "skipped" ? (
-
-
-
-
-
Email not sent automatically
-
{emailDelivery.message ?? "Share the link above manually."}
-
-
-
- ) : emailDelivery?.status === "failed" ? (
-
-
-
-
-
Email failed to send
-
{emailDelivery.message ?? "Copy the link above and share it manually."}
-
-
-
+ ) : (
+
+
+
+
+
+
+ {addMode === "single" && isSubmitted ? (
+
+
+
+
+
+
+
Member profile created
+
+ {memberName || "This person"} was added as an unclaimed family member profile.
+
+
+ They can claim this profile later using a claim invite.
+
+ {autoClaimInvite && autoClaimUrl ? (
+
+
Claim invite created automatically
+
+
+
{autoClaimUrl}
+ {autoClaimInvite.invitedEmail ? (
+
+ This link is email-bound to {autoClaimInvite.invitedEmail}.
+
+ ) : null}
+ {emailDelivery?.status === "sent" ? (
+
+
+
Invite email sent to {autoClaimInvite.invitedEmail}.
+
+ ) : emailDelivery?.status === "skipped" ? (
+
+
+
+
+
Email not sent automatically
+
{emailDelivery.message ?? "Share the link above manually."}
+
+
+
+ ) : emailDelivery?.status === "failed" ? (
+
+
+
+
+
Email failed to send
+
{emailDelivery.message ?? "Copy the link above and share it manually."}
+
+
+
+
+
+
+ ) : null}
) : null}
- ) : null}
-
-
+
-
-
-
-
-
- ) : (
-