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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.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("");
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 & 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 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>
|
||||
Reference in New Issue
Block a user