feat(spa): port prototype shell — topbar + sidebar 1:1, drop PrimeVue (Wave 2)

Rewrites the manager shell to match docs/design-handoff/shell.jsx exactly:
- AppTopbar.vue: drops PrimeVue Button/Menu, uses ui/Button + ui/Icon, prototype
  .wp-topbar markup, custom dropdown for the user menu with outside-click close.
- AppSidebar.vue: replaces AppMenu/AppMenuItem with a single inline component
  using prototype .wp-sidebar/.wp-nav markup. Sections: Home / Modules / Library
  / Get Started — modules section renamed from "Assets" to match prototype.
  Active state derives from route name with editor sub-routes mapping to their
  parent kind. External links open in a new tab with pi-external-link indicator.
- AppLayout.vue: prototype .wp-app grid (rows) + .wp-body grid (cols) with
  data-collapsed attribute driving the collapsed sidebar width.
- App.vue: trimmed to a single RouterView (ToastHost mounts inside AppLayout).
- Removes AppMenu.vue + AppMenuItem.vue (dead).

Tests: +14 (AppTopbar.test.ts ×8, AppSidebar.test.ts ×6). Total 333 → 347.
Zero PrimeVue references remain in src/manager/layout/.
This commit is contained in:
DumiFlex
2026-04-27 18:37:07 +03:00
parent 03d2b7bde5
commit 30fed89ce3
9 changed files with 546 additions and 285 deletions
-2
View File
@@ -1,9 +1,7 @@
<script setup lang="ts">
import { RouterView } from "vue-router";
import ToastHost from "./components/ui/ToastHost.vue";
</script>
<template>
<RouterView />
<ToastHost />
</template>
+110
View File
@@ -0,0 +1,110 @@
import { mount, flushPromises } from "@vue/test-utils";
import { setActivePinia, createPinia } from "pinia";
import { beforeEach, describe, expect, it } from "vitest";
import { createMemoryHistory, createRouter, type Router } from "vue-router";
import AppSidebar from "../layout/AppSidebar.vue";
import { useUiStore } from "../stores/uiStore";
function makeRouter(start = "/wildcards"): Router {
return createRouter({
history: createMemoryHistory(start),
routes: [
{ path: "/", redirect: "/wildcards" },
{ path: "/dashboard", name: "dashboard", component: { template: "<div/>" } },
{ path: "/wildcards", name: "wildcards", component: { template: "<div/>" } },
{ path: "/wildcards/:id/edit", name: "wildcards-edit", component: { template: "<div/>" } },
{ path: "/pipelines", name: "pipelines", component: { template: "<div/>" } },
{ path: "/fixed-values", name: "fixed-values", component: { template: "<div/>" } },
{ path: "/combines", name: "combines", component: { template: "<div/>" } },
{ path: "/derivations", name: "derivations", component: { template: "<div/>" } },
{ path: "/constraints", name: "constraints", component: { template: "<div/>" } },
{ path: "/categories", name: "categories", component: { template: "<div/>" } },
{ path: "/import-export", name: "import-export", component: { template: "<div/>" } },
{ path: "/test", name: "test", component: { template: "<div/>" } },
{ path: "/community", name: "community", component: { template: "<div/>" } },
],
});
}
async function mountSidebar(start = "/wildcards") {
const router = makeRouter(start);
await router.push(start);
await router.isReady();
const wrap = mount(AppSidebar, {
global: { plugins: [router] },
});
await flushPromises();
return { wrap, router };
}
beforeEach(() => {
setActivePinia(createPinia());
});
describe("AppSidebar.vue", () => {
it("renders all four section headers", async () => {
const { wrap } = await mountSidebar();
const sections = wrap.findAll(".wp-sidebar__section").map((n) => n.text());
expect(sections).toEqual(["Home", "Modules", "Library", "Get Started"]);
});
it("renders all six modules items", async () => {
const { wrap } = await mountSidebar();
const labels = wrap.findAll(".wp-nav__label").map((n) => n.text());
// Modules
expect(labels).toContain("Pipelines");
expect(labels).toContain("Wildcards");
expect(labels).toContain("Fixed Values");
expect(labels).toContain("Combines");
expect(labels).toContain("Derivations");
expect(labels).toContain("Constraints");
// Library
expect(labels).toContain("Categories");
expect(labels).toContain("Import / Export");
expect(labels).toContain("Test Runner");
// Get Started
expect(labels).toContain("Community");
expect(labels).toContain("Documentation");
expect(labels).toContain("View Source");
// Home
expect(labels).toContain("Dashboard");
});
it("marks the matching item active when on its route", async () => {
const { wrap } = await mountSidebar("/wildcards");
const active = wrap.find('[data-nav-id="wildcards"]');
expect(active.exists()).toBe(true);
expect(active.attributes("data-active")).toBe("true");
// Sibling not active
const sib = wrap.find('[data-nav-id="pipelines"]');
expect(sib.attributes("data-active")).toBeUndefined();
});
it("treats editor sub-routes as the parent kind active", async () => {
const { wrap } = await mountSidebar("/wildcards/abc/edit");
const active = wrap.find('[data-nav-id="wildcards"]');
expect(active.attributes("data-active")).toBe("true");
});
it("renders external links as anchors with target=_blank", async () => {
const { wrap } = await mountSidebar();
const docs = wrap
.findAll("a.wp-nav")
.find((a) => a.text().includes("Documentation"));
if (!docs) throw new Error("Documentation link not found");
expect(docs.attributes("target")).toBe("_blank");
expect(docs.attributes("rel")).toContain("noopener");
expect(docs.attributes("href")).toMatch(/^https?:\/\//);
});
it("hides section labels when sidebar is collapsed", async () => {
const { wrap } = await mountSidebar();
const ui = useUiStore();
ui.sidebarCollapsed = true;
await flushPromises();
expect(wrap.findAll(".wp-sidebar__section")).toHaveLength(0);
// Labels also hidden
expect(wrap.findAll(".wp-nav__label")).toHaveLength(0);
});
});
+125
View File
@@ -0,0 +1,125 @@
import { mount, flushPromises } from "@vue/test-utils";
import { setActivePinia, createPinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createMemoryHistory, createRouter, type Router } from "vue-router";
vi.mock("../community/mockApi", () => ({
getCurrentUser: vi.fn(() => null),
getInstalled: vi.fn(() => []),
getStarred: vi.fn(() => []),
getMyUploads: vi.fn(() => []),
getInstallHistory: vi.fn(() => []),
getApiStatus: vi.fn(async () => "online"),
signInWithGitHub: vi.fn(),
signOut: vi.fn(),
getFeatured: vi.fn(async () => []),
searchModules: vi.fn(async () => []),
getModule: vi.fn(),
installModule: vi.fn(),
uninstallModule: vi.fn(),
starModule: vi.fn(),
uploadModule: vi.fn(),
}));
import AppTopbar from "../layout/AppTopbar.vue";
import { useUiStore } from "../stores/uiStore";
import { useCommunityStore } from "../stores/communityStore";
function makeRouter(start = "/wildcards"): Router {
const r = createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", component: { template: "<div/>" } },
{ path: "/wildcards", name: "wildcards", component: { template: "<div/>" } },
{ path: "/settings", name: "settings", component: { template: "<div/>" } },
{ path: "/community", name: "community", component: { template: "<div/>" } },
{ path: "/community/discover", name: "community-discover", component: { template: "<div/>" } },
{ path: "/community/profile", name: "community-profile", component: { template: "<div/>" } },
],
});
r.push(start);
return r;
}
async function mountTopbar(start = "/wildcards") {
const router = makeRouter(start);
await router.isReady();
const wrap = mount(AppTopbar, {
global: { plugins: [router] },
});
await flushPromises();
return { wrap, router };
}
beforeEach(() => {
setActivePinia(createPinia());
});
describe("AppTopbar.vue", () => {
it("renders the brand link with logo + version", async () => {
const { wrap } = await mountTopbar();
const brand = wrap.find('[data-test="topbar-brand"]');
expect(brand.exists()).toBe(true);
expect(brand.text()).toContain("Wildcard Pipeline");
expect(brand.text()).toMatch(/v\d/);
expect(brand.find("img").exists()).toBe(true);
});
it("calls ui.toggleSidebar when the bars button is clicked", async () => {
const { wrap } = await mountTopbar();
const ui = useUiStore();
expect(ui.sidebarCollapsed).toBe(false);
await wrap.find('[data-test="topbar-toggle"]').trigger("click");
expect(ui.sidebarCollapsed).toBe(true);
});
it("calls ui.cycleTheme when the theme button is clicked", async () => {
const { wrap } = await mountTopbar();
const ui = useUiStore();
const before = ui.themeMode;
await wrap.find('[data-test="topbar-theme"]').trigger("click");
expect(ui.themeMode).not.toBe(before);
});
it("hides the community status pill on non-community routes", async () => {
const { wrap } = await mountTopbar("/wildcards");
expect(wrap.find('[data-test="topbar-status"]').exists()).toBe(false);
expect(wrap.find('[data-test="topbar-signin"]').exists()).toBe(false);
expect(wrap.find('[data-test="topbar-user"]').exists()).toBe(false);
});
it("shows the status pill on community routes", async () => {
const { wrap } = await mountTopbar("/community/discover");
expect(wrap.find('[data-test="topbar-status"]').exists()).toBe(true);
});
it("shows the sign-in button on community routes when no user", async () => {
const { wrap } = await mountTopbar("/community/discover");
expect(wrap.find('[data-test="topbar-signin"]').exists()).toBe(true);
});
it("shows the user pill + dropdown when signed in on community routes", async () => {
const { wrap } = await mountTopbar("/community/discover");
const community = useCommunityStore();
community.currentUser = {
login: "octocat",
avatar_url: "https://example.com/a.png",
verified: true,
name: "Octo Cat",
};
await flushPromises();
const userBtn = wrap.find('[data-test="topbar-user"]');
expect(userBtn.exists()).toBe(true);
// Dropdown closed initially
expect(wrap.find('[data-test="topbar-user-menu"]').exists()).toBe(false);
await userBtn.trigger("click");
expect(wrap.find('[data-test="topbar-user-menu"]').exists()).toBe(true);
});
it("navigates to /settings when settings cog clicked", async () => {
const { wrap, router } = await mountTopbar();
await wrap.find('[data-test="topbar-settings"]').trigger("click");
await flushPromises();
expect(router.currentRoute.value.path).toBe("/settings");
});
});
+6 -4
View File
@@ -2,19 +2,21 @@
import { RouterView } from "vue-router";
import AppTopbar from "./AppTopbar.vue";
import AppSidebar from "./AppSidebar.vue";
import ToastHost from "../components/ui/ToastHost.vue";
import { useUiStore } from "../stores/uiStore";
const ui = useUiStore();
</script>
<template>
<div class="flex flex-col min-h-screen">
<div class="wp-app">
<AppTopbar />
<div class="flex flex-1 min-h-0">
<AppSidebar v-show="!ui.sidebarCollapsed" />
<main class="flex-1 overflow-auto bg-wp-bg">
<div class="wp-body" :data-collapsed="ui.sidebarCollapsed || undefined">
<AppSidebar />
<main class="wp-content">
<RouterView />
</main>
</div>
<ToastHost />
</div>
</template>
-77
View File
@@ -1,77 +0,0 @@
<script setup lang="ts">
import AppMenuItem from "./AppMenuItem.vue";
interface Item {
label: string;
icon: string;
to?: string;
url?: string;
soon?: boolean;
}
interface Section { label: string; items: Item[]; }
const sections: Section[] = [
{
label: "Home",
items: [{ label: "Dashboard", icon: "pi pi-home", to: "/dashboard" }],
},
{
label: "Assets",
items: [
{ label: "Pipelines", icon: "pi pi-list", to: "/pipelines" },
{ label: "Wildcards", icon: "pi pi-th-large", to: "/wildcards" },
{ label: "Fixed Values", icon: "pi pi-tag", to: "/fixed-values" },
{ label: "Combines", icon: "pi pi-share-alt", to: "/combines" },
{ label: "Derivations", icon: "pi pi-code", to: "/derivations" },
{ label: "Constraints", icon: "pi pi-sitemap", to: "/constraints" },
],
},
{
label: "Library",
items: [
{ label: "Categories", icon: "pi pi-bookmark", to: "/categories" },
{ label: "Import / Export", icon: "pi pi-arrow-right-arrow-left", to: "/import-export" },
{ label: "Test Runner", icon: "pi pi-bolt", to: "/test" },
],
},
{
label: "Get Started",
items: [
{ label: "Community", icon: "pi pi-globe", to: "/community" },
{
label: "Documentation", icon: "pi pi-book",
url: "https://github.com/DumiFlex/ComfyUI-WildcardPipeline/wiki",
},
{
label: "View Source", icon: "pi pi-github",
url: "https://github.com/DumiFlex/ComfyUI-WildcardPipeline",
},
],
},
];
</script>
<template>
<nav class="app-menu">
<div v-for="section in sections" :key="section.label" class="app-menu__section">
<div class="app-menu__section-label">{{ section.label }}</div>
<ul class="app-menu__list">
<AppMenuItem v-for="item in section.items" :key="item.label" :item="item" />
</ul>
</div>
</nav>
</template>
<style scoped>
.app-menu { padding: 12px 0; }
.app-menu__section { margin-bottom: 12px; }
.app-menu__section-label {
padding: 4px 16px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--wp-text3);
font-weight: 600;
}
.app-menu__list { list-style: none; padding: 0; margin: 0; }
</style>
-73
View File
@@ -1,73 +0,0 @@
<script setup lang="ts">
import { RouterLink } from "vue-router";
defineProps<{
item: {
label: string;
icon: string;
to?: string;
url?: string;
soon?: boolean;
};
}>();
</script>
<template>
<li>
<span v-if="item.soon" class="sidebar-item sidebar-item--disabled">
<i :class="['pi', 'pi-fw', item.icon]" aria-hidden="true" />
<span class="sidebar-item__label">{{ item.label }}</span>
<span class="sidebar-item__badge">soon</span>
</span>
<a
v-else-if="item.url"
:href="item.url"
target="_blank" rel="noopener noreferrer"
class="sidebar-item"
>
<i :class="['pi', 'pi-fw', item.icon]" aria-hidden="true" />
<span class="sidebar-item__label">{{ item.label }}</span>
<i class="pi pi-external-link sidebar-item__external" aria-hidden="true" />
</a>
<RouterLink
v-else-if="item.to"
:to="item.to"
exact-active-class="sidebar-item--active"
class="sidebar-item"
>
<i :class="['pi', 'pi-fw', item.icon]" aria-hidden="true" />
<span class="sidebar-item__label">{{ item.label }}</span>
</RouterLink>
</li>
</template>
<style scoped>
.sidebar-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
color: var(--wp-text);
text-decoration: none;
border-left: 2px solid transparent;
font-size: 13px;
}
.sidebar-item:hover { background: var(--wp-bg2); }
.sidebar-item--active {
background: var(--wp-accent-glow);
border-left-color: var(--wp-accent);
color: #fff;
}
.sidebar-item--active :deep(.pi) { color: var(--wp-accent); }
.sidebar-item__label { flex: 1; }
.sidebar-item__badge {
font-size: 9.5px;
padding: 1px 5px;
background: var(--wp-bg3);
color: var(--wp-text3);
border-radius: 3px;
}
.sidebar-item__external { font-size: 10px; color: var(--wp-text3); }
.sidebar-item--disabled { opacity: 0.5; cursor: not-allowed; pointer-events: none; }
.sidebar-item :deep(.pi) { color: var(--wp-text2); }
</style>
+132 -4
View File
@@ -1,9 +1,137 @@
<script setup lang="ts">
import AppMenu from "./AppMenu.vue";
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import Icon from "../components/ui/Icon.vue";
import { useUiStore } from "../stores/uiStore";
interface NavItem {
id: string;
label: string;
icon: string;
to?: string;
url?: string;
}
interface NavSection {
label: string;
items: NavItem[];
}
const ui = useUiStore();
const router = useRouter();
const route = useRoute();
const SECTIONS: NavSection[] = [
{
label: "Home",
items: [
{ id: "dashboard", label: "Dashboard", icon: "pi-home", to: "/dashboard" },
],
},
{
label: "Modules",
items: [
{ id: "pipelines", label: "Pipelines", icon: "pi-list", to: "/pipelines" },
{ id: "wildcards", label: "Wildcards", icon: "pi-th-large", to: "/wildcards" },
{ id: "fixed", label: "Fixed Values", icon: "pi-tag", to: "/fixed-values" },
{ id: "combines", label: "Combines", icon: "pi-share-alt", to: "/combines" },
{ id: "derivations", label: "Derivations", icon: "pi-code", to: "/derivations" },
{ id: "constraints", label: "Constraints", icon: "pi-sitemap", to: "/constraints" },
],
},
{
label: "Library",
items: [
{ id: "categories", label: "Categories", icon: "pi-bookmark", to: "/categories" },
{ id: "io", label: "Import / Export", icon: "pi-arrow-right-arrow-left", to: "/import-export" },
{ id: "test", label: "Test Runner", icon: "pi-bolt", to: "/test" },
],
},
{
label: "Get Started",
items: [
{ id: "community", label: "Community", icon: "pi-globe", to: "/community" },
{ id: "_docs", label: "Documentation", icon: "pi-book", url: "https://github.com/DumiFlex/ComfyUI-WildcardPipeline/wiki" },
{ id: "_source", label: "View Source", icon: "pi-github", url: "https://github.com/DumiFlex/ComfyUI-WildcardPipeline" },
],
},
];
/** Sidebar item id derived from current route name. Matches prototype mapping. */
const activeId = computed<string>(() => {
const name = typeof route.name === "string" ? route.name : "";
const path = route.path || "";
// Editor route names start with the singular kind, e.g. `wildcards-edit`.
// We treat any route name that starts with a kind prefix as that kind active.
const KIND_BY_PREFIX: Array<[string, string]> = [
["wildcards", "wildcards"],
["pipelines", "pipelines"],
["fixed-values", "fixed"],
["combines", "combines"],
["derivations", "derivations"],
["constraints", "constraints"],
["categories", "categories"],
["import-export", "io"],
["test", "test"],
["dashboard", "dashboard"],
];
for (const [prefix, id] of KIND_BY_PREFIX) {
if (name === prefix || name.startsWith(`${prefix}-`)) return id;
}
if (path.startsWith("/community")) return "community";
return "";
});
function onItemClick(item: NavItem) {
if (item.url) return; // anchor handles external nav
if (item.to) router.push(item.to);
}
</script>
<template>
<aside class="w-56 bg-wp-bg border-r border-wp-border overflow-y-auto py-3">
<AppMenu />
</aside>
<nav class="wp-sidebar" :data-collapsed="ui.sidebarCollapsed || undefined">
<template v-for="section in SECTIONS" :key="section.label">
<div v-if="!ui.sidebarCollapsed" class="wp-sidebar__section">
{{ section.label }}
</div>
<div v-else class="wp-sidebar__spacer" aria-hidden="true" />
<template v-for="item in section.items" :key="item.id">
<a
v-if="item.url"
:href="item.url"
target="_blank"
rel="noopener noreferrer"
class="wp-nav"
:title="ui.sidebarCollapsed ? item.label : undefined"
:style="ui.sidebarCollapsed ? { justifyContent: 'center' } : undefined"
>
<span class="wp-nav__icon"><Icon :name="item.icon" /></span>
<span v-if="!ui.sidebarCollapsed" class="wp-nav__label">{{ item.label }}</span>
<Icon v-if="!ui.sidebarCollapsed" name="pi-external-link" class="wp-nav__ext" :size="10" />
</a>
<button
v-else
type="button"
class="wp-nav"
:data-active="activeId === item.id || undefined"
:data-nav-id="item.id"
:title="ui.sidebarCollapsed ? item.label : undefined"
:style="ui.sidebarCollapsed ? { justifyContent: 'center' } : undefined"
@click="onItemClick(item)"
>
<span class="wp-nav__icon"><Icon :name="item.icon" /></span>
<span v-if="!ui.sidebarCollapsed" class="wp-nav__label">{{ item.label }}</span>
</button>
</template>
</template>
</nav>
</template>
<style scoped>
/* .wp-sidebar, .wp-sidebar__section, .wp-nav, .wp-nav__icon, .wp-nav__label,
.wp-nav__ext are all defined globally in tokens.css. */
.wp-sidebar__spacer { height: 8px; }
</style>
+171 -123
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import Button from "primevue/button";
import Menu from "primevue/menu";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import Button from "../components/ui/Button.vue";
import Icon from "../components/ui/Icon.vue";
import { useUiStore } from "../stores/uiStore";
import { useCommunityStore } from "../stores/communityStore";
@@ -10,41 +10,47 @@ const ui = useUiStore();
const router = useRouter();
const route = useRoute();
const community = useCommunityStore();
const version = "1.4.0-dev";
const logoSrc = `${import.meta.env.BASE_URL}images/favicon.svg`;
/** Icon for the theme button reflects the *next* state in the cycle. */
/** Theme button icon — mirrors prototype (current state, not next). */
const themeIcon = computed(() => {
switch (ui.themeMode) {
case "dark": return "pi pi-sun"; // next: light
case "light": return "pi pi-desktop"; // next: auto
default: return "pi pi-moon"; // next: dark
case "light": return "pi-sun";
case "dark": return "pi-moon";
default: return "pi-desktop";
}
});
const themeLabel = computed(() =>
ui.themeMode === "dark" ? "Switch to light theme" :
ui.themeMode === "light" ? "Switch to auto (system) theme" :
"Switch to dark theme",
);
const themeLabel = computed(() => `Theme: ${ui.themeMode}`);
/** Status + user pill render only when the user is on a community route. */
/** Status pill + user pill render only on community routes. */
const onCommunity = computed(() => (route.path || "").startsWith("/community"));
const statusColor = computed(() => {
if (community.apiStatus === "online") return "var(--wp-success)";
if (community.apiStatus === "degraded") return "var(--wp-warn)";
return "var(--wp-danger)";
if (community.apiStatus === "online") return "var(--wp-success, #10b981)";
if (community.apiStatus === "degraded") return "var(--wp-warn, #f59e0b)";
return "var(--wp-danger, #ef4444)";
});
const userMenu = ref<InstanceType<typeof Menu> | null>(null);
const userMenuItems = computed(() => [
{ label: "My profile", icon: "pi pi-user", command: () => router.push("/community/profile") },
{ label: "Sign out", icon: "pi pi-sign-out", command: () => community.signOut() },
]);
function toggleUserMenu(event: Event) {
userMenu.value?.toggle(event);
}
/* User dropdown — outside-click closes it. */
const userMenuOpen = ref(false);
const userMenuRef = ref<HTMLDivElement | null>(null);
function toggleUserMenu() {
userMenuOpen.value = !userMenuOpen.value;
}
function closeUserMenu() {
userMenuOpen.value = false;
}
function onProfile() {
closeUserMenu();
router.push("/community/profile");
}
async function onSignOut() {
closeUserMenu();
await community.signOut();
}
async function onSignIn() {
try {
await community.signIn();
@@ -53,117 +59,133 @@ async function onSignIn() {
}
}
function onDocumentClick(event: MouseEvent) {
if (!userMenuOpen.value) return;
const el = userMenuRef.value;
if (el && event.target instanceof Node && !el.contains(event.target)) {
closeUserMenu();
}
}
onMounted(() => {
// Lazy probe — ensures the dot reflects current state on community routes.
if (onCommunity.value) community.refreshApiStatus();
document.addEventListener("click", onDocumentClick);
});
onBeforeUnmount(() => {
document.removeEventListener("click", onDocumentClick);
});
</script>
<template>
<header class="topbar">
<div class="topbar-left">
<Button
icon="pi pi-bars" text rounded severity="secondary"
aria-label="Toggle menu"
@click="ui.toggleSidebar"
/>
<RouterLink to="/" class="brand">
<img :src="logoSrc" alt="" class="brand-logo" />
<span class="brand-name">Wildcard Pipeline</span>
<span class="brand-version">v{{ version }}</span>
</RouterLink>
</div>
<div class="topbar-right">
<span
v-if="onCommunity"
class="topbar-status"
:title="`Community API: ${community.apiStatus}`"
<header class="wp-topbar">
<button
type="button"
class="wp-topbar__icon-btn"
aria-label="Toggle sidebar"
data-test="topbar-toggle"
@click="ui.toggleSidebar"
>
<Icon name="pi-bars" />
</button>
<RouterLink to="/" class="wp-topbar__brand" data-test="topbar-brand">
<img :src="logoSrc" alt="" />
<span>Wildcard Pipeline</span>
<span class="wp-topbar__version">v{{ version }}</span>
</RouterLink>
<div class="wp-topbar__spacer" />
<span
v-if="onCommunity"
class="wp-topbar-status"
:title="`Community API: ${community.apiStatus}`"
data-test="topbar-status"
>
<span class="wp-topbar-status__dot" :style="{ background: statusColor }" />
<span class="wp-topbar-status__label">{{ community.apiStatus }}</span>
</span>
<button
type="button"
class="wp-topbar__icon-btn"
:aria-label="themeLabel"
:title="themeLabel"
data-test="topbar-theme"
@click="ui.cycleTheme"
>
<Icon :name="themeIcon" />
</button>
<template v-if="onCommunity">
<div
v-if="community.currentUser"
ref="userMenuRef"
class="wp-topbar-user-wrap"
>
<span class="topbar-status__dot" :style="{ background: statusColor }" />
<span class="topbar-status__label">{{ community.apiStatus }}</span>
</span>
<Button
:icon="themeIcon" text rounded severity="secondary"
:aria-label="themeLabel"
:title="themeLabel"
@click="ui.cycleTheme"
/>
<template v-if="onCommunity">
<button
v-if="community.currentUser"
type="button"
class="topbar-user"
aria-haspopup="true"
aria-controls="topbar-user-menu"
class="wp-topbar-user"
aria-haspopup="menu"
:aria-expanded="userMenuOpen"
:title="`Signed in as @${community.currentUser.login}`"
@click="toggleUserMenu"
data-test="topbar-user"
@click.stop="toggleUserMenu"
>
<span
class="topbar-user__avatar"
class="wp-topbar-user__avatar"
:style="{ backgroundImage: `url(${community.currentUser.avatar_url})` }"
aria-hidden="true"
/>
<span class="topbar-user__login">@{{ community.currentUser.login }}</span>
<i class="pi pi-chevron-down topbar-user__caret" aria-hidden="true" />
<span class="wp-topbar-user__login">@{{ community.currentUser.login }}</span>
<Icon name="pi-chevron-down" :size="10" />
</button>
<Menu
v-if="community.currentUser"
id="topbar-user-menu"
ref="userMenu"
:model="userMenuItems"
:popup="true"
/>
<Button
v-else
label="Sign in with GitHub"
icon="pi pi-github"
severity="secondary"
outlined
@click="onSignIn"
/>
</template>
<div
v-if="userMenuOpen"
class="wp-topbar-user__menu"
role="menu"
data-test="topbar-user-menu"
>
<button type="button" class="wp-topbar-user__item" role="menuitem" @click="onProfile">
<Icon name="pi-user" />
<span>My profile</span>
</button>
<button type="button" class="wp-topbar-user__item" role="menuitem" @click="onSignOut">
<Icon name="pi-sign-out" />
<span>Sign out</span>
</button>
</div>
</div>
<Button
icon="pi pi-cog" text rounded severity="secondary"
aria-label="Settings"
@click="router.push('/settings')"
/>
</div>
v-else
variant="outline"
icon="pi-github"
data-test="topbar-signin"
@click="onSignIn"
>
Sign in with GitHub
</Button>
</template>
<button
type="button"
class="wp-topbar__icon-btn"
aria-label="Settings"
title="Settings"
data-test="topbar-settings"
@click="router.push('/settings')"
>
<Icon name="pi-cog" />
</button>
</header>
</template>
<style scoped>
.topbar {
height: var(--wp-topbar-h, 56px);
background: var(--wp-bg);
border-bottom: 1px solid var(--wp-border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
position: sticky;
top: 0;
z-index: 10;
}
.topbar-left, .topbar-right { display: flex; align-items: center; gap: 8px; }
.brand {
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
color: var(--wp-text);
font-weight: 600;
}
.brand:hover { color: var(--wp-text); filter: brightness(1.1); }
.brand-logo { width: 24px; height: 24px; }
.brand-name { letter-spacing: 0.2px; }
.brand-version {
font-size: 11px;
color: var(--wp-text-dim, var(--wp-text3));
font-weight: 400;
}
/* Layout for things not already covered by tokens.css.
.wp-topbar, .wp-topbar__brand, .wp-topbar__version, .wp-topbar__spacer,
.wp-topbar__icon-btn are all defined globally in tokens.css. */
/* Community-only chrome */
.topbar-status {
.wp-topbar-status {
display: inline-flex;
align-items: center;
gap: 6px;
@@ -173,14 +195,16 @@ onMounted(() => {
font-size: 11px;
color: var(--wp-text-muted);
}
.topbar-status__dot {
.wp-topbar-status__dot {
width: 8px;
height: 8px;
border-radius: 999px;
display: inline-block;
}
.topbar-status__label { text-transform: capitalize; }
.topbar-user {
.wp-topbar-status__label { text-transform: capitalize; }
.wp-topbar-user-wrap { position: relative; }
.wp-topbar-user {
display: inline-flex;
align-items: center;
gap: 6px;
@@ -192,8 +216,8 @@ onMounted(() => {
font: inherit;
color: var(--wp-text);
}
.topbar-user:hover { background: var(--wp-bg-3); }
.topbar-user__avatar {
.wp-topbar-user:hover { background: var(--wp-bg-3); }
.wp-topbar-user__avatar {
width: 22px; height: 22px;
border-radius: 999px;
background-size: cover;
@@ -201,11 +225,35 @@ onMounted(() => {
background-color: var(--wp-bg-3);
display: inline-block;
}
.topbar-user__login {
font-size: 12px;
.wp-topbar-user__login { font-size: 12px; }
.wp-topbar-user__menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 180px;
background: var(--wp-bg-1);
border: 1px solid var(--wp-border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
padding: 4px;
z-index: 50;
display: flex;
flex-direction: column;
gap: 2px;
}
.topbar-user__caret {
font-size: 10px;
color: var(--wp-text-muted);
.wp-topbar-user__item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
background: transparent;
border: 0;
border-radius: 6px;
color: var(--wp-text);
cursor: pointer;
font: inherit;
text-align: left;
}
.wp-topbar-user__item:hover { background: var(--wp-bg-3); }
</style>
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="icon" type="image/svg+xml" href="/wp/images/favicon.svg" />
<link rel="apple-touch-icon" href="/wp/images/apple-touch-icon.png" />
<link rel="manifest" href="/wp/images/site.webmanifest" />
<script type="module" crossorigin src="/wp/assets/manager-CcM7A8Hk.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-DUGKIqpP.css">
<script type="module" crossorigin src="/wp/assets/manager-CDUtSLHq.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-D3FbmiU9.css">
</head>
<body>
<div id="app"></div>