Search for pages, actions, and quick links.
Upload queues, drop targets, galleries and storage quotas. 7 blocks.
All four states at once, including the failed row most lists drop.
src/components/blocks/media/upload-progress-list.tsx"use client";
import { Button } from "@dashboardpack/core/components/ui/button";
import { cn } from "@dashboardpack/core/lib/utils";
import { AlertCircle, Check, FileText, RotateCcw, X } from "lucide-react";
/**
* An upload queue showing all four states at once: done, uploading, failed, queued.
*
* The failed row is the reason this block exists. Most upload lists show progress and success and
* then silently drop failures, so a user believes six files uploaded when five did. Here a failure
* keeps its row, states the reason, and offers a retry that does not require re-picking the file.
*
* Each bar is a `role="progressbar"` with `aria-valuetext` giving a percentage in words, and the
* whole list is `aria-live="polite"` so completions are announced. A visual-only progress bar tells
* a screen-reader user nothing about whether it is safe to navigate away.
*/
export function UploadProgressList() {
const files = [
{ name: "q3-forecast.xlsx", size: "248 KB", state: "done" as const, pct: 100 },
{ name: "brand-guidelines.pdf", size: "4.1 MB", state: "uploading" as const, pct: 62 },
{
name: "warehouse-audit.mov",
size: "184 MB",
state: "failed" as const,
pct: 0,
error: "Exceeds the 100 MB limit",
},
{ name: "supplier-list.csv", size: "38 KB", state: "queued" as const, pct: 0 },
];
return (
<div aria-live="polite" className="space-y-2 rounded-lg border p-3">
{files.map((file) => (
<div key={file.name} className="flex items-center gap-3 rounded-md p-2">
<div
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-md",
file.state === "done" && "bg-success/10 text-success",
file.state === "failed" && "bg-destructive/10 text-destructive",
(file.state === "uploading" || file.state === "queued") &&
"bg-muted text-muted-foreground",
)}
>
{file.state === "done" ? (
<Check className="h-4 w-4" aria-hidden="true" />
) : file.state === "failed" ? (
<AlertCircle className="h-4 w-4" aria-hidden="true" />
) : (
<FileText className="h-4 w-4" aria-hidden="true" />
)}
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-baseline justify-between gap-2">
{/* min-w-0 above plus truncate here: without the parent's min-w-0 a long
filename stretches the flex row instead of ellipsing. */}
<p className="truncate text-sm font-medium">{file.name}</p>
<p className="shrink-0 text-xs tabular-nums text-muted-foreground">{file.size}</p>
</div>
{file.state === "failed" ? (
<p className="text-xs text-destructive">{file.error}</p>
) : file.state === "queued" ? (
<p className="text-xs text-muted-foreground">Waiting…</p>
) : (
<div
role="progressbar"
aria-label={`Uploading ${file.name}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={file.pct}
aria-valuetext={`${file.pct}% uploaded`}
className="h-1 overflow-hidden rounded-full bg-muted"
>
<div
className={cn(
"h-full rounded-full",
file.state === "done" ? "bg-success" : "bg-primary",
)}
style={{ width: `${file.pct}%` }}
/>
</div>
)}
</div>
<Button
variant="ghost"
size="sm"
aria-label={file.state === "failed" ? `Retry ${file.name}` : `Remove ${file.name}`}
>
{file.state === "failed" ? (
<RotateCcw className="h-4 w-4" aria-hidden="true" />
) : (
<X className="h-4 w-4" aria-hidden="true" />
)}
</Button>
</div>
))}
</div>
);
}A real file input, so clicking, tapping and Enter all work.
src/components/blocks/media/dropzone-empty.tsx"use client";
import { useState } from "react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { cn } from "@dashboardpack/core/lib/utils";
import { UploadCloud } from "lucide-react";
/**
* A drop target that is also a real file input.
*
* The pattern most dropzones get wrong is being drop-only, which excludes anyone using a keyboard
* and anyone on a touch device with no drag gesture. Here the whole zone is a `<label>` wrapping a
* visually-hidden `<input type="file">`, so clicking, tapping and Enter all open the picker, and the
* label text becomes the input's accessible name with no ARIA.
*
* `dragLeave` needs the guard: dragging over a *child* element fires `dragleave` on the parent, so
* without checking `currentTarget.contains(relatedTarget)` the highlight flickers off as the pointer
* crosses the icon.
*
* `sr-only` rather than `display: none` on the input — a hidden input cannot be focused, so the
* keyboard path would silently break.
*/
export function DropzoneEmpty() {
const [over, setOver] = useState(false);
return (
<label
onDragOver={(event) => {
event.preventDefault();
setOver(true);
}}
onDragLeave={(event) => {
// Ignore leave events caused by moving onto a descendant.
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
setOver(false);
}}
onDrop={(event) => {
event.preventDefault();
setOver(false);
}}
className={cn(
"flex cursor-pointer flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-8 text-center transition-colors",
over ? "border-primary bg-primary/5" : "hover:border-primary/40 hover:bg-accent/30",
)}
>
<input type="file" multiple className="sr-only" />
<span
className={cn(
"flex h-11 w-11 items-center justify-center rounded-full transition-colors",
over ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground",
)}
>
<UploadCloud className="h-5 w-5" aria-hidden="true" />
</span>
<span className="space-y-1">
<span className="block text-sm font-medium">
Drop files here, or choose from your computer
</span>
<span className="block text-xs text-muted-foreground">
PDF, PNG, JPG, XLSX or CSV · up to 100 MB each
</span>
</span>
<Button variant="outline" size="sm" asChild>
<span>Browse files</span>
</Button>
</label>
);
}Icon-and-extension tiles that need no generated thumbnails.
src/components/blocks/media/attachment-grid.tsx"use client";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { FileArchive, FileImage, FileSpreadsheet, FileText, Film } from "lucide-react";
/**
* A file grid keyed on type, with no thumbnails to load.
*
* Icon-and-extension tiles are the honest choice for a mixed file list: a real thumbnail grid needs
* a generated preview per file, and falling back to a generic icon for the ones that have none
* produces a grid that looks half-broken. This is consistent at every size and costs no requests.
*
* The type map is a `Record` of literal Tailwind classes, not interpolation. Tailwind's scanner
* reads source text, so `bg-chart-${n}/10` would never be emitted and every tile would come out
* unstyled — the same constraint the StatCard tone map documents.
*/
export function AttachmentGrid() {
const types = {
doc: { Icon: FileText, tone: "bg-chart-1/10 text-chart-1" },
sheet: { Icon: FileSpreadsheet, tone: "bg-chart-2/10 text-chart-2" },
image: { Icon: FileImage, tone: "bg-chart-3/10 text-chart-3" },
video: { Icon: Film, tone: "bg-chart-4/10 text-chart-4" },
archive: { Icon: FileArchive, tone: "bg-chart-5/10 text-chart-5" },
} as const;
const files = [
{ name: "Contract-2026.pdf", size: "1.2 MB", type: "doc" as const },
{ name: "Q3-model.xlsx", size: "486 KB", type: "sheet" as const },
{ name: "hero-shot.png", size: "3.4 MB", type: "image" as const },
{ name: "walkthrough.mp4", size: "88 MB", type: "video" as const },
{ name: "assets.zip", size: "24 MB", type: "archive" as const },
{ name: "Minutes.docx", size: "62 KB", type: "doc" as const },
];
return (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{files.map((file) => {
const { Icon, tone } = types[file.type];
return (
<li key={file.name}>
<button
type="button"
className="group flex w-full items-center gap-3 rounded-lg border p-3 text-start transition-colors hover:border-primary/40 hover:bg-accent/40"
>
<span
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-md ${tone}`}
>
<Icon className="h-5 w-5" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{file.name}</span>
<span className="block text-xs tabular-nums text-muted-foreground">
{file.size}
</span>
</span>
</button>
</li>
);
})}
<li className="flex items-center justify-center rounded-lg border border-dashed p-3">
<Badge variant="secondary">+ 12 more</Badge>
</li>
</ul>
);
}Multi-select tiles with reserved aspect ratio, so nothing shifts on load.
src/components/blocks/media/image-gallery.tsx"use client";
import { useState } from "react";
import { cn } from "@dashboardpack/core/lib/utils";
import { Check, ImageIcon } from "lucide-react";
/**
* A selectable media grid using gradient placeholders rather than image files.
*
* The placeholders are deliberate: a block that ships with six JPEGs adds weight to every customer's
* repository for a demo they will delete. Gradients from the chart tokens carry the layout, adapt to
* dark mode with no second asset, and make the aspect ratio obvious.
*
* `aspect-square` on the tile with the content absolutely positioned inside is what keeps the grid
* from reflowing as images load in the real version — the box reserves its height before anything
* arrives, so there is no layout shift.
*
* Selection is a checkbox group, not a radio group: several images can be chosen at once, and
* `aria-checked` on each tile says so.
*/
export function ImageGallery() {
const items = [
{ id: "a", name: "hero-desktop", tone: "from-chart-1/40 to-chart-1/10" },
{ id: "b", name: "hero-mobile", tone: "from-chart-2/40 to-chart-2/10" },
{ id: "c", name: "team-offsite", tone: "from-chart-3/40 to-chart-3/10" },
{ id: "d", name: "product-01", tone: "from-chart-4/40 to-chart-4/10" },
{ id: "e", name: "product-02", tone: "from-chart-5/40 to-chart-5/10" },
{ id: "f", name: "warehouse", tone: "from-chart-1/40 to-chart-3/10" },
];
const [selected, setSelected] = useState<string[]>(["b", "d"]);
function toggle(id: string) {
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
}
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground" role="status">
{selected.length} of {items.length} selected
</p>
<ul className="grid grid-cols-3 gap-2 sm:grid-cols-6">
{items.map((item) => {
const active = selected.includes(item.id);
return (
<li key={item.id}>
<button
type="button"
role="checkbox"
aria-checked={active}
onClick={() => toggle(item.id)}
className={cn(
"relative block aspect-square w-full overflow-hidden rounded-lg border-2 transition-colors",
active ? "border-primary" : "border-transparent hover:border-border",
)}
>
<span
className={cn("absolute inset-0 bg-gradient-to-br", item.tone)}
aria-hidden="true"
/>
<span className="absolute inset-0 flex items-center justify-center">
<ImageIcon
className="h-5 w-5 text-foreground/30"
aria-hidden="true"
/>
</span>
<span className="sr-only">{item.name}</span>
{active && (
<span
aria-hidden="true"
className="absolute top-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground ltr:right-1 rtl:left-1"
>
<Check className="h-3 w-3" />
</span>
)}
</button>
</li>
);
})}
</ul>
</div>
);
}A segmented meter that answers what the space is being used by.
src/components/blocks/media/storage-quota.tsx"use client";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Card, CardContent } from "@dashboardpack/core/components/ui/card";
/**
* A storage meter broken down by what is using the space.
*
* A single "68% used" bar prompts the question it does not answer: used by what. The segmented bar
* and the legend beneath it are the answer, and they come from one array so the two can never
* disagree.
*
* The segments are a flex row of percentage widths rather than a stack of absolutely positioned
* offsets, which means no cumulative arithmetic and no rounding gap at the end. The free space is
* the track showing through, not a fifth segment, so the widths always sum to the used total.
*
* Colours come from the chart tokens, so the bar and any pie chart of the same data agree.
*/
export function StorageQuota() {
const total = 500;
const segments = [
{ label: "Documents", gb: 182, tone: "bg-chart-1" },
{ label: "Images", gb: 96, tone: "bg-chart-2" },
{ label: "Video", gb: 54, tone: "bg-chart-3" },
{ label: "Backups", gb: 8, tone: "bg-chart-4" },
];
const used = segments.reduce((sum, segment) => sum + segment.gb, 0);
const pct = Math.round((used / total) * 100);
return (
<Card>
<CardContent className="space-y-4 p-4 sm:p-6">
<div className="flex items-baseline justify-between gap-2">
<div>
<p className="text-sm text-muted-foreground">Storage used</p>
<p className="text-2xl font-bold tabular-nums">
{used} GB{" "}
<span className="text-sm font-normal text-muted-foreground">of {total} GB</span>
</p>
</div>
<p className="text-sm font-semibold tabular-nums">{pct}%</p>
</div>
<div
role="meter"
aria-label="Storage used"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={used}
aria-valuetext={`${used} of ${total} gigabytes used`}
className="flex h-2.5 overflow-hidden rounded-full bg-muted"
>
{segments.map((segment) => (
<div
key={segment.label}
className={segment.tone}
style={{ width: `${(segment.gb / total) * 100}%` }}
/>
))}
</div>
<ul className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{segments.map((segment) => (
<li key={segment.label} className="flex items-center gap-2">
<span
aria-hidden="true"
className={`h-2 w-2 shrink-0 rounded-full ${segment.tone}`}
/>
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{segment.label}
</span>
<span className="text-xs font-medium tabular-nums">{segment.gb}</span>
</li>
))}
</ul>
<Button variant="outline" size="sm" className="w-full">
Manage storage
</Button>
</CardContent>
</Card>
);
}Size deltas, and no restore on the version you are viewing.
src/components/blocks/media/document-versions.tsx"use client";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Avatar, AvatarFallback } from "@dashboardpack/core/components/ui/avatar";
/**
* A version history where the current version is not offered a "restore".
*
* Offering restore on the version you are already looking at is a no-op dressed as an action, and
* users click it to find out what it does. The current row gets a badge instead.
*
* Sizes carry a signed delta against the previous version, which is what makes a history scannable:
* a sudden −40% is how you spot the revision where someone deleted half the document.
*/
export function DocumentVersions() {
const versions = [
{ v: 7, when: "2 hours ago", who: "Ana Whitfield", initials: "AW", size: "4.1 MB", delta: "+180 KB", current: true },
{ v: 6, when: "Yesterday", who: "Marcus Oyelaran", initials: "MO", size: "3.9 MB", delta: "−2.4 MB", current: false },
{ v: 5, when: "3 days ago", who: "Ana Whitfield", initials: "AW", size: "6.3 MB", delta: "+420 KB", current: false },
{ v: 4, when: "Last week", who: "Priya Raman", initials: "PR", size: "5.9 MB", delta: "+1.1 MB", current: false },
];
return (
<ul className="divide-y rounded-lg border">
{versions.map((version) => (
<li key={version.v} className="flex flex-wrap items-center gap-3 p-3">
<span className="w-10 shrink-0 font-mono text-xs text-muted-foreground">
v{version.v}
</span>
<Avatar className="h-7 w-7 shrink-0">
<AvatarFallback className="text-[10px]">{version.initials}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{version.who}</p>
<p className="text-xs text-muted-foreground">{version.when}</p>
</div>
<div className="shrink-0 text-end">
<p className="text-xs tabular-nums">{version.size}</p>
<p
className={
version.delta.startsWith("−")
? "text-[11px] tabular-nums text-destructive"
: "text-[11px] tabular-nums text-muted-foreground"
}
>
{version.delta}
</p>
</div>
{/* No restore on the version you are already viewing. */}
{version.current ? (
<Badge variant="success" className="shrink-0 text-[10px]">
Current
</Badge>
) : (
<Button variant="outline" size="sm" className="shrink-0">
Restore
</Button>
)}
</li>
))}
</ul>
);
}