diff --git a/.plans/11-chat-settings-ui-options.html b/.plans/11-chat-settings-ui-options.html new file mode 100644 index 0000000..a1aa89e --- /dev/null +++ b/.plans/11-chat-settings-ui-options.html @@ -0,0 +1,115 @@ + + + + + + Showtime chat settings UI options + + + +
+
+

Chat settings: five presentation options

+

All options use Showtime's existing SettingsHeader, SettingsSection, SettingsItem, Button, Switch, Input, Dialog, and profile/avatar language. They require no RPC changes.

+
+ +
+

1. Profile-first notification sections

+

Channels are managed once, followed by one short notification section per profile.

+
RecommendedBest on mobileLowest learning cost
+
+
Chat
CHANNELS
+
Production
142 messages
+
Stage
38 messages
+
FONotifications for FOH
+
Production
Receive notifications
+
Stage
Muted for FOH
+
+
Strengths

Reads naturally, scales vertically, and makes “for whom?” unambiguous.

Tradeoff

Longer when there are many profiles and channels.

+
+ +
+

2. Channel-first rows with profile chips

+

Each channel row shows which profiles are muted. Clicking the chips opens a small popover.

+
CompactChannel-oriented
+
+
Chat
CHANNELS & NOTIFICATIONS
+
Production
Notifications on for all profiles
FO FOH ✓ST Stage ✓
+
Stage
Muted for 1 profile
FO FOH —ST Stage ✓
+
+
+
Strengths

Very compact and keeps channel management with notification state.

Tradeoff

Chip interactions are less obvious and become crowded with many profiles.

+
+ +
+

3. Notification matrix

+

A spreadsheet-like overview: profiles are rows and channels are columns.

+
Fastest overviewBest for power users
+
Chat
NOTIFICATION MATRIX
+ + + +
Profile# Production# Stage# RF
FO FOH
ST Stage
+
+
Strengths

Best at exposing every relationship and spotting inconsistent settings.

Tradeoff

Horizontal scrolling on phones and less consistent with the simple SettingsItem language.

+
+ +
+

4. Profile selector with focused channel list

+

Select one profile, then edit only that profile's notification preferences.

+
FocusedScales to many profiles
+
+
Notifications for FOH
+
Production
Receive notifications
+
Stage
Muted
+
RF
Receive notifications
+
+
Strengths

Low visual noise even with dozens of profiles and channels.

Tradeoff

Comparing profiles requires switching back and forth.

+
+ +
+

5. Notification modes with a custom editor

+

Each profile starts with a simple mode: all channels, no channels, or custom.

+
Simplest defaultsProgressive disclosure
+
Chat
NOTIFICATIONS
+
FO
FOH
Production and RF; Stage muted
+
ST
Stage
Every channel
+
PM
Production
No channel notifications
+
+
Strengths

Most users make one easy choice; custom complexity stays hidden.

Tradeoff

Adds a derived “mode” concept and more interaction states to explain.

+
+ +

Recommendation

Choose option 1 for the current product stage. It is the most predictable, uses the existing design system directly, works on every viewport, and needs no hidden popovers or derived concepts. Choose option 4 instead if real shows commonly have more than roughly six profiles.

+
+ + diff --git a/.plans/12-profile-settings-ui-options.html b/.plans/12-profile-settings-ui-options.html new file mode 100644 index 0000000..5e0b1ea --- /dev/null +++ b/.plans/12-profile-settings-ui-options.html @@ -0,0 +1,50 @@ + + + + + + Showtime profile settings UI options + + +
+

Profile settings: five presentation options

Every option supports name, color, default profile, create, and delete using the existing profile RPCs and Showtime's shadcn-based controls.

+ +

1. Clean inline roster

One SettingsItem per profile, with identity first and infrequent actions kept quiet.

RecommendedFastest editingResponsive
+
Profiles
PEOPLE & ROLES
+
FO
FOH ★ Default
Used on startup
+
ST
Stage
Profile
+
+
Strengths

Everything is visible and directly editable; closest to the settings reference.

Tradeoff

Rows can become control-heavy unless delete/default move into an overflow menu.

+
+ +

2. Master-detail editor

Choose a profile on the left and edit a calm, focused form on the right.

Best for complex profilesLow row density
+
+

Edit FOH

+
Strengths

Clear focus and room for future permissions or profile metadata.

Tradeoff

Slower for quickly renaming several profiles; mobile becomes two screens.

+
+ +

3. Identity cards

Profiles appear as friendly cards with strong color recognition.

Most visualGood for 2–6 profiles
+
Profiles
FO
FOH ★
Default profile
ST
Stage
Profile
PM
Production
Profile
+
Strengths

Approachable for nontechnical users and makes colors meaningful.

Tradeoff

Consumes space and requires a dialog or secondary editor.

+
+ +

4. Compact roster with edit sheet

The page is purely scannable; all editing happens in one reusable sheet.

