From fe64c683fd0ee27737ea5f92df2e4ac63c4c4e6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schie=C3=9Fl?= <150372753+johannesschiessl@users.noreply.github.com> Date: Wed, 15 Jul 2026 07:52:21 +0200 Subject: [PATCH 1/2] Improve React code quality --- apps/web/src/chats/ChatNavigation.ts | 33 +- .../src/components/chats/ChatMessageBody.tsx | 9 +- .../src/components/chats/ChatPresetDialog.tsx | 18 +- .../src/components/chats/ChatWorkspace.tsx | 20 +- .../connections/ConnectionDialog.tsx | 124 +++-- .../components/live/LiveSongNavigation.tsx | 9 +- .../components/microphones/MicrophoneCard.tsx | 182 +++++++ .../microphones/MicrophoneDeleteDialog.tsx | 81 +++ apps/web/src/components/mixes/MixCard.tsx | 189 +++++++ .../src/components/mixes/MixDeleteDialog.tsx | 77 +++ .../components/profiles/ProfileSwitcher.tsx | 20 +- .../src/components/shows/ShowComingSoon.tsx | 17 - .../src/components/shows/ShowDeleteDialog.tsx | 4 +- apps/web/src/components/shows/ShowList.tsx | 4 +- .../src/components/songs/MicrophoneName.tsx | 65 +++ .../src/components/songs/SongDeleteDialog.tsx | 75 +++ apps/web/src/components/songs/SongDetail.tsx | 272 ++++++++++ .../components/songs/SongMixAssignments.tsx | 154 ++++++ apps/web/src/components/ui/badge.tsx | 2 +- apps/web/src/components/ui/button.tsx | 2 +- apps/web/src/components/ui/item.tsx | 11 +- apps/web/src/components/ui/tabs.tsx | 2 +- apps/web/src/connection.ts | 2 - apps/web/src/main.tsx | 12 + .../src/notifications/NotificationCenter.ts | 9 - apps/web/src/profiles/currentProfilesState.ts | 12 + .../src/routes/shows/$showId/microphones.tsx | 258 +-------- apps/web/src/routes/shows/$showId/mixes.tsx | 261 +-------- .../routes/shows/$showId/setlist/$songId.tsx | 512 +----------------- .../routes/shows/$showId/setlist/index.tsx | 4 +- 30 files changed, 1292 insertions(+), 1148 deletions(-) create mode 100644 apps/web/src/components/microphones/MicrophoneCard.tsx create mode 100644 apps/web/src/components/microphones/MicrophoneDeleteDialog.tsx create mode 100644 apps/web/src/components/mixes/MixCard.tsx create mode 100644 apps/web/src/components/mixes/MixDeleteDialog.tsx delete mode 100644 apps/web/src/components/shows/ShowComingSoon.tsx create mode 100644 apps/web/src/components/songs/MicrophoneName.tsx create mode 100644 apps/web/src/components/songs/SongDeleteDialog.tsx create mode 100644 apps/web/src/components/songs/SongDetail.tsx create mode 100644 apps/web/src/components/songs/SongMixAssignments.tsx create mode 100644 apps/web/src/profiles/currentProfilesState.ts diff --git a/apps/web/src/chats/ChatNavigation.ts b/apps/web/src/chats/ChatNavigation.ts index f7d6488..d5ecc89 100644 --- a/apps/web/src/chats/ChatNavigation.ts +++ b/apps/web/src/chats/ChatNavigation.ts @@ -1,27 +1,28 @@ -import { router } from "@/router"; -import { makeChatNavigation } from "./ChatNavigationState"; +import type { ShowId } from "@showtime/contracts"; +import { makeChatNavigation, type ChatOpenRequest } from "./ChatNavigationState"; export type { ChatOpenRequest } from "./ChatNavigationState"; const eventName = "showtime-chat-open"; -const activeShowId = () => { - for (const match of router.state.matches) { - const showId = (match.params as { readonly showId?: unknown }).showId; - if (typeof showId === "string") return showId; - } - return undefined; -}; +let chatNavigation: ReturnType | undefined; -const chatNavigation = makeChatNavigation({ - getActiveShowId: activeShowId, - navigateToShow: (showId) => router.navigate({ to: "/shows/$showId", params: { showId } }), - publishOpenRequest: () => window.dispatchEvent(new Event(eventName)), -}); +export const configureChatNavigation = (options: { + readonly getActiveShowId: () => string | undefined; + readonly navigateToShow: (showId: ShowId) => Promise; +}) => { + chatNavigation = makeChatNavigation({ + ...options, + publishOpenRequest: () => window.dispatchEvent(new Event(eventName)), + }); +}; -export const openChat = chatNavigation.open; +export const openChat = (request: ChatOpenRequest) => { + if (!chatNavigation) throw new Error("Chat navigation has not been configured"); + return chatNavigation.open(request); +}; -export const consumeChatOpenRequest = chatNavigation.consume; +export const consumeChatOpenRequest = (showId: ShowId) => chatNavigation?.consume(showId); export const subscribeChatOpenRequests = (listener: () => void) => { window.addEventListener(eventName, listener); diff --git a/apps/web/src/components/chats/ChatMessageBody.tsx b/apps/web/src/components/chats/ChatMessageBody.tsx index 8005236..3bd7f60 100644 --- a/apps/web/src/components/chats/ChatMessageBody.tsx +++ b/apps/web/src/components/chats/ChatMessageBody.tsx @@ -11,14 +11,17 @@ export function ChatMessageBody({ readonly parts?: ReadonlyArray; }) { if (!parts?.length) return body; - return parts.map((part, index) => { - if (part.type === "text") return {part.text}; + let characterOffset = 0; + return parts.map((part) => { + const key = `${part.type}:${characterOffset}:${part.text}`; + characterOffset += part.text.length; + if (part.type === "text") return {part.text}; const colors = microphoneColorClassNames[part.color]; const label = part.type === "microphone" ? "Microphone" : "Mix"; const Icon = part.type === "microphone" ? Mic2Icon : SpeakerIcon; return ( ({ type: "list" }); - React.useEffect(() => { - if (!open) setMode({ type: "list" }); - }, [open]); - const back = () => setMode({ type: "list" }); + const changeOpen = (nextOpen: boolean) => { + if (!nextOpen) back(); + onOpenChange(nextOpen); + }; const title = mode.type === "list" ? "Message presets" @@ -142,7 +142,7 @@ export function ChatPresetDialog({ : "New preset"; return ( - +
@@ -239,7 +239,7 @@ function PresetList({
) : ( presets.map((preset) => ( - + }> + + + + } + > + event.currentTarget.select()} + onClick={(event) => { + event.stopPropagation(); + event.currentTarget.select(); + }} + onChange={(event) => setNumber(event.currentTarget.value)} + onBlur={commitNumber} + onKeyDown={(event) => event.key === "Enter" && event.currentTarget.blur()} + className={cn( + "w-full bg-transparent text-center text-2xl font-bold outline-none", + colors.text, + )} + /> + + +
+ {colorOptions.map((option) => ( + + ))} +
+
+
+
+ setName(event.currentTarget.value)} + onBlur={async () => { + if (name.trim() !== (microphone.name ?? "")) { + const result = await save({ name }); + if (Exit.isFailure(result)) setName(microphone.name ?? ""); + } + }} + onKeyDown={(event) => event.key === "Enter" && event.currentTarget.blur()} + /> +

+ Label already in use +

+ {saveError && ( +

+ {saveError} +

+ )} +
+
+ + ); +} diff --git a/apps/web/src/components/microphones/MicrophoneDeleteDialog.tsx b/apps/web/src/components/microphones/MicrophoneDeleteDialog.tsx new file mode 100644 index 0000000..460b235 --- /dev/null +++ b/apps/web/src/components/microphones/MicrophoneDeleteDialog.tsx @@ -0,0 +1,81 @@ +import * as React from "react"; +import { useAtomSet } from "@effect/atom-react"; +import { Exit } from "effect"; +import { Trash2Icon } from "lucide-react"; +import type { ShowId } from "@showtime/contracts"; +import { + microphoneAtoms, + microphonesRpcReactivityKey, + rpcErrorMessageFromCause, + type MicrophoneListItem, +} from "@/client"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +export function MicrophoneDeleteDialog({ + microphone, + showId, + onClose, +}: { + readonly microphone: MicrophoneListItem | undefined; + readonly showId: ShowId; + readonly onClose: () => void; +}) { + const deleteMicrophone = useAtomSet(microphoneAtoms(showId).delete, { mode: "promiseExit" }); + const [isDeleting, setIsDeleting] = React.useState(false); + const [deleteError, setDeleteError] = React.useState(); + + const confirmDelete = async () => { + if (!microphone) return; + setIsDeleting(true); + setDeleteError(undefined); + const result = await deleteMicrophone({ + payload: { showId, id: microphone.id }, + reactivityKeys: microphonesRpcReactivityKey(showId), + }); + if (Exit.isSuccess(result)) { + onClose(); + } else { + setDeleteError(rpcErrorMessageFromCause(result.cause)); + setIsDeleting(false); + } + }; + + const label = microphone?.name?.trim() + ? `“${microphone.name}” (number ${microphone.number})` + : `microphone ${microphone?.number ?? ""}`; + + return ( + !open && onClose()}> + + + Remove microphone? + + This will remove {label} from the active microphone list. + + + {deleteError && ( +

+ {deleteError} +

+ )} + + }> + Cancel + + + +
+
+ ); +} diff --git a/apps/web/src/components/mixes/MixCard.tsx b/apps/web/src/components/mixes/MixCard.tsx new file mode 100644 index 0000000..73a65b7 --- /dev/null +++ b/apps/web/src/components/mixes/MixCard.tsx @@ -0,0 +1,189 @@ +import * as React from "react"; +import { useAtomSet } from "@effect/atom-react"; +import { Exit } from "effect"; +import { CheckIcon, Trash2Icon } from "lucide-react"; +import { + colors as colorOptions, + mainMixId, + type Color, + type MixNumber, + type ShowId, +} from "@showtime/contracts"; +import { + mixAtoms, + mixesRpcReactivityKey, + rpcErrorMessageFromCause, + type MixListItem, +} from "@/client"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { colorPreviewClassNames } from "@/components/color"; +import { microphoneColorClassNames } from "@/components/microphones/microphone-color"; +import { cn } from "@/lib/utils"; + +export function MixCard({ + mix, + mixes, + showId, + onDelete, +}: { + readonly mix: MixListItem; + readonly mixes: ReadonlyArray; + readonly showId: ShowId; + readonly onDelete: () => void; +}) { + const edit = useAtomSet(mixAtoms(showId).edit, { mode: "promiseExit" }); + const [number, setNumber] = React.useState(String(mix.number)); + const [name, setName] = React.useState(mix.name ?? ""); + const [color, setColor] = React.useState(mix.color); + const [saveError, setSaveError] = React.useState(); + React.useEffect(() => { + setNumber(String(mix.number)); + setName(mix.name ?? ""); + setColor(mix.color); + setSaveError(undefined); + }, [mix.color, mix.name, mix.number]); + + const save = async (next: { number?: string; name?: string; color?: Color }) => { + setSaveError(undefined); + const result = await edit({ + payload: { + showId, + id: mix.id, + number: ((next.number ?? number.trim()) || mix.number) as MixNumber, + color: next.color ?? color, + ...(next.name !== undefined + ? { name: next.name.trim() } + : name.trim() + ? { name: name.trim() } + : {}), + }, + reactivityKeys: mixesRpcReactivityKey(showId), + }); + if (Exit.isFailure(result)) setSaveError(rpcErrorMessageFromCause(result.cause)); + return result; + }; + + const trimmedNumber = number.trim(); + const duplicate = mixes.some((other) => other.id !== mix.id && other.number === trimmedNumber); + const commitNumber = async () => { + const valid = trimmedNumber || mix.number; + setNumber(String(valid)); + if (valid !== mix.number) { + const result = await save({ number: valid }); + if (Exit.isFailure(result)) setNumber(String(mix.number)); + } + }; + + const colors = microphoneColorClassNames[color]; + return ( + + {mix.id !== mainMixId && ( + + )} + + + + } + > + event.currentTarget.select()} + onClick={(event) => { + event.stopPropagation(); + event.currentTarget.select(); + }} + onChange={(event) => setNumber(event.currentTarget.value)} + onBlur={commitNumber} + onKeyDown={(event) => event.key === "Enter" && event.currentTarget.blur()} + className={cn( + "w-full bg-transparent text-center text-2xl font-bold outline-none", + colors.text, + )} + /> + + +
+ {colorOptions.map((option) => ( + + ))} +
+
+
+
+ setName(event.currentTarget.value)} + onBlur={async () => { + if (name.trim() !== (mix.name ?? "")) { + const result = await save({ name }); + if (Exit.isFailure(result)) setName(mix.name ?? ""); + } + }} + onKeyDown={(event) => event.key === "Enter" && event.currentTarget.blur()} + /> +

+ Label already in use +

+ {saveError && ( +

+ {saveError} +

+ )} +
+ {mix.id === mainMixId && Main mix} +
+
+ ); +} diff --git a/apps/web/src/components/mixes/MixDeleteDialog.tsx b/apps/web/src/components/mixes/MixDeleteDialog.tsx new file mode 100644 index 0000000..cf29c6b --- /dev/null +++ b/apps/web/src/components/mixes/MixDeleteDialog.tsx @@ -0,0 +1,77 @@ +import * as React from "react"; +import { useAtomSet } from "@effect/atom-react"; +import { Exit } from "effect"; +import { Trash2Icon } from "lucide-react"; +import type { ShowId } from "@showtime/contracts"; +import { + mixAtoms, + mixesRpcReactivityKey, + rpcErrorMessageFromCause, + type MixListItem, +} from "@/client"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +export function MixDeleteDialog({ + mix, + showId, + onClose, +}: { + readonly mix: MixListItem | undefined; + readonly showId: ShowId; + readonly onClose: () => void; +}) { + const deleteMix = useAtomSet(mixAtoms(showId).delete, { mode: "promiseExit" }); + const [isDeleting, setIsDeleting] = React.useState(false); + const [deleteError, setDeleteError] = React.useState(); + + const confirmDelete = async () => { + if (!mix) return; + setIsDeleting(true); + setDeleteError(undefined); + const result = await deleteMix({ + payload: { showId, id: mix.id }, + reactivityKeys: mixesRpcReactivityKey(showId), + }); + if (Exit.isSuccess(result)) { + onClose(); + } else { + setDeleteError(rpcErrorMessageFromCause(result.cause)); + setIsDeleting(false); + } + }; + + const label = mix?.name?.trim() ? `“${mix.name}” (${mix.number})` : `mix ${mix?.number ?? ""}`; + + return ( + !open && onClose()}> + + + Remove mix? + This will remove {label} from the active mix list. + + {deleteError && ( +

+ {deleteError} +

+ )} + + }> + Cancel + + + +
+
+ ); +} diff --git a/apps/web/src/components/profiles/ProfileSwitcher.tsx b/apps/web/src/components/profiles/ProfileSwitcher.tsx index 2c739c3..01fd1ef 100644 --- a/apps/web/src/components/profiles/ProfileSwitcher.tsx +++ b/apps/web/src/components/profiles/ProfileSwitcher.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import { Exit, Option } from "effect"; +import { Exit } from "effect"; import { AsyncResult } from "effect/unstable/reactivity"; import { profilesSyncKey, @@ -40,21 +40,13 @@ import { InputGroupInput, } from "@/components/ui/input-group"; import { ColorPickerPopover } from "@/components/ColorPickerPopover"; +import { currentProfilesState } from "@/profiles/currentProfilesState"; const mutationOptions = { reactivityKeys: profilesSyncKey } as const; const isPendingProfile = (profile: Profile): boolean => "pending" in profile && profile.pending === true; -export const currentProfilesState = ( - result: AsyncResult.AsyncResult, -): ProfilesState | undefined => - AsyncResult.isSuccess(result) - ? result.value - : AsyncResult.isFailure(result) - ? Option.getOrUndefined(result.previousSuccess)?.value - : undefined; - export function ProfileSwitcher({ className, variant = "full", @@ -224,7 +216,7 @@ function ProfileAvatarPopover({ ); } -export function ProfileLabel({ profile }: { readonly profile: Profile }) { +function ProfileLabel({ profile }: { readonly profile: Profile }) { return ( @@ -313,7 +305,7 @@ function ProfileRow({ const [color, setColor] = React.useState(profile.color); const [busy, setBusy] = React.useState(false); const pending = isPendingProfile(profile); - const saveQueue = React.useRef(Promise.resolve()); + const saveQueue = React.useRef>(undefined); const suppressNextBlurSave = React.useRef(false); React.useEffect(() => { setName(profile.name); @@ -323,7 +315,7 @@ function ProfileRow({ const run = (operation: () => Promise>) => { setBusy(true); onError(undefined); - saveQueue.current = saveQueue.current + saveQueue.current = (saveQueue.current ?? Promise.resolve()) .then(async () => { const result = await operation(); if (Exit.isFailure(result)) onError(rpcErrorMessageFromCause(result.cause)); @@ -339,7 +331,7 @@ function ProfileRow({ } if (trimmedName === profile.name && nextColor === profile.color) return; onError(undefined); - saveQueue.current = saveQueue.current.then(async () => { + saveQueue.current = (saveQueue.current ?? Promise.resolve()).then(async () => { const result = await edit({ payload: { id: profile.id, diff --git a/apps/web/src/components/shows/ShowComingSoon.tsx b/apps/web/src/components/shows/ShowComingSoon.tsx deleted file mode 100644 index 6aa6a08..0000000 --- a/apps/web/src/components/shows/ShowComingSoon.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Badge } from "@/components/ui/badge"; -import { Empty, EmptyHeader, EmptyTitle } from "@/components/ui/empty"; - -type ShowComingSoonProps = { - readonly title: string; -}; - -export function ShowComingSoon({ title }: ShowComingSoonProps) { - return ( - - - Coming soon - {title} - - - ); -} diff --git a/apps/web/src/components/shows/ShowDeleteDialog.tsx b/apps/web/src/components/shows/ShowDeleteDialog.tsx index dc0d49f..eb5b552 100644 --- a/apps/web/src/components/shows/ShowDeleteDialog.tsx +++ b/apps/web/src/components/shows/ShowDeleteDialog.tsx @@ -28,7 +28,9 @@ export function ShowDeleteDialog({ onDeleted }: ShowDeleteDialogProps) { const [deleteError, setDeleteError] = React.useState(); const dialogRef = React.useRef(dialog); - dialogRef.current = dialog; + React.useEffect(() => { + dialogRef.current = dialog; + }, [dialog]); const close = () => setDialog({ type: "closed" }); diff --git a/apps/web/src/components/shows/ShowList.tsx b/apps/web/src/components/shows/ShowList.tsx index 369052b..54e1795 100644 --- a/apps/web/src/components/shows/ShowList.tsx +++ b/apps/web/src/components/shows/ShowList.tsx @@ -78,9 +78,7 @@ function ShowItems({ shows }: { readonly shows: ReadonlyArray }) { {shows.map((show) => ( -
- -
+ ))}
diff --git a/apps/web/src/components/songs/MicrophoneName.tsx b/apps/web/src/components/songs/MicrophoneName.tsx new file mode 100644 index 0000000..760f614 --- /dev/null +++ b/apps/web/src/components/songs/MicrophoneName.tsx @@ -0,0 +1,65 @@ +import * as React from "react"; +import type { Microphone, SongMicrophoneName } from "@showtime/contracts"; +import { Input } from "@/components/ui/input"; + +export function MicrophoneName({ + microphone, + microphoneNames, + disabled, + onSave, +}: { + readonly microphone: Microphone; + readonly microphoneNames: ReadonlyArray; + readonly disabled: boolean; + readonly onSave: (value: string) => void; +}) { + const inheritedName = microphone.name ?? ""; + const displayedName = + microphoneNames.find((item) => item.microphoneId === microphone.id)?.name ?? inheritedName; + const [editing, setEditing] = React.useState(false); + const [value, setValue] = React.useState(displayedName); + + React.useEffect(() => setValue(displayedName), [displayedName]); + + if (!editing) { + return ( + + ); + } + + return ( + setValue(event.currentTarget.value)} + onBlur={() => { + setEditing(false); + if (value.trim() !== displayedName) onSave(value); + }} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + setValue(displayedName); + setEditing(false); + } + }} + onClick={(event) => event.stopPropagation()} + className="h-auto min-w-0 border-transparent bg-transparent p-0 text-center text-sm font-medium shadow-none focus-visible:bg-input/30 focus-visible:ring-0 dark:bg-transparent dark:focus-visible:bg-input/30" + /> + ); +} diff --git a/apps/web/src/components/songs/SongDeleteDialog.tsx b/apps/web/src/components/songs/SongDeleteDialog.tsx new file mode 100644 index 0000000..cc6e12e --- /dev/null +++ b/apps/web/src/components/songs/SongDeleteDialog.tsx @@ -0,0 +1,75 @@ +import * as React from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { Exit } from "effect"; +import { Trash2Icon } from "lucide-react"; +import type { ShowId, SongId } from "@showtime/contracts"; +import { useAtomSet } from "@effect/atom-react"; +import { rpcErrorMessageFromCause, songAtoms, songsRpcReactivityKey } from "@/client"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +export function SongDeleteDialog({ + showId, + songId, + songName, + open, + onOpenChange, +}: { + readonly showId: ShowId; + readonly songId: SongId; + readonly songName: string; + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +}) { + const navigate = useNavigate(); + const deleteSong = useAtomSet(songAtoms(showId).delete, { mode: "promiseExit" }); + const [isDeleting, setIsDeleting] = React.useState(false); + const [error, setError] = React.useState(); + const confirm = async () => { + setIsDeleting(true); + const result = await deleteSong({ + payload: { showId, id: songId }, + reactivityKeys: songsRpcReactivityKey(showId), + }); + if (Exit.isFailure(result)) { + setError(rpcErrorMessageFromCause(result.cause)); + setIsDeleting(false); + return; + } + onOpenChange(false); + void navigate({ to: "/shows/$showId/setlist", params: { showId } }); + }; + return ( + + + + Delete song? + + “{songName}” will be removed from the active setlist. + + + {error && ( +

+ {error} +

+ )} + + }> + Cancel + + + +
+
+ ); +} diff --git a/apps/web/src/components/songs/SongDetail.tsx b/apps/web/src/components/songs/SongDetail.tsx new file mode 100644 index 0000000..63e0e50 --- /dev/null +++ b/apps/web/src/components/songs/SongDetail.tsx @@ -0,0 +1,272 @@ +import * as React from "react"; +import { Exit } from "effect"; +import { EllipsisIcon, Trash2Icon } from "lucide-react"; +import { + type Microphone, + type MicrophoneId, + type Mix, + type ShowId, + type Song, + type SongArtist, + type SongMicrophoneName, + type SongMixAssignment, + type SongName, +} from "@showtime/contracts"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { useAtomSet } from "@effect/atom-react"; +import { songAtoms, songsRpcReactivityKey } from "@/client"; +import { rpcErrorMessageFromCause } from "@/client"; +import { SongDeleteDialog } from "./SongDeleteDialog"; +import { SongMixAssignments } from "./SongMixAssignments"; + +type SongDetailState = { + readonly name: string; + readonly artist: string; + readonly notes: string; + readonly assignments: ReadonlyArray; + readonly microphoneNames: ReadonlyArray; + readonly isSaving: boolean; + readonly saveError?: string; + readonly deleteOpen: boolean; +}; + +const reduceSongDetailState = ( + state: SongDetailState, + update: Partial, +): SongDetailState => ({ ...state, ...update }); + +export function SongDetail({ + showId, + song, + number, + mixes, + microphones, +}: { + readonly showId: ShowId; + readonly song: Song; + readonly number: number; + readonly mixes: ReadonlyArray; + readonly microphones: ReadonlyArray; +}) { + const edit = useAtomSet(songAtoms(showId).edit, { mode: "promiseExit" }); + const [ + { name, artist, notes, assignments, microphoneNames, isSaving, saveError, deleteOpen }, + update, + ] = React.useReducer(reduceSongDetailState, { + name: song.name, + artist: song.artist, + notes: song.notes ?? "", + assignments: song.mixAssignments, + microphoneNames: song.microphoneNames ?? [], + isSaving: false, + deleteOpen: false, + }); + const isSavingRef = React.useRef(false); + const notesRef = React.useRef(null); + React.useLayoutEffect(() => { + const textarea = notesRef.current; + if (!textarea) return; + + const styles = window.getComputedStyle(textarea); + const lineHeight = Number.parseFloat(styles.lineHeight); + const verticalChrome = + Number.parseFloat(styles.paddingTop) + + Number.parseFloat(styles.paddingBottom) + + Number.parseFloat(styles.borderTopWidth) + + Number.parseFloat(styles.borderBottomWidth); + const maxHeight = lineHeight * 6 + verticalChrome; + + textarea.style.maxHeight = `${maxHeight}px`; + textarea.style.height = "auto"; + textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`; + }, [notes]); + + const save = async ( + next?: { + readonly name?: string; + readonly artist?: string; + readonly notes?: string; + readonly assignments?: ReadonlyArray; + readonly microphoneNames?: ReadonlyArray; + }, + blockUi = true, + ) => { + const nextName = (next?.name ?? name).trim(); + const nextArtist = (next?.artist ?? artist).trim(); + if (blockUi && isSavingRef.current) return false; + const activeMixIds = new Set(mixes.map((mix) => mix.id)); + const activeMicrophoneIds = new Set(microphones.map((microphone) => microphone.id)); + const normalizedAssignments = (next?.assignments ?? assignments).flatMap((assignment) => { + if (!activeMixIds.has(assignment.mixId)) return []; + const microphoneIds = assignment.microphoneIds.filter((id) => activeMicrophoneIds.has(id)); + return microphoneIds.length > 0 ? [{ ...assignment, microphoneIds }] : []; + }); + const normalizedMicrophoneNames = (next?.microphoneNames ?? microphoneNames).filter((item) => + activeMicrophoneIds.has(item.microphoneId), + ); + if (blockUi) { + isSavingRef.current = true; + update({ isSaving: true }); + } + update({ saveError: undefined }); + const result = await edit({ + payload: { + showId, + id: song.id, + name: nextName as SongName, + artist: nextArtist as SongArtist, + notes: next?.notes ?? notes, + mixAssignments: normalizedAssignments, + microphoneNames: normalizedMicrophoneNames, + }, + reactivityKeys: songsRpcReactivityKey(showId), + }); + if (blockUi) { + isSavingRef.current = false; + update({ isSaving: false }); + } + if (Exit.isFailure(result)) { + update({ + saveError: rpcErrorMessageFromCause(result.cause), + name: song.name, + artist: song.artist, + notes: song.notes ?? "", + assignments: song.mixAssignments, + microphoneNames: song.microphoneNames ?? [], + }); + return false; + } + return true; + }; + + const toggleMicrophone = (mixId: Mix["id"], microphoneId: MicrophoneId) => { + if (isSavingRef.current) return; + const existing = assignments.find((assignment) => assignment.mixId === mixId); + const selected = new Set(existing?.microphoneIds ?? []); + if (selected.has(microphoneId)) { + selected.delete(microphoneId); + } else { + selected.add(microphoneId); + } + const microphoneIds = microphones.reduce>((ids, microphone) => { + if (selected.has(microphone.id)) ids.push(microphone.id); + return ids; + }, []); + const next = [ + ...assignments.filter((assignment) => assignment.mixId !== mixId), + ...(microphoneIds.length ? [{ mixId, microphoneIds }] : []), + ]; + update({ assignments: next }); + void save({ assignments: next }); + }; + + const saveMicrophoneName = (microphone: Microphone, value: string) => { + const trimmed = value.trim(); + const override = trimmed && trimmed !== (microphone.name?.trim() ?? "") ? trimmed : undefined; + const nextMicrophoneNames = [ + ...microphoneNames.filter((item) => item.microphoneId !== microphone.id), + ...(override ? [{ microphoneId: microphone.id, name: override }] : []), + ]; + update({ microphoneNames: nextMicrophoneNames }); + void save({ microphoneNames: nextMicrophoneNames }, false); + }; + + return ( +
+
+
+ {number} +
+
+
+ update({ name: event.currentTarget.value })} + onBlur={() => { + if (name.trim() !== song.name) void save({ name }); + }} + onKeyDown={(event) => event.key === "Enter" && event.currentTarget.blur()} + className="h-auto w-full min-w-0 max-w-full border-transparent bg-transparent px-2 py-1 text-xl font-semibold shadow-none focus-visible:bg-input/30 disabled:bg-transparent disabled:opacity-100 sm:w-auto sm:shrink sm:text-2xl sm:[field-sizing:content] dark:bg-transparent dark:disabled:bg-transparent dark:focus-visible:bg-input/30" + /> + + update({ artist: event.currentTarget.value })} + onBlur={() => { + if (artist.trim() !== song.artist) void save({ artist }); + }} + onKeyDown={(event) => event.key === "Enter" && event.currentTarget.blur()} + className="h-auto min-w-0 max-w-full w-auto border-0 bg-transparent p-0 text-xs shadow-none [field-sizing:content] focus-visible:ring-0 disabled:bg-transparent disabled:opacity-100 dark:bg-transparent dark:disabled:bg-transparent" + /> + +
+