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)/member/[slug]/page.tsx b/src/app/(app)/member/[slug]/page.tsx index 4a2040b..a0af868 100644 --- a/src/app/(app)/member/[slug]/page.tsx +++ b/src/app/(app)/member/[slug]/page.tsx @@ -107,7 +107,7 @@ import { cn } from "~/lib/utils"; type ProfileTab = "posts" | "tagged" | "liked" | "gallery"; -const tabs: { id: ProfileTab; label: string; icon: typeof Dash }[] = [ +const allTabs: { id: ProfileTab; label: string; icon: typeof Dash }[] = [ { id: "posts", label: "Posts", icon: Dash }, { id: "gallery", label: "Gallery", icon: Image }, { id: "tagged", label: "Mentions & Tags", icon: UserSquare }, @@ -164,6 +164,24 @@ export default function MemberProfilePage() { } satisfies FamilyMemberProfile) : undefined; + // Filter tabs based on member status (hide Posts and Liked for unclaimed members) + const isMemberClaimed = member?.status === "claimed"; + const visibleTabs = allTabs.filter((tab) => { + if (!isMemberClaimed && (tab.id === "posts" || tab.id === "liked")) { + return false; + } + return true; + }); + + // Reset active tab if it's hidden for unclaimed members + const isActivTabVisible = visibleTabs.some((tab) => tab.id === activeTab); + if (!isActivTabVisible && visibleTabs.length > 0) { + const firstVisibleTab = visibleTabs[0]; + if (firstVisibleTab && activeTab !== firstVisibleTab.id) { + setActiveTab(firstVisibleTab.id); + } + } + const viewerRole = managementContext.data?.role?.toLowerCase(); const callerRole: "owner" | "admin" | "member" = viewerRole === "owner" || viewerRole === "admin" || viewerRole === "member" @@ -221,7 +239,7 @@ export default function MemberProfilePage() {
- {tabs.map(({ id, label, icon: Icon }) => ( + {visibleTabs.map(({ id, label, icon: Icon }) => (
+ ); +} diff --git a/src/app/(app)/members/new/page.tsx b/src/app/(app)/members/new/page.tsx index 97e21b8..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) { @@ -266,6 +305,7 @@ export default function AddMemberPage() { familyId: selectedFamilyId, memberId: data.id, name: normalizedName, + nickname: normalizedNickname.length > 0 ? normalizedNickname : undefined, image: avatarIntent.readUrl, }); } @@ -291,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; @@ -349,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

-
-
- {autoClaimInvite.invitedEmail ? ( -

- This link is email-bound to {autoClaimInvite.invitedEmail}. -

- ) : null} - {emailDelivery?.status === "sent" ? ( -
-
- ) : emailDelivery?.status === "skipped" ? ( -
-
-
-
- ) : emailDelivery?.status === "failed" ? ( -
-
-
-
+ ) : ( +
+
+ + +
+ + {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

+
+
+ {autoClaimInvite.invitedEmail ? ( +

+ This link is email-bound to {autoClaimInvite.invitedEmail}. +

+ ) : null} + {emailDelivery?.status === "sent" ? ( +
+
+ ) : emailDelivery?.status === "skipped" ? ( +
+
+
+
+ ) : emailDelivery?.status === "failed" ? ( +
+
+
+
+ +
+
+ ) : null}
) : null}
- ) : null} -
-
+
-
- - -
-
- ) : ( -
-
-
-
-
+
+ + +
+ + ) : addMode === "single" ? ( + +
+
+
+
- {formError ? ( - - - ) : null} - - {managementContext.isLoading ? ( - - - ) : null} - -
-
- - setMemberName(event.target.value)} - required - disabled={ - isSaving || - managementContext.isLoading || - !selectedFamilyId || - !canManageMembers - } - /> -
- -
- - setMemberNickname(event.target.value)} - disabled={ - isSaving || - managementContext.isLoading || - !selectedFamilyId || - !canManageMembers - } - /> -
- -
- - setMemberEmail(event.target.value)} - disabled={ - isSaving || - managementContext.isLoading || - !selectedFamilyId || - !canManageMembers - } - /> - {hasEmail ? ( -

- A claim invite link will be created automatically and bound to this email. -

+ {formError ? ( + + ) : null} -
- -
-

Profile photo (optional)

- { - handleAvatarSelected(event.currentTarget.files?.[0] ?? null); - event.currentTarget.value = ""; - }} - /> -
-
- - - - - - {isPreviewConverting ? ( -
-
+ + {managementContext.isLoading ? ( + + + ) : null} + +
+
+ + setMemberName(event.target.value)} + required + disabled={ + isSaving || + managementContext.isLoading || + !selectedFamilyId || + !canManageMembers + } + /> +
+ +
+ + setMemberNickname(event.target.value)} + disabled={ + isSaving || + managementContext.isLoading || + !selectedFamilyId || + !canManageMembers + } + /> +
+ +
+ + setMemberEmail(event.target.value)} + disabled={ + isSaving || + managementContext.isLoading || + !selectedFamilyId || + !canManageMembers + } + /> + {hasEmail ? ( +

+ A claim invite link will be created automatically and bound to this email. +

) : null}
-
- + +
+

