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:
265
web/src/components/app-shell.tsx
Normal file
265
web/src/components/app-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user