Block
Dashboard.
A TurtleDeck-style app shell: a collapsible sidebar with nav groups and a user menu, a header with breadcrumb and search, a stats row, a mining chart, a live console and a data table of turtles.
components/dashboard/dashboard.tsx
"use client";
import * as React from "react";
import { PlusIcon, SearchIcon } from "lucide-react";
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { Separator } from "@/components/ui/separator";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AppSidebar } from "./app-sidebar";
import { FleetConsole } from "./fleet-console";
import { MiningChart } from "./mining-chart";
import { Stats } from "./stats";
import { TurtlesTable } from "./turtles-table";
export default function Dashboard() {
const [active, setActive] = React.useState("Overview");
return (
<SidebarProvider>
<AppSidebar active={active} onNavigate={setActive} />
<SidebarInset className="min-w-0">
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background/90 px-3 backdrop-blur sm:px-4">
<SidebarTrigger />
<Separator orientation="vertical" className="mr-1 data-[orientation=vertical]:h-4" />
<Breadcrumb className="min-w-0">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem className="hidden sm:inline-flex">
<BreadcrumbLink href="#" className="whitespace-nowrap">
North hub
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden sm:block" />
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="truncate">{active}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<InputGroup className="h-9 w-36 sm:w-48 xl:w-64">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput type="search" placeholder="Search" aria-label="Search turtles and servers" className="h-[34px]" />
</InputGroup>
<Button size="sm" className="hidden lg:inline-flex">
<PlusIcon /> Add turtle
</Button>
</div>
</header>
<div className="flex flex-1 flex-col gap-4 p-3 sm:p-4 lg:gap-6 lg:p-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-medium tracking-[-0.04em] sm:text-3xl">
Good evening<span className="text-primary">.</span>
</h1>
<p className="text-sm text-muted-foreground">9 turtles are working. One found lava on layer 11.</p>
</div>
<Stats />
<div className="grid min-w-0 gap-4 lg:gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
<MiningChart />
<FleetConsole />
</div>
<TurtlesTable />
</div>
</SidebarInset>
</SidebarProvider>
);
}components/dashboard/app-sidebar.tsx
"use client";
import * as React from "react";
import {
BookOpenIcon,
ChevronsUpDownIcon,
CpuIcon,
GlobeIcon,
LayoutDashboardIcon,
LogOutIcon,
MapIcon,
PackageIcon,
PlusIcon,
ServerIcon,
SettingsIcon,
TerminalSquareIcon,
UserIcon,
} from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
useSidebar,
} from "@/components/ui/sidebar";
import { StatusDot } from "@/components/ui/status-dot";
const groups = [
{
label: "Fleet",
action: "Add turtle",
items: [
{ title: "Overview", icon: LayoutDashboardIcon },
{ title: "Turtles", icon: CpuIcon, badge: "12" },
{ title: "Inventory", icon: PackageIcon },
{ title: "Map", icon: MapIcon },
{ title: "Console", icon: TerminalSquareIcon },
],
},
{
label: "Hub",
action: "Add server",
items: [
{ title: "Hub site", icon: GlobeIcon },
{ title: "Servers", icon: ServerIcon, badge: "2" },
{ title: "Guestbook", icon: BookOpenIcon, badge: "5" },
],
},
];
export function AppSidebar({
active,
onNavigate,
...props
}: React.ComponentProps<typeof Sidebar> & { active: string; onNavigate: (title: string) => void }) {
const { isMobile, setOpenMobile } = useSidebar();
function go(title: string) {
onNavigate(title);
if (isMobile) setOpenMobile(false);
}
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" className="rounded-md border-transparent" tooltip="TurtleDeck">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground">
<CpuIcon className="size-4" />
</div>
<div className="grid flex-1 text-left leading-tight">
<span className="truncate font-display font-medium text-sidebar-foreground">TurtleDeck</span>
<span className="truncate font-mono text-[10px] tracking-[0.12em] uppercase">North hub</span>
</div>
<ChevronsUpDownIcon className="ml-auto" />
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
{groups.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
<SidebarGroupAction aria-label={group.action}>
<PlusIcon />
</SidebarGroupAction>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton isActive={active === item.title} tooltip={item.title} onClick={() => go(item.title)}>
<item.icon />
<span>{item.title}</span>
</SidebarMenuButton>
{item.badge && <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
<SidebarGroup className="mt-auto">
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton isActive={active === "Settings"} tooltip="Settings" onClick={() => go("Settings")}>
<SettingsIcon />
<span>Settings</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<div className="mt-2 flex items-center gap-2 rounded-md border border-sidebar-border bg-background/40 px-2.5 py-2 group-data-[collapsible=icon]:hidden">
<StatusDot />
<span className="font-mono text-[10px] tracking-[0.16em] text-muted-foreground uppercase">Relay online</span>
</div>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton size="lg" className="rounded-md border-transparent data-[state=open]:bg-sidebar-accent">
<Avatar className="size-8">
<AvatarFallback>AM</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left leading-tight">
<span className="truncate text-[13px] font-medium text-sidebar-foreground">Alex Marsh</span>
<span className="truncate font-mono text-[10px] text-muted-foreground">alex@example.com</span>
</div>
<ChevronsUpDownIcon className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side={isMobile ? "top" : "right"}
align="end"
sideOffset={8}
className="w-(--radix-dropdown-menu-trigger-width) min-w-56"
>
<DropdownMenuLabel>Signed in as Alex</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onSelect={() => go("Settings")}>
<UserIcon /> Profile
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => go("Settings")}>
<SettingsIcon /> Settings
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<LogOutIcon /> Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}components/dashboard/stats.tsx
import { ArrowDownRightIcon, ArrowUpRightIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Progress } from "@/components/ui/progress";
const stats = [
{ label: "Turtles online", value: "9 / 12", note: "3 parked at base", delta: "+2", up: true, meter: 75 },
{ label: "Blocks mined today", value: "18,204", note: "vs 15,960 yesterday", delta: "+14%", up: true, meter: 62 },
{ label: "Fuel in reserve", value: "41,300", note: "about 3 days of mining", delta: "-8%", up: false, meter: 41 },
{ label: "Hub visitors", value: "312", note: "27 guestbook entries", delta: "+31", up: true, meter: 88 },
];
export function Stats() {
return (
<dl className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{stats.map((stat) => (
<div key={stat.label} className="flex flex-col gap-3 rounded-lg border bg-card p-4">
<div className="flex items-center justify-between gap-2">
<dt className="font-mono text-[10px] tracking-[0.2em] text-success uppercase">{stat.label}</dt>
<span
className={cn(
"inline-flex items-center gap-0.5 font-mono text-[11px] tabular-nums",
stat.up ? "text-success" : "text-destructive",
)}
>
{stat.up ? <ArrowUpRightIcon className="size-3.5" /> : <ArrowDownRightIcon className="size-3.5" />}
{stat.delta}
</span>
</div>
<dd className="font-display text-3xl leading-none font-medium tracking-tight text-primary tabular-nums">{stat.value}</dd>
<Progress value={stat.meter} size="sm" aria-label={`${stat.label} meter`} />
<p className="text-xs text-muted-foreground">{stat.note}</p>
</div>
))}
</dl>
);
}components/dashboard/mining-chart.tsx
"use client";
import * as React from "react";
import { Area, AreaChart, Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
const data = [
{ day: "Mon", stone: 9200, ore: 1400 },
{ day: "Tue", stone: 11800, ore: 1900 },
{ day: "Wed", stone: 10400, ore: 1250 },
{ day: "Thu", stone: 13900, ore: 2300 },
{ day: "Fri", stone: 12600, ore: 2050 },
{ day: "Sat", stone: 15960, ore: 2600 },
{ day: "Sun", stone: 18204, ore: 3100 },
];
const chartConfig = {
stone: { label: "Stone", color: "var(--chart-1)" },
ore: { label: "Ore", color: "var(--chart-2)" },
} satisfies ChartConfig;
const formatK = (value: number) => (value >= 1000 ? `${value / 1000}k` : String(value));
export function MiningChart() {
const [view, setView] = React.useState("area");
return (
<Card className="min-w-0 gap-4">
<CardHeader>
<CardTitle>Blocks mined</CardTitle>
<CardDescription>All turtles, last 7 days.</CardDescription>
<CardAction>
<Tabs value={view} onValueChange={setView} variant="boxed">
<TabsList aria-label="Chart type">
<TabsTrigger value="area">Area</TabsTrigger>
<TabsTrigger value="bar">Bar</TabsTrigger>
</TabsList>
</Tabs>
</CardAction>
</CardHeader>
<CardContent className="px-2 sm:px-6">
<ChartContainer config={chartConfig} className="aspect-auto h-[240px] w-full">
{view === "area" ? (
<AreaChart data={data} margin={{ left: 0, right: 12, top: 8 }}>
<defs>
<linearGradient id="dash-fill-stone" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-stone)" stopOpacity={0.35} />
<stop offset="95%" stopColor="var(--color-stone)" stopOpacity={0.02} />
</linearGradient>
<linearGradient id="dash-fill-ore" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-ore)" stopOpacity={0.35} />
<stop offset="95%" stopColor="var(--color-ore)" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="day" tickLine={false} axisLine={false} tickMargin={8} />
<YAxis tickLine={false} axisLine={false} width={36} tickFormatter={formatK} />
<ChartTooltip cursor={false} content={<ChartTooltipContent indicator="line" />} />
<Area dataKey="stone" type="monotone" fill="url(#dash-fill-stone)" stroke="var(--color-stone)" strokeWidth={2} />
<Area dataKey="ore" type="monotone" fill="url(#dash-fill-ore)" stroke="var(--color-ore)" strokeWidth={2} />
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
) : (
<BarChart data={data} margin={{ left: 0, right: 12, top: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="day" tickLine={false} axisLine={false} tickMargin={8} />
<YAxis tickLine={false} axisLine={false} width={36} tickFormatter={formatK} />
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
<Bar dataKey="stone" fill="var(--color-stone)" radius={[2, 2, 0, 0]} />
<Bar dataKey="ore" fill="var(--color-ore)" radius={[2, 2, 0, 0]} />
<ChartLegend content={<ChartLegendContent />} />
</BarChart>
)}
</ChartContainer>
</CardContent>
</Card>
);
}components/dashboard/fleet-console.tsx
"use client";
import * as React from "react";
import { TerminalSquareIcon } from "lucide-react";
import { Console, ConsoleHeader, ConsoleInput, ConsoleLine, ConsoleLines } from "@/components/ui/console";
import { StatusDot } from "@/components/ui/status-dot";
type Line = { id: number; level: "info" | "system" | "error" | "input"; time: string; text: string };
const initial: Line[] = [
{ id: 1, level: "system", time: "14:02:10", text: "Connected to quarry-2" },
{ id: 2, level: "info", time: "14:02:12", text: "Digging layer 11, 48 blocks left" },
{ id: 3, level: "info", time: "14:02:16", text: "Inventory 12 / 16 slots" },
{ id: 4, level: "error", time: "14:02:19", text: "Lava found at -142, 11, 87. Skipping." },
{ id: 5, level: "system", time: "14:02:24", text: "farmer-07 harvested 64 wheat" },
];
const replies: Record<string, string> = {
help: "Commands: fuel, pos, inv, home, clear",
fuel: "Fuel 82% (4,120 moves)",
pos: "Position -138, 11, 90 facing north",
inv: "Cobblestone x384, Coal x41, Iron ore x17",
home: "Returning to base. ETA 40s.",
};
function now() {
return new Date().toTimeString().slice(0, 8);
}
export function FleetConsole() {
const [lines, setLines] = React.useState(initial);
const next = React.useRef(initial.length + 1);
function run(command: string) {
const cmd = command.toLowerCase();
if (cmd === "clear") {
setLines([]);
return;
}
const time = now();
const reply = replies[cmd];
setLines((prev) => [
...prev,
{ id: next.current++, level: "input", time, text: command },
reply
? { id: next.current++, level: "info", time, text: reply }
: { id: next.current++, level: "error", time, text: `Unknown command "${command}". Try help.` },
]);
}
return (
<Console className="h-[340px]">
<ConsoleHeader>
<span className="flex items-center gap-2">
<TerminalSquareIcon /> quarry-2
</span>
<span className="flex items-center gap-2">
<StatusDot /> live
</span>
</ConsoleHeader>
<ConsoleLines>
{lines.map((line) => (
<ConsoleLine key={line.id} level={line.level} time={line.time}>
{line.text}
</ConsoleLine>
))}
</ConsoleLines>
<ConsoleInput placeholder="Type help" aria-label="Command for quarry-2" onCommand={run} />
</Console>
);
}components/dashboard/turtles-table.tsx
"use client";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { DataTable, DataTableColumnHeader, createDataTableColumnHelper } from "@/components/ui/data-table";
import { Progress } from "@/components/ui/progress";
type Turtle = {
id: string;
name: string;
status: "mining" | "idle" | "returning" | "offline";
fuel: number;
x: number;
y: number;
z: number;
};
const names = ["Digger", "Quarry-2", "Branch", "Tunnel", "Farmhand", "Lumber", "Sifter", "Scout", "Bridge", "Cobble", "Strip-7", "Deepslate"];
const statuses: Turtle["status"][] = ["mining", "idle", "returning", "mining", "offline", "mining", "idle"];
const turtles: Turtle[] = names.map((name, i) => ({
id: `t-${String(i + 1).padStart(2, "0")}`,
name,
status: statuses[i % statuses.length],
fuel: (i * 37 + 11) % 101,
x: ((i * 73) % 400) - 200,
y: 12 + ((i * 17) % 60),
z: ((i * 131) % 500) - 250,
}));
const statusBadge = {
mining: <Badge variant="success">Mining</Badge>,
idle: <Badge variant="secondary">Idle</Badge>,
returning: <Badge variant="warning">Returning</Badge>,
offline: <Badge variant="outline">Offline</Badge>,
};
const helper = createDataTableColumnHelper<Turtle>();
const columns = helper.columns([
helper.accessor("name", {
header: ({ column }) => <DataTableColumnHeader column={column} title="Turtle" />,
cell: ({ row }) => (
<div className="flex flex-col">
<span className="font-medium text-foreground">{row.original.name}</span>
<span className="font-mono text-[11px] text-muted-foreground">{row.original.id}</span>
</div>
),
meta: { title: "Turtle" },
}),
helper.accessor("status", {
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ getValue }) => statusBadge[getValue()],
meta: { title: "Status" },
}),
helper.accessor("fuel", {
header: ({ column }) => <DataTableColumnHeader column={column} title="Fuel" />,
cell: ({ getValue }) => {
const fuel = getValue();
return (
<div className="flex w-28 items-center gap-2">
<Progress value={fuel} aria-label="Fuel" indicatorClassName={fuel < 20 ? "bg-destructive" : undefined} />
<span className="w-9 text-right font-mono text-xs text-muted-foreground tabular-nums">{fuel}%</span>
</div>
);
},
enableGlobalFilter: false,
meta: { title: "Fuel" },
}),
helper.accessor((row) => `${row.x} ${row.y} ${row.z}`, {
id: "position",
header: "Position",
cell: ({ row }) => (
<span className="font-mono text-xs whitespace-nowrap text-muted-foreground tabular-nums">
{row.original.x}, {row.original.y}, {row.original.z}
</span>
),
enableSorting: false,
meta: { title: "Position" },
}),
]);
export function TurtlesTable() {
const [data] = React.useState(turtles);
return (
<Card className="min-w-0 gap-4">
<CardHeader>
<CardTitle>Turtles</CardTitle>
<CardDescription>Every turtle registered to this hub.</CardDescription>
</CardHeader>
<CardContent className="px-4 sm:px-6">
<DataTable
columns={columns}
data={data}
getRowId={(row) => row.id}
selectable
pageSize={5}
filterPlaceholder="Filter turtles..."
/>
</CardContent>
</Card>
);
}Installation#
$npx lanterncn add dashboardThis copies all 6 files into components/dashboard and installs every component it uses. With the shadcn CLI: npx shadcn@latest add httptim/lantern-ui/dashboard.
Usage#
Render it from any page.
import Dashboard from "@/components/dashboard/dashboard";
export default function Page() {
return <Dashboard />;
}The block owns the whole viewport, so the sidebar keeps its default fixed positioning. Below 768px it opens as a sheet from the header trigger.
The console answers help, fuel, pos, inv, home and clear.