Profile photo (optional)

+ { + handleAvatarSelected(event.currentTarget.files?.[0] ?? null); + event.currentTarget.value = ""; + }} + /> +
+
+ + + + + + {isPreviewConverting ? ( +
+
+ ) : null} +
+
+ + {selectedAvatarFile ? ( + + ) : null} +
+
{selectedAvatarFile ? ( - +

Selected: {selectedAvatarFile.name}

+ ) : null} + {isSaving && uploadProgress > 0 ? ( +
+
+
+
+

Uploading photo: {uploadProgress}%

+
) : null}
-
- {selectedAvatarFile ? ( -

Selected: {selectedAvatarFile.name}

- ) : null} - {isSaving && uploadProgress > 0 ? ( -
-
-
+
-

Uploading photo: {uploadProgress}%

+ + Generate invite link now + + Create a claim link immediately after adding this member. + {hasEmail ? " Required because an email is provided." : ""} + + +
- ) : null} -
- -
-
+ +
+
-
+ > + {isSaving ? ( + <> +
-
- - -
+ + ) : ( +
+
+
+
+
+ + {bulkError ? ( + + + ) : null} + + {managementContext.isLoading ? ( + + + ) : null} + +
+ {bulkRows.map((row, index) => ( +
+
+ updateBulkRow(row.id, event.target.value)} + placeholder={`Member name ${index + 1}`} + disabled={isBulkSaving || isContextBlocked} + /> + +
+ {row.error ?

{row.error}

: null} +
+ ))} + +
-
+ {bulkProgress ? ( +

+ Creating members... {bulkProgress.completed}/{bulkProgress.total} +

+ ) : null} + + {bulkResults.length > 0 ? ( +
+

+ Created {bulkResults.filter((result) => result.status === "created").length} of {bulkResults.length} members. +

+ {bulkResults.some((result) => result.status === "failed") ? ( +
    + {bulkResults + .filter((result) => result.status === "failed") + .map((result) => ( +
  • + {result.name}: {result.message ?? "Unable to create this member."} +
  • + ))} +
+ ) : null} +
+ ) : null} + +
+ + +
+ + )} + )} ); diff --git a/src/app/(app)/members/page.tsx b/src/app/(app)/members/page.tsx index 7b80325..649fe04 100644 --- a/src/app/(app)/members/page.tsx +++ b/src/app/(app)/members/page.tsx @@ -109,7 +109,7 @@ export default function MembersPage() { {canManageMembers ? ( - -
@@ -409,6 +514,12 @@ export function PostCard({

) : null} + {shareFeedback === "error" ? ( +

+ Unable to share this post right now. Please try again. +

+ ) : null} + ["variant"]; triggerSize?: React.ComponentProps["size"]; triggerClassName?: string; + isCurrentUser?: boolean; }; type EditProfileFormState = { @@ -114,6 +116,7 @@ export function EditProfileDialog({ triggerVariant = "outline", triggerSize = "sm", triggerClassName, + isCurrentUser = false, }: EditProfileDialogProps) { const [open, setOpen] = useState(false); const [isSaving, setIsSaving] = useState(false); @@ -355,8 +358,7 @@ export function EditProfileDialog({

Edit profile

-

Update the member name and avatar.

-
+

Update the member name and avatar.

- +
+ {isCurrentUser ? ( + +
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 (