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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user