Skip to contentVibraUI

Uploads

Uploads a month over the last twelve months, or a day over the last thirty or seven, as the reader switches; reads uploadsByRange().

Preview

Install

npx shadcn@latest add @vibra/widget-projects-files-uploads-chart

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

Source

app/projects/files/components/uploads-chart.tsx
"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>
  )
}
app/projects/files/data.ts
/**
 * 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 }
}
app/projects/files/components/files-vocabulary.ts
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