+
)}
- {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 (
+
+ );
+}
+
+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) => (
-
))}
-
- {adding ? "Adding…" : "Add 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={
+
+ }
+ >
+
+
+
+ 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();
+ }
+ }}
+ />
+
+ {isDefault && Default }
+
+
+
+ {
+ setDeleteError(undefined);
+ setDeleteOpen(true);
}}
- />
-
- {
- setColor(nextColor);
- save(name, nextColor);
- }}
- trigger={
-
- }
- >
-
-
-
-
-
-
- run(() => setDefault({ payload: { id: profile.id }, ...mutationOptions }))}
- >
-
-
- run(() => remove({ payload: { id: profile.id }, ...mutationOptions }))}
- >
-
-
-
-
+ >
+ 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"}
+
+
+
+
+ setDeleteTarget(channel)}
+ >
+ Delete
+
+
+
+ ))}
+ {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 (
+
+ void toggle(!profileChannel.notificationsEnabled)}
+ >
+ {profileChannel.notificationsEnabled ? : }
+ {profileChannel.notificationsEnabled ? "On" : "Off"}
+
+
+ );
+}
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={ }
+ >
+
+ {color}
+
+
+ }
+ />
+
+ {error && (
+
+ {error}
+
+ )}
+ {saving &&
Saving…
}
+
+
+
+ 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 (
+ <>
+
+
+
+
+
+
+
+ {showId && (
+
+ {show?.name ?? "Show"}
+
+
+
+
+
+
+
+ )}
+
+ Settings
+
+
+ {showId ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+ {showId ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ :
+ }
+ >
+
+ Back
+
+
+
+
+
+
+
+
+ : }
+ >
+
+
+ Settings
+
+
+ {showId ? (
+ <>
+
+
+ >
+ ) : null}
+ {showId ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+ {showId ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+type Icon = React.ComponentType<{ className?: string }>;
+type ShowSection = "general" | "chat" | "updates" | "profiles" | "connections";
+type GlobalSection = "updates" | "profiles" | "connections";
+
+function SettingsShowLink({
+ showId,
+ section,
+ label,
+ icon: Icon,
+}: {
+ showId: string;
+ section: ShowSection;
+ label: string;
+ icon: Icon;
+}) {
+ return (
+
+
+ }
+ >
+
+ {label}
+
+
+ );
+}
+
+function SettingsGlobalLink({
+ section,
+ label,
+ icon: Icon,
+}: {
+ section: GlobalSection;
+ label: string;
+ icon: Icon;
+}) {
+ return (
+
+
+ }
+ >
+
+ {label}
+
+
+ );
+}
+
+const mobileLinkClassName =
+ "shrink-0 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground outline-none hover:bg-muted data-active:bg-muted data-active:text-foreground";
+
+function MobileShowLink({
+ showId,
+ section,
+ label,
+}: {
+ showId: string;
+ section: ShowSection;
+ label: string;
+}) {
+ return (
+
+ {label}
+
+ );
+}
+
+function MobileGlobalLink({ section, label }: { section: GlobalSection; label: string }) {
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/web/src/components/settings/SettingsPage.tsx b/apps/web/src/components/settings/SettingsPage.tsx
new file mode 100644
index 0000000..b897c26
--- /dev/null
+++ b/apps/web/src/components/settings/SettingsPage.tsx
@@ -0,0 +1,64 @@
+import * as React from "react";
+import { Item, ItemActions, ItemContent, ItemDescription, ItemTitle } from "@/components/ui/item";
+import { cn } from "@/lib/utils";
+
+export function SettingsHeader({ children }: { readonly children: React.ReactNode }) {
+ return {children} ;
+}
+
+export function SettingsSection({
+ title,
+ action,
+ children,
+ className,
+}: {
+ readonly title?: string;
+ readonly action?: React.ReactNode;
+ readonly children: React.ReactNode;
+ readonly className?: string;
+}) {
+ const titleId = React.useId();
+
+ return (
+
+ {(title || action) && (
+
+ {title && (
+
+ {title}
+
+ )}
+ {action &&
{action}
}
+
+ )}
+ {children}
+
+ );
+}
+
+export function SettingsItem({
+ title,
+ description,
+ action,
+ children,
+ className,
+}: {
+ readonly title: React.ReactNode;
+ readonly description?: React.ReactNode;
+ readonly action?: React.ReactNode;
+ readonly children?: React.ReactNode;
+ readonly className?: string;
+}) {
+ return (
+ -
+
+ {title}
+ {description && {description} }
+ {children}
+
+ {action && (
+ {action}
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/settings/UpdatesSettings.tsx b/apps/web/src/components/settings/UpdatesSettings.tsx
new file mode 100644
index 0000000..b7f9e80
--- /dev/null
+++ b/apps/web/src/components/settings/UpdatesSettings.tsx
@@ -0,0 +1,67 @@
+import type { ShowtimeDesktopUpdateState } from "@showtime/shared";
+import desktopPackage from "../../../../desktop/package.json";
+import { SettingsHeader, SettingsItem, SettingsSection } from "@/components/settings/SettingsPage";
+import {
+ DesktopUpdateDialogView,
+ useDesktopUpdateState,
+} from "@/components/updates/DesktopUpdateDialog";
+
+export function UpdatesSettings() {
+ const { bridge, state, applyState } = useDesktopUpdateState();
+ const displayVersion = state
+ ? formatVersion(state.currentVersion)
+ : bridge
+ ? "Loading…"
+ : formatVersion(desktopPackage.version);
+
+ return (
+
+ Updates
+
+ {displayVersion}}
+ />
+
+ ) : undefined
+ }
+ />
+
+
+ );
+}
+
+const formatVersion = (version: string): string => version.replace(/^v/i, "");
+
+const updateDescription = (state: ShowtimeDesktopUpdateState | undefined): string => {
+ if (!state) return "Updates are managed by the installed Showtime application.";
+ switch (state.kind) {
+ case "unsupported":
+ return "Automatic updates are unavailable on this platform.";
+ case "checking":
+ return "Checking for a newer version of Showtime.";
+ case "up-to-date":
+ return "Showtime is up to date.";
+ case "available":
+ return `Version ${formatVersion(state.version)} is available to download.`;
+ case "downloading":
+ return `Downloading version ${formatVersion(state.version)}: ${Math.round(state.percent)}%.`;
+ case "ready":
+ return `Version ${formatVersion(state.version)} is ready to install.`;
+ case "blocked-live":
+ return "The update is paused while a show is Live.";
+ case "error":
+ case "recovery-required":
+ return state.message;
+ }
+};
diff --git a/apps/web/src/components/shows/ShowDeleteDialog.tsx b/apps/web/src/components/shows/ShowDeleteDialog.tsx
index dc0d49f..278c206 100644
--- a/apps/web/src/components/shows/ShowDeleteDialog.tsx
+++ b/apps/web/src/components/shows/ShowDeleteDialog.tsx
@@ -1,16 +1,17 @@
import * as React from "react";
import { Trash2Icon } from "lucide-react";
import { Exit } from "effect";
-import { Button } from "@/components/ui/button";
import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
import { useAtom, useAtomSet } from "@effect/atom-react";
import { deleteShowAtom, showDialogAtom, showMutationOptions } from "@/client";
import { rpcErrorMessageFromCause } from "@/client";
@@ -84,29 +85,36 @@ export function ShowDeleteDialog({ onDeleted }: ShowDeleteDialogProps) {
};
return (
- !open && close()}>
-
-
- Delete show?
-
- This will permanently delete "{showName}". This cannot be undone.
-
-
+ !open && close()}>
+
+
+
+
+
+ Delete show?
+
+ This will permanently delete{" "}
+ {showName} . This cannot be
+ undone.
+
+
{deleteError && (
{deleteError}
)}
-
- }>
- Cancel
-
-
-
+
+ Cancel
+
{isDeleting ? "Deleting..." : "Delete"}
-
-
-
-
+
+
+
+
);
}
diff --git a/apps/web/src/components/shows/ShowLayout.tsx b/apps/web/src/components/shows/ShowLayout.tsx
index 37fd367..74623e5 100644
--- a/apps/web/src/components/shows/ShowLayout.tsx
+++ b/apps/web/src/components/shows/ShowLayout.tsx
@@ -1,6 +1,5 @@
import { Link, Outlet, useParams, useRouterState } from "@tanstack/react-router";
import {
- ArrowLeftIcon,
ListMusicIcon,
Mic2Icon,
PlayIcon,
@@ -8,6 +7,7 @@ import {
SpeakerIcon,
MessageCircleIcon,
LibraryIcon,
+ SettingsIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -38,13 +38,18 @@ import { songAtoms } from "@/client";
import { useCreateSong } from "@/components/songs/useCreateSong";
import { createdSongHandoff } from "@/components/songs/CreatedSongHandoff";
import { ShowPageAction } from "./ShowPageAction";
-import { ProfileSwitcher } from "@/components/profiles/ProfileSwitcher";
import { ChatDrawer, ChatUnreadBadge } from "@/components/chats/ChatDrawer";
import { ChatPresetLauncher } from "@/components/chats/ChatPresetLauncher";
-import { ConnectionDialog } from "@/components/connections/ConnectionDialog";
import { ScrollArea } from "@/components/ui/scroll-area";
+import { ShowSwitcher } from "@/components/shows/ShowSwitcher";
export function ShowLayout() {
+ const pathname = useRouterState({ select: (state) => state.location.pathname });
+ if (pathname.includes("/settings")) return ;
+ return ;
+}
+
+function ShowWorkspaceLayout({ pathname }: { readonly pathname: string }) {
const [chatOpen, setChatOpen] = React.useState(false);
const [selectedChannelId, setSelectedChannelId] = React.useState();
const { showId = "", show } = useShowFromParams();
@@ -55,7 +60,6 @@ export function ShowLayout() {
const currentSongId = typeof params.songId === "string" ? (params.songId as SongId) : undefined;
const songsResult = useAtomValue(songAtoms(typedShowId).songs);
const syncedSongsResult = useAtomValue(songAtoms(typedShowId).syncedSongs);
- const pathname = useRouterState({ select: (state) => state.location.pathname });
const isAllSongsRoute = /\/setlist\/?$/.test(pathname);
const songs = AsyncResult.isSuccess(songsResult)
? songsResult.value
@@ -72,6 +76,7 @@ export function ShowLayout() {
-
-
- {showName}
-
+
@@ -186,10 +183,14 @@ export function ShowLayout() {
>
LIVE
-
- }>
- Back to all shows
-
+
+
+
@@ -248,7 +249,20 @@ function ShowHeader({
@@ -355,6 +369,10 @@ type ShowSidebarLinkProps = (
readonly to: "/shows/$showId/setlist/$songId";
readonly params: { readonly showId: string; readonly songId: string };
}
+ | {
+ readonly to: "/shows/$showId/settings/$section";
+ readonly params: { readonly showId: string; readonly section: string };
+ }
) & {
readonly label: string;
readonly badge?: string;
diff --git a/apps/web/src/components/shows/ShowSwitcher.tsx b/apps/web/src/components/shows/ShowSwitcher.tsx
new file mode 100644
index 0000000..28cb01f
--- /dev/null
+++ b/apps/web/src/components/shows/ShowSwitcher.tsx
@@ -0,0 +1,121 @@
+import { useAtomValue } from "@effect/atom-react";
+import { useNavigate, useParams } from "@tanstack/react-router";
+import { Option } from "effect";
+import { AsyncResult } from "effect/unstable/reactivity";
+import { ArrowLeftIcon, Settings2Icon } from "lucide-react";
+import { showsAtom } from "@/client";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { showColorClassNames } from "@/components/shows/show-color";
+import { cn } from "@/lib/utils";
+
+const allShowsValue = "all-shows";
+const globalSettingsLabels = {
+ connections: "Connections",
+ profiles: "Profiles",
+ updates: "Updates",
+} as const;
+
+export function ShowSwitcher({
+ showId,
+ destination,
+}: {
+ readonly showId?: string;
+ readonly destination: "show" | "settings";
+}) {
+ const navigate = useNavigate();
+ const params = useParams({ strict: false });
+ const result = useAtomValue(showsAtom);
+ const shows = AsyncResult.isSuccess(result)
+ ? result.value
+ : AsyncResult.isFailure(result)
+ ? (Option.getOrUndefined(result.previousSuccess)?.value ?? [])
+ : [];
+ const selected = shows.find((show) => show.id === showId);
+ const globalSettingsLabel =
+ typeof params.section === "string" && params.section in globalSettingsLabels
+ ? globalSettingsLabels[params.section as keyof typeof globalSettingsLabels]
+ : globalSettingsLabels.updates;
+
+ const select = (value: string | null) => {
+ if (!value) return;
+ if (value === allShowsValue) {
+ void navigate(
+ destination === "settings"
+ ? { to: "/settings/$section", params: { section: "updates" } }
+ : { to: "/" },
+ );
+ return;
+ }
+
+ void navigate(
+ destination === "settings"
+ ? {
+ to: "/shows/$showId/settings/$section",
+ params: { showId: value, section: "general" },
+ }
+ : { to: "/shows/$showId", params: { showId: value } },
+ );
+ };
+
+ return (
+
+
+
+
+ {selected ? (
+
+ ) : destination === "settings" ? (
+ globalSettingsLabel
+ ) : (
+ "All shows"
+ )}
+
+
+
+ {shows.map((show) => (
+
+
+
+ ))}
+
+
+
+ {destination === "settings" ? (
+
+ ) : (
+
+ )}
+
+ {destination === "settings" ? "Updates" : "All shows"}
+
+
+
+
+
+ );
+}
+
+function ShowLabel({
+ name,
+ color,
+}: {
+ readonly name: string;
+ readonly color: keyof typeof showColorClassNames;
+}) {
+ return (
+
+
+ {name}
+
+ );
+}
diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..1dafcff
--- /dev/null
+++ b/apps/web/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import * as React from "react";
+import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
+ return ;
+}
+
+function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
+ return ;
+}
+
+function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
+ return ;
+}
+
+function AlertDialogOverlay({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) {
+ return (
+
+ );
+}
+
+function AlertDialogContent({
+ className,
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Popup.Props & { size?: "default" | "sm" }) {
+ return (
+
+
+
+
+ );
+}
+
+function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDialogTitle({ className, ...props }: AlertDialogPrimitive.Title.Props) {
+ return (
+
+ );
+}
+
+function AlertDialogDescription({ className, ...props }: AlertDialogPrimitive.Description.Props) {
+ return (
+
+ );
+}
+
+function AlertDialogAction({ className, ...props }: React.ComponentProps) {
+ return ;
+}
+
+function AlertDialogCancel({
+ className,
+ variant = "outline",
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Close.Props &
+ Pick, "variant" | "size">) {
+ return (
+ }
+ {...props}
+ />
+ );
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+};
diff --git a/apps/web/src/components/ui/input-group.tsx b/apps/web/src/components/ui/input-group.tsx
index dd14ac1..18e60d7 100644
--- a/apps/web/src/components/ui/input-group.tsx
+++ b/apps/web/src/components/ui/input-group.tsx
@@ -6,13 +6,19 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
-function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
+function InputGroup({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps<"div"> & { readonly variant?: "default" | "ghost" }) {
return (
[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
+ variant === "ghost" &&
+ "border-transparent bg-transparent shadow-none focus-within:bg-input/30 has-disabled:bg-transparent dark:bg-transparent dark:focus-within:bg-input/30 dark:has-disabled:bg-transparent",
className,
)}
{...props}
@@ -109,7 +115,7 @@ function InputGroupInput({ className, ...props }: React.ComponentProps<"input">)
) {
+ return (
+
+ );
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return
;
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return (
+
+ );
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+ return (
+
tr]:last:border-b-0", className)}
+ {...props}
+ />
+ );
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ );
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+
+ );
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+
+ );
+}
+
+function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
+ return (
+
+ );
+}
+
+export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
diff --git a/apps/web/src/components/updates/DesktopUpdateDialog.tsx b/apps/web/src/components/updates/DesktopUpdateDialog.tsx
index f7a1927..e28f63f 100644
--- a/apps/web/src/components/updates/DesktopUpdateDialog.tsx
+++ b/apps/web/src/components/updates/DesktopUpdateDialog.tsx
@@ -2,6 +2,17 @@ import * as React from "react";
import type { ShowtimeDesktopUpdateState } from "@showtime/shared";
import { DownloadIcon, RefreshCwIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
@@ -35,20 +46,11 @@ const formatBytes = (bytes: number) => {
return `${(bytes / 1_048_576).toFixed(bytes >= 10_485_760 ? 0 : 1)} MB`;
};
-export function DesktopUpdateDialog() {
+export function useDesktopUpdateState() {
const bridge = window.showtime;
const [state, setState] = React.useState();
- const [open, setOpen] = React.useState(false);
- const [confirmInstall, setConfirmInstall] = React.useState(false);
const applyState = React.useCallback((nextState: ShowtimeDesktopUpdateState) => {
setState(nextState);
- if (
- nextState.kind === "blocked-live" ||
- nextState.kind === "error" ||
- nextState.kind === "recovery-required"
- ) {
- setConfirmInstall(false);
- }
}, []);
React.useEffect(() => {
@@ -58,81 +60,139 @@ export function DesktopUpdateDialog() {
return unsubscribe;
}, [applyState, bridge]);
+ return { bridge, state, applyState } as const;
+}
+
+export function DesktopUpdateDialog({ showIdle = false }: { readonly showIdle?: boolean }) {
+ const updateState = useDesktopUpdateState();
+
+ return ;
+}
+
+type DesktopUpdateDialogViewProps = ReturnType & {
+ readonly showIdle?: boolean;
+};
+
+export function DesktopUpdateDialogView({
+ bridge,
+ state,
+ applyState,
+ showIdle = false,
+}: DesktopUpdateDialogViewProps) {
+ const [open, setOpen] = React.useState(false);
+ const [confirmInstall, setConfirmInstall] = React.useState(false);
+
+ React.useEffect(() => {
+ if (
+ state?.kind === "blocked-live" ||
+ state?.kind === "error" ||
+ state?.kind === "recovery-required"
+ ) {
+ setConfirmInstall(false);
+ }
+ }, [state]);
+
if (!bridge || !state) return null;
const label = triggerLabel(state);
- if (!label) return null;
-
- const version = "version" in state ? state.version : undefined;
const download = () => void bridge.downloadUpdate().then(applyState);
const check = () => void bridge.checkForUpdates().then(applyState);
const install = () => void bridge.installUpdate().then(applyState);
+ if (!label) {
+ if (!showIdle) return null;
+ if (state.kind === "unsupported") return Updates unavailable ;
+ if (state.kind === "checking") {
+ return (
+
+ Checking…
+
+ );
+ }
+ return (
+
+ Check for updates
+
+ );
+ }
+
+ const version = "version" in state ? state.version : undefined;
return (
- {
- setOpen(nextOpen);
- if (!nextOpen) setConfirmInstall(false);
- }}
- >
- }>
- {state.kind === "downloading" ? (
-
- ) : (
-
- )}
- {label}
-
-
-
- {confirmInstall ? "Restart Showtime now?" : "Showtime update"}
-
- {confirmInstall
- ? "The app and its local server will close. Connected devices will disconnect until Showtime restarts."
- : `Installed version ${state.currentVersion}${version ? ` · Available version ${version}` : ""}`}
-
-
-
- {!confirmInstall && }
-
-
- {confirmInstall ? (
- <>
- setConfirmInstall(false)}>
- Not now
+ <>
+ {
+ setOpen(nextOpen);
+ if (!nextOpen) setConfirmInstall(false);
+ }}
+ >
+ }>
+ {state.kind === "downloading" ? (
+
+ ) : (
+
+ )}
+ {label}
+
+
+
+ Showtime update
+
+ {`Installed version ${state.currentVersion}${version ? ` · Available version ${version}` : ""}`}
+
+
+
+
+
+
+ {state.kind === "available" ? (
+ Download update
+ ) : state.kind === "ready" ? (
+ setConfirmInstall(true)}>Install & restart
+ ) : state.kind === "blocked-live" ? (
+ setConfirmInstall(true)}
+ >
+ Try again
- Confirm restart
- >
- ) : state.kind === "available" ? (
- Download update
- ) : state.kind === "ready" ? (
- setConfirmInstall(true)}>Install & restart
- ) : state.kind === "blocked-live" ? (
- setConfirmInstall(true)}
- >
- Try again
-
- ) : state.kind === "error" ? (
- setConfirmInstall(true)
+ : download
+ }
+ >
+ {state.retry === "check"
+ ? "Check again"
: state.retry === "install"
- ? () => setConfirmInstall(true)
- : download
- }
- >
- {state.retry === "check"
- ? "Check again"
- : state.retry === "install"
- ? "Retry install"
- : "Retry download"}
-
- ) : null}
-
-
-
+ ? "Retry install"
+ : "Retry download"}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ Restart Showtime now?
+
+ The app and its local server will close. Connected devices will disconnect until
+ Showtime restarts.
+
+
+
+ Not now
+ Confirm restart
+
+
+
+ >
);
}
diff --git a/apps/web/src/hooks/use-mobile-drawer.ts b/apps/web/src/hooks/use-mobile-drawer.ts
new file mode 100644
index 0000000..754ffda
--- /dev/null
+++ b/apps/web/src/hooks/use-mobile-drawer.ts
@@ -0,0 +1,19 @@
+import * as React from "react";
+
+const mobileDrawerQuery = "(max-width: 639px)";
+
+export function useIsMobileDrawer() {
+ const [isMobile, setIsMobile] = React.useState(() =>
+ typeof window === "undefined" ? false : window.matchMedia(mobileDrawerQuery).matches,
+ );
+
+ React.useEffect(() => {
+ const query = window.matchMedia(mobileDrawerQuery);
+ const update = () => setIsMobile(query.matches);
+ update();
+ query.addEventListener("change", update);
+ return () => query.removeEventListener("change", update);
+ }, []);
+
+ return isMobile;
+}
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index 320cdaa..3d62f32 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -9,16 +9,27 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
+import { Route as SettingsRouteRouteImport } from './routes/settings/route'
import { Route as LiveRouteRouteImport } from './routes/live/route'
import { Route as IndexRouteImport } from './routes/index'
+import { Route as SettingsIndexRouteImport } from './routes/settings/index'
+import { Route as SettingsSectionRouteImport } from './routes/settings/$section'
import { Route as LiveShowIdRouteImport } from './routes/live/$showId'
import { Route as ShowsShowIdRouteRouteImport } from './routes/shows/$showId/route'
import { Route as ShowsShowIdIndexRouteImport } from './routes/shows/$showId/index'
import { Route as ShowsShowIdMixesRouteImport } from './routes/shows/$showId/mixes'
import { Route as ShowsShowIdMicrophonesRouteImport } from './routes/shows/$showId/microphones'
+import { Route as ShowsShowIdSettingsRouteRouteImport } from './routes/shows/$showId/settings/route'
+import { Route as ShowsShowIdSettingsIndexRouteImport } from './routes/shows/$showId/settings/index'
import { Route as ShowsShowIdSetlistIndexRouteImport } from './routes/shows/$showId/setlist/index'
+import { Route as ShowsShowIdSettingsSectionRouteImport } from './routes/shows/$showId/settings/$section'
import { Route as ShowsShowIdSetlistSongIdRouteImport } from './routes/shows/$showId/setlist/$songId'
+const SettingsRouteRoute = SettingsRouteRouteImport.update({
+ id: '/settings',
+ path: '/settings',
+ getParentRoute: () => rootRouteImport,
+} as any)
const LiveRouteRoute = LiveRouteRouteImport.update({
id: '/live',
path: '/live',
@@ -29,6 +40,16 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
+const SettingsIndexRoute = SettingsIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => SettingsRouteRoute,
+} as any)
+const SettingsSectionRoute = SettingsSectionRouteImport.update({
+ id: '/$section',
+ path: '/$section',
+ getParentRoute: () => SettingsRouteRoute,
+} as any)
const LiveShowIdRoute = LiveShowIdRouteImport.update({
id: '/$showId',
path: '/$showId',
@@ -54,11 +75,29 @@ const ShowsShowIdMicrophonesRoute = ShowsShowIdMicrophonesRouteImport.update({
path: '/microphones',
getParentRoute: () => ShowsShowIdRouteRoute,
} as any)
+const ShowsShowIdSettingsRouteRoute =
+ ShowsShowIdSettingsRouteRouteImport.update({
+ id: '/settings',
+ path: '/settings',
+ getParentRoute: () => ShowsShowIdRouteRoute,
+ } as any)
+const ShowsShowIdSettingsIndexRoute =
+ ShowsShowIdSettingsIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => ShowsShowIdSettingsRouteRoute,
+ } as any)
const ShowsShowIdSetlistIndexRoute = ShowsShowIdSetlistIndexRouteImport.update({
id: '/setlist/',
path: '/setlist/',
getParentRoute: () => ShowsShowIdRouteRoute,
} as any)
+const ShowsShowIdSettingsSectionRoute =
+ ShowsShowIdSettingsSectionRouteImport.update({
+ id: '/$section',
+ path: '/$section',
+ getParentRoute: () => ShowsShowIdSettingsRouteRoute,
+ } as any)
const ShowsShowIdSetlistSongIdRoute =
ShowsShowIdSetlistSongIdRouteImport.update({
id: '/setlist/$songId',
@@ -69,79 +108,119 @@ const ShowsShowIdSetlistSongIdRoute =
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/live': typeof LiveRouteRouteWithChildren
+ '/settings': typeof SettingsRouteRouteWithChildren
'/shows/$showId': typeof ShowsShowIdRouteRouteWithChildren
'/live/$showId': typeof LiveShowIdRoute
+ '/settings/$section': typeof SettingsSectionRoute
+ '/settings/': typeof SettingsIndexRoute
+ '/shows/$showId/settings': typeof ShowsShowIdSettingsRouteRouteWithChildren
'/shows/$showId/microphones': typeof ShowsShowIdMicrophonesRoute
'/shows/$showId/mixes': typeof ShowsShowIdMixesRoute
'/shows/$showId/': typeof ShowsShowIdIndexRoute
'/shows/$showId/setlist/$songId': typeof ShowsShowIdSetlistSongIdRoute
+ '/shows/$showId/settings/$section': typeof ShowsShowIdSettingsSectionRoute
'/shows/$showId/setlist/': typeof ShowsShowIdSetlistIndexRoute
+ '/shows/$showId/settings/': typeof ShowsShowIdSettingsIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/live': typeof LiveRouteRouteWithChildren
'/live/$showId': typeof LiveShowIdRoute
+ '/settings/$section': typeof SettingsSectionRoute
+ '/settings': typeof SettingsIndexRoute
'/shows/$showId/microphones': typeof ShowsShowIdMicrophonesRoute
'/shows/$showId/mixes': typeof ShowsShowIdMixesRoute
'/shows/$showId': typeof ShowsShowIdIndexRoute
'/shows/$showId/setlist/$songId': typeof ShowsShowIdSetlistSongIdRoute
+ '/shows/$showId/settings/$section': typeof ShowsShowIdSettingsSectionRoute
'/shows/$showId/setlist': typeof ShowsShowIdSetlistIndexRoute
+ '/shows/$showId/settings': typeof ShowsShowIdSettingsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/live': typeof LiveRouteRouteWithChildren
+ '/settings': typeof SettingsRouteRouteWithChildren
'/shows/$showId': typeof ShowsShowIdRouteRouteWithChildren
'/live/$showId': typeof LiveShowIdRoute
+ '/settings/$section': typeof SettingsSectionRoute
+ '/settings/': typeof SettingsIndexRoute
+ '/shows/$showId/settings': typeof ShowsShowIdSettingsRouteRouteWithChildren
'/shows/$showId/microphones': typeof ShowsShowIdMicrophonesRoute
'/shows/$showId/mixes': typeof ShowsShowIdMixesRoute
'/shows/$showId/': typeof ShowsShowIdIndexRoute
'/shows/$showId/setlist/$songId': typeof ShowsShowIdSetlistSongIdRoute
+ '/shows/$showId/settings/$section': typeof ShowsShowIdSettingsSectionRoute
'/shows/$showId/setlist/': typeof ShowsShowIdSetlistIndexRoute
+ '/shows/$showId/settings/': typeof ShowsShowIdSettingsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/live'
+ | '/settings'
| '/shows/$showId'
| '/live/$showId'
+ | '/settings/$section'
+ | '/settings/'
+ | '/shows/$showId/settings'
| '/shows/$showId/microphones'
| '/shows/$showId/mixes'
| '/shows/$showId/'
| '/shows/$showId/setlist/$songId'
+ | '/shows/$showId/settings/$section'
| '/shows/$showId/setlist/'
+ | '/shows/$showId/settings/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/live'
| '/live/$showId'
+ | '/settings/$section'
+ | '/settings'
| '/shows/$showId/microphones'
| '/shows/$showId/mixes'
| '/shows/$showId'
| '/shows/$showId/setlist/$songId'
+ | '/shows/$showId/settings/$section'
| '/shows/$showId/setlist'
+ | '/shows/$showId/settings'
id:
| '__root__'
| '/'
| '/live'
+ | '/settings'
| '/shows/$showId'
| '/live/$showId'
+ | '/settings/$section'
+ | '/settings/'
+ | '/shows/$showId/settings'
| '/shows/$showId/microphones'
| '/shows/$showId/mixes'
| '/shows/$showId/'
| '/shows/$showId/setlist/$songId'
+ | '/shows/$showId/settings/$section'
| '/shows/$showId/setlist/'
+ | '/shows/$showId/settings/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
LiveRouteRoute: typeof LiveRouteRouteWithChildren
+ SettingsRouteRoute: typeof SettingsRouteRouteWithChildren
ShowsShowIdRouteRoute: typeof ShowsShowIdRouteRouteWithChildren
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
+ '/settings': {
+ id: '/settings'
+ path: '/settings'
+ fullPath: '/settings'
+ preLoaderRoute: typeof SettingsRouteRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/live': {
id: '/live'
path: '/live'
@@ -156,6 +235,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/settings/': {
+ id: '/settings/'
+ path: '/'
+ fullPath: '/settings/'
+ preLoaderRoute: typeof SettingsIndexRouteImport
+ parentRoute: typeof SettingsRouteRoute
+ }
+ '/settings/$section': {
+ id: '/settings/$section'
+ path: '/$section'
+ fullPath: '/settings/$section'
+ preLoaderRoute: typeof SettingsSectionRouteImport
+ parentRoute: typeof SettingsRouteRoute
+ }
'/live/$showId': {
id: '/live/$showId'
path: '/$showId'
@@ -191,6 +284,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ShowsShowIdMicrophonesRouteImport
parentRoute: typeof ShowsShowIdRouteRoute
}
+ '/shows/$showId/settings': {
+ id: '/shows/$showId/settings'
+ path: '/settings'
+ fullPath: '/shows/$showId/settings'
+ preLoaderRoute: typeof ShowsShowIdSettingsRouteRouteImport
+ parentRoute: typeof ShowsShowIdRouteRoute
+ }
+ '/shows/$showId/settings/': {
+ id: '/shows/$showId/settings/'
+ path: '/'
+ fullPath: '/shows/$showId/settings/'
+ preLoaderRoute: typeof ShowsShowIdSettingsIndexRouteImport
+ parentRoute: typeof ShowsShowIdSettingsRouteRoute
+ }
'/shows/$showId/setlist/': {
id: '/shows/$showId/setlist/'
path: '/setlist'
@@ -198,6 +305,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ShowsShowIdSetlistIndexRouteImport
parentRoute: typeof ShowsShowIdRouteRoute
}
+ '/shows/$showId/settings/$section': {
+ id: '/shows/$showId/settings/$section'
+ path: '/$section'
+ fullPath: '/shows/$showId/settings/$section'
+ preLoaderRoute: typeof ShowsShowIdSettingsSectionRouteImport
+ parentRoute: typeof ShowsShowIdSettingsRouteRoute
+ }
'/shows/$showId/setlist/$songId': {
id: '/shows/$showId/setlist/$songId'
path: '/setlist/$songId'
@@ -220,7 +334,38 @@ const LiveRouteRouteWithChildren = LiveRouteRoute._addFileChildren(
LiveRouteRouteChildren,
)
+interface SettingsRouteRouteChildren {
+ SettingsSectionRoute: typeof SettingsSectionRoute
+ SettingsIndexRoute: typeof SettingsIndexRoute
+}
+
+const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
+ SettingsSectionRoute: SettingsSectionRoute,
+ SettingsIndexRoute: SettingsIndexRoute,
+}
+
+const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(
+ SettingsRouteRouteChildren,
+)
+
+interface ShowsShowIdSettingsRouteRouteChildren {
+ ShowsShowIdSettingsSectionRoute: typeof ShowsShowIdSettingsSectionRoute
+ ShowsShowIdSettingsIndexRoute: typeof ShowsShowIdSettingsIndexRoute
+}
+
+const ShowsShowIdSettingsRouteRouteChildren: ShowsShowIdSettingsRouteRouteChildren =
+ {
+ ShowsShowIdSettingsSectionRoute: ShowsShowIdSettingsSectionRoute,
+ ShowsShowIdSettingsIndexRoute: ShowsShowIdSettingsIndexRoute,
+ }
+
+const ShowsShowIdSettingsRouteRouteWithChildren =
+ ShowsShowIdSettingsRouteRoute._addFileChildren(
+ ShowsShowIdSettingsRouteRouteChildren,
+ )
+
interface ShowsShowIdRouteRouteChildren {
+ ShowsShowIdSettingsRouteRoute: typeof ShowsShowIdSettingsRouteRouteWithChildren
ShowsShowIdMicrophonesRoute: typeof ShowsShowIdMicrophonesRoute
ShowsShowIdMixesRoute: typeof ShowsShowIdMixesRoute
ShowsShowIdIndexRoute: typeof ShowsShowIdIndexRoute
@@ -229,6 +374,7 @@ interface ShowsShowIdRouteRouteChildren {
}
const ShowsShowIdRouteRouteChildren: ShowsShowIdRouteRouteChildren = {
+ ShowsShowIdSettingsRouteRoute: ShowsShowIdSettingsRouteRouteWithChildren,
ShowsShowIdMicrophonesRoute: ShowsShowIdMicrophonesRoute,
ShowsShowIdMixesRoute: ShowsShowIdMixesRoute,
ShowsShowIdIndexRoute: ShowsShowIdIndexRoute,
@@ -242,6 +388,7 @@ const ShowsShowIdRouteRouteWithChildren =
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
LiveRouteRoute: LiveRouteRouteWithChildren,
+ SettingsRouteRoute: SettingsRouteRouteWithChildren,
ShowsShowIdRouteRoute: ShowsShowIdRouteRouteWithChildren,
}
export const routeTree = rootRouteImport
diff --git a/apps/web/src/routes/settings/$section.tsx b/apps/web/src/routes/settings/$section.tsx
new file mode 100644
index 0000000..b977e71
--- /dev/null
+++ b/apps/web/src/routes/settings/$section.tsx
@@ -0,0 +1,14 @@
+import { createFileRoute, Navigate } from "@tanstack/react-router";
+import { ProfilesSettings } from "@/components/profiles/ProfileSwitcher";
+import { ConnectionsSettings } from "@/components/connections/ConnectionDialog";
+import { UpdatesSettings } from "@/components/settings/UpdatesSettings";
+
+export const Route = createFileRoute("/settings/$section")({ component: SettingsSection });
+
+function SettingsSection() {
+ const { section } = Route.useParams();
+ if (section === "updates") return ;
+ if (section === "profiles") return ;
+ if (section === "connections") return ;
+ return ;
+}
diff --git a/apps/web/src/routes/settings/index.tsx b/apps/web/src/routes/settings/index.tsx
new file mode 100644
index 0000000..1922320
--- /dev/null
+++ b/apps/web/src/routes/settings/index.tsx
@@ -0,0 +1,7 @@
+import { createFileRoute, redirect } from "@tanstack/react-router";
+
+export const Route = createFileRoute("/settings/")({
+ beforeLoad: () => {
+ throw redirect({ to: "/settings/$section", params: { section: "updates" } });
+ },
+});
diff --git a/apps/web/src/routes/settings/route.tsx b/apps/web/src/routes/settings/route.tsx
new file mode 100644
index 0000000..22822d9
--- /dev/null
+++ b/apps/web/src/routes/settings/route.tsx
@@ -0,0 +1,4 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { SettingsLayout } from "@/components/settings/SettingsLayout";
+
+export const Route = createFileRoute("/settings")({ component: SettingsLayout });
diff --git a/apps/web/src/routes/shows/$showId/settings/$section.tsx b/apps/web/src/routes/shows/$showId/settings/$section.tsx
new file mode 100644
index 0000000..4a32540
--- /dev/null
+++ b/apps/web/src/routes/shows/$showId/settings/$section.tsx
@@ -0,0 +1,26 @@
+import { createFileRoute, Navigate } from "@tanstack/react-router";
+import { ChatSettings } from "@/components/settings/ChatSettings";
+import { ProfilesSettings } from "@/components/profiles/ProfileSwitcher";
+import { ConnectionsSettings } from "@/components/connections/ConnectionDialog";
+import { GeneralSettings } from "@/components/settings/GeneralSettings";
+import { UpdatesSettings } from "@/components/settings/UpdatesSettings";
+
+export const Route = createFileRoute("/shows/$showId/settings/$section")({
+ component: SettingsSection,
+});
+
+function SettingsSection() {
+ const { showId, section } = Route.useParams();
+ if (section === "general") return ;
+ if (section === "chat") return ;
+ if (section === "updates") return ;
+ if (section === "profiles") return ;
+ if (section === "connections") return ;
+ return (
+
+ );
+}
diff --git a/apps/web/src/routes/shows/$showId/settings/index.tsx b/apps/web/src/routes/shows/$showId/settings/index.tsx
new file mode 100644
index 0000000..ef19d6e
--- /dev/null
+++ b/apps/web/src/routes/shows/$showId/settings/index.tsx
@@ -0,0 +1,10 @@
+import { createFileRoute, redirect } from "@tanstack/react-router";
+
+export const Route = createFileRoute("/shows/$showId/settings/")({
+ beforeLoad: ({ params }) => {
+ throw redirect({
+ to: "/shows/$showId/settings/$section",
+ params: { showId: params.showId, section: "general" },
+ });
+ },
+});
diff --git a/apps/web/src/routes/shows/$showId/settings/route.tsx b/apps/web/src/routes/shows/$showId/settings/route.tsx
new file mode 100644
index 0000000..3d3d523
--- /dev/null
+++ b/apps/web/src/routes/shows/$showId/settings/route.tsx
@@ -0,0 +1,4 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { SettingsLayout } from "@/components/settings/SettingsLayout";
+
+export const Route = createFileRoute("/shows/$showId/settings")({ component: SettingsLayout });