diff --git a/src/workspaces/officerdev/src/apps/FileViewer/FileCarousel.tsx b/src/workspaces/officerdev/src/apps/FileViewer/FileCarousel.tsx new file mode 100644 index 00000000..cb0744df --- /dev/null +++ b/src/workspaces/officerdev/src/apps/FileViewer/FileCarousel.tsx @@ -0,0 +1,165 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI'; +import { getFileType, getThumbUrl } from './file-types'; +import { useFileViewer } from './FileViewerContext'; + +/** + * Move between the files in a folder without going back to the listing. + * + * ── Which files ── + * + * Everything in the same folder the viewer can SHOW — images, video, audio, pdf, markdown, code, text. + * Not directories and not archives: opening one is an extraction rather than a view, so putting them in + * a strip of things you step through would offer a step that does something else entirely. + * + * ── It lists the folder itself ── + * + * The viewer is given one path and nothing else, and it is rendered by two different screens that reach + * it in two different ways. Listing the folder here means neither has to pass a collection, and the + * strip is right the moment a file is opened from anywhere — a link somebody sent, a search result, or + * the grid. + * + * ── The thumbnails are the file browser's ── + * + * Same `/thumb` route, same cache, same size the grid asks for. So a folder already browsed has a warm + * strip, and a HEIC in it works exactly where it works elsewhere rather than being a second thing to + * teach about formats. + */ + +const STEPPABLE = new Set(['image', 'video', 'audio', 'pdf', 'markdown', 'code', 'text']); + +export const FileCarousel = ({ onClose }: { onClose?: () => void }) => { + const { filePath, root, onNavigate } = useFileViewer(); + const files = useFilesAPI(root || 'home'); + const [entries, setEntries] = useState([]); + + const folder = filePath.slice(0, filePath.lastIndexOf('/')) || '/'; + + useEffect(() => { + let live = true; + void files + .listDir(folder) + .then((data) => { + if (live) setEntries(data.entries ?? []); + }) + .catch(() => { + // A folder that cannot be listed simply gets no strip. The file itself is already open and + // readable; failing loudly here would break a working view over its navigation. + if (live) setEntries([]); + }); + return () => { + live = false; + }; + // `folder` only: re-listing on every file change would refetch the same directory for each step. + }, [folder]); + + const siblings = useMemo( + () => + entries + .filter((e) => e.type === 'file' && STEPPABLE.has(getFileType(e.name))) + .sort((a, b) => a.name.localeCompare(b.name)), + [entries], + ); + + const index = siblings.findIndex((e) => `${folder === '/' ? '' : folder}/${e.name}` === filePath); + const go = useCallback( + (delta: number) => { + if (index === -1 || !onNavigate) return; + const next = siblings[index + delta]; + if (next) onNavigate(`${folder === '/' ? '' : folder}/${next.name}`); + }, + [index, siblings, folder, onNavigate], + ); + + /* + Keyboard, on the window. + + Ignored while somebody is typing — the viewer can be EDITING a file, and stealing arrow keys from a + text area would make the editor unusable. `isContentEditable` catches the rich cases that are not + inputs. + */ + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || target?.isContentEditable) return; + + if (event.key === 'Escape' && onClose) { + event.preventDefault(); + onClose(); + } else if (event.key === 'ArrowLeft') { + event.preventDefault(); + go(-1); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + go(1); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [go, onClose]); + + // One file in the folder is not a carousel. The strip would be a row containing the thing already on + // screen, and two arrows that do nothing. + if (!onNavigate || siblings.length < 2) return null; + + return ( +
+ go(-1)} /> + +
+ {siblings.map((entry, i) => { + const path = `${folder === '/' ? '' : folder}/${entry.name}`; + const active = i === index; + return ( + + ); + })} +
+ + = siblings.length - 1} onClick={() => go(1)} /> +
+ ); +}; + +const Caret = ({ side, disabled, onClick }: { side: 'left' | 'right'; disabled: boolean; onClick: () => void }) => { + const Icon = side === 'left' ? ChevronLeft : ChevronRight; + return ( + + ); +}; diff --git a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerContext.tsx b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerContext.tsx index e245b31d..5f71d563 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerContext.tsx +++ b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerContext.tsx @@ -5,6 +5,14 @@ export type FileViewerContextValue = { filePath: string; fileName: string; root: string; + /** + * Show a different file, without leaving the viewer. Absent when the host cannot express it. + * + * Each host writes it differently — the Files screen puts a name in `?view=`, an embedded panel puts a + * path — so the viewer asks rather than deciding, and a host that offers nothing simply gets no + * carousel. + */ + onNavigate?: (filePath: string) => void; fileType: FileType; content: string | null; loading: boolean; diff --git a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerProvider.tsx b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerProvider.tsx index a70d2cdb..500a07d5 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerProvider.tsx +++ b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerProvider.tsx @@ -13,6 +13,8 @@ type FileViewerProviderProps = { root?: string; content?: string; onOpenFile?: (filePath: string, root: string) => void; + /** Show a different file in the same viewer — what the carousel calls. */ + onNavigate?: (filePath: string) => void; onReplaceView?: (viewPath: string, viewRoot: string, ephemeralPath: string, ephemeralRoot: string) => void; autoPlay?: boolean; children: ReactNode; @@ -45,6 +47,7 @@ export const FileViewerProvider = ({ root = 'home', content: directContent, onOpenFile, + onNavigate, onReplaceView, autoPlay = false, children, @@ -171,6 +174,7 @@ export const FileViewerProvider = ({ void; + /** Enables the carousel. Without it the viewer shows one file and no strip. */ + onNavigate?: (filePath: string) => void; }; -export const FileViewerView = ({ filePath, fileName, root, content, onClose }: FileViewerViewProps) => ( - +export const FileViewerView = ({ filePath, fileName, root, content, onClose, onNavigate }: FileViewerViewProps) => ( +
@@ -28,6 +31,11 @@ export const FileViewerView = ({ filePath, fileName, root, content, onClose }: F
+ + {/* Below the content, outside the scrolling area — a strip that scrolled away with a long + markdown file would be a control you have to go looking for. Renders nothing when the folder + holds one viewable file, or when the host gave no `onNavigate`. */} +
); diff --git a/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts b/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts index 24b56036..66baedd0 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts +++ b/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts @@ -160,6 +160,19 @@ export function getRawUrl(filePath: string, root?: string, audioTrack?: number): return `${API_URL}/file-browser/raw?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}${audioParam}`; } +/** + * The file browser's own thumbnail, at the size its grid asks for. + * + * Same route, same cache, same size — so a folder already browsed has a warm carousel strip, and a + * format that needs a plugin to decode works here exactly where it works there. + */ +export function getThumbUrl(filePath: string, root?: string, size = 256): string { + const headers = getHeaders(); + const token = headers['Authorization']?.replace('Bearer ', '') ?? ''; + const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : ''; + return `${API_URL}/file-browser/thumb?path=${encodeURIComponent(filePath)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}`; +} + export function getAudioTracksUrl(filePath: string, root?: string): string { const headers = getHeaders(); const token = headers['Authorization']?.replace('Bearer ', '') ?? ''; diff --git a/src/workspaces/officerdev/src/apps/FileViewer/index.ts b/src/workspaces/officerdev/src/apps/FileViewer/index.ts index 5865781c..3a0c7dd7 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/index.ts +++ b/src/workspaces/officerdev/src/apps/FileViewer/index.ts @@ -1,4 +1,5 @@ export { FileViewerView } from './FileViewerView'; +export { FileCarousel } from './FileCarousel'; export { FileViewerProvider } from './FileViewerProvider'; export { FileViewerHeader } from './FileViewerHeader'; export { FileViewerBody } from './FileViewerBody'; diff --git a/src/workspaces/officerdev/src/hooks/useFileViewerPanels/Providers.tsx b/src/workspaces/officerdev/src/hooks/useFileViewerPanels/Providers.tsx index e82a6f91..7d0d7784 100644 --- a/src/workspaces/officerdev/src/hooks/useFileViewerPanels/Providers.tsx +++ b/src/workspaces/officerdev/src/hooks/useFileViewerPanels/Providers.tsx @@ -53,6 +53,15 @@ export function ViewerProvider({ children }: { children: ReactNode }) { fileName={fileName} onOpenFile={onOpenFile} onReplaceView={onReplaceView(setSearchParams, folderFromPathname(pathname))} + /* The carousel's step. `?view=` here is a NAME within the folder the pathname names — this + screen's own convention — so the absolute path the carousel works in is converted back. */ + onNavigate={(next) => + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + params.set('view', relativeToFolder(folderFromPathname(pathname), next)); + return params; + }) + } > {children} @@ -83,6 +92,15 @@ export function EphemeralProvider({ children }: { children: ReactNode }) { root={ephemeralRoot} onOpenFile={onOpenFile} onReplaceView={onReplaceView(setSearchParams, folderFromPathname(pathname))} + /* The carousel's step. `?view=` here is a NAME within the folder the pathname names — this + screen's own convention — so the absolute path the carousel works in is converted back. */ + onNavigate={(next) => + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + params.set('view', relativeToFolder(folderFromPathname(pathname), next)); + return params; + }) + } > {children} @@ -104,6 +122,15 @@ export function Ephemeral2Provider({ children }: { children: ReactNode }) { root={ephemeral2Root} autoPlay={ephemeral2Auto} onReplaceView={onReplaceView(setSearchParams, folderFromPathname(pathname))} + /* The carousel's step. `?view=` here is a NAME within the folder the pathname names — this + screen's own convention — so the absolute path the carousel works in is converted back. */ + onNavigate={(next) => + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + params.set('view', relativeToFolder(folderFromPathname(pathname), next)); + return params; + }) + } > {children} diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index 46911b13..dac96706 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -66,6 +66,7 @@ export { export type { DirEntry, TaskSummary } from './apps/FileBrowser'; export { FileViewerView, + FileCarousel, FileViewerProvider, FileViewerHeader, FileViewerBody,