From 69fe7633668fd56b3a93949b358ab9549e7f3eec Mon Sep 17 00:00:00 2001 From: Sam Lanning Date: Wed, 15 Jul 2026 14:31:59 +0100 Subject: [PATCH] Introduce ShowFileConfig and other related changes Introduce a new component for the UI / frontend for consistently managing show files across sigil-based apps. The first usage of this component will be in Arcane Desktop, but additional usages (e.g. timecode-toolbox) are expected later on. This commit / PR also creates a bunch of other non-breaking changes that this new component depends on. --- .changeset/big-ghosts-trade.md | 11 + .changeset/early-bees-take.md | 5 + .changeset/every-pans-cry.md | 5 + .changeset/fine-roses-show.md | 10 + .changeset/silent-bags-make.md | 8 + packages/sigil/package.json | 6 + .../sigil/src/frontend/controls/buttons.tsx | 52 ++ .../sigil/src/frontend/controls/content.tsx | 18 +- packages/sigil/src/frontend/showfiles.tsx | 606 ++++++++++++++++++ packages/sigil/src/frontend/styles/sigil.css | 23 + packages/sigil/src/frontend/user-actions.tsx | 78 ++- packages/sigil/tsup.config.ts | 1 + 12 files changed, 819 insertions(+), 4 deletions(-) create mode 100644 .changeset/big-ghosts-trade.md create mode 100644 .changeset/early-bees-take.md create mode 100644 .changeset/every-pans-cry.md create mode 100644 .changeset/fine-roses-show.md create mode 100644 .changeset/silent-bags-make.md create mode 100644 packages/sigil/src/frontend/showfiles.tsx diff --git a/.changeset/big-ghosts-trade.md b/.changeset/big-ghosts-trade.md new file mode 100644 index 0000000..77d64b1 --- /dev/null +++ b/.changeset/big-ghosts-trade.md @@ -0,0 +1,11 @@ +--- +'@arcanewizards/sigil': patch +--- + +Expand user-actions module with more utilities + +- `ActionResponse` type, designed to allow for more rich error messages from + e.g. server actions +- `mapUserActionState` (basic functional map function over `UserActionState`) +- `useUserAction` - a react hook for managing state relating to user actions + and promise-based calls diff --git a/.changeset/early-bees-take.md b/.changeset/early-bees-take.md new file mode 100644 index 0000000..6fd3922 --- /dev/null +++ b/.changeset/early-bees-take.md @@ -0,0 +1,5 @@ +--- +'@arcanewizards/sigil': patch +--- + +Add sigil-grid tailwind utility classes diff --git a/.changeset/every-pans-cry.md b/.changeset/every-pans-cry.md new file mode 100644 index 0000000..8b7e073 --- /dev/null +++ b/.changeset/every-pans-cry.md @@ -0,0 +1,5 @@ +--- +'@arcanewizards/sigil': patch +--- + +Add vAligh attribute to ControlLabel diff --git a/.changeset/fine-roses-show.md b/.changeset/fine-roses-show.md new file mode 100644 index 0000000..2a77894 --- /dev/null +++ b/.changeset/fine-roses-show.md @@ -0,0 +1,10 @@ +--- +'@arcanewizards/sigil': minor +--- + +Introduce a new `ShowFileConfig` component + +Introduce a new component for the UI / frontend for consistently managing show +files across sigil-based apps. The first usage of this component will be in +Arcane Desktop, but additional usages (e.g. timecode-toolbox) are expected +later on. diff --git a/.changeset/silent-bags-make.md b/.changeset/silent-bags-make.md new file mode 100644 index 0000000..c56560f --- /dev/null +++ b/.changeset/silent-bags-make.md @@ -0,0 +1,8 @@ +--- +'@arcanewizards/sigil': patch +--- + +Introduce ControlFileButton component + +This is a special-case of a ControlButton, +designed to load files from the users' system. diff --git a/packages/sigil/package.json b/packages/sigil/package.json index 22635c8..88ca858 100644 --- a/packages/sigil/package.json +++ b/packages/sigil/package.json @@ -65,6 +65,12 @@ "import": "./dist/frontend/preferences.js", "require": "./dist/frontend/preferences.cjs" }, + "./frontend/showfiles": { + "@arcanewizards/source": "./src/frontend/showfiles.tsx", + "types": "./dist/frontend/showfiles.d.ts", + "import": "./dist/frontend/showfiles.js", + "require": "./dist/frontend/showfiles.cjs" + }, "./frontend/spinner": { "@arcanewizards/source": "./src/frontend/spinner.tsx", "types": "./dist/frontend/spinner.d.ts", diff --git a/packages/sigil/src/frontend/controls/buttons.tsx b/packages/sigil/src/frontend/controls/buttons.tsx index 6923639..67aed05 100644 --- a/packages/sigil/src/frontend/controls/buttons.tsx +++ b/packages/sigil/src/frontend/controls/buttons.tsx @@ -2,6 +2,8 @@ import { ComponentPropsWithoutRef, CSSProperties, forwardRef, + useCallback, + useRef, type ReactNode, } from 'react'; import { cn } from '@arcanejs/toolkit-frontend/util'; @@ -217,6 +219,56 @@ export const CheckboxControlButton = forwardRef< CheckboxControlButton.displayName = 'CheckboxControlButton'; +export type ControlFileButtonProps = Omit & + Pick, 'accept' | 'multiple'> & { + onFilesSelected: (files: FileList) => Promise; + }; + +export const ControlFileButton = forwardRef< + HTMLButtonElement, + ControlFileButtonProps +>(({ onFilesSelected, accept, multiple, children, ...props }, ref) => { + const fileInputRef = useRef(null); + + const handleClick = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleChange = useCallback( + (event: React.ChangeEvent) => { + if (event.target.files) { + onFilesSelected(event.target.files).finally(() => { + // Reset the input value to allow re-selecting the same file + // But only after promise has resolded, and so files have been read + event.target.value = ''; + }); + } + }, + [onFilesSelected], + ); + + return ( + <> + + + {children} + + + ); +}); + export type LongPressableControlButtonProps = Omit< ControlButtonFrameProps, 'icon' | 'onClick' | 'touching' diff --git a/packages/sigil/src/frontend/controls/content.tsx b/packages/sigil/src/frontend/controls/content.tsx index 60f4340..b05cc38 100644 --- a/packages/sigil/src/frontend/controls/content.tsx +++ b/packages/sigil/src/frontend/controls/content.tsx @@ -58,11 +58,20 @@ export type ControlLabelProps = ComponentPropsWithoutRef<'label'> & { position?: ControlPosition; disabled?: boolean; nonMicro?: boolean; + vAlign?: 'start' | 'center' | 'end'; }; export const ControlLabel = forwardRef( ( - { className, disabled, nonMicro, position = 'label', subgrid, ...props }, + { + className, + disabled, + nonMicro, + position = 'label', + subgrid, + vAlign = 'center', + ...props + }, ref, ) => { return ( @@ -70,7 +79,10 @@ export const ControlLabel = forwardRef( {...props} ref={ref} className={cn( - 'flex items-center justify-end gap-0.6 p-0.6', + 'flex justify-end gap-0.6 p-0.6', + cnd(vAlign === 'start', 'items-start'), + cnd(vAlign === 'center', 'items-center'), + cnd(vAlign === 'end', 'items-end'), clsControlPosition(position), cnd(nonMicro, 'max-[550px]:hidden'), cnd(disabled, 'opacity-50'), @@ -95,7 +107,7 @@ export const ControlDetails = forwardRef( {...props} ref={ref} className={cn( - 'flex items-center px-0.3 text-sigil-foreground-muted', + 'flex items-center px-0.3 py-0.6 text-sigil-foreground-muted', clsControlPosition(position), cnd(align === 'start', 'justify-start'), cnd(align === 'center', 'justify-center'), diff --git a/packages/sigil/src/frontend/showfiles.tsx b/packages/sigil/src/frontend/showfiles.tsx new file mode 100644 index 0000000..aea5a56 --- /dev/null +++ b/packages/sigil/src/frontend/showfiles.tsx @@ -0,0 +1,606 @@ +import { cn } from '@arcanejs/toolkit-frontend/util'; +import { ComponentProps, FC, useCallback, useState } from 'react'; +import { + clsControlPosition, + ControlButton, + ControlButtonGroup, + ControlDialog, + ControlDialogButtons, + ControlFileButton, + ControlInput, + ControlLabel, + ControlParagraph, + ControlPosition, +} from './controls'; +import { cnd } from './styling'; +import { TooltipBoundary, TooltipWrapper } from './tooltip'; +import { + ActionResponse, + LoadingWrapper, + success, + useUserAction, +} from './user-actions'; + +const ShowFileUuidBrand = Symbol('ShowFileUuid'); + +export type ShowFileUuidBrand = typeof ShowFileUuidBrand; + +export type ShowFileUuid = string & { + _brand: ShowFileUuidBrand; +}; + +export const asShowFileUuid = (uuid: string): ShowFileUuid => { + return uuid as ShowFileUuid; +}; + +export type ShowFileConfigData = { + status: + | { + type: 'no-show-file'; + } + | { + type: 'show-file-loaded'; + hasChanges: boolean; + uuid: ShowFileUuid; + }; + /** + * Map from showfile names to their filename UUIDs + */ + showfiles: Record; + /** + * An error message to display if present + */ + error?: string; +}; + +export type ShowFileConfigStrings = { + unsavedChanges: string; + changesSaved: string; + save: string; + saveAs: string; + load: string; + delete: string; + rename: string; + import: string; + export: string; + searchPlaceholder: string; + noShowFiles: string; + inUse: string; + dialogs: { + cancel: string; + nameLabel: string; + namePlaceholder: string; + emptyNameError: string; + saveAs: { + description: string; + save: string; + }; + saveAsOverwriteConfirmation: { + title: string; + description: string; + overwrite: string; + }; + loadUnsavedChangesConfirmation: { + title: string; + description: string; + load: string; + }; + deleteConfirmation: { + title: string; + description: string; + delete: string; + }; + rename: { + title: string; + description: string; + }; + }; +}; + +export type ShowFileConfigProps = { + mimeType: { + extension: string; + mimeType: string; + }; + data: ShowFileConfigData; + description?: string; + strings: ShowFileConfigStrings; + position?: ControlPosition; + onSave: () => ActionResponse; + onSaveAs: (name: string) => ActionResponse; + onLoad: (uuid: ShowFileUuid) => ActionResponse; + /** + * Return the file content that should be exported for the given showfile. + */ + onExport: (uuid: ShowFileUuid) => Promise; + onDelete: (uuid: ShowFileUuid) => ActionResponse; + onRename: ( + uuid: ShowFileUuid, + newName: string, + ) => ActionResponse; + onImport: (file: FileList) => ActionResponse; +}; + +const Scroll: FC> = ({ + className, + ...props +}) => ( + +); + +type DialogMode = + | { + mode: 'save-as'; + name: string; + error?: string; + } + | { + mode: 'save-as-overwrite-confirmation'; + name: string; + } + | { + mode: 'load-unsaved-changes-confirmation'; + uuid: ShowFileUuid; + } + | { + mode: 'delete-confirmation'; + uuid: ShowFileUuid; + } + | { + mode: 'rename'; + uuid: ShowFileUuid; + name: string; + error?: string; + } + | null; + +const getDialogTitle = ( + mode: DialogMode, + strings: ShowFileConfigStrings, +): string => { + switch (mode?.mode) { + case 'save-as': + return strings.saveAs; + case 'save-as-overwrite-confirmation': + return strings.dialogs.saveAsOverwriteConfirmation.title; + case 'load-unsaved-changes-confirmation': + return strings.dialogs.loadUnsavedChangesConfirmation.title; + case 'delete-confirmation': + return strings.dialogs.deleteConfirmation.title; + case 'rename': + return strings.dialogs.rename.title; + default: + return ''; + } +}; + +export const ShowFileConfig: FC = ({ + mimeType, + data, + description, + strings, + position, + onSave, + onSaveAs, + onExport, + onImport, + onLoad, + onDelete, + onRename, +}) => { + const unsavedChanges = + data.status.type === 'no-show-file' || data.status.hasChanges; + const canSaveCurrentFile = + data.status.type === 'show-file-loaded' && data.status.hasChanges; + + /** When set, it will be a success message to display to the user */ + const [userAction, performAction] = useUserAction(); + + const [dialogMode, setDialogMode] = useState(null); + + const onSaveButtonClicked = useCallback(() => { + performAction(() => onSave()); + }, [onSave, performAction]); + + const onSaveAsButtonClicked = useCallback(() => { + setDialogMode({ mode: 'save-as', name: '' }); + }, []); + + const onExportButtonClicked = useCallback( + (name: string, uuid: ShowFileUuid) => { + performAction(() => + onExport(uuid).then((blob) => { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${name}.${mimeType.extension}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + return success(null); + }), + ); + }, + [performAction, onExport, mimeType.extension], + ); + + const dialogClosed = useCallback(() => { + setDialogMode(null); + }, []); + + const onSaveAsName = useCallback( + (name: string) => { + if (name.trim() === '') { + setDialogMode({ + mode: 'save-as', + name: '', + error: strings.dialogs.emptyNameError, + }); + return; + } + const existingName = Object.keys(data.showfiles).includes(name); + if (existingName) { + setDialogMode({ mode: 'save-as-overwrite-confirmation', name }); + } else { + performAction(() => onSaveAs(name)); + setDialogMode(null); + } + }, + [data.showfiles, onSaveAs, performAction, strings], + ); + + const onSaveAsSubmit = useCallback(() => { + if (dialogMode?.mode !== 'save-as') return; + onSaveAsName(dialogMode.name); + }, [dialogMode, onSaveAsName]); + + const onSaveAsInputChanged = useCallback( + (name: string, enterPressed: boolean) => { + setDialogMode((current) => { + if (!current || current.mode !== 'save-as') return current; + return { ...current, name }; + }); + if (enterPressed) { + onSaveAsName(name); + } + }, + [onSaveAsName], + ); + + const onRenameWithName = useCallback( + (uuid: ShowFileUuid, name: string) => { + if (name.trim() === '') { + setDialogMode({ + mode: 'rename', + uuid, + name: '', + error: strings.dialogs.emptyNameError, + }); + return; + } + performAction(() => onRename(uuid, name)); + setDialogMode(null); + }, + [onRename, performAction, strings], + ); + + const onRenameSubmit = useCallback(() => { + if (dialogMode?.mode !== 'rename') return; + onRenameWithName(dialogMode.uuid, dialogMode.name); + }, [dialogMode, onRenameWithName]); + + const onRenameInputChanged = useCallback( + (name: string, enterPressed: boolean) => { + setDialogMode((current) => { + if (!current || current.mode !== 'rename') return current; + return { ...current, name }; + }); + if (enterPressed && dialogMode?.mode === 'rename') { + onRenameWithName(dialogMode.uuid, name); + } + }, + [dialogMode, onRenameWithName], + ); + + const onImportFilesSelected = useCallback( + (files: FileList) => + performAction(() => onImport(files).then(() => success(null))), + [onImport, performAction], + ); + + const onLoadButtonClicked = useCallback( + (uuid: ShowFileUuid) => { + if (unsavedChanges) { + setDialogMode({ mode: 'load-unsaved-changes-confirmation', uuid }); + } else { + performAction(() => onLoad(uuid)); + } + }, + [unsavedChanges, onLoad, performAction], + ); + + return ( + + {dialogMode && ( + + <> + {dialogMode.mode === 'save-as' && ( + <> + + {strings.dialogs.saveAs.description} + + {strings.dialogs.nameLabel} + + {dialogMode.error && ( + + {dialogMode.error} + + )} + + + {strings.dialogs.cancel} + + + {strings.dialogs.saveAs.save} + + + + )} + {dialogMode.mode === 'save-as-overwrite-confirmation' && ( + <> + + {strings.dialogs.saveAsOverwriteConfirmation.description} + + + + {strings.dialogs.cancel} + + { + performAction(() => onSaveAs(dialogMode.name)); + setDialogMode(null); + }} + variant="large" + destructive + > + {strings.dialogs.saveAsOverwriteConfirmation.overwrite} + + + + )} + {dialogMode.mode === 'load-unsaved-changes-confirmation' && ( + <> + + {strings.dialogs.loadUnsavedChangesConfirmation.description} + + + + {strings.dialogs.cancel} + + { + performAction(() => onLoad(dialogMode.uuid)); + setDialogMode(null); + }} + variant="large" + > + {strings.dialogs.loadUnsavedChangesConfirmation.load} + + + + )} + {dialogMode.mode === 'delete-confirmation' && ( + <> + + {strings.dialogs.deleteConfirmation.description} + + + + {strings.dialogs.cancel} + + { + performAction(() => onDelete(dialogMode.uuid)); + setDialogMode(null); + }} + variant="large" + destructive + > + {strings.dialogs.deleteConfirmation.delete} + + + + )} + {dialogMode.mode === 'rename' && ( + <> + + {strings.dialogs.rename.description} + + {strings.dialogs.nameLabel} + + {dialogMode.error && ( + + {dialogMode.error} + + )} + + + {strings.dialogs.cancel} + + + {strings.rename} + + + + )} + + + )} + {description && ( +
+ {description} +
+ )} +
+ + {strings.saveAs} + + + {strings.save} + + + {unsavedChanges ? strings.unsavedChanges : strings.changesSaved} + +
+ + {strings.import} + +
+ {/* eslint-disable-next-line better-tailwindcss/enforce-consistent-line-wrapping */} + + {Object.keys(data.showfiles).length === 0 && ( +
+ {strings.noShowFiles} +
+ )} +
+ {Object.entries(data.showfiles) + .sort(([nameA], [nameB]) => nameA.localeCompare(nameB)) + .map(([name, uuid]) => ( +
+ + + + + + setDialogMode({ mode: 'delete-confirmation', uuid }) + } + variant="large" + icon="delete" + title={strings.delete} + /> + + setDialogMode({ mode: 'rename', uuid, name }) + } + variant="large" + icon="edit" + title={strings.rename} + /> + onExportButtonClicked(name, uuid)} + variant="table-row" + icon="publish" + title={strings.export} + /> + +
+ ))} +
+
+ {data.error && ( +
+ {data.error} +
+ )} + {userAction.success && userAction.data && ( +
+ {userAction.data} +
+ )} + + ); +}; + +ShowFileConfig.displayName = 'ShowFileConfig'; diff --git a/packages/sigil/src/frontend/styles/sigil.css b/packages/sigil/src/frontend/styles/sigil.css index 8dc4441..94f4c6b 100644 --- a/packages/sigil/src/frontend/styles/sigil.css +++ b/packages/sigil/src/frontend/styles/sigil.css @@ -191,6 +191,29 @@ } } +@utility sigil-grid-table-basic { + display: grid; + grid-template-columns: [name-start] 1fr [name-end controls-start] max-content [controls-end]; + grid-gap: var(--sigil-control-gap); + font-size: 0.7rem; +} + +@utility sigil-grid-table-basic-subgrid { + display: grid; + grid-area: auto / name-start / span 1 / controls-end; + grid-template-columns: subgrid; +} + +@utility sigil-grid-pos-name { + grid-column: name-start / name-end; + grid-row: auto / span 1; +} + +@utility sigil-grid-pos-controls { + grid-column: controls-start / controls-end; + grid-row: auto / span 1; +} + .arcane-theme-root { /* * Any component-specific styling variables diff --git a/packages/sigil/src/frontend/user-actions.tsx b/packages/sigil/src/frontend/user-actions.tsx index 2fc8cd6..1310800 100644 --- a/packages/sigil/src/frontend/user-actions.tsx +++ b/packages/sigil/src/frontend/user-actions.tsx @@ -1,10 +1,26 @@ -import { FC } from 'react'; +import { FC, useCallback, useMemo, useState, useTransition } from 'react'; import { cn } from '@arcanejs/toolkit-frontend/util'; import { Spinner } from './spinner'; import { Alert, AlertDescription, AlertTitle } from './alert'; import { Icon } from '@arcanejs/toolkit-frontend/components/core'; import { cnd } from './styling'; +export type ActionResponse = Promise< + | { + success: true; + data: T; + } + | { + success: false; + title: string; + details?: string; + } +>; + +export const success = (data: T): Awaited> => { + return { success: true, data }; +}; + export type InternalUserActionState = | { type: 'idle' | 'loading'; @@ -82,6 +98,66 @@ export const prepareUserActionsState = ( } }; +export const mapUserActionState = ( + state: UserActionState, + transform: (data: T) => U, +): UserActionState => { + if (state.success) { + return { + idle: false, + success: true, + data: transform(state.data), + loading: false, + error: false, + }; + } + return state as UserActionState; +}; + +export const useUserAction = (initial?: T) => { + const [state, setState] = useState>( + initial ? { type: 'success', data: initial } : { type: 'idle' }, + ); + + const [isPending, startTransition] = useTransition(); + + const performAction = useCallback( + async (action: () => ActionResponse) => + startTransition(async () => { + try { + const result = await action(); + if (!result.success) { + setState({ + type: 'error', + title: result.title, + details: result.details, + }); + return; + } + setState({ type: 'success', data: result.data }); + } catch (error) { + setState({ + type: 'error', + title: 'An unexpected error ocurred', + details: `${error}`, + }); + } + }), + [], + ); + + const reset = useCallback(() => { + setState({ type: 'idle' }); + }, []); + + const returnState = useMemo( + () => prepareUserActionsState(state, isPending), + [state, isPending], + ); + + return [returnState, performAction, reset] as const; +}; + type UserActionAlertProps = { action: UserActionState; displaySuccess?: boolean; diff --git a/packages/sigil/tsup.config.ts b/packages/sigil/tsup.config.ts index a12dbcf..58163b3 100644 --- a/packages/sigil/tsup.config.ts +++ b/packages/sigil/tsup.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ 'src/frontend/dialogs.tsx', 'src/frontend/input.ts', 'src/frontend/preferences.ts', + 'src/frontend/showfiles.tsx', 'src/frontend/spinner.tsx', 'src/frontend/styling.ts', 'src/frontend/styling.hooks.ts',