corrected folder upload and virtualization
This commit is contained in:
@@ -41,6 +41,7 @@
|
|||||||
"@simplewebauthn/server": "^13.2.2",
|
"@simplewebauthn/server": "^13.2.2",
|
||||||
"@tabler/icons-react": "^3.36.0",
|
"@tabler/icons-react": "^3.36.0",
|
||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.90.5",
|
||||||
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
"@tiptap/extension-text-align": "^3.15.3",
|
"@tiptap/extension-text-align": "^3.15.3",
|
||||||
"@types/three": "^0.182.0",
|
"@types/three": "^0.182.0",
|
||||||
"@typescript/native-preview": "^7.0.0-dev.20260107.1",
|
"@typescript/native-preview": "^7.0.0-dev.20260107.1",
|
||||||
@@ -905,6 +906,10 @@
|
|||||||
|
|
||||||
"@tanstack/react-query": ["@tanstack/react-query@5.90.12", "", { "dependencies": { "@tanstack/query-core": "5.90.12" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg=="],
|
"@tanstack/react-query": ["@tanstack/react-query@5.90.12", "", { "dependencies": { "@tanstack/query-core": "5.90.12" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg=="],
|
||||||
|
|
||||||
|
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.18", "", { "dependencies": { "@tanstack/virtual-core": "3.13.18" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A=="],
|
||||||
|
|
||||||
|
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.18", "", {}, "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg=="],
|
||||||
|
|
||||||
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
||||||
|
|
||||||
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
|
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
"@simplewebauthn/server": "^13.2.2",
|
"@simplewebauthn/server": "^13.2.2",
|
||||||
"@tabler/icons-react": "^3.36.0",
|
"@tabler/icons-react": "^3.36.0",
|
||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.90.5",
|
||||||
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
"@tiptap/extension-text-align": "^3.15.3",
|
"@tiptap/extension-text-align": "^3.15.3",
|
||||||
"@types/three": "^0.182.0",
|
"@types/three": "^0.182.0",
|
||||||
"@typescript/native-preview": "^7.0.0-dev.20260107.1",
|
"@typescript/native-preview": "^7.0.0-dev.20260107.1",
|
||||||
|
|||||||
@@ -9,9 +9,13 @@ import { useForm } from 'hooks/useForm';
|
|||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
|
|
||||||
|
const initialState: LoginFormState = {
|
||||||
|
email: 'pastilhas@pastilhas.dev',
|
||||||
|
password: '1234567890',
|
||||||
|
};
|
||||||
export function Login() {
|
export function Login() {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const { state, formRef, update, isValid } = useForm<LoginFormState>({}, validateForm);
|
const { state, formRef, update, isValid } = useForm<LoginFormState>(initialState, validateForm);
|
||||||
const { signin } = useAuth();
|
const { signin } = useAuth();
|
||||||
const [, setDuckHidden] = useGlobal('DUCK_HIDDEN', false);
|
const [, setDuckHidden] = useGlobal('DUCK_HIDDEN', false);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useRef, useCallback } from 'react';
|
import { useRef, useCallback, useMemo, useState, useEffect } from 'react';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import type { DirEntry, TaskSummary } from 'widgets/FileBrowser';
|
import type { DirEntry, TaskSummary } from 'widgets/FileBrowser';
|
||||||
import { FileItem } from './FileItem';
|
import { FileItem } from './FileItem';
|
||||||
|
|
||||||
@@ -21,6 +22,35 @@ type FileGridProps = {
|
|||||||
onRenamingChange: (name: string | null) => void;
|
onRenamingChange: (name: string | null) => void;
|
||||||
getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[];
|
getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[];
|
||||||
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
||||||
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIST_ROW_HEIGHT = 42;
|
||||||
|
const GRID_ROW_HEIGHT = 130;
|
||||||
|
const GRID_GAP = 12;
|
||||||
|
|
||||||
|
const useColumnCount = (scrollRef: React.RefObject<HTMLDivElement | null>) => {
|
||||||
|
const [cols, setCols] = useState(4);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => {
|
||||||
|
const width = el.clientWidth - 32; // subtract p-4 (16px each side)
|
||||||
|
// Breakpoints matching grid-cols-2 sm:3 md:4 lg:5 xl:6
|
||||||
|
if (width >= 1280) setCols(6);
|
||||||
|
else if (width >= 1024) setCols(5);
|
||||||
|
else if (width >= 768) setCols(4);
|
||||||
|
else if (width >= 640) setCols(3);
|
||||||
|
else setCols(2);
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
const observer = new ResizeObserver(measure);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [scrollRef]);
|
||||||
|
|
||||||
|
return cols;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FileGrid = ({
|
export const FileGrid = ({
|
||||||
@@ -40,14 +70,18 @@ export const FileGrid = ({
|
|||||||
onRenamingChange,
|
onRenamingChange,
|
||||||
getMatchingTasks,
|
getMatchingTasks,
|
||||||
onRunTask,
|
onRunTask,
|
||||||
|
scrollRef,
|
||||||
}: FileGridProps) => {
|
}: FileGridProps) => {
|
||||||
const lastClickedIdx = useRef<number>(-1);
|
const lastClickedIdx = useRef<number>(-1);
|
||||||
|
|
||||||
// Sort: directories first, then files, alphabetically within each group
|
const sorted = useMemo(
|
||||||
const sorted = [...entries].sort((a, b) => {
|
() =>
|
||||||
|
[...entries].sort((a, b) => {
|
||||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
});
|
}),
|
||||||
|
[entries],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(entry: DirEntry, ev: React.MouseEvent) => {
|
(entry: DirEntry, ev: React.MouseEvent) => {
|
||||||
@@ -71,7 +105,6 @@ export const FileGrid = ({
|
|||||||
onSelect(next);
|
onSelect(next);
|
||||||
lastClickedIdx.current = idx;
|
lastClickedIdx.current = idx;
|
||||||
} else {
|
} else {
|
||||||
// Plain select (from context menu or checkbox click without modifiers)
|
|
||||||
onSelect(new Set([entry.name]));
|
onSelect(new Set([entry.name]));
|
||||||
lastClickedIdx.current = idx;
|
lastClickedIdx.current = idx;
|
||||||
}
|
}
|
||||||
@@ -79,16 +112,25 @@ export const FileGrid = ({
|
|||||||
[sorted, selected, onSelect],
|
[sorted, selected, onSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const cols = useColumnCount(scrollRef);
|
||||||
const cutPaths = clipboard?.mode === 'cut' ? new Set(clipboard.paths) : new Set<string>();
|
const cutPaths = clipboard?.mode === 'cut' ? new Set(clipboard.paths) : new Set<string>();
|
||||||
const anySelected = selected.size > 0;
|
const anySelected = selected.size > 0;
|
||||||
|
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
|
||||||
|
|
||||||
|
const rowCount = viewMode === 'list' ? sorted.length : Math.ceil(sorted.length / cols);
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: rowCount,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => (viewMode === 'list' ? LIST_ROW_HEIGHT : GRID_ROW_HEIGHT + GRID_GAP),
|
||||||
|
overscan: 10,
|
||||||
|
});
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return <div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">This folder is empty</div>;
|
return <div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">This folder is empty</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
|
const renderItem = (entry: DirEntry) => (
|
||||||
|
|
||||||
const items = sorted.map((entry) => (
|
|
||||||
<FileItem
|
<FileItem
|
||||||
key={entry.name}
|
key={entry.name}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
@@ -109,15 +151,40 @@ export const FileGrid = ({
|
|||||||
matchingTasks={getMatchingTasks(entry.name, entry.type)}
|
matchingTasks={getMatchingTasks(entry.name, entry.type)}
|
||||||
onRunTask={onRunTask}
|
onRunTask={onRunTask}
|
||||||
/>
|
/>
|
||||||
));
|
);
|
||||||
|
|
||||||
if (viewMode === 'list') {
|
|
||||||
return <div className="file-grid flex flex-col">{items}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="file-grid grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
<div className="file-grid relative w-full" style={{ height: virtualizer.getTotalSize() }}>
|
||||||
{items}
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
|
if (viewMode === 'list') {
|
||||||
|
const entry = sorted[virtualRow.index]!;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={virtualRow.key}
|
||||||
|
className="absolute left-0 w-full"
|
||||||
|
style={{ top: virtualRow.start, height: virtualRow.size }}
|
||||||
|
>
|
||||||
|
{renderItem(entry)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIdx = virtualRow.index * cols;
|
||||||
|
const rowEntries = sorted.slice(startIdx, startIdx + cols);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={virtualRow.key}
|
||||||
|
className="absolute left-0 w-full grid gap-3"
|
||||||
|
style={{
|
||||||
|
top: virtualRow.start,
|
||||||
|
height: virtualRow.size - GRID_GAP,
|
||||||
|
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rowEntries.map(renderItem)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export const Files = () => {
|
|||||||
const { getMatchingTasks } = useTasks();
|
const { getMatchingTasks } = useTasks();
|
||||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const fileScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const viewPath = searchParams.get('view');
|
const viewPath = searchParams.get('view');
|
||||||
const viewerFileName = viewPath ? viewPath.split('/').pop()! : '';
|
const viewerFileName = viewPath ? viewPath.split('/').pop()! : '';
|
||||||
const files = useFiles(homeRoot);
|
const files = useFiles(homeRoot);
|
||||||
@@ -586,7 +587,7 @@ export const Files = () => {
|
|||||||
) : (
|
) : (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div className="h-full overflow-auto p-4" onClick={handleBackgroundClick}>
|
<div ref={fileScrollRef} className="h-full overflow-auto p-4" onClick={handleBackgroundClick}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
|
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
|
||||||
@@ -609,6 +610,7 @@ export const Files = () => {
|
|||||||
onRenamingChange={setRenamingName}
|
onRenamingChange={setRenamingName}
|
||||||
getMatchingTasks={getMatchingTasks}
|
getMatchingTasks={getMatchingTasks}
|
||||||
onRunTask={handleRunTask}
|
onRunTask={handleRunTask}
|
||||||
|
scrollRef={fileScrollRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ const DEFAULT_HOME_LAYOUT: LayoutNode = {
|
|||||||
id: 'home-left',
|
id: 'home-left',
|
||||||
direction: 'vertical',
|
direction: 'vertical',
|
||||||
children: [
|
children: [
|
||||||
{ size: 50, node: { type: 'panel', id: 'home-tl', widgetType: 'chat' } },
|
{ size: 50, node: { type: 'panel', id: 'home-tl', widgetType: null } },
|
||||||
{ size: 50, node: { type: 'panel', id: 'home-bl', widgetType: 'file-browser' } },
|
{ size: 50, node: { type: 'panel', id: 'home-bl', widgetType: null } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -27,8 +27,8 @@ const DEFAULT_HOME_LAYOUT: LayoutNode = {
|
|||||||
id: 'home-right',
|
id: 'home-right',
|
||||||
direction: 'vertical',
|
direction: 'vertical',
|
||||||
children: [
|
children: [
|
||||||
{ size: 50, node: { type: 'panel', id: 'home-tr', widgetType: 'chat-history' } },
|
{ size: 50, node: { type: 'panel', id: 'home-tr', widgetType: null } },
|
||||||
{ size: 50, node: { type: 'panel', id: 'home-br', widgetType: 'sound-library' } },
|
{ size: 50, node: { type: 'panel', id: 'home-br', widgetType: null } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export const ProfileSettings = () => {
|
|||||||
<div className="text-base font-bold text-duck-dark">{section.title}</div>
|
<div className="text-base font-bold text-duck-dark">{section.title}</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent>{section.content}</AccordionContent>
|
<AccordionContent className="px-1">{section.content}</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-bookworm-slim
|
FROM imbios/bun-node:22-slim
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y python3 make g++ zsh git curl ca-certificates fortune-mod cowsay \
|
&& apt-get install -y python3 make g++ zsh git curl ca-certificates fortune-mod cowsay \
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import type { LayoutNode, WorkspaceDefinition, WidgetRegistry } from './types';
|
import type { LayoutNode, WorkspaceDefinition, WidgetRegistry } from './types';
|
||||||
import { splitPanel, removePanel, setWidget, updateSizes, countPanels } from './layout-utils';
|
import { splitPanel, removePanel, setWidget, updateSizes, countPanels, hasAnyWidget } from './layout-utils';
|
||||||
import { WorkspaceProvider } from './WorkspaceContext';
|
import { WorkspaceProvider } from './WorkspaceContext';
|
||||||
import { WorkspaceHeader } from './WorkspaceHeader';
|
import { WorkspaceHeader } from './WorkspaceHeader';
|
||||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||||
@@ -14,7 +14,7 @@ type WorkspaceViewProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const WorkspaceView = ({ workspace, name, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
|
export const WorkspaceView = ({ workspace, name, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(() => !hasAnyWidget(layout));
|
||||||
|
|
||||||
const handleSetWidget = useCallback(
|
const handleSetWidget = useCallback(
|
||||||
(panelId: string, widgetType: string | null) => {
|
(panelId: string, widgetType: string | null) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WidgetRegistry, WidgetRegistryEntry } from './types';
|
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WidgetRegistry, WidgetRegistryEntry } from './types';
|
||||||
export { createDefaultLayout, splitPanel, removePanel, setWidget, updateSizes, pruneEmptyPanels, countPanels } from './layout-utils';
|
export { createDefaultLayout, splitPanel, removePanel, setWidget, updateSizes, pruneEmptyPanels, countPanels, hasAnyWidget } from './layout-utils';
|
||||||
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
||||||
export { WorkspaceView } from './WorkspaceView';
|
export { WorkspaceView } from './WorkspaceView';
|
||||||
|
|||||||
@@ -156,3 +156,8 @@ export function countPanels(node: LayoutNode): number {
|
|||||||
if (node.type === 'panel') return 1;
|
if (node.type === 'panel') return 1;
|
||||||
return node.children.reduce((sum, child) => sum + countPanels(child.node), 0);
|
return node.children.reduce((sum, child) => sum + countPanels(child.node), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasAnyWidget(node: LayoutNode): boolean {
|
||||||
|
if (node.type === 'panel') return node.widgetType !== null;
|
||||||
|
return node.children.some((child) => hasAnyWidget(child.node));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useRef, useState, useCallback } from 'react';
|
||||||
import { FolderPlus, Upload, FolderUp, Scissors, Copy, ClipboardPaste, Trash2, X, Check } from 'lucide-react';
|
import { FolderPlus, Upload, FolderUp, Scissors, Copy, ClipboardPaste, Trash2, X, Check } from 'lucide-react';
|
||||||
|
|
||||||
type ToolbarProps = {
|
type ToolbarProps = {
|
||||||
@@ -28,11 +28,9 @@ export const Toolbar = ({
|
|||||||
const [folderName, setFolderName] = useState('');
|
const [folderName, setFolderName] = useState('');
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const folderInputRef = useRef<HTMLInputElement | null>(null);
|
const folderInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const setFolderInputRef = useCallback((input: HTMLInputElement | null) => {
|
||||||
useEffect(() => {
|
folderInputRef.current = input;
|
||||||
const input = folderInputRef.current;
|
if (input) input.setAttribute('webkitdirectory', '');
|
||||||
if (!input) return;
|
|
||||||
input.setAttribute('webkitdirectory', '');
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
@@ -43,6 +41,34 @@ export const Toolbar = ({
|
|||||||
setShowInput(false);
|
setShowInput(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hiddenInputs = (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(ev) => {
|
||||||
|
if (ev.target.files?.length) {
|
||||||
|
onUpload(ev.target.files);
|
||||||
|
ev.target.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={setFolderInputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(ev) => {
|
||||||
|
if (ev.target.files?.length) {
|
||||||
|
onUpload(ev.target.files);
|
||||||
|
ev.target.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
if (selectionCount > 0) {
|
if (selectionCount > 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -87,6 +113,7 @@ export const Toolbar = ({
|
|||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
{hiddenInputs}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -169,29 +196,7 @@ export const Toolbar = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<input
|
{hiddenInputs}
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={(ev) => {
|
|
||||||
if (ev.target.files?.length) {
|
|
||||||
onUpload(ev.target.files);
|
|
||||||
ev.target.value = '';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
ref={folderInputRef}
|
|
||||||
type="file"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(ev) => {
|
|
||||||
if (ev.target.files?.length) {
|
|
||||||
onUpload(ev.target.files);
|
|
||||||
ev.target.value = '';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user