diff --git a/package.json b/package.json index 1c89b08b..ea1b82cd 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dev": "bun --env-file=.env --watch src/server.tsx", "start": "NODE_ENV=production bun src/server.tsx", "prebuild": "bun run ./scripts/prebuild.ts", + "build:web": "bun run ./scripts/build/web.ts", "build:dashboard": "bun run ./scripts/build/dashboard.ts", "build:editor": "bun run ./scripts/build/editor.ts", "build:editor:app": "bun run ./scripts/build/editor.ts --app", diff --git a/scripts/build/web.ts b/scripts/build/web.ts new file mode 100644 index 00000000..d52c552f --- /dev/null +++ b/scripts/build/web.ts @@ -0,0 +1,164 @@ +#!/usr/bin/env bun +import plugin from 'bun-plugin-tailwind'; +import { config as dotenv } from 'dotenv'; +import { existsSync } from 'fs'; +import { rm } from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { buildConfig } from './helpers'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const envPath = path.resolve(__dirname, '../../.env'); +dotenv({ path: envPath }); + +if (process.argv.includes('--help') || process.argv.includes('-h')) { + console.log(` +šŸ—ļø Bun Build Script + +Usage: bun run build.ts [options] + +Common Options: + --outdir Output directory (default: "dist") + --minify Enable minification (or --minify.whitespace, --minify.syntax, etc) + --sourcemap Sourcemap type: none|linked|inline|external + --target Build target: browser|bun|node + --format Output format: esm|cjs|iife + --splitting Enable code splitting + --packages Package handling: bundle|external + --public-path Public path for assets + --env Environment handling: inline|disable|prefix* + --conditions Package.json export conditions (comma separated) + --external External packages (comma separated) + --banner Add banner text to output + --footer Add footer text to output + --define Define global constants (e.g. --define.VERSION=1.0.0) + --help, -h Show this help message + +Example: + bun run build.ts --outdir=dist --minify --sourcemap=linked --external=react,react-dom +`); + process.exit(0); +} + +const toCamelCase = (str: string): string => str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); + +const parseValue = (value: string): unknown => { + if (value === 'true') return true; + if (value === 'false') return false; + + if (/^\d+$/.test(value)) return parseInt(value, 10); + if (/^\d*\.\d+$/.test(value)) return parseFloat(value); + + if (value.includes(',')) return value.split(',').map((v) => v.trim()); + + return value; +}; + +function parseArgs(): Partial { + const config: Record = {}; + const args = process.argv.slice(2); + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === undefined) continue; + if (!arg.startsWith('--')) continue; + + if (arg.startsWith('--no-')) { + const key = toCamelCase(arg.slice(5)); + config[key] = false; + continue; + } + + if (!arg.includes('=') && (i === args.length - 1 || args[i + 1]?.startsWith('--'))) { + const key = toCamelCase(arg.slice(2)); + config[key] = true; + continue; + } + + let key: string; + let value: string; + + if (arg.includes('=')) { + [key, value] = arg.slice(2).split('=', 2) as [string, string]; + } else { + key = arg.slice(2); + value = args[++i] ?? ''; + } + + key = toCamelCase(key); + + if (key.includes('.')) { + const [parentKey, childKey] = key.split('.'); + if (parentKey && childKey) { + config[parentKey] = config[parentKey] || {}; + (config[parentKey] as Record)[childKey] = parseValue(value); + } + } else { + config[key] = parseValue(value); + } + } + + return config as Partial; +} + +const formatFileSize = (bytes: number): string => { + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(2)} ${units[unitIndex]}`; +}; + +console.log('\nšŸš€ Starting build process...\n'); + +const cliConfig = parseArgs(); +const outdir = cliConfig.outdir || path.join(process.cwd(), 'dist'); + +if (existsSync(outdir)) { + console.log(`šŸ—‘ļø Cleaning previous build at ${outdir}`); + await rm(outdir, { recursive: true, force: true }); +} + +const start = performance.now(); + +const configPath = 'src/workspaces/config/src/index.ts'; +if (existsSync(configPath)) { + console.log('šŸ”§ Generating config with environment values...'); + buildConfig(configPath, 'officer-web'); +} + +const entrypoints = [...new Bun.Glob('**.html').scanSync('src/apps/officer-web')] + .map((a) => path.resolve('src/apps/officer-web', a)) + .filter((dir) => !dir.includes('node_modules')); +console.log(`šŸ“„ Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? 'file' : 'files'} to process\n`); + +const result = await Bun.build({ + entrypoints, + outdir, + plugins: [plugin], + minify: true, + target: 'browser', + sourcemap: 'linked', + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + ...cliConfig, +}); + +const end = performance.now(); + +const outputTable = result.outputs.map((output) => ({ + File: path.relative(process.cwd(), output.path), + Type: output.kind, + Size: formatFileSize(output.size), +})); + +console.table(outputTable); +const buildTime = (end - start).toFixed(2); + +console.log(`\nāœ… Build completed in ${buildTime}ms\n`); diff --git a/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx b/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx index 9dc2ed26..81cd9405 100644 --- a/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx +++ b/src/apps/officer-web/Screens/Authentication/LandingPage/Login.tsx @@ -10,8 +10,8 @@ import { useAuth } from 'hooks/useAuth'; import { useGlobal } from 'hooks/useGlobal'; const initialState: LoginFormState = { - email: 'pastilhas@pastilhas.dev', - password: '1234567890', + // email: 'pastilhas@pastilhas.dev', + // password: '1234567890', }; export function Login() { const [isSubmitting, setIsSubmitting] = useState(false); diff --git a/src/apps/officer-web/Screens/Dashboard/Automation/index.tsx b/src/apps/officer-web/Screens/Dashboard/Automation/index.tsx index 6c028f8d..d4bdd224 100644 --- a/src/apps/officer-web/Screens/Dashboard/Automation/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Automation/index.tsx @@ -1,7 +1,8 @@ -import { useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useClient } from 'hooks/useClient'; import { useAuth } from 'hooks/useAuth'; +import { useIsMobile } from 'hooks/useIsMobile'; import { usePanelChannel } from 'hooks/usePanelChannel'; import type { LayoutNode, PanelComponents } from 'officerdev'; import { WorkspaceLayout } from 'officerdev'; @@ -55,7 +56,8 @@ export const Automation = () => { staleTime: Infinity, }); const [savedSizes, setSavedSizes] = useUserState('automation:chat-sizes', null); - const [selection] = usePanelChannel('automation:selected-capability', null); + const [selection, setSelection] = usePanelChannel('automation:selected-capability', null); + const isMobile = useIsMobile(); const editing = selection?.editing ?? false; const prevEditing = useRef(editing); @@ -94,11 +96,21 @@ export const Automation = () => { [], ); + const mobilePanelId = isMobile && selection ? 'automation-right' : undefined; + const onMobileBack = useCallback(() => setSelection(null), [setSelection]); + if (!isFetched) return null; return (
- +
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index 1f6c0ff7..1b8d322b 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo } from 'react'; -import { useParams } from 'react-router'; +import { useParams, useNavigate } from 'react-router'; import type { LayoutNode, SelectedSession } from 'officerdev'; import { WorkspaceView } from 'officerdev'; +import { useIsMobile } from 'hooks/useIsMobile'; import { useWorkspacesState } from 'state/useWorkspacesState'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { useChatSessions } from 'state/useChatSessions'; @@ -37,6 +38,9 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => { const { sessions } = useChatSessions(); const [, setSelected] = usePanelChannel('chat:selected-session', null); const rawWorkspace = useWorkspacesState('screens/chat', defaultLayout); + const isMobile = useIsMobile(); + const navigate = useNavigate(); + const mobilePanelId = isMobile && (sessionId || isNew) ? 'chat-detail' : undefined; // Normalize synchronously so the wrong panel never renders const workspace = useMemo(() => { @@ -64,7 +68,13 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => { return (
- + { + if (!id) navigate('/chat', { replace: true }); + }} + />
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx index a5c635c5..6b561d0f 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx @@ -11,10 +11,10 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { return (
-
+
- +
{children}
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx index 98eb24a4..0a60c03a 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx @@ -1,23 +1,81 @@ -import { Link } from 'react-router'; +import { useState } from 'react'; +import { Link, useLocation } from 'react-router'; +import { Menu } from 'lucide-react'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { useIsMobile } from 'hooks/useIsMobile'; +import type { DockItem } from '../Dock'; import { UserMenu } from './UserMenu'; -export function Header() { +type HeaderProps = { + dockItems?: DockItem[]; +}; + +export function Header({ dockItems }: HeaderProps) { + const [open, setOpen] = useState(false); + const isMobile = useIsMobile(); + const location = useLocation(); + + const isActive = (to: string) => (to === '/' ? location.pathname === '/' : location.pathname.startsWith(to)); + return (
- - Officer - +
+ {isMobile && dockItems && ( + + )} + + Officer + +
+ + {isMobile && dockItems && ( + + + + Navigation + + + + + )}
); } diff --git a/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx index ecfebbe6..eb771730 100644 --- a/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router'; import { useGlobal } from 'hooks/useGlobal'; +import { useIsMobile } from 'hooks/useIsMobile'; import { useWorkspacesState } from 'state/useWorkspacesState'; import type { LayoutNode, ProjectType } from 'officerdev'; import { @@ -20,10 +21,20 @@ import { defaultLayout } from './defaultLayout'; export const ProjectListScreen = () => { const workspace = useWorkspacesState('screens/projects', defaultLayout); + const isMobile = useIsMobile(); + const [selected, setSelected] = useGlobal(SELECTED_PROJECT, null); + const [creating] = useGlobal(CREATING_PROJECT, false); + const mobilePanelId = isMobile && (selected || creating) ? 'proj-home-right' : undefined; return (
- + { + if (!id) setSelected(null); + }} + />
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspacesScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspacesScreen.tsx index a71af450..a783c17f 100644 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspacesScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspacesScreen.tsx @@ -1,14 +1,25 @@ import type { LayoutNode } from 'officerdev'; -import { WorkspaceView } from 'officerdev'; +import { WorkspaceView, SELECTED_WORKSPACE_KEY } from 'officerdev'; +import { useIsMobile } from 'hooks/useIsMobile'; +import { useGlobal } from 'hooks/useGlobal'; import { useWorkspacesState } from 'state/useWorkspacesState'; import { defaultLayout } from './defaultLayout'; export const WorkspacesScreen = () => { const workspace = useWorkspacesState('screens/workspaces', defaultLayout); + const isMobile = useIsMobile(); + const [selected, setSelected] = useGlobal(SELECTED_WORKSPACE_KEY, null); + const mobilePanelId = isMobile && selected ? 'ws-home-right' : undefined; return (
- + { + if (!id) setSelected(null); + }} + />
); }; diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index 315c3cb7..0bc4fa43 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -1,6 +1,6 @@ import { createRouter } from '@@/create-router'; import { resolve, dirname, join, parse as parsePath } from 'node:path'; -import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises'; +import { readdir, stat, mkdir, rm, rename, readFile, cp, unlink } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { getHomeDir, DATA_PATH, getUserSettingsFile } from '@@/data-path'; @@ -221,8 +221,7 @@ router.get('/raw', async (ctx) => { }); }); -// Transcode video via ffmpeg for non-native browser formats (mkv, avi, wmv, etc.) -// Outputs fragmented MP4 streamed to the client +// Transcode video via ffmpeg with caching — outputs a seekable MP4 file router.get('/transcode', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); @@ -233,37 +232,86 @@ router.get('/transcode', async (ctx) => { const s = await stat(absPath); if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcode a directory'); - const startTime = ctx.req.query('t') || '0'; + const userDataDir = getUserDataDir(user.email); + const { dir, name } = parsePath(relPath); + const cacheRel = dir ? `video/${dir}/${name}.mp4` : `video/${name}.mp4`; + const cacheAbs = resolve(userDataDir, cacheRel); + + if (!existsSync(cacheAbs)) { + await mkdir(dirname(cacheAbs), { recursive: true }); + const tmpPath = cacheAbs + '.tmp'; + + const proc = Bun.spawn( + [ + 'ffmpeg', '-i', absPath, + '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23', + '-c:a', 'aac', '-b:a', '128k', + '-movflags', '+faststart', + '-y', tmpPath, + ], + { stdout: 'ignore', stderr: 'pipe' }, + ); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + await unlink(tmpPath).catch(() => {}); + throw errors.BAD_REQUEST(stderr.trim() || 'Video transcoding failed'); + } + + await rename(tmpPath, cacheAbs); + } + + const file = Bun.file(cacheAbs); + const total = file.size; + const rangeHeader = ctx.req.header('range'); + + if (rangeHeader) { + const match = rangeHeader.match(/bytes=(\d*)-(\d*)/); + if (match) { + const start = match[1] ? parseInt(match[1], 10) : 0; + const end = match[2] ? parseInt(match[2], 10) : total - 1; + const chunkSize = end - start + 1; + return new Response(file.slice(start, end + 1), { + status: 206, + headers: { + 'Content-Type': 'video/mp4', + 'Content-Range': `bytes ${start}-${end}/${total}`, + 'Content-Length': String(chunkSize), + 'Accept-Ranges': 'bytes', + }, + }); + } + } + + return new Response(file, { + headers: { + 'Content-Type': 'video/mp4', + 'Content-Length': String(total), + 'Accept-Ranges': 'bytes', + }, + }); +}); + +// Transcode audio via ffmpeg for universal playback (outputs MP3) +router.get('/transcode-audio', async (ctx) => { + const user = ctx.get('user'); + const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); + const relPath = (ctx.req.query('path') || '').replace(/^\/+/, ''); + if (!relPath) throw errors.BAD_REQUEST('path is required'); + + const absPath = resolveUserPath(rootDir, relPath); + const s = await stat(absPath); + if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcode a directory'); const proc = Bun.spawn( - [ - 'ffmpeg', - '-ss', - startTime, - '-i', - absPath, - '-c:v', - 'libx264', - '-preset', - 'ultrafast', - '-crf', - '23', - '-c:a', - 'aac', - '-b:a', - '128k', - '-movflags', - 'frag_mp4+empty_moov+default_base_moof', - '-f', - 'mp4', - 'pipe:1', - ], + ['ffmpeg', '-i', absPath, '-c:a', 'libmp3lame', '-q:a', '2', '-f', 'mp3', 'pipe:1'], { stdout: 'pipe', stderr: 'ignore' }, ); return new Response(proc.stdout as ReadableStream, { headers: { - 'Content-Type': 'video/mp4', + 'Content-Type': 'audio/mpeg', 'Transfer-Encoding': 'chunked', }, }); diff --git a/src/servers/api/pi/rest.ts b/src/servers/api/pi/rest.ts index 26738af6..bce1ed7b 100644 --- a/src/servers/api/pi/rest.ts +++ b/src/servers/api/pi/rest.ts @@ -2,6 +2,7 @@ import type { Context } from 'hono'; import { createRouter } from '../../create-router'; import * as storage from './storage'; import { readApiKeys, readLocalProviders } from '../server-settings/pi-mono'; +import { readSttConfig } from '../server-settings/stt'; import { listPiModels } from './list-models'; import { getHomeDir } from '../../data-path'; import { resolveBaseCwd } from './websocket'; @@ -497,3 +498,42 @@ piRestRouter.post('/pi/sessions/:sessionId/move', async (ctx: Context) => { return ctx.json({ error: 'Failed to move session' }, 500); } }); + +/** + * POST /api/pi/stt + * Proxy audio to configured Whisper server for speech-to-text transcription. + * Accepts multipart form data with audio file + whisper params. + */ +piRestRouter.post('/pi/stt', async (ctx: Context) => { + const sttConfig = await readSttConfig(); + if (!sttConfig?.url) { + return ctx.json({ error: 'Whisper not configured — set it up in Settings → Speech to Text' }, 400); + } + + const body = await ctx.req.parseBody(); + const file = body['file']; + if (!file || !(file instanceof File)) { + return ctx.json({ error: 'file is required' }, 400); + } + + const formData = new FormData(); + formData.append('file', file, 'recording.wav'); + formData.append('temperature', String(body['temperature'] ?? '0.0')); + formData.append('temperature_inc', String(body['temperature_inc'] ?? '0.2')); + formData.append('response_format', String(body['response_format'] ?? 'json')); + + try { + const res = await fetch(`${sttConfig.url.replace(/\/+$/, '')}/inference`, { + method: 'POST', + body: formData, + }); + if (!res.ok) { + return ctx.json({ error: `Whisper returned ${res.status}` }, 502); + } + const json = await res.json(); + return ctx.json(json); + } catch (err) { + logger.error('STT proxy failed', { error: String(err) }); + return ctx.json({ error: 'Failed to reach Whisper server' }, 502); + } +}); diff --git a/src/workspaces/officerdev/src/apps/Chat/useAudioRecording.ts b/src/workspaces/officerdev/src/apps/Chat/useAudioRecording.ts index 65b780ad..cee69d39 100644 --- a/src/workspaces/officerdev/src/apps/Chat/useAudioRecording.ts +++ b/src/workspaces/officerdev/src/apps/Chat/useAudioRecording.ts @@ -46,7 +46,12 @@ export function useAudioRecording(onTranscription: (text: string) => void) { formData.append('temperature_inc', '0.2'); formData.append('response_format', 'json'); - const res = await fetch('http://macmini:8178/inference', { method: 'POST', body: formData }); + const token = localStorage.getItem('BEARER_TOKEN') ?? sessionStorage.getItem('BEARER_TOKEN'); + const res = await fetch('/api/pi/stt', { + method: 'POST', + body: formData, + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); if (!res.ok) throw new Error(`Whisper returned ${res.status}`); const json = await res.json(); if (json.error) throw new Error(json.error); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx index 7d5f2d7e..fe691fd4 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx @@ -586,13 +586,7 @@ export const FileItem = ({ style={ selected ? undefined - : cardStyle({ - backgroundColor: 'rgba(255, 255, 255, 0.85)', - backgroundImage: ` - linear-gradient(to right, rgba(20, 83, 45, 0.06) 1px, transparent 1px), - linear-gradient(to bottom, rgba(20, 83, 45, 0.06) 1px, transparent 1px) - `, - }) + : cardStyle() } onClick={handleClick} onDoubleClick={handleDoubleClick} diff --git a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerBody.tsx b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerBody.tsx index 47801dee..44608c0f 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerBody.tsx +++ b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerBody.tsx @@ -52,7 +52,11 @@ export const FileViewerBody = () => { ) : fileType === 'image' ? ( ) : fileType === 'video' ? ( - + ) : fileType === 'audio' ? ( ) : content !== null && editing && isJson ? ( diff --git a/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts b/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts index 203386c9..088ff119 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts +++ b/src/workspaces/officerdev/src/apps/FileViewer/file-types.ts @@ -147,11 +147,18 @@ export function getRawUrl(filePath: string, root?: string): string { return `${API_URL}/file-browser/raw?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}`; } -export function getTranscodeUrl(filePath: string, root?: string, t = '0'): string { +export function getTranscodeUrl(filePath: string, root?: string): string { const headers = getHeaders(); const token = headers['Authorization']?.replace('Bearer ', '') ?? ''; const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : ''; - return `${API_URL}/file-browser/transcode?path=${encodeURIComponent(filePath)}&t=${encodeURIComponent(t)}&token=${encodeURIComponent(token)}${rootParam}`; + return `${API_URL}/file-browser/transcode?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}`; +} + +export function getTranscodeAudioUrl(filePath: string, root?: string): string { + const headers = getHeaders(); + const token = headers['Authorization']?.replace('Bearer ', '') ?? ''; + const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : ''; + return `${API_URL}/file-browser/transcode-audio?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}`; } export function getArchiveBaseName(name: string): string { diff --git a/src/workspaces/officerdev/src/apps/FileViewer/renderers/VideoRenderer.tsx b/src/workspaces/officerdev/src/apps/FileViewer/renderers/VideoRenderer.tsx index 0009612e..be7613a1 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/renderers/VideoRenderer.tsx +++ b/src/workspaces/officerdev/src/apps/FileViewer/renderers/VideoRenderer.tsx @@ -6,9 +6,10 @@ import { useSeekBar, SeekBar } from './SeekBar'; type VideoRendererProps = { src: string; fileName: string; + fallbackSrc?: string; }; -export const VideoRenderer = ({ src, fileName }: VideoRendererProps) => { +export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps) => { const videoRef = useRef(null); const containerRef = useRef(null); const hideTimer = useRef>(null); @@ -34,7 +35,14 @@ export const VideoRenderer = ({ src, fileName }: VideoRendererProps) => { const onPlay = () => setPlaying(true); const onPause = () => setPlaying(false); const onEnded = () => setPlaying(false); - const onError = () => setError(true); + const onError = () => { + if (fallbackSrc && v.src !== fallbackSrc) { + v.src = fallbackSrc; + v.load(); + } else { + setError(true); + } + }; v.addEventListener('loadedmetadata', onLoaded); v.addEventListener('timeupdate', onTime); v.addEventListener('play', onPlay); diff --git a/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx b/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx index 1db5aa33..4a6eca91 100644 --- a/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx +++ b/src/workspaces/officerdev/src/components/Workspace/PanelSlot.tsx @@ -1,7 +1,7 @@ import type { ComponentType } from 'react'; import { useCallback } from 'react'; import { createPortal } from 'react-dom'; -import { ArrowLeftRight, X, Minus } from 'lucide-react'; +import { ArrowLeftRight, ChevronLeft, X, Minus } from 'lucide-react'; import type { LayoutPanel, AppRegistry, PanelComponents, PanelComponentEntry } from './types'; import { useWorkspace } from './WorkspaceContext'; import { Card } from '@/components/Card'; @@ -183,7 +183,7 @@ const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId // }; export const PanelSlot = ({ panel, registry, components, interactive, noHeader, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => { - const { maximizedPanelId, transitioningPanelId } = useWorkspace(); + const { maximizedPanelId, transitioningPanelId, isMobile, onMobileBack } = useWorkspace(); const isMaximized = maximizedPanelId === panel.id; const rawPanelComponent = components?.[panel.id]; @@ -253,14 +253,26 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader, const ResolvedHeader = HeaderComponent ?? DefaultHeader; - const trafficLights = interactive ? ( + const trafficLights = interactive && !isMobile ? ( onSetApp(panel.id, null)} /> ) : null; + const mobileBackButton = isMobile && onMobileBack ? ( + + ) : null; + const headerContent = (
+ {mobileBackButton} {ResolvedHeader && } - {onClose && ( + {!mobileBackButton && onClose && (