feat(spa): community tab — discover/detail/upload/profile + offline/404 (mock)

UI-only mock implementation: simulated mock API with localStorage-backed
auth + install state, ~30 seeded modules across all 6 kinds plus 3 bundles,
and full screens (Discover, Detail, 3-step Upload wizard, Profile, Offline,
404) wired through the router. Topbar grows an API status dot and
GitHub-style user pill that only render on /community/* routes. No backend
work — point is design fidelity for later real-API hookup.
This commit is contained in:
DumiFlex
2026-04-27 07:18:44 +03:00
parent 928e33cc5f
commit f1add58631
20 changed files with 3833 additions and 2 deletions
@@ -0,0 +1,124 @@
/**
* CommunityDiscover.vue tests.
*
* Mocks the mock API at the module boundary so we can drive deterministic
* results, then assert that filter / sort / kind controls forward the right
* query options to the store.
*/
import { mount, flushPromises } from "@vue/test-utils";
import { setActivePinia, createPinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createMemoryHistory, createRouter } from "vue-router";
import PrimeVue from "primevue/config";
import ToastService from "primevue/toastservice";
vi.mock("../community/mockApi", async () => {
const actual = await vi.importActual<typeof import("../community/mockApi")>("../community/mockApi");
return {
...actual,
searchModules: vi.fn(),
getFeatured: vi.fn().mockResolvedValue([]),
getApiStatus: vi.fn().mockResolvedValue("online"),
};
});
import * as mockApi from "../community/mockApi";
import CommunityDiscover from "../views/community/CommunityDiscover.vue";
const seedAtom = (id: string, overrides: Record<string, unknown> = {}) => ({
id,
type: "wildcard",
name: `Module ${id}`,
description: "",
category: "subject",
tags: ["portrait"],
author: { login: "u", avatar_url: "", verified: false },
versions: [{ version: "1.0.0", published_at: new Date().toISOString() }],
stars: 10,
downloads: 100,
rating: 4.5,
rating_count: 5,
rating_dist: [3, 1, 1, 0, 0],
nsfw: false,
engine_min_version: "1.4",
license: "MIT",
hero: "linear-gradient(135deg, #000 0%, #111 100%)",
tagline: `Tagline ${id}`,
readme: "# Hi",
updated_at: new Date().toISOString(),
preview_options: [],
comments: [],
...overrides,
});
beforeEach(() => {
setActivePinia(createPinia());
mockApi._resetForTests();
vi.mocked(mockApi.searchModules).mockReset();
vi.mocked(mockApi.searchModules).mockResolvedValue([]);
vi.mocked(mockApi.getFeatured).mockReset();
vi.mocked(mockApi.getFeatured).mockResolvedValue([]);
vi.mocked(mockApi.getApiStatus).mockReset();
vi.mocked(mockApi.getApiStatus).mockResolvedValue("online");
});
afterEach(() => vi.clearAllMocks());
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", component: { template: "<div/>" } },
{ path: "/community/discover", name: "community-discover", component: { template: "<div/>" } },
{ path: "/community/upload", name: "community-upload", component: { template: "<div/>" } },
{ path: "/community/profile", name: "community-profile", component: { template: "<div/>" } },
{ path: "/community/offline", name: "community-offline", component: { template: "<div/>" } },
{ path: "/community/m/:id", name: "community-detail", component: { template: "<div/>" } },
],
});
}
function mountView() {
return mount(CommunityDiscover, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
}
describe("CommunityDiscover.vue", () => {
it("renders the hero title + Publish button", async () => {
const wrap = mountView();
await flushPromises();
expect(wrap.text()).toContain("Community");
expect(wrap.text()).toContain("Publish");
});
it("renders a card per result", async () => {
vi.mocked(mockApi.searchModules).mockResolvedValue([
seedAtom("a"),
seedAtom("b"),
seedAtom("c"),
] as never);
const wrap = mountView();
await flushPromises();
expect(wrap.findAll(".wp-comm-card").length).toBe(3);
});
it("forwards search query to the API", async () => {
const wrap = mountView();
await flushPromises();
const input = wrap.find('input[aria-label="Search community"]');
await input.setValue("hair");
await flushPromises();
const calls = vi.mocked(mockApi.searchModules).mock.calls;
const last = calls[calls.length - 1][0];
expect(last?.q).toBe("hair");
});
it("renders the empty state when feed is empty", async () => {
vi.mocked(mockApi.searchModules).mockResolvedValue([]);
const wrap = mountView();
await flushPromises();
expect(wrap.text()).toContain("Nothing to show");
});
});
@@ -0,0 +1,137 @@
/**
* CommunityUpload.vue tests.
*
* Verifies wizard step navigation, field-level validation gating, and the
* happy-path submit that lands on the success step.
*/
import { mount, flushPromises } from "@vue/test-utils";
import { setActivePinia, createPinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createMemoryHistory, createRouter } from "vue-router";
import PrimeVue from "primevue/config";
import ToastService from "primevue/toastservice";
vi.mock("../community/mockApi", async () => {
const actual = await vi.importActual<typeof import("../community/mockApi")>("../community/mockApi");
return {
...actual,
uploadModule: vi.fn(),
};
});
import * as mockApi from "../community/mockApi";
import CommunityUpload from "../views/community/CommunityUpload.vue";
import { useCommunityStore } from "../stores/communityStore";
beforeEach(() => {
setActivePinia(createPinia());
mockApi._resetForTests();
vi.mocked(mockApi.uploadModule).mockReset();
});
afterEach(() => vi.clearAllMocks());
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", component: { template: "<div/>" } },
{ path: "/community/discover", name: "community-discover", component: { template: "<div/>" } },
{ path: "/community/upload", name: "community-upload", component: { template: "<div/>" } },
{ path: "/community/m/:id", name: "community-detail", component: { template: "<div/>" } },
],
});
}
function mountView() {
return mount(CommunityUpload, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
}
describe("CommunityUpload.vue", () => {
it("shows sign-in gate when there is no current user", async () => {
const wrap = mountView();
await flushPromises();
expect(wrap.text()).toContain("Sign in to publish");
});
it("step 1 → step 2 → review path", async () => {
const store = useCommunityStore();
await store.signIn();
const wrap = mountView();
await flushPromises();
// Stepper visible at step 1.
expect(wrap.text()).toContain("What are you publishing?");
// Click Next to advance to step 2.
const next1 = wrap.findAll("button").find((b) => b.text().includes("Next"));
expect(next1).toBeTruthy();
await next1!.trigger("click");
await flushPromises();
// Step 2 is rendered.
expect(wrap.text()).toContain("Name *");
expect(wrap.text()).toContain("Tagline *");
// Fill required fields.
await wrap.find('input[aria-label="Module name"]').setValue("Hair Color Pro");
await wrap.find('input[aria-label="Tagline"]').setValue("Best hair colors.");
await wrap.find('input[aria-label="Tag input"]').setValue("portrait");
const addTag = wrap.findAll("button").find((b) => b.text().trim() === "Add");
await addTag!.trigger("click");
await flushPromises();
const review = wrap.findAll("button").find((b) => b.text().includes("Review"));
expect(review).toBeTruthy();
expect(review!.attributes("disabled")).toBeFalsy();
await review!.trigger("click");
await flushPromises();
expect(wrap.text()).toContain("Review before publishing");
});
it("Review → Publish hits the upload API and shows success", async () => {
vi.mocked(mockApi.uploadModule).mockResolvedValue({
id: "wc_new123",
type: "wildcard",
name: "Test",
description: "",
category: "subject",
tags: ["test"],
author: { login: "you", avatar_url: "", verified: false },
versions: [{ version: "1.0.0", published_at: new Date().toISOString() }],
stars: 0, downloads: 0, rating: 0, rating_count: 0,
rating_dist: [0, 0, 0, 0, 0],
nsfw: false,
engine_min_version: "1.4",
license: "MIT",
hero: "linear-gradient(135deg, #000 0%, #111 100%)",
tagline: "x",
readme: "",
updated_at: new Date().toISOString(),
preview_options: [],
comments: [],
});
const store = useCommunityStore();
await store.signIn();
const wrap = mountView();
await flushPromises();
// Drive: Next → fill → Review → Publish
await wrap.findAll("button").find((b) => b.text().includes("Next"))!.trigger("click");
await flushPromises();
await wrap.find('input[aria-label="Module name"]').setValue("Test");
await wrap.find('input[aria-label="Tagline"]').setValue("Tag");
await wrap.find('input[aria-label="Tag input"]').setValue("test");
await wrap.findAll("button").find((b) => b.text().trim() === "Add")!.trigger("click");
await flushPromises();
await wrap.findAll("button").find((b) => b.text().includes("Review"))!.trigger("click");
await flushPromises();
await wrap.findAll("button").find((b) => b.text().includes("Publish"))!.trigger("click");
await flushPromises();
expect(vi.mocked(mockApi.uploadModule)).toHaveBeenCalled();
expect(wrap.text()).toContain("Published!");
});
});
@@ -0,0 +1,109 @@
/**
* Tests for the community Pinia store.
*
* The mock API is the unit under test for state-shape behavior — we exercise
* it directly through the store's actions and assert the localStorage round-
* trip + the in-memory Set updates.
*/
import { setActivePinia, createPinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as mockApi from "../community/mockApi";
import { useCommunityStore } from "../stores/communityStore";
beforeEach(() => {
setActivePinia(createPinia());
mockApi._resetForTests();
mockApi.setForceOffline(false);
});
afterEach(() => {
vi.clearAllMocks();
mockApi._resetForTests();
});
describe("communityStore", () => {
it("starts signed-out with empty install + star sets", () => {
const store = useCommunityStore();
expect(store.currentUser).toBeNull();
expect(store.installed.size).toBe(0);
expect(store.starred.size).toBe(0);
});
it("signIn round-trips through localStorage", async () => {
const store = useCommunityStore();
await store.signIn();
expect(store.currentUser).not.toBeNull();
expect(store.currentUser?.login).toBe("you");
// Read it back through the API to prove it's persisted.
const stored = mockApi.getCurrentUser();
expect(stored?.login).toBe("you");
});
it("signOut clears the user", async () => {
const store = useCommunityStore();
await store.signIn();
await store.signOut();
expect(store.currentUser).toBeNull();
expect(mockApi.getCurrentUser()).toBeNull();
});
it("install adds module id to set + history", async () => {
const store = useCommunityStore();
await store.install("wc_haircolor_pro");
expect(store.installed.has("wc_haircolor_pro")).toBe(true);
expect(store.installHistory[0].id).toBe("wc_haircolor_pro");
});
it("uninstall removes module id from the installed set", async () => {
const store = useCommunityStore();
await store.install("wc_haircolor_pro");
await store.uninstall("wc_haircolor_pro");
expect(store.installed.has("wc_haircolor_pro")).toBe(false);
});
it("toggleStar flips the starred state idempotently", async () => {
const store = useCommunityStore();
await store.toggleStar("wc_haircolor_pro");
expect(store.starred.has("wc_haircolor_pro")).toBe(true);
await store.toggleStar("wc_haircolor_pro");
expect(store.starred.has("wc_haircolor_pro")).toBe(false);
});
it("search populates feed and clears it on offline", async () => {
const store = useCommunityStore();
await store.search({});
expect(store.feed.length).toBeGreaterThan(0);
mockApi.setForceOffline(true);
await store.search({});
expect(store.feed.length).toBe(0);
expect(store.apiStatus).toBe("offline");
});
it("loadFeatured fills the featured ref", async () => {
const store = useCommunityStore();
await store.loadFeatured();
expect(store.featured.length).toBeGreaterThan(0);
});
it("upload appends a module + records it in myUploads", async () => {
const store = useCommunityStore();
await store.signIn();
const result = await store.upload({
atom: "single",
type: "wildcard",
name: "Test Wildcard",
tagline: "Test tagline.",
description: "Test description.",
category: "subject",
license: "MIT",
engine_min_version: "1.4",
nsfw: false,
tags: ["test"],
readme: "# Hi",
version: "1.0.0",
});
expect(result.name).toBe("Test Wildcard");
expect(store.myUploads.has(result.id)).toBe(true);
});
});
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
/**
* Community module / bundle card.
*
* Used by Discover grid, Profile tabs, and the search results. Click opens
* the detail screen; star button is local to the card (delegated to store).
*/
import { computed } from "vue";
import { useRouter } from "vue-router";
import { useCommunityStore } from "../stores/communityStore";
import { engineSatisfies } from "./mockApi";
import { fmtNumber, relativeTime, KIND_ICON, KIND_LABEL } from "./format";
import type { CommunityAtom } from "./types";
const props = defineProps<{ atom: CommunityAtom }>();
const router = useRouter();
const store = useCommunityStore();
const installed = computed(() => store.installed.has(props.atom.id));
const starred = computed(() => store.starred.has(props.atom.id));
const compatible = computed(() =>
engineSatisfies(store.engineVersion, props.atom.engine_min_version || "0"),
);
const kindKey = computed(() =>
props.atom.type === "bundle" ? "bundle" : props.atom.type,
);
function openDetail() {
router.push({ name: "community-detail", params: { id: props.atom.id } });
}
async function onStar(event: Event) {
event.stopPropagation();
if (!store.currentUser) return;
await store.toggleStar(props.atom.id);
}
function onKey(event: KeyboardEvent) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openDetail();
}
}
</script>
<template>
<article
class="wp-comm-card"
role="button"
tabindex="0"
:aria-label="`Open ${atom.name}`"
@click="openDetail"
@keydown="onKey"
>
<div class="wp-comm-card__hero" :style="{ background: atom.hero }">
<span class="wp-comm-card__kind">
<i :class="['pi', KIND_ICON[kindKey]]" aria-hidden="true" />
{{ KIND_LABEL[kindKey] }}
</span>
<span v-if="atom.nsfw" class="wp-comm-card__nsfw">18+</span>
<span
v-if="!compatible"
class="wp-comm-card__incompat"
:title="`Needs engine >= ${atom.engine_min_version}`"
>
engine ≥ {{ atom.engine_min_version }}
</span>
</div>
<div class="wp-comm-card__body">
<div class="wp-comm-card__title-row">
<h3 class="wp-comm-card__title">{{ atom.name }}</h3>
<button
type="button"
class="wp-comm-card__star"
:data-on="starred"
:aria-label="starred ? 'Unstar module' : 'Star module'"
:title="starred ? 'Unstar' : 'Star'"
@click="onStar"
>
<i :class="['pi', starred ? 'pi-star-fill' : 'pi-star']" aria-hidden="true" />
</button>
</div>
<p class="wp-comm-card__tagline">{{ atom.tagline }}</p>
<div class="wp-comm-card__author">
<span
class="wp-comm-card__avatar"
:style="{ backgroundImage: `url(${atom.author.avatar_url})` }"
aria-hidden="true"
/>
<span>@{{ atom.author.login }}</span>
<i v-if="atom.author.verified" class="pi pi-verified" aria-hidden="true" />
<span> · {{ relativeTime(atom.updated_at) }}</span>
</div>
<div class="wp-comm-card__stats">
<span title="Downloads"><i class="pi pi-download" />{{ fmtNumber(atom.downloads) }}</span>
<span title="Stars"><i class="pi pi-star-fill" />{{ fmtNumber(atom.stars) }}</span>
<span title="Rating"><i class="pi pi-star" />{{ atom.rating.toFixed(1) }}</span>
</div>
<div v-if="atom.tags.length" class="wp-comm-card__tags">
<span v-for="tag in atom.tags.slice(0, 4)" :key="tag" class="wp-comm-card__tag">
{{ tag }}
</span>
</div>
<div v-if="installed" class="wp-comm-card__installed">
<i class="pi pi-check-circle" aria-hidden="true" /> Installed
</div>
</div>
</article>
</template>
<style scoped>
@import "./community.css";
</style>
+597
View File
@@ -0,0 +1,597 @@
/* ============================================================
* Community tab — shared component styles.
*
* Ported from docs/design-handoff/wildcardpipeline/project/styles-community.css.
* Loaded via CommunityDiscover.vue which @imports this file at component scope.
* Kept in one place because the same primitives (cards, hero, chips) repeat
* across Discover / Detail / Profile / Upload.
* ============================================================ */
.wp-comm-page {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
color: var(--wp-text);
}
.wp-comm-page__header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
flex-wrap: wrap;
}
.wp-comm-page__title { font-size: 22px; margin: 0; letter-spacing: -0.01em; }
.wp-comm-page__subtitle { font-size: 13px; color: var(--wp-text-muted); margin: 4px 0 0; max-width: 720px; }
.wp-comm-page__actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* ---------- Filter bar ---------- */
.wp-comm-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
background: var(--wp-bg-1);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
padding: 10px;
}
.wp-comm-filters__search { flex: 1; min-width: 220px; }
.wp-comm-filters__toggle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--wp-text-muted);
padding: 4px 8px;
border-radius: 999px;
background: var(--wp-bg-3);
user-select: none;
}
.wp-comm-filters__toggle--nsfw { color: var(--wp-warn); }
/* ---------- Tag rail ---------- */
.wp-comm-tagrail {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 4px 0;
}
.wp-comm-chip-btn {
font-size: 11px;
padding: 4px 10px;
border-radius: 999px;
background: var(--wp-bg-2);
border: 1px solid var(--wp-border);
color: var(--wp-text-muted);
cursor: pointer;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.wp-comm-chip-btn:hover {
background: var(--wp-bg-3);
color: var(--wp-text);
}
.wp-comm-chip-btn[data-active="true"] {
background: color-mix(in oklab, var(--wp-accent-500) 22%, transparent);
border-color: color-mix(in oklab, var(--wp-accent-500) 32%, transparent);
color: var(--wp-accent-text);
}
/* ---------- Featured row ---------- */
.wp-comm-featured {
display: flex;
flex-direction: column;
gap: 10px;
background: var(--wp-bg-1);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-lg);
padding: 14px;
}
.wp-comm-featured__head {
display: flex;
justify-content: space-between;
align-items: center;
}
.wp-comm-featured__label {
display: inline-flex; gap: 8px; align-items: center;
margin: 0; font-size: 13px; font-weight: 600;
color: var(--wp-text);
}
.wp-comm-featured__hint {
font-size: 11px;
color: var(--wp-text-muted);
}
.wp-comm-featured__row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
@media (max-width: 1024px) { .wp-comm-featured__row { grid-template-columns: 1fr 1fr; } }
@media (max-width: 640px) { .wp-comm-featured__row { grid-template-columns: 1fr; } }
.wp-comm-feat {
position: relative;
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
overflow: hidden;
text-align: left;
cursor: pointer;
min-height: 160px;
display: flex;
align-items: stretch;
color: #fff;
font: inherit;
padding: 0;
}
.wp-comm-feat:hover { transform: translateY(-1px); }
.wp-comm-feat__inner {
display: flex;
flex-direction: column;
gap: 6px;
background: linear-gradient(180deg, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.45) 100%);
padding: 14px;
width: 100%;
}
.wp-comm-feat h3 { margin: 0; font-size: 16px; }
.wp-comm-feat p {
margin: 0;
font-size: 12px;
color: rgba(255,255,255,0.86);
}
.wp-comm-feat__stats {
margin-top: auto;
display: flex;
gap: 12px;
font-size: 11px;
color: rgba(255,255,255,0.92);
align-items: center;
}
.wp-comm-feat__stats i { margin-right: 4px; }
/* ---------- Card grid ---------- */
.wp-comm-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
@media (max-width: 1024px) { .wp-comm-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 640px) { .wp-comm-grid { grid-template-columns: 1fr; } }
.wp-comm-card {
display: flex;
flex-direction: column;
background: var(--wp-bg-1);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
overflow: hidden;
cursor: pointer;
text-align: left;
font: inherit;
color: var(--wp-text);
padding: 0;
transition: border-color 0.12s, transform 0.12s;
}
.wp-comm-card:hover { border-color: var(--wp-border-strong); transform: translateY(-1px); }
.wp-comm-card:focus-visible {
outline: 2px solid var(--wp-border-focus);
outline-offset: 2px;
}
.wp-comm-card__hero {
height: 84px;
position: relative;
}
.wp-comm-card__kind {
position: absolute; top: 8px; left: 8px;
display: inline-flex; gap: 4px; align-items: center;
font-size: 11px;
background: rgba(0,0,0,0.42);
color: #fff;
border-radius: 999px;
padding: 2px 8px;
}
.wp-comm-card__nsfw {
position: absolute; top: 8px; right: 8px;
font-size: 10px; font-weight: 600;
background: var(--wp-warn);
color: #1c1c28;
border-radius: 999px;
padding: 1px 6px;
}
.wp-comm-card__incompat {
position: absolute; bottom: 8px; right: 8px;
font-size: 10px; font-weight: 600;
background: rgba(239, 68, 68, 0.78);
color: #fff;
border-radius: 999px;
padding: 1px 6px;
}
.wp-comm-card__body {
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.wp-comm-card__title-row {
display: flex;
justify-content: space-between;
gap: 8px;
}
.wp-comm-card__title {
margin: 0;
font-size: 14px;
font-weight: 600;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wp-comm-card__star {
background: none;
border: none;
color: var(--wp-text-dim);
cursor: pointer;
padding: 0;
}
.wp-comm-card__star[data-on="true"] { color: #fbbf24; }
.wp-comm-card__tagline {
margin: 0;
font-size: 12px;
color: var(--wp-text-muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.wp-comm-card__author {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--wp-text-muted);
}
.wp-comm-card__avatar {
width: 18px; height: 18px;
border-radius: 999px;
background: var(--wp-bg-3);
display: inline-block;
background-size: cover; background-position: center;
}
.wp-comm-card__stats {
display: flex;
gap: 12px;
font-size: 11px;
color: var(--wp-text-muted);
margin-top: 2px;
}
.wp-comm-card__stats i { margin-right: 4px; }
.wp-comm-card__tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.wp-comm-card__tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 999px;
background: var(--wp-bg-3);
color: var(--wp-text-muted);
}
.wp-comm-card__installed {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--wp-success);
}
/* ---------- Detail ---------- */
.wp-comm-detail-hero {
border-radius: var(--wp-radius-lg);
padding: 18px;
color: #fff;
position: relative;
overflow: hidden;
}
.wp-comm-detail-hero::before {
content: "";
position: absolute; inset: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.5) 100%);
}
.wp-comm-detail-hero__inner {
position: relative;
display: flex;
gap: 12px;
align-items: flex-start;
flex-wrap: wrap;
}
.wp-comm-detail-hero__title {
font-size: 22px;
margin: 6px 0 4px;
letter-spacing: -0.01em;
}
.wp-comm-detail-hero__tagline {
font-size: 13px;
margin: 0;
color: rgba(255,255,255,0.86);
max-width: 720px;
}
.wp-comm-detail-hero__meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 11px;
color: rgba(255,255,255,0.78);
margin-top: 10px;
align-items: center;
}
.wp-comm-detail-hero__author {
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 999px;
padding: 2px 8px;
display: inline-flex;
align-items: center;
gap: 4px;
color: #fff;
font: inherit;
font-size: 11px;
cursor: pointer;
}
.wp-comm-detail-hero__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-left: auto;
}
.wp-comm-detail-stats {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
background: var(--wp-bg-1);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
padding: 12px;
}
@media (max-width: 1024px) { .wp-comm-detail-stats { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 640px) { .wp-comm-detail-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.wp-comm-detail-stats > div {
display: flex; flex-direction: column; gap: 2px;
font-size: 12px; color: var(--wp-text-muted);
}
.wp-comm-detail-stats > div strong {
color: var(--wp-text);
font-size: 14px;
font-weight: 600;
}
.wp-comm-tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--wp-border);
flex-wrap: wrap;
}
.wp-comm-tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
padding: 8px 14px;
font-size: 13px;
color: var(--wp-text-muted);
cursor: pointer;
font: inherit;
}
.wp-comm-tab[data-active="true"] {
color: var(--wp-accent-text);
border-bottom-color: var(--wp-accent-500);
}
.wp-comm-tabbody { padding: 12px 0; }
.wp-comm-readme {
background: var(--wp-bg-1);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
padding: 16px 18px;
line-height: 1.55;
color: var(--wp-text);
}
.wp-comm-readme h2, .wp-comm-readme h3 { margin: 12px 0 6px; }
.wp-comm-readme code {
font-family: var(--wp-font-mono);
background: var(--wp-bg-3);
padding: 1px 4px;
border-radius: 3px;
font-size: 12px;
}
.wp-comm-readme .wp-mention { color: var(--wp-accent-text); }
.wp-comm-readme p, .wp-comm-readme ul { margin: 6px 0; }
.wp-comm-readme .wp-md-spacer { height: 6px; }
.wp-comm-versions { display: flex; flex-direction: column; gap: 8px; }
.wp-comm-version {
background: var(--wp-bg-1);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
padding: 12px 14px;
}
.wp-comm-version[data-active="true"] { border-color: var(--wp-accent-500); }
.wp-comm-version__head {
display: flex; justify-content: space-between;
background: none; border: none; padding: 0; font: inherit; color: inherit;
cursor: pointer; width: 100%;
}
.wp-comm-version__date { color: var(--wp-text-muted); font-size: 12px; }
.wp-comm-version__notes { margin: 6px 0 0; font-size: 13px; color: var(--wp-text-muted); }
/* ---------- Reviews ---------- */
.wp-comm-reviews { display: flex; flex-direction: column; gap: 12px; }
.wp-comm-reviews__summary {
display: grid;
grid-template-columns: 200px 1fr;
gap: 16px;
background: var(--wp-bg-1);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
padding: 16px;
}
@media (max-width: 640px) { .wp-comm-reviews__summary { grid-template-columns: 1fr; } }
.wp-comm-reviews__big-num {
font-size: 36px; font-weight: 600; line-height: 1.1;
}
.wp-comm-reviews__bars { display: flex; flex-direction: column; gap: 4px; font-size: 11px; }
.wp-comm-reviews__bar { display: flex; align-items: center; gap: 8px; }
.wp-comm-reviews__bar-track {
flex: 1;
height: 6px;
background: var(--wp-bg-3);
border-radius: 999px;
overflow: hidden;
}
.wp-comm-reviews__bar-track > div {
height: 100%;
background: var(--wp-accent-500);
}
.wp-comm-review {
background: var(--wp-bg-1);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
padding: 12px 14px;
}
.wp-comm-review__head {
display: flex; align-items: center; gap: 8px;
font-size: 12px; color: var(--wp-text-muted);
}
.wp-comm-review__head strong { color: var(--wp-text); font-size: 13px; }
.wp-comm-review__text { margin: 6px 0 0; font-size: 13px; }
/* ---------- Stepper ---------- */
.wp-comm-stepper {
display: flex;
gap: 12px;
align-items: center;
background: var(--wp-bg-1);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
padding: 10px 14px;
}
.wp-comm-step {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--wp-text-muted);
}
.wp-comm-step__num {
width: 22px; height: 22px;
border-radius: 999px;
background: var(--wp-bg-3);
display: inline-flex; align-items: center; justify-content: center;
font-size: 12px;
color: var(--wp-text-muted);
}
.wp-comm-step[data-active="true"] { color: var(--wp-text); }
.wp-comm-step[data-active="true"] .wp-comm-step__num {
background: var(--wp-accent-500);
color: #fff;
}
.wp-comm-step[data-done="true"] .wp-comm-step__num {
background: var(--wp-success);
color: #0b0b12;
}
/* ---------- Atom picker ---------- */
.wp-comm-atom-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
@media (max-width: 640px) { .wp-comm-atom-grid { grid-template-columns: 1fr; } }
.wp-comm-atom {
display: flex;
flex-direction: column;
gap: 4px;
background: var(--wp-bg-1);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
padding: 14px;
text-align: left;
cursor: pointer;
font: inherit;
color: var(--wp-text);
}
.wp-comm-atom strong { font-size: 13px; }
.wp-comm-atom span { font-size: 12px; color: var(--wp-text-muted); }
.wp-comm-atom[data-active="true"] { border-color: var(--wp-accent-500); }
/* ---------- Profile hero ---------- */
.wp-comm-profile-hero {
display: flex;
align-items: center;
gap: 16px;
background: var(--wp-bg-1);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-lg);
padding: 16px;
}
.wp-comm-profile-avatar {
width: 64px; height: 64px;
border-radius: 999px;
background: var(--wp-bg-3);
background-size: cover; background-position: center;
flex-shrink: 0;
}
.wp-comm-profile-stats {
display: flex;
gap: 18px;
margin-top: 8px;
font-size: 12px;
color: var(--wp-text-muted);
}
.wp-comm-profile-stats strong {
display: block;
color: var(--wp-text);
font-size: 16px;
font-weight: 600;
}
/* ---------- Empty / 404 ---------- */
.wp-comm-empty {
text-align: center;
padding: 48px 16px;
color: var(--wp-text-muted);
background: var(--wp-bg-1);
border: 1px dashed var(--wp-border);
border-radius: var(--wp-radius);
}
.wp-comm-empty h3 { margin: 8px 0 4px; color: var(--wp-text); font-size: 15px; }
.wp-comm-empty p { margin: 0; font-size: 12.5px; }
.wp-comm-empty__icon { font-size: 32px; color: var(--wp-text-dim); }
/* ---------- Histogram ---------- */
.wp-comm-histo-row {
display: grid;
grid-template-columns: minmax(120px, 1fr) 2fr 36px;
gap: 8px;
align-items: center;
font-size: 12px;
padding: 4px 0;
}
.wp-comm-histo-row__bar {
height: 8px;
background: var(--wp-bg-3);
border-radius: 999px;
overflow: hidden;
}
.wp-comm-histo-row__bar > div {
height: 100%;
background: var(--wp-accent-500);
}
.wp-comm-histo-row__pct {
text-align: right;
color: var(--wp-text-muted);
}
.wp-comm-histo-row__val {
font-family: var(--wp-font-mono);
font-size: 11.5px;
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Tiny formatting helpers shared by the Community screens.
*
* Mirrors `fmtNum` + `relTime` + `KIND_*` from the React prototype so the
* card / detail / featured strip read the same numbers and labels.
*/
export function fmtNumber(value: number): string {
if (!Number.isFinite(value)) return "0";
if (value < 1000) return String(value);
const div = value / 1000;
const decimals = value >= 10_000 ? 0 : 1;
return `${div.toFixed(decimals).replace(/\.0$/, "")}k`;
}
/** Pretty-print an ISO timestamp as "5m ago" / "3d ago" / etc. */
export function relativeTime(iso: string): string {
const t = Date.parse(iso);
if (!Number.isFinite(t)) return "";
const diff = Date.now() - t;
const m = Math.floor(diff / 60_000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 30) return `${d}d ago`;
const mo = Math.floor(d / 30);
if (mo < 12) return `${mo}mo ago`;
return `${Math.floor(mo / 12)}y ago`;
}
export const KIND_LABEL: Record<string, string> = {
wildcard: "Wildcard",
fixed_values: "Fixed Values",
combine: "Combine",
derivation: "Derivation",
constraint: "Constraint",
pipeline: "Pipeline",
bundle: "Pack",
};
export const KIND_ICON: Record<string, string> = {
wildcard: "pi-th-large",
fixed_values: "pi-tag",
combine: "pi-share-alt",
derivation: "pi-code",
constraint: "pi-sitemap",
pipeline: "pi-list",
bundle: "pi-box",
};
+46
View File
@@ -0,0 +1,46 @@
/**
* Tiny markdown-ish renderer used by the Community detail README tab.
*
* The Community tab is mock-only and we don't ship `marked`, so this
* mirrors the renderer the React prototype uses (community-data.jsx).
* Handles: H1/H2, lists, **bold**, `code`, and naive @mention coloring.
*
* Output is escaped before any inline rule runs, so the only HTML that
* ever leaves this function is what we generate ourselves.
*/
export function renderMarkdown(src: string): string {
const lines = String(src ?? "").split("\n");
const out: string[] = [];
let inList = false;
const flushList = () => {
if (inList) {
out.push("</ul>");
inList = false;
}
};
const inline = (text: string): string =>
text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/(\B@[a-z0-9_.\-]+)/gi, '<span class="wp-mention">$1</span>');
for (const ln of lines) {
if (/^# /.test(ln)) { flushList(); out.push(`<h2>${inline(ln.slice(2))}</h2>`); continue; }
if (/^## /.test(ln)) { flushList(); out.push(`<h3>${inline(ln.slice(3))}</h3>`); continue; }
if (/^- /.test(ln)) {
if (!inList) { out.push("<ul>"); inList = true; }
out.push(`<li>${inline(ln.slice(2))}</li>`);
continue;
}
if (!ln.trim()) { flushList(); out.push('<p class="wp-md-spacer"></p>'); continue; }
flushList();
out.push(`<p>${inline(ln)}</p>`);
}
flushList();
return out.join("");
}
+342
View File
@@ -0,0 +1,342 @@
/**
* Mock community API.
*
* UI-only — there is no real backend. Each function returns a Promise with
* a small artificial latency so the UI gets to render real loading /
* skeleton states. State (auth, install history, stars) is persisted to
* localStorage so it survives reload.
*/
import {
SEED_ATOMS,
SEED_AUTHORS,
SEED_FEATURED_IDS,
SEED_TAGS,
} from "./seed";
import type {
CommunityApiStatus,
CommunityAtom,
CommunityInstallEntry,
CommunityKind,
CommunityModule,
CommunitySearchQuery,
CommunityUploadPayload,
CommunityUser,
} from "./types";
const STORAGE_KEYS = {
user: "wp-community-user",
installed: "wp-community-installed",
history: "wp-community-history",
starred: "wp-community-starred",
uploads: "wp-community-uploads",
forceOffline: "wp-community-force-offline",
};
const DEFAULT_LATENCY = 250;
/** In-memory copy of the mock catalog — uploads from the wizard append here. */
const catalog: CommunityAtom[] = SEED_ATOMS.slice();
function delay(ms = DEFAULT_LATENCY) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
function readJson<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
function writeJson<T>(key: string, value: T) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
/* no-op — quota / private mode */
}
}
function readSet(key: string): string[] {
return readJson<string[]>(key, []);
}
/* ----------------------------- offline flag ----------------------------- */
export function isForceOffline(): boolean {
return readJson<boolean>(STORAGE_KEYS.forceOffline, false);
}
export function setForceOffline(value: boolean): void {
writeJson(STORAGE_KEYS.forceOffline, value);
}
/* ----------------------------- API status ------------------------------- */
export async function getApiStatus(): Promise<CommunityApiStatus> {
await delay(120);
return isForceOffline() ? "offline" : "online";
}
/* --------------------------------- auth -------------------------------- */
export function getCurrentUser(): CommunityUser | null {
return readJson<CommunityUser | null>(STORAGE_KEYS.user, null);
}
export async function signInWithGitHub(): Promise<CommunityUser> {
await delay(400);
if (isForceOffline()) throw new Error("offline");
// Mocked GitHub OAuth response — real flow would round-trip a popup window.
const user: CommunityUser = {
login: "you",
name: "You",
avatar_url: "https://avatars.githubusercontent.com/u/0?v=4",
verified: false,
};
writeJson(STORAGE_KEYS.user, user);
return user;
}
export async function signOut(): Promise<void> {
await delay(120);
try {
localStorage.removeItem(STORAGE_KEYS.user);
} catch {
/* no-op */
}
}
/* --------------------------- catalog queries --------------------------- */
function compareVersion(have: string, need: string): number {
const a = have.split(".").map((n) => parseInt(n, 10) || 0);
const b = need.split(".").map((n) => parseInt(n, 10) || 0);
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const x = a[i] || 0;
const y = b[i] || 0;
if (x !== y) return x - y;
}
return 0;
}
function engineSatisfies(have: string, need: string): boolean {
return compareVersion(have, need) >= 0;
}
export async function searchModules(query: CommunitySearchQuery = {}): Promise<CommunityAtom[]> {
await delay(180);
if (isForceOffline()) throw new Error("offline");
const q = (query.q || "").trim().toLowerCase();
const verified = !!query.verified_only;
const includeNsfw = !!query.include_nsfw;
const compatibleOnly = !!query.compatible_only;
const engine = query.engine || "1.4";
const sort = query.sort || "trending";
let items = catalog.slice();
if (query.kind && query.kind !== "all") {
if (query.kind === "bundle") items = items.filter((it) => it.type === "bundle");
else items = items.filter((it) => it.type === query.kind);
}
if (query.tag) {
items = items.filter((it) => it.tags.includes(query.tag as string));
}
if (q) {
items = items.filter((it) => {
const hay = `${it.name} ${it.tagline} ${it.tags.join(" ")} ${it.author.login}`.toLowerCase();
return hay.includes(q);
});
}
if (verified) items = items.filter((it) => it.author.verified);
if (!includeNsfw) items = items.filter((it) => !it.nsfw);
if (compatibleOnly) {
items = items.filter((it) => engineSatisfies(engine, it.engine_min_version || "0"));
}
items.sort((a, b) => {
const at = Date.parse(a.updated_at) || 0;
const bt = Date.parse(b.updated_at) || 0;
if (sort === "stars") return b.stars - a.stars;
if (sort === "downloads") return b.downloads - a.downloads;
if (sort === "recent") return bt - at;
if (sort === "rating") return b.rating - a.rating;
// trending = blend of recent updates × stars
const aScore = a.stars * 0.6 + (1e10 / Math.max(1, Date.now() - at)) * 0.4;
const bScore = b.stars * 0.6 + (1e10 / Math.max(1, Date.now() - bt)) * 0.4;
return bScore - aScore;
});
return items;
}
export async function getModule(id: string): Promise<CommunityAtom> {
await delay(220);
if (isForceOffline()) throw new Error("offline");
const found = catalog.find((it) => it.id === id);
if (!found) {
const err = new Error("not_found") as Error & { code?: number };
err.code = 404;
throw err;
}
return found;
}
export async function getFeatured(): Promise<CommunityAtom[]> {
await delay(150);
if (isForceOffline()) throw new Error("offline");
return SEED_FEATURED_IDS
.map((id) => catalog.find((it) => it.id === id))
.filter((it): it is CommunityAtom => Boolean(it));
}
export function getAllTags(): string[] {
return SEED_TAGS.slice();
}
export function getAllAuthors(): CommunityUser[] {
return SEED_AUTHORS.slice();
}
/* --------------------------- install + stars --------------------------- */
export function getInstalled(): string[] {
return readSet(STORAGE_KEYS.installed);
}
export function getInstallHistory(): CommunityInstallEntry[] {
return readJson<CommunityInstallEntry[]>(STORAGE_KEYS.history, []);
}
export async function installModule(id: string): Promise<void> {
await delay(450);
if (isForceOffline()) throw new Error("offline");
const installed = new Set(getInstalled());
installed.add(id);
writeJson(STORAGE_KEYS.installed, Array.from(installed));
const history = getInstallHistory().filter((h) => h.id !== id);
history.unshift({ id, at: new Date().toISOString() });
writeJson(STORAGE_KEYS.history, history.slice(0, 50));
}
export async function uninstallModule(id: string): Promise<void> {
await delay(250);
if (isForceOffline()) throw new Error("offline");
const installed = getInstalled().filter((x) => x !== id);
writeJson(STORAGE_KEYS.installed, installed);
}
export function getStarred(): string[] {
return readSet(STORAGE_KEYS.starred);
}
export async function starModule(id: string, on: boolean): Promise<void> {
await delay(180);
if (isForceOffline()) throw new Error("offline");
const set = new Set(getStarred());
if (on) set.add(id);
else set.delete(id);
writeJson(STORAGE_KEYS.starred, Array.from(set));
}
/* ------------------------------- uploads ------------------------------- */
export function getMyUploads(): string[] {
return readSet(STORAGE_KEYS.uploads);
}
function makeId(prefix: string): string {
const rand = Math.random().toString(36).slice(2, 8);
return `${prefix}_${rand}`;
}
export async function uploadModule(payload: CommunityUploadPayload): Promise<CommunityModule> {
await delay(800);
if (isForceOffline()) throw new Error("offline");
const user = getCurrentUser() ?? {
login: "you",
avatar_url: "https://avatars.githubusercontent.com/u/0?v=4",
verified: false,
};
const id = makeId(payload.atom === "bundle" ? "pkg" : kindPrefix(payload.type));
const now = new Date().toISOString();
const module: CommunityModule = {
id,
type: payload.type,
name: payload.name,
description: payload.description,
category: payload.category,
tags: payload.tags,
author: user,
versions: [{ version: payload.version, published_at: now, changelog: "Initial release." }],
stars: 0,
downloads: 0,
rating: 0,
rating_count: 0,
rating_dist: [0, 0, 0, 0, 0],
nsfw: payload.nsfw,
engine_min_version: payload.engine_min_version,
license: payload.license,
hero: "linear-gradient(135deg, var(--wp-accent-600) 0%, var(--wp-accent-800) 100%)",
tagline: payload.tagline,
readme: payload.readme,
updated_at: now,
preview_options: [],
comments: [],
};
catalog.unshift(module);
const uploads = new Set(getMyUploads());
uploads.add(id);
writeJson(STORAGE_KEYS.uploads, Array.from(uploads));
return module;
}
function kindPrefix(kind: CommunityKind): string {
switch (kind) {
case "wildcard": return "wc";
case "fixed_values": return "fv";
case "combine": return "cmb";
case "derivation": return "drv";
case "constraint": return "cn";
case "pipeline": return "pl";
default: return "mod";
}
}
/* ------------------------ catalog inspection helpers ------------------- */
/** Unsafe: bypasses the network. Used by the profile screen to resolve ids
* back to atoms without the artificial latency. */
export function findInCatalog(id: string): CommunityAtom | undefined {
return catalog.find((it) => it.id === id);
}
/** For tests: reset all client-side state. */
export function _resetForTests(): void {
Object.values(STORAGE_KEYS).forEach((key) => {
try { localStorage.removeItem(key); } catch { /* no-op */ }
});
}
/** For tests / SSR: read-only access to the seeded catalog. */
export function _catalogSnapshot(): CommunityAtom[] {
return catalog.slice();
}
/** Convenience guard: bundle vs module. */
export function isBundle(atom: CommunityAtom): atom is Extract<CommunityAtom, { type: "bundle" }> {
return atom.type === "bundle";
}
/** Convenience guard: module (not bundle). */
export function isModule(atom: CommunityAtom): atom is CommunityModule {
return atom.type !== "bundle";
}
export { engineSatisfies };
+561
View File
@@ -0,0 +1,561 @@
/**
* Seed data for the Community tab.
*
* UI-only mock — these entries simulate ~30 modules + a handful of bundles
* spanning every kind. Authors, hero gradients, and counts are hand-tuned
* so the screens look believable without a real registry backend.
*/
import type {
CommunityAtom,
CommunityBundle,
CommunityComment,
CommunityKind,
CommunityModule,
CommunityUser,
CommunityVersion,
} from "./types";
const NOW = Date.now();
const day = (n: number) => new Date(NOW - n * 86_400_000).toISOString();
const hr = (n: number) => new Date(NOW - n * 3_600_000).toISOString();
export const SEED_AUTHORS: CommunityUser[] = [
{ login: "anya-vox", name: "Anya V.", avatar_url: "https://avatars.githubusercontent.com/u/1?v=4", verified: true },
{ login: "kaito.studio",name: "Kaito Studio", avatar_url: "https://avatars.githubusercontent.com/u/2?v=4", verified: true },
{ login: "marlow", name: "Sam Marlow", avatar_url: "https://avatars.githubusercontent.com/u/3?v=4", verified: false },
{ login: "mira.lab", name: "Mira Lab", avatar_url: "https://avatars.githubusercontent.com/u/4?v=4", verified: true },
{ login: "pixelnaut", name: "pixelnaut", avatar_url: "https://avatars.githubusercontent.com/u/5?v=4", verified: false },
{ login: "penumbra-co", name: "Penumbra Co.", avatar_url: "https://avatars.githubusercontent.com/u/6?v=4", verified: true },
{ login: "lila", name: "Lila Tran", avatar_url: "https://avatars.githubusercontent.com/u/7?v=4", verified: false },
{ login: "dax-prompts", name: "Dax", avatar_url: "https://avatars.githubusercontent.com/u/8?v=4", verified: true },
];
const a = (login: string): CommunityUser =>
SEED_AUTHORS.find((u) => u.login === login)!;
export const SEED_TAGS = [
"portrait", "scene", "lighting", "cinematic", "studio",
"nsfw", "anime", "photoreal", "concept-art", "fashion",
"weather", "post-process", "negative", "fantasy", "sci-fi",
"ghibli", "noir", "vibrant", "pastel", "monochrome",
];
/** Reasonable rating-distribution skew for a given mean — keeps math simple. */
function ratingDist(mean: number, total: number): number[] {
const w =
mean >= 4.5 ? [0.72, 0.21, 0.05, 0.015, 0.005] :
mean >= 4.0 ? [0.55, 0.30, 0.10, 0.03, 0.02] :
[0.35, 0.32, 0.20, 0.08, 0.05];
const dist = w.map((p) => Math.floor(p * total));
const drift = total - dist.reduce((s, n) => s + n, 0);
dist[0] += drift;
return dist;
}
function versions(latest: string, count: number): CommunityVersion[] {
const out: CommunityVersion[] = [];
for (let i = 0; i < Math.min(count, 4); i++) {
out.push({
version: i === 0 ? latest : `${parseFloat(latest) - 0.1 * (i + 1)}`.slice(0, 5) + ".0",
published_at: day(2 + i * 30),
changelog: i === 0
? "Latest release — added 4 options, fixed weight rebalance."
: "Maintenance release.",
});
}
return out;
}
const COMMENT_POOL: Omit<CommunityComment, "id">[] = [
{ user: a("lila"), body: "Drops in cleanly with the 1.4 engine — best I've tried.", at: hr(4), rating: 5 },
{ user: a("marlow"), body: "Solid. Wish there were more sub-categories for warm tones.", at: hr(20), rating: 4 },
{ user: a("pixelnaut"), body: "Stars deserved. README walks through pipeline integration.", at: day(2), rating: 5 },
{ user: a("dax-prompts"), body: "Mixed — some options feel redundant. Trim next version?", at: day(5), rating: 3 },
{ user: a("anya-vox"), body: "Beautiful curation. Pairs with my Cinematic pack.", at: day(7), rating: 5 },
];
function seededComments(seed: number): CommunityComment[] {
const n = 2 + (seed % 3);
return COMMENT_POOL.slice(0, n).map((c, i) => ({ ...c, id: `cm_${seed}_${i}` }));
}
interface ModuleSeed {
id: string;
type: CommunityKind;
name: string;
tagline: string;
description: string;
category: string;
tags: string[];
author: CommunityUser;
stars: number;
downloads: number;
rating: number;
rating_count: number;
nsfw?: boolean;
license?: string;
engine_min_version?: string;
hero: string;
preview: string[];
updated: string;
latest_version: string;
version_count: number;
}
const MODULE_SEEDS: ModuleSeed[] = [
{
id: "wc_haircolor_pro", type: "wildcard", name: "Hair Color Pro",
tagline: "120 hand-tuned hair colors with sub-categories.",
description: "A meticulously curated wildcard covering warm, cool, and neutral hair tones.",
category: "subject", tags: ["portrait", "fashion", "vibrant"],
author: a("anya-vox"), stars: 612, downloads: 9810, rating: 4.7, rating_count: 188,
hero: "linear-gradient(135deg, #b91c1c 0%, #facc15 100%)",
preview: ["auburn", "chestnut", "platinum blonde", "raven black", "ash brown", "rose gold", "honey blonde"],
updated: hr(11), latest_version: "1.3.2", version_count: 12,
},
{
id: "wc_weather_xl", type: "wildcard", name: "Weather XL",
tagline: "30 weather variants with humidity and temperature tags.",
description: "Drop-in weather wildcard tuned for photoreal and concept-art pipelines.",
category: "weather", tags: ["weather", "scene", "photoreal"],
author: a("mira.lab"), stars: 482, downloads: 7140, rating: 4.6, rating_count: 142,
hero: "linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%)",
preview: ["light rain", "thunderstorm", "dense fog", "snow flurries", "summer haze", "golden hour mist"],
updated: hr(38), latest_version: "0.9.4", version_count: 6,
},
{
id: "wc_lighting_neon", type: "wildcard", name: "Neon Lighting Mood",
tagline: "Cyberpunk-friendly lighting wildcard — 22 options.",
description: "Magenta, cyan, ultraviolet — all carefully balanced for late-night scenes.",
category: "lighting", tags: ["cinematic", "vibrant", "sci-fi", "concept-art"],
author: a("dax-prompts"), stars: 318, downloads: 5390, rating: 4.5, rating_count: 96,
hero: "linear-gradient(135deg, #ec4899 0%, #6366f1 50%, #06b6d4 100%)",
preview: ["magenta backlight", "cyan rim light", "ultraviolet wash", "split-tone neon", "chromatic flare"],
updated: day(1), latest_version: "1.1.0", version_count: 4,
},
{
id: "wc_pose_dramatic", type: "wildcard", name: "Dramatic Poses",
tagline: "Twelve film-grade dramatic poses for editorial.",
description: "Curated by editorial photographers; weighted for natural mid-stride feel.",
category: "subject", tags: ["fashion", "cinematic"],
author: a("kaito.studio"), stars: 142, downloads: 2840, rating: 4.4, rating_count: 38,
hero: "linear-gradient(135deg, #312e81 0%, #db2777 100%)",
preview: ["windswept profile", "over-shoulder glance", "mid-stride confident"],
updated: day(9), latest_version: "1.0.2", version_count: 2,
},
{
id: "wc_nsfw_lingerie", type: "wildcard", name: "Lingerie Editorial",
tagline: "Editorial-style lingerie wardrobe wildcard. 18+.",
description: "Editorial-only lingerie wardrobe. NSFW gated by registry policy.",
category: "outfit", tags: ["fashion", "nsfw"], nsfw: true, license: "Proprietary",
author: a("dax-prompts"), stars: 92, downloads: 1280, rating: 4.1, rating_count: 24,
hero: "linear-gradient(135deg, #831843 0%, #1f1f1f 100%)",
preview: ["[hidden — enable NSFW to preview]"],
updated: day(6), latest_version: "0.5.0", version_count: 1,
},
{
id: "wc_anime_subjects", type: "wildcard", name: "Anime Subjects",
tagline: "60 anime archetypes with classic stat-block tags.",
description: "Cel-shaded archetypes that mix cleanly with Ghibli and cyberpunk packs.",
category: "subject", tags: ["anime", "concept-art"],
author: a("pixelnaut"), stars: 246, downloads: 3210, rating: 4.4, rating_count: 70,
hero: "linear-gradient(135deg, #f97316 0%, #fb7185 100%)",
preview: ["mecha pilot", "magical girl", "shounen rival", "side-character cook"],
updated: day(3), latest_version: "0.7.1", version_count: 3,
},
{
id: "wc_outfits_streetwear", type: "wildcard", name: "Streetwear Outfits",
tagline: "Modern streetwear — 80 looks across 5 sub-styles.",
description: "Tuned for editorial / fashion shoots. Pairs nicely with Studio Photo Pack.",
category: "outfit", tags: ["fashion", "studio"],
author: a("lila"), stars: 191, downloads: 4120, rating: 4.5, rating_count: 51,
hero: "linear-gradient(135deg, #0f766e 0%, #1e293b 100%)",
preview: ["oversized hoodie", "tech jacket", "varsity coat", "pleated trousers"],
updated: day(4), latest_version: "1.2.0", version_count: 4,
},
{
id: "wc_scene_locations", type: "wildcard", name: "Scene Locations",
tagline: "120 scene backdrops across urban / nature / liminal.",
description: "Anchor your prompts to a real-feeling location. Engine 1.4+.",
category: "scene", tags: ["scene", "cinematic", "photoreal"],
author: a("kaito.studio"), stars: 540, downloads: 8410, rating: 4.7, rating_count: 132,
hero: "linear-gradient(135deg, #0c4a6e 0%, #0ea5e9 100%)",
preview: ["abandoned subway platform", "rooftop laundry line", "winter playground at dawn"],
updated: day(2), latest_version: "2.0.1", version_count: 5,
},
{
id: "fv_brand_palette", type: "fixed_values", name: "Brand Palette — Aurora",
tagline: "Aurora studio palette + standard quality tags.",
description: "Lock in your brand colors and quality tail in one drop-in module.",
category: "style", tags: ["pastel", "studio"], license: "CC0",
author: a("lila"), stars: 41, downloads: 720, rating: 4.2, rating_count: 12,
hero: "linear-gradient(135deg, #c4b5fd 0%, #67e8f9 100%)",
preview: ["$primary=#8b5cf6", "$secondary=#22d3ee", "$tail=ultra-detailed, sharp focus"],
updated: day(20), latest_version: "1.0.0", version_count: 1,
},
{
id: "fv_lens_pack", type: "fixed_values", name: "Lens Pack Plus",
tagline: "Twelve canonical lens-bias presets (50/85/35/24...).",
description: "Lock the camera identity per pipeline. Battle-tested at studio scale.",
category: "camera", tags: ["photoreal", "studio"],
author: a("penumbra-co"), stars: 280, downloads: 4810, rating: 4.7, rating_count: 78,
hero: "linear-gradient(135deg, #475569 0%, #1e293b 100%)",
preview: ["50mm f/1.8", "85mm f/1.4", "35mm f/2"],
updated: day(15), latest_version: "2.0.0", version_count: 4,
},
{
id: "fv_quality_tail", type: "fixed_values", name: "Quality Tail Master",
tagline: "Battle-tested quality / negative tail used by 1k+ pipelines.",
description: "Keep your default render quality consistent across pipelines.",
category: "style", tags: ["post-process", "studio"],
author: a("penumbra-co"), stars: 1200, downloads: 18230, rating: 4.9, rating_count: 412,
hero: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
preview: ["ultra-detailed", "sharp focus", "studio quality"],
updated: day(11), latest_version: "3.1.0", version_count: 8,
},
{
id: "cmb_subject_phrase", type: "combine", name: "Subject Phrase Builder",
tagline: "Composes natural-sounding subject sentences.",
description: "Stitches subject + outfit + pose into a clean clause.",
category: "subject", tags: ["portrait", "post-process"],
author: a("marlow"), stars: 178, downloads: 3210, rating: 4.4, rating_count: 54,
hero: "linear-gradient(135deg, #14b8a6 0%, #0f766e 100%)",
preview: [
"A 29-year-old with auburn hair, wearing a tailored tuxedo",
"A 47-year-old with platinum hair, wearing an evening gown",
],
updated: day(7), latest_version: "1.0.4", version_count: 3,
},
{
id: "cmb_scene_assembler", type: "combine", name: "Scene Assembler",
tagline: "Assembles location + lighting + weather into a single scene clause.",
description: "Drop-in scene builder — pairs naturally with Scene Locations + Weather XL.",
category: "scene", tags: ["scene", "cinematic"],
author: a("kaito.studio"), stars: 322, downloads: 5910, rating: 4.7, rating_count: 88,
hero: "linear-gradient(135deg, #1e1b4b 0%, #312e81 100%)",
preview: ["abandoned subway platform, dim fluorescents, dense fog"],
updated: day(4), latest_version: "1.1.0", version_count: 4,
},
{
id: "cmb_outfit_phrase", type: "combine", name: "Outfit Phrase",
tagline: "Builds wardrobe sentences from sub-modules.",
description: "Composes natural outfit clauses — handles plurals + commas.",
category: "outfit", tags: ["fashion"],
author: a("anya-vox"), stars: 84, downloads: 1410, rating: 4.3, rating_count: 22,
hero: "linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)",
preview: ["wearing a tailored blazer over a silk camisole"],
updated: day(13), latest_version: "0.6.2", version_count: 2,
},
{
id: "drv_negative_guard", type: "derivation", name: "Negative Guard XL",
tagline: "Always-on anatomy + quality negatives. Battle-tested.",
description: "Drop-in negative-prompt derivation. Used by 1k+ pipelines.",
category: "style", tags: ["negative", "post-process"],
author: a("penumbra-co"), stars: 1610, downloads: 22140, rating: 4.9, rating_count: 488,
hero: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
preview: ["extra fingers, distorted hands", "blurry, jpeg artifacts", "low-res, mutated"],
updated: day(4), latest_version: "4.2.1", version_count: 14,
},
{
id: "drv_wet_weather", type: "derivation", name: "Wet Weather Adjustments",
tagline: "Drop-in postprocess: rain → wet clothes, fog → dewy.",
description: "Sets up automatic appearance adjustments based on weather wildcards.",
category: "weather", tags: ["weather", "post-process"],
author: a("mira.lab"), stars: 218, downloads: 3640, rating: 4.6, rating_count: 64,
hero: "linear-gradient(135deg, #0369a1 0%, #1e293b 100%)",
preview: ['if rain → append "wet clothes, soaked hair"', 'if fog → append "damp hair, dewy skin"'],
updated: day(10), latest_version: "1.2.0", version_count: 5,
},
{
id: "drv_studio_lighting", type: "derivation", name: "Studio Lighting Bias",
tagline: "Bias scene lighting toward studio softboxes when camera = studio.",
description: "Cohesive lighting derivation — auto-applies softbox / rim cues.",
category: "lighting", tags: ["studio", "photoreal"],
author: a("penumbra-co"), stars: 412, downloads: 6210, rating: 4.7, rating_count: 110,
hero: "linear-gradient(135deg, #475569 0%, #0f172a 100%)",
preview: ["softbox key light", "rim light", "hair light"],
updated: day(8), latest_version: "2.0.0", version_count: 6,
},
{
id: "cn_palette", type: "constraint", name: "Palette Coherence",
tagline: "Bias matrix between hair × outfit color families.",
description: "Reduces clashing palettes; gentle nudges, not hard locks.",
category: "style", tags: ["fashion", "post-process"],
author: a("anya-vox"), stars: 96, downloads: 1840, rating: 4.3, rating_count: 28,
hero: "linear-gradient(135deg, #f59e0b 0%, #ef4444 50%, #8b5cf6 100%)",
preview: ["warm × cool → reduce", "rose gold × tuxedo → boost (override)"],
updated: day(11), latest_version: "0.4.0", version_count: 2,
},
{
id: "cn_subject_outfit", type: "constraint", name: "Subject × Outfit Match",
tagline: "Keeps subject archetypes paired with realistic outfit choices.",
description: "Cuts down on out-of-genre outfit pairings. Edit-friendly.",
category: "subject", tags: ["fashion", "studio"],
author: a("kaito.studio"), stars: 168, downloads: 2410, rating: 4.5, rating_count: 41,
hero: "linear-gradient(135deg, #db2777 0%, #4c1d95 100%)",
preview: ["mecha pilot × evening gown → reduce", "shounen rival × tuxedo → boost"],
updated: day(6), latest_version: "1.0.0", version_count: 3,
},
{
id: "pl_editorial", type: "pipeline", name: "Editorial Portrait Pipeline",
tagline: "Production-ready editorial portrait pipeline — 8 modules.",
description: "Drop-in pipeline preset for editorial portraits. Studio-ready.",
category: "subject", tags: ["studio", "fashion", "photoreal"],
author: a("penumbra-co"), stars: 514, downloads: 7320, rating: 4.8, rating_count: 122,
hero: "linear-gradient(135deg, #1e293b 0%, #312e81 100%)",
preview: ["full editorial portrait pipeline", "8 wired modules"],
updated: day(2), latest_version: "2.0.0", version_count: 4,
},
{
id: "pl_anime_starter", type: "pipeline", name: "Anime Starter Pipeline",
tagline: "Beginner-friendly anime pipeline — 6 modules wired.",
description: "Best on engine 1.4. Includes Anime Subjects + Streetwear + Studio Lighting.",
category: "style", tags: ["anime", "concept-art"],
author: a("pixelnaut"), stars: 244, downloads: 4320, rating: 4.5, rating_count: 71,
hero: "linear-gradient(135deg, #fb7185 0%, #f97316 100%)",
preview: ["Anime Subjects → Streetwear → Studio Lighting → Quality Tail"],
updated: day(3), latest_version: "0.7.0", version_count: 3,
},
{
id: "wc_emotions_basic", type: "wildcard", name: "Basic Emotions",
tagline: "16 facial-expression keywords — clean and minimal.",
description: "A safe baseline expression wildcard for any portrait pipeline.",
category: "subject", tags: ["portrait"],
author: a("marlow"), stars: 58, downloads: 1140, rating: 4.0, rating_count: 18,
hero: "linear-gradient(135deg, #475569 0%, #1e1b4b 100%)",
preview: ["serene", "amused", "stoic", "wistful"],
updated: day(18), latest_version: "1.0.0", version_count: 1,
},
{
id: "wc_camera_angles", type: "wildcard", name: "Camera Angles Pro",
tagline: "Pro camera angle wildcard. 20 options + presets.",
description: "Great pair with Lens Pack. Handles dutch + low-angle + worm's eye.",
category: "camera", tags: ["studio", "photoreal", "cinematic"],
author: a("dax-prompts"), stars: 312, downloads: 4910, rating: 4.6, rating_count: 71,
hero: "linear-gradient(135deg, #1e293b 0%, #475569 100%)",
preview: ["worm's eye", "dutch tilt", "high angle", "over the shoulder"],
updated: day(5), latest_version: "1.4.0", version_count: 5,
},
{
id: "wc_fantasy_subjects", type: "wildcard", name: "Fantasy Subjects",
tagline: "Classic high-fantasy archetypes — 50 entries.",
description: "Adventurers, mages, beasts — all with class & race flavor.",
category: "subject", tags: ["fantasy", "concept-art"],
author: a("kaito.studio"), stars: 388, downloads: 5610, rating: 4.6, rating_count: 92,
hero: "linear-gradient(135deg, #4c1d95 0%, #1e1b4b 100%)",
preview: ["elven ranger", "tiefling warlock", "stoic dwarf paladin"],
updated: day(7), latest_version: "1.5.0", version_count: 6,
},
{
id: "wc_noir_lighting", type: "wildcard", name: "Noir Lighting",
tagline: "Hard shadows + venetian blinds + smoke. 18 options.",
description: "Drop-in noir lighting moodboard. Pairs with Cinematic Vol. 1.",
category: "lighting", tags: ["noir", "cinematic", "monochrome"],
author: a("kaito.studio"), stars: 174, downloads: 2810, rating: 4.5, rating_count: 41,
hero: "linear-gradient(135deg, #0f172a 0%, #1f1f1f 100%)",
preview: ["venetian-blind shadows", "single-source key", "smoke-diffused fill"],
updated: day(12), latest_version: "1.0.0", version_count: 2,
},
{
id: "wc_ghibli_scenes", type: "wildcard", name: "Ghibli Scenes",
tagline: "Watercolor Ghibli vibes — 24 scene tags.",
description: "Soft palette, hand-drawn flavor. Best with Anime Studio Starter.",
category: "scene", tags: ["ghibli", "anime", "pastel"],
author: a("pixelnaut"), stars: 408, downloads: 6510, rating: 4.7, rating_count: 102,
hero: "linear-gradient(135deg, #5eead4 0%, #fde68a 100%)",
preview: ["sun-dappled meadow", "spirit-filled forest", "rainy bus stop at dusk"],
updated: day(4), latest_version: "1.2.0", version_count: 3,
},
{
id: "fv_locked_camera", type: "fixed_values", name: "Locked Studio Camera",
tagline: "Locked studio camera defaults — softbox, 85mm, eye-level.",
description: "Lock-in baseline camera identity for studio shoots.",
category: "camera", tags: ["studio", "photoreal"],
author: a("penumbra-co"), stars: 88, downloads: 1612, rating: 4.4, rating_count: 24,
hero: "linear-gradient(135deg, #404040 0%, #171717 100%)",
preview: ["$lens=85mm f/1.4", "$key=softbox", "$angle=eye-level"],
updated: day(20), latest_version: "1.0.1", version_count: 2,
},
{
id: "drv_portrait_polish", type: "derivation", name: "Portrait Polish",
tagline: "Auto-tunes skin / hair quality based on subject choice.",
description: "Subtle polish for portrait pipelines.",
category: "style", tags: ["portrait", "post-process"],
author: a("anya-vox"), stars: 188, downloads: 2810, rating: 4.5, rating_count: 38,
hero: "linear-gradient(135deg, #b91c1c 0%, #4c1d95 100%)",
preview: ['if subject = portrait → append "skin pores, gentle subsurface"'],
updated: day(9), latest_version: "1.1.0", version_count: 3,
},
{
id: "cn_genre_bias", type: "constraint", name: "Genre Bias",
tagline: "Soft constraint to keep cross-genre choices coherent.",
description: "Gentle nudges between fantasy / sci-fi / photoreal axes.",
category: "style", tags: ["fantasy", "sci-fi"],
author: a("dax-prompts"), stars: 110, downloads: 1340, rating: 4.4, rating_count: 21,
hero: "linear-gradient(135deg, #4c1d95 0%, #312e81 100%)",
preview: ["sci-fi × medieval → reduce", "fantasy × cyberpunk → boost (override)"],
updated: day(14), latest_version: "0.5.0", version_count: 1,
},
];
interface BundleSeed {
id: string;
name: string;
tagline: string;
description: string;
category: string;
tags: string[];
author: CommunityUser;
stars: number;
downloads: number;
rating: number;
rating_count: number;
hero: string;
sub_kinds: CommunityKind[];
module_ids: string[];
preview_samples: string[];
updated: string;
latest_version: string;
version_count: number;
}
const BUNDLE_SEEDS: BundleSeed[] = [
{
id: "pkg_cinematic", name: "Cinematic Vol. 1",
tagline: "Weather-aware moody scene pack — 14 modules, drop-in pipeline.",
description: "A complete cinematic pipeline including moody weather, scene locations, and noir lighting.",
category: "lighting", tags: ["cinematic", "scene", "weather", "noir"],
author: a("kaito.studio"), stars: 1240, downloads: 18420, rating: 4.8, rating_count: 312,
hero: "linear-gradient(135deg, #312e81 0%, #0f172a 60%, #1e1b4b 100%)",
sub_kinds: ["pipeline", "wildcard", "combine", "derivation"],
module_ids: ["wc_scene_locations", "wc_weather_xl", "wc_noir_lighting", "cmb_scene_assembler"],
preview_samples: ["overcast Tokyo at dusk", "neon-lit alley after rain", "fog-rolling mountain pass"],
updated: day(2), latest_version: "2.1.0", version_count: 7,
},
{
id: "pkg_studio", name: "Studio Photo Pack",
tagline: "Locked studio camera defaults + 9 pose wildcards.",
description: "Plug-and-play studio pack for editorial / fashion shoots.",
category: "camera", tags: ["studio", "photoreal", "fashion"],
author: a("penumbra-co"), stars: 988, downloads: 14210, rating: 4.9, rating_count: 274,
hero: "linear-gradient(135deg, #404040 0%, #171717 100%)",
sub_kinds: ["fixed_values", "wildcard", "combine"],
module_ids: ["fv_locked_camera", "wc_pose_dramatic", "wc_outfits_streetwear"],
preview_samples: ["seated 3/4", "leaning against wall", "candid laughter"],
updated: day(5), latest_version: "3.0.0", version_count: 5,
},
{
id: "pkg_anime", name: "Anime Studio Starter",
tagline: "Ghibli + cyberpunk wildcards, paired pose pack.",
description: "A friendly starter pack for anime-style pipelines.",
category: "style", tags: ["anime", "ghibli", "concept-art"],
author: a("pixelnaut"), stars: 244, downloads: 4320, rating: 4.5, rating_count: 71,
hero: "linear-gradient(135deg, #fb7185 0%, #f97316 100%)",
sub_kinds: ["wildcard", "combine"],
module_ids: ["wc_anime_subjects", "wc_ghibli_scenes"],
preview_samples: ["studio ghibli watercolor", "cyberpunk concept art", "cel-shaded character"],
updated: day(3), latest_version: "0.7.0", version_count: 3,
},
];
function buildModule(seed: ModuleSeed): CommunityModule {
const license = seed.license ?? "MIT";
const engine = seed.engine_min_version ?? "1.4";
const versionList = versions(seed.latest_version, seed.version_count);
return {
id: seed.id,
type: seed.type,
name: seed.name,
description: seed.description,
category: seed.category,
tags: seed.tags,
author: seed.author,
versions: versionList,
stars: seed.stars,
downloads: seed.downloads,
rating: seed.rating,
rating_count: seed.rating_count,
rating_dist: ratingDist(seed.rating, seed.rating_count),
nsfw: seed.nsfw ?? false,
engine_min_version: engine,
license,
hero: seed.hero,
tagline: seed.tagline,
readme: defaultReadme({
name: seed.name, author: seed.author, license, engine, kind: seed.type, tagline: seed.tagline, tags: seed.tags,
}),
updated_at: seed.updated,
preview_options: seed.preview.map((v, i) => ({ value: v, weight: Math.max(1, 6 - i) })),
comments: seededComments(seed.id.charCodeAt(4) || 0),
};
}
function buildBundle(seed: BundleSeed, modules: CommunityModule[]): CommunityBundle {
const license = "MIT";
const engine = "1.4";
const versionList = versions(seed.latest_version, seed.version_count);
const includedModules = seed.module_ids
.map((id) => modules.find((m) => m.id === id))
.filter((m): m is CommunityModule => Boolean(m));
return {
id: seed.id,
type: "bundle",
name: seed.name,
description: seed.description,
tagline: seed.tagline,
category: seed.category,
tags: seed.tags,
author: seed.author,
versions: versionList,
stars: seed.stars,
downloads: seed.downloads,
rating: seed.rating,
rating_count: seed.rating_count,
rating_dist: ratingDist(seed.rating, seed.rating_count),
nsfw: false,
engine_min_version: engine,
license,
hero: seed.hero,
readme: defaultReadme({
name: seed.name, author: seed.author, license, engine, kind: "bundle", tagline: seed.tagline, tags: seed.tags,
}),
updated_at: seed.updated,
modules: includedModules,
sub_kinds: seed.sub_kinds,
preview_samples: seed.preview_samples,
comments: seededComments(seed.id.charCodeAt(4) || 0),
};
}
function defaultReadme(info: {
name: string; author: CommunityUser; license: string; engine: string;
kind: CommunityKind | "bundle"; tagline: string; tags: string[];
}): string {
return [
`# ${info.name}`,
"",
info.tagline,
"",
`**Author** · @${info.author.login} ${info.author.verified ? "✓" : ""}`,
`**License** · ${info.license} · **Engine** · ≥ ${info.engine} · **Kind** · ${info.kind}`,
"",
"## What's inside",
`Drop-in ${info.kind} module${info.kind === "bundle" ? " bundle" : ""}. Tags: ${info.tags.join(", ")}.`,
"",
"## Install",
"Click **Install** above. The module is added to your local library and is then editable like any other.",
"",
"## Notes",
"- Snapshot is captured on insert — saved workflows stay reproducible regardless of registry updates.",
"- Re-sync from the module's detail page to pull the latest version.",
].join("\n");
}
const SEED_MODULES: CommunityModule[] = MODULE_SEEDS.map(buildModule);
const SEED_BUNDLES: CommunityBundle[] = BUNDLE_SEEDS.map((b) => buildBundle(b, SEED_MODULES));
export const SEED_ATOMS: CommunityAtom[] = [...SEED_BUNDLES, ...SEED_MODULES];
export const SEED_FEATURED_IDS = ["pkg_cinematic", "wc_haircolor_pro", "pkg_studio", "wc_weather_xl"];
export { SEED_MODULES, SEED_BUNDLES };
+154
View File
@@ -0,0 +1,154 @@
/**
* Community tab — type definitions for the mock registry.
*
* UI-only mock — no real backend. Types intentionally mirror what the real
* /community/api would emit so a future hookup is a straight swap from
* mockApi.ts → fetch.
*/
/** Public author identity. Real backend would derive this from GitHub OAuth. */
export interface CommunityUser {
/** GitHub-style login handle (no @). */
login: string;
/** Avatar URL or emoji shorthand (mock allows either). */
avatar_url: string;
/** Verified author badge. */
verified: boolean;
/** Optional display name. */
name?: string;
}
/** Module kinds the registry recognises. Maps 1:1 to local-library kinds. */
export type CommunityKind =
| "wildcard"
| "fixed_values"
| "combine"
| "derivation"
| "constraint"
| "pipeline";
export interface CommunityVersion {
version: string;
published_at: string;
changelog?: string;
}
export interface CommunityComment {
id: string;
user: CommunityUser;
body: string;
at: string;
rating?: number;
}
export interface CommunityPreviewOption {
value: string;
weight: number;
}
/** A single registry entry. */
export interface CommunityModule {
id: string;
type: CommunityKind;
name: string;
description: string;
category: string;
tags: string[];
author: CommunityUser;
versions: CommunityVersion[];
stars: number;
downloads: number;
rating: number;
rating_count: number;
/** Histogram of 5..1 star counts (index 0 = 5★). */
rating_dist: number[];
nsfw: boolean;
engine_min_version?: string;
license: string;
/** Hero gradient for cards / detail. */
hero: string;
/** Tagline shown under the title in cards. */
tagline: string;
readme: string;
/** Last update timestamp (ISO). */
updated_at: string;
/** Optional opaque payload — engine wires this to the live module. */
payload?: unknown;
/** Preview shape — used by the kind-aware Preview tab. */
preview_options?: CommunityPreviewOption[];
/** Comments seeded for the Reviews tab. */
comments?: CommunityComment[];
}
/** A bundle (a.k.a. pack) — a collection of related modules. */
export interface CommunityBundle {
id: string;
type: "bundle";
name: string;
description: string;
tagline: string;
category: string;
tags: string[];
author: CommunityUser;
versions: CommunityVersion[];
stars: number;
downloads: number;
rating: number;
rating_count: number;
rating_dist: number[];
nsfw: boolean;
engine_min_version?: string;
license: string;
hero: string;
readme: string;
updated_at: string;
modules: CommunityModule[];
/** Sub-kinds preview list (e.g. ["wildcard", "combine"]). */
sub_kinds: CommunityKind[];
/** Sample resolutions (for preview tab). */
preview_samples?: string[];
comments?: CommunityComment[];
}
/** Discriminated union for catalog entries. */
export type CommunityAtom = CommunityModule | CommunityBundle;
export type CommunityApiStatus = "online" | "degraded" | "offline";
export type CommunitySortKey =
| "trending"
| "recent"
| "downloads"
| "stars"
| "rating";
export interface CommunitySearchQuery {
q?: string;
kind?: CommunityKind | "bundle" | "all";
tag?: string | null;
sort?: CommunitySortKey;
verified_only?: boolean;
include_nsfw?: boolean;
compatible_only?: boolean;
engine?: string;
}
export interface CommunityUploadPayload {
atom: "single" | "bundle";
type: CommunityKind;
name: string;
tagline: string;
description: string;
category: string;
license: string;
engine_min_version: string;
nsfw: boolean;
tags: string[];
readme: string;
version: string;
}
export interface CommunityInstallEntry {
id: string;
at: string;
}
+1
View File
@@ -37,6 +37,7 @@ const sections: Section[] = [
{
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",
+126 -2
View File
@@ -1,11 +1,15 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, onMounted, ref } from "vue";
import Button from "primevue/button";
import { useRouter } from "vue-router";
import Menu from "primevue/menu";
import { useRoute, useRouter } from "vue-router";
import { useUiStore } from "../stores/uiStore";
import { useCommunityStore } from "../stores/communityStore";
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`;
@@ -22,6 +26,37 @@ const themeLabel = computed(() =>
ui.themeMode === "light" ? "Switch to auto (system) theme" :
"Switch to dark theme",
);
/** Status + user pill render only when the user is on a community route. */
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)";
});
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);
}
async function onSignIn() {
try {
await community.signIn();
} catch {
/* mockApi surfaces error via apiStatus */
}
}
onMounted(() => {
// Lazy probe — ensures the dot reflects current state on community routes.
if (onCommunity.value) community.refreshApiStatus();
});
</script>
<template>
@@ -39,12 +74,54 @@ const themeLabel = computed(() =>
</RouterLink>
</div>
<div class="topbar-right">
<span
v-if="onCommunity"
class="topbar-status"
:title="`Community API: ${community.apiStatus}`"
>
<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"
:title="`Signed in as @${community.currentUser.login}`"
@click="toggleUserMenu"
>
<span
class="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" />
</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>
<Button
icon="pi pi-cog" text rounded severity="secondary"
aria-label="Settings"
@@ -84,4 +161,51 @@ const themeLabel = computed(() =>
color: var(--wp-text-dim, var(--wp-text3));
font-weight: 400;
}
/* Community-only chrome */
.topbar-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 999px;
background: var(--wp-bg-2);
font-size: 11px;
color: var(--wp-text-muted);
}
.topbar-status__dot {
width: 8px;
height: 8px;
border-radius: 999px;
display: inline-block;
}
.topbar-status__label { text-transform: capitalize; }
.topbar-user {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--wp-bg-2);
border: 1px solid var(--wp-border);
border-radius: 999px;
padding: 2px 10px 2px 2px;
cursor: pointer;
font: inherit;
color: var(--wp-text);
}
.topbar-user:hover { background: var(--wp-bg-3); }
.topbar-user__avatar {
width: 22px; height: 22px;
border-radius: 999px;
background-size: cover;
background-position: center;
background-color: var(--wp-bg-3);
display: inline-block;
}
.topbar-user__login {
font-size: 12px;
}
.topbar-user__caret {
font-size: 10px;
color: var(--wp-text-muted);
}
</style>
+7
View File
@@ -30,6 +30,13 @@ const routes: RouteRecordRaw[] = [
{ path: "import-export", name: "import-export", component: () => import("../views/ImportExport.vue") },
{ path: "test", name: "test", component: () => import("../views/TestRunner.vue") },
{ path: "settings", name: "settings", component: () => import("../views/Settings.vue") },
{ path: "community", name: "community", redirect: "/community/discover" },
{ path: "community/discover", name: "community-discover", component: () => import("../views/community/CommunityDiscover.vue") },
{ path: "community/upload", name: "community-upload", component: () => import("../views/community/CommunityUpload.vue") },
{ path: "community/profile", name: "community-profile", component: () => import("../views/community/CommunityProfile.vue") },
{ path: "community/offline", name: "community-offline", component: () => import("../views/community/CommunityOffline.vue") },
{ path: "community/404", name: "community-404", component: () => import("../views/community/Community404.vue") },
{ path: "community/m/:id", name: "community-detail", component: () => import("../views/community/CommunityDetail.vue"), props: true },
],
},
{ path: "/:pathMatch(.*)*", redirect: "/wildcards" },
+165
View File
@@ -0,0 +1,165 @@
/**
* Pinia store for the Community tab.
*
* Wraps the mock API in `community/mockApi.ts`. State (auth, install
* history, stars) is persisted via mockApi → localStorage so the SPA's
* runtime ref state stays in sync across reloads.
*/
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import * as api from "../community/mockApi";
import type {
CommunityApiStatus,
CommunityAtom,
CommunityInstallEntry,
CommunitySearchQuery,
CommunityUploadPayload,
CommunityUser,
} from "../community/types";
export const useCommunityStore = defineStore("community", () => {
const currentUser = ref<CommunityUser | null>(api.getCurrentUser());
const apiStatus = ref<CommunityApiStatus>("online");
const installed = ref<Set<string>>(new Set(api.getInstalled()));
const starred = ref<Set<string>>(new Set(api.getStarred()));
const myUploads = ref<Set<string>>(new Set(api.getMyUploads()));
const installHistory = ref<CommunityInstallEntry[]>(api.getInstallHistory());
const featured = ref<CommunityAtom[]>([]);
const feed = ref<CommunityAtom[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
/** Mock-only: read-only assumed engine version. */
const engineVersion = ref("1.4");
/* ------------------------------ helpers ------------------------------ */
function setApiStatus(status: CommunityApiStatus) {
apiStatus.value = status;
}
async function refreshApiStatus() {
try {
apiStatus.value = await api.getApiStatus();
} catch {
apiStatus.value = "offline";
}
return apiStatus.value;
}
/* -------------------------------- auth ------------------------------- */
async function signIn() {
try {
currentUser.value = await api.signInWithGitHub();
} catch (e) {
apiStatus.value = "offline";
throw e;
}
}
async function signOut() {
await api.signOut();
currentUser.value = null;
}
/* ------------------------------- catalog ----------------------------- */
async function loadFeatured() {
try {
featured.value = await api.getFeatured();
apiStatus.value = "online";
} catch {
featured.value = [];
apiStatus.value = "offline";
}
}
async function search(query: CommunitySearchQuery = {}) {
loading.value = true;
error.value = null;
try {
feed.value = await api.searchModules({ ...query, engine: engineVersion.value });
apiStatus.value = "online";
} catch (e) {
feed.value = [];
apiStatus.value = "offline";
error.value = e instanceof Error ? e.message : "error";
} finally {
loading.value = false;
}
}
async function getModule(id: string) {
return await api.getModule(id);
}
/* --------------------------- install + stars ------------------------- */
async function install(id: string) {
await api.installModule(id);
installed.value = new Set([...installed.value, id]);
installHistory.value = api.getInstallHistory();
}
async function uninstall(id: string) {
await api.uninstallModule(id);
const next = new Set(installed.value);
next.delete(id);
installed.value = next;
}
async function toggleStar(id: string) {
const willStar = !starred.value.has(id);
await api.starModule(id, willStar);
const next = new Set(starred.value);
if (willStar) next.add(id);
else next.delete(id);
starred.value = next;
}
/* ------------------------------- upload ------------------------------ */
async function upload(payload: CommunityUploadPayload) {
const result = await api.uploadModule(payload);
myUploads.value = new Set([...myUploads.value, result.id]);
return result;
}
/* ------------------------------ getters ------------------------------ */
const isInstalled = computed(() => (id: string) => installed.value.has(id));
const isStarred = computed(() => (id: string) => starred.value.has(id));
const isMyUpload = computed(() => (id: string) => myUploads.value.has(id));
return {
// state
currentUser,
apiStatus,
installed,
starred,
myUploads,
installHistory,
featured,
feed,
loading,
error,
engineVersion,
// actions
setApiStatus,
refreshApiStatus,
signIn,
signOut,
loadFeatured,
search,
getModule,
install,
uninstall,
toggleStar,
upload,
// getters
isInstalled,
isStarred,
isMyUpload,
};
});
@@ -0,0 +1,36 @@
<script setup lang="ts">
/**
* Community → 404 state.
*/
import { useRouter } from "vue-router";
import Button from "primevue/button";
const router = useRouter();
function back() {
router.push({ name: "community-discover" });
}
</script>
<template>
<div class="wp-comm-page">
<div class="wp-comm-empty">
<div class="big">404</div>
<h3>Module not found</h3>
<p>It might have been unpublished, renamed, or moved.</p>
<Button label="Back to Discover" icon="pi pi-arrow-left" class="mt-3" @click="back" />
</div>
</div>
</template>
<style scoped>
@import "../../community/community.css";
.big {
font-size: 88px;
font-weight: 700;
color: var(--wp-accent-text);
line-height: 1;
}
.mt-3 { margin-top: 16px; }
</style>
@@ -0,0 +1,344 @@
<script setup lang="ts">
/**
* Community → Module / Bundle detail screen.
*
* Hero, install + star + report buttons, stats strip, and four tabs:
* README, Preview (kind-aware), Versions changelog, Reviews.
*/
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import Button from "primevue/button";
import { useToast } from "primevue/usetoast";
import { useCommunityStore } from "../../stores/communityStore";
import { engineSatisfies } from "../../community/mockApi";
import { renderMarkdown } from "../../community/markdown";
import { KIND_ICON, KIND_LABEL, fmtNumber, relativeTime } from "../../community/format";
import type { CommunityAtom, CommunityVersion } from "../../community/types";
const props = defineProps<{ id: string }>();
const router = useRouter();
const store = useCommunityStore();
const toast = useToast();
const atom = ref<CommunityAtom | null>(null);
const loading = ref(true);
const error = ref<"offline" | "404" | null>(null);
const tab = ref<"readme" | "preview" | "versions" | "reviews">("readme");
const selectedVersion = ref<string | null>(null);
const installing = ref(false);
const installed = computed(() => atom.value && store.installed.has(atom.value.id));
const starred = computed(() => atom.value && store.starred.has(atom.value.id));
const compatible = computed(() => {
if (!atom.value) return true;
return engineSatisfies(store.engineVersion, atom.value.engine_min_version || "0");
});
const renderedReadme = computed(() => atom.value ? renderMarkdown(atom.value.readme) : "");
const previewKind = computed(() => {
if (!atom.value) return "wildcard";
return atom.value.type === "bundle" ? "bundle" : atom.value.type;
});
async function load() {
loading.value = true;
error.value = null;
try {
const result = await store.getModule(props.id);
atom.value = result;
selectedVersion.value = result.versions[0]?.version ?? null;
} catch (e) {
const code = (e as Error & { code?: number }).code;
if (code === 404) {
error.value = "404";
router.replace({ name: "community-404" });
} else {
error.value = "offline";
router.replace({ name: "community-offline" });
}
} finally {
loading.value = false;
}
}
async function onInstall() {
if (!atom.value) return;
installing.value = true;
try {
await store.install(atom.value.id);
toast.add({ severity: "success", summary: "Installed", detail: atom.value.name, life: 2500 });
} catch {
toast.add({ severity: "error", summary: "Install failed", detail: "Registry unreachable.", life: 3000 });
} finally {
installing.value = false;
}
}
async function onUninstall() {
if (!atom.value) return;
try {
await store.uninstall(atom.value.id);
toast.add({ severity: "info", summary: "Removed", detail: atom.value.name, life: 2500 });
} catch {
toast.add({ severity: "error", summary: "Could not remove", life: 3000 });
}
}
async function onStar() {
if (!atom.value) return;
if (!store.currentUser) {
toast.add({ severity: "info", summary: "Sign in to star modules.", life: 2500 });
return;
}
await store.toggleStar(atom.value.id);
}
function onBack() {
router.push({ name: "community-discover" });
}
const tabs: { key: "readme" | "preview" | "versions" | "reviews"; label: string }[] = [
{ key: "readme", label: "README" },
{ key: "preview", label: "Preview" },
{ key: "versions", label: "Versions" },
{ key: "reviews", label: "Reviews" },
];
function previewOptions(): { value: string; weight: number }[] {
if (!atom.value || atom.value.type === "bundle") return [];
return atom.value.preview_options ?? [];
}
function bundleSamples(): string[] {
if (!atom.value || atom.value.type !== "bundle") return [];
return atom.value.preview_samples ?? [];
}
function bundleSubKinds(): string[] {
if (!atom.value || atom.value.type !== "bundle") return [];
return atom.value.sub_kinds;
}
function changelog(): CommunityVersion[] {
return atom.value?.versions ?? [];
}
const previewTotal = computed(() => {
const opts = previewOptions();
return opts.reduce((sum, o) => sum + (o.weight || 0), 0) || 1;
});
watch(() => props.id, () => { load(); });
onMounted(() => load());
</script>
<template>
<div class="wp-comm-page">
<Button
label="Back to Community"
icon="pi pi-arrow-left"
severity="secondary"
text
class="self-start"
@click="onBack"
/>
<div v-if="loading" class="wp-comm-empty">Loading...</div>
<template v-else-if="atom">
<!-- Hero -->
<section class="wp-comm-detail-hero" :style="{ background: atom.hero }">
<div class="wp-comm-detail-hero__inner">
<div class="grow">
<span class="wp-comm-card__kind">
<i :class="['pi', KIND_ICON[atom.type === 'bundle' ? 'bundle' : atom.type]]" aria-hidden="true" />
{{ KIND_LABEL[atom.type === "bundle" ? "bundle" : atom.type] }}
</span>
<span v-if="atom.nsfw" class="wp-comm-card__nsfw" style="position: static; margin-left: 8px;">18+</span>
<h1 class="wp-comm-detail-hero__title">{{ atom.name }}</h1>
<p class="wp-comm-detail-hero__tagline">{{ atom.tagline }}</p>
<div class="wp-comm-detail-hero__meta">
<span class="wp-comm-detail-hero__author">
<span
class="wp-comm-card__avatar"
:style="{ backgroundImage: `url(${atom.author.avatar_url})` }"
aria-hidden="true"
/>
<span>@{{ atom.author.login }}</span>
<i v-if="atom.author.verified" class="pi pi-verified" aria-hidden="true" />
</span>
<span>·</span>
<span><i class="pi pi-clock" aria-hidden="true" /> updated {{ relativeTime(atom.updated_at) }}</span>
<span>·</span>
<span><i class="pi pi-bookmark" aria-hidden="true" /> {{ atom.license }}</span>
<span>·</span>
<span><i class="pi pi-cog" aria-hidden="true" /> engine ≥ {{ atom.engine_min_version }}</span>
</div>
</div>
<div class="wp-comm-detail-hero__actions">
<Button
v-if="installed"
label="Installed — Remove"
icon="pi pi-check"
severity="secondary"
@click="onUninstall"
/>
<Button
v-else
:label="atom.type === 'bundle' ? 'Install pack' : 'Install module'"
icon="pi pi-download"
:disabled="!compatible || installing"
@click="onInstall"
/>
<Button
:label="starred ? 'Starred' : 'Star'"
:icon="starred ? 'pi pi-star-fill' : 'pi pi-star'"
severity="secondary"
@click="onStar"
/>
<Button
icon="pi pi-flag"
aria-label="Report module"
severity="secondary"
text
/>
</div>
</div>
</section>
<!-- Stats strip -->
<div class="wp-comm-detail-stats">
<div><span>Downloads</span><strong>{{ fmtNumber(atom.downloads) }}</strong></div>
<div><span>Stars</span><strong>{{ fmtNumber(atom.stars + (starred ? 1 : 0)) }}</strong></div>
<div><span>Rating</span><strong>{{ atom.rating.toFixed(1) }} <small>({{ atom.rating_count }})</small></strong></div>
<div><span>Versions</span><strong>{{ atom.versions.length }}</strong></div>
<div><span>Latest</span><strong>{{ atom.versions[0]?.version }}</strong></div>
</div>
<!-- Tabs -->
<div class="wp-comm-tabs" role="tablist">
<button
v-for="t in tabs"
:key="t.key"
type="button"
class="wp-comm-tab"
role="tab"
:data-active="tab === t.key"
:aria-selected="tab === t.key"
@click="tab = t.key"
>{{ t.label }}<template v-if="t.key === 'reviews'"> · {{ atom.comments?.length ?? 0 }}</template></button>
</div>
<div class="wp-comm-tabbody">
<div
v-if="tab === 'readme'"
class="wp-comm-readme"
v-html="renderedReadme"
/>
<div v-else-if="tab === 'preview'">
<template v-if="previewKind === 'bundle'">
<h3>What's in this pack</h3>
<div class="wp-comm-grid">
<div v-for="kind in bundleSubKinds()" :key="kind" class="wp-comm-card">
<div class="wp-comm-card__body" style="display:flex; flex-direction:row; align-items:center; gap:8px;">
<i :class="['pi', KIND_ICON[kind]]" aria-hidden="true" />
<span>{{ KIND_LABEL[kind] }}</span>
</div>
</div>
</div>
<h3 style="margin-top: 16px;">Sample resolutions</h3>
<div class="wp-comm-readme">
<ul>
<li v-for="(s, i) in bundleSamples()" :key="i">{{ s }}</li>
</ul>
</div>
</template>
<template v-else>
<h3>Options &amp; weight distribution</h3>
<div class="wp-comm-readme">
<div v-for="(opt, i) in previewOptions()" :key="i" class="wp-comm-histo-row">
<span class="wp-comm-histo-row__val">{{ opt.value }}</span>
<div class="wp-comm-histo-row__bar">
<div :style="{ width: ((opt.weight / previewTotal) * 100) + '%' }" />
</div>
<span class="wp-comm-histo-row__pct">{{ Math.round((opt.weight / previewTotal) * 100) }}%</span>
</div>
<p v-if="!previewOptions().length" class="text-muted">No preview shape provided.</p>
</div>
</template>
</div>
<div v-else-if="tab === 'versions'" class="wp-comm-versions">
<div
v-for="v in changelog()"
:key="v.version"
class="wp-comm-version"
:data-active="selectedVersion === v.version"
>
<button
type="button"
class="wp-comm-version__head"
@click="selectedVersion = v.version"
>
<span><strong>{{ v.version }}</strong></span>
<span class="wp-comm-version__date">{{ relativeTime(v.published_at) }}</span>
</button>
<p class="wp-comm-version__notes">{{ v.changelog || "(no notes)" }}</p>
</div>
</div>
<div v-else-if="tab === 'reviews'" class="wp-comm-reviews">
<div class="wp-comm-reviews__summary">
<div>
<div class="wp-comm-reviews__big-num">{{ atom.rating.toFixed(1) }}</div>
<div style="color: var(--wp-text-muted); font-size: 12px;">{{ atom.rating_count }} reviews</div>
</div>
<div class="wp-comm-reviews__bars">
<div v-for="(n, i) in atom.rating_dist" :key="i" class="wp-comm-reviews__bar">
<span style="width: 16px;">{{ 5 - i }}★</span>
<div class="wp-comm-reviews__bar-track">
<div :style="{ width: ((n / Math.max(...atom.rating_dist, 1)) * 100) + '%' }" />
</div>
<span style="width: 36px; text-align: right; color: var(--wp-text-muted);">{{ n }}</span>
</div>
</div>
</div>
<div v-if="atom.comments && atom.comments.length">
<div v-for="cm in atom.comments" :key="cm.id" class="wp-comm-review">
<div class="wp-comm-review__head">
<span
class="wp-comm-card__avatar"
:style="{ backgroundImage: `url(${cm.user.avatar_url})` }"
aria-hidden="true"
/>
<strong>{{ cm.user.name || cm.user.login }}</strong>
<span>@{{ cm.user.login }}</span>
<span style="margin-left: auto;">{{ relativeTime(cm.at) }}</span>
</div>
<p class="wp-comm-review__text">{{ cm.body }}</p>
</div>
</div>
<p v-else class="text-muted">No reviews yet — be the first.</p>
<div v-if="store.currentUser" class="wp-comm-review">
<p style="margin: 0; color: var(--wp-text-muted); font-size: 12px;">
Reviewing as <strong>@{{ store.currentUser.login }}</strong> — full review form lands with backend hookup.
</p>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
@import "../../community/community.css";
.self-start { align-self: flex-start; }
.grow { flex: 1; min-width: 240px; }
.text-muted { color: var(--wp-text-muted); font-size: 12px; }
</style>
@@ -0,0 +1,246 @@
<script setup lang="ts">
/**
* Community → Discover screen.
*
* Wraps the search store, exposes filter / sort / kind / verified / NSFW
* toggles, and renders a featured strip + card grid. UI-only, mock-backed.
*/
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import Button from "primevue/button";
import InputText from "primevue/inputtext";
import Select from "primevue/select";
import Checkbox from "primevue/checkbox";
import { useCommunityStore } from "../../stores/communityStore";
import CommunityCard from "../../community/CommunityCard.vue";
import { KIND_ICON, KIND_LABEL, fmtNumber } from "../../community/format";
import { getAllTags } from "../../community/mockApi";
import type { CommunitySortKey } from "../../community/types";
const router = useRouter();
const store = useCommunityStore();
const q = ref("");
const kind = ref<string>("all");
const tag = ref<string | null>(null);
const sort = ref<CommunitySortKey>("trending");
const verifiedOnly = ref(false);
const includeNsfw = ref(false);
const compatibleOnly = ref(true);
const KIND_OPTIONS = [
{ label: "All kinds", value: "all" },
{ label: "Wildcards", value: "wildcard" },
{ label: "Fixed Values",value: "fixed_values" },
{ label: "Combines", value: "combine" },
{ label: "Derivations", value: "derivation" },
{ label: "Constraints", value: "constraint" },
{ label: "Pipelines", value: "pipeline" },
{ label: "Packs", value: "bundle" },
];
const SORT_OPTIONS: { label: string; value: CommunitySortKey }[] = [
{ label: "Trending", value: "trending" },
{ label: "Recently updated", value: "recent" },
{ label: "Most downloaded", value: "downloads" },
{ label: "Most starred", value: "stars" },
{ label: "Highest rated", value: "rating" },
];
const TAG_RAIL = getAllTags().slice(0, 14);
const featured = computed(() => store.featured);
async function reload() {
await store.search({
q: q.value,
kind: kind.value as never,
tag: tag.value,
sort: sort.value,
verified_only: verifiedOnly.value,
include_nsfw: includeNsfw.value,
compatible_only: compatibleOnly.value,
});
}
watch(
[q, kind, tag, sort, verifiedOnly, includeNsfw, compatibleOnly],
() => {
reload();
},
{ deep: true },
);
function clearFilters() {
q.value = "";
kind.value = "all";
tag.value = null;
sort.value = "trending";
verifiedOnly.value = false;
}
function openDetail(id: string) {
router.push({ name: "community-detail", params: { id } });
}
onMounted(async () => {
await Promise.all([store.loadFeatured(), reload()]);
if (store.apiStatus === "offline") {
router.replace({ name: "community-offline" });
}
});
</script>
<template>
<div class="wp-comm-page">
<header class="wp-comm-page__header">
<div>
<h1 class="wp-comm-page__title">Community</h1>
<p class="wp-comm-page__subtitle">
Discover, install, and share modules with the rest of the pipeline.
</p>
</div>
<div class="wp-comm-page__actions">
<Button
label="My profile"
icon="pi pi-user"
severity="secondary"
outlined
@click="router.push('/community/profile')"
/>
<Button
label="Publish"
icon="pi pi-upload"
@click="router.push('/community/upload')"
/>
</div>
</header>
<!-- Featured strip -->
<section v-if="featured.length" class="wp-comm-featured">
<div class="wp-comm-featured__head">
<h2 class="wp-comm-featured__label">
<i class="pi pi-bookmark-fill" aria-hidden="true" /> Featured this week
</h2>
<span class="wp-comm-featured__hint">Curated by the team</span>
</div>
<div class="wp-comm-featured__row">
<button
v-for="atom in featured.slice(0, 3)"
:key="atom.id"
type="button"
class="wp-comm-feat"
:style="{ background: atom.hero }"
@click="openDetail(atom.id)"
>
<div class="wp-comm-feat__inner">
<span class="wp-comm-card__kind">
<i :class="['pi', KIND_ICON[atom.type === 'bundle' ? 'bundle' : atom.type]]" aria-hidden="true" />
{{ KIND_LABEL[atom.type === "bundle" ? "bundle" : atom.type] }}
</span>
<h3>{{ atom.name }}</h3>
<p>{{ atom.tagline }}</p>
<div class="wp-comm-feat__stats">
<span><i class="pi pi-download" />{{ fmtNumber(atom.downloads) }}</span>
<span><i class="pi pi-star-fill" />{{ fmtNumber(atom.stars) }}</span>
<span><i class="pi pi-star" />{{ atom.rating.toFixed(1) }}</span>
</div>
</div>
</button>
</div>
</section>
<!-- Filter bar -->
<section class="wp-comm-filters">
<span class="wp-comm-filters__search p-input-icon-left">
<i class="pi pi-search" />
<InputText
v-model="q"
placeholder="Search modules, tags, authors..."
class="w-full"
aria-label="Search community"
/>
</span>
<Select
v-model="kind"
:options="KIND_OPTIONS"
option-label="label"
option-value="value"
placeholder="Kind"
class="w-40"
aria-label="Filter by kind"
/>
<Select
v-model="sort"
:options="SORT_OPTIONS"
option-label="label"
option-value="value"
placeholder="Sort"
class="w-44"
aria-label="Sort modules"
/>
<label class="wp-comm-filters__toggle">
<Checkbox v-model="verifiedOnly" :binary="true" /> Verified only
</label>
<label class="wp-comm-filters__toggle">
<Checkbox v-model="compatibleOnly" :binary="true" /> Compatible (engine {{ store.engineVersion }})
</label>
<label class="wp-comm-filters__toggle wp-comm-filters__toggle--nsfw">
<Checkbox v-model="includeNsfw" :binary="true" /> Show 18+
</label>
</section>
<!-- Tag rail -->
<div class="wp-comm-tagrail" role="group" aria-label="Filter by tag">
<button
type="button"
class="wp-comm-chip-btn"
:data-active="tag === null"
@click="tag = null"
>All tags</button>
<button
v-for="t in TAG_RAIL"
:key="t"
type="button"
class="wp-comm-chip-btn"
:data-active="tag === t"
@click="tag = tag === t ? null : t"
>{{ t }}</button>
</div>
<!-- Body -->
<div v-if="store.loading" class="wp-comm-grid" aria-busy="true">
<article v-for="n in 6" :key="n" class="wp-comm-card">
<div class="wp-comm-card__hero" style="background: var(--wp-bg-2)" />
<div class="wp-comm-card__body">
<div class="wp-comm-card__title">Loading...</div>
</div>
</article>
</div>
<div v-else-if="store.feed.length === 0" class="wp-comm-empty">
<i class="pi pi-search wp-comm-empty__icon" aria-hidden="true" />
<h3>{{ q ? `No matches for "${q}"` : "Nothing to show" }}</h3>
<p>Try clearing filters or broaden your search.</p>
<Button
class="mt-3"
label="Clear filters"
icon="pi pi-filter-slash"
severity="secondary"
outlined
@click="clearFilters"
/>
</div>
<div v-else class="wp-comm-grid">
<CommunityCard v-for="atom in store.feed" :key="atom.id" :atom="atom" />
</div>
</div>
</template>
<style scoped>
@import "../../community/community.css";
.w-full { width: 100%; }
.w-40 { width: 160px; }
.w-44 { width: 180px; }
.mt-3 { margin-top: 12px; }
</style>
@@ -0,0 +1,81 @@
<script setup lang="ts">
/**
* Community → Offline state.
*
* Mock-only — drives the `wp-community-force-offline` localStorage flag so
* the user can toggle the demo offline / online without fiddling with code.
*/
import { ref } from "vue";
import { useRouter } from "vue-router";
import Button from "primevue/button";
import { useCommunityStore } from "../../stores/communityStore";
import { isForceOffline, setForceOffline } from "../../community/mockApi";
const router = useRouter();
const store = useCommunityStore();
const forced = ref(isForceOffline());
async function retry() {
const status = await store.refreshApiStatus();
if (status === "online") {
router.replace({ name: "community-discover" });
}
}
async function forceOnline() {
setForceOffline(false);
forced.value = false;
await store.refreshApiStatus();
router.replace({ name: "community-discover" });
}
function forceOffline() {
setForceOffline(true);
forced.value = true;
store.setApiStatus("offline");
}
</script>
<template>
<div class="wp-comm-page">
<div class="wp-comm-empty">
<i class="pi pi-wifi wp-comm-empty__icon" style="color: var(--wp-danger);" aria-hidden="true" />
<h3>Registry unreachable</h3>
<p>
Community registry is offline. We'll keep retrying.
</p>
<div class="actions">
<Button label="Try again" icon="pi pi-refresh" @click="retry" />
<Button
v-if="forced"
label="Force online (demo)"
icon="pi pi-check"
severity="secondary"
outlined
@click="forceOnline"
/>
<Button
v-else
label="Force offline (demo)"
icon="pi pi-times-circle"
severity="secondary"
outlined
@click="forceOffline"
/>
</div>
</div>
</div>
</template>
<style scoped>
@import "../../community/community.css";
.actions {
display: flex;
gap: 8px;
justify-content: center;
margin-top: 16px;
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,205 @@
<script setup lang="ts">
/**
* Community → Profile screen.
*
* Sections: My uploads, Starred modules, Installed modules, Install history.
* All sourced from the community store + the in-memory mock catalog.
*/
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import Button from "primevue/button";
import { useCommunityStore } from "../../stores/communityStore";
import CommunityCard from "../../community/CommunityCard.vue";
import { findInCatalog } from "../../community/mockApi";
import { relativeTime } from "../../community/format";
import type { CommunityAtom } from "../../community/types";
const router = useRouter();
const store = useCommunityStore();
const tab = ref<"uploads" | "starred" | "installed" | "history">("uploads");
const uploadAtoms = computed<CommunityAtom[]>(() =>
Array.from(store.myUploads)
.map((id) => findInCatalog(id))
.filter((atom): atom is CommunityAtom => Boolean(atom)),
);
const starredAtoms = computed<CommunityAtom[]>(() =>
Array.from(store.starred)
.map((id) => findInCatalog(id))
.filter((atom): atom is CommunityAtom => Boolean(atom)),
);
const installedAtoms = computed<CommunityAtom[]>(() =>
Array.from(store.installed)
.map((id) => findInCatalog(id))
.filter((atom): atom is CommunityAtom => Boolean(atom)),
);
const tabs = computed(() => [
{ key: "uploads", label: `My uploads · ${uploadAtoms.value.length}` },
{ key: "starred", label: `Starred · ${starredAtoms.value.length}` },
{ key: "installed", label: `Installed · ${installedAtoms.value.length}` },
{ key: "history", label: `History · ${store.installHistory.length}` },
] as const);
function back() {
router.push({ name: "community-discover" });
}
onMounted(() => {
if (!store.currentUser) return;
});
</script>
<template>
<div class="wp-comm-page">
<Button
label="Back to Community"
icon="pi pi-arrow-left"
severity="secondary"
text
class="self-start"
@click="back"
/>
<div v-if="!store.currentUser" class="wp-comm-empty">
<i class="pi pi-user wp-comm-empty__icon" aria-hidden="true" />
<h3>Sign in to view your profile</h3>
<p>Use the GitHub button in the topbar to sign in (mock).</p>
</div>
<template v-else>
<section class="wp-comm-profile-hero">
<div
class="wp-comm-profile-avatar"
:style="{ backgroundImage: `url(${store.currentUser.avatar_url})` }"
aria-hidden="true"
/>
<div style="flex: 1;">
<h1 style="margin: 0;">{{ store.currentUser.name || store.currentUser.login }}</h1>
<div style="color: var(--wp-text-muted);">@{{ store.currentUser.login }} · joined just now</div>
<div class="wp-comm-profile-stats">
<div><strong>{{ uploadAtoms.length }}</strong><span>Uploads</span></div>
<div><strong>{{ starredAtoms.length }}</strong><span>Stars</span></div>
<div><strong>{{ installedAtoms.length }}</strong><span>Installed</span></div>
<div><strong>{{ store.installHistory.length }}</strong><span>Install history</span></div>
</div>
</div>
<div style="display: flex; gap: 8px;">
<Button label="Publish" icon="pi pi-upload" @click="router.push('/community/upload')" />
<Button label="Sign out" icon="pi pi-sign-out" severity="secondary" outlined @click="store.signOut()" />
</div>
</section>
<div class="wp-comm-tabs" role="tablist">
<button
v-for="t in tabs"
:key="t.key"
type="button"
class="wp-comm-tab"
role="tab"
:data-active="tab === t.key"
@click="tab = t.key"
>{{ t.label }}</button>
</div>
<div class="wp-comm-tabbody">
<div v-if="tab === 'uploads'">
<div v-if="!uploadAtoms.length" class="wp-comm-empty">
<i class="pi pi-upload wp-comm-empty__icon" aria-hidden="true" />
<h3>You haven't published anything yet.</h3>
<Button class="mt-3" label="Publish a module" icon="pi pi-upload" @click="router.push('/community/upload')" />
</div>
<div v-else class="wp-comm-grid">
<CommunityCard v-for="atom in uploadAtoms" :key="atom.id" :atom="atom" />
</div>
</div>
<div v-if="tab === 'starred'">
<div v-if="!starredAtoms.length" class="wp-comm-empty">
<i class="pi pi-star wp-comm-empty__icon" aria-hidden="true" />
<h3>No starred modules yet.</h3>
<p>Star modules to save them for later.</p>
</div>
<div v-else class="wp-comm-grid">
<CommunityCard v-for="atom in starredAtoms" :key="atom.id" :atom="atom" />
</div>
</div>
<div v-if="tab === 'installed'">
<div v-if="!installedAtoms.length" class="wp-comm-empty">
<i class="pi pi-download wp-comm-empty__icon" aria-hidden="true" />
<h3>No modules installed from the registry.</h3>
<p>Browse Community to find some.</p>
</div>
<div v-else class="wp-comm-grid">
<CommunityCard v-for="atom in installedAtoms" :key="atom.id" :atom="atom" />
</div>
</div>
<div v-if="tab === 'history'">
<div v-if="!store.installHistory.length" class="wp-comm-empty">
<i class="pi pi-clock wp-comm-empty__icon" aria-hidden="true" />
<h3>No install history yet.</h3>
</div>
<table v-else class="history-table">
<thead>
<tr><th>Module</th><th>Installed</th><th></th></tr>
</thead>
<tbody>
<tr v-for="entry in store.installHistory" :key="entry.id + entry.at">
<td>
<button
type="button"
class="link-btn"
@click="router.push({ name: 'community-detail', params: { id: entry.id } })"
>{{ findInCatalog(entry.id)?.name || entry.id }}</button>
</td>
<td style="color: var(--wp-text-muted);">{{ relativeTime(entry.at) }}</td>
<td style="text-align: right;">
<Button
label="Open"
severity="secondary"
text
@click="router.push({ name: 'community-detail', params: { id: entry.id } })"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</div>
</template>
<style scoped>
@import "../../community/community.css";
.self-start { align-self: flex-start; }
.mt-3 { margin-top: 12px; }
.history-table {
width: 100%;
border-collapse: collapse;
background: var(--wp-bg-1);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
overflow: hidden;
}
.history-table th, .history-table td {
padding: 10px 12px;
text-align: left;
font-size: 12.5px;
}
.history-table thead { background: var(--wp-bg-2); }
.history-table tr + tr td { border-top: 1px solid var(--wp-border); }
.link-btn {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--wp-accent-text);
cursor: pointer;
text-decoration: underline;
}
</style>
@@ -0,0 +1,388 @@
<script setup lang="ts">
/**
* Community → Upload wizard.
*
* Three-step wizard (Atom → Details → Review) with a final success state.
* Mock-only — submit echoes back through the store and routes to detail.
*/
import { computed, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import Button from "primevue/button";
import InputText from "primevue/inputtext";
import Textarea from "primevue/textarea";
import Select from "primevue/select";
import Checkbox from "primevue/checkbox";
import { useToast } from "primevue/usetoast";
import { useCommunityStore } from "../../stores/communityStore";
import { KIND_LABEL } from "../../community/format";
import type { CommunityUploadPayload } from "../../community/types";
const router = useRouter();
const store = useCommunityStore();
const toast = useToast();
const step = ref<1 | 2 | 3 | 4>(1);
const submitting = ref(false);
const newId = ref<string | null>(null);
const form = reactive<CommunityUploadPayload>({
atom: "single",
type: "wildcard",
name: "",
tagline: "",
description: "",
category: "subject",
license: "MIT",
engine_min_version: store.engineVersion,
nsfw: false,
tags: [],
readme: "",
version: "1.0.0",
});
const tagInput = ref("");
const KIND_OPTIONS = (
["wildcard", "fixed_values", "combine", "derivation", "constraint", "pipeline"] as const
).map((value) => ({ label: KIND_LABEL[value], value }));
const CATEGORY_OPTIONS = [
"subject", "outfit", "scene", "lighting", "style", "camera", "weather", "misc",
].map((c) => ({ label: c, value: c }));
const LICENSE_OPTIONS = ["MIT", "GPL-3", "Apache-2", "CC0", "CC-BY", "Custom"].map((value) => ({
label: value,
value,
}));
function addTag() {
const t = tagInput.value.trim().toLowerCase();
if (t && !form.tags.includes(t)) form.tags.push(t);
tagInput.value = "";
}
function removeTag(tag: string) {
const idx = form.tags.indexOf(tag);
if (idx >= 0) form.tags.splice(idx, 1);
}
const detailsValid = computed(
() => form.name.trim() && form.tagline.trim() && form.tags.length > 0,
);
async function submit() {
if (!detailsValid.value) return;
if (!store.currentUser) {
toast.add({ severity: "warn", summary: "Sign in to publish.", life: 2500 });
return;
}
submitting.value = true;
try {
const result = await store.upload(form);
newId.value = result.id;
step.value = 4;
toast.add({ severity: "success", summary: "Published", detail: result.name, life: 2500 });
} catch {
toast.add({ severity: "error", summary: "Publish failed", detail: "Registry unreachable.", life: 3000 });
} finally {
submitting.value = false;
}
}
function viewModule() {
if (!newId.value) return;
router.push({ name: "community-detail", params: { id: newId.value } });
}
function back() {
router.push({ name: "community-discover" });
}
function onTagKey(event: KeyboardEvent) {
if (event.key === "Enter") {
event.preventDefault();
addTag();
}
}
</script>
<template>
<div class="wp-comm-page">
<Button
label="Back"
icon="pi pi-arrow-left"
severity="secondary"
text
class="self-start"
@click="back"
/>
<header class="wp-comm-page__header">
<div>
<h1 class="wp-comm-page__title">Publish to Community</h1>
<p class="wp-comm-page__subtitle">
Share a module or pack with the registry. You can edit and version it later.
</p>
</div>
</header>
<div v-if="!store.currentUser" class="wp-comm-empty">
<i class="pi pi-github wp-comm-empty__icon" aria-hidden="true" />
<h3>Sign in to publish</h3>
<p>We use GitHub OAuth to verify uploaders. Use the button in the topbar.</p>
</div>
<template v-else>
<!-- Stepper -->
<div class="wp-comm-stepper">
<div
v-for="(label, i) in ['Atom', 'Details', 'Review']"
:key="label"
class="wp-comm-step"
:data-active="step === i + 1"
:data-done="step > i + 1"
>
<span class="wp-comm-step__num">
<i v-if="step > i + 1" class="pi pi-check" aria-hidden="true" />
<span v-else>{{ i + 1 }}</span>
</span>
<span>{{ label }}</span>
</div>
</div>
<!-- Step 1: Atom -->
<div v-if="step === 1" class="wp-card-body">
<h3>What are you publishing?</h3>
<div class="wp-comm-atom-grid">
<button
type="button"
class="wp-comm-atom"
:data-active="form.atom === 'single'"
@click="form.atom = 'single'"
>
<i class="pi pi-th-large" aria-hidden="true" />
<strong>Single module</strong>
<span>One Wildcard, Combine, Derivation, Constraint, or Fixed Values list.</span>
</button>
<button
type="button"
class="wp-comm-atom"
:data-active="form.atom === 'bundle'"
@click="form.atom = 'bundle'"
>
<i class="pi pi-box" aria-hidden="true" />
<strong>Module pack</strong>
<span>A bundle of related modules — like a full pipeline preset.</span>
</button>
</div>
<div v-if="form.atom === 'single'" class="form-row">
<label class="form-label">Module kind</label>
<Select
v-model="form.type"
:options="KIND_OPTIONS"
option-label="label"
option-value="value"
class="select-narrow"
aria-label="Module kind"
/>
</div>
<div class="form-actions">
<Button label="Next" icon="pi pi-arrow-right" icon-pos="right" @click="step = 2" />
</div>
</div>
<!-- Step 2: Details -->
<div v-if="step === 2" class="wp-card-body">
<div class="form-row">
<label class="form-label">Name *</label>
<InputText v-model="form.name" placeholder="Hair Color Pro" aria-label="Module name" />
</div>
<div class="form-row">
<label class="form-label">Tagline *</label>
<InputText v-model="form.tagline" placeholder="One sentence about what this does." aria-label="Tagline" />
</div>
<div class="form-grid">
<div>
<label class="form-label">Category</label>
<Select
v-model="form.category"
:options="CATEGORY_OPTIONS"
option-label="label"
option-value="value"
aria-label="Category"
/>
</div>
<div>
<label class="form-label">License</label>
<Select
v-model="form.license"
:options="LICENSE_OPTIONS"
option-label="label"
option-value="value"
aria-label="License"
/>
</div>
<div>
<label class="form-label">Min engine</label>
<InputText v-model="form.engine_min_version" aria-label="Minimum engine version" />
</div>
<div>
<label class="form-label">Version</label>
<InputText v-model="form.version" placeholder="1.0.0" aria-label="Version" />
</div>
</div>
<div class="form-row">
<label class="form-label">Tags *</label>
<div class="tag-row">
<InputText
v-model="tagInput"
placeholder="portrait, fashion, vibrant..."
aria-label="Tag input"
@keydown="onTagKey"
/>
<Button label="Add" severity="secondary" outlined @click="addTag" />
</div>
<div v-if="form.tags.length" class="tag-list">
<span v-for="t in form.tags" :key="t" class="wp-comm-card__tag">
{{ t }}
<button
type="button"
class="tag-remove"
aria-label="Remove tag"
@click="removeTag(t)"
><i class="pi pi-times" aria-hidden="true" /></button>
</span>
</div>
</div>
<div class="form-row">
<label class="form-label">README (Markdown)</label>
<Textarea
v-model="form.readme"
rows="8"
class="mono"
placeholder="# What's inside&#10;&#10;Describe the module..."
aria-label="README"
/>
</div>
<div class="form-row">
<label class="wp-comm-filters__toggle">
<Checkbox v-model="form.nsfw" :binary="true" /> Mark as 18+ (NSFW)
</label>
</div>
<div class="form-actions form-actions--split">
<Button label="Back" icon="pi pi-arrow-left" severity="secondary" @click="step = 1" />
<Button
label="Review"
icon="pi pi-arrow-right"
icon-pos="right"
:disabled="!detailsValid"
@click="step = 3"
/>
</div>
</div>
<!-- Step 3: Review -->
<div v-if="step === 3" class="wp-card-body">
<h3>Review before publishing</h3>
<div class="review-grid">
<div><span>Kind</span><strong>{{ form.atom === "bundle" ? "Pack" : KIND_LABEL[form.type] }}</strong></div>
<div><span>Name</span><strong>{{ form.name }}</strong></div>
<div><span>Tagline</span><strong>{{ form.tagline }}</strong></div>
<div><span>Category</span><strong>{{ form.category }}</strong></div>
<div><span>License</span><strong>{{ form.license }}</strong></div>
<div><span>Engine</span><strong>≥ {{ form.engine_min_version }}</strong></div>
<div><span>Version</span><strong>{{ form.version }}</strong></div>
<div><span>NSFW</span><strong>{{ form.nsfw ? "Yes" : "No" }}</strong></div>
<div class="review-grid__full"><span>Tags</span><strong>{{ form.tags.join(", ") }}</strong></div>
</div>
<p style="color: var(--wp-text-muted); font-size: 12px;">
By publishing, you agree to the registry's content guidelines. You can edit metadata or unpublish at any time from your profile.
</p>
<div class="form-actions form-actions--split">
<Button label="Back" icon="pi pi-arrow-left" severity="secondary" @click="step = 2" />
<Button
label="Publish"
icon="pi pi-upload"
:loading="submitting"
@click="submit"
/>
</div>
</div>
<!-- Step 4: Success -->
<div v-if="step === 4" class="wp-comm-empty">
<i class="pi pi-check-circle wp-comm-empty__icon" style="color: var(--wp-success);" aria-hidden="true" />
<h3>Published!</h3>
<p>Your module is live with id <code>{{ newId }}</code>.</p>
<div class="form-actions" style="justify-content: center;">
<Button label="View on Community" icon="pi pi-arrow-right" @click="viewModule" />
<Button label="Back to Discover" severity="secondary" outlined @click="back" />
</div>
</div>
</template>
</div>
</template>
<style scoped>
@import "../../community/community.css";
.self-start { align-self: flex-start; }
.wp-card-body {
background: var(--wp-bg-1);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.form-row { display: flex; flex-direction: column; gap: 6px; }
.form-label {
font-size: 12px;
color: var(--wp-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
}
.form-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.form-actions--split { justify-content: space-between; }
.tag-row { display: flex; gap: 6px; }
.tag-list { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.tag-remove {
background: none;
border: none;
margin-left: 4px;
padding: 0;
cursor: pointer;
color: inherit;
}
.review-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.review-grid > div {
display: flex;
flex-direction: column;
gap: 2px;
background: var(--wp-bg-2);
border-radius: var(--wp-radius-sm);
padding: 8px 10px;
font-size: 12px;
color: var(--wp-text-muted);
}
.review-grid > div strong { color: var(--wp-text); font-size: 13px; }
.review-grid__full { grid-column: 1 / -1; }
.select-narrow { max-width: 240px; }
.mono :deep(textarea) { font-family: var(--wp-font-mono); font-size: 12px; }
</style>