Cleanest pageTouch friendly
+
Profiles
PROFILES
FO
FOH
Default profile
ST
Stage
Profile
+
Strengths

Minimal visual noise; the same sheet supports create and edit.

Tradeoff

Every small change requires opening and closing another surface.

+
+ +

5. Default profile plus secondary roster

Elevate the default profile, then list everyone else more compactly.

Makes default obviousHierarchy-driven
+
Profiles
DEFAULT PROFILE
FO
FOH
Selected automatically on new devices
OTHER PROFILES
ST
Stage
PM
Production
+
Strengths

Explains the default concept without relying on an icon-only star.

Tradeoff

Moving rows after changing default can feel visually jumpy.

+
+ +

Recommendation

Choose option 1, but place “Set default” and “Delete” in a small overflow menu. Keep name autosave-on-blur and color autosave-on-select. It remains fast, predictable, and closest to the rest of the minimal settings system. Choose option 2 only if profiles are expected to gain significantly more configuration later.

+
+ diff --git a/apps/web/src/components/TitleBar.tsx b/apps/web/src/components/TitleBar.tsx index 751758d..4a31fc3 100644 --- a/apps/web/src/components/TitleBar.tsx +++ b/apps/web/src/components/TitleBar.tsx @@ -6,10 +6,9 @@ import { Button } from "@/components/ui/button"; import { useAtomSet } from "@effect/atom-react"; import { showDialogAtom } from "@/client"; import { Link, useParams, useRouterState } from "@tanstack/react-router"; -import { ArrowLeftIcon, PlusIcon } from "lucide-react"; +import { ArrowLeftIcon, PlusIcon, SettingsIcon } from "lucide-react"; import { showColorClassNames } from "./shows/show-color"; import { isDesktopHost } from "@/platform"; -import { ConnectionDialog } from "@/components/connections/ConnectionDialog"; import { ShowPageAction } from "@/components/shows/ShowPageAction"; import { PwaInstallButton } from "@/components/connections/PwaInstallButton"; import { DesktopUpdateDialog } from "@/components/updates/DesktopUpdateDialog"; @@ -26,6 +25,7 @@ type TitleBarProps = { }; onLiveBack?: () => void; actions?: React.ReactNode; + hideSettings?: boolean; stack?: "default" | "above-content" | "below-content"; }; @@ -37,6 +37,7 @@ export function TitleBar({ liveShow, onLiveBack, actions, + hideSettings = false, stack = "default", }: TitleBarProps) { const pathname = useRouterState({ @@ -46,6 +47,7 @@ export function TitleBar({ const setDialog = useAtomSet(showDialogAtom); const isShowsRoute = pathname === "/"; const isLiveRoute = pathname.includes("/live"); + const isSettingsRoute = pathname.includes("/settings"); const showId = typeof params.showId === "string" ? params.showId : undefined; const showColorClassName = showColorClassNames[liveShow?.color ?? "neutral"]; const desktopHost = isDesktopHost(); @@ -109,12 +111,35 @@ export function TitleBar({ {actions} {desktopHost && !isLiveRoute && } {!desktopHost && isShowsRoute && } - {!isLiveRoute && ( - <> - - - - )} + {!hideSettings && + !isSettingsRoute && + (showId ? ( + + ) : ( + + ))} {isShowsRoute && ( - - - - )} - - {state.clients.map((client) => { - const id = client.kind === "pending" ? client.invitationId : client.clientId; - const connected = client.kind === "paired" && client.connected; - return ( - - - - - - - {client.name} - profile.id === client.clientProfile, - )} - /> - - - {client.kind === "pending" - ? timeUntil(client.expiresAt, now) - : connected - ? "Connected now" - : "Not currently connected"} - {hasShowtimeConnectionManagementScopes(client.scopes) - ? " · Can manage connections" - : ""} - - - - {client.kind === "pending" && ( - - )} - {manager.canDelete && ( - + Change + )} - - - ); - })} - - {state.enabled && manager.canCreate && ( - + + + } + /> + + )} + setCreateOpen(true)} + > + Add a new client + + ) : undefined + } + > + {state.clients.length > 0 && ( + + {state.clients.map((client) => { + const id = client.kind === "pending" ? client.invitationId : client.clientId; + const connected = client.kind === "paired" && client.connected; + return ( + + + + + + + {client.name} + profile.id === client.clientProfile, + )} + /> + + + {client.kind === "pending" + ? timeUntil(client.expiresAt, now) + : connected + ? "Connected now" + : "Not currently connected"} + {hasShowtimeConnectionManagementScopes(client.scopes) + ? " · Can manage connections" + : ""} + + + + {client.kind === "pending" && ( + + )} + {manager.canDelete && ( + + )} + + + ); + })} + )} {!loading && state.clients.length === 0 && ( -

No clients have access yet.

- )} - {(error ?? loadError) && ( -

- {error ?? loadError} -

+ + + + + + No connected clients + + Add a client to give another device access to Showtime. + + + )} - - +
+ {(error ?? loadError) && ( +

+ {error ?? loadError} +

+ )} + - { - setState(next); - setPairingClient(undefined); - }} - /> - !next && setPairingClient(undefined)} - /> - - ); -} - -function HostNameDialog({ - manager, - open, - currentState, - onOpenChange, - onChanged, -}: { - readonly manager: ConnectionManagementClient; - readonly open: boolean; - readonly currentState: ShowtimeConnectionsState; - readonly onOpenChange: (open: boolean) => void; - readonly onChanged: (state: ShowtimeConnectionsState) => void; -}) { - const [draft, setDraft] = React.useState(currentState.hostName); - const [confirming, setConfirming] = React.useState(false); - const [saving, setSaving] = React.useState(false); - const [error, setError] = React.useState(); - - React.useEffect(() => { - if (!open) return; - setDraft(currentState.hostName); - setConfirming(false); - setSaving(false); - setError(undefined); - }, [open, currentState.hostName]); - - const candidate = draft.trim() ? normalizeShowtimeHostName(draft) : undefined; - const hostname = candidate ? `showtime-${candidate}.local` : undefined; - const changed = candidate !== undefined && candidate !== currentState.hostName; - - const save = async () => { - if (!candidate || !manager.setHostName) return; - setSaving(true); - setError(undefined); - try { - onChanged(await manager.setHostName(candidate)); - onOpenChange(false); - } catch { - setError("Showtime could not change the host name."); - } finally { - setSaving(false); - } - }; - - return ( - - - - {confirming ? "Change the host name?" : "Host name"} - - {confirming - ? "The old address will stop working. Every paired client and pending connection will be removed." - : "Choose the permanent local address people use to open this Showtime host."} - - - {!confirming ? ( - <> - setDraft(event.target.value)} - onKeyDown={(event) => event.key === "Enter" && changed && setConfirming(true)} - /> -

- New address: {hostname ?? "Enter a host name"} + + + + + + + Change the host name? + + The old address will stop working. Every paired client and pending connection will be + removed. Change{" "} + {state.hostname} to{" "} + + showtime-{hostNameCandidate}.local + {" "} + and remove {state.clients.length} connection{state.clients.length === 1 ? "" : "s"}? + + + {hostNameError && ( +

+ {hostNameError}

- - - ) : ( - <> -

- Change {currentState.hostname} to {hostname} and - remove {currentState.clients.length} connection - {currentState.clients.length === 1 ? "" : "s"}? + )} + + Cancel + + {loading ? "Changing..." : "Change and remove connections"} + + + + + !open && setDeleteTarget(undefined)} + > + + + + + + Revoke device? + + {deleteTarget?.name} will + no longer be able to connect to Showtime. + + + {deleteError && ( +

+ {deleteError}

-
- - -
- - )} - {error && ( -

- {error} -

- )} -
-
+ )} + + Cancel + + {loading ? "Revoking..." : "Revoke device"} + + + + + ); } @@ -559,15 +612,16 @@ function CreateClientDialog({ ); } -function PairClientDialog({ +function PairClientPopover({ manager, client, - onOpenChange, + disabled, }: { readonly manager: ConnectionManagementClient; - readonly client: ShowtimePendingClient | undefined; - readonly onOpenChange: (open: boolean) => void; + readonly client: ShowtimePendingClient; + readonly disabled: boolean; }) { + const [open, setOpen] = React.useState(false); const [candidates, setCandidates] = React.useState>( [], ); @@ -579,7 +633,7 @@ function PairClientDialog({ const [copied, setCopied] = React.useState(false); const [error, setError] = React.useState(); React.useEffect(() => { - if (!client) return; + if (!open) return; let active = true; setCandidates([]); setSelectedUrl(""); @@ -587,24 +641,41 @@ function PairClientDialog({ setQrCode(undefined); setCopied(false); setError(undefined); - let timer: number | undefined; + let loadTimer: number | undefined; + let expiryTimer: number | undefined; let consecutiveFailures = 0; let expiresAt = Date.parse(client.expiresAt); let hasRequestedPairingInfo = false; - const setExpiredError = () => setError("This connection link has expired."); + const setExpired = () => { + if (!active) return; + setCandidates([]); + setSelectedUrl(""); + setQrCode(undefined); + setError("This connection link has expired."); + }; + const scheduleExpiry = () => { + if (expiryTimer !== undefined) window.clearTimeout(expiryTimer); + const wait = expiresAt - Date.now(); + if (!Number.isFinite(wait) || wait <= 0) { + setExpired(); + return false; + } + expiryTimer = window.setTimeout(setExpired, wait); + return true; + }; const hasExpired = () => pairingInfoRetryWait(expiresAt, 0) === undefined; const scheduleLoad = (delay: number) => { const wait = pairingInfoRetryWait(expiresAt, delay); if (wait === undefined) { - setExpiredError(); + setExpired(); return; } - timer = window.setTimeout(load, wait); + loadTimer = window.setTimeout(load, wait); }; const load = () => { if (!active) return; if (!canLoadPairingInfo(hasRequestedPairingInfo, expiresAt)) { - setExpiredError(); + setExpired(); return; } hasRequestedPairingInfo = true; @@ -612,14 +683,11 @@ function PairClientDialog({ (info) => { if (!active) return; if (info.expiresAt === null) { - setExpiredError(); + setExpired(); return; } expiresAt = Date.parse(info.expiresAt); - if (hasExpired()) { - setExpiredError(); - return; - } + if (hasExpired() || !scheduleExpiry()) return; consecutiveFailures = 0; setDiscovery(info.discovery); setCandidates(info.candidates); @@ -636,11 +704,11 @@ function PairClientDialog({ () => { if (!active) return; if (hasExpired()) { - setExpiredError(); + setExpired(); return; } consecutiveFailures += 1; - setError("Showtime could not refresh the connection link. Retrying…"); + setError("Showtime could not refresh the connection link. Retrying..."); scheduleLoad(pairingInfoRetryDelay(consecutiveFailures)); }, ); @@ -648,16 +716,17 @@ function PairClientDialog({ load(); return () => { active = false; - if (timer !== undefined) window.clearTimeout(timer); + if (loadTimer !== undefined) window.clearTimeout(loadTimer); + if (expiryTimer !== undefined) window.clearTimeout(expiryTimer); }; - }, [client, manager]); + }, [client.expiresAt, client.invitationId, manager, open]); React.useEffect(() => { setQrCode(undefined); setCopied(false); setError(undefined); if (!selectedUrl) return; let active = true; - void QRCode.toDataURL(selectedUrl, { errorCorrectionLevel: "M", margin: 2, width: 320 }).then( + void QRCode.toDataURL(selectedUrl, { errorCorrectionLevel: "M", margin: 2, width: 640 }).then( (value) => active && setQrCode(value), ); return () => { @@ -666,18 +735,17 @@ function PairClientDialog({ }, [selectedUrl]); const selected = candidates.find((candidate) => candidate.url === selectedUrl); return ( - - - - Connect {client?.name} - - Open this link on one device within five minutes. It can only be used once. - - + + } + > + Connect + + {candidates.length > 0 && ( )} {selectedUrl && ( -
- - event.currentTarget.select()} - /> -
+ event.currentTarget.select()} + /> )} {qrCode && ( -
+
{`QR
)} - {discovery.kind === "probing" && ( -

Finding an easy local address…

+ {discovery.kind === "probing" && !error && ( +

Finding an easy local address...

)} {discovery.kind === "degraded" && candidates.length > 0 && (

- The easy local name is unavailable on this network. The IP address below will still - work. + The easy local name is unavailable on this network. The IP address will still work.

)}
+ + ); } diff --git a/apps/web/src/components/profiles/ProfileSwitcher.tsx b/apps/web/src/components/profiles/ProfileSwitcher.tsx index 2c739c3..20ebd98 100644 --- a/apps/web/src/components/profiles/ProfileSwitcher.tsx +++ b/apps/web/src/components/profiles/ProfileSwitcher.tsx @@ -9,12 +9,24 @@ import { type ProfileName, type ProfilesState, } from "@showtime/contracts"; -import { PencilIcon, PlusIcon, StarIcon, Trash2Icon } from "lucide-react"; +import { PencilIcon, Trash2Icon } from "lucide-react"; import { profileAtoms, rpcErrorMessageFromCause } from "@/client"; import { useSelectedProfile } from "@/profiles"; import { ProfileAvatar } from "@/components/profiles/ProfileAvatar"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ButtonGroup } from "@/components/ui/button-group"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Dialog, DialogContent, @@ -22,7 +34,6 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Item, ItemActions, ItemContent, ItemGroup } from "@/components/ui/item"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, @@ -31,7 +42,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { showColorClassNames } from "@/components/shows/show-color"; import { cn } from "@/lib/utils"; import { InputGroup, @@ -39,7 +49,10 @@ import { InputGroupButton, InputGroupInput, } from "@/components/ui/input-group"; +import { Item, ItemActions, ItemContent, ItemGroup } from "@/components/ui/item"; import { ColorPickerPopover } from "@/components/ColorPickerPopover"; +import { SettingsHeader, SettingsItem, SettingsSection } from "@/components/settings/SettingsPage"; +import { colorPreviewClassNames } from "@/components/color"; const mutationOptions = { reactivityKeys: profilesSyncKey } as const; @@ -243,20 +256,61 @@ function ProfileDialog({ readonly onOpenChange: (open: boolean) => void; readonly state: ProfilesState | undefined; readonly loadResult: AsyncResult.AsyncResult; +}) { + return ( + + + + Profiles + + Choose names and colors, and set the profile used by default. + + + + + + ); +} + +export function ProfilesSettings() { + const result = useAtomValue(profileAtoms.state); + const state = currentProfilesState(result); + + return ( +
+ Profiles + +
+ ); +} + +function ProfilesSettingsContent({ + state, + loadResult, +}: { + readonly state: ProfilesState | undefined; + readonly loadResult: AsyncResult.AsyncResult; }) { const create = useAtomSet(profileAtoms.create, { mode: "promiseExit" }); + const setDefault = useAtomSet(profileAtoms.setDefault, { mode: "promiseExit" }); + const { selected, select } = useSelectedProfile(state); const [error, setError] = React.useState(); + const [newName, setNewName] = React.useState(""); const [adding, setAdding] = React.useState(false); + const [settingDefault, setSettingDefault] = React.useState(false); - const add = async () => { - if (!state || adding) return; + const add = async (event: React.FormEvent) => { + event.preventDefault(); + const name = newName.trim(); + if (!state || !name || adding) return; setAdding(true); setError(undefined); const result = await create({ - payload: { name: `Profile ${state.profiles.length + 1}` as ProfileName, color: "sky" }, + payload: { name: name as ProfileName, color: "sky" }, ...mutationOptions, }); if (Exit.isFailure(result)) setError(rpcErrorMessageFromCause(result.cause)); + else setNewName(""); setAdding(false); }; @@ -264,19 +318,41 @@ function ProfileDialog({ AsyncResult.isFailure(loadResult) && !state ? rpcErrorMessageFromCause(loadResult.cause) : undefined; + const defaultProfile = state?.profiles.find((profile) => profile.id === state.defaultProfileId); return ( - - - - Profiles - - Choose names and colors, and set the profile used by default. - - - +
+ + + setNewName(event.currentTarget.value)} + /> + {newName.length > 0 && ( + + + {adding ? "Adding…" : "Add"} + + + )} + + + } + > + {state?.profiles.map((profile) => ( - ))} - - {(error ?? loadError) && ( -

- {error ?? loadError} -

- )} - -
+ + + { + const profile = state?.profiles.find((item) => item.id === id); + if (!profile || profile.id === state?.defaultProfileId) return; + setSettingDefault(true); + setError(undefined); + const result = await setDefault({ + payload: { id: profile.id }, + ...mutationOptions, + }); + if (Exit.isFailure(result)) setError(rpcErrorMessageFromCause(result.cause)); + setSettingDefault(false); + }} + > + + + {defaultProfile && } + + + + {state?.profiles.map((profile) => ( + + + + ))} + + + } + /> + { + const profile = state?.profiles.find((item) => item.id === id); + if (profile) select(profile); + }} + > + + + {selected && } + + + + {state?.profiles.map((profile) => ( + + + + ))} + + + } + /> + + {(error ?? loadError) && ( +

+ {error ?? loadError} +

+ )} + ); } -function ProfileRow({ +function ProfileItem({ profile, isDefault, onError, @@ -308,10 +453,11 @@ function ProfileRow({ }) { const edit = useAtomSet(profileAtoms.edit, { mode: "promiseExit" }); const remove = useAtomSet(profileAtoms.delete, { mode: "promiseExit" }); - const setDefault = useAtomSet(profileAtoms.setDefault, { mode: "promiseExit" }); const [name, setName] = React.useState(profile.name as string); const [color, setColor] = React.useState(profile.color); const [busy, setBusy] = React.useState(false); + const [deleteOpen, setDeleteOpen] = React.useState(false); + const [deleteError, setDeleteError] = React.useState(); const pending = isPendingProfile(profile); const saveQueue = React.useRef(Promise.resolve()); const suppressNextBlurSave = React.useRef(false); @@ -320,17 +466,6 @@ function ProfileRow({ setColor(profile.color); }, [profile.color, profile.name]); - const run = (operation: () => Promise>) => { - setBusy(true); - onError(undefined); - saveQueue.current = saveQueue.current - .then(async () => { - const result = await operation(); - if (Exit.isFailure(result)) onError(rpcErrorMessageFromCause(result.cause)); - }) - .finally(() => setBusy(false)); - }; - const save = (nextName: string, nextColor: Color) => { const trimmedName = nextName.trim(); if (!trimmedName) { @@ -352,74 +487,118 @@ function ProfileRow({ }); }; + const deleteProfile = async () => { + setBusy(true); + setDeleteError(undefined); + saveQueue.current = saveQueue.current + .then(async () => { + const result = await remove({ payload: { id: profile.id }, ...mutationOptions }); + if (Exit.isFailure(result)) { + setDeleteError(rpcErrorMessageFromCause(result.cause)); + } else { + setDeleteOpen(false); + } + }) + .finally(() => setBusy(false)); + await saveQueue.current; + }; + return ( - - - - setName(event.currentTarget.value)} - onBlur={() => { - if (suppressNextBlurSave.current) { - suppressNextBlurSave.current = false; - return; - } - save(name, color); - }} - onKeyDown={(event) => { - if (event.key === "Enter") event.currentTarget.blur(); - if (event.key === "Escape") { - suppressNextBlurSave.current = true; - setName(profile.name); - event.currentTarget.blur(); - } + <> + + +
+ + + { + setColor(nextColor); + save(name, nextColor); + }} + trigger={ +
+
+ + - - -
+ > + Delete + + +
+ + + + + + + Delete profile? + + {profile.name} will be + permanently deleted. This cannot be undone. + + + {deleteError && ( +

+ {deleteError} +

+ )} + + Cancel + + {busy ? "Deleting..." : "Delete profile"} + + +
+
+ ); } diff --git a/apps/web/src/components/settings/ChatSettings.tsx b/apps/web/src/components/settings/ChatSettings.tsx new file mode 100644 index 0000000..25017a8 --- /dev/null +++ b/apps/web/src/components/settings/ChatSettings.tsx @@ -0,0 +1,520 @@ +import * as React from "react"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { Exit, Option } from "effect"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { + chatsSyncKey, + type ChatChannel, + type ChatChannelName, + type ChatSnapshot, + type Profile, + type ProfileId, + type ShowId, +} from "@showtime/contracts"; +import { BellIcon, BellOffIcon, Trash2Icon } from "lucide-react"; +import { chatAtoms, profileAtoms, rpcErrorMessageFromCause } from "@/client"; +import { ProfileAvatar } from "@/components/profiles/ProfileAvatar"; +import { currentProfilesState } from "@/components/profiles/ProfileSwitcher"; +import { SettingsHeader, SettingsItem, SettingsSection } from "@/components/settings/SettingsPage"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Card, CardContent } from "@/components/ui/card"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, + InputGroupText, +} from "@/components/ui/input-group"; +import { Item, ItemActions, ItemContent } from "@/components/ui/item"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useShowFromParams } from "@/hooks/useShowFromParams"; + +const currentChatSnapshot = ( + result: AsyncResult.AsyncResult, +): ChatSnapshot | undefined => + AsyncResult.isSuccess(result) + ? result.value + : AsyncResult.isFailure(result) + ? Option.getOrUndefined(result.previousSuccess)?.value + : undefined; + +export function ChatSettings() { + const { showId } = useShowFromParams(); + const profilesResult = useAtomValue(profileAtoms.state); + const profilesState = currentProfilesState(profilesResult); + + return ( +
+ Chat + {showId && profilesState && profilesState.profiles.length > 0 ? ( + + ) : ( + + + + )} +
+ ); +} + +function ChatSettingsLoaded({ + showId, + profiles, +}: { + readonly showId: ShowId; + readonly profiles: ReadonlyArray; +}) { + const ownerProfile = profiles[0]!; + const atoms = chatAtoms(showId, ownerProfile.id); + const result = useAtomValue(atoms.state); + const snapshot = currentChatSnapshot(result); + const createChannel = useAtomSet(atoms.createChannel, { mode: "promiseExit" }); + const deleteChannel = useAtomSet(atoms.deleteChannel, { mode: "promiseExit" }); + const [newName, setNewName] = React.useState(""); + const [deleteTarget, setDeleteTarget] = React.useState(); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(); + const mutationOptions = { reactivityKeys: chatsSyncKey(showId) } as const; + const channels = snapshot?.channels ?? []; + + const add = async (event: React.FormEvent) => { + event.preventDefault(); + const name = newName.trim(); + if (!name || busy) return; + setBusy(true); + setError(undefined); + const exit = await createChannel({ + payload: { showId, name: name as ChatChannelName }, + ...mutationOptions, + }); + if (Exit.isFailure(exit)) setError(rpcErrorMessageFromCause(exit.cause)); + else setNewName(""); + setBusy(false); + }; + + const remove = async () => { + if (!deleteTarget || busy) return; + setBusy(true); + setError(undefined); + const exit = await deleteChannel({ + payload: { showId, channelId: deleteTarget.id }, + ...mutationOptions, + }); + if (Exit.isFailure(exit)) setError(rpcErrorMessageFromCause(exit.cause)); + else setDeleteTarget(undefined); + setBusy(false); + }; + + return ( + <> + + + + # + + setNewName(event.currentTarget.value)} + /> + {newName.length > 0 && ( + + + Add + + + )} + + + } + > + {channels.map((channel) => ( + + +
+ + + {channel.messageCount} message{channel.messageCount === 1 ? "" : "s"} + +
+
+ + + +
+ ))} + {error && ( +

+ {error} +

+ )} +
+ + +
+ +
+
+ + !open && setDeleteTarget(undefined)} + > + + + + + + Delete channel? + + All messages in{" "} + #{deleteTarget?.name} will + be permanently deleted. + + + + Cancel + + Delete channel + + + + + + ); +} + +function ChannelNameInput({ + showId, + ownerProfileId, + channel, + onError, +}: { + readonly showId: ShowId; + readonly ownerProfileId: ProfileId; + readonly channel: ChatChannel; + readonly onError: (message: string | undefined) => void; +}) { + const renameChannel = useAtomSet(chatAtoms(showId, ownerProfileId).renameChannel, { + mode: "promiseExit", + }); + const [name, setName] = React.useState(channel.name as string); + const [saving, setSaving] = React.useState(false); + const cancelSave = React.useRef(false); + + React.useEffect(() => setName(channel.name), [channel.name]); + + const save = async () => { + const trimmed = name.trim(); + if (!trimmed) { + setName(channel.name); + return; + } + if (trimmed === channel.name) return; + setSaving(true); + onError(undefined); + const exit = await renameChannel({ + payload: { showId, channelId: channel.id, name: trimmed as ChatChannelName }, + reactivityKeys: chatsSyncKey(showId), + }); + if (Exit.isFailure(exit)) { + setName(channel.name); + onError(rpcErrorMessageFromCause(exit.cause)); + } + setSaving(false); + }; + + return ( + + + # + + { + cancelSave.current = false; + }} + onChange={(event) => setName(event.currentTarget.value)} + onBlur={() => { + if (cancelSave.current) { + cancelSave.current = false; + return; + } + void save(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + cancelSave.current = true; + setName(channel.name); + event.currentTarget.blur(); + } + }} + /> + + ); +} + +type ChatSnapshotResult = AsyncResult.AsyncResult; + +function NotificationStateLoader({ + showId, + profile, + onResult, +}: { + readonly showId: ShowId; + readonly profile: Profile; + readonly onResult: (profileId: ProfileId, result: ChatSnapshotResult) => void; +}) { + const result = useAtomValue(chatAtoms(showId, profile.id).state); + + React.useEffect(() => onResult(profile.id, result), [onResult, profile.id, result]); + return null; +} + +function ChannelNotifications({ + showId, + profiles, + channels, + ownerProfileId, + ownerResult, +}: { + readonly showId: ShowId; + readonly profiles: ReadonlyArray; + readonly channels: ReadonlyArray; + readonly ownerProfileId: ProfileId; + readonly ownerResult: ChatSnapshotResult; +}) { + const [results, setResults] = React.useState>( + () => new Map(), + ); + const [error, setError] = React.useState(); + const recordResult = React.useCallback((profileId: ProfileId, result: ChatSnapshotResult) => { + setResults((current) => { + if (current.get(profileId) === result) return current; + const next = new Map(current); + next.set(profileId, result); + return next; + }); + }, []); + const resultFor = (profile: Profile) => + profile.id === ownerProfileId ? ownerResult : results.get(profile.id); + const snapshotFor = (profile: Profile) => { + const profileResult = resultFor(profile); + return profileResult ? currentChatSnapshot(profileResult) : undefined; + }; + const snapshotIsCurrent = (snapshot: ChatSnapshot | undefined) => + snapshot !== undefined && + channels.every((channel) => snapshot.channels.some((item) => item.id === channel.id)); + const ready = profiles.every((profile) => snapshotIsCurrent(snapshotFor(profile))); + const failed = profiles.some((profile) => { + const profileResult = resultFor(profile); + return ( + profileResult !== undefined && + AsyncResult.isFailure(profileResult) && + !snapshotIsCurrent(currentChatSnapshot(profileResult)) + ); + }); + + return ( + <> + {profiles.map((profile) => + profile.id === ownerProfileId ? null : ( + + ), + )} + + + {ready ? ( + + + + + Channel + + {profiles.map((profile) => ( + + + + {profile.name} + + + ))} + + + + {channels.map((channel) => ( + snapshotFor(profile)!} + onError={setError} + /> + ))} + +
+ ) : ( +

+ {failed + ? "Notification settings could not be loaded." + : "Loading notification settings\u2026"} +

+ )} + {error && ( +

+ {error} +

+ )} +
+
+ + ); +} + +function NotificationChannelRow({ + showId, + channel, + profiles, + snapshotFor, + onError, +}: { + readonly showId: ShowId; + readonly channel: ChatChannel; + readonly profiles: ReadonlyArray; + readonly snapshotFor: (profile: Profile) => ChatSnapshot; + readonly onError: (message: string | undefined) => void; +}) { + return ( + + + #{channel.name} + + {profiles.map((profile) => ( + + ))} + + ); +} + +function NotificationToggleCell({ + showId, + channel, + profile, + snapshot, + onError, +}: { + readonly showId: ShowId; + readonly channel: ChatChannel; + readonly profile: Profile; + readonly snapshot: ChatSnapshot; + readonly onError: (message: string | undefined) => void; +}) { + const setNotifications = useAtomSet(chatAtoms(showId, profile.id).setNotifications, { + mode: "promiseExit", + }); + const [saving, setSaving] = React.useState(false); + + const toggle = async (enabled: boolean) => { + setSaving(true); + onError(undefined); + const exit = await setNotifications({ + payload: { showId, channelId: channel.id, profileId: profile.id, enabled }, + reactivityKeys: chatsSyncKey(showId), + }); + if (Exit.isFailure(exit)) onError(rpcErrorMessageFromCause(exit.cause)); + setSaving(false); + }; + + const profileChannel = snapshot.channels.find((item) => item.id === channel.id)!; + + return ( + + + + ); +} diff --git a/apps/web/src/components/settings/GeneralSettings.tsx b/apps/web/src/components/settings/GeneralSettings.tsx new file mode 100644 index 0000000..88db7f1 --- /dev/null +++ b/apps/web/src/components/settings/GeneralSettings.tsx @@ -0,0 +1,166 @@ +import * as React from "react"; +import { useAtomSet } from "@effect/atom-react"; +import { Exit } from "effect"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { useNavigate } from "@tanstack/react-router"; +import { ChevronsUpDownIcon, Trash2Icon } from "lucide-react"; +import type { Color, ShowName } from "@showtime/contracts"; +import { + editShowAtom, + rpcErrorMessageFromCause, + showDialogAtom, + showMutationOptions, + type ShowListItem, +} from "@/client"; +import { useShowFromParams } from "@/hooks/useShowFromParams"; +import { SettingsHeader, SettingsItem, SettingsSection } from "@/components/settings/SettingsPage"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { ColorPickerPopover } from "@/components/ColorPickerPopover"; +import { showColorClassNames } from "@/components/shows/show-color"; +import { ShowDeleteDialog } from "@/components/shows/ShowDeleteDialog"; +import { cn } from "@/lib/utils"; + +export function GeneralSettings() { + const { show, result } = useShowFromParams(); + const navigate = useNavigate(); + let content: React.ReactNode; + + if (AsyncResult.isInitial(result)) { + content =

Loading show…

; + } else if (AsyncResult.isFailure(result) && !show) { + content = ( +

+ {rpcErrorMessageFromCause(result.cause)} +

+ ); + } else if (!show) { + content =

This show could not be found.

; + } else { + content = ; + } + + return ( +
+ General + {content} + navigate({ to: "/", replace: true })} /> +
+ ); +} + +function GeneralSettingsLoaded({ show }: { readonly show: ShowListItem }) { + const editShow = useAtomSet(editShowAtom, { mode: "promiseExit" }); + const setDialog = useAtomSet(showDialogAtom); + const [name, setName] = React.useState(show.name); + const [color, setColor] = React.useState(show.color); + const [saving, setSaving] = React.useState(false); + const [error, setError] = React.useState(); + const saveQueue = React.useRef(Promise.resolve()); + const pendingSaves = React.useRef(0); + const suppressNextBlurSave = React.useRef(false); + const committed = React.useRef({ name: show.name as string, color: show.color }); + + const save = (nextName: string, nextColor: Color) => { + const trimmed = nextName.trim(); + if (!trimmed) { + setName(committed.current.name); + return; + } + if (trimmed === committed.current.name && nextColor === committed.current.color) return; + pendingSaves.current += 1; + setSaving(true); + setError(undefined); + saveQueue.current = saveQueue.current + .then(async () => { + const result = await editShow({ + payload: { id: show.id, name: trimmed as ShowName, color: nextColor }, + ...showMutationOptions, + }); + if (Exit.isFailure(result)) { + setError(rpcErrorMessageFromCause(result.cause)); + } else { + committed.current = { name: trimmed, color: nextColor }; + setError(undefined); + } + }) + .finally(() => { + pendingSaves.current -= 1; + if (pendingSaves.current === 0) setSaving(false); + }); + }; + + return ( + <> +
+ + setName(event.currentTarget.value)} + onBlur={() => { + if (suppressNextBlurSave.current) { + suppressNextBlurSave.current = false; + return; + } + save(name, color); + }} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + suppressNextBlurSave.current = true; + setName(committed.current.name); + event.currentTarget.blur(); + } + }} + /> + } + /> + { + setColor(nextColor); + save(name, nextColor); + }} + trigger={
+ + + setDialog({ type: "delete", show })}> + Delete show + + } + /> + + + ); +} diff --git a/apps/web/src/components/settings/SettingsLayout.tsx b/apps/web/src/components/settings/SettingsLayout.tsx new file mode 100644 index 0000000..6292cbf --- /dev/null +++ b/apps/web/src/components/settings/SettingsLayout.tsx @@ -0,0 +1,277 @@ +import { Link, Outlet } from "@tanstack/react-router"; +import { + ArrowLeftIcon, + MessageCircleIcon, + RefreshCwIcon, + Settings2Icon, + UserRoundIcon, + WifiIcon, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, +} from "@/components/ui/sidebar"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useShowFromParams } from "@/hooks/useShowFromParams"; +import { cn } from "@/lib/utils"; +import { isDesktopHost } from "@/platform"; +import { ShowSwitcher } from "@/components/shows/ShowSwitcher"; + +export function SettingsLayout() { + const { showId, show } = useShowFromParams(); + const desktopHost = isDesktopHost(); + + return ( + <> +