mobile
This commit is contained in:
@@ -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);
|
||||
|
||||
+1
-7
@@ -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}
|
||||
|
||||
@@ -52,7 +52,11 @@ export const FileViewerBody = () => {
|
||||
) : fileType === 'image' ? (
|
||||
<ImageRenderer src={getRawUrl(filePath, root)} fileName={fileName} />
|
||||
) : fileType === 'video' ? (
|
||||
<VideoRenderer src={videoSrc} fileName={fileName} />
|
||||
<VideoRenderer
|
||||
src={videoSrc}
|
||||
fileName={fileName}
|
||||
fallbackSrc={needsTranscode(fileName) ? undefined : getTranscodeUrl(filePath, root)}
|
||||
/>
|
||||
) : fileType === 'audio' ? (
|
||||
<AudioRenderer src={getRawUrl(filePath, root)} fileName={fileName} autoPlay={autoPlay} />
|
||||
) : 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}`;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout>>(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);
|
||||
|
||||
@@ -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 ? (
|
||||
<TrafficLights panelId={panel.id} isLastPanel={isLastPanel} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, 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 = (
|
||||
<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} />}
|
||||
{onClose && (
|
||||
{!mobileBackButton && onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer"
|
||||
|
||||
@@ -22,6 +22,8 @@ type WorkspaceContextValue = {
|
||||
maximizedPanelId: string | null;
|
||||
setMaximizedPanelId: (id: string | null) => void;
|
||||
transitioningPanelId: string | null;
|
||||
isMobile: boolean;
|
||||
onMobileBack: (() => void) | null;
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
@@ -38,6 +40,8 @@ const WorkspaceContext = createContext<WorkspaceContextValue>({
|
||||
maximizedPanelId: null,
|
||||
setMaximizedPanelId: noop,
|
||||
transitioningPanelId: null,
|
||||
isMobile: false,
|
||||
onMobileBack: null,
|
||||
});
|
||||
|
||||
export const WorkspaceProvider = WorkspaceContext.Provider;
|
||||
|
||||
@@ -13,11 +13,14 @@ type WorkspaceLayoutProps = {
|
||||
workspaceId?: string;
|
||||
cwd?: string;
|
||||
noHeader?: boolean;
|
||||
isMobile?: boolean;
|
||||
mobilePanelId?: string;
|
||||
onMobileBack?: (() => void) | null;
|
||||
};
|
||||
|
||||
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 = registryProp ?? globalRegistry;
|
||||
|
||||
@@ -29,12 +32,14 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry: registryProp
|
||||
);
|
||||
|
||||
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
|
||||
layout={layout}
|
||||
registry={registry}
|
||||
components={components}
|
||||
noHeader={noHeader}
|
||||
isMobile={isMobile}
|
||||
mobilePanelId={mobilePanelId}
|
||||
onSetApp={noop}
|
||||
onSplit={noop}
|
||||
onRemove={noop}
|
||||
|
||||
@@ -10,6 +10,8 @@ type WorkspaceRendererProps = {
|
||||
components?: PanelComponents;
|
||||
interactive?: boolean;
|
||||
noHeader?: boolean;
|
||||
isMobile?: boolean;
|
||||
mobilePanelId?: string;
|
||||
onSetApp: (panelId: string, appType: string | null) => void;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
onRemove: (panelId: string) => void;
|
||||
@@ -22,6 +24,8 @@ export const WorkspaceRenderer = ({
|
||||
components,
|
||||
interactive = false,
|
||||
noHeader = false,
|
||||
isMobile = false,
|
||||
mobilePanelId,
|
||||
onSetApp,
|
||||
onSplit,
|
||||
onRemove,
|
||||
@@ -37,6 +41,8 @@ export const WorkspaceRenderer = ({
|
||||
components={components}
|
||||
interactive={interactive}
|
||||
noHeader={noHeader}
|
||||
isMobile={isMobile}
|
||||
mobilePanelId={mobilePanelId}
|
||||
totalPanels={totalPanels}
|
||||
onSetApp={onSetApp}
|
||||
onSplit={onSplit}
|
||||
@@ -53,6 +59,8 @@ type LayoutNodeRendererProps = {
|
||||
components?: PanelComponents;
|
||||
interactive: boolean;
|
||||
noHeader: boolean;
|
||||
isMobile: boolean;
|
||||
mobilePanelId?: string;
|
||||
totalPanels: number;
|
||||
onSetApp: (panelId: string, appType: string | null) => void;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
@@ -65,12 +73,27 @@ const getFixedHeight = (node: LayoutNode, registry: AppRegistry): number | undef
|
||||
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 = ({
|
||||
node,
|
||||
registry,
|
||||
components,
|
||||
interactive,
|
||||
noHeader,
|
||||
isMobile,
|
||||
mobilePanelId,
|
||||
totalPanels,
|
||||
onSetApp,
|
||||
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);
|
||||
|
||||
if (hasFixedChild) {
|
||||
@@ -126,6 +172,8 @@ const LayoutNodeRenderer = ({
|
||||
components={components}
|
||||
interactive={interactive}
|
||||
noHeader={noHeader}
|
||||
isMobile={isMobile}
|
||||
mobilePanelId={mobilePanelId}
|
||||
totalPanels={totalPanels}
|
||||
onSetApp={onSetApp}
|
||||
onSplit={onSplit}
|
||||
@@ -151,6 +199,8 @@ const LayoutNodeRenderer = ({
|
||||
components={components}
|
||||
interactive={interactive}
|
||||
noHeader={noHeader}
|
||||
isMobile={isMobile}
|
||||
mobilePanelId={mobilePanelId}
|
||||
totalPanels={totalPanels}
|
||||
onSetApp={onSetApp}
|
||||
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 { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '@/components/ui/resizable';
|
||||
import { useIsMobile } from 'hooks/useIsMobile';
|
||||
import type { LayoutNode, WorkspaceState, EphemeralPanels, PanelComponents } from './types';
|
||||
import type { DefaultFileSort } from './WorkspaceContext';
|
||||
import type { DropPosition } from './layout-utils';
|
||||
@@ -17,12 +18,15 @@ type WorkspaceViewProps = {
|
||||
defaultFileSort?: DefaultFileSort;
|
||||
components?: PanelComponents;
|
||||
ephemeral?: EphemeralPanels | null;
|
||||
mobilePanelId?: string;
|
||||
onMobilePanelChange?: (id: string | null) => void;
|
||||
};
|
||||
|
||||
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 isMobile = useIsMobile();
|
||||
|
||||
const layout = workspace.value;
|
||||
const onLayoutChange = workspace.setValue;
|
||||
@@ -125,6 +129,13 @@ export const WorkspaceView = ({ workspace, cwd = '~', root, initialFilePath, def
|
||||
}
|
||||
}, [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;
|
||||
|
||||
return (
|
||||
@@ -144,42 +155,59 @@ export const WorkspaceView = ({ workspace, cwd = '~', root, initialFilePath, def
|
||||
maximizedPanelId,
|
||||
setMaximizedPanelId: setMaximizedAnimated,
|
||||
transitioningPanelId,
|
||||
isMobile,
|
||||
onMobileBack,
|
||||
}}
|
||||
>
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
||||
<ResizablePanel defaultSize={100} minSize={15}>
|
||||
<WorkspaceRenderer
|
||||
layout={layout}
|
||||
registry={registry}
|
||||
components={components}
|
||||
interactive
|
||||
onSetApp={handleSetApp}
|
||||
onSplit={handleSplit}
|
||||
onRemove={handleRemove}
|
||||
onResized={handleResized}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle className="bg-transparent after:bg-transparent" disabled={!isEphemeralOpen} />
|
||||
<ResizablePanel
|
||||
ref={ephemeralPanelRef}
|
||||
collapsible
|
||||
collapsedSize={0}
|
||||
defaultSize={0}
|
||||
minSize={15}
|
||||
>
|
||||
{isEphemeralOpen ? (
|
||||
{isMobile && isEphemeralOpen && ephemeral ? (
|
||||
<WorkspaceRenderer
|
||||
layout={ephemeral.layout}
|
||||
registry={registry}
|
||||
components={ephemeral.components}
|
||||
isMobile={isMobile}
|
||||
onSetApp={noop}
|
||||
onSplit={noop}
|
||||
onRemove={noop}
|
||||
onResized={noop}
|
||||
/>
|
||||
) : (
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
||||
<ResizablePanel defaultSize={100} minSize={15}>
|
||||
<WorkspaceRenderer
|
||||
layout={ephemeral.layout}
|
||||
layout={layout}
|
||||
registry={registry}
|
||||
components={ephemeral.components}
|
||||
onSetApp={noop}
|
||||
onSplit={noop}
|
||||
onRemove={noop}
|
||||
onResized={noop}
|
||||
components={components}
|
||||
interactive
|
||||
isMobile={isMobile}
|
||||
mobilePanelId={mobilePanelId}
|
||||
onSetApp={handleSetApp}
|
||||
onSplit={handleSplit}
|
||||
onRemove={handleRemove}
|
||||
onResized={handleResized}
|
||||
/>
|
||||
) : null}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle className="bg-transparent after:bg-transparent" disabled={!isEphemeralOpen} />
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -74,4 +74,5 @@ export type EphemeralPanels = {
|
||||
layout: LayoutNode;
|
||||
components: PanelComponents;
|
||||
defaultBaseSize?: number;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
@@ -105,7 +105,7 @@ export const useFileViewerPanels = (): EphemeralPanels | null => {
|
||||
);
|
||||
|
||||
if (!viewPath && !chatContext) return null;
|
||||
return { layout, components, defaultBaseSize: 40 };
|
||||
return { layout, components, defaultBaseSize: 40, onClose: onCloseViewer };
|
||||
};
|
||||
|
||||
export type UseFileViewerPanelsType = ReturnType<typeof useFileViewerPanels>;
|
||||
|
||||
Reference in New Issue
Block a user