Improve banner drafts and event settings assistant

This commit is contained in:
2026-09-09 19:12:06 -04:00
parent 2eecf853a1
commit 176b5fa95c
4 changed files with 157 additions and 60 deletions
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { CheckIcon, ImageIcon, WandSparklesIcon } from "lucide-react";
import { CheckIcon, ImageIcon, SaveIcon, WandSparklesIcon } from "lucide-react";
import { eventSlugSchema } from "@album/contracts";
import { LocationInput } from "@/components/location-input";
import { BannerUpload } from "@/components/banner-upload";
@@ -15,6 +15,7 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { NativeSelect } from "@/components/ui/native-select";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import type { PublishingPolicy } from "@album/contracts";
import {
Card,
@@ -103,6 +104,56 @@ export function EventSettingsForm({
);
const [formUploadEnabled, setFormUploadEnabled] = useState(uploadEnabled);
const [formListed, setFormListed] = useState(listed);
const [bannerBusy, setBannerBusy] = useState(false);
const [bannerReset, setBannerReset] = useState(0);
const customPreview = api.banners.status.useQuery({ eventId, bannerId: formCustomBanner ?? "" }, { enabled: Boolean(formCustomBanner), retry: false });
const previewUrl = formCustomBanner ? customPreview.data?.url : bannerPhotos.find((photo) => photo.id === formBanner)?.displayUrl;
const draft = { ...publishing, title: formTitle, slug: formSlug, description: formDescription.trim() || null,
location: formLocation.trim() || null, locationCoordinates: coordinates, bannerPhotoId: formBanner,
customBannerId: formCustomBanner, uploadEnabled: formUploadEnabled, listed: formListed };
const [baseline, setBaseline] = useState(draft);
const changes = [
["Title", draft.title !== baseline.title], ["Guest link", draft.slug !== baseline.slug],
["Description", draft.description !== baseline.description],
["Location", draft.location !== baseline.location || JSON.stringify(draft.locationCoordinates) !== JSON.stringify(baseline.locationCoordinates)],
["Banner", draft.bannerPhotoId !== baseline.bannerPhotoId || draft.customBannerId !== baseline.customBannerId],
["Publishing rules", draft.notesPolicy !== baseline.notesPolicy || draft.galleryPolicy !== baseline.galleryPolicy],
["Guest statistics", draft.showPhotoStats !== baseline.showPhotoStats || draft.showSubmitterStats !== baseline.showSubmitterStats || draft.showNoteStats !== baseline.showNoteStats],
["Uploads", draft.uploadEnabled !== baseline.uploadEnabled], ["Homepage listing", draft.listed !== baseline.listed],
].filter(([, changed]) => changed).map(([label]) => String(label));
const dirty = changes.length > 0;
const issues = [
!formTitle.trim() || formTitle.trim().length > 120 ? "Enter a title between 1 and 120 characters." : null,
!canSaveSlug ? slugMessage : null,
formDescription.trim().length > 2000 ? "Keep the description under 2,000 characters." : null,
formLocation.trim().length > 300 ? "Keep the location under 300 characters." : null,
bannerBusy ? "Wait for the banner upload and processing to finish." : null,
formCustomBanner && (customPreview.isPending || customPreview.isError || customPreview.data?.status !== "ready") ? "The selected banner isn't ready. Retry its preview or select another banner." : null,
formBanner && !photos.isLoading && !bannerPhotos.some((photo) => photo.id === formBanner) ? "The selected gallery photo is unavailable. Choose another banner." : null,
].filter((issue): issue is string => Boolean(issue));
useEffect(() => {
if (!dirty && !bannerBusy) return;
const beforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ""; };
const navigate = (event: MouseEvent) => {
const target = event.target instanceof Element ? event.target.closest('a[href]') : null;
if (!target || target.getAttribute("target") === "_blank" || event.metaKey || event.ctrlKey) return;
if (!window.confirm("You have unsaved event settings. Leave without saving?")) {
event.preventDefault(); event.stopPropagation();
}
};
window.addEventListener("beforeunload", beforeUnload);
document.addEventListener("click", navigate, true);
return () => { window.removeEventListener("beforeunload", beforeUnload); document.removeEventListener("click", navigate, true); };
}, [dirty, bannerBusy]);
function discard() {
setFormTitle(baseline.title); setFormSlug(baseline.slug); setFormDescription(baseline.description ?? "");
setFormLocation(baseline.location ?? ""); setCoordinates(baseline.locationCoordinates);
setFormBanner(baseline.bannerPhotoId); setFormCustomBanner(baseline.customBannerId);
setFormUploadEnabled(baseline.uploadEnabled); setFormListed(baseline.listed);
setPublishing({ notesPolicy: baseline.notesPolicy, galleryPolicy: baseline.galleryPolicy, showPhotoStats: baseline.showPhotoStats,
showSubmitterStats: baseline.showSubmitterStats, showNoteStats: baseline.showNoteStats });
setBannerReset((value) => value + 1); updateEvent.reset();
}
const utils = api.useUtils();
async function generateSlug() {
setGeneratingSlug(true);
@@ -114,7 +165,8 @@ export function EventSettingsForm({
} finally { setGeneratingSlug(false); }
}
const updateEvent = api.manager.updateEvent.useMutation({
onSuccess: async () => {
onSuccess: async (_result, variables) => {
setBaseline({ ...draft, ...variables });
toast.success("Event saved");
await utils.manager.event.invalidate({ eventId });
router.refresh();
@@ -138,7 +190,7 @@ export function EventSettingsForm({
uploadEnabled?: boolean;
listed?: boolean;
}) {
if (!canSaveSlug) { toast.error("Choose a valid, available guest link before saving."); return; }
if (issues.length || updateEvent.isPending) { toast.error(issues[0] ?? "Saving is already in progress."); return; }
updateEvent.mutate({
...publishing,
eventId,
@@ -156,7 +208,8 @@ export function EventSettingsForm({
}
return (
<Card>
<div className="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_19rem]">
<Card className="min-w-0">
<CardHeader>
<CardTitle className="text-lg font-semibold tracking-tight">Event settings</CardTitle>
<CardDescription>
@@ -166,13 +219,50 @@ export function EventSettingsForm({
</CardHeader>
<CardContent>
<form
id="event-settings-form"
className="flex flex-col gap-5"
onSubmit={(event) => {
event.preventDefault();
save();
}}
>
<fieldset disabled={updateEvent.isPending} className="min-w-0">
<FieldGroup>
<Field>
<span id="banner-label" className="text-sm font-medium">Event Banner</span>
<FieldDescription>
Preview your choice below, then Save changes to apply it. Uploading alone does not change the live banner.
</FieldDescription>
<div className="overflow-hidden rounded-xl border bg-muted">
{previewUrl ? <img src={previewUrl} alt="Selected event banner preview" width={1200} height={450} className="aspect-[8/3] w-full object-cover" /> :
<div className="flex aspect-[8/3] items-center justify-center gap-2 text-sm text-muted-foreground"><ImageIcon className="size-5" />{formCustomBanner || formBanner ? "Loading banner preview…" : "No banner selected"}</div>}
<p className="border-t bg-card px-4 py-3 text-sm" role="status">
{changes.includes("Banner") ? "Unsaved banner selection" : "Saved banner"} · {formCustomBanner ? "Dedicated upload" : formBanner ? "Gallery photo" : "No image"}
</p>
</div>
{customPreview.isError ? <Button type="button" variant="outline" onClick={() => void customPreview.refetch()}>Retry banner preview</Button> : null}
<BannerUpload key={bannerReset} eventId={eventId} selectedId={formCustomBanner} onBusyChange={setBannerBusy}
onSelect={(id) => { setFormCustomBanner(id); setFormBanner(null); }} />
<Button type="button" variant="outline" disabled={bannerBusy || (!formBanner && !formCustomBanner)} onClick={() => { setFormBanner(null); setFormCustomBanner(null); }}>Remove banner</Button>
<details className="rounded-lg border p-3">
<summary className="cursor-pointer py-2 text-sm font-medium">Choose from approved gallery photos ({bannerPhotos.length})</summary>
<fieldset disabled={bannerBusy} aria-labelledby="banner-label" className="mt-3 grid max-h-80 grid-cols-2 gap-3 overflow-y-auto sm:grid-cols-3">
{bannerPhotos.map((photo, index) => (
<button key={photo.id} type="button" aria-label={`Use photo ${index + 1} as banner`}
aria-pressed={formBanner === photo.id} onClick={() => { setFormBanner(photo.id); setFormCustomBanner(null); }}
className="relative aspect-[3/2] overflow-hidden rounded-lg border-2 border-transparent aria-pressed:border-primary focus-visible:outline-2 focus-visible:outline-ring">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photo.thumbUrl ?? photo.displayUrl ?? ""} alt="" width={300} height={200} loading="lazy" className="size-full object-cover" />
{formBanner === photo.id ? <span className="absolute right-2 top-2 rounded-full bg-primary p-1 text-primary-foreground"><CheckIcon aria-hidden="true" className="size-4" /></span> : null}
</button>
))}
</fieldset>
{photos.isLoading ? <p className="text-xs text-muted-foreground">Loading photos</p> : null}
{photos.isError ? <p role="alert" className="text-sm text-destructive">Could not load banner photos. Try reopening Settings.</p> : null}
{!photos.isLoading && !photos.isError && bannerPhotos.length === 0 ? <p className="text-sm text-muted-foreground">Upload and approve an event photo to use it as a banner.</p> : null}
</details>
<FieldDescription>Dedicated uploads stay out of the gallery. Gallery banners follow gallery visibility rules. Images are center-cropped for this preview; page layouts may crop differently.</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="notes-policy">Publish notes</FieldLabel>
<NativeSelect id="notes-policy" value={publishing.notesPolicy} onChange={(e) => setPublishing({ ...publishing, notesPolicy: e.target.value as PublishingPolicy })}>
@@ -209,6 +299,7 @@ export function EventSettingsForm({
value={formTitle}
onChange={(event) => setFormTitle(event.target.value)}
required
maxLength={120}
/>
</Field>
<Field data-invalid={!slugValid || (formSlug !== slug && !slugChecking && !slugAvailable && !availability.isError)}>
@@ -257,32 +348,6 @@ export function EventSettingsForm({
}} />
<FieldDescription>Shown on the event's guest page.</FieldDescription>
</Field>
<Field>
<span id="banner-label" className="text-sm font-medium">Event Banner</span>
<FieldDescription>
Upload a dedicated banner, or choose an approved gallery photo. Dedicated banners appear as soon as the event is published; gallery photos follow gallery publishing settings.
</FieldDescription>
<BannerUpload eventId={eventId} selectedId={formCustomBanner}
onSelect={(id) => { setFormCustomBanner(id); setFormBanner(null); }} />
<div role="group" aria-labelledby="banner-label" className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<button type="button" aria-pressed={formBanner === null && formCustomBanner === null} onClick={() => { setFormBanner(null); setFormCustomBanner(null); }}
className="flex aspect-[3/2] flex-col items-center justify-center gap-2 rounded-lg border-2 border-border bg-muted text-sm text-muted-foreground aria-pressed:border-primary aria-pressed:text-primary focus-visible:outline-2 focus-visible:outline-ring">
<ImageIcon aria-hidden="true" className="size-5" />No Banner
</button>
{bannerPhotos.map((photo, index) => (
<button key={photo.id} type="button" aria-label={`Use photo ${index + 1} as banner`}
aria-pressed={formBanner === photo.id} onClick={() => { setFormBanner(photo.id); setFormCustomBanner(null); }}
className="relative aspect-[3/2] overflow-hidden rounded-lg border-2 border-transparent aria-pressed:border-primary focus-visible:outline-2 focus-visible:outline-ring">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photo.thumbUrl ?? photo.displayUrl ?? ""} alt="" width={300} height={200} loading="lazy" className="size-full object-cover" />
{formBanner === photo.id ? <span className="absolute right-2 top-2 rounded-full bg-primary p-1 text-primary-foreground"><CheckIcon aria-hidden="true" className="size-4" /></span> : null}
</button>
))}
</div>
{photos.isLoading ? <p className="text-xs text-muted-foreground">Loading photos</p> : null}
{photos.isError ? <p role="alert" className="text-sm text-destructive">Could not load banner photos. Try reopening Settings.</p> : null}
{!photos.isLoading && !photos.isError && bannerPhotos.length === 0 ? <p className="text-sm text-muted-foreground">Upload and approve an event photo to use it as a banner.</p> : null}
</Field>
<Field orientation="horizontal">
<FieldLabel htmlFor="uploads">Accept uploads</FieldLabel>
<Switch
@@ -290,7 +355,6 @@ export function EventSettingsForm({
checked={formUploadEnabled}
onCheckedChange={(checked) => {
setFormUploadEnabled(checked);
save({ uploadEnabled: checked });
}}
/>
</Field>
@@ -301,40 +365,40 @@ export function EventSettingsForm({
checked={formListed}
onCheckedChange={(checked) => {
setFormListed(checked);
save({ listed: checked });
}}
/>
</Field>
</FieldGroup>
</fieldset>
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={updateEvent.isPending || !canSaveSlug}>
<Button type="submit" disabled={updateEvent.isPending || issues.length > 0 || !dirty}>
{updateEvent.isPending ? <Spinner data-icon="inline-start" /> : null}
Save
Save changes
</Button>
{status !== "published" ? (
<Button
type="button"
variant="outline"
disabled={updateEvent.isPending}
disabled={updateEvent.isPending || issues.length > 0}
onClick={() => save({ status: "published" })}
>
Publish link
Save and publish link
</Button>
) : (
<Button
type="button"
variant="outline"
disabled={updateEvent.isPending}
disabled={updateEvent.isPending || issues.length > 0}
onClick={() => save({ status: "closed" })}
>
Close uploads
Save and close uploads
</Button>
)}
{status === "closed" ? (
<Button
type="button"
variant="ghost"
disabled={updateEvent.isPending}
disabled={updateEvent.isPending || issues.length > 0}
onClick={() => save({ status: "published" })}
>
Reopen
@@ -343,7 +407,7 @@ export function EventSettingsForm({
<Button
type="button"
variant="secondary"
disabled={release.isPending || galleryPolicy === "never"}
disabled={release.isPending || updateEvent.isPending || dirty || bannerBusy || galleryPolicy === "never"}
onClick={() =>
release.mutate({ eventId, notifyGuests: true })
}
@@ -354,5 +418,22 @@ export function EventSettingsForm({
</form>
</CardContent>
</Card>
<aside aria-label="Event settings assistant" className="order-first xl:order-last xl:sticky xl:top-24">
<Card>
<CardHeader><CardTitle>Assistant</CardTitle><CardDescription>Review your settings before they go live.</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-4">
<div role="status" aria-live="polite"><p className="text-sm font-medium">{updateEvent.isPending ? "Saving changes…" : dirty ? `${changes.length} unsaved ${changes.length === 1 ? "change" : "changes"}` : "All changes saved"}</p>
{dirty ? <ul className="mt-2 list-inside list-disc text-sm text-muted-foreground">{changes.map((change) => <li key={change}>{change}</li>)}</ul> : <p className="mt-1 text-sm text-muted-foreground">Selections stay in draft until you save.</p>}</div>
{issues.length ? <Alert variant="destructive"><AlertTitle>Needs attention</AlertTitle><AlertDescription><ul className="list-inside list-disc">{issues.map((issue) => <li key={issue}>{issue}</li>)}</ul></AlertDescription></Alert> : null}
{updateEvent.error ? <Alert variant="destructive"><AlertTitle>Changes not saved</AlertTitle><AlertDescription>{updateEvent.error.message} Your edits are still here. Correct the issue and try again.</AlertDescription></Alert> : null}
<Button type="submit" form="event-settings-form" disabled={!dirty || issues.length > 0 || updateEvent.isPending}><SaveIcon data-icon="inline-start" />{updateEvent.isPending ? "Saving…" : "Save changes"}</Button>
<Button type="button" variant="outline" disabled={!dirty || bannerBusy || updateEvent.isPending} onClick={discard}>Discard changes</Button>
<p className="text-xs text-muted-foreground">{formBanner && publishing.galleryPolicy === "never" ? "Your gallery banner will be hidden while the gallery is private. Use a dedicated upload for an independent header." : "A dedicated banner is visible when the event is published. It does not add a photo to the gallery."}</p>
<p className="text-xs text-muted-foreground">Display images are optimized for fast loading. Original uploads are retained unchanged.</p>
<p className="text-xs text-muted-foreground">This pane tracks event settings. Scheduling below has its own save action. Save settings before releasing the gallery or notifying guests.</p>
</CardContent>
</Card>
</aside>
</div>
);
}
@@ -56,7 +56,6 @@ export default async function EventDashboardPage({
label: "Settings",
content: (
<div className="flex flex-col gap-4">
<EventSchedule eventId={event.id} />
<EventSettingsForm
eventId={event.id}
title={event.title}
@@ -77,6 +76,7 @@ export default async function EventDashboardPage({
showSubmitterStats={event.showSubmitterStats}
showNoteStats={event.showNoteStats}
/>
<EventSchedule eventId={event.id} />
</div>
),
},
+31 -16
View File
@@ -1,27 +1,45 @@
"use client";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { UploadIcon } from "lucide-react";
import { allowedImageTypeSchema, MAX_PHOTO_BYTES } from "@album/contracts";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
export function BannerUpload({ eventId, selectedId, onSelect }: {
export function BannerUpload({ eventId, selectedId, onSelect, onBusyChange }: {
eventId: string;
selectedId: string | null;
onSelect: (id: string) => void;
onBusyChange: (busy: boolean) => void;
}) {
const input = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [bannerId, setBannerId] = useState<string | null>(selectedId);
const [bannerId, setBannerId] = useState<string | null>(null);
const [startedAt, setStartedAt] = useState<number | null>(null);
const [timedOut, setTimedOut] = useState(false);
const selectRef = useRef(onSelect);
useEffect(() => { selectRef.current = onSelect; }, [onSelect]);
const create = api.banners.create.useMutation();
const complete = api.banners.complete.useMutation();
const status = api.banners.status.useQuery({ eventId, bannerId: bannerId ?? "" }, {
enabled: Boolean(bannerId),
refetchInterval: (query) => ["ready", "failed"].includes(query.state.data?.status ?? "") ? false : 1500,
refetchInterval: (query) => timedOut || ["ready", "failed"].includes(query.state.data?.status ?? "") ? false : 1500,
retry: false,
});
useEffect(() => {
if (!timedOut && bannerId && status.data?.status === "ready") {
selectRef.current(bannerId);
setBannerId(null);
setStartedAt(null);
toast.success("Banner ready to preview. Save changes to apply it.");
}
}, [bannerId, status.data?.status, timedOut]);
useEffect(() => {
if (!startedAt) return;
const timer = setTimeout(() => setTimedOut(true), 120_000);
return () => clearTimeout(timer);
}, [startedAt]);
async function upload(file: File) {
const mime = file.type || (/\.heic$/i.test(file.name) ? "image/heic" : /\.heif$/i.test(file.name) ? "image/heif" : "");
const parsed = allowedImageTypeSchema.safeParse(mime);
@@ -30,6 +48,9 @@ export function BannerUpload({ eventId, selectedId, onSelect }: {
return;
}
setUploading(true);
setTimedOut(false);
setBannerId(null);
setStartedAt(null);
try {
const pending = await create.mutateAsync({ eventId, contentType: parsed.data, byteSize: file.size });
const response = await fetch(pending.uploadUrl, {
@@ -38,11 +59,14 @@ export function BannerUpload({ eventId, selectedId, onSelect }: {
if (!response.ok) throw new Error("Banner upload failed. Please try again.");
await complete.mutateAsync({ eventId, bannerId: pending.bannerId });
setBannerId(pending.bannerId);
setStartedAt(Date.now());
} catch (error) {
toast.error(error instanceof Error ? error.message : "Banner upload failed");
} finally { setUploading(false); }
}
const processing = Boolean(bannerId) && status.data?.status !== "ready" && status.data?.status !== "failed" && !status.isError;
const processing = Boolean(bannerId) && status.data?.status !== "failed" && !status.isError && !timedOut;
useEffect(() => { onBusyChange(uploading || processing); }, [uploading, processing, onBusyChange]);
useEffect(() => () => onBusyChange(false), [onBusyChange]);
return (
<div className="flex flex-col gap-3 rounded-lg border p-3">
<div className="flex flex-wrap items-center gap-2">
@@ -54,21 +78,12 @@ export function BannerUpload({ eventId, selectedId, onSelect }: {
if (file) void upload(file);
}} />
<Button type="button" variant="outline" disabled={uploading || processing} onClick={() => input.current?.click()}>
<UploadIcon data-icon="inline-start" />{uploading ? "Uploading…" : "Upload Banner"}
<UploadIcon data-icon="inline-start" />{uploading ? "Uploading…" : processing ? "Preparing banner…" : selectedId ? "Replace uploaded banner" : "Upload a banner"}
</Button>
{status.data?.status === "ready" && bannerId ? (
<Button type="button" variant="secondary" disabled={selectedId === bannerId} onClick={() => onSelect(bannerId)}>
{selectedId === bannerId ? "Banner Selected" : "Use Uploaded Banner"}
</Button>
) : null}
</div>
<p className="text-xs text-muted-foreground">A separate image just for this event's header. It won't appear in the gallery. Up to 25 MB.</p>
{processing ? <p role="status" className="text-sm text-muted-foreground">Preparing your banner You can keep editing while it processes.</p> : null}
{status.isError || status.data?.status === "failed" ? <p role="alert" className="text-sm text-destructive">Couldn't prepare this banner. Try uploading another image.</p> : null}
{status.data?.url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={status.data.url} alt="Uploaded banner preview" width={1200} height={450} className="aspect-[8/3] w-full rounded-lg object-cover" />
) : null}
{status.isError || status.data?.status === "failed" || timedOut ? <p role="alert" className="text-sm text-destructive">{timedOut ? "Processing is taking longer than expected. Your previous banner is unchanged; try another upload." : "Couldn't prepare this banner. Your previous banner is unchanged. Try uploading another image."}</p> : null}
</div>
);
}
+4 -3
View File
@@ -1,6 +1,6 @@
"use client";
import type { ReactNode } from "react";
import { useState, type ReactNode } from "react";
import { ImageIcon, Settings2Icon, UsersIcon, StickyNoteIcon, ActivityIcon } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -18,13 +18,14 @@ export function EventWorkspace({
tabs: EventWorkspaceTab[];
}) {
const initial = tabs[0]?.id ?? "photos";
const [settingsVisited, setSettingsVisited] = useState(initial === "settings");
const icons = { photos: ImageIcon, settings: Settings2Icon, people: UsersIcon, notes: StickyNoteIcon, activity: ActivityIcon, audit: ActivityIcon };
return (
<div className="reveal flex flex-col gap-6 sm:gap-8">
{heading}
{tabs.length === 0 ? null : (
<Tabs defaultValue={initial} className="gap-5">
<Tabs defaultValue={initial} onValueChange={(value) => { if (value === "settings") setSettingsVisited(true); }} className="gap-5">
<TabsList variant="line" aria-label="Event sections" className="w-full max-w-full justify-start gap-2 overflow-x-auto p-0 shadow-[inset_0_-1px_0_var(--border)] group-data-horizontal/tabs:h-12">
{tabs.map((tab) => {
const Icon = icons[tab.id as keyof typeof icons];
@@ -40,7 +41,7 @@ export function EventWorkspace({
); })}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="flex flex-col gap-4">
<TabsContent key={tab.id} value={tab.id} forceMount={tab.id === "settings" && settingsVisited ? true : undefined} className="flex flex-col gap-4 data-[state=inactive]:hidden">
{tab.content}
</TabsContent>
))}