feat(phase2): planned badge, Cmd+K palette, per-project dashboard cards

- Project.status (planned/wired/active) + seed updates
- ProjectBadge component
- CommandPalette (⌘K) wired in PanelShell with project + nav + sign-out
- Dashboard '/' shows per-project cards + KPI strip
- Added doner312 to seeded projects
This commit is contained in:
Semih
2026-05-13 10:54:07 +00:00
parent 7b83ef8f11
commit e455ebbfce
14 changed files with 782 additions and 57 deletions

View File

@@ -12,7 +12,9 @@
"prisma:migrate:deploy": "prisma migrate deploy", "prisma:migrate:deploy": "prisma migrate deploy",
"prisma:seed": "tsx prisma/seed.ts" "prisma:seed": "tsx prisma/seed.ts"
}, },
"prisma": { "seed": "tsx prisma/seed.ts" }, "prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": { "dependencies": {
"@base-ui/react": "^1.4.1", "@base-ui/react": "^1.4.1",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -24,6 +26,7 @@
"better-auth": "^1.2.0", "better-auth": "^1.2.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"next": "^16.0.0", "next": "^16.0.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",

View File

@@ -72,7 +72,9 @@ model Project {
key String @unique key String @unique
name String name String
description String? description String?
active Boolean @default(true) /// planned | wired | active
status String @default("planned")
active Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }

View File

@@ -3,18 +3,19 @@ import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const projects = [ const projects = [
{ key: "sase", name: "Sase.tr", description: "VIN/chassis lookup + auto parts catalog", active: true }, { key: "sase", name: "Sase.tr", description: "VIN/chassis lookup + auto parts catalog", status: "active", active: true },
{ key: "catvicer", name: "Catvicer", description: "Multi-tenant catering SaaS", active: false }, { key: "catvicer", name: "Catvicer", description: "Multi-tenant catering SaaS", status: "planned", active: false },
{ key: "kokpit", name: "Kokpit", description: "Internal ops dashboard", active: false }, { key: "kokpit", name: "Kokpit", description: "Internal ops dashboard", status: "planned", active: false },
{ key: "otoyedekparca", name: "otoyedekparca.co", description: "Auto parts marketplace", active: false }, { key: "otoyedekparca", name: "otoyedekparca.co", description: "Auto parts marketplace", status: "planned", active: false },
{ key: "eryaman", name: "Eryaman Evleri TYY", description: "Residence portal", active: false }, { key: "eryaman", name: "Eryaman Evleri TYY", description: "Residence portal", status: "planned", active: false },
{ key: "doner312", name: "312 Döner", description: "QSR ordering & ops", status: "planned", active: false },
]; ];
async function main() { async function main() {
for (const p of projects) { for (const p of projects) {
await prisma.project.upsert({ await prisma.project.upsert({
where: { key: p.key }, where: { key: p.key },
update: { name: p.name, description: p.description }, update: { name: p.name, description: p.description, status: p.status, active: p.active },
create: p, create: p,
}); });
} }

View File

@@ -1,42 +1,97 @@
import { headers } from "next/headers"; import Link from "next/link";
import { redirect } from "next/navigation"; import { PanelShell } from "@/components/panel-shell";
import { auth } from "@/lib/auth"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { AppSidebar } from "@/components/app-sidebar"; import { ProjectBadge } from "@/lib/project-badge";
import { ChartAreaInteractive } from "@/components/chart-area-interactive"; import { prisma } from "@/lib/db";
import { SectionCards } from "@/components/section-cards"; import { saseDb } from "@/lib/db-sase";
import { SiteHeader } from "@/components/site-header";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; export const dynamic = "force-dynamic";
async function fetchProjectMetric(key: string): Promise<string | null> {
try {
if (key === "sase") {
const users = await saseDb.user.count();
return `${users} users`;
}
return null;
} catch {
return null;
}
}
async function fetchPanelStats() {
const [audit24h, audit7d] = await Promise.all([
prisma.auditLog.count({
where: { createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } },
}),
prisma.auditLog.count({
where: { createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) } },
}),
]);
return { audit24h, audit7d };
}
export default async function Home() { export default async function Home() {
const session = await auth.api.getSession({ headers: await headers() }); const [projects, stats] = await Promise.all([
if (!session) redirect("/login"); prisma.project.findMany({ orderBy: [{ status: "desc" }, { name: "asc" }] }),
fetchPanelStats(),
]);
const projectsWithMetric = await Promise.all(
projects.map(async (p) => ({ ...p, metric: await fetchProjectMetric(p.key) })),
);
const active = projectsWithMetric.filter((p) => p.status === "active").length;
const planned = projectsWithMetric.filter((p) => p.status === "planned").length;
return ( return (
<SidebarProvider <PanelShell title="Overview">
style={ <div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{ <KpiCard label="Active spokes" value={active.toString()} hint={`of ${projects.length} total`} />
"--sidebar-width": "calc(var(--spacing) * 72)", <KpiCard label="Planned" value={planned.toString()} hint="awaiting wiring" />
"--header-height": "calc(var(--spacing) * 12)", <KpiCard label="Audit (24h)" value={stats.audit24h.toString()} hint="all actions logged" />
} as React.CSSProperties <KpiCard label="Audit (7d)" value={stats.audit7d.toString()} hint="rolling window" />
} </div>
>
<AppSidebar <div>
variant="inset" <h2 className="mb-3 text-sm font-medium text-muted-foreground">Projects</h2>
user={{ name: session.user.name, email: session.user.email }} <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
/> {projectsWithMetric.map((p) => (
<SidebarInset> <Link key={p.id} href={`/projects/${p.key}`} className="block">
<SiteHeader /> <Card className="h-full transition-colors hover:border-foreground/40">
<div className="flex flex-1 flex-col"> <CardHeader>
<div className="@container/main flex flex-1 flex-col gap-2"> <div className="flex items-center justify-between">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6"> <CardTitle className="text-base">{p.name}</CardTitle>
<SectionCards /> <ProjectBadge status={p.status} />
<div className="px-4 lg:px-6"> </div>
<ChartAreaInteractive /> <CardDescription>{p.description ?? "—"}</CardDescription>
</div> </CardHeader>
</div> <CardContent>
</div> <div className="text-sm tabular-nums">
{p.metric ?? <span className="text-muted-foreground"></span>}
</div>
</CardContent>
</Card>
</Link>
))}
</div> </div>
</SidebarInset> </div>
</SidebarProvider>
<p className="mt-4 text-xs text-muted-foreground">
Tip: <kbd className="rounded border px-1.5 py-0.5">K</kbd> for command palette
</p>
</PanelShell>
);
}
function KpiCard({ label, value, hint }: { label: string; value: string; hint: string }) {
return (
<Card>
<CardHeader>
<CardDescription>{label}</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">{value}</CardTitle>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">{hint}</CardContent>
</Card>
); );
} }

View File

@@ -2,7 +2,7 @@ import { notFound } from "next/navigation";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { PanelShell } from "@/components/panel-shell"; import { PanelShell } from "@/components/panel-shell";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { ProjectBadge } from "@/lib/project-badge";
import { SaseHealth } from "./_sase"; import { SaseHealth } from "./_sase";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -16,9 +16,7 @@ export default async function ProjectDetail({ params }: { params: Promise<{ key:
<PanelShell title={project.name}> <PanelShell title={project.name}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="text-xl font-semibold">{project.name}</h2> <h2 className="text-xl font-semibold">{project.name}</h2>
<Badge variant={project.active ? "default" : "outline"}> <ProjectBadge status={project.status} />
{project.active ? "wired" : "not wired"}
</Badge>
</div> </div>
<p className="text-sm text-muted-foreground">{project.description}</p> <p className="text-sm text-muted-foreground">{project.description}</p>

View File

@@ -1,13 +1,15 @@
import Link from "next/link"; import Link from "next/link";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
import { PanelShell } from "@/components/panel-shell"; import { PanelShell } from "@/components/panel-shell";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { ProjectBadge } from "@/lib/project-badge";
export const dynamic = "force-dynamic";
export default async function ProjectsPage() { export default async function ProjectsPage() {
const projects = await prisma.project.findMany({ orderBy: { name: "asc" } }); const projects = await prisma.project.findMany({
orderBy: [{ status: "desc" }, { name: "asc" }],
});
return ( return (
<PanelShell title="Projects"> <PanelShell title="Projects">
@@ -18,18 +20,13 @@ export default async function ProjectsPage() {
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-base">{p.name}</CardTitle> <CardTitle className="text-base">{p.name}</CardTitle>
<Badge variant={p.active ? "default" : "outline"}> <ProjectBadge status={p.status} />
{p.active ? "wired" : "not wired"}
</Badge>
</div> </div>
<CardDescription>{p.description ?? "—"}</CardDescription> <CardDescription>{p.description ?? "—"}</CardDescription>
</CardHeader> </CardHeader>
</Card> </Card>
</Link> </Link>
))} ))}
{projects.length === 0 && (
<p className="text-sm text-muted-foreground">No projects yet run prisma seed.</p>
)}
</div> </div>
</PanelShell> </PanelShell>
); );

View File

@@ -0,0 +1,94 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import {
ActivityIcon,
FolderIcon,
LayoutDashboardIcon,
LogOutIcon,
ScrollTextIcon,
Settings2Icon,
TerminalIcon,
} from "lucide-react";
import { signOut } from "@/lib/auth-client";
type ProjectLite = { key: string; name: string; status: string };
export function CommandPalette({ projects }: { projects: ProjectLite[] }) {
const router = useRouter();
const [open, setOpen] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen((v) => !v);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const go = (path: string) => {
setOpen(false);
router.push(path);
};
return (
<CommandDialog open={open} onOpenChange={setOpen} title="Command palette" description="Quick navigation">
<CommandInput placeholder="Jump to project, page, or action…" />
<CommandList>
<CommandEmpty>No match.</CommandEmpty>
<CommandGroup heading="Navigation">
<CommandItem onSelect={() => go("/")}><LayoutDashboardIcon /> Overview</CommandItem>
<CommandItem onSelect={() => go("/projects")}><FolderIcon /> Projects</CommandItem>
<CommandItem onSelect={() => go("/operations")}><TerminalIcon /> Operations</CommandItem>
<CommandItem onSelect={() => go("/events")}><ActivityIcon /> Events</CommandItem>
<CommandItem onSelect={() => go("/audit")}><ScrollTextIcon /> Audit</CommandItem>
<CommandItem onSelect={() => go("/settings")}><Settings2Icon /> Settings</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Projects">
{projects.map((p) => (
<CommandItem
key={p.key}
keywords={[p.key, p.name, p.status]}
onSelect={() => go(`/projects/${p.key}`)}
>
<FolderIcon />
<span>{p.name}</span>
<span className="ml-auto text-xs text-muted-foreground">{p.status}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Account">
<CommandItem
onSelect={async () => {
setOpen(false);
await signOut();
router.replace("/login");
}}
>
<LogOutIcon /> Sign out
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
);
}

View File

@@ -1,8 +1,10 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { AppSidebar } from "@/components/app-sidebar"; import { AppSidebar } from "@/components/app-sidebar";
import { SiteHeader } from "@/components/site-header"; import { SiteHeader } from "@/components/site-header";
import { CommandPalette } from "@/components/command-palette";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
export async function PanelShell({ export async function PanelShell({
@@ -14,6 +16,12 @@ export async function PanelShell({
}) { }) {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login"); if (!session) redirect("/login");
const projects = await prisma.project.findMany({
select: { key: true, name: true, status: true },
orderBy: [{ status: "desc" }, { name: "asc" }],
});
return ( return (
<SidebarProvider <SidebarProvider
style={ style={
@@ -28,6 +36,7 @@ export async function PanelShell({
<SiteHeader title={title} /> <SiteHeader title={title} />
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">{children}</div> <div className="flex flex-1 flex-col gap-4 p-4 md:p-6">{children}</div>
</SidebarInset> </SidebarInset>
<CommandPalette projects={projects} />
</SidebarProvider> </SidebarProvider>
); );
} }

View File

@@ -0,0 +1,196 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
children: React.ReactNode
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,13 @@
import { Badge } from "@/components/ui/badge";
export function ProjectBadge({ status }: { status: string }) {
switch (status) {
case "active":
return <Badge className="bg-emerald-500/15 text-emerald-300 border-emerald-500/30">active</Badge>;
case "wired":
return <Badge variant="secondary">wired</Badge>;
case "planned":
default:
return <Badge variant="outline">planned</Badge>;
}
}

21
pnpm-lock.yaml generated
View File

@@ -47,6 +47,9 @@ importers:
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
lucide-react: lucide-react:
specifier: ^1.14.0 specifier: ^1.14.0
version: 1.14.0(react@19.2.6) version: 1.14.0(react@19.2.6)
@@ -1471,6 +1474,12 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} engines: {node: '>=6'}
cmdk@1.1.1:
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
code-block-writer@13.0.3: code-block-writer@13.0.3:
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
@@ -4017,6 +4026,18 @@ snapshots:
clsx@2.1.1: {} clsx@2.1.1: {}
cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
code-block-writer@13.0.3: {} code-block-writer@13.0.3: {}
color-convert@2.0.1: color-convert@2.0.1: