chore: save snapshot before tag 0.3

This commit is contained in:
liaibo
2025-12-27 20:23:35 +08:00
parent 9bd5c9c403
commit c0b7b40eee
508 changed files with 2097 additions and 356976 deletions
+2 -1
View File
@@ -28,13 +28,14 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
let sidebarInitialData: SidebarInitialData | null = null;
if (activeWorkspaceId) {
const dataset = await fetchSidebarDataset(supabase, activeWorkspaceId);
const dataset = await fetchSidebarDataset(supabase, activeWorkspaceId);
documents = dataset.documents;
sidebarInitialData = {
activeWorkspaceId,
workspaces,
documents: dataset.documents,
trashedDocuments: dataset.trashedDocuments,
mediaAssets: dataset.mediaAssets,
};
}
@@ -0,0 +1,154 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
type Action = "copy" | "move" | "delete" | "rename";
interface BatchPayload {
action: Action;
assetIds: string[];
targetDocumentId?: string;
newName?: string;
}
const BUCKET = process.env.NEXT_PUBLIC_SUPABASE_MEDIA_BUCKET ?? "workspace";
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = (await request.json()) as BatchPayload;
if (!payload?.action || !payload.assetIds?.length) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const { data: assets, error: fetchError } = await supabase
.from("media_assets")
.select("*")
.in("id", payload.assetIds);
if (fetchError) {
return NextResponse.json({ error: fetchError.message }, { status: 500 });
}
if (!assets?.length) {
return NextResponse.json({ error: "未找到附件" }, { status: 404 });
}
try {
switch (payload.action) {
case "delete": {
await Promise.all(
assets.map(async (asset) => {
if (asset.storage_path) {
await supabase.storage.from(asset.bucket || BUCKET).remove([asset.storage_path]);
}
}),
);
const { error } = await supabase.from("media_assets").delete().in("id", payload.assetIds);
if (error) throw error;
return NextResponse.json({ ok: true });
}
case "rename": {
if (payload.assetIds.length !== 1 || !payload.newName) {
return NextResponse.json({ error: "重命名需要单个文件与新名称" }, { status: 400 });
}
const asset = assets[0];
const ext = asset.file_name?.includes(".") ? `.${asset.file_name.split(".").pop()}` : "";
const newFileName = payload.newName.includes(".") || !ext ? payload.newName : `${payload.newName}${ext}`;
const targetPath = `${session.user.id}/${asset.workspace_id}/${asset.document_id}/assets/${newFileName}`;
if (asset.storage_path) {
const moveResult = await supabase.storage
.from(asset.bucket || BUCKET)
.move(asset.storage_path, targetPath);
if (moveResult.error) throw moveResult.error;
}
const { error } = await supabase
.from("media_assets")
.update({
file_name: newFileName,
storage_path: targetPath,
file_url: null,
thumbnail_url: null,
})
.eq("id", asset.id);
if (error) throw error;
return NextResponse.json({ ok: true });
}
case "copy":
case "move": {
if (!payload.targetDocumentId) {
return NextResponse.json({ error: "缺少目标页面" }, { status: 400 });
}
const { data: targetDoc, error: docErr } = await supabase
.from("documents")
.select("workspace_id")
.eq("id", payload.targetDocumentId)
.single();
if (docErr || !targetDoc) {
return NextResponse.json({ error: "目标页面不存在" }, { status: 404 });
}
const results = [];
for (const asset of assets) {
const fileName = asset.file_name ?? "附件";
const targetPath = `${session.user.id}/${targetDoc.workspace_id}/${payload.targetDocumentId}/assets/${fileName}`;
const sourcePath = asset.storage_path;
const bucket = asset.bucket || BUCKET;
if (!sourcePath) continue;
if (payload.action === "copy") {
const copyRes = await supabase.storage.from(bucket).copy(sourcePath, targetPath);
if (copyRes.error) throw copyRes.error;
const { data: inserted, error } = await supabase
.from("media_assets")
.insert({
workspace_id: targetDoc.workspace_id,
document_id: payload.targetDocumentId,
asset_type: asset.asset_type,
file_name: fileName,
file_size: asset.file_size,
mime_type: asset.mime_type,
bucket,
storage_path: targetPath,
created_by: session.user.id,
})
.select("*")
.single();
if (error) throw error;
results.push(inserted);
} else {
const moveRes = await supabase.storage.from(bucket).move(sourcePath, targetPath);
if (moveRes.error) throw moveRes.error;
const { data: updated, error } = await supabase
.from("media_assets")
.update({
workspace_id: targetDoc.workspace_id,
document_id: payload.targetDocumentId,
storage_path: targetPath,
file_name: fileName,
file_url: null,
thumbnail_url: null,
})
.eq("id", asset.id)
.select("*")
.single();
if (error) throw error;
results.push(updated);
}
}
return NextResponse.json({ items: results });
}
default:
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
}
} catch (err) {
const message = err instanceof Error ? err.message : "操作失败";
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,106 @@
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Copy, Download, Hash, Link as LinkIcon, Move, PenLine, Trash2 } from "lucide-react";
import type { MediaAsset } from "@/types/media";
import { cn } from "@/lib/utils";
interface AssetContextMenuProps {
asset: MediaAsset;
position: { x: number; y: number };
onClose: () => void;
onOpen: (asset: MediaAsset) => void;
onCopyLink: (asset: MediaAsset) => void;
onCopyPath: (asset: MediaAsset) => void;
onRename: (asset: MediaAsset) => void;
onMove: (asset: MediaAsset) => void;
onDelete: (assetIds: string[]) => void;
onDownload: (asset: MediaAsset) => void;
}
export function AssetContextMenu({
asset,
position,
onClose,
onOpen,
onCopyLink,
onCopyPath,
onRename,
onMove,
onDelete,
onDownload,
}: AssetContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState(position);
useLayoutEffect(() => {
const clampPosition = () => {
const element = menuRef.current;
if (!element) {
setPos(position);
return;
}
const rect = element.getBoundingClientRect();
const padding = 12;
const maxLeft = Math.max(padding, window.innerWidth - rect.width - padding);
const maxTop = Math.max(padding, window.innerHeight - rect.height - padding);
const left = Math.min(Math.max(padding, position.x), maxLeft);
const top = Math.min(Math.max(padding, position.y), maxTop);
setPos({ x: left, y: top });
};
clampPosition();
window.addEventListener("resize", clampPosition);
return () => window.removeEventListener("resize", clampPosition);
}, [position]);
useEffect(() => {
const close = () => onClose();
window.addEventListener("click", close);
return () => window.removeEventListener("click", close);
}, [onClose]);
const buttonClass =
"flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-gray-700 hover:bg-[#f5f7fb]";
return (
<div
ref={menuRef}
className="fixed z-50 rounded-xl border border-[#ececec] bg-white p-1 text-sm text-gray-700 shadow-2xl"
style={{ top: pos.y, left: pos.x, minWidth: 200 }}
>
<button type="button" className={buttonClass} onClick={() => onOpen(asset)}>
<LinkIcon className="h-4 w-4 text-[#2563eb]" />
<span></span>
</button>
<button type="button" className={buttonClass} onClick={() => onDownload(asset)}>
<Download className="h-4 w-4 text-gray-500" />
<span></span>
</button>
<button type="button" className={buttonClass} onClick={() => onCopyLink(asset)}>
<Copy className="h-4 w-4 text-gray-500" />
<span></span>
</button>
<button type="button" className={buttonClass} onClick={() => onCopyPath(asset)}>
<Hash className="h-4 w-4 text-gray-500" />
<span></span>
</button>
<div className="my-1 border-t border-[#f2f2f2]" />
<button type="button" className={buttonClass} onClick={() => onRename(asset)}>
<PenLine className="h-4 w-4 text-gray-500" />
<span></span>
</button>
<button type="button" className={buttonClass} onClick={() => onMove(asset)}>
<Move className="h-4 w-4 text-gray-500" />
<span>...</span>
</button>
<button
type="button"
className="mt-1 flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-red-500 hover:bg-red-50"
onClick={() => onDelete([asset.id])}
>
<Trash2 className="h-4 w-4" />
<span></span>
</button>
</div>
);
}
@@ -0,0 +1,320 @@
"use client";
import { ChevronRight, FileText, Folder, Paperclip, Plus } from "lucide-react";
import type { DocumentNode } from "@/lib/documents";
import type { MediaAsset } from "@/types/media";
import { cn } from "@/lib/utils";
import { useMemo } from "react";
interface FileTreeProps {
nodes: DocumentNode[];
assetsByDoc: Record<string, MediaAsset[]>;
expanded: Set<string>;
activeId: string;
onToggleExpand: (id: string) => void;
onOpenDocument: (id: string) => void;
onOpenAsset: (asset: MediaAsset) => void;
onCreateChild: (parentId: string | null) => void;
onContextMenu: (event: React.MouseEvent, node: DocumentNode) => void;
selectedAssetIds?: Set<string>;
onToggleAssetSelect?: (assetId: string) => void;
onSelectOnlyAsset?: (assetId: string) => void;
disableSelection?: boolean;
onDropFiles?: (docId: string, files: FileList) => void;
onAssetContextMenu?: (event: React.MouseEvent, asset: MediaAsset) => void;
}
const INDENT = 16;
export function FileTree({
nodes,
assetsByDoc,
expanded,
activeId,
onToggleExpand,
onOpenDocument,
onOpenAsset,
onCreateChild,
onContextMenu,
selectedAssetIds = new Set<string>(),
onToggleAssetSelect,
onSelectOnlyAsset,
disableSelection = false,
onDropFiles,
onAssetContextMenu,
}: FileTreeProps) {
if (nodes.length === 0) {
return <div className="px-4 py-6 text-sm text-gray-400"></div>;
}
return (
<div
className="divide-y divide-[#f4f4f5]"
onDragOver={(e) => {
if (onDropFiles) {
e.preventDefault();
}
}}
onDrop={(e) => {
if (onDropFiles && e.dataTransfer.files?.length) {
e.preventDefault();
const files = e.dataTransfer.files;
const targetDoc = nodes[0]?.id ?? "";
onDropFiles(targetDoc, files);
}
}}
>
{nodes.map((node) => (
<FileTreeNode
key={node.id}
node={node}
depth={0}
assetsByDoc={assetsByDoc}
expanded={expanded}
activeId={activeId}
onToggleExpand={onToggleExpand}
onOpenDocument={onOpenDocument}
onOpenAsset={onOpenAsset}
onCreateChild={onCreateChild}
onContextMenu={onContextMenu}
selectedAssetIds={selectedAssetIds}
onToggleAssetSelect={onToggleAssetSelect}
onSelectOnlyAsset={onSelectOnlyAsset}
disableSelection={disableSelection}
onDropFiles={onDropFiles}
onAssetContextMenu={onAssetContextMenu}
/>
))}
</div>
);
}
interface FileTreeNodeProps {
node: DocumentNode;
depth: number;
assetsByDoc: Record<string, MediaAsset[]>;
expanded: Set<string>;
activeId: string;
onToggleExpand: (id: string) => void;
onOpenDocument: (id: string) => void;
onOpenAsset: (asset: MediaAsset) => void;
onCreateChild: (parentId: string | null) => void;
onContextMenu: (event: React.MouseEvent, node: DocumentNode) => void;
selectedAssetIds: Set<string>;
onToggleAssetSelect?: (assetId: string) => void;
onSelectOnlyAsset?: (assetId: string) => void;
disableSelection: boolean;
onDropFiles?: (docId: string, files: FileList) => void;
onAssetContextMenu?: (event: React.MouseEvent, asset: MediaAsset) => void;
}
function FileTreeNode({
node,
depth,
assetsByDoc,
expanded,
activeId,
onToggleExpand,
onOpenDocument,
onOpenAsset,
onCreateChild,
onContextMenu,
selectedAssetIds,
onToggleAssetSelect,
onSelectOnlyAsset,
disableSelection,
onDropFiles,
onAssetContextMenu,
}: FileTreeNodeProps) {
const isExpanded = expanded.has(node.id);
const assets = useMemo(() => assetsByDoc[node.id] ?? [], [assetsByDoc, node.id]);
const hasChildren = node.children.length > 0 || assets.length > 0;
return (
<div
className="py-0.5"
onDragOver={(e) => {
if (onDropFiles) e.preventDefault();
}}
onDrop={(e) => {
if (onDropFiles && e.dataTransfer.files?.length) {
e.preventDefault();
onDropFiles(node.id, e.dataTransfer.files);
}
}}
>
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-[#f5f7fb]",
activeId === node.id && "bg-[#e8f2ff] text-[#2563eb]",
)}
style={{ paddingLeft: depth * INDENT + 8 }}
onContextMenu={(event) => onContextMenu(event, node)}
>
{hasChildren ? (
<button
type="button"
aria-label="展开或折叠"
onClick={() => onToggleExpand(node.id)}
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100"
>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button>
) : (
<span className="h-5 w-5" />
)}
<button
type="button"
className="flex flex-1 items-center gap-2 text-left"
onClick={() => onOpenDocument(node.id)}
>
<Folder className="h-4 w-4 text-[#2563eb]" />
<span className="truncate">{node.title || "无标题"}</span>
</button>
<button
type="button"
aria-label="新建子页面"
onClick={() => onCreateChild(node.id)}
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{isExpanded && (
<div className="space-y-0.5">
<FileLeafRow
depth={depth + 1}
label="index.md"
icon={<FileText className="h-4 w-4 text-gray-500" />}
onClick={() => onOpenDocument(node.id)}
stopBubble
/>
{assets.map((asset) => (
<FileLeafRow
key={asset.id}
depth={depth + 1}
label={asset.file_name || "附件"}
icon={<Paperclip className="h-4 w-4 text-gray-500" />}
selected={selectedAssetIds.has(asset.id)}
selectable={!disableSelection}
onSelectToggle={
onToggleAssetSelect ? () => onToggleAssetSelect(asset.id) : undefined
}
onSelectOnly={onSelectOnlyAsset ? () => onSelectOnlyAsset(asset.id) : undefined}
onClick={() => onOpenAsset(asset)}
stopBubble
onContextMenu={
onAssetContextMenu
? (e) => {
e.preventDefault();
e.stopPropagation();
onAssetContextMenu(e, asset);
}
: undefined
}
/>
))}
{node.children.map((child) => (
<FileTreeNode
key={child.id}
node={child}
depth={depth + 1}
assetsByDoc={assetsByDoc}
expanded={expanded}
activeId={activeId}
onToggleExpand={onToggleExpand}
onOpenDocument={onOpenDocument}
onOpenAsset={onOpenAsset}
onCreateChild={onCreateChild}
onContextMenu={onContextMenu}
selectedAssetIds={selectedAssetIds}
onToggleAssetSelect={onToggleAssetSelect}
onSelectOnlyAsset={onSelectOnlyAsset}
disableSelection={disableSelection}
onDropFiles={onDropFiles}
onAssetContextMenu={onAssetContextMenu}
/>
))}
</div>
)}
</div>
);
}
function FileLeafRow({
depth,
label,
icon,
onClick,
selected = false,
selectable = false,
onSelectToggle,
onSelectOnly,
stopBubble = false,
onContextMenu,
}: {
depth: number;
label: string;
icon: React.ReactNode;
onClick: () => void;
selected?: boolean;
selectable?: boolean;
onSelectToggle?: () => void;
onSelectOnly?: () => void;
stopBubble?: boolean;
onContextMenu?: (event: React.MouseEvent) => void;
}) {
return (
<div
className="flex items-center gap-2 rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f8fafc]"
style={{ paddingLeft: depth * INDENT + 32 }}
onClick={(e) => {
if (stopBubble) e.stopPropagation();
// 附件行默认只负责选中,不直接打开,避免误触下载
if (selectable) return;
onClick();
}}
onMouseDown={(e) => {
if (stopBubble) e.stopPropagation();
}}
role="button"
tabIndex={0}
onContextMenu={(e) => {
if (stopBubble) e.stopPropagation();
if (onContextMenu) onContextMenu(e);
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick();
}
}}
>
{selectable ? (
<input
type="checkbox"
checked={selected}
onChange={(e) => {
e.stopPropagation();
if (onSelectToggle) onSelectToggle();
}}
className="h-4 w-4 rounded border-gray-300 text-[#2563eb]"
/>
) : (
<span className="h-4 w-4" />
)}
{icon}
<button
type="button"
className="flex-1 truncate text-left"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
{label}
</button>
</div>
);
}
+128 -55
View File
@@ -42,6 +42,8 @@ import { PrivateTree } from "@/components/sidebar/private-tree";
import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree";
import { supabaseBrowser } from "@/lib/supabase/client";
import { useSearchPaletteStore } from "@/store/search-palette";
import { FileTree } from "@/components/sidebar/file-tree";
import type { MediaAsset } from "@/types/media";
const TOP_BUTTONS = [
{ id: "search", icon: SearchIcon, label: "搜索" },
@@ -74,7 +76,9 @@ interface ContextMenuState {
export function Sidebar({ initialData }: SidebarProps) {
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } =
useSidebarStore();
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
const viewMode = useSidebarStore((state) => state.viewMode);
const setViewMode = useSidebarStore((state) => state.setViewMode);
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
const sidebarQuery = useSidebarData(initialData);
const sidebarData = sidebarQuery.data ?? initialData;
const segments = useSelectedLayoutSegments();
@@ -148,6 +152,18 @@ export function Sidebar({ initialData }: SidebarProps) {
);
}, [sidebarData.trashedDocuments, trashSearch]);
const assetsByDoc = useMemo(() => {
const map: Record<string, MediaAsset[]> = {};
const assets = sidebarData.mediaAssets ?? [];
assets.forEach((asset) => {
if (!map[asset.document_id]) {
map[asset.document_id] = [];
}
map[asset.document_id].push(asset);
});
return map;
}, [sidebarData.mediaAssets]);
const activeWorkspace =
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
sidebarData.workspaces[0];
@@ -231,6 +247,17 @@ export function Sidebar({ initialData }: SidebarProps) {
});
}, []);
const handleOpenAsset = useCallback((asset: MediaAsset) => {
const url = asset.signed_url ?? asset.file_url;
if (!url) {
window.alert("暂无可用的文件链接");
return;
}
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
}
}, []);
const handleResizeStart = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
@@ -577,74 +604,120 @@ export function Sidebar({ initialData }: SidebarProps) {
<Library className="h-4 w-4" />
</div>
<Input
className="mt-2 h-8 rounded-md border-[#eeeeee]"
placeholder="搜索页面"
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
<div className="mt-2 flex items-center gap-2">
<Input
className="h-8 flex-1 rounded-md border-[#eeeeee]"
placeholder="搜索页面"
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
<div className="flex rounded-md border border-[#e5e7eb] bg-white">
<button
type="button"
className={cn(
"px-3 py-1 text-xs",
viewMode === "section" ? "bg-[#2563eb] text-white" : "text-gray-600",
)}
onClick={() => setViewMode("section")}
>
</button>
<button
type="button"
className={cn(
"px-3 py-1 text-xs",
viewMode === "filesystem" ? "bg-[#2563eb] text-white" : "text-gray-600",
)}
onClick={() => setViewMode("filesystem")}
>
</button>
</div>
</div>
</div>
<SectionList
id="starred"
label="星标置顶"
icon={SECTION_ICONS.starred}
nodes={starredNodes}
collapsed={collapsedSections.starred}
onToggle={() => toggleSection("starred")}
/>
<SectionList
id="public"
label="公共页面"
icon={SECTION_ICONS.public}
nodes={publicNodes}
collapsed={collapsedSections.public}
onToggle={() => toggleSection("public")}
/>
<SectionList
id="shared"
label="共享页面"
icon={SECTION_ICONS.shared}
nodes={sharedNodes}
collapsed={collapsedSections.shared}
onToggle={() => toggleSection("shared")}
/>
<SectionList
id="templates"
label="模板中心"
icon={SECTION_ICONS.templates}
nodes={templateNodes}
collapsed={collapsedSections.templates}
onToggle={() => toggleSection("templates")}
/>
{viewMode === "section" ? (
<>
<SectionList
id="starred"
label="星标置顶"
icon={SECTION_ICONS.starred}
nodes={starredNodes}
collapsed={collapsedSections.starred}
onToggle={() => toggleSection("starred")}
/>
<SectionList
id="public"
label="公共页面"
icon={SECTION_ICONS.public}
nodes={publicNodes}
collapsed={collapsedSections.public}
onToggle={() => toggleSection("public")}
/>
<SectionList
id="shared"
label="共享页面"
icon={SECTION_ICONS.shared}
nodes={sharedNodes}
collapsed={collapsedSections.shared}
onToggle={() => toggleSection("shared")}
/>
<SectionList
id="templates"
label="模板中心"
icon={SECTION_ICONS.templates}
nodes={templateNodes}
collapsed={collapsedSections.templates}
onToggle={() => toggleSection("templates")}
/>
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
<button
type="button"
className="flex items-center justify-between px-4 py-3 text-sm font-medium text-gray-600"
onClick={() => toggleSection("private")}
>
<span> / </span>
<MoreHorizontal className="h-4 w-4 text-gray-400" />
</button>
{!collapsedSections.private ? (
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
<button
type="button"
className="flex items-center justify-between px-4 py-3 text-sm font-medium text-gray-600"
onClick={() => toggleSection("private")}
>
<span> / </span>
<MoreHorizontal className="h-4 w-4 text-gray-400" />
</button>
{!collapsedSections.private ? (
<div className="flex-1 px-1 pb-2">
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
<PrivateTree
nodes={filteredPrivateTree}
expanded={expanded}
activeId={activeId}
onToggleExpand={toggleExpand}
onMove={handleMove}
onCreateChild={handleCreate}
onContextMenu={openContextMenu}
/>
</div>
</div>
) : (
<div className="px-4 pb-2 text-xs text-gray-400"></div>
)}
</div>
</>
) : (
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
<div className="flex-1 px-1 pb-2">
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
<PrivateTree
<FileTree
nodes={filteredPrivateTree}
assetsByDoc={assetsByDoc}
expanded={expanded}
activeId={activeId}
onToggleExpand={toggleExpand}
onMove={handleMove}
onOpenDocument={(id) => handleOpenDocument(id, "main")}
onOpenAsset={handleOpenAsset}
onCreateChild={handleCreate}
onContextMenu={openContextMenu}
/>
</div>
</div>
) : (
<div className="px-4 pb-2 text-xs text-gray-400"></div>
)}
</div>
</div>
)}
<div className="border-t border-[#f1f1f1] p-3">
<Button className="w-full justify-center gap-2" variant="outline" onClick={() => handleCreate(null)}>
+7
View File
@@ -7,11 +7,14 @@ interface SidebarState {
width: number;
collapsedSections: Record<SidebarSectionId, boolean>;
trashConfirm: boolean;
viewMode: "section" | "filesystem";
setOpen: (open: boolean) => void;
setWidth: (width: number) => void;
toggleSection: (section: SidebarSectionId) => void;
setSectionCollapsed: (section: SidebarSectionId, collapsed: boolean) => void;
setTrashConfirm: (value: boolean) => void;
setViewMode: (mode: SidebarState["viewMode"]) => void;
setCollapsedSections: (collapsed: Record<SidebarSectionId, boolean>) => void;
}
const sectionDefaults: Record<SidebarSectionId, boolean> = {
@@ -29,6 +32,7 @@ export const useSidebarStore = create<SidebarState>()(
width: 280,
collapsedSections: { ...sectionDefaults },
trashConfirm: true,
viewMode: "section",
setOpen: (open) => set({ open }),
setWidth: (width) => set({ width }),
toggleSection: (section) =>
@@ -46,6 +50,8 @@ export const useSidebarStore = create<SidebarState>()(
},
})),
setTrashConfirm: (value) => set({ trashConfirm: value }),
setViewMode: (mode) => set({ viewMode: mode }),
setCollapsedSections: (collapsed) => set({ collapsedSections: collapsed }),
}),
{
name: "sidebar-ui",
@@ -53,6 +59,7 @@ export const useSidebarStore = create<SidebarState>()(
width: state.width,
collapsedSections: state.collapsedSections,
trashConfirm: state.trashConfirm,
viewMode: state.viewMode,
}),
},
),
+5
View File
@@ -5,11 +5,16 @@ export interface MediaAsset {
asset_type: string;
file_url: string | null;
thumbnail_url: string | null;
bucket?: string | null;
storage_path?: string | null;
file_name: string | null;
file_size: number | null;
mime_type: string | null;
ocr_payload?: unknown;
ocr_strategy?: string | null;
ocr_text: string | null;
ocr_status: string | null;
signed_url?: string | null;
created_at: string;
updated_at: string;
}
+12
View File
@@ -288,9 +288,13 @@ export type Database = {
asset_type: string;
file_url: string | null;
thumbnail_url: string | null;
bucket: string | null;
storage_path: string | null;
file_name: string | null;
file_size: number | null;
mime_type: string | null;
ocr_payload: Json | null;
ocr_strategy: string | null;
ocr_text: string | null;
ocr_status: string | null;
created_by: string | null;
@@ -304,9 +308,13 @@ export type Database = {
asset_type?: string;
file_url?: string | null;
thumbnail_url?: string | null;
bucket?: string | null;
storage_path?: string | null;
file_name?: string | null;
file_size?: number | null;
mime_type?: string | null;
ocr_payload?: Json | null;
ocr_strategy?: string | null;
ocr_text?: string | null;
ocr_status?: string | null;
created_by?: string | null;
@@ -320,9 +328,13 @@ export type Database = {
asset_type?: string;
file_url?: string | null;
thumbnail_url?: string | null;
bucket?: string | null;
storage_path?: string | null;
file_name?: string | null;
file_size?: number | null;
mime_type?: string | null;
ocr_payload?: Json | null;
ocr_strategy?: string | null;
ocr_text?: string | null;
ocr_status?: string | null;
created_by?: string | null;