feat(spa): dashboard hero + branded categories/import/test pages

This commit is contained in:
DumiFlex
2026-04-26 21:40:27 +03:00
parent 82b568efbb
commit e49df411d7
4 changed files with 121 additions and 41 deletions
+10 -10
View File
@@ -43,7 +43,10 @@ async function remove(row: CategoryRow) {
<template>
<div class="p-6 text-wp-text max-w-3xl">
<h1 class="text-xl font-semibold mb-4">Categories</h1>
<h1 class="text-xl font-semibold m-0">Categories</h1>
<p class="text-sm text-wp-text2 m-0 mt-1 mb-6">
Tag groups for filtering modules. Color appears on category chips in the DataTable.
</p>
<div class="flex gap-2 mb-6 items-end">
<div class="flex-1">
@@ -54,12 +57,12 @@ async function remove(row: CategoryRow) {
<label for="cat-color" class="block text-xs text-wp-text2 mb-1">Color</label>
<ColorPicker id="cat-color" v-model="newColor" />
</div>
<Button label="+ Add" severity="primary" data-test="add-category-btn" @click="add" />
<Button label="Add" icon="pi pi-plus" severity="primary" data-test="add-category-btn" @click="add" />
</div>
<DataTable :value="store.items" data-key="id" :loading="store.loading">
<Column field="name" header="Name" />
<Column field="color" header="Color" style="width:8rem">
<Column field="color" header="Color" header-style="width:9rem">
<template #body="{ data }">
<span
class="inline-block w-4 h-4 rounded mr-2 align-middle"
@@ -69,14 +72,11 @@ async function remove(row: CategoryRow) {
<span class="font-mono text-xs">{{ data.color || "—" }}</span>
</template>
</Column>
<Column field="sort_order" header="Order" sortable style="width:6rem" />
<Column header="Actions" style="width:6rem">
<Column field="sort_order" header="Order" sortable header-style="width:6rem" />
<Column header="Actions" header-style="width:6rem">
<template #body="{ data }">
<Button
icon="pi pi-trash" text rounded size="small" severity="danger"
aria-label="Delete category"
@click="remove(data)"
/>
<Button icon="pi pi-trash" text rounded size="small" severity="danger"
aria-label="Delete category" @click="remove(data)" />
</template>
</Column>
<template #empty>
+91 -2
View File
@@ -1,9 +1,98 @@
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import Button from "primevue/button";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import RelativeDate from "../components/RelativeDate.vue";
const router = useRouter();
const moduleStore = useModuleStore();
const categoryStore = useCategoryStore();
const faviconUrl = `${import.meta.env.BASE_URL}images/favicon.svg`;
onMounted(async () => {
moduleStore.filter.type = undefined;
await Promise.all([moduleStore.fetchAll(), categoryStore.fetchAll()]);
});
const recent = computed(() => [...moduleStore.items].slice(0, 5));
</script>
<template>
<div class="p-6 text-wp-text">
<h1 class="text-xl font-semibold m-0">Dashboard</h1>
<p class="text-sm text-wp-text2 m-0 mt-2">Stub — wired up in Task 9.</p>
<section class="hero-card mb-6">
<img :src="faviconUrl" alt="" class="hero-card__logo" />
<div class="flex-1">
<h1 class="text-2xl font-semibold m-0">Welcome to Wildcard Pipeline</h1>
<p class="text-sm m-0 mt-1 opacity-90">
Manage modules — wildcards, fixed values — that drop into ComfyUI prompts as snapshots.
</p>
</div>
</section>
<div class="grid grid-cols-4 gap-4 mb-6">
<div class="stat-card">
<div class="stat-card__label">Wildcards</div>
<div class="stat-card__value">{{ moduleStore.wildcards.length }}</div>
</div>
<div class="stat-card">
<div class="stat-card__label">Fixed values</div>
<div class="stat-card__value">{{ moduleStore.fixedValues.length }}</div>
</div>
<div class="stat-card">
<div class="stat-card__label">Categories</div>
<div class="stat-card__value">{{ categoryStore.items.length }}</div>
</div>
<div class="stat-card">
<div class="stat-card__label">Total modules</div>
<div class="stat-card__value">{{ moduleStore.items.length }}</div>
</div>
</div>
<h2 class="text-sm uppercase tracking-wider text-wp-text2 mb-2">Recent edits</h2>
<div v-if="recent.length" class="bg-wp-bg2 border border-wp-border rounded">
<div
v-for="m in recent" :key="m.id"
class="flex items-center justify-between px-4 py-2 border-b border-wp-border last:border-b-0 cursor-pointer hover:bg-wp-bg3"
@click="router.push(m.type === 'wildcard' ? `/wildcards/${m.id}/edit` : `/fixed-values/${m.id}/edit`)"
>
<div class="flex items-center gap-3">
<i :class="m.type === 'wildcard' ? 'pi pi-th-large text-wp-violet' : 'pi pi-tag text-wp-rose'" />
<span class="font-medium">{{ m.name }}</span>
<span class="text-xs text-wp-text3 font-mono">{{ m.id }}</span>
</div>
<RelativeDate :value="m.updated_at" />
</div>
</div>
<p v-else class="text-sm text-wp-text2">No modules yet.</p>
<div class="mt-6 flex gap-2 flex-wrap">
<Button label="New wildcard" icon="pi pi-plus" severity="primary" @click="router.push('/wildcards/new')" />
<Button label="New fixed values" icon="pi pi-plus" severity="primary" outlined @click="router.push('/fixed-values/new')" />
<Button label="Open test runner" icon="pi pi-bolt" severity="secondary" outlined @click="router.push('/test')" />
</div>
</div>
</template>
<style scoped>
.hero-card {
display: flex; gap: 16px; align-items: center;
padding: 24px;
background: var(--wp-brand-gradient);
border-radius: var(--wp-radius);
color: #fff;
}
.hero-card__logo { width: 64px; height: 64px; }
.stat-card {
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
padding: 16px;
}
.stat-card__label {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
color: var(--wp-text2);
}
.stat-card__value { font-size: 28px; font-weight: 600; margin-top: 4px; }
</style>
+5 -6
View File
@@ -37,7 +37,6 @@ async function onUpload(e: Event) {
} catch (err) {
toast.add({ severity: "error", summary: "Import failed", detail: String(err), life: 5000 });
} finally {
// reset so the same file can be selected again
if (uploadInput.value) uploadInput.value.value = "";
}
}
@@ -45,10 +44,9 @@ async function onUpload(e: Event) {
<template>
<div class="p-6 text-wp-text max-w-2xl">
<h1 class="text-xl font-semibold mb-4">Import / Export</h1>
<p class="text-sm text-wp-text2 mb-6">
Library bundles are JSON files containing all module declarations and categories.
Workflow files are NOT handled here.
<h1 class="text-xl font-semibold m-0">Import / Export</h1>
<p class="text-sm text-wp-text2 m-0 mt-1 mb-6">
Library bundles are JSON files containing all module declarations and categories. Workflow files are NOT handled here.
</p>
<div class="flex gap-3 items-center mb-6">
@@ -56,13 +54,14 @@ async function onUpload(e: Event) {
<Button icon="pi pi-upload" label="Import bundle" severity="secondary" outlined @click="uploadInput?.click()" />
<input
ref="uploadInput" type="file" accept="application/json"
aria-label="Upload bundle JSON file"
class="hidden" data-test="upload-input"
aria-label="Upload bundle JSON file"
@change="onUpload"
/>
</div>
<div v-if="lastResult" class="text-sm text-wp-text2 p-3 rounded bg-wp-bg2 border border-wp-border">
<i class="pi pi-info-circle mr-2" />
Last import: {{ lastResult }}
</div>
</div>
+15 -23
View File
@@ -17,7 +17,10 @@ const variableBinding = ref("$x");
const result = ref<TestResponse | null>(null);
const running = ref(false);
onMounted(async () => { await store.fetchAll(); });
onMounted(async () => {
store.filter.type = undefined;
await store.fetchAll();
});
const selectedModule = computed(() => store.items.find((m) => m.id === moduleId.value));
@@ -32,14 +35,9 @@ async function run() {
if (!mod) return;
running.value = true;
try {
const instance = mod.type === "wildcard"
? { variable_binding: variableBinding.value }
: {};
const instance = mod.type === "wildcard" ? { variable_binding: variableBinding.value } : {};
result.value = await api.test({
type: mod.type,
payload: mod.payload,
instance,
samples: samples.value,
type: mod.type, payload: mod.payload, instance, samples: samples.value,
});
} catch (e) {
toast.add({ severity: "error", summary: "Test failed", detail: String(e), life: 4000 });
@@ -59,20 +57,18 @@ const histogramEntries = computed(() => {
<template>
<div class="p-6 text-wp-text max-w-3xl">
<h1 class="text-xl font-semibold mb-4">Test runner</h1>
<h1 class="text-xl font-semibold m-0">Test runner</h1>
<p class="text-sm text-wp-text2 m-0 mt-1 mb-6">
Run a module against the engine N times, view the resolved-value distribution.
</p>
<div class="grid grid-cols-3 gap-3 mb-4">
<div class="col-span-2">
<label for="tr-module" class="block text-xs text-wp-text2 mb-1">Module</label>
<Select
id="tr-module"
v-model="moduleId"
:options="store.items"
option-label="name"
option-value="id"
placeholder="Select a module…"
filter
class="w-full"
id="tr-module" v-model="moduleId"
:options="store.items" option-label="name" option-value="id"
placeholder="Select a module…" filter class="w-full"
/>
</div>
<div>
@@ -95,14 +91,10 @@ const histogramEntries = computed(() => {
<div v-if="result" class="mt-6">
<h2 class="text-sm uppercase tracking-wider text-wp-text2 mb-2">Histogram</h2>
<div class="space-y-1">
<div
v-for="entry in histogramEntries"
:key="entry.value"
class="flex items-center gap-2 text-sm"
>
<div v-for="entry in histogramEntries" :key="entry.value" class="flex items-center gap-2 text-sm">
<div class="w-48 truncate" :title="entry.value">{{ entry.value }}</div>
<div class="flex-1 h-3 bg-wp-bg2 rounded overflow-hidden">
<div class="h-full bg-wp-accent" :style="{ width: entry.pct + '%' }" />
<div class="h-full" :style="{ width: entry.pct + '%', background: 'var(--wp-accent)' }" />
</div>
<div class="w-12 text-right text-wp-text2">{{ entry.count }}</div>
</div>