mobile
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
"dev": "bun --env-file=.env --watch src/server.tsx",
|
"dev": "bun --env-file=.env --watch src/server.tsx",
|
||||||
"start": "NODE_ENV=production bun src/server.tsx",
|
"start": "NODE_ENV=production bun src/server.tsx",
|
||||||
"prebuild": "bun run ./scripts/prebuild.ts",
|
"prebuild": "bun run ./scripts/prebuild.ts",
|
||||||
|
"build:web": "bun run ./scripts/build/web.ts",
|
||||||
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
|
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
|
||||||
"build:editor": "bun run ./scripts/build/editor.ts",
|
"build:editor": "bun run ./scripts/build/editor.ts",
|
||||||
"build:editor:app": "bun run ./scripts/build/editor.ts --app",
|
"build:editor:app": "bun run ./scripts/build/editor.ts --app",
|
||||||
|
|||||||
@@ -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 <path> Output directory (default: "dist")
|
||||||
|
--minify Enable minification (or --minify.whitespace, --minify.syntax, etc)
|
||||||
|
--sourcemap <type> Sourcemap type: none|linked|inline|external
|
||||||
|
--target <target> Build target: browser|bun|node
|
||||||
|
--format <format> Output format: esm|cjs|iife
|
||||||
|
--splitting Enable code splitting
|
||||||
|
--packages <type> Package handling: bundle|external
|
||||||
|
--public-path <path> Public path for assets
|
||||||
|
--env <mode> Environment handling: inline|disable|prefix*
|
||||||
|
--conditions <list> Package.json export conditions (comma separated)
|
||||||
|
--external <list> External packages (comma separated)
|
||||||
|
--banner <text> Add banner text to output
|
||||||
|
--footer <text> Add footer text to output
|
||||||
|
--define <obj> 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<Bun.BuildConfig> {
|
||||||
|
const config: Record<string, unknown> = {};
|
||||||
|
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<string, unknown>)[childKey] = parseValue(value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
config[key] = parseValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config as Partial<Bun.BuildConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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`);
|
||||||
@@ -10,8 +10,8 @@ import { useAuth } from 'hooks/useAuth';
|
|||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
|
|
||||||
const initialState: LoginFormState = {
|
const initialState: LoginFormState = {
|
||||||
email: 'pastilhas@pastilhas.dev',
|
// email: 'pastilhas@pastilhas.dev',
|
||||||
password: '1234567890',
|
// password: '1234567890',
|
||||||
};
|
};
|
||||||
export function Login() {
|
export function Login() {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
|
import { useIsMobile } from 'hooks/useIsMobile';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||||
import { WorkspaceLayout } from 'officerdev';
|
import { WorkspaceLayout } from 'officerdev';
|
||||||
@@ -55,7 +56,8 @@ export const Automation = () => {
|
|||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
const [savedSizes, setSavedSizes] = useUserState<number[] | null>('automation:chat-sizes', null);
|
const [savedSizes, setSavedSizes] = useUserState<number[] | null>('automation:chat-sizes', null);
|
||||||
const [selection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
const [selection, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const editing = selection?.editing ?? false;
|
const editing = selection?.editing ?? false;
|
||||||
const prevEditing = useRef(editing);
|
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;
|
if (!isFetched) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
<WorkspaceLayout layout={layout} onLayoutChange={handleLayoutChange} components={panelComponents} />
|
<WorkspaceLayout
|
||||||
|
layout={layout}
|
||||||
|
onLayoutChange={handleLayoutChange}
|
||||||
|
components={panelComponents}
|
||||||
|
isMobile={isMobile}
|
||||||
|
mobilePanelId={mobilePanelId}
|
||||||
|
onMobileBack={mobilePanelId ? onMobileBack : null}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import type { LayoutNode, SelectedSession } from 'officerdev';
|
import type { LayoutNode, SelectedSession } from 'officerdev';
|
||||||
import { WorkspaceView } from 'officerdev';
|
import { WorkspaceView } from 'officerdev';
|
||||||
|
import { useIsMobile } from 'hooks/useIsMobile';
|
||||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { useChatSessions } from 'state/useChatSessions';
|
import { useChatSessions } from 'state/useChatSessions';
|
||||||
@@ -37,6 +38,9 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
|||||||
const { sessions } = useChatSessions();
|
const { sessions } = useChatSessions();
|
||||||
const [, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
const [, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
||||||
const rawWorkspace = useWorkspacesState<LayoutNode>('screens/chat', defaultLayout);
|
const rawWorkspace = useWorkspacesState<LayoutNode>('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
|
// Normalize synchronously so the wrong panel never renders
|
||||||
const workspace = useMemo(() => {
|
const workspace = useMemo(() => {
|
||||||
@@ -64,7 +68,13 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full pt-2">
|
<div className="h-full w-full pt-2">
|
||||||
<WorkspaceView workspace={workspace} />
|
<WorkspaceView
|
||||||
|
workspace={workspace}
|
||||||
|
mobilePanelId={mobilePanelId}
|
||||||
|
onMobilePanelChange={(id) => {
|
||||||
|
if (!id) navigate('/chat', { replace: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden h-dvh outline-none inset-0">
|
<div className="relative overflow-hidden h-dvh outline-none inset-0">
|
||||||
<Header />
|
<Header dockItems={visibleItems} />
|
||||||
<section className="relative h-dvh snap-start overflow-hidden">
|
<section className="relative h-dvh snap-start overflow-hidden">
|
||||||
<Background />
|
<Background />
|
||||||
<Dock items={visibleItems} />
|
<Dock items={visibleItems} className="hidden md:flex" />
|
||||||
<div className="absolute inset-0 z-2 pt-[52px] md:pt-[64px] pb-2 overflow-y-auto">
|
<div className="absolute inset-0 z-2 pt-[52px] md:pt-[64px] pb-2 overflow-y-auto">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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';
|
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 (
|
return (
|
||||||
<header className="fixed z-10 w-full">
|
<header className="fixed z-10 w-full">
|
||||||
<div
|
<div
|
||||||
className="shrink-0 border-b backdrop-blur-xl shadow-lg px-3 py-2 md:px-6 md:py-3 flex items-center justify-between"
|
className="shrink-0 border-b backdrop-blur-xl shadow-lg px-3 py-2 md:px-6 md:py-3 flex items-center justify-between"
|
||||||
style={{ backgroundColor: 'rgba(255, 255, 255, 0.25)', borderColor: 'rgba(255, 255, 255, 0.35)' }}
|
style={{ backgroundColor: 'rgba(255, 255, 255, 0.25)', borderColor: 'rgba(255, 255, 255, 0.35)' }}
|
||||||
>
|
>
|
||||||
<Link to="/">
|
<div className="flex items-center gap-2">
|
||||||
<img
|
{isMobile && dockItems && (
|
||||||
src="/static/officer-logo.svg"
|
<button onClick={() => setOpen(true)} className="p-1.5 rounded-md hover:bg-white/20 transition-colors">
|
||||||
alt="Officer"
|
<Menu className="h-5 w-5 text-white" />
|
||||||
className="h-8 md:h-12 w-auto"
|
</button>
|
||||||
style={{ transform: 'skew(-15deg, -2deg)' }}
|
)}
|
||||||
/>
|
<Link to="/">
|
||||||
</Link>
|
<img
|
||||||
|
src="/static/officer-logo.svg"
|
||||||
|
alt="Officer"
|
||||||
|
className="h-8 md:h-12 w-auto"
|
||||||
|
style={{ transform: 'skew(-15deg, -2deg)' }}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isMobile && dockItems && (
|
||||||
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
|
<SheetContent side="left">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Navigation</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<nav className="flex flex-col gap-1 mt-4 px-2">
|
||||||
|
{dockItems.map((item) => {
|
||||||
|
const active = isActive(item.to);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${active ? 'bg-white/20' : 'hover:bg-white/10'}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
|
||||||
|
style={{
|
||||||
|
background: item.color,
|
||||||
|
boxShadow: active ? `0 0 12px ${item.color}40` : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm ${active ? 'text-white font-medium' : 'text-white/80'}`}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router';
|
import { useNavigate, useSearchParams } from 'react-router';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
|
import { useIsMobile } from 'hooks/useIsMobile';
|
||||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||||
import type { LayoutNode, ProjectType } from 'officerdev';
|
import type { LayoutNode, ProjectType } from 'officerdev';
|
||||||
import {
|
import {
|
||||||
@@ -20,10 +21,20 @@ import { defaultLayout } from './defaultLayout';
|
|||||||
|
|
||||||
export const ProjectListScreen = () => {
|
export const ProjectListScreen = () => {
|
||||||
const workspace = useWorkspacesState<LayoutNode>('screens/projects', defaultLayout);
|
const workspace = useWorkspacesState<LayoutNode>('screens/projects', defaultLayout);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [selected, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||||
|
const [creating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||||
|
const mobilePanelId = isMobile && (selected || creating) ? 'proj-home-right' : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<WorkspaceView workspace={workspace} />
|
<WorkspaceView
|
||||||
|
workspace={workspace}
|
||||||
|
mobilePanelId={mobilePanelId}
|
||||||
|
onMobilePanelChange={(id) => {
|
||||||
|
if (!id) setSelected(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
import type { LayoutNode } from 'officerdev';
|
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 { useWorkspacesState } from 'state/useWorkspacesState';
|
||||||
import { defaultLayout } from './defaultLayout';
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
|
||||||
export const WorkspacesScreen = () => {
|
export const WorkspacesScreen = () => {
|
||||||
const workspace = useWorkspacesState<LayoutNode>('screens/workspaces', defaultLayout);
|
const workspace = useWorkspacesState<LayoutNode>('screens/workspaces', defaultLayout);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [selected, setSelected] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
|
||||||
|
const mobilePanelId = isMobile && selected ? 'ws-home-right' : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<WorkspaceView workspace={workspace} />
|
<WorkspaceView
|
||||||
|
workspace={workspace}
|
||||||
|
mobilePanelId={mobilePanelId}
|
||||||
|
onMobilePanelChange={(id) => {
|
||||||
|
if (!id) setSelected(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createRouter } from '@@/create-router';
|
import { createRouter } from '@@/create-router';
|
||||||
import { resolve, dirname, join, parse as parsePath } from 'node:path';
|
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 { existsSync } from 'node:fs';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { getHomeDir, DATA_PATH, getUserSettingsFile } from '@@/data-path';
|
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.)
|
// Transcode video via ffmpeg with caching — outputs a seekable MP4 file
|
||||||
// Outputs fragmented MP4 streamed to the client
|
|
||||||
router.get('/transcode', async (ctx) => {
|
router.get('/transcode', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
|
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
|
||||||
@@ -233,37 +232,86 @@ router.get('/transcode', async (ctx) => {
|
|||||||
const s = await stat(absPath);
|
const s = await stat(absPath);
|
||||||
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcode a directory');
|
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(
|
const proc = Bun.spawn(
|
||||||
[
|
['ffmpeg', '-i', absPath, '-c:a', 'libmp3lame', '-q:a', '2', '-f', 'mp3', 'pipe:1'],
|
||||||
'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',
|
|
||||||
],
|
|
||||||
{ stdout: 'pipe', stderr: 'ignore' },
|
{ stdout: 'pipe', stderr: 'ignore' },
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Response(proc.stdout as ReadableStream, {
|
return new Response(proc.stdout as ReadableStream, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'video/mp4',
|
'Content-Type': 'audio/mpeg',
|
||||||
'Transfer-Encoding': 'chunked',
|
'Transfer-Encoding': 'chunked',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Context } from 'hono';
|
|||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import * as storage from './storage';
|
import * as storage from './storage';
|
||||||
import { readApiKeys, readLocalProviders } from '../server-settings/pi-mono';
|
import { readApiKeys, readLocalProviders } from '../server-settings/pi-mono';
|
||||||
|
import { readSttConfig } from '../server-settings/stt';
|
||||||
import { listPiModels } from './list-models';
|
import { listPiModels } from './list-models';
|
||||||
import { getHomeDir } from '../../data-path';
|
import { getHomeDir } from '../../data-path';
|
||||||
import { resolveBaseCwd } from './websocket';
|
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);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -46,7 +46,12 @@ export function useAudioRecording(onTranscription: (text: string) => void) {
|
|||||||
formData.append('temperature_inc', '0.2');
|
formData.append('temperature_inc', '0.2');
|
||||||
formData.append('response_format', 'json');
|
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}`);
|
if (!res.ok) throw new Error(`Whisper returned ${res.status}`);
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (json.error) throw new Error(json.error);
|
if (json.error) throw new Error(json.error);
|
||||||
|
|||||||
+1
-7
@@ -586,13 +586,7 @@ export const FileItem = ({
|
|||||||
style={
|
style={
|
||||||
selected
|
selected
|
||||||
? undefined
|
? undefined
|
||||||
: cardStyle({
|
: 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)
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
|
|||||||
@@ -52,7 +52,11 @@ export const FileViewerBody = () => {
|
|||||||
) : fileType === 'image' ? (
|
) : fileType === 'image' ? (
|
||||||
<ImageRenderer src={getRawUrl(filePath, root)} fileName={fileName} />
|
<ImageRenderer src={getRawUrl(filePath, root)} fileName={fileName} />
|
||||||
) : fileType === 'video' ? (
|
) : fileType === 'video' ? (
|
||||||
<VideoRenderer src={videoSrc} fileName={fileName} />
|
<VideoRenderer
|
||||||
|
src={videoSrc}
|
||||||
|
fileName={fileName}
|
||||||
|
fallbackSrc={needsTranscode(fileName) ? undefined : getTranscodeUrl(filePath, root)}
|
||||||
|
/>
|
||||||
) : fileType === 'audio' ? (
|
) : fileType === 'audio' ? (
|
||||||
<AudioRenderer src={getRawUrl(filePath, root)} fileName={fileName} autoPlay={autoPlay} />
|
<AudioRenderer src={getRawUrl(filePath, root)} fileName={fileName} autoPlay={autoPlay} />
|
||||||
) : content !== null && editing && isJson ? (
|
) : content !== null && editing && isJson ? (
|
||||||
|
|||||||
@@ -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}`;
|
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 headers = getHeaders();
|
||||||
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
||||||
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
|
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 {
|
export function getArchiveBaseName(name: string): string {
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { useSeekBar, SeekBar } from './SeekBar';
|
|||||||
type VideoRendererProps = {
|
type VideoRendererProps = {
|
||||||
src: string;
|
src: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
fallbackSrc?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VideoRenderer = ({ src, fileName }: VideoRendererProps) => {
|
export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps) => {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const hideTimer = useRef<ReturnType<typeof setTimeout>>(null);
|
const hideTimer = useRef<ReturnType<typeof setTimeout>>(null);
|
||||||
@@ -34,7 +35,14 @@ export const VideoRenderer = ({ src, fileName }: VideoRendererProps) => {
|
|||||||
const onPlay = () => setPlaying(true);
|
const onPlay = () => setPlaying(true);
|
||||||
const onPause = () => setPlaying(false);
|
const onPause = () => setPlaying(false);
|
||||||
const onEnded = () => 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('loadedmetadata', onLoaded);
|
||||||
v.addEventListener('timeupdate', onTime);
|
v.addEventListener('timeupdate', onTime);
|
||||||
v.addEventListener('play', onPlay);
|
v.addEventListener('play', onPlay);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
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 type { LayoutPanel, AppRegistry, PanelComponents, PanelComponentEntry } from './types';
|
||||||
import { useWorkspace } from './WorkspaceContext';
|
import { useWorkspace } from './WorkspaceContext';
|
||||||
import { Card } from '@/components/Card';
|
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) => {
|
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 isMaximized = maximizedPanelId === panel.id;
|
||||||
|
|
||||||
const rawPanelComponent = components?.[panel.id];
|
const rawPanelComponent = components?.[panel.id];
|
||||||
@@ -253,14 +253,26 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader,
|
|||||||
|
|
||||||
const ResolvedHeader = HeaderComponent ?? DefaultHeader;
|
const ResolvedHeader = HeaderComponent ?? DefaultHeader;
|
||||||
|
|
||||||
const trafficLights = interactive ? (
|
const trafficLights = interactive && !isMobile ? (
|
||||||
<TrafficLights panelId={panel.id} isLastPanel={isLastPanel} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)} />
|
<TrafficLights panelId={panel.id} isLastPanel={isLastPanel} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)} />
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
const mobileBackButton = isMobile && onMobileBack ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onMobileBack}
|
||||||
|
className="flex items-center gap-0.5 text-xs font-medium text-black/70 hover:text-black transition-colors cursor-pointer shrink-0"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const headerContent = (
|
const headerContent = (
|
||||||
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-black/10 text-black font-semibold">
|
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-black/10 text-black font-semibold">
|
||||||
|
{mobileBackButton}
|
||||||
{ResolvedHeader && <ResolvedHeader panelId={panel.id} />}
|
{ResolvedHeader && <ResolvedHeader panelId={panel.id} />}
|
||||||
{onClose && (
|
{!mobileBackButton && onClose && (
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer"
|
className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer"
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ type WorkspaceContextValue = {
|
|||||||
maximizedPanelId: string | null;
|
maximizedPanelId: string | null;
|
||||||
setMaximizedPanelId: (id: string | null) => void;
|
setMaximizedPanelId: (id: string | null) => void;
|
||||||
transitioningPanelId: string | null;
|
transitioningPanelId: string | null;
|
||||||
|
isMobile: boolean;
|
||||||
|
onMobileBack: (() => void) | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
@@ -38,6 +40,8 @@ const WorkspaceContext = createContext<WorkspaceContextValue>({
|
|||||||
maximizedPanelId: null,
|
maximizedPanelId: null,
|
||||||
setMaximizedPanelId: noop,
|
setMaximizedPanelId: noop,
|
||||||
transitioningPanelId: null,
|
transitioningPanelId: null,
|
||||||
|
isMobile: false,
|
||||||
|
onMobileBack: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const WorkspaceProvider = WorkspaceContext.Provider;
|
export const WorkspaceProvider = WorkspaceContext.Provider;
|
||||||
|
|||||||
@@ -13,11 +13,14 @@ type WorkspaceLayoutProps = {
|
|||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
noHeader?: boolean;
|
noHeader?: boolean;
|
||||||
|
isMobile?: boolean;
|
||||||
|
mobilePanelId?: string;
|
||||||
|
onMobileBack?: (() => void) | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp, components, workspaceId, cwd, noHeader }: WorkspaceLayoutProps) => {
|
export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp, components, workspaceId, cwd, noHeader, isMobile, mobilePanelId, onMobileBack }: WorkspaceLayoutProps) => {
|
||||||
const { registry: globalRegistry } = useAppRegistry();
|
const { registry: globalRegistry } = useAppRegistry();
|
||||||
const registry = registryProp ?? globalRegistry;
|
const registry = registryProp ?? globalRegistry;
|
||||||
|
|
||||||
@@ -29,12 +32,14 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~', swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop, maximizedPanelId: null, setMaximizedPanelId: noop, transitioningPanelId: null }}>
|
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~', swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop, maximizedPanelId: null, setMaximizedPanelId: noop, transitioningPanelId: null, isMobile: isMobile ?? false, onMobileBack: onMobileBack ?? null }}>
|
||||||
<WorkspaceRenderer
|
<WorkspaceRenderer
|
||||||
layout={layout}
|
layout={layout}
|
||||||
registry={registry}
|
registry={registry}
|
||||||
components={components}
|
components={components}
|
||||||
noHeader={noHeader}
|
noHeader={noHeader}
|
||||||
|
isMobile={isMobile}
|
||||||
|
mobilePanelId={mobilePanelId}
|
||||||
onSetApp={noop}
|
onSetApp={noop}
|
||||||
onSplit={noop}
|
onSplit={noop}
|
||||||
onRemove={noop}
|
onRemove={noop}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ type WorkspaceRendererProps = {
|
|||||||
components?: PanelComponents;
|
components?: PanelComponents;
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
noHeader?: boolean;
|
noHeader?: boolean;
|
||||||
|
isMobile?: boolean;
|
||||||
|
mobilePanelId?: string;
|
||||||
onSetApp: (panelId: string, appType: string | null) => void;
|
onSetApp: (panelId: string, appType: string | null) => void;
|
||||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||||
onRemove: (panelId: string) => void;
|
onRemove: (panelId: string) => void;
|
||||||
@@ -22,6 +24,8 @@ export const WorkspaceRenderer = ({
|
|||||||
components,
|
components,
|
||||||
interactive = false,
|
interactive = false,
|
||||||
noHeader = false,
|
noHeader = false,
|
||||||
|
isMobile = false,
|
||||||
|
mobilePanelId,
|
||||||
onSetApp,
|
onSetApp,
|
||||||
onSplit,
|
onSplit,
|
||||||
onRemove,
|
onRemove,
|
||||||
@@ -37,6 +41,8 @@ export const WorkspaceRenderer = ({
|
|||||||
components={components}
|
components={components}
|
||||||
interactive={interactive}
|
interactive={interactive}
|
||||||
noHeader={noHeader}
|
noHeader={noHeader}
|
||||||
|
isMobile={isMobile}
|
||||||
|
mobilePanelId={mobilePanelId}
|
||||||
totalPanels={totalPanels}
|
totalPanels={totalPanels}
|
||||||
onSetApp={onSetApp}
|
onSetApp={onSetApp}
|
||||||
onSplit={onSplit}
|
onSplit={onSplit}
|
||||||
@@ -53,6 +59,8 @@ type LayoutNodeRendererProps = {
|
|||||||
components?: PanelComponents;
|
components?: PanelComponents;
|
||||||
interactive: boolean;
|
interactive: boolean;
|
||||||
noHeader: boolean;
|
noHeader: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
mobilePanelId?: string;
|
||||||
totalPanels: number;
|
totalPanels: number;
|
||||||
onSetApp: (panelId: string, appType: string | null) => void;
|
onSetApp: (panelId: string, appType: string | null) => void;
|
||||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||||
@@ -65,12 +73,27 @@ const getFixedHeight = (node: LayoutNode, registry: AppRegistry): number | undef
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Find a panel node by id anywhere in the layout tree */
|
||||||
|
const findChildById = (children: { node: LayoutNode; size: number }[], id: string): { node: LayoutNode; size: number } | undefined => {
|
||||||
|
for (const child of children) {
|
||||||
|
if (child.node.id === id) return child;
|
||||||
|
if (child.node.type === 'panel' && child.node.id === id) return child;
|
||||||
|
if (child.node.type === 'group') {
|
||||||
|
const found = findChildById(child.node.children, id);
|
||||||
|
if (found) return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const LayoutNodeRenderer = ({
|
const LayoutNodeRenderer = ({
|
||||||
node,
|
node,
|
||||||
registry,
|
registry,
|
||||||
components,
|
components,
|
||||||
interactive,
|
interactive,
|
||||||
noHeader,
|
noHeader,
|
||||||
|
isMobile,
|
||||||
|
mobilePanelId,
|
||||||
totalPanels,
|
totalPanels,
|
||||||
onSetApp,
|
onSetApp,
|
||||||
onSplit,
|
onSplit,
|
||||||
@@ -111,6 +134,29 @@ const LayoutNodeRenderer = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile: render only one panel at a time for horizontal groups
|
||||||
|
if (isMobile && node.direction === 'horizontal' && node.children.length > 1) {
|
||||||
|
const activeChild = (mobilePanelId && findChildById(node.children, mobilePanelId)) || node.children[0]!;
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<LayoutNodeRenderer
|
||||||
|
node={activeChild.node}
|
||||||
|
registry={registry}
|
||||||
|
components={components}
|
||||||
|
interactive={interactive}
|
||||||
|
noHeader={noHeader}
|
||||||
|
isMobile={isMobile}
|
||||||
|
mobilePanelId={mobilePanelId}
|
||||||
|
totalPanels={totalPanels}
|
||||||
|
onSetApp={onSetApp}
|
||||||
|
onSplit={onSplit}
|
||||||
|
onRemove={onRemove}
|
||||||
|
onResized={onResized}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const hasFixedChild = node.direction === 'vertical' && node.children.some((c) => getFixedHeight(c.node, registry) !== undefined);
|
const hasFixedChild = node.direction === 'vertical' && node.children.some((c) => getFixedHeight(c.node, registry) !== undefined);
|
||||||
|
|
||||||
if (hasFixedChild) {
|
if (hasFixedChild) {
|
||||||
@@ -126,6 +172,8 @@ const LayoutNodeRenderer = ({
|
|||||||
components={components}
|
components={components}
|
||||||
interactive={interactive}
|
interactive={interactive}
|
||||||
noHeader={noHeader}
|
noHeader={noHeader}
|
||||||
|
isMobile={isMobile}
|
||||||
|
mobilePanelId={mobilePanelId}
|
||||||
totalPanels={totalPanels}
|
totalPanels={totalPanels}
|
||||||
onSetApp={onSetApp}
|
onSetApp={onSetApp}
|
||||||
onSplit={onSplit}
|
onSplit={onSplit}
|
||||||
@@ -151,6 +199,8 @@ const LayoutNodeRenderer = ({
|
|||||||
components={components}
|
components={components}
|
||||||
interactive={interactive}
|
interactive={interactive}
|
||||||
noHeader={noHeader}
|
noHeader={noHeader}
|
||||||
|
isMobile={isMobile}
|
||||||
|
mobilePanelId={mobilePanelId}
|
||||||
totalPanels={totalPanels}
|
totalPanels={totalPanels}
|
||||||
onSetApp={onSetApp}
|
onSetApp={onSetApp}
|
||||||
onSplit={onSplit}
|
onSplit={onSplit}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback, useEffect, useRef, type ComponentRef } from 'react';
|
import { useState, useCallback, useEffect, useRef, useMemo, type ComponentRef } from 'react';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable';
|
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable';
|
||||||
|
import { useIsMobile } from 'hooks/useIsMobile';
|
||||||
import type { LayoutNode, WorkspaceState, EphemeralPanels, PanelComponents } from './types';
|
import type { LayoutNode, WorkspaceState, EphemeralPanels, PanelComponents } from './types';
|
||||||
import type { DefaultFileSort } from './WorkspaceContext';
|
import type { DefaultFileSort } from './WorkspaceContext';
|
||||||
import type { DropPosition } from './layout-utils';
|
import type { DropPosition } from './layout-utils';
|
||||||
@@ -17,12 +18,15 @@ type WorkspaceViewProps = {
|
|||||||
defaultFileSort?: DefaultFileSort;
|
defaultFileSort?: DefaultFileSort;
|
||||||
components?: PanelComponents;
|
components?: PanelComponents;
|
||||||
ephemeral?: EphemeralPanels | null;
|
ephemeral?: EphemeralPanels | null;
|
||||||
|
mobilePanelId?: string;
|
||||||
|
onMobilePanelChange?: (id: string | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
export const WorkspaceView = ({ workspace, cwd = '~', root, initialFilePath, defaultFileSort, components, ephemeral }: WorkspaceViewProps) => {
|
export const WorkspaceView = ({ workspace, cwd = '~', root, initialFilePath, defaultFileSort, components, ephemeral, mobilePanelId, onMobilePanelChange }: WorkspaceViewProps) => {
|
||||||
const { registry } = useAppRegistry();
|
const { registry } = useAppRegistry();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const layout = workspace.value;
|
const layout = workspace.value;
|
||||||
const onLayoutChange = workspace.setValue;
|
const onLayoutChange = workspace.setValue;
|
||||||
@@ -125,6 +129,13 @@ export const WorkspaceView = ({ workspace, cwd = '~', root, initialFilePath, def
|
|||||||
}
|
}
|
||||||
}, [isEphemeralOpen, ephemeral?.defaultBaseSize]);
|
}, [isEphemeralOpen, ephemeral?.defaultBaseSize]);
|
||||||
|
|
||||||
|
const onMobileBack = useMemo(() => {
|
||||||
|
if (!isMobile) return null;
|
||||||
|
if (mobilePanelId && onMobilePanelChange) return () => onMobilePanelChange(null);
|
||||||
|
if (isEphemeralOpen && ephemeral?.onClose) return ephemeral.onClose;
|
||||||
|
return null;
|
||||||
|
}, [isMobile, mobilePanelId, onMobilePanelChange, isEphemeralOpen, ephemeral?.onClose]);
|
||||||
|
|
||||||
if (!workspace.isLoaded) return null;
|
if (!workspace.isLoaded) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -144,42 +155,59 @@ export const WorkspaceView = ({ workspace, cwd = '~', root, initialFilePath, def
|
|||||||
maximizedPanelId,
|
maximizedPanelId,
|
||||||
setMaximizedPanelId: setMaximizedAnimated,
|
setMaximizedPanelId: setMaximizedAnimated,
|
||||||
transitioningPanelId,
|
transitioningPanelId,
|
||||||
|
isMobile,
|
||||||
|
onMobileBack,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
{isMobile && isEphemeralOpen && ephemeral ? (
|
||||||
<ResizablePanel defaultSize={100} minSize={15}>
|
<WorkspaceRenderer
|
||||||
<WorkspaceRenderer
|
layout={ephemeral.layout}
|
||||||
layout={layout}
|
registry={registry}
|
||||||
registry={registry}
|
components={ephemeral.components}
|
||||||
components={components}
|
isMobile={isMobile}
|
||||||
interactive
|
onSetApp={noop}
|
||||||
onSetApp={handleSetApp}
|
onSplit={noop}
|
||||||
onSplit={handleSplit}
|
onRemove={noop}
|
||||||
onRemove={handleRemove}
|
onResized={noop}
|
||||||
onResized={handleResized}
|
/>
|
||||||
/>
|
) : (
|
||||||
</ResizablePanel>
|
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
||||||
<ResizableHandle className="bg-transparent after:bg-transparent" disabled={!isEphemeralOpen} />
|
<ResizablePanel defaultSize={100} minSize={15}>
|
||||||
<ResizablePanel
|
|
||||||
ref={ephemeralPanelRef}
|
|
||||||
collapsible
|
|
||||||
collapsedSize={0}
|
|
||||||
defaultSize={0}
|
|
||||||
minSize={15}
|
|
||||||
>
|
|
||||||
{isEphemeralOpen ? (
|
|
||||||
<WorkspaceRenderer
|
<WorkspaceRenderer
|
||||||
layout={ephemeral.layout}
|
layout={layout}
|
||||||
registry={registry}
|
registry={registry}
|
||||||
components={ephemeral.components}
|
components={components}
|
||||||
onSetApp={noop}
|
interactive
|
||||||
onSplit={noop}
|
isMobile={isMobile}
|
||||||
onRemove={noop}
|
mobilePanelId={mobilePanelId}
|
||||||
onResized={noop}
|
onSetApp={handleSetApp}
|
||||||
|
onSplit={handleSplit}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
onResized={handleResized}
|
||||||
/>
|
/>
|
||||||
) : null}
|
</ResizablePanel>
|
||||||
</ResizablePanel>
|
<ResizableHandle className="bg-transparent after:bg-transparent" disabled={!isEphemeralOpen} />
|
||||||
</ResizablePanelGroup>
|
<ResizablePanel
|
||||||
|
ref={ephemeralPanelRef}
|
||||||
|
collapsible
|
||||||
|
collapsedSize={0}
|
||||||
|
defaultSize={0}
|
||||||
|
minSize={15}
|
||||||
|
>
|
||||||
|
{isEphemeralOpen ? (
|
||||||
|
<WorkspaceRenderer
|
||||||
|
layout={ephemeral.layout}
|
||||||
|
registry={registry}
|
||||||
|
components={ephemeral.components}
|
||||||
|
onSetApp={noop}
|
||||||
|
onSplit={noop}
|
||||||
|
onRemove={noop}
|
||||||
|
onResized={noop}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
)}
|
||||||
</WorkspaceProvider>
|
</WorkspaceProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,4 +74,5 @@ export type EphemeralPanels = {
|
|||||||
layout: LayoutNode;
|
layout: LayoutNode;
|
||||||
components: PanelComponents;
|
components: PanelComponents;
|
||||||
defaultBaseSize?: number;
|
defaultBaseSize?: number;
|
||||||
|
onClose?: () => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export const useFileViewerPanels = (): EphemeralPanels | null => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!viewPath && !chatContext) return null;
|
if (!viewPath && !chatContext) return null;
|
||||||
return { layout, components, defaultBaseSize: 40 };
|
return { layout, components, defaultBaseSize: 40, onClose: onCloseViewer };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UseFileViewerPanelsType = ReturnType<typeof useFileViewerPanels>;
|
export type UseFileViewerPanelsType = ReturnType<typeof useFileViewerPanels>;
|
||||||
|
|||||||
Reference in New Issue
Block a user