feat(web): add global metadata visibility toggle
Introduce MetadataContext + useMetadata hook backed by localStorage and wire a Braces button into the sidebar footer that toggles raw metadata visibility across the app. Replace per-page collapsible metadata in PeerDetail and WorkspaceDetail with a global animated reveal styled distinctly (warning-colored card) to signal raw payload. Also adopts the shared Breadcrumb in both detail pages.
This commit is contained in:
@@ -2,6 +2,7 @@ import { Link, useMatchRoute } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
Boxes,
|
||||
Braces,
|
||||
Check,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
@@ -21,6 +22,7 @@ import { HealthDot } from "@/components/shared/HealthDot";
|
||||
import { useDemo } from "@/hooks/useDemo";
|
||||
import { useHealthStatus } from "@/hooks/useHealthStatus";
|
||||
import { useInstances } from "@/hooks/useInstances";
|
||||
import { useMetadata } from "@/hooks/useMetadata";
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
|
||||
@@ -42,6 +44,7 @@ export function Sidebar() {
|
||||
const { instances, active, activate } = useInstances();
|
||||
const { theme, toggle } = useTheme();
|
||||
const { demo, toggle: toggleDemo, mask } = useDemo();
|
||||
const { showMetadata, toggle: toggleMeta } = useMetadata();
|
||||
const { data: health } = useHealthStatus();
|
||||
const [switcherOpen, setSwitcherOpen] = useState(false);
|
||||
const switcherRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -285,7 +288,7 @@ export function Sidebar() {
|
||||
</AnimatePresence>
|
||||
</nav>
|
||||
|
||||
{/* Theme toggle + footer */}
|
||||
{/* Footer — version, demo, metadata, theme */}
|
||||
<div
|
||||
className="px-3 sm:px-5 py-3 flex items-center justify-between"
|
||||
style={{ borderTop: "1px solid var(--border)" }}
|
||||
@@ -311,6 +314,19 @@ export function Sidebar() {
|
||||
<Eye className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMeta}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center transition-colors"
|
||||
style={{
|
||||
background: showMetadata ? "rgba(245,158,11,0.1)" : "var(--surface)",
|
||||
border: `1px solid ${showMetadata ? "rgba(245,158,11,0.3)" : "var(--border)"}`,
|
||||
color: showMetadata ? COLOR.warning : "var(--text-3)",
|
||||
}}
|
||||
title={showMetadata ? "Hide raw metadata" : "Show raw metadata"}
|
||||
>
|
||||
<Braces className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
ChevronDown,
|
||||
Eye,
|
||||
EyeOff,
|
||||
MessageCircle,
|
||||
Save,
|
||||
Search,
|
||||
User,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Eye, EyeOff, MessageCircle, Save, Search, User, Users, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
usePeer,
|
||||
@@ -20,6 +10,7 @@ import {
|
||||
useSearchPeer,
|
||||
useSetPeerCard,
|
||||
} from "@/api/queries";
|
||||
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||
import { Badge } from "@/components/shared/Badge";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||
@@ -38,10 +29,12 @@ import {
|
||||
SectionHeading,
|
||||
} from "@/components/ui/typography";
|
||||
import { useDemo } from "@/hooks/useDemo";
|
||||
import { useMetadata } from "@/hooks/useMetadata";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
|
||||
export function PeerDetail() {
|
||||
const { mask } = useDemo();
|
||||
const { showMetadata } = useMetadata();
|
||||
const { workspaceId, peerId } = useParams({ strict: false }) as {
|
||||
workspaceId: string;
|
||||
peerId: string;
|
||||
@@ -65,7 +58,6 @@ export function PeerDetail() {
|
||||
|
||||
const [cardDraft, setCardDraft] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [metaExpanded, setMetaExpanded] = useState(false);
|
||||
|
||||
const observeMe = (peer as { configuration?: { observe_me?: boolean } } | undefined)
|
||||
?.configuration?.observe_me;
|
||||
@@ -79,27 +71,7 @@ export function PeerDetail() {
|
||||
return (
|
||||
<div className="page-container page-container--xl">
|
||||
<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)" }}>
|
||||
<Link to="/workspaces" className="hover:underline">
|
||||
Workspaces
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link
|
||||
to="/workspaces/$workspaceId"
|
||||
params={{ workspaceId } as never}
|
||||
className="hover:underline font-mono"
|
||||
>
|
||||
{mask(workspaceId)}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link
|
||||
to="/workspaces/$workspaceId/peers"
|
||||
params={{ workspaceId } as never}
|
||||
className="hover:underline"
|
||||
>
|
||||
Peers
|
||||
</Link>
|
||||
</div>
|
||||
<Breadcrumb />
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
@@ -377,47 +349,29 @@ export function PeerDetail() {
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Metadata — collapsible */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
className="rounded-xl theme-card overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMetaExpanded((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-5 py-4"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
<SectionHeading className="mb-0">Metadata</SectionHeading>
|
||||
{/* Metadata — global toggle */}
|
||||
<AnimatePresence>
|
||||
{showMetadata && (
|
||||
<motion.div
|
||||
animate={{ rotate: metaExpanded ? 0 : -90 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<ChevronDown
|
||||
className="w-4 h-4"
|
||||
strokeWidth={2}
|
||||
style={{ color: COLOR.dimText }}
|
||||
/>
|
||||
</motion.div>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{metaExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
<div
|
||||
className="rounded-xl p-5"
|
||||
style={{
|
||||
background: "rgba(245,158,11,0.04)",
|
||||
border: "1px solid rgba(245,158,11,0.2)",
|
||||
}}
|
||||
>
|
||||
<div className="px-5 pb-5">
|
||||
<JsonViewer data={peer.metadata} maxHeight="300px" />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
<SectionHeading style={{ color: COLOR.warning }}>Metadata</SectionHeading>
|
||||
<JsonViewer data={peer.metadata} maxHeight="300px" />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Boxes,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useDeleteWorkspace, useQueueStatus, useScheduleDream, useWorkspace } from "@/api/queries";
|
||||
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||
import { ConfirmDialog } from "@/components/shared/ConfirmDialog";
|
||||
import { ErrorAlert } from "@/components/shared/ErrorAlert";
|
||||
import { JsonViewer } from "@/components/shared/JsonViewer";
|
||||
@@ -22,6 +22,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Body, Caption, PageTitle, SectionHeading } from "@/components/ui/typography";
|
||||
import { ScheduleDreamModal } from "@/components/workspaces/ScheduleDreamModal";
|
||||
import { useDemo } from "@/hooks/useDemo";
|
||||
import { useMetadata } from "@/hooks/useMetadata";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
|
||||
const NAV_SECTIONS = [
|
||||
@@ -53,6 +54,7 @@ const NAV_SECTIONS = [
|
||||
|
||||
export function WorkspaceDetail() {
|
||||
const { mask } = useDemo();
|
||||
const { showMetadata } = useMetadata();
|
||||
const { workspaceId } = useParams({ strict: false }) as { workspaceId: string };
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -74,14 +76,7 @@ export function WorkspaceDetail() {
|
||||
return (
|
||||
<div className="page-container page-container--wide">
|
||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<Link
|
||||
to="/workspaces"
|
||||
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} />
|
||||
Workspaces
|
||||
</Link>
|
||||
<Breadcrumb />
|
||||
<div className="flex items-start justify-between gap-4 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Boxes
|
||||
@@ -125,7 +120,7 @@ export function WorkspaceDetail() {
|
||||
<Link
|
||||
to={`/workspaces/$workspaceId/${s.to}` as never}
|
||||
params={{ workspaceId } as never}
|
||||
className="block rounded-xl p-5 group transition-all theme-card"
|
||||
className="block h-full rounded-xl p-5 group transition-all theme-card"
|
||||
>
|
||||
<Icon
|
||||
className="w-5 h-5 mb-3"
|
||||
@@ -309,16 +304,29 @@ export function WorkspaceDetail() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.38 }}
|
||||
className="rounded-xl p-5 theme-card"
|
||||
>
|
||||
<SectionHeading>Metadata</SectionHeading>
|
||||
<JsonViewer data={workspace.metadata} />
|
||||
</motion.div>
|
||||
{/* Metadata — global toggle */}
|
||||
<AnimatePresence>
|
||||
{showMetadata && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="rounded-xl p-5"
|
||||
style={{
|
||||
background: "rgba(245,158,11,0.04)",
|
||||
border: "1px solid rgba(245,158,11,0.2)",
|
||||
}}
|
||||
>
|
||||
<SectionHeading style={{ color: COLOR.warning }}>Metadata</SectionHeading>
|
||||
<JsonViewer data={workspace.metadata} />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
34
packages/web/src/context/MetadataContext.tsx
Normal file
34
packages/web/src/context/MetadataContext.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createContext, type ReactNode, useContext, useState } from "react";
|
||||
|
||||
const STORAGE_KEY = "openconcho:show-metadata";
|
||||
|
||||
interface MetadataContextValue {
|
||||
showMetadata: boolean;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
const MetadataContext = createContext<MetadataContextValue | null>(null);
|
||||
|
||||
export function MetadataProvider({ children }: { children: ReactNode }) {
|
||||
const [showMetadata, setShowMetadata] = useState<boolean>(
|
||||
() => localStorage.getItem(STORAGE_KEY) === "true",
|
||||
);
|
||||
|
||||
function toggle() {
|
||||
setShowMetadata((v) => {
|
||||
const next = !v;
|
||||
localStorage.setItem(STORAGE_KEY, String(next));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<MetadataContext.Provider value={{ showMetadata, toggle }}>{children}</MetadataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMetadataContext(): MetadataContextValue {
|
||||
const ctx = useContext(MetadataContext);
|
||||
if (!ctx) throw new Error("useMetadataContext must be used within MetadataProvider");
|
||||
return ctx;
|
||||
}
|
||||
1
packages/web/src/hooks/useMetadata.ts
Normal file
1
packages/web/src/hooks/useMetadata.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useMetadataContext as useMetadata } from "@/context/MetadataContext";
|
||||
@@ -3,6 +3,7 @@ import { createRouter, RouterProvider } from "@tanstack/react-router";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { DemoProvider } from "./context/DemoContext";
|
||||
import { MetadataProvider } from "./context/MetadataContext";
|
||||
import { initDeepLinks } from "./lib/deep-link";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import "./index.css";
|
||||
@@ -37,7 +38,9 @@ createRoot(root).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DemoProvider>
|
||||
<RouterProvider router={router} />
|
||||
<MetadataProvider>
|
||||
<RouterProvider router={router} />
|
||||
</MetadataProvider>
|
||||
</DemoProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
|
||||
Reference in New Issue
Block a user