31 changed files with 3202 additions and 1333 deletions
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,113 +0,0 @@ |
|||||
import { useEffect, useState } from "react"; |
|
||||
import { useQuery } from "@tanstack/react-query"; |
|
||||
import { api } from "../lib/api"; |
|
||||
|
|
||||
type TreeNode = { |
|
||||
id: string; |
|
||||
name: string; |
|
||||
}; |
|
||||
|
|
||||
type DirectoryTreeProps = { |
|
||||
selectedId?: string; |
|
||||
expandedIds?: string[]; |
|
||||
rootLabel?: string; |
|
||||
onOpenDirectory?: (node: TreeNode) => void; |
|
||||
onSelectDirectory?: (node: TreeNode) => void; |
|
||||
}; |
|
||||
|
|
||||
export function DirectoryTree({ |
|
||||
selectedId, |
|
||||
expandedIds = [], |
|
||||
rootLabel = "All files", |
|
||||
onOpenDirectory, |
|
||||
onSelectDirectory, |
|
||||
}: DirectoryTreeProps) { |
|
||||
return ( |
|
||||
<div className="tree-root"> |
|
||||
<DirectoryNode |
|
||||
node={{ id: "dir_root", name: rootLabel }} |
|
||||
level={0} |
|
||||
selectedId={selectedId} |
|
||||
expandedIds={expandedIds} |
|
||||
onOpenDirectory={onOpenDirectory} |
|
||||
onSelectDirectory={onSelectDirectory} |
|
||||
/> |
|
||||
</div> |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
function DirectoryNode({ |
|
||||
node, |
|
||||
level, |
|
||||
selectedId, |
|
||||
expandedIds, |
|
||||
onOpenDirectory, |
|
||||
onSelectDirectory, |
|
||||
}: { |
|
||||
node: TreeNode; |
|
||||
level: number; |
|
||||
selectedId?: string; |
|
||||
expandedIds: string[]; |
|
||||
onOpenDirectory?: (node: TreeNode) => void; |
|
||||
onSelectDirectory?: (node: TreeNode) => void; |
|
||||
}) { |
|
||||
const [isExpanded, setIsExpanded] = useState(level === 0); |
|
||||
const childrenQuery = useQuery({ |
|
||||
queryKey: ["directories", node.id, "tree"], |
|
||||
queryFn: () => api.listDirectoryChildren(node.id), |
|
||||
enabled: isExpanded, |
|
||||
}); |
|
||||
|
|
||||
useEffect(() => { |
|
||||
if (expandedIds.includes(node.id)) { |
|
||||
setIsExpanded(true); |
|
||||
} |
|
||||
}, [expandedIds, node.id]); |
|
||||
|
|
||||
const directories = |
|
||||
childrenQuery.data?.items.filter((item) => item.kind === "directory") ?? []; |
|
||||
|
|
||||
return ( |
|
||||
<div className="tree-node"> |
|
||||
<div |
|
||||
className={selectedId === node.id ? "tree-row active" : "tree-row"} |
|
||||
style={{ paddingLeft: `${level * 12}px` }} |
|
||||
> |
|
||||
<button |
|
||||
type="button" |
|
||||
className="tree-toggle" |
|
||||
onClick={() => setIsExpanded((current) => !current)} |
|
||||
aria-label={isExpanded ? "Collapse folder" : "Expand folder"} |
|
||||
> |
|
||||
{isExpanded ? "−" : "+"} |
|
||||
</button> |
|
||||
<button |
|
||||
type="button" |
|
||||
className="tree-label" |
|
||||
onClick={() => { |
|
||||
onSelectDirectory?.(node); |
|
||||
onOpenDirectory?.(node); |
|
||||
}} |
|
||||
> |
|
||||
{node.name} |
|
||||
</button> |
|
||||
</div> |
|
||||
|
|
||||
{isExpanded && directories.length ? ( |
|
||||
<div className="tree-children"> |
|
||||
{directories.map((directory) => ( |
|
||||
<DirectoryNode |
|
||||
key={directory.id} |
|
||||
node={{ id: directory.id, name: directory.name }} |
|
||||
level={level + 1} |
|
||||
selectedId={selectedId} |
|
||||
expandedIds={expandedIds} |
|
||||
onOpenDirectory={onOpenDirectory} |
|
||||
onSelectDirectory={onSelectDirectory} |
|
||||
/> |
|
||||
))} |
|
||||
</div> |
|
||||
) : null} |
|
||||
</div> |
|
||||
); |
|
||||
} |
|
||||
@ -0,0 +1,117 @@ |
|||||
|
type PaginationControlsProps = { |
||||
|
page: number; |
||||
|
pageSize: number; |
||||
|
totalItems: number; |
||||
|
totalPages: number; |
||||
|
onPageChange: (page: number) => void; |
||||
|
onPageSizeChange: (pageSize: number) => void; |
||||
|
pageSizeOptions?: number[]; |
||||
|
label: string; |
||||
|
}; |
||||
|
|
||||
|
export function PaginationControls({ |
||||
|
page, |
||||
|
pageSize, |
||||
|
totalItems, |
||||
|
totalPages, |
||||
|
onPageChange, |
||||
|
onPageSizeChange, |
||||
|
pageSizeOptions = [8, 16, 32, 64], |
||||
|
label, |
||||
|
}: PaginationControlsProps) { |
||||
|
const safeTotalPages = Math.max(1, totalPages); |
||||
|
const pageItems = getPaginationItems(page, safeTotalPages); |
||||
|
|
||||
|
return ( |
||||
|
<div className="pagination-bar pagination-bar-full" aria-label={label}> |
||||
|
<span className="pagination-copy"> |
||||
|
{totalItems} items |
||||
|
</span> |
||||
|
<div className="pagination-controls" role="group" aria-label={`${label} controls`}> |
||||
|
<button |
||||
|
className="pagination-page-button pagination-arrow-button" |
||||
|
type="button" |
||||
|
aria-label="Previous page" |
||||
|
disabled={page === 1} |
||||
|
onClick={() => onPageChange(Math.max(1, page - 1))} |
||||
|
> |
||||
|
‹ |
||||
|
</button> |
||||
|
<div className="pagination-page-list" aria-label={`Page ${page} of ${safeTotalPages}`}> |
||||
|
{pageItems.map((item, index) => |
||||
|
item === "ellipsis" ? ( |
||||
|
<span key={`ellipsis-${index}`} className="pagination-ellipsis" aria-hidden="true"> |
||||
|
... |
||||
|
</span> |
||||
|
) : ( |
||||
|
<button |
||||
|
key={item} |
||||
|
className={item === page ? "pagination-page-button active" : "pagination-page-button"} |
||||
|
type="button" |
||||
|
aria-current={item === page ? "page" : undefined} |
||||
|
onClick={() => onPageChange(item)} |
||||
|
> |
||||
|
{item} |
||||
|
</button> |
||||
|
), |
||||
|
)} |
||||
|
</div> |
||||
|
<button |
||||
|
className="pagination-page-button pagination-arrow-button" |
||||
|
type="button" |
||||
|
aria-label="Next page" |
||||
|
disabled={page === safeTotalPages} |
||||
|
onClick={() => onPageChange(Math.min(safeTotalPages, page + 1))} |
||||
|
> |
||||
|
› |
||||
|
</button> |
||||
|
<label className="pagination-size-control"> |
||||
|
<select |
||||
|
className="input-select pagination-select" |
||||
|
aria-label="Rows per page" |
||||
|
value={pageSize} |
||||
|
onChange={(event) => onPageSizeChange(Number(event.target.value))} |
||||
|
> |
||||
|
{pageSizeOptions.map((option) => ( |
||||
|
<option key={option} value={option}> |
||||
|
{option} / page |
||||
|
</option> |
||||
|
))} |
||||
|
</select> |
||||
|
</label> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
function getPaginationItems(page: number, totalPages: number): Array<number | "ellipsis"> { |
||||
|
if (totalPages <= 7) { |
||||
|
return Array.from({ length: totalPages }, (_, index) => index + 1); |
||||
|
} |
||||
|
|
||||
|
const pages = new Set<number>([1, totalPages, page - 1, page, page + 1]); |
||||
|
if (page <= 4) { |
||||
|
[2, 3, 4, 5].forEach((item) => pages.add(item)); |
||||
|
} |
||||
|
if (page >= totalPages - 3) { |
||||
|
[totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1].forEach((item) => pages.add(item)); |
||||
|
} |
||||
|
|
||||
|
const sortedPages = Array.from(pages) |
||||
|
.filter((item) => item >= 1 && item <= totalPages) |
||||
|
.sort((first, second) => first - second); |
||||
|
|
||||
|
return sortedPages.flatMap((item, index) => { |
||||
|
const previous = sortedPages[index - 1]; |
||||
|
if (!previous) { |
||||
|
return [item]; |
||||
|
} |
||||
|
if (item - previous === 2) { |
||||
|
return [previous + 1, item]; |
||||
|
} |
||||
|
if (item - previous > 2) { |
||||
|
return ["ellipsis", item]; |
||||
|
} |
||||
|
return [item]; |
||||
|
}); |
||||
|
} |
||||
@ -1,257 +0,0 @@ |
|||||
import { useEffect, useMemo, useRef, useState } from "react"; |
|
||||
import { DirectoryTree } from "../components/DirectoryTree"; |
|
||||
import { formatBytes } from "../lib/format"; |
|
||||
import { uploadFileToDirectory } from "../lib/uploads"; |
|
||||
import { useToast } from "../features/toast/ToastProvider"; |
|
||||
|
|
||||
type UploadItem = { |
|
||||
id: string; |
|
||||
name: string; |
|
||||
size: number; |
|
||||
status: string; |
|
||||
progress: number; |
|
||||
uploadedFileId?: string; |
|
||||
}; |
|
||||
|
|
||||
export function UploadsPage() { |
|
||||
const inputRef = useRef<HTMLInputElement | null>(null); |
|
||||
const { pushToast } = useToast(); |
|
||||
const [items, setItems] = useState<UploadItem[]>([]); |
|
||||
const [directoryId, setDirectoryId] = useState("dir_root"); |
|
||||
const [page, setPage] = useState(1); |
|
||||
|
|
||||
const completedCount = useMemo( |
|
||||
() => items.filter((item) => item.status === "Completed").length, |
|
||||
[items], |
|
||||
); |
|
||||
const activeCount = useMemo( |
|
||||
() => items.filter((item) => item.status !== "Completed").length, |
|
||||
[items], |
|
||||
); |
|
||||
const pageSize = 5; |
|
||||
const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); |
|
||||
const pagedItems = useMemo( |
|
||||
() => items.slice((page - 1) * pageSize, page * pageSize), |
|
||||
[items, page], |
|
||||
); |
|
||||
|
|
||||
async function uploadFile(file: File) { |
|
||||
const tempId = crypto.randomUUID(); |
|
||||
setItems((current) => [ |
|
||||
{ id: tempId, name: file.name, size: file.size, status: "Starting", progress: 0 }, |
|
||||
...current, |
|
||||
]); |
|
||||
|
|
||||
try { |
|
||||
const payload = await uploadFileToDirectory(file, directoryId, (progress, stage, uploadedFileId) => { |
|
||||
setItems((current) => |
|
||||
current.map((item) => |
|
||||
item.id === tempId |
|
||||
? { |
|
||||
...item, |
|
||||
status: stage === "completed" ? "Completed" : stage[0].toUpperCase() + stage.slice(1), |
|
||||
progress, |
|
||||
uploadedFileId, |
|
||||
} |
|
||||
: item, |
|
||||
), |
|
||||
); |
|
||||
}); |
|
||||
setItems((current) => |
|
||||
current.map((item) => |
|
||||
item.id === tempId |
|
||||
? { |
|
||||
...item, |
|
||||
status: "Completed", |
|
||||
progress: 100, |
|
||||
uploadedFileId: payload.file.id as string, |
|
||||
} |
|
||||
: item, |
|
||||
), |
|
||||
); |
|
||||
pushToast(`Uploaded ${file.name}.`, "success"); |
|
||||
} catch (error) { |
|
||||
pushToast(error instanceof Error ? error.message : "Upload failed.", "error"); |
|
||||
setItems((current) => |
|
||||
current.map((item) => |
|
||||
item.id === tempId |
|
||||
? { |
|
||||
...item, |
|
||||
status: error instanceof Error ? error.message : "Upload failed", |
|
||||
} |
|
||||
: item, |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async function handleFiles(fileList: FileList | null) { |
|
||||
if (!fileList?.length) { |
|
||||
return; |
|
||||
} |
|
||||
for (const file of Array.from(fileList)) { |
|
||||
await uploadFile(file); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
useEffect(() => { |
|
||||
if (page > totalPages) { |
|
||||
setPage(totalPages); |
|
||||
} |
|
||||
}, [page, totalPages]); |
|
||||
|
|
||||
return ( |
|
||||
<div className="compact-page-shell compact-page-shell-wide"> |
|
||||
<div className="uploads-page-grid"> |
|
||||
<div className="uploads-main"> |
|
||||
<section className="panel uploads-primary-panel"> |
|
||||
<div className="panel-toolbar"> |
|
||||
<div className="section-heading"> |
|
||||
<span className="eyebrow">Uploads</span> |
|
||||
<h3>Browser upload queue</h3> |
|
||||
<p className="item-muted">Send files to a selected folder and keep the queue in view.</p> |
|
||||
</div> |
|
||||
<button type="button" onClick={() => inputRef.current?.click()}> |
|
||||
Choose Files |
|
||||
</button> |
|
||||
</div> |
|
||||
<div className="summary-strip uploads-summary-strip"> |
|
||||
<article className="summary-card"> |
|
||||
<span className="metric-label">Target</span> |
|
||||
<strong>{directoryId}</strong> |
|
||||
</article> |
|
||||
<article className="summary-card"> |
|
||||
<span className="metric-label">Active</span> |
|
||||
<strong>{activeCount}</strong> |
|
||||
</article> |
|
||||
<article className="summary-card"> |
|
||||
<span className="metric-label">Completed</span> |
|
||||
<strong>{completedCount}</strong> |
|
||||
</article> |
|
||||
</div> |
|
||||
|
|
||||
<div |
|
||||
className="dropzone uploads-dropzone" |
|
||||
onDragOver={(event) => event.preventDefault()} |
|
||||
onDrop={(event) => { |
|
||||
event.preventDefault(); |
|
||||
void handleFiles(event.dataTransfer.files); |
|
||||
}} |
|
||||
> |
|
||||
<div className="uploads-dropzone-copy"> |
|
||||
<strong>Drop files here</strong> |
|
||||
<p>Files upload into the selected folder and stay visible in the queue below.</p> |
|
||||
</div> |
|
||||
<div className="uploads-target-inline"> |
|
||||
<span className="metric-label">Target directory ID</span> |
|
||||
<input |
|
||||
value={directoryId} |
|
||||
onChange={(event) => setDirectoryId(event.target.value)} |
|
||||
aria-label="Target directory id" |
|
||||
/> |
|
||||
<span className="item-muted">Current target: {directoryId}</span> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<input |
|
||||
ref={inputRef} |
|
||||
hidden |
|
||||
type="file" |
|
||||
multiple |
|
||||
onChange={(event) => void handleFiles(event.target.files)} |
|
||||
/> |
|
||||
<div className="uploads-queue-section"> |
|
||||
<div className="panel-toolbar compact-toolbar"> |
|
||||
<div className="section-heading"> |
|
||||
<span className="eyebrow">Queue</span> |
|
||||
<h3>Recent uploads</h3> |
|
||||
</div> |
|
||||
<div className="inline-metrics"> |
|
||||
<span>{items.length} queued</span> |
|
||||
<span>{completedCount} completed</span> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<div className="list-surface"> |
|
||||
<div className="list-table"> |
|
||||
{items.length ? ( |
|
||||
pagedItems.map((item) => ( |
|
||||
<div key={item.id} className="table-row"> |
|
||||
<div> |
|
||||
<strong>{item.name}</strong> |
|
||||
<span className="item-muted">{formatBytes(item.size)}</span> |
|
||||
</div> |
|
||||
<div className="table-meta"> |
|
||||
<span className="status-chip">{item.status}</span> |
|
||||
<span>{item.progress}%</span> |
|
||||
{item.uploadedFileId ? ( |
|
||||
<a href={`/app/files`} className="inline-link"> |
|
||||
Open files |
|
||||
</a> |
|
||||
) : null} |
|
||||
</div> |
|
||||
</div> |
|
||||
)) |
|
||||
) : ( |
|
||||
<div className="empty-state empty-state-rich"> |
|
||||
<strong>Your upload queue will appear here.</strong> |
|
||||
<p className="item-muted">Choose files or drag them into the drop zone to start a new upload.</p> |
|
||||
</div> |
|
||||
)} |
|
||||
</div> |
|
||||
</div> |
|
||||
{items.length > pageSize ? ( |
|
||||
<div className="pagination-bar" aria-label="Uploads pagination"> |
|
||||
<span className="pagination-copy"> |
|
||||
Page {page} of {totalPages} |
|
||||
</span> |
|
||||
<div className="toolbar-group"> |
|
||||
<button |
|
||||
className="ghost-button compact-button" |
|
||||
type="button" |
|
||||
disabled={page === 1} |
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))} |
|
||||
> |
|
||||
Previous |
|
||||
</button> |
|
||||
<button |
|
||||
className="ghost-button compact-button" |
|
||||
type="button" |
|
||||
disabled={page === totalPages} |
|
||||
onClick={() => setPage((current) => Math.min(totalPages, current + 1))} |
|
||||
> |
|
||||
Next |
|
||||
</button> |
|
||||
</div> |
|
||||
</div> |
|
||||
) : null} |
|
||||
</div> |
|
||||
</section> |
|
||||
</div> |
|
||||
|
|
||||
<aside className="panel uploads-side-panel"> |
|
||||
<div className="section-heading"> |
|
||||
<span className="eyebrow">Target Folder</span> |
|
||||
<h3>Choose destination</h3> |
|
||||
</div> |
|
||||
<DirectoryTree |
|
||||
selectedId={directoryId} |
|
||||
expandedIds={["dir_root"]} |
|
||||
rootLabel="Root" |
|
||||
onSelectDirectory={(node) => setDirectoryId(node.id)} |
|
||||
/> |
|
||||
<div className="meta-list compact-meta-list"> |
|
||||
<div> |
|
||||
<span>Selected target</span> |
|
||||
<strong>{directoryId}</strong> |
|
||||
</div> |
|
||||
<div> |
|
||||
<span>Queued items</span> |
|
||||
<strong>{items.length}</strong> |
|
||||
</div> |
|
||||
</div> |
|
||||
</aside> |
|
||||
</div> |
|
||||
</div> |
|
||||
); |
|
||||
} |
|
||||
File diff suppressed because it is too large
Loading…
Reference in new issue