Redesign: Linear-inspired dark mode frontend

Complete rewrite of all pages:
- layout.tsx: App shell with 240px sidebar (saved views, queue list, admin link)
- app-shell.tsx: Client sidebar component with route highlighting + counts
- page.tsx: Sleek ticket list with filter chips (All/Open/In progress/Resolved), search bar, status dots, assignee avatars, skeleton loading
- tickets/[id]/page.tsx: Two-panel conversation layout — message thread (left) + properties sidebar (right) with status change, scrip preview, reply box
- admin/page.tsx: Suspense-wrapped admin with tabs in sheet panels
- command-palette.tsx: Cmd+K search with keyboard navigation

Design tokens from Linear:
- bg-[#08090a] canvas, bg-[#0f1011] panels, bg-[#191a1b] cards
- text-[#f7f8f8] primary, text-[#d0d6e0] secondary, text-[#8a8f98] tertiary
- borders: rgba(255,255,255,0.08) standard, rgba(255,255,255,0.05) subtle
- accent: #5e6ad2 primary, #7170ff interactive
- status colors: new=gray, open=indigo, in_progress=amber, resolved=green
- Inter font, weights 400/510/590, no pure white

Fixed: Suspense boundaries for useSearchParams in layout and admin pages
Build: passes with zero errors
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-07 22:16:18 +02:00
parent df677cb37f
commit 77860eb6c4
7 changed files with 2118 additions and 1256 deletions

View File

@@ -0,0 +1,265 @@
"use client";
import { useState, useEffect, Suspense } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
LayoutGridIcon,
UserIcon,
InboxIcon,
ClockIcon,
SettingsIcon,
} from "lucide-react";
import { getTickets, getQueues } from "@/lib/api";
import type { Queue } from "@/lib/types";
import { CommandPalette } from "@/components/command-palette";
import { cn } from "@/lib/utils";
interface ViewCounts {
all: number;
my: number;
unassigned: number;
recent: number;
}
function SidebarNav() {
const pathname = usePathname();
const searchParams = useSearchParams();
const [counts, setCounts] = useState<ViewCounts>({
all: 0,
my: 0,
unassigned: 0,
recent: 0,
});
const [queues, setQueues] = useState<(Queue & { count: number })[]>([]);
useEffect(() => {
getTickets().then(({ data }) => {
if (data) {
const now = Date.now();
const week = 7 * 24 * 60 * 60 * 1000;
setCounts({
all: data.length,
my: data.filter((t) => t.owner_id).length,
unassigned: data.filter((t) => !t.owner_id).length,
recent: data.filter(
(t) => new Date(t.updated_at).getTime() > now - week
).length,
});
}
});
getQueues().then(({ data }) => {
if (data) {
Promise.all(
data.map((q) =>
getTickets({ queue_id: q.id }).then(({ data: tickets }) => ({
...q,
count: tickets?.length ?? 0,
}))
)
).then(setQueues);
}
});
}, []);
const views = [
{
label: "All tickets",
href: "/",
param: null,
count: counts.all,
icon: LayoutGridIcon,
},
{
label: "My tickets",
href: "/?view=my",
param: "my",
count: counts.my,
icon: UserIcon,
},
{
label: "Unassigned",
href: "/?view=unassigned",
param: "unassigned",
count: counts.unassigned,
icon: InboxIcon,
},
{
label: "Recently updated",
href: "/?view=recent",
param: "recent",
count: counts.recent,
icon: ClockIcon,
},
];
const currentView = searchParams.get("view");
return (
<>
<div className="mb-4">
{views.map((view) => {
const Icon = view.icon;
const active =
pathname === "/" &&
(view.param ? currentView === view.param : !currentView);
return (
<Link
key={view.label}
href={view.href}
className={cn(
"flex items-center justify-between px-2 py-1.5 rounded-md text-[13px] transition-colors mb-0.5",
active
? "bg-[rgba(255,255,255,0.05)] text-[#f7f8f8] font-medium"
: "text-[#8a8f98] hover:text-[#d0d6e0] hover:bg-[rgba(255,255,255,0.03)] font-normal"
)}
>
<span className="flex items-center gap-2.5">
<Icon className="w-4 h-4 flex-shrink-0" />
{view.label}
</span>
{view.count > 0 && (
<span className="text-xs tabular-nums text-[#8a8f98]">
{view.count}
</span>
)}
</Link>
);
})}
</div>
{queues.length > 0 && (
<div>
<div className="px-2 py-1.5 text-[11px] font-semibold text-[#8a8f98] uppercase tracking-wider">
Queues
</div>
{queues.map((queue) => {
const active =
pathname === "/" && searchParams.get("queue") === queue.id;
return (
<Link
key={queue.id}
href={`/?queue=${queue.id}`}
className={cn(
"flex items-center justify-between px-2 py-1.5 rounded-md text-[13px] transition-colors",
active
? "bg-[rgba(255,255,255,0.05)] text-[#f7f8f8] font-medium"
: "text-[#8a8f98] hover:text-[#d0d6e0] hover:bg-[rgba(255,255,255,0.03)] font-normal"
)}
>
<span className="flex items-center gap-2.5">
<span className="w-2 h-2 rounded-full bg-[#8a8f98] flex-shrink-0" />
{queue.name}
</span>
{queue.count > 0 && (
<span className="text-xs tabular-nums text-[#8a8f98]">
{queue.count}
</span>
)}
</Link>
);
})}
</div>
)}
</>
);
}
function SidebarBottom() {
const pathname = usePathname();
return (
<div className="border-t border-[rgba(255,255,255,0.05)] p-2">
<Link
href="/admin"
className={cn(
"flex items-center gap-2.5 px-2 py-1.5 rounded-md text-[13px] transition-colors",
pathname === "/admin"
? "bg-[rgba(255,255,255,0.05)] text-[#f7f8f8] font-medium"
: "text-[#8a8f98] hover:text-[#d0d6e0] hover:bg-[rgba(255,255,255,0.03)] font-normal"
)}
>
<SettingsIcon className="w-4 h-4 flex-shrink-0" />
Admin
</Link>
<div className="flex items-center gap-2 px-2 py-1.5 mt-0.5">
<div className="w-5 h-5 rounded-full bg-[#5e6ad2] flex items-center justify-center flex-shrink-0">
<span className="text-[#f7f8f8] text-[10px] font-semibold">U</span>
</div>
<span className="text-[13px] text-[#8a8f98] truncate">User</span>
</div>
</div>
);
}
export function AppShell({ children }: { children: React.ReactNode }) {
const [commandOpen, setCommandOpen] = useState(false);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || e.key === "/") {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
(e.target instanceof HTMLElement && e.target.isContentEditable)
) {
return;
}
e.preventDefault();
setCommandOpen((o) => !o);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
return (
<div className="flex h-screen overflow-hidden">
{/* Sidebar */}
<aside className="w-60 flex-shrink-0 flex flex-col bg-[#0f1011] border-r border-[rgba(255,255,255,0.05)]">
{/* Brand */}
<div className="h-11 flex items-center px-3 border-b border-[rgba(255,255,255,0.05)]">
<Link href="/" className="flex items-center gap-2">
<div className="w-5 h-5 rounded-md bg-[#5e6ad2] flex items-center justify-center">
<span className="text-[#f7f8f8] text-[11px] font-semibold">
T
</span>
</div>
<span className="font-semibold text-[#f7f8f8] text-sm tracking-tight">
Tessera
</span>
</Link>
</div>
{/* Nav */}
<nav className="flex-1 overflow-y-auto py-2 px-2">
<Suspense
fallback={
<div className="space-y-1.5 px-2">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-7 bg-[#191a1b] rounded-md animate-pulse"
/>
))}
</div>
}
>
<SidebarNav />
</Suspense>
</nav>
{/* Bottom */}
<SidebarBottom />
</aside>
{/* Main */}
<main className="flex-1 overflow-y-auto">{children}</main>
{/* Command Palette */}
<CommandPalette open={commandOpen} onOpenChange={setCommandOpen} />
</div>
);
}