Recent uploads
The files touched most recently, with kind, size and when each landed, starred through the server or readied for download from its row menu; reads recentUploads().
Preview
"use client"
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon, DownloadIcon, StarIcon, StarOffIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatBytes } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
DataTable,
DataTableColumnHeader,
DataTableRowActions,
type DataTableColumnDef,
} from "@/components/ui/data-table"
import { DateCell } from "@/components/ui/table-cells"
import { toggleStar } from "../actions"
import { type FileRow } from "../data"
import { BROWSE_HREF, KIND_ICONS, KIND_LABELS } from "./files-vocabulary"
type Handlers = { onStar: (row: FileRow) => void; onDownload: (row: FileRow) => void }
function uploadColumns({ onStar, onDownload }: Handlers): DataTableColumnDef<FileRow>[] {
return [
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
cell: ({ row }) => {
const Icon = KIND_ICONS[row.original.kind] ?? KIND_ICONS.other
return (
<span className="flex items-center gap-2.5">
<Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">{row.original.name}</span>
{row.original.starred ? (
<StarIcon aria-label="Starred" className="size-3.5 shrink-0 fill-current text-warning" />
) : null}
</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: "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: "modifiedAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Uploaded" />,
cell: ({ row }) => <DateCell date={row.original.modifiedAt} className="text-muted-foreground" />,
meta: { align: "right", label: "Uploaded" },
},
{
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: "Download", icon: <DownloadIcon />, onSelect: () => onDownload(row.original) },
{
label: row.original.starred ? "Unstar" : "Star",
icon: row.original.starred ? <StarOffIcon /> : <StarIcon />,
onSelect: () => onStar(row.original),
},
]}
/>
),
meta: { align: "right", label: "Actions" },
},
]
}
/**
* The files touched most recently. The rows arrive from the server page;
* this island owns them from then on, so a star the server accepts shows on
* the row without a second read. A download here is a status line, because
* nothing on a framed preview should leave the page.
*/
export function RecentUploads({ rows: initial }: { rows: FileRow[] }) {
// The title names the table too, so a screen reader announces it by name.
const titleId = React.useId()
const [rows, setRows] = React.useState(initial)
const [notice, setNotice] = React.useState("")
const [refusal, setRefusal] = React.useState("")
const star = React.useCallback(async (row: FileRow) => {
setNotice("")
setRefusal("")
const result = await toggleStar(row.id)
if (!result.ok) return setRefusal(result.error.message)
setRows((current) => current.map((entry) => (entry.id === row.id ? result.data : entry)))
setNotice(`${result.data.starred ? "Starred" : "Unstarred"} ${result.data.name}.`)
}, [])
const download = React.useCallback((row: FileRow) => {
setRefusal("")
setNotice(`Preparing ${row.name} for download.`)
}, [])
const columns = React.useMemo(() => uploadColumns({ onStar: star, onDownload: download }), [star, download])
return (
<Card data-widget="widget-projects-files-recent-uploads" role="region" aria-label="Recent uploads">
<CardHeader>
<CardTitle id={titleId}>Recent uploads</CardTitle>
<CardDescription>The files touched most recently.</CardDescription>
<CardAction>
<Link href={BROWSE_HREF} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
View all
<ArrowRightIcon data-icon="inline-end" />
</Link>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{refusal ? (
<Callout variant="danger" role="alert">
{refusal}
</Callout>
) : null}
<DataTable
aria-labelledby={titleId}
columns={columns}
data={rows}
enableRowSelection={false}
getRowId={(row) => row.id}
pageSize={8}
size="sm"
emptyMessage="Nothing has been uploaded yet."
/>
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{notice}
</p>
</CardContent>
</Card>
)
}Install
$
npx shadcn@latest add @vibra/widget-projects-files-recent-uploadsNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon, DownloadIcon, StarIcon, StarOffIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatBytes } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
DataTable,
DataTableColumnHeader,
DataTableRowActions,
type DataTableColumnDef,
} from "@/components/ui/data-table"
import { DateCell } from "@/components/ui/table-cells"
import { toggleStar } from "../actions"
import { type FileRow } from "../data"
import { BROWSE_HREF, KIND_ICONS, KIND_LABELS } from "./files-vocabulary"
type Handlers = { onStar: (row: FileRow) => void; onDownload: (row: FileRow) => void }
function uploadColumns({ onStar, onDownload }: Handlers): DataTableColumnDef<FileRow>[] {
return [
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
cell: ({ row }) => {
const Icon = KIND_ICONS[row.original.kind] ?? KIND_ICONS.other
return (
<span className="flex items-center gap-2.5">
<Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">{row.original.name}</span>
{row.original.starred ? (
<StarIcon aria-label="Starred" className="size-3.5 shrink-0 fill-current text-warning" />
) : null}
</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: "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: "modifiedAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Uploaded" />,
cell: ({ row }) => <DateCell date={row.original.modifiedAt} className="text-muted-foreground" />,
meta: { align: "right", label: "Uploaded" },
},
{
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: "Download", icon: <DownloadIcon />, onSelect: () => onDownload(row.original) },
{
label: row.original.starred ? "Unstar" : "Star",
icon: row.original.starred ? <StarOffIcon /> : <StarIcon />,
onSelect: () => onStar(row.original),
},
]}
/>
),
meta: { align: "right", label: "Actions" },
},
]
}
/**
* The files touched most recently. The rows arrive from the server page;
* this island owns them from then on, so a star the server accepts shows on
* the row without a second read. A download here is a status line, because
* nothing on a framed preview should leave the page.
*/
export function RecentUploads({ rows: initial }: { rows: FileRow[] }) {
// The title names the table too, so a screen reader announces it by name.
const titleId = React.useId()
const [rows, setRows] = React.useState(initial)
const [notice, setNotice] = React.useState("")
const [refusal, setRefusal] = React.useState("")
const star = React.useCallback(async (row: FileRow) => {
setNotice("")
setRefusal("")
const result = await toggleStar(row.id)
if (!result.ok) return setRefusal(result.error.message)
setRows((current) => current.map((entry) => (entry.id === row.id ? result.data : entry)))
setNotice(`${result.data.starred ? "Starred" : "Unstarred"} ${result.data.name}.`)
}, [])
const download = React.useCallback((row: FileRow) => {
setRefusal("")
setNotice(`Preparing ${row.name} for download.`)
}, [])
const columns = React.useMemo(() => uploadColumns({ onStar: star, onDownload: download }), [star, download])
return (
<Card data-widget="widget-projects-files-recent-uploads" role="region" aria-label="Recent uploads">
<CardHeader>
<CardTitle id={titleId}>Recent uploads</CardTitle>
<CardDescription>The files touched most recently.</CardDescription>
<CardAction>
<Link href={BROWSE_HREF} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
View all
<ArrowRightIcon data-icon="inline-end" />
</Link>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{refusal ? (
<Callout variant="danger" role="alert">
{refusal}
</Callout>
) : null}
<DataTable
aria-labelledby={titleId}
columns={columns}
data={rows}
enableRowSelection={false}
getRowId={(row) => row.id}
pageSize={8}
size="sm"
emptyMessage="Nothing has been uploaded yet."
/>
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{notice}
</p>
</CardContent>
</Card>
)
}/**
* What this page reads. Every file comes from `db.files`; the four groups,
* the storage band and the recent uploads are rules over those rows, and
* the quota is what the workspace's plan includes, read off `db.plans`. The
* two series no row records — transfers by month and uploads by range — are
* generated once from `seeded("files-overview")`. "Now" is `REFERENCE_DATE`.
*
* The client islands import only the types below. The words and icons the
* page puts on a kind live beside them in `components/files-vocabulary.ts`,
* so nothing that reaches `db` is ever pulled into the browser.
*/
import { getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, seeded, type File, type Member } from "@/lib/sample-data"
export type FileKind = File["kind"]
/** One file as the recent-uploads table prints it. Exported because the star returns one. */
export type FileRow = {
id: string
name: string
kind: FileKind
sizeBytes: number
ownerName: string
ownerInitials: string
modifiedAt: Date
starred: boolean
}
// Read per call, never held at module scope: a member renamed since the server
// started is the owner's 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"),
modifiedAt: file.modifiedAt,
starred: file.starred,
}
}
/** Every file that is not a folder. */
const stored = () => db.files.all().filter((row) => row.kind !== "folder")
export type StorageGroupId = "documents" | "images" | "videos" | "other"
export type StorageGroup = {
id: StorageGroupId
label: string
count: number
bytes: number
/** This group's share of everything used, 0–1. */
share: number
}
// The four tiles: three kinds on their own, and the rest together.
const GROUPS: readonly { id: StorageGroupId; label: string; kinds: FileKind[] }[] = [
{ id: "documents", label: "Documents", kinds: ["document"] },
{ id: "images", label: "Images", kinds: ["image"] },
{ id: "videos", label: "Videos", kinds: ["video"] },
{ id: "other", label: "Other", kinds: ["audio", "archive", "other"] },
]
/** The drive in four groups, each with its count, its bytes and its share of what is used. */
export function storageGroups(): StorageGroup[] {
const rows = stored()
const used = rows.reduce((sum, row) => sum + row.sizeBytes, 0)
return GROUPS.map((group) => {
const inGroup = rows.filter((row) => group.kinds.includes(row.kind))
const bytes = inGroup.reduce((sum, row) => sum + row.sizeBytes, 0)
return { id: group.id, label: group.label, count: inGroup.length, bytes, share: used === 0 ? 0 : bytes / used }
})
}
const GB = 1024 ** 3
/**
* What the workspace's plan includes: the Team tier's "100 GB storage" line,
* read off the plan rather than written here. A plan that names no figure
* gets twice what is used, rounded up to a whole hundred gigabytes.
*/
export function storageQuota(): number {
const plan = db.plans.all().find((entry) => entry.popular) ?? db.plans.all()[0]
const named = plan?.features.map((feature) => feature.match(/^(\d+) GB storage$/)?.[1]).find(Boolean)
if (named) return Number(named) * GB
const used = stored().reduce((sum, row) => sum + row.sizeBytes, 0)
return Math.max(1, Math.ceil((used * 2) / (100 * GB))) * 100 * GB
}
export type StorageBand = {
usedBytes: number
quotaBytes: number
freeBytes: number
/** Bytes per kind, largest first. */
segments: { kind: FileKind; bytes: number }[]
}
/** The band under the tiles: used against the quota, split by kind. */
export function storageBand(): StorageBand {
const rows = stored()
const usedBytes = rows.reduce((sum, row) => sum + row.sizeBytes, 0)
const quotaBytes = storageQuota()
const byKind = new Map<FileKind, number>()
for (const row of rows) byKind.set(row.kind, (byKind.get(row.kind) ?? 0) + row.sizeBytes)
return {
usedBytes,
quotaBytes,
freeBytes: Math.max(0, quotaBytes - usedBytes),
segments: [...byKind.entries()]
.map(([kind, bytes]) => ({ kind, bytes }))
.sort((a, b) => b.bytes - a.bytes),
}
}
const rand = seeded("files-overview")
// Drawn once, in a fixed order: twelve months of transfers, then the three
// upload series, so each is the same on every render.
const TRANSFERS = Array.from({ length: 12 }, () => ({
desktop: 140 + Math.floor(rand() * 120),
mobile: 60 + Math.floor(rand() * 90),
}))
const UPLOADS_12M = Array.from({ length: 12 }, (_, index) => 60 + index * 4 + Math.floor(rand() * 40))
const UPLOADS_30D = Array.from({ length: 30 }, () => 1 + Math.floor(rand() * 7))
const UPLOADS_7D = Array.from({ length: 7 }, () => 2 + Math.floor(rand() * 6))
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" })
const MONTH_YEAR = new Intl.DateTimeFormat("en-US", { month: "short", year: "numeric", timeZone: "UTC" })
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
const DAY_MS = 86_400_000
/** The first of the month `back` months before the reference month. */
function monthStart(back: number): Date {
return new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() - back, 1))
}
export type TransferPoint = { month: string; desktop: number; mobile: number }
/** Transfers by month over the last twelve complete months, desktop and mobile. */
export function transfersByMonth(): TransferPoint[] {
return TRANSFERS.map((point, index) => ({ month: MONTH.format(monthStart(12 - index)), ...point }))
}
/** "Sep 2025 – Aug 2026": the window the transfers cover. */
export function transfersRange(): string {
return `${MONTH_YEAR.format(monthStart(12))} – ${MONTH_YEAR.format(monthStart(1))}`
}
export type UploadRange = "12m" | "30d" | "7d"
export type UploadPoint = { label: string; uploads: number }
/** Uploads per month, per day over thirty days, and per day over seven, keyed by range. */
export function uploadsByRange(): Record<UploadRange, UploadPoint[]> {
const day = (back: number) => DAY.format(new Date(REFERENCE_DATE.getTime() - back * DAY_MS))
return {
"12m": UPLOADS_12M.map((uploads, index) => ({ label: MONTH.format(monthStart(12 - index)), uploads })),
"30d": UPLOADS_30D.map((uploads, index) => ({ label: day(30 - index), uploads })),
"7d": UPLOADS_7D.map((uploads, index) => ({ label: day(7 - index), uploads })),
}
}
/** The eight files touched most recently. */
export function recentUploads(): FileRow[] {
const members = membersById()
return stored()
.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime())
.slice(0, 8)
.map((file) => toRow(file, members))
}
/** 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: whoever owns this workspace. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { db, type Result } from "@/lib/sample-data"
import { toRow, type FileRow } from "./data"
/**
* The two things this page changes. 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.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}
/** 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) }
}import {
ArchiveIcon,
FileIcon,
FileTextIcon,
FolderIcon,
ImageIcon,
MusicIcon,
VideoIcon,
type LucideIcon,
} from "lucide-react"
import { type StatCardProps } from "@/components/ui/stat-card"
/**
* The words, icons and tones this page puts on a kind of file. Vocabulary,
* not data — it lives beside the islands that print 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",
}
/** The same kinds as a legend names them: a share of the drive, not one file. */
export const KIND_PLURALS: Record<string, string> = {
folder: "Folders",
document: "Documents",
image: "Images",
video: "Videos",
audio: "Audio",
archive: "Archives",
other: "Other",
}
export const KIND_ICONS: Record<string, LucideIcon> = {
folder: FolderIcon,
document: FileTextIcon,
image: ImageIcon,
video: VideoIcon,
audio: MusicIcon,
archive: ArchiveIcon,
other: FileIcon,
}
/** Each tile in its own tint, so a reader finds videos by the amber frame before reading a word. */
export const GROUP_TONES: Record<string, NonNullable<StatCardProps["tone"]>> = {
documents: "info",
images: "success",
videos: "warning",
other: "neutral",
}
/** The bar in each tile takes the tone's own fill; the neutral tile keeps the ink. */
export const GROUP_BAR_TONES: Record<string, "default" | "success" | "warning" | "danger" | "info"> = {
documents: "info",
images: "success",
videos: "warning",
other: "default",
}
export const UPLOAD_RANGES = [
{ value: "12m", label: "12 months" },
{ value: "30d", label: "30 days" },
{ value: "7d", label: "7 days" },
]
export const UPLOAD_RANGE_CAPTIONS: Record<string, string> = {
"12m": "Uploads a month, last 12 months",
"30d": "Uploads a day, last 30 days",
"7d": "Uploads a day, last 7 days",
}
/** Where the browser lives, for the links out of this page. */
export const BROWSE_HREF = "/projects/files/browse"Its page
On its page the card sits among the rest of the dashboard and shares its range and its data with them.
From the Files overview page