Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions apps/studio/frontend/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages, getTranslations } from "next-intl/server";
import Shell from "@/components/Shell";
import { ToastProvider } from "@/components/Toast";
import "./globals.css";
Expand All @@ -9,13 +11,17 @@ export const metadata: Metadata = {
description: "Lion Studio orchestration observability",
};

export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: ReactNode;
}>) {
const locale = await getLocale();
const messages = await getMessages();
const t = await getTranslations("common");

return (
<html lang="en" suppressHydrationWarning>
<html lang={locale} suppressHydrationWarning>
<head>
{/* Prevent FOUC: read localStorage before paint, default to light */}
<script
Expand All @@ -29,11 +35,13 @@ export default function RootLayout({
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:z-[100] focus:m-2 focus:rounded focus:border focus:border-edge focus:bg-surface-nav focus:px-4 focus:py-2 focus:text-content-primary"
>
Skip to main content
{t("skipToMain")}
</a>
<ToastProvider>
<Shell>{children}</Shell>
</ToastProvider>
<NextIntlClientProvider messages={messages}>
<ToastProvider>
<Shell>{children}</Shell>
</ToastProvider>
</NextIntlClientProvider>
</body>
</html>
);
Expand Down
49 changes: 36 additions & 13 deletions apps/studio/frontend/components/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import Link from "next/link";
import { usePathname } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import { Suspense, useEffect, useState, type ReactNode } from "react";
import Breadcrumb from "@/components/nav/Breadcrumb";
import NavGroup from "@/components/nav/NavGroup";
Expand All @@ -12,7 +13,34 @@ export interface ShellProps {
children: ReactNode;
}

function LocaleSwitcher() {
const t = useTranslations("nav");
const locale = useLocale();

function switchLocale(newLocale: string) {
document.cookie = `NEXT_LOCALE=${newLocale};path=/;max-age=31536000;SameSite=Lax`;
window.location.reload();
}

const nextLocale = locale === "en" ? "zh" : "en";
const buttonLabel = locale === "en" ? t("localeSwitcher.labelZh") : t("localeSwitcher.labelEn");
const ariaLabel =
locale === "en" ? t("localeSwitcher.switchToZh") : t("localeSwitcher.switchToEn");

return (
<button
type="button"
onClick={() => switchLocale(nextLocale)}
aria-label={ariaLabel}
className="ml-1 flex h-6 items-center justify-center rounded border border-edge px-1.5 text-[11px] text-content-secondary hover:border-edge-strong hover:text-content-primary transition-colors cursor-pointer"
>
{buttonLabel}
</button>
);
}

function ThemeToggle() {
const t = useTranslations("nav");
const [dark, setDark] = useState(false);

useEffect(() => {
Expand All @@ -36,7 +64,7 @@ function ThemeToggle() {
<button
type="button"
onClick={toggle}
aria-label="Toggle theme"
aria-label={t("theme.toggle")}
aria-pressed={dark}
className="ml-1 flex h-6 w-6 shrink-0 items-center justify-center rounded text-content-muted hover:bg-interactive-secondary hover:text-content-primary transition-colors"
>
Expand Down Expand Up @@ -84,18 +112,12 @@ function ThemeToggle() {
}

export default function Shell({ children }: ShellProps) {
const t = useTranslations("nav");
const pathname = usePathname() ?? "/";
const [mobileOpen, setMobileOpen] = useState(false);

return (
<div className="min-h-screen bg-surface-base text-content-primary">
{/* Skip-to-main: sr-only by default; visible on keyboard focus */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-2 focus:top-2 focus:z-[9999] focus:rounded focus:bg-surface-raised focus:px-3 focus:py-1.5 focus:text-body focus:text-content-primary focus:shadow-card-hover focus:outline-none focus:ring-2 focus:ring-interactive-primary"
>
Skip to main content
</a>
<header
className="sticky top-0 z-40 border-b border-edge bg-surface-nav"
style={{ boxShadow: "var(--shadow-header)" }}
Expand All @@ -104,7 +126,7 @@ export default function Shell({ children }: ShellProps) {
{/* Brand: monogram + wordmark */}
<Link
href="/"
title="Dashboard"
title={t("dashboard.title")}
className="group flex shrink-0 items-center gap-2 self-center"
>
<span
Expand All @@ -116,16 +138,16 @@ export default function Shell({ children }: ShellProps) {
</span>
<span className="flex flex-col leading-tight">
<span className="text-[13px] font-semibold tracking-tight text-content-primary">
Lion Studio
{t("brand.name")}
</span>
<span className="hidden text-[9px] font-medium uppercase tracking-[0.12em] text-content-muted sm:inline">
Orchestration
{t("brand.subtitle")}
</span>
</span>
</Link>

{/* Desktop: 4-group primary nav */}
<nav aria-label="Primary" className="hidden items-stretch gap-0.5 md:flex">
<nav aria-label={t("primary.ariaLabel")} className="hidden items-stretch gap-0.5 md:flex">
{NAV_GROUPS.map((group) => (
<NavGroup key={group.label} group={group} pathname={pathname} />
))}
Expand All @@ -139,11 +161,12 @@ export default function Shell({ children }: ShellProps) {
<Suspense fallback={null}>
<ProjectChip />
</Suspense>
<LocaleSwitcher />
<ThemeToggle />
{/* Hamburger: visible below 768px */}
<button
type="button"
aria-label="Open navigation menu"
aria-label={t("mobile.open")}
aria-expanded={mobileOpen}
onClick={() => setMobileOpen((v) => !v)}
className="flex h-8 w-8 items-center justify-center rounded text-content-muted transition-colors hover:bg-interactive-secondary hover:text-content-primary md:hidden"
Expand Down
47 changes: 41 additions & 6 deletions apps/studio/frontend/components/nav/Breadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,54 @@

import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { NAV_GROUPS, isRouteActive } from "./types";

const GROUP_KEY: Record<string, string> = {
Dashboard: "groups.dashboard",
Work: "groups.work",
Library: "groups.library",
Admin: "groups.admin",
};

const ITEM_KEY: Record<string, string> = {
Dashboard: "items.dashboard",
Shows: "items.shows",
Runs: "items.runs",
Projects: "items.projects",
Teams: "items.teams",
Invocations: "items.invocations",
Schedules: "items.schedules",
Playbooks: "items.playbooks",
Agents: "items.agents",
Plugins: "items.plugins",
Skills: "items.skills",
Health: "items.health",
Maintenance: "items.maintenance",
};

export default function Breadcrumb() {
const t = useTranslations("nav");
const pathname = usePathname() ?? "/";

const tGroup = (label: string) => {
const key = GROUP_KEY[label];
return key ? t(key as Parameters<typeof t>[0]) : label;
};

const tItem = (label: string) => {
const key = ITEM_KEY[label];
return key ? t(key as Parameters<typeof t>[0]) : label;
};

// Dashboard root
if (pathname === "/") {
return (
<nav
aria-label="Breadcrumb"
aria-label={t("breadcrumb.ariaLabel")}
className="flex h-6 items-center gap-1 border-b border-edge bg-surface-base px-4 text-meta text-content-muted"
>
<span>Dashboard</span>
<span>{t("items.dashboard")}</span>
</nav>
);
}
Expand All @@ -40,7 +75,7 @@ export default function Breadcrumb() {
const firstSegment = pathname.split("/").filter(Boolean)[0] ?? "";
return (
<nav
aria-label="Breadcrumb"
aria-label={t("breadcrumb.ariaLabel")}
className="flex h-6 items-center gap-1 border-b border-edge bg-surface-base px-4 text-meta text-content-muted"
>
{firstSegment && <span>{decodeURIComponent(firstSegment)}</span>}
Expand All @@ -61,13 +96,13 @@ export default function Breadcrumb() {

return (
<nav
aria-label="Breadcrumb"
aria-label={t("breadcrumb.ariaLabel")}
className="flex h-6 items-center gap-1 border-b border-edge bg-surface-base px-4 text-meta text-content-muted"
>
<span>{groupLabel}</span>
<span>{tGroup(groupLabel)}</span>
<span aria-hidden="true">›</span>
<Link href={itemHref} className="transition-colors duration-150 hover:text-content-secondary">
{itemLabel}
{tItem(itemLabel)}
</Link>
{detailSegment && (
<>
Expand Down
47 changes: 41 additions & 6 deletions apps/studio/frontend/components/nav/NavGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,32 @@

import Link from "next/link";
import { useId, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { type NavGroupDef, isRouteActive } from "./types";

const GROUP_KEY: Record<string, string> = {
Dashboard: "groups.dashboard",
Work: "groups.work",
Library: "groups.library",
Admin: "groups.admin",
};

const ITEM_KEY: Record<string, string> = {
Dashboard: "items.dashboard",
Shows: "items.shows",
Runs: "items.runs",
Projects: "items.projects",
Teams: "items.teams",
Invocations: "items.invocations",
Schedules: "items.schedules",
Playbooks: "items.playbooks",
Agents: "items.agents",
Plugins: "items.plugins",
Skills: "items.skills",
Health: "items.health",
Maintenance: "items.maintenance",
};

interface NavGroupProps {
group: NavGroupDef;
pathname: string;
Expand All @@ -12,7 +36,18 @@ interface NavGroupProps {
}

export default function NavGroup({ group, pathname, mobile = false, onNavigate }: NavGroupProps) {
const t = useTranslations("nav");
const [open, setOpen] = useState(false);

const tGroup = (label: string) => {
const key = GROUP_KEY[label];
return key ? t(key as Parameters<typeof t>[0]) : label;
};

const tItem = (label: string) => {
const key = ITEM_KEY[label];
return key ? t(key as Parameters<typeof t>[0]) : label;
};
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// closeTimerRef must be declared unconditionally at the top (React
// rules-of-hooks) even though only the desktop dropdown branch uses it.
Expand All @@ -36,7 +71,7 @@ export default function NavGroup({ group, pathname, mobile = false, onNavigate }
active ? "font-semibold text-content-primary" : "text-content-secondary",
].join(" ")}
>
{group.label}
{tGroup(group.label)}
</Link>
</div>
);
Expand All @@ -51,7 +86,7 @@ export default function NavGroup({ group, pathname, mobile = false, onNavigate }
active ? "text-content-primary" : "text-content-muted hover:text-content-secondary",
].join(" ")}
>
{group.label}
{tGroup(group.label)}
{active && (
<span className="absolute inset-x-2.5 bottom-0 h-[2px] rounded-t bg-interactive-primary" />
)}
Expand All @@ -75,7 +110,7 @@ export default function NavGroup({ group, pathname, mobile = false, onNavigate }
groupActive ? "font-semibold text-content-primary" : "text-content-secondary"
}
>
{group.label}
{tGroup(group.label)}
</span>
<span
className={["transition-transform duration-150", open ? "rotate-180" : ""].join(" ")}
Expand All @@ -99,7 +134,7 @@ export default function NavGroup({ group, pathname, mobile = false, onNavigate }
: "text-content-secondary hover:text-content-primary",
].join(" ")}
>
{item.label}
{tItem(item.label)}
</Link>
);
})}
Expand Down Expand Up @@ -193,7 +228,7 @@ export default function NavGroup({ group, pathname, mobile = false, onNavigate }
: "text-content-muted hover:text-content-secondary",
].join(" ")}
>
{group.label} ▾
{tGroup(group.label)} ▾
{(groupActive || open) && (
<span className="absolute inset-x-2.5 bottom-0 h-[2px] rounded-t bg-interactive-primary" />
)}
Expand Down Expand Up @@ -222,7 +257,7 @@ export default function NavGroup({ group, pathname, mobile = false, onNavigate }
active ? "font-semibold text-content-primary" : "text-content-secondary",
].join(" ")}
>
{item.label}
{tItem(item.label)}
</Link>
);
})}
Expand Down
Loading
Loading