Skip to contentVibraUI
Part of the Projects dashboardinstalls at /projects/files/browse

File 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.

Open the live page

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

Install

npx shadcn@latest add @vibra/projects-files-browser

Needs the @vibra registry in your components.json — set it up once.

Source

app/projects/files/browse/page.tsx
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>
  )
}
app/projects/files/browse/data.ts
/**
 * 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 }
}
app/projects/files/browse/actions.ts
"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 } }
}
app/projects/files/browse/components/file-columns.tsx
"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" },
    },
  ]
}
app/projects/files/browse/components/files-browser.tsx
"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>
  )
}
app/projects/files/browse/components/files-vocabulary.ts
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"}`
}
app/projects/files/browse/components/rename-dialog.tsx
"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>
  )
}
app/projects/files/browse/components/upload-sheet.tsx
"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>
  )
}