/projects/files/browseFile browser
The drive one folder at a time: the open folder in the URL, every row selectable, a star on each, rename and delete from the row menu, an upload sheet with a dropzone, and a bulk bar for what is ticked.
Installs at /projects/files/browse, the Browse leaf under Files. The page is a server component inside AppShell: it reads every row through db.files and hands them to one client island, which reads the open folder off ?folder= — inside a Suspense boundary, so a generated project still prerenders — and filters to it in the browser. A folder's name is a next/link that rewrites ?folder= and keeps every other parameter, so a framed preview's own settings ride along; the trail above the table names the folder and links back to the root. A drop stages the files in the sheet and the Upload button sends them — a deliberate reading of the spec's "a drop calls uploadFiles", so a mis-drop can be taken back before anything reaches the drive. Every write goes through a server action that re-reads the row it was asked about: uploadFiles takes only the dropped names and works the kind, size and owner out itself, refusing a name the folder already has; renameFile refuses a blank or a twin; deleteFiles takes a folder with everything in it and refuses an id it cannot find; toggleStar flips the row's star. An accepted change edits the rows on the page at once, a refusal is an alert where the reader was looking — above the table, inside the sheet, inside the dialog — and a success is the status line under the rows. Composes AppShell, PageHeader, Breadcrumb, DataTable, DateCell, Avatar, Button, SelectionActionBar, ConfirmDialog, Dialog, Sheet, FileDropzone, Input, Label, AsyncButton, Callout and Spinner.
Preview
import * as React from "react"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { Spinner } from "@/components/ui/spinner"
import { signOut } from "./actions"
import { FilesBrowser } from "./components/files-browser"
import { browserRows, currentUser, driveSummary, shellNotifications } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/projects/nav"
/**
* The drive, folder by folder. The page is a server component: it reads
* every row through `db.files` and hands them to one client island, which
* reads the open folder off the URL — inside a Suspense boundary, because a
* `useSearchParams` outside one bails a prerendered page out to the nearest
* boundary and a generated project would fail to build.
*/
export default function FilesBrowserPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.filesBrowser}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="projects-files-browser-title"
title="File browser"
description="Every file in the workspace, one folder at a time."
meta={driveSummary()}
/>
<React.Suspense
fallback={
<div className="flex min-h-64 items-center justify-center">
<Spinner className="size-5 text-muted-foreground" />
</div>
}
>
<FilesBrowser titleId="projects-files-browser-title" rows={browserRows()} />
</React.Suspense>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/projects-files-browserNeeds the @vibra registry in your components.json — set it up once.
Source
import * as React from "react"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { Spinner } from "@/components/ui/spinner"
import { signOut } from "./actions"
import { FilesBrowser } from "./components/files-browser"
import { browserRows, currentUser, driveSummary, shellNotifications } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/projects/nav"
/**
* The drive, folder by folder. The page is a server component: it reads
* every row through `db.files` and hands them to one client island, which
* reads the open folder off the URL — inside a Suspense boundary, because a
* `useSearchParams` outside one bails a prerendered page out to the nearest
* boundary and a generated project would fail to build.
*/
export default function FilesBrowserPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.filesBrowser}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="projects-files-browser-title"
title="File browser"
description="Every file in the workspace, one folder at a time."
meta={driveSummary()}
/>
<React.Suspense
fallback={
<div className="flex min-h-64 items-center justify-center">
<Spinner className="size-5 text-muted-foreground" />
</div>
}
>
<FilesBrowser titleId="projects-files-browser-title" rows={browserRows()} />
</React.Suspense>
</AppShell>
)
}/**
* What this page reads. Every row — the twelve folders and everything in or
* beside them — comes from `db.files`, with its owner resolved against
* `db.members`; the browser filters by folder in the browser, because the
* folder is the URL's to choose. "Now" is `REFERENCE_DATE`.
*
* The client island imports only the types below. The icons and words the
* page puts on a kind live beside it in `components/files-vocabulary.ts`, so
* nothing that reaches `db` is ever pulled into the browser.
*/
import { getInitials } from "@/lib/format"
import { db, type File, type Member } from "@/lib/sample-data"
export type FileKind = File["kind"]
/** One row as the browser prints it. Exported because every action returns one. */
export type FileRow = {
id: string
name: string
kind: FileKind
sizeBytes: number
ownerName: string
ownerInitials: string
ownerAvatar?: string
/** The folder it sits in, or undefined at the root. */
parentId?: string
modifiedAt: Date
starred: boolean
}
// Read per call, never held at module scope: a member renamed since the
// server started is the name on the next render.
const membersById = () => new Map(db.members.all().map((member) => [member.id, member]))
export function toRow(file: File, members = membersById()): FileRow {
const owner = members.get(file.ownerId)
return {
id: file.id,
name: file.name,
kind: file.kind,
sizeBytes: file.sizeBytes,
ownerName: owner?.name ?? "Someone",
ownerInitials: getInitials(owner?.name ?? "Someone"),
ownerAvatar: owner?.avatarUrl,
parentId: file.parentId,
modifiedAt: file.modifiedAt,
starred: file.starred,
}
}
/** Every row on the drive, folders first and then by name, the way a browser lists them. */
export function browserRows(): FileRow[] {
const members = membersById()
return db.files
.all()
.map((file) => toRow(file, members))
.sort((a, b) => {
const folder = Number(b.kind === "folder") - Number(a.kind === "folder")
return folder || a.name.localeCompare(b.name)
})
}
/** The line under the title: how much the drive holds. */
export function driveSummary(): string {
const rows = db.files.all()
const folders = rows.filter((row) => row.kind === "folder").length
return `${rows.length - folders} files in ${folders} folders`
}
/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The person looking at the page — and the owner of anything they upload. */
export function currentUser() {
const owner = ownerRow()
return { id: owner.id, name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { REFERENCE_DATE, asString, db, invalidInput, ownKey, seeded, type File, type Result } from "@/lib/sample-data"
import { currentUser, toRow, type FileRow } from "./data"
/**
* The four things this page changes, plus sign-out. Server actions, so the
* page can stay a server component and still hand its client island
* something to call, and `Result` so every caller reads one success-or-error
* shape. Every action re-reads the row it was asked about and trusts that
* row, never the form.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}
// A file's kind from its extension; anything unknown is "other".
const KIND_BY_EXTENSION: Record<string, File["kind"]> = {
pdf: "document", doc: "document", docx: "document", xls: "document", xlsx: "document",
ppt: "document", pptx: "document", md: "document", txt: "document",
png: "image", jpg: "image", jpeg: "image", gif: "image", svg: "image", webp: "image",
mp4: "video", mov: "video", webm: "video",
mp3: "audio", m4a: "audio", wav: "audio",
zip: "archive", gz: "archive", tar: "archive", rar: "archive",
}
function kindOf(name: string): File["kind"] {
const extension = name.toLowerCase().split(".").pop() ?? ""
// The table's own keys only: a file named "constructor" is not a function.
return ownKey(KIND_BY_EXTENSION, extension) ? KIND_BY_EXTENSION[extension] : "other"
}
/** A plausible size for a name, drawn from the name itself so the same upload weighs the same twice. */
function sizeFor(name: string): number {
const rand = seeded(`upload:${name}`)
const kind = kindOf(name)
const [min, max] =
kind === "video" ? [200_000_000, 2_000_000_000] : kind === "archive" ? [20_000_000, 800_000_000] : kind === "image" ? [100_000, 8_000_000] : [10_000, 4_000_000]
return Math.round(min + rand() * (max - min))
}
async function folderNamed(id: string | undefined): Promise<Result<File | undefined>> {
if (id === undefined) return { ok: true, data: undefined }
const folder = await db.files.get(id)
if (!folder || folder.kind !== "folder") {
return { ok: false, error: { code: "not_found", message: `No folder with id "${id}"` } }
}
return { ok: true, data: folder }
}
const MAX_NAME = 120
/** The most files one drop adds: a folder's worth, not a flood of rows in a store every visitor shares. */
const MAX_UPLOAD = 50
/**
* Adds files to a folder — or to the root when none is named. The names are
* what the dropzone took; the kind, the size and the owner are worked out
* here. A name already in that folder is refused rather than doubled.
*/
export async function uploadFiles(names: string[], parentId?: string): Promise<Result<FileRow[]>> {
if (!Array.isArray(names) || names.some((name) => typeof name !== "string")) {
return invalidInput("Upload files by name.", "files")
}
if (names.length > MAX_UPLOAD) {
return { ok: false, error: { code: "too_many", message: `Upload ${MAX_UPLOAD} files or fewer at a time.`, field: "files" } }
}
const cleaned = names.map((name) => name.trim()).filter(Boolean)
if (cleaned.length === 0) {
return { ok: false, error: { code: "required", message: "Drop at least one file to upload." } }
}
const long = cleaned.find((name) => name.length > MAX_NAME)
if (long) {
return { ok: false, error: { code: "too_long", message: `Keep each name under ${MAX_NAME} characters.`, field: "files" } }
}
const repeated = cleaned.find((name, index) => cleaned.indexOf(name) !== index)
if (repeated) {
return { ok: false, error: { code: "conflict", message: `${repeated} is in this drop twice.`, field: "files" } }
}
const folder = await folderNamed(parentId)
if (!folder.ok) return folder
const siblings = new Set(db.files.all().filter((row) => row.parentId === parentId).map((row) => row.name))
const twin = cleaned.find((name) => siblings.has(name))
if (twin) {
return {
ok: false,
error: { code: "conflict", message: `${twin} is already in ${folder.data?.name ?? "All files"}.` },
}
}
const rows: FileRow[] = []
for (const name of cleaned) {
const created = await db.files.create({
name,
kind: kindOf(name),
sizeBytes: sizeFor(name),
ownerId: currentUser().id,
parentId,
modifiedAt: REFERENCE_DATE,
starred: false,
})
if (!created.ok) return created
rows.push(toRow(created.data))
}
if (folder.data) await db.files.update(folder.data.id, { modifiedAt: REFERENCE_DATE })
return { ok: true, data: rows }
}
/** Stars a file, or takes the star off again. */
export async function toggleStar(id: string): Promise<Result<FileRow>> {
const file = await db.files.get(id)
if (!file) {
return { ok: false, error: { code: "not_found", message: `No file with id "${id}"` } }
}
const updated = await db.files.update(id, { starred: !file.starred })
if (!updated.ok) return updated
return { ok: true, data: toRow(updated.data) }
}
/** Renames a file or a folder. Blank is refused; so is a name a sibling already has. */
export async function renameFile(id: string, name: string): Promise<Result<FileRow>> {
const file = await db.files.get(id)
if (!file) {
return { ok: false, error: { code: "not_found", message: `No file with id "${id}"` } }
}
const trimmed = asString(name).trim()
if (!trimmed) {
return { ok: false, error: { code: "required", message: "Give the file a name.", field: "name" } }
}
if (trimmed.length > MAX_NAME) {
return { ok: false, error: { code: "too_long", message: `Keep the name under ${MAX_NAME} characters.`, field: "name" } }
}
const twin = db.files.all().some((row) => row.id !== id && row.parentId === file.parentId && row.name === trimmed)
if (twin) {
return { ok: false, error: { code: "conflict", message: `Something here is already called ${trimmed}.`, field: "name" } }
}
const updated = await db.files.update(id, { name: trimmed, modifiedAt: REFERENCE_DATE })
if (!updated.ok) return updated
return { ok: true, data: toRow(updated.data) }
}
/** Removes files; a folder goes with everything in it. Every id has to exist, or nothing is removed. */
export async function deleteFiles(ids: string[]): Promise<Result<{ ids: string[]; count: number }>> {
if (!Array.isArray(ids) || ids.length === 0) {
return { ok: false, error: { code: "required", message: "Tick at least one file to delete." } }
}
const rows = db.files.all()
const missing = ids.find((id) => !rows.some((row) => row.id === id))
if (missing) {
return { ok: false, error: { code: "not_found", message: `No file with id "${missing}"` } }
}
// The cascade runs until nothing new joins it. Today one pass would do —
// every folder sits at the root, an invariant entities-f.test.ts pins — but
// a nested folder some later entity allows must take its own children with
// it rather than leave them pointing at a parent that is gone.
const doomed = new Set(ids)
let grew = true
while (grew) {
grew = false
for (const row of rows) {
if (row.parentId && doomed.has(row.parentId) && !doomed.has(row.id)) {
doomed.add(row.id)
grew = true
}
}
}
for (const id of doomed) {
const removed = await db.files.remove(id)
if (!removed.ok) return removed
}
return { ok: true, data: { ids: [...doomed], count: doomed.size } }
}"use client"
import Link from "next/link"
import { PencilIcon, StarIcon, Trash2Icon } from "lucide-react"
import { formatBytes } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
DataTableColumnHeader,
DataTableRowActions,
selectionColumn,
type DataTableColumnDef,
} from "@/components/ui/data-table"
import { DateCell } from "@/components/ui/table-cells"
import { type FileRow } from "../data"
import { KIND_ICONS, KIND_LABELS } from "./files-vocabulary"
export type ColumnHandlers = {
/** The href that opens a folder, with the page's own query kept. */
hrefFor: (folderId: string) => string
onStar: (row: FileRow) => void
onRename: (row: FileRow) => void
onDelete: (row: FileRow) => void
}
/**
* The browser's columns. A folder's name is a link that rewrites `?folder=`,
* so the open folder is the URL's and a step into one can be shared or
* reloaded; a file's name is text. The star is the row's own toggle and the
* menu holds the two things that need a second look.
*/
export function fileColumns({ hrefFor, onStar, onRename, onDelete }: ColumnHandlers): DataTableColumnDef<FileRow>[] {
return [
selectionColumn<FileRow>({ rowLabel: (row) => row.name }),
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
cell: ({ row }) => {
const Icon = KIND_ICONS[row.original.kind] ?? KIND_ICONS.other
const folder = row.original.kind === "folder"
return (
<span className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"
>
<Icon className="size-4" />
</span>
{folder ? (
<Link href={hrefFor(row.original.id)} className="truncate font-medium underline-offset-4 hover:underline">
{row.original.name}
</Link>
) : (
<span className="truncate font-medium">{row.original.name}</span>
)}
</span>
)
},
meta: { label: "Name" },
},
{
accessorKey: "kind",
header: ({ column }) => <DataTableColumnHeader column={column} title="Kind" />,
cell: ({ row }) => <span className="text-muted-foreground">{KIND_LABELS[row.original.kind]}</span>,
meta: { label: "Kind" },
},
{
accessorKey: "modifiedAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Modified" />,
cell: ({ row }) => <DateCell date={row.original.modifiedAt} className="text-muted-foreground" />,
meta: { label: "Modified" },
},
{
accessorKey: "sizeBytes",
header: ({ column }) => <DataTableColumnHeader column={column} title="Size" />,
cell: ({ row }) => <span className="tabular-nums">{formatBytes(row.original.sizeBytes)}</span>,
meta: { align: "right", label: "Size" },
},
{
accessorKey: "ownerName",
header: ({ column }) => <DataTableColumnHeader column={column} title="Owner" />,
cell: ({ row }) => (
<Avatar size="sm" aria-label={row.original.ownerName} title={row.original.ownerName}>
{row.original.ownerAvatar ? <AvatarImage src={row.original.ownerAvatar} alt="" /> : null}
<AvatarFallback>{row.original.ownerInitials}</AvatarFallback>
</Avatar>
),
meta: { label: "Owner" },
},
{
id: "starred",
accessorFn: (row) => (row.starred ? 1 : 0),
header: ({ column }) => <DataTableColumnHeader column={column} title="Star" />,
cell: ({ row }) => (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-pressed={row.original.starred}
aria-label={`${row.original.starred ? "Unstar" : "Star"} ${row.original.name}`}
onClick={() => onStar(row.original)}
className={row.original.starred ? "text-warning" : "text-muted-foreground"}
>
<StarIcon aria-hidden="true" className={row.original.starred ? "fill-current" : undefined} />
</Button>
),
meta: { align: "center", label: "Star" },
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<DataTableRowActions
label={`Open menu for ${row.original.name}`}
actions={[
{ label: "Rename", icon: <PencilIcon />, onSelect: () => onRename(row.original) },
{ label: "Delete", icon: <Trash2Icon />, destructive: true, separatorBefore: true, onSelect: () => onDelete(row.original) },
]}
/>
),
meta: { align: "right", label: "Actions" },
},
]
}"use client"
import * as React from "react"
import Link from "next/link"
import { useSearchParams } from "next/navigation"
import { DownloadIcon, Trash2Icon } from "lucide-react"
import type { RowSelectionState } from "@tanstack/react-table"
import { SelectionActionBar } from "@/components/ui/action-bar"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { DataTable } from "@/components/ui/data-table"
import { deleteFiles, renameFile, toggleStar, uploadFiles } from "../actions"
import { type FileRow } from "../data"
import { fileColumns } from "./file-columns"
import { FOLDER_PARAM, fileCount, folderHref } from "./files-vocabulary"
import { RenameDialog } from "./rename-dialog"
import { UploadSheet } from "./upload-sheet"
/**
* The drive, one folder at a time. The open folder is `?folder=` in the URL
* — read here, inside the page's Suspense boundary — so a step into a folder
* is a link, a reload lands on the same folder, and a preview can deep-link.
* The rows arrive from the server page and this island owns them from then
* on: every action writes through the server and, when it is accepted, edits
* the rows here so the table shows the result without a second read. A
* refusal is an alert above the table; a success is the status line under it.
*/
export function FilesBrowser({ titleId, rows: initial }: { titleId: string; rows: FileRow[] }) {
const params = useSearchParams()
const folderId = params.get(FOLDER_PARAM) ?? undefined
const [rows, setRows] = React.useState(initial)
const [selection, setSelection] = React.useState<RowSelectionState>({})
const [renaming, setRenaming] = React.useState<FileRow | null>(null)
const [deleting, setDeleting] = React.useState<FileRow[] | null>(null)
const [notice, setNotice] = React.useState("")
const [refusal, setRefusal] = React.useState("")
const folder = folderId ? rows.find((row) => row.id === folderId && row.kind === "folder") : undefined
const visible = React.useMemo(
() => rows.filter((row) => (folder ? row.parentId === folder.id : row.parentId === undefined)),
[rows, folder]
)
const selected = visible.filter((row) => selection[row.id])
const destination = folder?.name ?? "All files"
// The search params object is what the hrefs are built from; the columns
// are rebuilt only when it changes.
const paramsKey = params.toString()
// Only setters, which never change, so the columns holding the star can
// be built once rather than on every keystroke in the search box.
const say = React.useCallback((message: string) => {
setRefusal("")
setNotice(message)
}, [])
const refuse = React.useCallback((message: string) => {
setNotice("")
setRefusal(message)
}, [])
const star = React.useCallback(
async (row: FileRow) => {
const result = await toggleStar(row.id)
if (!result.ok) return refuse(result.error.message)
setRows((current) => current.map((entry) => (entry.id === row.id ? result.data : entry)))
say(`${result.data.starred ? "Starred" : "Unstarred"} ${result.data.name}.`)
},
[say, refuse]
)
async function rename(row: FileRow, name: string) {
const result = await renameFile(row.id, name)
if (!result.ok) return { ok: false, message: result.error.message }
setRows((current) => current.map((entry) => (entry.id === row.id ? result.data : entry)))
say(`Renamed ${row.name} to ${result.data.name}.`)
return { ok: true, message: "" }
}
async function remove(targets: FileRow[]) {
const result = await deleteFiles(targets.map((row) => row.id))
if (!result.ok) return refuse(result.error.message)
const gone = new Set(result.data.ids)
setRows((current) => current.filter((row) => !gone.has(row.id)))
setSelection({})
say(`Deleted ${fileCount(result.data.count)}.`)
}
async function upload(names: string[]) {
const result = await uploadFiles(names, folder?.id)
if (!result.ok) return { ok: false, message: result.error.message }
setRows((current) => [...result.data, ...current])
say(`Uploaded ${fileCount(result.data.length)} to ${destination}.`)
return { ok: true, message: "" }
}
const columns = React.useMemo(
() =>
fileColumns({
hrefFor: (id) => folderHref(new URLSearchParams(paramsKey), id),
onStar: star,
onRename: setRenaming,
onDelete: (row) => setDeleting([row]),
}),
[paramsKey, star]
)
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<Breadcrumb aria-label="Folder">
<BreadcrumbList>
<BreadcrumbItem>
{folder ? (
<BreadcrumbLink render={<Link href={folderHref(new URLSearchParams(paramsKey), undefined)} />}>
All files
</BreadcrumbLink>
) : (
<BreadcrumbPage>All files</BreadcrumbPage>
)}
</BreadcrumbItem>
{folder ? (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{folder.name}</BreadcrumbPage>
</BreadcrumbItem>
</>
) : null}
</BreadcrumbList>
</Breadcrumb>
<UploadSheet destination={destination} onUpload={upload} />
</div>
{refusal ? (
<Callout variant="danger" role="alert">
{refusal}
</Callout>
) : null}
<DataTable
aria-labelledby={titleId}
columns={columns}
data={visible}
searchKey="name"
searchPlaceholder="Search files and folders"
getRowId={(row) => row.id}
state={{ rowSelection: selection }}
onRowSelectionChange={(updater) =>
setSelection((current) => (typeof updater === "function" ? updater(current) : updater))
}
pageSize={25}
pageSizeOptions={[10, 25, 50]}
emptyMessage={folder ? "This folder is empty." : "Nothing on the drive yet."}
/>
<p role="status" aria-label="Outcome" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{notice}
</p>
{selected.length > 0 ? (
<div className="sticky bottom-6 z-40 flex justify-center">
<SelectionActionBar className="static" count={selected.length} itemLabel="file" onClear={() => setSelection({})}>
<Button variant="ghost" size="sm" onClick={() => say(`Preparing ${fileCount(selected.length)} for download.`)}>
<DownloadIcon data-icon="inline-start" />
Download
</Button>
<Button size="sm" variant="destructive" onClick={() => setDeleting(selected)}>
<Trash2Icon data-icon="inline-start" />
Delete
</Button>
</SelectionActionBar>
</div>
) : null}
<RenameDialog row={renaming} onClose={() => setRenaming(null)} onRename={rename} />
<ConfirmDialog
open={deleting !== null}
onOpenChange={(next) => {
if (!next) setDeleting(null)
}}
variant="destructive"
title={deleting ? `Delete ${fileCount(deleting.length)}?` : "Delete"}
description="A folder goes with everything in it. This cannot be undone."
confirmText={deleting ? `Delete ${fileCount(deleting.length)}` : "Delete"}
onConfirm={async () => {
if (deleting) await remove(deleting)
setDeleting(null)
}}
/>
</div>
)
}import {
ArchiveIcon,
FileIcon,
FileTextIcon,
FolderIcon,
ImageIcon,
MusicIcon,
VideoIcon,
type LucideIcon,
} from "lucide-react"
/**
* The words and icons this page puts on a kind of file, and the one rule for
* a folder's link. Vocabulary, not data — it lives beside the island that
* prints it rather than in `data.ts`, which reads `db` and must never reach
* the browser.
*/
export const KIND_LABELS: Record<string, string> = {
folder: "Folder",
document: "Document",
image: "Image",
video: "Video",
audio: "Audio",
archive: "Archive",
other: "Other",
}
export const KIND_ICONS: Record<string, LucideIcon> = {
folder: FolderIcon,
document: FileTextIcon,
image: ImageIcon,
video: VideoIcon,
audio: MusicIcon,
archive: ArchiveIcon,
other: FileIcon,
}
/** The query parameter the open folder lives in. */
export const FOLDER_PARAM = "folder"
/**
* The href that opens a folder — or the root, for `undefined` — keeping every
* other parameter the page was given, so a framed preview's own settings
* ride along and a filter in the query survives a step into a folder.
*/
export function folderHref(current: URLSearchParams, folderId: string | undefined): string {
const next = new URLSearchParams(current)
if (folderId === undefined) next.delete(FOLDER_PARAM)
else next.set(FOLDER_PARAM, folderId)
return `?${next.toString()}`
}
/** "3 files", "1 file". */
export function fileCount(count: number): string {
return `${count} ${count === 1 ? "file" : "files"}`
}"use client"
import * as React from "react"
import { AsyncButton } from "@/components/ui/async-button"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Callout } from "@/components/ui/callout"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { type FileRow } from "../data"
export type RenameDialogProps = {
/** The row being renamed; null closes the dialog. */
row: FileRow | null
onClose: () => void
/** Sends the name to the server; a refusal comes back as a message for the field. */
onRename: (row: FileRow, name: string) => Promise<{ ok: boolean; message: string }>
}
/**
* Mounted only while a row is open, so the field is re-armed by construction
* with that row's name. A refused name is reported on the field; an accepted
* one closes the dialog, and the row it renamed is the caller's to update.
*/
export function RenameDialog({ row, onClose, onRename }: RenameDialogProps) {
if (!row) return null
return <RenameForm key={row.id} row={row} onClose={onClose} onRename={onRename} />
}
function RenameForm({ row, onClose, onRename }: RenameDialogProps & { row: FileRow }) {
const id = React.useId()
const [name, setName] = React.useState(row.name)
const [error, setError] = React.useState("")
const [pending, setPending] = React.useState(false)
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setError("")
setPending(true)
const result = await onRename(row, name)
setPending(false)
if (!result.ok) return setError(result.message)
onClose()
}
return (
<Dialog open onOpenChange={(next) => (next ? undefined : onClose())}>
<DialogContent>
<DialogHeader>
<DialogTitle>{`Rename ${row.kind === "folder" ? "folder" : "file"}`}</DialogTitle>
<DialogDescription>{`Everything that links to ${row.name} keeps working.`}</DialogDescription>
</DialogHeader>
<form onSubmit={submit} noValidate className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor={`${id}-name`}>Name</Label>
<Input
id={`${id}-name`}
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="off"
autoFocus
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
/>
</div>
{error ? (
<Callout id={`${id}-error`} variant="danger" role="alert">
{error}
</Callout>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<AsyncButton type="submit" loading={pending}>
Rename
</AsyncButton>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}"use client"
import * as React from "react"
import { UploadIcon } from "lucide-react"
import { AsyncButton } from "@/components/ui/async-button"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { FileDropzone } from "@/components/ui/file-dropzone"
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { fileCount } from "./files-vocabulary"
export type UploadSheetProps = {
/** Where the files land: the open folder's name, or "All files" at the root. */
destination: string
/** Sends the names to the server; the message that comes back is printed here when it is a refusal. */
onUpload: (names: string[]) => Promise<{ ok: boolean; message: string }>
}
/**
* The Upload button and the sheet it opens. The dropzone holds the files
* until the reader confirms; the server takes only their names, and what it
* says about them is printed inside the sheet — a refusal stays here, a
* success closes it and reaches the status line under the table.
*/
export function UploadSheet({ destination, onUpload }: UploadSheetProps) {
const [open, setOpen] = React.useState(false)
const [files, setFiles] = React.useState<File[]>([])
const [refusal, setRefusal] = React.useState("")
const [pending, setPending] = React.useState(false)
function reset(next: boolean) {
setOpen(next)
if (!next) {
setFiles([])
setRefusal("")
}
}
async function confirm() {
setRefusal("")
setPending(true)
const result = await onUpload(files.map((file) => file.name))
setPending(false)
if (!result.ok) return setRefusal(result.message)
reset(false)
}
return (
<Sheet open={open} onOpenChange={reset}>
<Button onClick={() => setOpen(true)}>
<UploadIcon data-icon="inline-start" />
Upload
</Button>
<SheetContent side="right" className="gap-4 overflow-y-auto p-4">
<SheetHeader className="p-0">
<SheetTitle>Upload files</SheetTitle>
<SheetDescription>{`They go into ${destination}. Drop as many as you like.`}</SheetDescription>
</SheetHeader>
<FileDropzone
aria-label="Upload files"
value={files}
onFilesAccepted={(accepted) => {
setRefusal("")
setFiles((current) => [...current, ...accepted])
}}
onRemove={(file) => setFiles((current) => current.filter((entry) => entry !== file))}
description="Any kind of file; the name is what the drive keeps."
/>
{refusal ? (
<Callout variant="danger" role="alert">
{refusal}
</Callout>
) : null}
<SheetFooter className="mt-auto flex-row justify-end p-0">
<Button variant="outline" onClick={() => reset(false)}>
Cancel
</Button>
<AsyncButton loading={pending} disabled={files.length === 0} onClick={confirm}>
{`Upload ${fileCount(files.length)}`}
</AsyncButton>
</SheetFooter>
</SheetContent>
</Sheet>
)
}