feat(spa): port prototype dashboard + utilities views (Wave 5)
Rewrite Dashboard, Categories, ImportExport, TestRunner, and Settings 1:1 with the React prototype, dropping all PrimeVue from these screens in favour of the local ui/* primitives. - Dashboard: hero + 6-stat grid + recent/favorites tabbed list. - Categories: sticky-header table with inline name edit, color picker popover, sort_order field, and module count badge. - ImportExport: tabbed export/import with collapsible group tree, presets row, conflict badges, and bundle summary using ui/Card + ui/Checkbox + ui/Select. - TestRunner: kind segmented control, ui/Select module picker, raw histogram bars and constraint matrix table; deterministic microtask yield in run() instead of setTimeout. - Settings: real About / Theme / Storage cards wired to uiStore. Tests + new Settings.test.ts; updated selectors for the dropped PrimeVue primitives. All 338 vitest tests pass; 0 PrimeVue refs left in these views; net bundle (gzip) -24 kB across the five screens.
This commit is contained in:
@@ -90,7 +90,23 @@ describe("Dashboard.vue", () => {
|
||||
apiMod.list.mockResolvedValue({ items: [], total: 0 });
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
// Default tab is Recent — empty-state copy.
|
||||
expect(wrap.text()).toContain("No edits yet.");
|
||||
// Switch to Favorites tab and confirm its empty-state copy.
|
||||
await wrap.get('[data-test="dashboard-tab-favorites"]').trigger("click");
|
||||
expect(wrap.text()).toContain("No favorites yet.");
|
||||
});
|
||||
|
||||
it("clicking a stat card navigates to that kind list", async () => {
|
||||
apiMod.list.mockResolvedValue({ items: [], total: 0 });
|
||||
const router = makeRouter();
|
||||
const push = vi.spyOn(router, "push");
|
||||
const wrap = mount(Dashboard, {
|
||||
global: { plugins: [router, PrimeVue, ToastService, ConfirmationService] },
|
||||
});
|
||||
await flushPromises();
|
||||
const stats = wrap.findAll(".dashboard__stat");
|
||||
await stats[0]!.trigger("click"); // wildcards
|
||||
expect(push).toHaveBeenCalledWith("/wildcards");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -218,10 +218,9 @@ describe("ImportExport.vue — Import tab", () => {
|
||||
await wrap.find('[data-test="io-export-select-none"]').trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
// Find the group's checkbox (PrimeVue Checkbox uses an inner <input>).
|
||||
// ui/Checkbox is a custom <button role="checkbox"> — click it to toggle on.
|
||||
const groupCheckEl = wrap.get('[data-test="io-export-group-check-wildcard"]');
|
||||
const input = groupCheckEl.find("input");
|
||||
await input.setValue(true);
|
||||
await groupCheckEl.trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
// Both rows should now be selected → download should be enabled.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import Settings from "../views/Settings.vue";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("Settings.vue", () => {
|
||||
it("renders About / Theme / Storage cards", () => {
|
||||
const wrap = mount(Settings);
|
||||
const text = wrap.text();
|
||||
expect(text).toContain("Settings");
|
||||
expect(text).toContain("About");
|
||||
expect(text).toContain("Theme");
|
||||
expect(text).toContain("Storage");
|
||||
});
|
||||
|
||||
it("clicking a theme chip swaps uiStore.themeMode", async () => {
|
||||
const wrap = mount(Settings);
|
||||
const store = useUiStore();
|
||||
expect(store.themeMode).toBeDefined();
|
||||
await wrap.get('[data-test="settings-theme-light"]').trigger("click");
|
||||
expect(store.themeMode).toBe("light");
|
||||
await wrap.get('[data-test="settings-theme-auto"]').trigger("click");
|
||||
expect(store.themeMode).toBe("auto");
|
||||
await wrap.get('[data-test="settings-theme-dark"]').trigger("click");
|
||||
expect(store.themeMode).toBe("dark");
|
||||
});
|
||||
|
||||
it("active theme chip carries data-active='true'", async () => {
|
||||
const wrap = mount(Settings);
|
||||
const store = useUiStore();
|
||||
store.setThemeMode("light");
|
||||
await wrap.vm.$nextTick();
|
||||
const lightChip = wrap.get('[data-test="settings-theme-light"]');
|
||||
const darkChip = wrap.get('[data-test="settings-theme-dark"]');
|
||||
expect(lightChip.attributes("data-active")).toBe("true");
|
||||
expect(darkChip.attributes("data-active")).toBe("false");
|
||||
});
|
||||
});
|
||||
@@ -133,22 +133,24 @@ describe("TestRunner.vue", () => {
|
||||
it("kind selector switches the module dropdown to that kind only", async () => {
|
||||
const wrap = mountRunner();
|
||||
await flushPromises();
|
||||
// Locate the SelectButton DOM and click the Combine option.
|
||||
// Click the Combine button in the segmented kind picker.
|
||||
const kindSelector = wrap.find('[data-test="kind-selector"]');
|
||||
const combineOpt = kindSelector
|
||||
.findAll("div, span, button")
|
||||
.find((el) => el.text().trim() === "Combine");
|
||||
.findAll("button")
|
||||
.find((el) => el.text().trim().includes("Combine"));
|
||||
expect(combineOpt).toBeTruthy();
|
||||
await combineOpt!.trigger("click");
|
||||
await flushPromises();
|
||||
const select = wrap.find('[data-test="module-select"]');
|
||||
await select.trigger("click");
|
||||
// ui/Select renders the options inline once open. Click the trigger button.
|
||||
const trigger = wrap
|
||||
.find('[data-test="module-select"]')
|
||||
.find('[data-test="select-trigger"]');
|
||||
await trigger.trigger("click");
|
||||
await flushPromises();
|
||||
// The PrimeVue overlay panel renders the option list. Combine modules
|
||||
// should appear; non-combine ones should not.
|
||||
const overlayText = document.body.textContent ?? "";
|
||||
expect(overlayText).toContain("Subject Phrase");
|
||||
expect(overlayText).not.toContain("Hair Color");
|
||||
// Combine modules should appear in the menu; non-combine ones should not.
|
||||
const html = wrap.html();
|
||||
expect(html).toContain("Subject Phrase");
|
||||
expect(html).not.toContain("Hair Color");
|
||||
});
|
||||
|
||||
it("runs wildcard kind and renders histogram panel", async () => {
|
||||
@@ -158,6 +160,7 @@ describe("TestRunner.vue", () => {
|
||||
expect(runBtn).toBeTruthy();
|
||||
await runBtn!.trigger("click");
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
expect(wrap.find('[data-test="result-wildcard"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -173,6 +176,7 @@ describe("TestRunner.vue", () => {
|
||||
const runBtn = wrap.findAll("button").find((b) => b.text().includes("Run"));
|
||||
await runBtn!.trigger("click");
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
expect(wrap.find('[data-test="result-fixed"]').exists()).toBe(true);
|
||||
expect(wrap.text()).toContain("$name");
|
||||
expect(wrap.text()).toContain("Mira");
|
||||
@@ -183,13 +187,14 @@ describe("TestRunner.vue", () => {
|
||||
await flushPromises();
|
||||
const kindSelector = wrap.find('[data-test="kind-selector"]');
|
||||
const plOpt = kindSelector
|
||||
.findAll("div, span, button")
|
||||
.findAll("button")
|
||||
.find((el) => el.text().trim() === "Pipeline");
|
||||
await plOpt!.trigger("click");
|
||||
await flushPromises();
|
||||
const runBtn = wrap.findAll("button").find((b) => b.text().includes("Run"));
|
||||
await runBtn!.trigger("click");
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
expect(wrap.find('[data-test="result-pipeline"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -205,6 +210,7 @@ describe("TestRunner.vue", () => {
|
||||
const runBtn = wrap.findAll("button").find((b) => b.text().includes("Run"));
|
||||
await runBtn!.trigger("click");
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
expect(wrap.find('[data-test="result-derivation"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -220,6 +226,7 @@ describe("TestRunner.vue", () => {
|
||||
const runBtn = wrap.findAll("button").find((b) => b.text().includes("Run"));
|
||||
await runBtn!.trigger("click");
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
expect(wrap.find('[data-test="result-constraint"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+128
-262
@@ -1,17 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref } from "vue";
|
||||
import Card from "primevue/card";
|
||||
import Button from "primevue/button";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Tag from "primevue/tag";
|
||||
import Dialog from "primevue/dialog";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
import Card from "../components/ui/Card.vue";
|
||||
import Field from "../components/ui/Field.vue";
|
||||
import Icon from "../components/ui/Icon.vue";
|
||||
import Input from "../components/ui/Input.vue";
|
||||
import ColorPicker from "../components/ColorPicker.vue";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { ApiError } from "../api/client";
|
||||
import { useToast } from "../composables/useToast";
|
||||
import type { CategoryRow } from "../api/types";
|
||||
import ColorPicker from "../components/ColorPicker.vue";
|
||||
import RelativeDate from "../components/RelativeDate.vue";
|
||||
|
||||
const store = useCategoryStore();
|
||||
const moduleStore = useModuleStore();
|
||||
@@ -24,12 +23,6 @@ const newColor = ref("#a78bfa");
|
||||
const editingId = ref<string | null>(null);
|
||||
const editingName = ref("");
|
||||
|
||||
// Edit-modal state for the pencil action.
|
||||
const modalOpen = ref(false);
|
||||
const modalRow = ref<CategoryRow | null>(null);
|
||||
const modalName = ref("");
|
||||
const modalColor = ref("#a78bfa");
|
||||
|
||||
// Module counts keyed by category id, derived from the loaded module list.
|
||||
const countsByCategory = computed(() => {
|
||||
const map = new Map<string, number>();
|
||||
@@ -49,15 +42,9 @@ function moduleCount(row: CategoryRow): number {
|
||||
return countsByCategory.value.get(row.id) ?? 0;
|
||||
}
|
||||
|
||||
// CategoryRow doesn't formally include updated_at, but the backend may
|
||||
// include it; expose a helper that reads it defensively for the column.
|
||||
function rowUpdatedAt(row: CategoryRow): string | undefined {
|
||||
return (row as CategoryRow & { updated_at?: string }).updated_at;
|
||||
}
|
||||
|
||||
function reportError(e: unknown, summary: string) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e);
|
||||
toast.add({ severity: "error", summary, detail: msg, life: 4000 });
|
||||
toast.push({ severity: "error", summary, detail: msg, life: 4000 });
|
||||
}
|
||||
|
||||
async function add() {
|
||||
@@ -67,7 +54,7 @@ async function add() {
|
||||
await store.create({ name, color: newColor.value });
|
||||
newName.value = "";
|
||||
newColor.value = "#a78bfa";
|
||||
toast.add({ severity: "success", summary: "Created", life: 2000 });
|
||||
toast.push({ severity: "success", summary: "Created", life: 2000 });
|
||||
} catch (e) {
|
||||
reportError(e, "Failed");
|
||||
}
|
||||
@@ -76,7 +63,7 @@ async function add() {
|
||||
async function remove(row: CategoryRow) {
|
||||
try {
|
||||
await store.remove(row.id);
|
||||
toast.add({ severity: "success", summary: "Deleted", life: 2000 });
|
||||
toast.push({ severity: "success", summary: "Deleted", life: 2000 });
|
||||
} catch (e) {
|
||||
reportError(e, "Delete failed");
|
||||
}
|
||||
@@ -91,6 +78,15 @@ async function changeColor(row: CategoryRow, value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function changeSortOrder(row: CategoryRow, value: number) {
|
||||
if (value === row.sort_order) return;
|
||||
try {
|
||||
await store.update(row.id, { sort_order: value });
|
||||
} catch (e) {
|
||||
reportError(e, "Update failed");
|
||||
}
|
||||
}
|
||||
|
||||
function startInlineEdit(row: CategoryRow) {
|
||||
editingId.value = row.id;
|
||||
editingName.value = row.name;
|
||||
@@ -117,106 +113,67 @@ async function commitInlineEdit(row: CategoryRow) {
|
||||
}
|
||||
try {
|
||||
await store.update(row.id, { name });
|
||||
toast.add({ severity: "success", summary: "Renamed", life: 1500 });
|
||||
toast.push({ severity: "success", summary: "Renamed", life: 1500 });
|
||||
} catch (e) {
|
||||
reportError(e, "Rename failed");
|
||||
} finally {
|
||||
cancelInlineEdit();
|
||||
}
|
||||
}
|
||||
|
||||
function openEditModal(row: CategoryRow) {
|
||||
modalRow.value = row;
|
||||
modalName.value = row.name;
|
||||
modalColor.value = row.color ?? "#a78bfa";
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
modalOpen.value = false;
|
||||
modalRow.value = null;
|
||||
}
|
||||
|
||||
async function saveEditModal() {
|
||||
if (!modalRow.value) return;
|
||||
const id = modalRow.value.id;
|
||||
const name = modalName.value.trim();
|
||||
if (!name) {
|
||||
toast.add({ severity: "warn", summary: "Name is required", life: 2000 });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await store.update(id, { name, color: modalColor.value });
|
||||
toast.add({ severity: "success", summary: "Saved", life: 1500 });
|
||||
closeEditModal();
|
||||
} catch (e) {
|
||||
reportError(e, "Save failed");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="categories-page">
|
||||
<header class="categories-page__header">
|
||||
<h1 class="text-xl font-semibold m-0 text-wp-text">Categories</h1>
|
||||
<p class="text-sm text-wp-text2 m-0 mt-1">
|
||||
Group modules into categories. Each category has a name and a color used as a chip in lists.
|
||||
</p>
|
||||
</header>
|
||||
<div class="wp-page wp-page--fill wp-cat-page">
|
||||
<div class="wp-page__header">
|
||||
<div class="wp-page__title-wrap">
|
||||
<h1 class="wp-page__title">Categories</h1>
|
||||
<p class="wp-page__subtitle">
|
||||
Tag groups for filtering modules. Color appears on category chips in the DataTable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card class="categories-page__new">
|
||||
<template #title>
|
||||
<span class="text-sm font-medium">New category</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="categories-new-row">
|
||||
<InputText
|
||||
id="cat-name"
|
||||
<Card title="New category">
|
||||
<div class="wp-cat-newrow">
|
||||
<Field label="Name" class="wp-cat-newrow__name">
|
||||
<Input
|
||||
v-model="newName"
|
||||
placeholder="Style"
|
||||
class="flex-1"
|
||||
aria-label="New category name"
|
||||
data-test="new-cat-name"
|
||||
@keydown.enter="add"
|
||||
/>
|
||||
<ColorPicker
|
||||
v-model="newColor"
|
||||
aria-label="New category color"
|
||||
/>
|
||||
<Button
|
||||
label="Add"
|
||||
icon="pi pi-plus"
|
||||
severity="primary"
|
||||
data-test="add-category-btn"
|
||||
@click="add"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Field>
|
||||
<Field label="Color">
|
||||
<ColorPicker v-model="newColor" aria-label="New category color" />
|
||||
</Field>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon="pi-plus"
|
||||
data-test="add-category-btn"
|
||||
@click="add"
|
||||
>Add</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="categories-page__table-wrap">
|
||||
<table class="categories-table">
|
||||
<div class="wp-table-wrap wp-table-wrap--scroll">
|
||||
<table class="wp-table wp-table--sticky-head wp-cat-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="categories-table__col-color">
|
||||
Color
|
||||
</th>
|
||||
<th>Name</th>
|
||||
<th class="categories-table__col-count">
|
||||
Modules
|
||||
</th>
|
||||
<th class="categories-table__col-updated">
|
||||
Updated
|
||||
</th>
|
||||
<th class="categories-table__col-actions">
|
||||
Actions
|
||||
</th>
|
||||
<th class="wp-cat-col--color">Color</th>
|
||||
<th class="wp-cat-col--sort">Sort</th>
|
||||
<th class="wp-cat-col--count">Modules</th>
|
||||
<th class="wp-cat-col--actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="store.items.length === 0">
|
||||
<td colspan="5" class="categories-table__empty">
|
||||
No categories yet.
|
||||
<td colspan="5">
|
||||
<div class="wp-empty">
|
||||
<div class="wp-empty__icon"><Icon name="pi-bookmark" /></div>
|
||||
<div class="wp-dim">No categories yet.</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
@@ -225,20 +182,12 @@ async function saveEditModal() {
|
||||
class="wp-cat-row"
|
||||
:data-test="`cat-row-${row.id}`"
|
||||
>
|
||||
<td>
|
||||
<ColorPicker
|
||||
:model-value="row.color || '#a78bfa'"
|
||||
:aria-label="`Edit color for ${row.name}`"
|
||||
@update:model-value="(v) => changeColor(row, v)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="editingId === row.id">
|
||||
<InputText
|
||||
<Input
|
||||
v-model="editingName"
|
||||
:data-test="`cat-name-input-${row.id}`"
|
||||
aria-label="Edit category name"
|
||||
class="w-full"
|
||||
@keydown.enter="commitInlineEdit(row)"
|
||||
@keydown.esc="cancelInlineEdit"
|
||||
@blur="commitInlineEdit(row)"
|
||||
@@ -246,209 +195,126 @@ async function saveEditModal() {
|
||||
</template>
|
||||
<template v-else>
|
||||
<span
|
||||
class="categories-table__name"
|
||||
class="wp-cat-name"
|
||||
:data-test="`cat-name-${row.id}`"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
:aria-label="`Edit ${row.name}`"
|
||||
@dblclick="startInlineEdit(row)"
|
||||
:style="{ background: row.color || 'var(--wp-bg-3)' }"
|
||||
@click="startInlineEdit(row)"
|
||||
@keydown.enter.prevent="startInlineEdit(row)"
|
||||
>
|
||||
<Tag
|
||||
:value="row.name"
|
||||
:style="{ background: row.color || 'var(--wp-bg-3)', color: '#fff' }"
|
||||
/>
|
||||
</span>
|
||||
>{{ row.name }}</span>
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<span class="categories-table__count" :data-test="`cat-count-${row.id}`">
|
||||
<div class="wp-cat-color">
|
||||
<ColorPicker
|
||||
:model-value="row.color || '#a78bfa'"
|
||||
:aria-label="`Edit color for ${row.name}`"
|
||||
@update:model-value="(v) => changeColor(row, v)"
|
||||
/>
|
||||
<span class="wp-mono wp-dim wp-cat-color__hex">{{ row.color || '—' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<Input
|
||||
type="number"
|
||||
size="sm"
|
||||
:model-value="row.sort_order"
|
||||
aria-label="Sort order"
|
||||
:data-test="`cat-sort-${row.id}`"
|
||||
@blur="(e) => changeSortOrder(row, Number((e.target as HTMLInputElement).value) || 0)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span class="wp-cat-count" :data-test="`cat-count-${row.id}`">
|
||||
{{ moduleCount(row) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-wp-text2 text-xs">
|
||||
<RelativeDate
|
||||
v-if="rowUpdatedAt(row)"
|
||||
:value="rowUpdatedAt(row)!"
|
||||
<td class="wp-cat-col--actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="pi-trash"
|
||||
:aria-label="`Delete ${row.name}`"
|
||||
:data-test="`cat-delete-${row.id}`"
|
||||
@click="remove(row)"
|
||||
/>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="categories-table__actions">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
:aria-label="`Edit ${row.name}`"
|
||||
:data-test="`cat-edit-${row.id}`"
|
||||
@click="openEditModal(row)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="danger"
|
||||
:aria-label="`Delete ${row.name}`"
|
||||
:data-test="`cat-delete-${row.id}`"
|
||||
@click="remove(row)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="modalOpen"
|
||||
modal
|
||||
header="Edit category"
|
||||
:style="{ width: '24rem' }"
|
||||
:draggable="false"
|
||||
@hide="closeEditModal"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-wp-text2 mb-1" for="modal-cat-name">Name</label>
|
||||
<InputText
|
||||
id="modal-cat-name"
|
||||
v-model="modalName"
|
||||
class="w-full"
|
||||
data-test="modal-cat-name"
|
||||
@keydown.enter="saveEditModal"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-wp-text2 mb-1">Color</label>
|
||||
<ColorPicker v-model="modalColor" aria-label="Edit category color" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancel" text @click="closeEditModal" />
|
||||
<Button
|
||||
label="Save"
|
||||
icon="pi pi-check"
|
||||
severity="primary"
|
||||
data-test="modal-save"
|
||||
@click="saveEditModal"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.categories-page {
|
||||
.wp-cat-page { padding: 18px 22px 40px; max-width: 1200px; margin: 0 auto; }
|
||||
|
||||
.wp-cat-newrow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 1.25rem;
|
||||
gap: 1rem;
|
||||
color: var(--wp-text);
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wp-cat-newrow__name { flex: 1; min-width: 200px; }
|
||||
|
||||
.categories-page__header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.categories-page__new {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.categories-page__new :deep(.p-card-body) {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.categories-page__new :deep(.p-card-content) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.categories-new-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.categories-new-row > .flex-1 { flex: 1 1 auto; }
|
||||
|
||||
/* Sticky-header scroll wrap. The container claims the remaining space so
|
||||
* the header + new-category card stay anchored when categories grow.
|
||||
*/
|
||||
.categories-page__table-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
.wp-table-wrap--scroll {
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius);
|
||||
background: var(--wp-bg-1);
|
||||
}
|
||||
|
||||
.categories-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
.categories-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--wp-bg-2);
|
||||
border-bottom: 1px solid var(--wp-border-strong);
|
||||
.wp-cat-table thead th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: var(--wp-text-muted);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 12px;
|
||||
padding: 9px 12px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.06em;
|
||||
border-bottom: 1px solid var(--wp-border-strong);
|
||||
}
|
||||
.categories-table tbody td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
.wp-cat-table tbody td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--wp-border);
|
||||
vertical-align: middle;
|
||||
font-size: 13px;
|
||||
}
|
||||
.categories-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.wp-cat-table tbody tr:last-child td { border-bottom: none; }
|
||||
|
||||
.categories-table__col-color { width: 80px; }
|
||||
.categories-table__col-count { width: 6rem; }
|
||||
.categories-table__col-updated { width: 8rem; }
|
||||
.categories-table__col-actions { width: 7rem; text-align: right; }
|
||||
.wp-cat-col--color { width: 240px; }
|
||||
.wp-cat-col--sort { width: 90px; }
|
||||
.wp-cat-col--count { width: 90px; }
|
||||
.wp-cat-col--actions { width: 80px; text-align: right; }
|
||||
|
||||
.categories-table__col-actions { text-align: right; }
|
||||
.categories-table__actions {
|
||||
.wp-cat-name {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.wp-cat-name:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--wp-bg-1), 0 0 0 4px var(--wp-accent-500);
|
||||
}
|
||||
|
||||
.categories-table__count {
|
||||
.wp-cat-color { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.wp-cat-color__hex { font-size: 11.5px; }
|
||||
|
||||
.wp-cat-count {
|
||||
display: inline-block;
|
||||
min-width: 2rem;
|
||||
padding: 0 0.5rem;
|
||||
padding: 0 8px;
|
||||
text-align: center;
|
||||
background: var(--wp-bg-3);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: var(--wp-text-muted);
|
||||
}
|
||||
|
||||
.categories-table__empty {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: var(--wp-text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.categories-table__name {
|
||||
cursor: text;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
outline: none;
|
||||
}
|
||||
.categories-table__name:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--wp-accent-500);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+164
-399
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import Button from "primevue/button";
|
||||
import Menu from "primevue/menu";
|
||||
import Button from "../components/ui/Button.vue";
|
||||
import Card from "../components/ui/Card.vue";
|
||||
import Icon from "../components/ui/Icon.vue";
|
||||
import RelativeDate from "../components/RelativeDate.vue";
|
||||
import { api } from "../api/client";
|
||||
import type { ModuleRow, ModuleType } from "../api/types";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import RelativeDate from "../components/RelativeDate.vue";
|
||||
|
||||
interface KindMeta {
|
||||
type: ModuleType;
|
||||
@@ -18,12 +19,12 @@ interface KindMeta {
|
||||
|
||||
// Mirrors KIND_META from docs/design-handoff/wildcardpipeline/project/data.jsx.
|
||||
const KIND_META: KindMeta[] = [
|
||||
{ type: "wildcard", label: "Wildcards", icon: "pi pi-th-large", color: "var(--wp-kind-wildcard)", slug: "wildcards" },
|
||||
{ type: "fixed_values", label: "Fixed Values", icon: "pi pi-tag", color: "var(--wp-kind-fixed)", slug: "fixed-values" },
|
||||
{ type: "combine", label: "Combines", icon: "pi pi-share-alt", color: "var(--wp-kind-combine)", slug: "combines" },
|
||||
{ type: "derivation", label: "Derivations", icon: "pi pi-code", color: "var(--wp-kind-derivation)", slug: "derivations" },
|
||||
{ type: "constraint", label: "Constraints", icon: "pi pi-sitemap", color: "var(--wp-kind-constraint)", slug: "constraints" },
|
||||
{ type: "pipeline", label: "Pipelines", icon: "pi pi-list", color: "var(--wp-kind-pipeline)", slug: "pipelines" },
|
||||
{ type: "wildcard", label: "Wildcards", icon: "pi-th-large", color: "var(--wp-kind-wildcard)", slug: "wildcards" },
|
||||
{ type: "fixed_values", label: "Fixed Values", icon: "pi-tag", color: "var(--wp-kind-fixed)", slug: "fixed-values" },
|
||||
{ type: "combine", label: "Combines", icon: "pi-share-alt", color: "var(--wp-kind-combine)", slug: "combines" },
|
||||
{ type: "derivation", label: "Derivations", icon: "pi-code", color: "var(--wp-kind-derivation)", slug: "derivations" },
|
||||
{ type: "constraint", label: "Constraints", icon: "pi-sitemap", color: "var(--wp-kind-constraint)", slug: "constraints" },
|
||||
{ type: "pipeline", label: "Pipelines", icon: "pi-list", color: "var(--wp-kind-pipeline)", slug: "pipelines" },
|
||||
];
|
||||
|
||||
const KIND_BY_TYPE: Record<ModuleType, KindMeta> = KIND_META.reduce((acc, k) => {
|
||||
@@ -45,24 +46,11 @@ const counts = ref<Record<ModuleType, number>>({
|
||||
|
||||
const recentItems = ref<ModuleRow[]>([]);
|
||||
const favoriteItems = ref<ModuleRow[]>([]);
|
||||
const tab = ref<"recent" | "favorites">("recent");
|
||||
|
||||
const logoUrl = `${import.meta.env.BASE_URL}images/favicon.svg`;
|
||||
const wikiUrl = "https://github.com/DumiFlex/ComfyUI-WildcardPipeline/wiki";
|
||||
|
||||
const newModuleMenu = ref<InstanceType<typeof Menu> | null>(null);
|
||||
const newModuleItems = computed(() =>
|
||||
KIND_META.map((k) => ({
|
||||
label: k.label,
|
||||
icon: k.icon,
|
||||
// TODO: route to the kind-specific create form when slug-aware deeplinks land.
|
||||
command: () => router.push(`/${k.slug}/new`),
|
||||
})),
|
||||
);
|
||||
|
||||
function toggleNewModuleMenu(event: Event) {
|
||||
newModuleMenu.value?.toggle(event);
|
||||
}
|
||||
|
||||
function openDocs() {
|
||||
window.open(wikiUrl, "_blank", "noopener");
|
||||
}
|
||||
@@ -71,6 +59,22 @@ function navigateToKind(slug: string) {
|
||||
router.push(`/${slug}`);
|
||||
}
|
||||
|
||||
function openTestRunner() {
|
||||
router.push("/test");
|
||||
}
|
||||
|
||||
function openImportExport() {
|
||||
router.push("/import-export");
|
||||
}
|
||||
|
||||
function newWildcard() {
|
||||
router.push("/wildcards/new");
|
||||
}
|
||||
|
||||
function newPipeline() {
|
||||
router.push("/pipelines/new");
|
||||
}
|
||||
|
||||
function editRow(row: ModuleRow) {
|
||||
const meta = KIND_BY_TYPE[row.type];
|
||||
if (!meta) return;
|
||||
@@ -88,6 +92,10 @@ function categoryFor(row: ModuleRow): { name: string; color: string | null } | u
|
||||
return categoryById.value.get(row.category_id);
|
||||
}
|
||||
|
||||
const visibleItems = computed<ModuleRow[]>(() =>
|
||||
tab.value === "favorites" ? favoriteItems.value : recentItems.value,
|
||||
);
|
||||
|
||||
async function loadCounts() {
|
||||
// Fetch one record per kind in parallel; ModuleListResponse exposes total.
|
||||
await Promise.all(
|
||||
@@ -103,7 +111,6 @@ async function loadCounts() {
|
||||
}
|
||||
|
||||
async function loadRecent() {
|
||||
// TODO: add server-side sort=updated_at&order=desc — for now we client-slice.
|
||||
try {
|
||||
const res = await api.modules.list({ limit: 50 });
|
||||
const sorted = [...res.items].sort((a, b) => {
|
||||
@@ -137,384 +144,159 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- A) Brand hero -->
|
||||
<section class="dashboard__hero">
|
||||
<div class="dashboard__hero-icon">
|
||||
<img :src="logoUrl" alt="" />
|
||||
</div>
|
||||
<div class="wp-page">
|
||||
<!-- Hero -->
|
||||
<div class="wp-hero">
|
||||
<div class="wp-hero__icon"><img :src="logoUrl" alt="" /></div>
|
||||
<div class="dashboard__hero-text">
|
||||
<h2 class="dashboard__hero-title">Welcome to Wildcard Pipeline</h2>
|
||||
<p class="dashboard__hero-sub">
|
||||
Modular procedural prompt generation. Build, test, and reuse wildcards.
|
||||
<h2 class="wp-hero__title">Welcome to Wildcard Pipeline</h2>
|
||||
<p class="wp-hero__sub">
|
||||
Manage modules — wildcards, fixed values, combines, derivations and constraints — that drop into ComfyUI prompts as snapshots.
|
||||
</p>
|
||||
</div>
|
||||
<div class="dashboard__hero-actions">
|
||||
<Button
|
||||
label="Browse docs"
|
||||
icon="pi pi-book"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="dashboard__hero-btn dashboard__hero-btn--outline"
|
||||
@click="openDocs"
|
||||
/>
|
||||
<Button
|
||||
label="New module"
|
||||
icon="pi pi-plus"
|
||||
class="dashboard__hero-btn dashboard__hero-btn--primary"
|
||||
aria-haspopup="true"
|
||||
aria-controls="dashboard-new-module-menu"
|
||||
@click="toggleNewModuleMenu"
|
||||
/>
|
||||
<Menu
|
||||
id="dashboard-new-module-menu"
|
||||
ref="newModuleMenu"
|
||||
:model="newModuleItems"
|
||||
:popup="true"
|
||||
/>
|
||||
<div class="wp-hsplit dashboard__hero-actions">
|
||||
<Button variant="outline" icon="pi-book" @click="openDocs">Docs</Button>
|
||||
<Button variant="primary" icon="pi-plus" @click="newWildcard">New module</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- B) Stat cards -->
|
||||
<section class="dashboard__stats" aria-label="Module counts">
|
||||
<!-- Stats -->
|
||||
<div class="wp-stats" aria-label="Module counts">
|
||||
<button
|
||||
v-for="kind in KIND_META"
|
||||
:key="kind.type"
|
||||
type="button"
|
||||
class="dashboard__stat"
|
||||
class="wp-stat dashboard__stat"
|
||||
:aria-label="`View all ${kind.label}`"
|
||||
@click="navigateToKind(kind.slug)"
|
||||
@keydown.enter.prevent="navigateToKind(kind.slug)"
|
||||
@keydown.space.prevent="navigateToKind(kind.slug)"
|
||||
>
|
||||
<span class="dashboard__stat-icon" :style="{ color: kind.color, background: `color-mix(in oklab, ${kind.color} 14%, transparent)` }">
|
||||
<i :class="kind.icon" aria-hidden="true" />
|
||||
</span>
|
||||
<span class="dashboard__stat-label">{{ kind.label }}</span>
|
||||
<span class="dashboard__stat-value">{{ counts[kind.type] }}</span>
|
||||
<span class="dashboard__stat-link">
|
||||
View all
|
||||
<i class="pi pi-arrow-right" aria-hidden="true" />
|
||||
</span>
|
||||
<div
|
||||
class="wp-stat__icon"
|
||||
:style="{ color: kind.color, background: `color-mix(in oklab, ${kind.color} 16%, transparent)` }"
|
||||
>
|
||||
<Icon :name="kind.icon" />
|
||||
</div>
|
||||
<div class="wp-stat__label">{{ kind.label }}</div>
|
||||
<div class="wp-stat__value">{{ counts[kind.type] }}</div>
|
||||
<div class="wp-stat__delta">
|
||||
<Icon name="pi-arrow-up-right" :size="9" /> View all
|
||||
</div>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- C) Quick actions -->
|
||||
<section class="dashboard__quick" aria-label="Quick actions">
|
||||
<Button
|
||||
label="Test Runner"
|
||||
icon="pi pi-bolt"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="dashboard__quick-btn"
|
||||
@click="router.push('/test')"
|
||||
/>
|
||||
<Button
|
||||
label="Categories"
|
||||
icon="pi pi-folder"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="dashboard__quick-btn"
|
||||
@click="router.push('/categories')"
|
||||
/>
|
||||
<Button
|
||||
label="Import / Export"
|
||||
icon="pi pi-arrow-right-arrow-left"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="dashboard__quick-btn"
|
||||
@click="router.push('/import-export')"
|
||||
/>
|
||||
</section>
|
||||
<!-- Quick actions row -->
|
||||
<div class="dashboard__quick">
|
||||
<Button variant="ghost" icon="pi-bolt" @click="openTestRunner">Open test runner</Button>
|
||||
<Button variant="ghost" icon="pi-arrow-right-arrow-left" @click="openImportExport">Import / Export</Button>
|
||||
<Button variant="ghost" icon="pi-plus" @click="newPipeline">New pipeline</Button>
|
||||
</div>
|
||||
|
||||
<!-- D) Recent + Favorites -->
|
||||
<section class="dashboard__recent">
|
||||
<article class="dashboard__panel">
|
||||
<header class="dashboard__panel-header">
|
||||
<h3 class="dashboard__panel-title">Recently updated</h3>
|
||||
<a class="dashboard__panel-link" href="#" @click.prevent="router.push('/wildcards')">
|
||||
View all
|
||||
<i class="pi pi-arrow-right" aria-hidden="true" />
|
||||
</a>
|
||||
</header>
|
||||
<ul v-if="recentItems.length" class="dashboard__list">
|
||||
<li
|
||||
v-for="row in recentItems"
|
||||
:key="row.id"
|
||||
class="dashboard__row"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
:aria-label="`Edit ${row.name}`"
|
||||
@click="editRow(row)"
|
||||
@keydown.enter.prevent="editRow(row)"
|
||||
@keydown.space.prevent="editRow(row)"
|
||||
<!-- Recent / Favorites -->
|
||||
<Card padding>
|
||||
<template #actions>
|
||||
<div class="wp-tabs wp-recent__tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="wp-tabs__btn wp-tab"
|
||||
:data-active="tab === 'recent' ? 'true' : 'false'"
|
||||
data-test="dashboard-tab-recent"
|
||||
@click="tab = 'recent'"
|
||||
>
|
||||
<span
|
||||
class="dashboard__row-icon"
|
||||
:style="{
|
||||
color: KIND_BY_TYPE[row.type]?.color,
|
||||
background: `color-mix(in oklab, ${KIND_BY_TYPE[row.type]?.color} 18%, transparent)`,
|
||||
}"
|
||||
>
|
||||
<i :class="KIND_BY_TYPE[row.type]?.icon ?? 'pi pi-circle'" aria-hidden="true" />
|
||||
</span>
|
||||
<span class="dashboard__row-name">{{ row.name }}</span>
|
||||
<span
|
||||
v-if="categoryFor(row)"
|
||||
class="dashboard__row-chip"
|
||||
:style="{ background: categoryFor(row)!.color || 'var(--wp-bg-3)' }"
|
||||
>{{ categoryFor(row)!.name }}</span>
|
||||
<RelativeDate :value="row.updated_at" />
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="dashboard__empty">No edits yet.</p>
|
||||
</article>
|
||||
|
||||
<article class="dashboard__panel">
|
||||
<header class="dashboard__panel-header">
|
||||
<h3 class="dashboard__panel-title">Favorites</h3>
|
||||
<a class="dashboard__panel-link" href="#" @click.prevent="router.push('/wildcards')">
|
||||
View all
|
||||
<i class="pi pi-arrow-right" aria-hidden="true" />
|
||||
</a>
|
||||
</header>
|
||||
<ul v-if="favoriteItems.length" class="dashboard__list">
|
||||
<li
|
||||
v-for="row in favoriteItems"
|
||||
:key="row.id"
|
||||
class="dashboard__row"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
:aria-label="`Edit ${row.name}`"
|
||||
@click="editRow(row)"
|
||||
@keydown.enter.prevent="editRow(row)"
|
||||
@keydown.space.prevent="editRow(row)"
|
||||
<Icon name="pi-clock" /> Recent edits
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-tabs__btn wp-tab"
|
||||
:data-active="tab === 'favorites' ? 'true' : 'false'"
|
||||
data-test="dashboard-tab-favorites"
|
||||
@click="tab = 'favorites'"
|
||||
>
|
||||
<span
|
||||
class="dashboard__row-icon"
|
||||
:style="{
|
||||
color: KIND_BY_TYPE[row.type]?.color,
|
||||
background: `color-mix(in oklab, ${KIND_BY_TYPE[row.type]?.color} 18%, transparent)`,
|
||||
}"
|
||||
>
|
||||
<i :class="KIND_BY_TYPE[row.type]?.icon ?? 'pi pi-circle'" aria-hidden="true" />
|
||||
</span>
|
||||
<span class="dashboard__row-name">{{ row.name }}</span>
|
||||
<span
|
||||
v-if="categoryFor(row)"
|
||||
class="dashboard__row-chip"
|
||||
:style="{ background: categoryFor(row)!.color || 'var(--wp-bg-3)' }"
|
||||
>{{ categoryFor(row)!.name }}</span>
|
||||
<RelativeDate :value="row.updated_at" />
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="dashboard__empty">No favorites yet.</p>
|
||||
</article>
|
||||
</section>
|
||||
<Icon name="pi-star-fill" /> Favorites
|
||||
</button>
|
||||
<a
|
||||
class="dashboard__view-all"
|
||||
href="#"
|
||||
@click.prevent="navigateToKind('wildcards')"
|
||||
>View all <Icon name="pi-arrow-right" :size="10" /></a>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="visibleItems.length" class="wp-list wp-recent">
|
||||
<div
|
||||
v-for="row in visibleItems"
|
||||
:key="row.id"
|
||||
class="wp-list__row"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
:aria-label="`Edit ${row.name}`"
|
||||
@click="editRow(row)"
|
||||
@keydown.enter.prevent="editRow(row)"
|
||||
@keydown.space.prevent="editRow(row)"
|
||||
>
|
||||
<span
|
||||
class="dashboard__row-icon"
|
||||
:style="{
|
||||
color: KIND_BY_TYPE[row.type]?.color,
|
||||
background: `color-mix(in oklab, ${KIND_BY_TYPE[row.type]?.color} 18%, transparent)`,
|
||||
}"
|
||||
>
|
||||
<Icon :name="KIND_BY_TYPE[row.type]?.icon ?? 'pi-circle'" />
|
||||
</span>
|
||||
<span class="dashboard__row-name">{{ row.name }}</span>
|
||||
<span
|
||||
v-if="categoryFor(row)"
|
||||
class="dashboard__row-chip"
|
||||
:style="{ background: categoryFor(row)!.color || 'var(--wp-bg-3)' }"
|
||||
>{{ categoryFor(row)!.name }}</span>
|
||||
<span class="wp-id">{{ row.id }}</span>
|
||||
<RelativeDate :value="row.updated_at" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="dashboard__empty">
|
||||
{{ tab === 'favorites' ? 'No favorites yet.' : 'No edits yet.' }}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.dashboard__hero-text { flex: 1; min-width: 0; }
|
||||
.dashboard__hero-actions { gap: 8px; }
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
.dashboard__hero {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 18px 20px;
|
||||
border-radius: var(--wp-radius-xl);
|
||||
background:
|
||||
radial-gradient(120% 140% at 0% 0%, color-mix(in oklab, var(--wp-brand-1) 55%, transparent) 0%, transparent 55%),
|
||||
radial-gradient(120% 140% at 100% 100%, color-mix(in oklab, var(--wp-brand-3) 50%, transparent) 0%, transparent 60%),
|
||||
linear-gradient(135deg, var(--wp-brand-1), var(--wp-brand-2), var(--wp-brand-3));
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
}
|
||||
.dashboard__hero::after {
|
||||
content: "";
|
||||
position: absolute; inset: 0;
|
||||
background: radial-gradient(80% 100% at 50% 0%, rgba(255, 255, 255, 0.06), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.dashboard__hero-icon {
|
||||
width: 52px; height: 52px;
|
||||
border-radius: 14px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
display: grid; place-items: center;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.dashboard__hero-icon img { width: 44px; height: 44px; display: block; }
|
||||
.dashboard__hero-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.dashboard__hero-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 2px;
|
||||
color: #fff;
|
||||
}
|
||||
.dashboard__hero-sub {
|
||||
font-size: 12.5px;
|
||||
margin: 0;
|
||||
max-width: 640px;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
.dashboard__hero-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Force translucent treatment regardless of light/dark theme — gradient is dark in both. */
|
||||
.dashboard__hero :deep(.dashboard__hero-btn.p-button) {
|
||||
border-radius: var(--wp-radius);
|
||||
}
|
||||
.dashboard__hero :deep(.dashboard__hero-btn--outline.p-button) {
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
}
|
||||
.dashboard__hero :deep(.dashboard__hero-btn--outline.p-button:hover) {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
color: #fff;
|
||||
}
|
||||
.dashboard__hero :deep(.dashboard__hero-btn--primary.p-button) {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-color: transparent;
|
||||
color: var(--wp-accent-800);
|
||||
}
|
||||
.dashboard__hero :deep(.dashboard__hero-btn--primary.p-button:hover) {
|
||||
background: #fff;
|
||||
color: var(--wp-accent-900);
|
||||
}
|
||||
|
||||
/* ---------- Stat cards ---------- */
|
||||
.dashboard__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.dashboard__stats { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.dashboard__stats { grid-template-columns: 1fr; }
|
||||
}
|
||||
.dashboard__stat {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px 14px;
|
||||
background: var(--wp-bg-2);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-lg);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
}
|
||||
.dashboard__stat:hover { border-color: var(--wp-border-strong); }
|
||||
.dashboard__stat:focus-visible {
|
||||
outline: 2px solid var(--wp-border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.dashboard__stat-icon {
|
||||
position: absolute; top: 10px; right: 10px;
|
||||
width: 26px; height: 26px;
|
||||
border-radius: 7px;
|
||||
display: grid; place-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
.dashboard__stat-label {
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--wp-text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.dashboard__stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.dashboard__stat-link {
|
||||
font-size: 11.5px;
|
||||
color: var(--wp-accent-text);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.dashboard__stat-link i { font-size: 10px; }
|
||||
|
||||
/* ---------- Quick actions ---------- */
|
||||
.dashboard__quick {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.dashboard__quick :deep(.dashboard__quick-btn.p-button) {
|
||||
border-radius: 999px;
|
||||
background: var(--wp-bg-1);
|
||||
border: 1px solid var(--wp-border);
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.dashboard__quick :deep(.dashboard__quick-btn.p-button:hover) {
|
||||
background: var(--wp-bg-3);
|
||||
border-color: var(--wp-border-strong);
|
||||
color: var(--wp-text);
|
||||
}
|
||||
|
||||
/* ---------- Recent + Favorites ---------- */
|
||||
.dashboard__recent {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
@media (max-width: 880px) {
|
||||
.dashboard__recent { grid-template-columns: 1fr; }
|
||||
}
|
||||
.dashboard__panel {
|
||||
background: var(--wp-bg-1);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-lg);
|
||||
padding: 12px;
|
||||
.wp-recent__tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.dashboard__panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.dashboard__panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0;
|
||||
.wp-tabs__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: var(--wp-text-muted);
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.wp-tabs__btn:hover { color: var(--wp-text); }
|
||||
.wp-tabs__btn[data-active="true"] {
|
||||
color: var(--wp-text);
|
||||
background: var(--wp-bg-2);
|
||||
border-color: var(--wp-border);
|
||||
}
|
||||
.dashboard__panel-link {
|
||||
.dashboard__view-all {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--wp-accent-text);
|
||||
text-decoration: none;
|
||||
@@ -522,38 +304,14 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.dashboard__panel-link:hover { color: var(--wp-accent-text-strong); }
|
||||
.dashboard__panel-link i { font-size: 10px; }
|
||||
.dashboard__view-all:hover { color: var(--wp-text); }
|
||||
|
||||
.dashboard__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dashboard__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.dashboard__row:hover { background: var(--wp-bg-3); }
|
||||
.dashboard__row + .dashboard__row {
|
||||
border-top: 1px solid var(--wp-border);
|
||||
border-radius: 0;
|
||||
}
|
||||
.dashboard__row:focus-visible {
|
||||
outline: 2px solid var(--wp-border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.dashboard__row-icon {
|
||||
width: 22px; height: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
display: grid; place-items: center;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -579,6 +337,13 @@ onMounted(async () => {
|
||||
.dashboard__empty {
|
||||
font-size: 12.5px;
|
||||
color: var(--wp-text-muted);
|
||||
margin: 6px 4px 4px;
|
||||
margin: 12px 4px 6px;
|
||||
}
|
||||
|
||||
.dashboard__stat { cursor: pointer; text-align: left; font: inherit; color: inherit; }
|
||||
.dashboard__stat:hover { border-color: var(--wp-border-strong); }
|
||||
.dashboard__stat:focus-visible {
|
||||
outline: 2px solid var(--wp-border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+319
-499
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Settings — port of `SettingsScreen` in
|
||||
* `docs/design-handoff/wildcardpipeline/project/screens/utilities.jsx`.
|
||||
* About / Theme / Storage cards using the local ui/* primitives.
|
||||
*/
|
||||
import Button from "../components/ui/Button.vue";
|
||||
import Card from "../components/ui/Card.vue";
|
||||
import Icon from "../components/ui/Icon.vue";
|
||||
import { useUiStore, type ThemeMode } from "../stores/uiStore";
|
||||
|
||||
const uiStore = useUiStore();
|
||||
|
||||
const repoUrl = "https://github.com/DumiFlex/ComfyUI-WildcardPipeline";
|
||||
|
||||
interface ThemeOption { value: ThemeMode; label: string; icon: string }
|
||||
const THEMES: ThemeOption[] = [
|
||||
{ value: "dark", label: "Dark", icon: "pi-moon" },
|
||||
{ value: "light", label: "Light", icon: "pi-sun" },
|
||||
{ value: "auto", label: "Auto", icon: "pi-desktop" },
|
||||
];
|
||||
|
||||
function setTheme(mode: ThemeMode) {
|
||||
uiStore.setThemeMode(mode);
|
||||
}
|
||||
|
||||
function resetPreferences() {
|
||||
// Best-effort — clear the manager's localStorage namespace + reload.
|
||||
try {
|
||||
const toClear: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith("wp-")) toClear.push(key);
|
||||
}
|
||||
for (const k of toClear) localStorage.removeItem(k);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 text-wp-text">
|
||||
<h1 class="text-xl font-semibold m-0">Settings</h1>
|
||||
<p class="text-sm text-wp-text2 m-0 mt-2">Stub — full impl deferred (post-Plan-2).</p>
|
||||
<div class="wp-page wp-settings">
|
||||
<div class="wp-page__header">
|
||||
<div class="wp-page__title-wrap">
|
||||
<h1 class="wp-page__title">Settings</h1>
|
||||
<p class="wp-page__subtitle">
|
||||
Application preferences. Stored locally.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card title="About">
|
||||
<div class="wp-settings__about">
|
||||
<span class="wp-dim">Version</span><span class="wp-mono">1.4.x-dev</span>
|
||||
<span class="wp-dim">License</span><span class="wp-mono">GPL-3.0-or-later</span>
|
||||
<span class="wp-dim">Repository</span>
|
||||
<a
|
||||
class="wp-settings__link wp-mono"
|
||||
:href="repoUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<Icon name="pi-github" /> {{ repoUrl }}
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Theme">
|
||||
<p class="wp-dim wp-settings__hint">
|
||||
Choose dark, light, or follow the OS preference.
|
||||
</p>
|
||||
<div class="wp-settings__radio" role="radiogroup" aria-label="Theme">
|
||||
<button
|
||||
v-for="t in THEMES"
|
||||
:key="t.value"
|
||||
type="button"
|
||||
class="wp-settings__chip"
|
||||
role="radio"
|
||||
:aria-checked="uiStore.themeMode === t.value"
|
||||
:data-active="uiStore.themeMode === t.value ? 'true' : 'false'"
|
||||
:data-test="`settings-theme-${t.value}`"
|
||||
@click="setTheme(t.value)"
|
||||
>
|
||||
<Icon :name="t.icon" /> {{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Storage">
|
||||
<p class="wp-dim wp-settings__hint">
|
||||
Preferences (theme, sidebar state, last filters) are persisted in your
|
||||
browser's <span class="wp-mono">localStorage</span>. Modules + categories
|
||||
are stored server-side in SQLite.
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="pi-refresh"
|
||||
data-test="settings-reset"
|
||||
@click="resetPreferences"
|
||||
>Reset preferences</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-settings { max-width: 720px; }
|
||||
|
||||
.wp-settings__about {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 16px;
|
||||
font-size: 12.5px;
|
||||
align-items: center;
|
||||
}
|
||||
.wp-settings__link {
|
||||
color: var(--wp-accent-text);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.wp-settings__link:hover { color: var(--wp-text); }
|
||||
|
||||
.wp-settings__hint {
|
||||
font-size: 12.5px;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.wp-settings__radio {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.wp-settings__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--wp-border);
|
||||
background: var(--wp-bg-2);
|
||||
color: var(--wp-text-muted);
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wp-settings__chip:hover {
|
||||
color: var(--wp-text);
|
||||
border-color: var(--wp-border-strong);
|
||||
}
|
||||
.wp-settings__chip[data-active="true"] {
|
||||
background: color-mix(in oklab, var(--wp-accent-500) 18%, transparent);
|
||||
border-color: var(--wp-accent-500);
|
||||
color: var(--wp-accent-text);
|
||||
}
|
||||
</style>
|
||||
|
||||
+414
-391
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user