/projects/filesFiles overview
The drive at a glance: documents, images, videos and the rest in four toned tiles with their share of what is used, the storage band against the plan's quota, transfers and uploads by month, and the files touched most recently.
Installs at /projects/files, the Overview leaf under Files; the browser at /projects/files/browse is its own block. The page is a server component inside AppShell: it reads every file through db.files, groups them into four tiles whose bars are each group's share of everything used, and draws the storage band against the quota the workspace's plan names in db.plans, so the figure is read rather than written. The two series no row records — transfers by month and uploads over three windows — come from seeded("projects-files") once, in data.ts. The recent-uploads table's row menu stars a file through toggleStar, which the row shows at once, and offers a download that is a status line rather than a navigation, because a framed preview has nowhere to go; every View more link goes through next/link to the browser. Composes AppShell, PageHeader, DashboardGrid, StatCardGroup, StatCard, ProgressStat, Card, PercentageBar, ChartCard, StackedBarChart, LineChart, SegmentedControl, DataTable, DateCell and Callout.
Preview
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { RecentUploads } from "./components/recent-uploads"
import { StorageBandPanel } from "./components/storage-band"
import { StorageStats } from "./components/storage-stats"
import { TransfersChart } from "./components/transfers-chart"
import { UploadsChart } from "./components/uploads-chart"
import {
currentUser,
driveSummary,
recentUploads,
shellNotifications,
storageBand,
storageGroups,
transfersByMonth,
transfersRange,
uploadsByRange,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/projects/nav"
/**
* The drive at a glance. The page is a server component: it reads every file
* through `db.files`, groups and sums them, and hands plain rows to the
* islands that need state — the upload range and the table whose row menu
* stars a file.
*/
export default function FilesOverviewPage() {
const band = storageBand()
return (
<AppShell
nav={NAV}
activeHref={ROUTES.files}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Files"
description="What the drive holds, how much of it is used, and what moved lately."
meta={driveSummary()}
/>
<StorageStats groups={storageGroups()} usedBytes={band.usedBytes} />
<DashboardGrid>
<DashboardGridItem colSpan={12}>
<StorageBandPanel band={band} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 6 }}>
<TransfersChart points={transfersByMonth()} range={transfersRange()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 6 }}>
<UploadsChart byRange={uploadsByRange()} />
</DashboardGridItem>
<DashboardGridItem colSpan={12}>
<RecentUploads rows={recentUploads()} />
</DashboardGridItem>
</DashboardGrid>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/projects-filesNeeds the @vibra registry in your components.json — set it up once.
Source
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { RecentUploads } from "./components/recent-uploads"
import { StorageBandPanel } from "./components/storage-band"
import { StorageStats } from "./components/storage-stats"
import { TransfersChart } from "./components/transfers-chart"
import { UploadsChart } from "./components/uploads-chart"
import {
currentUser,
driveSummary,
recentUploads,
shellNotifications,
storageBand,
storageGroups,
transfersByMonth,
transfersRange,
uploadsByRange,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/projects/nav"
/**
* The drive at a glance. The page is a server component: it reads every file
* through `db.files`, groups and sums them, and hands plain rows to the
* islands that need state — the upload range and the table whose row menu
* stars a file.
*/
export default function FilesOverviewPage() {
const band = storageBand()
return (
<AppShell
nav={NAV}
activeHref={ROUTES.files}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Files"
description="What the drive holds, how much of it is used, and what moved lately."
meta={driveSummary()}
/>
<StorageStats groups={storageGroups()} usedBytes={band.usedBytes} />
<DashboardGrid>
<DashboardGridItem colSpan={12}>
<StorageBandPanel band={band} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 6 }}>
<TransfersChart points={transfersByMonth()} range={transfersRange()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 6 }}>
<UploadsChart byRange={uploadsByRange()} />
</DashboardGridItem>
<DashboardGridItem colSpan={12}>
<RecentUploads rows={recentUploads()} />
</DashboardGridItem>
</DashboardGrid>
</AppShell>
)
}/**
* 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""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>
)
}import { formatBytes } from "@/lib/format"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { PercentageBar } from "@/components/ui/percentage-bar"
import { type StorageBand } from "../data"
import { KIND_PLURALS } from "./files-vocabulary"
/** What is used against the plan's quota, split by kind. No state, so it renders on the server. */
export function StorageBandPanel({ band }: { band: StorageBand }) {
return (
<Card data-widget="widget-projects-files-storage-band" role="region" aria-label="Storage">
<CardHeader>
<CardTitle>{`${formatBytes(band.usedBytes, 1)} used of ${formatBytes(band.quotaBytes, 0)}`}</CardTitle>
<CardDescription>Everything on the drive, by kind.</CardDescription>
<CardAction className="text-sm tabular-nums text-muted-foreground">
{`${formatBytes(band.freeBytes, 1)} free`}
</CardAction>
</CardHeader>
<CardContent>
<PercentageBar
segments={[
...band.segments.map((segment) => ({ label: KIND_PLURALS[segment.kind] ?? segment.kind, value: segment.bytes })),
{ label: "Free", value: band.freeBytes, color: "var(--muted)" },
]}
format={(bytes, percent) => `${formatBytes(bytes, 1)} (${percent}%)`}
/>
</CardContent>
</Card>
)
}import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { formatBytes, formatNumber, formatPercent } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { ProgressStat } from "@/components/ui/progress-stat"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type StorageGroup } from "../data"
import { BROWSE_HREF, GROUP_BAR_TONES, GROUP_TONES } from "./files-vocabulary"
/**
* The drive in four tiles: how many files of each kind, the bytes they take
* as a share of everything used, and a way through to the browser. No
* state, so the tiles render on the server.
*
* The link rides on the strip's action slot rather than under the caption:
* below a 40rem container the group turns the caption screen-reader-only and
* hides the footer, and the strip is the one place on a tile a phone still
* shows. It is the kit's link — the button's link variant — so it is drawn in
* the accent like every other link on the page.
*/
export function StorageStats({ groups, usedBytes }: { groups: StorageGroup[]; usedBytes: number }) {
return (
<StatCardGroup data-widget="widget-projects-files-storage-stats" columns={4}>
{groups.map((group) => (
<StatCard
key={group.id}
id={`storage-${group.id}`}
label={group.label}
value={formatNumber(group.count)}
tone={GROUP_TONES[group.id]}
icon={
<Link href={BROWSE_HREF} className={buttonVariants({ variant: "link", size: "xs" })}>
View more
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</Link>
}
description={`${formatBytes(group.bytes, 1)} · ${formatPercent(group.share)} of what is used`}
footer={
<ProgressStat
size="sm"
label={group.label}
value={group.bytes}
target={usedBytes}
format={(bytes) => formatBytes(bytes, 1)}
tone={GROUP_BAR_TONES[group.id]}
showPercent={false}
/>
}
/>
))}
</StatCardGroup>
)
}"use client"
import { formatNumber } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { StackedBarChart } from "@/components/ui/stacked-bar-chart"
import { type TransferPoint } from "../data"
/** Transfers by month, desktop stacked on mobile, with the window it covers in the strip. */
export function TransfersChart({ points, range }: { points: TransferPoint[]; range: string }) {
return (
<ChartCard
data-widget="widget-projects-files-transfers-chart"
title="Transfers by month"
description="Files moved in or out, by the device that moved them."
actions={<span className="text-xs tabular-nums text-muted-foreground">{range}</span>}
height={260}
className="h-full"
>
<StackedBarChart
data={points}
index="month"
series={[
{ key: "desktop", label: "Desktop" },
{ key: "mobile", label: "Mobile" },
]}
valueFormatter={(value) => formatNumber(value)}
height={260}
legend="bottom"
/>
</ChartCard>
)
}"use client"
import * as React from "react"
import { formatNumber } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { LineChart } from "@/components/ui/line-chart"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { type UploadPoint, type UploadRange } from "../data"
import { UPLOAD_RANGES, UPLOAD_RANGE_CAPTIONS } from "./files-vocabulary"
/**
* Uploads over one of three windows. The range is client state; the three
* series arrive computed from the server page, so the control re-binds the
* chart rather than fetching anything.
*/
export function UploadsChart({ byRange }: { byRange: Record<UploadRange, UploadPoint[]> }) {
const [range, setRange] = React.useState<UploadRange>("12m")
return (
<ChartCard
data-widget="widget-projects-files-uploads-chart"
title="Uploads"
description={UPLOAD_RANGE_CAPTIONS[range]}
actions={
<SegmentedControl
size="sm"
aria-label="Upload range"
options={UPLOAD_RANGES}
value={range}
onValueChange={(next) => setRange(next as UploadRange)}
/>
}
height={260}
className="h-full"
>
<LineChart
data={byRange[range]}
index="label"
series={[{ key: "uploads", label: "Uploads" }]}
valueFormatter={(value) => formatNumber(value)}
height={260}
legend="none"
/>
</ChartCard>
)
}