feat(web): add shared Breadcrumb component for workspace pages
Replace ad-hoc back-arrow and inline breadcrumb markup in PeerList, SessionList, SessionDetail, ConclusionBrowser, and WebhookManager with a single router-aware Breadcrumb. Demo-aware via useDemo() so workspace and entity IDs render masked when demo mode is active.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { Link, useParams } from "@tanstack/react-router";
|
import { Link, useParams } from "@tanstack/react-router";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { ArrowLeft, Eye, Lightbulb, Plus, Search, Trash2, X } from "lucide-react";
|
import { Eye, Lightbulb, Plus, Search, Trash2, X } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
useQueryConclusions,
|
useQueryConclusions,
|
||||||
} from "@/api/queries";
|
} from "@/api/queries";
|
||||||
import type { components } from "@/api/schema.d.ts";
|
import type { components } from "@/api/schema.d.ts";
|
||||||
|
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||||
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
||||||
import { EmptyState } from "@/components/shared/EmptyState";
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
@@ -108,15 +109,7 @@ export function ConclusionBrowser() {
|
|||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-8">
|
||||||
<Link
|
<Breadcrumb />
|
||||||
to="/workspaces/$workspaceId"
|
|
||||||
params={{ workspaceId } as never}
|
|
||||||
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
|
||||||
style={{ color: "var(--text-3)" }}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
|
||||||
{mask(workspaceId)}
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Lightbulb className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
<Lightbulb className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
<PageTitle>Conclusions</PageTitle>
|
<PageTitle>Conclusions</PageTitle>
|
||||||
|
|||||||
104
packages/web/src/components/layout/Breadcrumb.tsx
Normal file
104
packages/web/src/components/layout/Breadcrumb.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { Link, useRouter } from "@tanstack/react-router";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import { useDemo } from "@/hooks/useDemo";
|
||||||
|
|
||||||
|
const SECTION_LABELS: Record<string, string> = {
|
||||||
|
peers: "Peers",
|
||||||
|
sessions: "Sessions",
|
||||||
|
conclusions: "Conclusions",
|
||||||
|
webhooks: "Webhooks",
|
||||||
|
chat: "Chat",
|
||||||
|
};
|
||||||
|
|
||||||
|
const KNOWN_SECTIONS = new Set(Object.keys(SECTION_LABELS));
|
||||||
|
|
||||||
|
type Segment = { label: string; href: string | null; mono?: boolean };
|
||||||
|
|
||||||
|
function buildSegments(pathname: string, mask: (v: string) => string): Segment[] {
|
||||||
|
if (!pathname.startsWith("/workspaces")) return [];
|
||||||
|
|
||||||
|
const rest = pathname.slice("/workspaces".length); // "" | "/wid" | "/wid/peers" | ...
|
||||||
|
|
||||||
|
if (!rest) return [{ label: "Workspaces", href: null }];
|
||||||
|
|
||||||
|
const parts = rest.slice(1).split("/"); // ["wid"] | ["wid", "peers"] | ...
|
||||||
|
const wid = parts[0];
|
||||||
|
if (!wid) return [{ label: "Workspaces", href: null }];
|
||||||
|
|
||||||
|
const segments: Segment[] = [{ label: "Workspaces", href: "/workspaces" }];
|
||||||
|
|
||||||
|
if (parts.length === 1) {
|
||||||
|
segments.push({ label: mask(wid), href: null, mono: true });
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
segments.push({ label: mask(wid), href: `/workspaces/${wid}`, mono: true });
|
||||||
|
|
||||||
|
const section = parts[1];
|
||||||
|
if (!section || !KNOWN_SECTIONS.has(section)) return segments;
|
||||||
|
|
||||||
|
if (parts.length === 2) {
|
||||||
|
segments.push({ label: SECTION_LABELS[section], href: null });
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
segments.push({ label: SECTION_LABELS[section], href: `/workspaces/${wid}/${section}` });
|
||||||
|
|
||||||
|
const subId = parts[2];
|
||||||
|
if (!subId) return segments;
|
||||||
|
|
||||||
|
if (parts.length === 3) {
|
||||||
|
segments.push({ label: mask(subId), href: null, mono: true });
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
segments.push({
|
||||||
|
label: mask(subId),
|
||||||
|
href: `/workspaces/${wid}/${section}/${subId}`,
|
||||||
|
mono: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const subSection = parts[3];
|
||||||
|
if (subSection && SECTION_LABELS[subSection]) {
|
||||||
|
segments.push({ label: SECTION_LABELS[subSection], href: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Breadcrumb() {
|
||||||
|
const { state } = useRouter();
|
||||||
|
const { mask } = useDemo();
|
||||||
|
const segments = buildSegments(state.location.pathname, mask);
|
||||||
|
|
||||||
|
if (segments.length <= 1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="Breadcrumb" className="flex items-center gap-1 mb-4 flex-wrap">
|
||||||
|
{segments.map((seg, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-1">
|
||||||
|
{i > 0 && (
|
||||||
|
<ChevronRight
|
||||||
|
className="w-3 h-3 shrink-0"
|
||||||
|
style={{ color: "var(--text-4)" }}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{seg.href ? (
|
||||||
|
<Link
|
||||||
|
to={seg.href as never}
|
||||||
|
className={`text-xs transition-colors hover:text-[color:var(--accent-text)] ${seg.mono ? "font-mono" : ""}`}
|
||||||
|
style={{ color: "var(--text-3)" }}
|
||||||
|
>
|
||||||
|
{seg.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium ${seg.mono ? "font-mono" : ""}`}
|
||||||
|
style={{ color: "var(--text-2)" }}
|
||||||
|
>
|
||||||
|
{seg.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
import { useNavigate, useParams } from "@tanstack/react-router";
|
||||||
import { motion, type Variants } from "framer-motion";
|
import { motion, type Variants } from "framer-motion";
|
||||||
import { ArrowLeft, ChevronRight, Clock, Eye, Users } from "lucide-react";
|
import { ChevronRight, Clock, Eye, Users } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { usePeers } from "@/api/queries";
|
import { usePeers } from "@/api/queries";
|
||||||
import type { components } from "@/api/schema.d.ts";
|
import type { components } from "@/api/schema.d.ts";
|
||||||
|
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||||
import { EmptyState } from "@/components/shared/EmptyState";
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
import { JsonViewer } from "@/components/shared/JsonViewer";
|
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||||
@@ -105,15 +106,7 @@ export function PeerList() {
|
|||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-6">
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-6">
|
||||||
<Link
|
<Breadcrumb />
|
||||||
to="/workspaces/$workspaceId"
|
|
||||||
params={{ workspaceId } as never}
|
|
||||||
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
|
||||||
style={{ color: COLOR.dimText }}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
|
||||||
{mask(workspaceId)}
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Users className="w-5 h-5" style={{ color: COLOR.accent }} strokeWidth={1.5} />
|
<Users className="w-5 h-5" style={{ color: COLOR.accent }} strokeWidth={1.5} />
|
||||||
<PageTitle>Peers</PageTitle>
|
<PageTitle>Peers</PageTitle>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
import { useNavigate, useParams } from "@tanstack/react-router";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { AlignLeft, Clock, Copy, MessageSquare, Search, Trash2, Users, X } from "lucide-react";
|
import { AlignLeft, Clock, Copy, MessageSquare, Search, Trash2, Users, X } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
useSessionSummaries,
|
useSessionSummaries,
|
||||||
} from "@/api/queries";
|
} from "@/api/queries";
|
||||||
import type { components } from "@/api/schema.d.ts";
|
import type { components } from "@/api/schema.d.ts";
|
||||||
|
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||||
import { Badge } from "@/components/shared/Badge";
|
import { Badge } from "@/components/shared/Badge";
|
||||||
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
||||||
import { JsonViewer } from "@/components/shared/JsonViewer";
|
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||||
@@ -110,23 +111,7 @@ export function SessionDetail() {
|
|||||||
return (
|
return (
|
||||||
<div className="page-container page-container--wide">
|
<div className="page-container page-container--wide">
|
||||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||||
<div className="flex items-center gap-2 text-xs mb-4" style={{ color: "var(--text-3)" }}>
|
<Breadcrumb />
|
||||||
<Link
|
|
||||||
to="/workspaces/$workspaceId"
|
|
||||||
params={{ workspaceId } as never}
|
|
||||||
className="hover:underline font-mono"
|
|
||||||
>
|
|
||||||
{mask(workspaceId)}
|
|
||||||
</Link>
|
|
||||||
<span>/</span>
|
|
||||||
<Link
|
|
||||||
to="/workspaces/$workspaceId/sessions"
|
|
||||||
params={{ workspaceId } as never}
|
|
||||||
className="hover:underline"
|
|
||||||
>
|
|
||||||
Sessions
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-4 mb-1">
|
<div className="flex items-start justify-between gap-4 mb-1">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
import { useNavigate, useParams } from "@tanstack/react-router";
|
||||||
import { motion, type Variants } from "framer-motion";
|
import { motion, type Variants } from "framer-motion";
|
||||||
import { ArrowLeft, ChevronRight, CircleDot, Clock, MessageSquare } from "lucide-react";
|
import { ChevronRight, CircleDot, Clock, MessageSquare } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useSessions } from "@/api/queries";
|
import { useSessions } from "@/api/queries";
|
||||||
import type { components } from "@/api/schema.d.ts";
|
import type { components } from "@/api/schema.d.ts";
|
||||||
|
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||||
import { EmptyState } from "@/components/shared/EmptyState";
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
import { Pagination } from "@/components/shared/Pagination";
|
import { Pagination } from "@/components/shared/Pagination";
|
||||||
@@ -66,15 +67,7 @@ export function SessionList() {
|
|||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-6">
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} className="mb-6">
|
||||||
<Link
|
<Breadcrumb />
|
||||||
to="/workspaces/$workspaceId"
|
|
||||||
params={{ workspaceId } as never}
|
|
||||||
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
|
||||||
style={{ color: COLOR.dimText }}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
|
||||||
{mask(workspaceId)}
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<MessageSquare className="w-5 h-5" style={{ color: COLOR.accent }} strokeWidth={1.5} />
|
<MessageSquare className="w-5 h-5" style={{ color: COLOR.accent }} strokeWidth={1.5} />
|
||||||
<PageTitle>Sessions</PageTitle>
|
<PageTitle>Sessions</PageTitle>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
import { open } from "@tauri-apps/plugin-shell";
|
import { open } from "@tauri-apps/plugin-shell";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { ArrowLeft, ExternalLink, Plus, Trash2, Webhook, Zap } from "lucide-react";
|
import { ExternalLink, Plus, Trash2, Webhook, Zap } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useCreateWebhook, useDeleteWebhook, useTestWebhook, useWebhooks } from "@/api/queries";
|
import { useCreateWebhook, useDeleteWebhook, useTestWebhook, useWebhooks } from "@/api/queries";
|
||||||
|
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||||
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
||||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||||
import { Skeleton } from "@/components/shared/Skeleton";
|
import { Skeleton } from "@/components/shared/Skeleton";
|
||||||
@@ -55,15 +55,7 @@ export function WebhookManager({ workspaceId }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||||
<Link
|
<Breadcrumb />
|
||||||
to="/workspaces/$workspaceId"
|
|
||||||
params={{ workspaceId } as never}
|
|
||||||
className="inline-flex items-center gap-1.5 text-xs mb-4 transition-colors"
|
|
||||||
style={{ color: "var(--text-3)" }}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-3 h-3" strokeWidth={1.5} />
|
|
||||||
{mask(workspaceId)}
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Webhook className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
<Webhook className="w-5 h-5" style={{ color: "var(--accent)" }} strokeWidth={1.5} />
|
||||||
|
|||||||
Reference in New Issue
Block a user