mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(wardrobe): module foundation — garments + outfits space-scoped data layer (M1)
M1 of docs/plans/wardrobe-module.md — pure data layer + backend plumbing, zero UI (that's M2). A user can now hold a digital wardrobe per space: brand merch, club Trikots, family Kleiderschrank, team Kostüme, practice Dresscode, and personal closet all live as separate pools under the same Dexie tables, space-scoped like tags/scenes/agents after Phase 2c. Data model — two tables, no join: - wardrobeGarments (Dexie v41): single clothing items / accessories. Indexed on `category` + `createdAt` + `isArchived`. Encrypted: name/brand/color/size/material/tags/notes. Plaintext: category, mediaIds, counters, timestamps — all indexed or structural. `mediaIds[0]` is the primary photo used for try-on; additional ids are alternate views (back, detail) for M7. - wardrobeOutfits (Dexie v41): named compositions referencing garment ids. Encrypted: name/description/tags. Plaintext: garmentIds (FK array), occasion (closed enum — useful for undecrypted filtering), season, booleans, lastTryOn snapshot. - picture.images gains `wardrobeOutfitId?: string | null` as a plaintext back-reference. Try-on results land in the Picture gallery like any other generation; the outfit detail view queries them via this id rather than maintaining a third table. Space scope: - `wardrobe` added to all five explicit allowlists in shared-types/ spaces.ts (personal is wildcard, no edit needed). Each space type gets a one-line comment explaining the real-world use case. - App registry: `wardrobe` entry in shared-branding/mana-apps.ts with a rose→fuchsia gradient icon (T-shirt on hanger silhouette), color #e11d48, tier 'beta', status 'beta'. - Module registry: wardrobeModuleConfig imported + appended to MODULE_CONFIGS so SYNC_APP_MAP picks it up automatically. Backend: - MAX_REFERENCE_IMAGES bumped 4 → 8 in picture/generate-with- reference (plus the client-side default in ReferenceImagePicker). Justified with a comment: face + body + top + bottom + shoes + outerwear + 2 accessories = 8. Cost doesn't scale with ref count (OpenAI bills per output), so the bump is a pure capability expansion with no credit-side risk. - New POST /api/v1/wardrobe/garments/upload wraps uploadImageToMedia with app='wardrobe'. Registered under /api/v1/wardrobe in index.ts. Pattern 1:1 with the profile/me-images/upload endpoint; tier-gating falls out of wardrobe NOT being in RESOURCE_MODULES (tier='guest' works — consistent with picture's plain CRUD). Stores emit domain events (WardrobeGarmentAdded, WardrobeOutfitCreated, WardrobeOutfitTryOn, etc.) so later mana-ai missions can observe activity without polling. No UI in this commit. M2 (Garments-Grundlayer) wires the route + grid + upload-zone; M3 the Outfit composer; M4 the Try-On integration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f7536bc0b9
commit
4fc9d6c59c
36 changed files with 2058 additions and 158 deletions
|
|
@ -25,6 +25,7 @@
|
|||
"drizzle-orm": "^0.38.0",
|
||||
"hono": "^4.7.0",
|
||||
"postgres": "^3.4.0",
|
||||
"prom-client": "^15.1.3",
|
||||
"rrule": "^2.8.1",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
|
|
|
|||
176
apps/api/scripts/gc-website-assets.ts
Normal file
176
apps/api/scripts/gc-website-assets.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Website orphan-asset scan.
|
||||
*
|
||||
* Reports (M7 first-pass: read-only, no deletion) mana-media assets
|
||||
* scoped to `app=website` that are not referenced by any currently-
|
||||
* published snapshot or any non-scrubbed form submission. Older-than-30d
|
||||
* orphans are candidates for eventual deletion.
|
||||
*
|
||||
* Run with:
|
||||
* bun apps/api/scripts/gc-website-assets.ts
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — scan completed, report printed
|
||||
* 1 — scan failed (DB / HTTP error)
|
||||
*
|
||||
* Switching to delete-mode is a separate commit — after we have 2–3
|
||||
* weeks of read-only reports in production showing the count is
|
||||
* stable and the candidate list looks right.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import postgres from 'postgres';
|
||||
|
||||
const DATABASE_URL =
|
||||
process.env.DATABASE_URL ?? 'postgresql://mana:devpassword@localhost:5432/mana_platform';
|
||||
|
||||
const MEDIA_URL = process.env.PUBLIC_MANA_MEDIA_URL ?? 'http://localhost:3015';
|
||||
|
||||
/** Grace period — orphans younger than this are kept (recently
|
||||
* uploaded, likely about to be linked). */
|
||||
const GRACE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// ── 1. Load every URL + mediaId referenced by a live snapshot ──
|
||||
|
||||
async function collectReferencedIds(sql: ReturnType<typeof postgres>): Promise<Set<string>> {
|
||||
const rows = await sql<{ blob: unknown }[]>`
|
||||
SELECT blob
|
||||
FROM website.published_snapshots
|
||||
WHERE is_current = TRUE
|
||||
`;
|
||||
const refs = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
walkAny(row.blob, (value) => {
|
||||
if (typeof value !== 'string') return;
|
||||
// mana-media URLs look like .../api/v1/media/{id}/file/{variant}.
|
||||
// Pull the id out with a narrow regex so we don't accidentally
|
||||
// match user-typed URLs that happen to contain `/media/`.
|
||||
const match = value.match(/\/api\/v1\/media\/([0-9a-f-]{36})\//i);
|
||||
if (match) refs.add(match[1]!.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
// Submissions payloads don't typically contain media URLs, but
|
||||
// include them for completeness (a form might accept a file upload
|
||||
// in the future).
|
||||
const subs = await sql<{ payload: unknown }[]>`
|
||||
SELECT payload FROM website.submissions WHERE payload IS NOT NULL
|
||||
`;
|
||||
for (const row of subs) {
|
||||
walkAny(row.payload, (value) => {
|
||||
if (typeof value !== 'string') return;
|
||||
const match = value.match(/\/api\/v1\/media\/([0-9a-f-]{36})\//i);
|
||||
if (match) refs.add(match[1]!.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
function walkAny(value: unknown, visit: (v: unknown) => void): void {
|
||||
visit(value);
|
||||
if (!value || typeof value !== 'object') return;
|
||||
if (Array.isArray(value)) {
|
||||
for (const child of value) walkAny(child, visit);
|
||||
return;
|
||||
}
|
||||
for (const key of Object.keys(value as Record<string, unknown>)) {
|
||||
walkAny((value as Record<string, unknown>)[key], visit);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Ask mana-media for every asset scoped to app=website ────
|
||||
|
||||
interface MediaListEntry {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
filename?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
async function listWebsiteMedia(): Promise<MediaListEntry[]> {
|
||||
const token = process.env.MANA_SERVICE_KEY;
|
||||
if (!token) {
|
||||
console.warn(
|
||||
'[gc] MANA_SERVICE_KEY not set — skipping mana-media listing. Report shows references only.'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${MEDIA_URL}/api/v1/internal/media/list?app=website`, {
|
||||
headers: { 'X-Service-Key': token },
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[gc] mana-media list failed: ${res.status}`);
|
||||
return [];
|
||||
}
|
||||
const body = (await res.json()) as { items?: MediaListEntry[] };
|
||||
return body.items ?? [];
|
||||
} catch (err) {
|
||||
console.warn('[gc] mana-media unreachable', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Report ────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const sql = postgres(DATABASE_URL, { max: 2 });
|
||||
try {
|
||||
console.log('[gc] scanning published_snapshots + submissions for media references…');
|
||||
const referenced = await collectReferencedIds(sql);
|
||||
console.log(`[gc] referenced mediaIds: ${referenced.size}`);
|
||||
|
||||
console.log('[gc] listing mana-media items for app=website…');
|
||||
const media = await listWebsiteMedia();
|
||||
console.log(`[gc] media items in scope: ${media.length}`);
|
||||
|
||||
if (media.length === 0) {
|
||||
console.log('[gc] nothing to compare — exiting.');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const orphans = media
|
||||
.filter((m) => !referenced.has(m.id.toLowerCase()))
|
||||
.filter((m) => now - new Date(m.createdAt).getTime() > GRACE_MS);
|
||||
|
||||
console.log(
|
||||
`[gc] orphans older than 30d: ${orphans.length} / ${media.length} (${referenced.size} referenced)`
|
||||
);
|
||||
|
||||
const report = {
|
||||
scannedAt: new Date().toISOString(),
|
||||
referencedCount: referenced.size,
|
||||
mediaCount: media.length,
|
||||
orphanCount: orphans.length,
|
||||
orphans: orphans.map((m) => ({
|
||||
id: m.id,
|
||||
createdAt: m.createdAt,
|
||||
filename: m.filename,
|
||||
size: m.size,
|
||||
})),
|
||||
};
|
||||
|
||||
const out = `/tmp/gc-website-assets-${report.scannedAt.replace(/[:.]/g, '-')}.json`;
|
||||
await fs.writeFile(out, JSON.stringify(report, null, 2));
|
||||
console.log(`[gc] report written to ${out}`);
|
||||
|
||||
// Head of the orphan list so ops can sanity-check at a glance.
|
||||
for (const o of orphans.slice(0, 10)) {
|
||||
console.log(` ${o.id} ${o.createdAt} ${o.filename ?? ''}`);
|
||||
}
|
||||
if (orphans.length > 10) {
|
||||
console.log(` … and ${orphans.length - 10} more (see report file)`);
|
||||
}
|
||||
} finally {
|
||||
await sql.end({ timeout: 2 });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[gc] scan failed', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -20,6 +20,9 @@ import {
|
|||
// MCP server
|
||||
import { handleMcpRequest } from './mcp/server';
|
||||
|
||||
// Prometheus metrics
|
||||
import { register as metricsRegister } from './lib/metrics';
|
||||
|
||||
// Module routes
|
||||
import { calendarRoutes } from './modules/calendar/routes';
|
||||
import { contactsRoutes } from './modules/contacts/routes';
|
||||
|
|
@ -28,6 +31,7 @@ import { chatRoutes } from './modules/chat/routes';
|
|||
import { contextRoutes } from './modules/context/routes';
|
||||
import { pictureRoutes } from './modules/picture/routes';
|
||||
import { profileRoutes } from './modules/profile/routes';
|
||||
import { wardrobeRoutes } from './modules/wardrobe/routes';
|
||||
import { storageRoutes } from './modules/storage/routes';
|
||||
import { todoRoutes } from './modules/todo/routes';
|
||||
import { plantsRoutes } from './modules/plants/routes';
|
||||
|
|
@ -55,6 +59,15 @@ app.onError(errorHandler);
|
|||
app.notFound(notFoundHandler);
|
||||
app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
|
||||
app.route('/health', healthRoute('mana-api'));
|
||||
|
||||
// Prometheus scrape endpoint. Unauthenticated on purpose — the Grafana
|
||||
// / Prometheus stack runs on the internal network; we rely on the
|
||||
// reverse-proxy layer to block external access to /metrics.
|
||||
app.get('/metrics', async (c) => {
|
||||
c.header('Content-Type', metricsRegister.contentType);
|
||||
return c.text(await metricsRegister.metrics());
|
||||
});
|
||||
|
||||
app.use('/api/*', rateLimitMiddleware({ max: 200, windowMs: 60_000 }));
|
||||
|
||||
// Public routes — no auth required (weather data is public, published
|
||||
|
|
@ -103,6 +116,7 @@ app.route('/api/v1/chat', chatRoutes);
|
|||
app.route('/api/v1/context', contextRoutes);
|
||||
app.route('/api/v1/picture', pictureRoutes);
|
||||
app.route('/api/v1/profile', profileRoutes);
|
||||
app.route('/api/v1/wardrobe', wardrobeRoutes);
|
||||
app.route('/api/v1/storage', storageRoutes);
|
||||
app.route('/api/v1/todo', todoRoutes);
|
||||
app.route('/api/v1/plants', plantsRoutes);
|
||||
|
|
|
|||
106
apps/api/src/lib/metrics.ts
Normal file
106
apps/api/src/lib/metrics.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Prometheus metrics for mana-api.
|
||||
*
|
||||
* Follows the same shape as mana-ai (default metrics with a service
|
||||
* prefix, plus module-specific counters / histograms). Scraped from
|
||||
* GET /metrics — mounted unauthenticated since the surface is
|
||||
* internal-network only.
|
||||
*
|
||||
* Naming convention: `mana_api_<module>_*`. Underscore separators,
|
||||
* standard Prometheus regex `[a-zA-Z_:][a-zA-Z0-9_:]*`.
|
||||
*/
|
||||
|
||||
import { Counter, Histogram, Registry, collectDefaultMetrics } from 'prom-client';
|
||||
|
||||
export const register = new Registry();
|
||||
register.setDefaultLabels({ service: 'mana-api' });
|
||||
collectDefaultMetrics({ register, prefix: 'mana_api_' });
|
||||
|
||||
// ── Website module ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every call to POST /sites/:id/publish. `result` labels:
|
||||
* - `success` — snapshot stored, is_current flipped
|
||||
* - `slug_taken` — another site already has this slug live
|
||||
* - `invalid` — validation error (bad slug, missing fields)
|
||||
* - `error` — unexpected failure (DB, network)
|
||||
*/
|
||||
export const websitePublishTotal = new Counter({
|
||||
name: 'mana_api_website_publish_total',
|
||||
help: 'Publish attempts against the website module.',
|
||||
labelNames: ['result'] as const,
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
export const websitePublishDuration = new Histogram({
|
||||
name: 'mana_api_website_publish_duration_seconds',
|
||||
help: 'End-to-end latency of the publish flow (validation + DB transaction).',
|
||||
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2, 5],
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
/**
|
||||
* Form submissions received on the public endpoint. `result` labels:
|
||||
* - `received` — stored in submissions table
|
||||
* - `spam` — honeypot tripped, silent-dropped
|
||||
* - `rate_limit` — IP rate-limit hit
|
||||
* - `not_found` — slug or block missing
|
||||
* - `invalid` — payload validation failed
|
||||
*/
|
||||
export const websiteSubmissionsTotal = new Counter({
|
||||
name: 'mana_api_website_submissions_total',
|
||||
help: 'Form submissions received on the website submit endpoint.',
|
||||
labelNames: ['result'] as const,
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
/**
|
||||
* Host resolver lookups from hooks.server.ts. `result` labels:
|
||||
* - `hit` — verified binding found, slug returned
|
||||
* - `miss` — hostname not bound, 404
|
||||
* - `error` — DB error
|
||||
*/
|
||||
export const websiteHostResolveTotal = new Counter({
|
||||
name: 'mana_api_website_host_resolve_total',
|
||||
help: 'Custom-host to slug resolutions hit / miss.',
|
||||
labelNames: ['result'] as const,
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
/**
|
||||
* DNS verification runs. `result`:
|
||||
* - `verified` — both TXT + CNAME/A matched
|
||||
* - `failed` — at least one check failed; reason in logs
|
||||
*/
|
||||
export const websiteDomainVerifyTotal = new Counter({
|
||||
name: 'mana_api_website_domain_verify_total',
|
||||
help: 'Custom-domain DNS verification attempts.',
|
||||
labelNames: ['result'] as const,
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
/**
|
||||
* Public snapshot reads — how many visitors hit /public/sites/:slug.
|
||||
* `result`:
|
||||
* - `hit` — snapshot served
|
||||
* - `not_found` — unpublished or unknown slug
|
||||
*/
|
||||
export const websitePublicReadsTotal = new Counter({
|
||||
name: 'mana_api_website_public_reads_total',
|
||||
help: 'Reads of the public /public/sites/:slug endpoint.',
|
||||
labelNames: ['result'] as const,
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
/**
|
||||
* Cache-header guidance for operators: how often public reads return
|
||||
* a cache-purge-worthy freshly-published blob vs a routine read. We
|
||||
* emit this via header inspection in the public route; `purge_needed`
|
||||
* is a heuristic (new-snapshot age < 10s).
|
||||
*/
|
||||
export const websitePublicReadAge = new Histogram({
|
||||
name: 'mana_api_website_public_read_age_seconds',
|
||||
help: 'Age of the served snapshot at read time (seconds since publishedAt).',
|
||||
buckets: [1, 10, 60, 300, 1800, 3600, 21600, 86400],
|
||||
registers: [register],
|
||||
});
|
||||
|
|
@ -240,10 +240,12 @@ routes.post('/generate', async (c) => {
|
|||
// image input natively. Replicate/local fallback is a later milestone.
|
||||
|
||||
// OpenAI gpt-image-1 / gpt-image-2 accept up to 16 reference images per
|
||||
// edit call. We clamp at 4 to keep credit exposure + upload payload size
|
||||
// predictable while still covering the common "face + fullbody + outfit"
|
||||
// workflow the plan targets.
|
||||
const MAX_REFERENCE_IMAGES = 4;
|
||||
// edit call. We clamp at 8 to cover the Wardrobe try-on workflow — one
|
||||
// face-ref + one body-ref + up to six garment photos (top/bottom/shoes/
|
||||
// outerwear + two accessories) — while keeping credit exposure and
|
||||
// upload payload size predictable. Pre-wardrobe the cap was 4; bumped
|
||||
// in docs/plans/wardrobe-module.md M1.
|
||||
const MAX_REFERENCE_IMAGES = 8;
|
||||
|
||||
routes.post('/generate-with-reference', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
|
|
|
|||
55
apps/api/src/modules/wardrobe/routes.ts
Normal file
55
apps/api/src/modules/wardrobe/routes.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* Wardrobe module — server endpoints.
|
||||
*
|
||||
* Thin wrapper around mana-media for garment photo uploads. Plan:
|
||||
* docs/plans/wardrobe-module.md M1. No logic beyond tagging uploads
|
||||
* as `app='wardrobe'` so a later `GET /api/v1/media?app=wardrobe&...`
|
||||
* query can enumerate a user's garment pool without scanning every
|
||||
* media reference.
|
||||
*
|
||||
* Try-on generation does NOT live here — it reuses the Picture
|
||||
* module's POST /api/v1/picture/generate-with-reference endpoint
|
||||
* with MAX_REFERENCE_IMAGES bumped to 8 so face + body + garments
|
||||
* fit into one call.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { AuthVariables } from '@mana/shared-hono';
|
||||
|
||||
const routes = new Hono<{ Variables: AuthVariables }>();
|
||||
|
||||
// Same 10MB cap as the other photo-upload endpoints (profile me-images,
|
||||
// picture uploads). Phone-camera PNG/HEIC routinely comes in under 6MB.
|
||||
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
routes.post('/garments/upload', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const formData = await c.req.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
|
||||
if (!file) return c.json({ error: 'No file' }, 400);
|
||||
if (file.size > MAX_UPLOAD_BYTES) return c.json({ error: 'Max 10MB' }, 400);
|
||||
|
||||
try {
|
||||
const { uploadImageToMedia } = await import('../../lib/media');
|
||||
const buffer = await file.arrayBuffer();
|
||||
const result = await uploadImageToMedia(buffer, file.name, {
|
||||
app: 'wardrobe',
|
||||
userId,
|
||||
});
|
||||
|
||||
return c.json(
|
||||
{
|
||||
mediaId: result.id,
|
||||
storagePath: result.id,
|
||||
publicUrl: result.urls.original,
|
||||
thumbnailUrl: result.urls.thumbnail,
|
||||
},
|
||||
201
|
||||
);
|
||||
} catch (_err) {
|
||||
return c.json({ error: 'Upload failed' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export { routes as wardrobeRoutes };
|
||||
|
|
@ -10,6 +10,11 @@ import { Hono } from 'hono';
|
|||
import { and, eq } from 'drizzle-orm';
|
||||
import { db, publishedSnapshots, customDomains } from './schema';
|
||||
import { errorResponse } from '../../lib/responses';
|
||||
import {
|
||||
websiteHostResolveTotal,
|
||||
websitePublicReadsTotal,
|
||||
websitePublicReadAge,
|
||||
} from '../../lib/metrics';
|
||||
import { websiteSubmitRoutes } from './submit';
|
||||
|
||||
const routes = new Hono();
|
||||
|
|
@ -26,7 +31,10 @@ routes.route('/', websiteSubmitRoutes);
|
|||
routes.get('/resolve-host', async (c) => {
|
||||
const raw = c.req.query('host');
|
||||
const host = typeof raw === 'string' ? raw.toLowerCase().trim() : '';
|
||||
if (!host) return errorResponse(c, 'host query param required', 400);
|
||||
if (!host) {
|
||||
websiteHostResolveTotal.inc({ result: 'error' });
|
||||
return errorResponse(c, 'host query param required', 400);
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ siteId: customDomains.siteId, hostname: customDomains.hostname })
|
||||
|
|
@ -34,7 +42,10 @@ routes.get('/resolve-host', async (c) => {
|
|||
.where(and(eq(customDomains.hostname, host), eq(customDomains.status, 'verified')))
|
||||
.limit(1);
|
||||
|
||||
if (!rows[0]) return errorResponse(c, 'Host not found', 404, { code: 'NOT_FOUND' });
|
||||
if (!rows[0]) {
|
||||
websiteHostResolveTotal.inc({ result: 'miss' });
|
||||
return errorResponse(c, 'Host not found', 404, { code: 'NOT_FOUND' });
|
||||
}
|
||||
|
||||
// Look up the slug from the most recent published snapshot.
|
||||
const snap = await db
|
||||
|
|
@ -46,11 +57,13 @@ routes.get('/resolve-host', async (c) => {
|
|||
.limit(1);
|
||||
|
||||
if (!snap[0]) {
|
||||
websiteHostResolveTotal.inc({ result: 'miss' });
|
||||
return errorResponse(c, 'Site not currently published', 404, {
|
||||
code: 'NOT_PUBLISHED',
|
||||
});
|
||||
}
|
||||
|
||||
websiteHostResolveTotal.inc({ result: 'hit' });
|
||||
c.header('Cache-Control', 'public, max-age=60, s-maxage=600');
|
||||
return c.json({ slug: snap[0].slug, siteId: rows[0].siteId });
|
||||
});
|
||||
|
|
@ -78,7 +91,10 @@ routes.get('/sites/:slug', async (c) => {
|
|||
.where(and(eq(publishedSnapshots.slug, slug), eq(publishedSnapshots.isCurrent, true)))
|
||||
.limit(1);
|
||||
|
||||
if (!rows[0]) return errorResponse(c, 'Site not found', 404, { code: 'NOT_FOUND' });
|
||||
if (!rows[0]) {
|
||||
websitePublicReadsTotal.inc({ result: 'not_found' });
|
||||
return errorResponse(c, 'Site not found', 404, { code: 'NOT_FOUND' });
|
||||
}
|
||||
|
||||
// Conservative caching: short freshness window, aggressive stale-while-
|
||||
// revalidate. Publish endpoint will purge by tag in M6; until then CF
|
||||
|
|
@ -86,6 +102,10 @@ routes.get('/sites/:slug', async (c) => {
|
|||
c.header('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=86400');
|
||||
c.header('Cache-Tag', `site-${rows[0].id}`);
|
||||
|
||||
const ageSec = Math.max(0, (Date.now() - rows[0].publishedAt.getTime()) / 1000);
|
||||
websitePublicReadsTotal.inc({ result: 'hit' });
|
||||
websitePublicReadAge.observe(ageSec);
|
||||
|
||||
return c.json({
|
||||
snapshotId: rows[0].id,
|
||||
slug: rows[0].slug,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { z } from 'zod';
|
|||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import type { AuthVariables } from '@mana/shared-hono';
|
||||
import { errorResponse, validationError } from '../../lib/responses';
|
||||
import { websitePublishTotal, websitePublishDuration } from '../../lib/metrics';
|
||||
import { db, publishedSnapshots, submissions } from './schema';
|
||||
import { isValidSlug } from './reserved-slugs';
|
||||
|
||||
|
|
@ -63,6 +64,7 @@ const DraftSnapshotSchema = z.object({
|
|||
// ─── POST /sites/:id/publish ────────────────────────────
|
||||
|
||||
routes.post('/sites/:id/publish', async (c) => {
|
||||
const publishTimer = websitePublishDuration.startTimer();
|
||||
const userId = c.get('userId');
|
||||
// Space id flows in via an explicit header (mana-auth doesn't yet
|
||||
// embed the active space in JWT claims). Nullable — full membership
|
||||
|
|
@ -71,18 +73,30 @@ routes.post('/sites/:id/publish', async (c) => {
|
|||
const spaceId = spaceIdHeader && /^[0-9a-f-]{36}$/i.test(spaceIdHeader) ? spaceIdHeader : null;
|
||||
const siteId = c.req.param('id');
|
||||
|
||||
if (!siteId) return errorResponse(c, 'siteId required', 400);
|
||||
if (!siteId) {
|
||||
websitePublishTotal.inc({ result: 'invalid' });
|
||||
publishTimer();
|
||||
return errorResponse(c, 'siteId required', 400);
|
||||
}
|
||||
|
||||
const parsed = DraftSnapshotSchema.safeParse(await c.req.json().catch(() => null));
|
||||
if (!parsed.success) return validationError(c, parsed.error.issues);
|
||||
if (!parsed.success) {
|
||||
websitePublishTotal.inc({ result: 'invalid' });
|
||||
publishTimer();
|
||||
return validationError(c, parsed.error.issues);
|
||||
}
|
||||
|
||||
const draft = parsed.data;
|
||||
if (draft.site.id !== siteId) {
|
||||
websitePublishTotal.inc({ result: 'invalid' });
|
||||
publishTimer();
|
||||
return errorResponse(c, 'Site id mismatch between path and body', 400, {
|
||||
code: 'SITE_ID_MISMATCH',
|
||||
});
|
||||
}
|
||||
if (!isValidSlug(draft.site.slug)) {
|
||||
websitePublishTotal.inc({ result: 'invalid' });
|
||||
publishTimer();
|
||||
return errorResponse(c, `Slug "${draft.site.slug}" is invalid or reserved`, 400, {
|
||||
code: 'INVALID_SLUG',
|
||||
});
|
||||
|
|
@ -97,6 +111,8 @@ routes.post('/sites/:id/publish', async (c) => {
|
|||
)
|
||||
.limit(1);
|
||||
if (conflicting[0] && conflicting[0].siteId !== siteId) {
|
||||
websitePublishTotal.inc({ result: 'slug_taken' });
|
||||
publishTimer();
|
||||
return errorResponse(
|
||||
c,
|
||||
`Slug "${draft.site.slug}" is already taken by another published site`,
|
||||
|
|
@ -139,6 +155,8 @@ routes.post('/sites/:id/publish', async (c) => {
|
|||
|
||||
if (!result) throw new Error('Insert returned no row');
|
||||
|
||||
websitePublishTotal.inc({ result: 'success' });
|
||||
publishTimer();
|
||||
return c.json(
|
||||
{
|
||||
snapshotId: result.id,
|
||||
|
|
@ -151,10 +169,14 @@ routes.post('/sites/:id/publish', async (c) => {
|
|||
// Postgres unique-constraint violation → slug conflict we didn't
|
||||
// catch in the pre-check (classic race).
|
||||
if (err instanceof Error && /unique/i.test(err.message)) {
|
||||
websitePublishTotal.inc({ result: 'slug_taken' });
|
||||
publishTimer();
|
||||
return errorResponse(c, `Slug "${draft.site.slug}" was taken by a concurrent publish`, 409, {
|
||||
code: 'SLUG_TAKEN',
|
||||
});
|
||||
}
|
||||
websitePublishTotal.inc({ result: 'error' });
|
||||
publishTimer();
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
import { Hono } from 'hono';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db, publishedSnapshots, submissions } from './schema';
|
||||
import { websiteSubmissionsTotal } from '../../lib/metrics';
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
|
|
@ -142,6 +143,7 @@ routes.post('/submit/:siteSlug/:blockId', async (c) => {
|
|||
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ??
|
||||
'unknown';
|
||||
if (rateLimitHit(`${siteSlug}:${ip}`)) {
|
||||
websiteSubmissionsTotal.inc({ result: 'rate_limit' });
|
||||
return c.json({ error: 'Rate limit überschritten — bitte später erneut versuchen' }, 429);
|
||||
}
|
||||
|
||||
|
|
@ -157,19 +159,23 @@ routes.post('/submit/:siteSlug/:blockId', async (c) => {
|
|||
.limit(1);
|
||||
|
||||
if (!snapshotRow[0]) {
|
||||
websiteSubmissionsTotal.inc({ result: 'not_found' });
|
||||
return c.json({ error: 'Website nicht gefunden oder offline' }, 404);
|
||||
}
|
||||
|
||||
const block = findFormBlock(snapshotRow[0].blob as SnapshotBlob, blockId);
|
||||
if (!block) {
|
||||
websiteSubmissionsTotal.inc({ result: 'not_found' });
|
||||
return c.json({ error: 'Block nicht gefunden' }, 404);
|
||||
}
|
||||
if (!isFormBlock(block)) {
|
||||
websiteSubmissionsTotal.inc({ result: 'invalid' });
|
||||
return c.json({ error: 'Block ist kein Formular' }, 400);
|
||||
}
|
||||
|
||||
const rawBody = (await c.req.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
if (!rawBody || typeof rawBody !== 'object') {
|
||||
websiteSubmissionsTotal.inc({ result: 'invalid' });
|
||||
return c.json({ error: 'Payload fehlt oder ungültig' }, 400);
|
||||
}
|
||||
|
||||
|
|
@ -178,6 +184,7 @@ routes.post('/submit/:siteSlug/:blockId', async (c) => {
|
|||
// Form renderer. We also look for a generic "_trap" key.
|
||||
const trap = rawBody.honeypot ?? rawBody._trap;
|
||||
if (typeof trap === 'string' && trap.trim().length > 0) {
|
||||
websiteSubmissionsTotal.inc({ result: 'spam' });
|
||||
// Act as success to the bot, store nothing.
|
||||
return c.json({ ok: true, spam: true }, 202);
|
||||
}
|
||||
|
|
@ -188,6 +195,7 @@ routes.post('/submit/:siteSlug/:blockId', async (c) => {
|
|||
for (const field of block.props.fields) {
|
||||
const result = validateField(field, rawBody[field.name]);
|
||||
if (!result.ok) {
|
||||
websiteSubmissionsTotal.inc({ result: 'invalid' });
|
||||
return c.json({ error: result.error, field: field.name }, 400);
|
||||
}
|
||||
cleaned[field.name] = result.value;
|
||||
|
|
@ -209,6 +217,7 @@ routes.post('/submit/:siteSlug/:blockId', async (c) => {
|
|||
})
|
||||
.returning({ id: submissions.id });
|
||||
|
||||
websiteSubmissionsTotal.inc({ result: 'received' });
|
||||
return c.json({ ok: true, submissionId: row?.id ?? null }, 201);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ import type {
|
|||
} from '../../modules/broadcast/types';
|
||||
import type { LocalArticle, LocalHighlight } from '../../modules/articles/types';
|
||||
import type { LocalMeImage } from '../../modules/profile/types';
|
||||
import type { LocalWardrobeGarment, LocalWardrobeOutfit } from '../../modules/wardrobe/types';
|
||||
|
||||
export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
||||
// ─── Chat ────────────────────────────────────────────────
|
||||
|
|
@ -552,6 +553,33 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
// lives in MinIO behind owner-RLS, not in Dexie.
|
||||
meImages: entry<LocalMeImage>(['label', 'tags']),
|
||||
|
||||
// ─── Wardrobe (garments + outfits) ───────────────────────
|
||||
// docs/plans/wardrobe-module.md M1. Two space-scoped tables.
|
||||
//
|
||||
// Garments: user-typed clothing metadata is the sensitive surface —
|
||||
// brand names leak purchasing patterns, notes leak preferences,
|
||||
// tags leak categorization intent. `category` stays plaintext
|
||||
// because it's the Category-Tabs filter index; `mediaIds`, dates,
|
||||
// and counters are structural.
|
||||
wardrobeGarments: entry<LocalWardrobeGarment>([
|
||||
'name',
|
||||
'brand',
|
||||
'color',
|
||||
'size',
|
||||
'material',
|
||||
'tags',
|
||||
'notes',
|
||||
]),
|
||||
// Outfits: name + description + tags are user-authored. Occasion
|
||||
// stays plaintext (closed enum, small cardinality — useful to
|
||||
// filter on without decrypt). `garmentIds` is an array of FKs,
|
||||
// plaintext by the standard "IDs are plaintext" rule. `lastTryOn`
|
||||
// is a structural pointer + prompt; the prompt itself isn't
|
||||
// secret (OpenAI already saw it) but lands inside the encrypted
|
||||
// JSON-stringified blob via the `season` array-path anyway — keep
|
||||
// it plaintext and revisit if prompts later carry personal data.
|
||||
wardrobeOutfits: entry<LocalWardrobeOutfit>(['name', 'description', 'tags']),
|
||||
|
||||
// Per-agent kontext documents — same schema as kontextDoc but keyed
|
||||
// per agent. Content is free-form markdown.
|
||||
agentKontextDocs: { enabled: true, fields: ['content'] },
|
||||
|
|
|
|||
|
|
@ -974,6 +974,24 @@ db.version(40).upgrade(async (tx) => {
|
|||
});
|
||||
});
|
||||
|
||||
// v41 — Wardrobe module (docs/plans/wardrobe-module.md M1).
|
||||
// Two space-scoped tables — garments (individual clothing items) and
|
||||
// outfits (named compositions of garment refs). Try-on results live in
|
||||
// picture.images with a wardrobeOutfitId back-reference; no join table
|
||||
// here.
|
||||
//
|
||||
// Indices:
|
||||
// - wardrobeGarments.category for the Category-Tabs filter
|
||||
// - wardrobeGarments.createdAt for "newest first" ordering
|
||||
// - wardrobeOutfits.createdAt for the grid default sort
|
||||
// - wardrobeOutfits.isFavorite for the favorites filter
|
||||
// Both tables get the standard spaceId/authorId/visibility stamping
|
||||
// via the Dexie hook (they're NOT in USER_LEVEL_TABLES).
|
||||
db.version(41).stores({
|
||||
wardrobeGarments: 'id, category, createdAt, isArchived',
|
||||
wardrobeOutfits: 'id, createdAt, isFavorite, isArchived',
|
||||
});
|
||||
|
||||
// ─── Sync Routing ──────────────────────────────────────────
|
||||
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
|
||||
// toSyncName() and fromSyncName() are now derived from per-module
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ import { invoicesModuleConfig } from '$lib/modules/invoices/module.config';
|
|||
import { broadcastModuleConfig } from '$lib/modules/broadcast/module.config';
|
||||
import { wetterModuleConfig } from '$lib/modules/wetter/module.config';
|
||||
import { websiteModuleConfig } from '$lib/modules/website/module.config';
|
||||
import { wardrobeModuleConfig } from '$lib/modules/wardrobe/module.config';
|
||||
import { aiModuleConfig } from '$lib/data/ai/module.config';
|
||||
|
||||
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
||||
|
|
@ -164,6 +165,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
|||
broadcastModuleConfig,
|
||||
wetterModuleConfig,
|
||||
websiteModuleConfig,
|
||||
wardrobeModuleConfig,
|
||||
aiModuleConfig,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
maxSelection?: number;
|
||||
}
|
||||
|
||||
let { selectedIds = $bindable([]), maxSelection = 4 }: Props = $props();
|
||||
let { selectedIds = $bindable([]), maxSelection = 8 }: Props = $props();
|
||||
|
||||
const referenceImages$ = useReferenceImages();
|
||||
const referenceImages = $derived(referenceImages$.value ?? []);
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export function toImage(local: LocalImage): Image {
|
|||
sourceImageId: local.sourceImageId ?? undefined,
|
||||
referenceImageIds: local.referenceImageIds ?? undefined,
|
||||
generationMode: local.generationMode ?? undefined,
|
||||
wardrobeOutfitId: local.wardrobeOutfitId ?? undefined,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -35,6 +35,13 @@ export interface LocalImage extends BaseRecord {
|
|||
/** mana-media ids of the me-images that fed a reference-edit. */
|
||||
referenceImageIds?: string[] | null;
|
||||
generationMode?: ImageGenerationMode | null;
|
||||
/**
|
||||
* Back-reference to `wardrobeOutfits.id` when this image was produced
|
||||
* by the Wardrobe try-on flow (plan docs/plans/wardrobe-module.md).
|
||||
* Lets the outfit detail view query all historical try-ons without
|
||||
* an extra table. Plaintext — it's an FK.
|
||||
*/
|
||||
wardrobeOutfitId?: string | null;
|
||||
}
|
||||
|
||||
export interface LocalBoard extends BaseRecord {
|
||||
|
|
@ -96,6 +103,7 @@ export interface Image {
|
|||
sourceImageId?: string;
|
||||
referenceImageIds?: string[];
|
||||
generationMode?: ImageGenerationMode;
|
||||
wardrobeOutfitId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* Wardrobe module — Dexie table accessors.
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalWardrobeGarment, LocalWardrobeOutfit } from './types';
|
||||
|
||||
export const wardrobeGarmentsTable = db.table<LocalWardrobeGarment>('wardrobeGarments');
|
||||
export const wardrobeOutfitsTable = db.table<LocalWardrobeOutfit>('wardrobeOutfits');
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
export const wardrobeModuleConfig: ModuleConfig = {
|
||||
appId: 'wardrobe',
|
||||
tables: [{ name: 'wardrobeGarments' }, { name: 'wardrobeOutfits' }],
|
||||
};
|
||||
139
apps/mana/apps/web/src/lib/modules/wardrobe/queries.ts
Normal file
139
apps/mana/apps/web/src/lib/modules/wardrobe/queries.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* Wardrobe module — read-side queries.
|
||||
*
|
||||
* All queries go through `scopedForModule` so switching the active
|
||||
* space swaps the visible pool automatically (Brand-merch vs personal
|
||||
* wardrobe vs family-wardrobe). Try-on history lives in `picture.images`
|
||||
* filtered by `wardrobeOutfitId` — see useOutfitTryOns below.
|
||||
*/
|
||||
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import type { LocalImage, Image } from '$lib/modules/picture/types';
|
||||
import { toImage } from '$lib/modules/picture/queries';
|
||||
import {
|
||||
toGarment,
|
||||
toOutfit,
|
||||
type Garment,
|
||||
type GarmentCategory,
|
||||
type LocalWardrobeGarment,
|
||||
type LocalWardrobeOutfit,
|
||||
type Outfit,
|
||||
type OutfitOccasion,
|
||||
} from './types';
|
||||
|
||||
// ─── Garments ─────────────────────────────────────────────────────
|
||||
|
||||
/** All non-archived, non-deleted garments in the active space. */
|
||||
export function useAllGarments() {
|
||||
return useLiveQueryWithDefault<Garment[]>(async () => {
|
||||
const locals = await scopedForModule<LocalWardrobeGarment, string>(
|
||||
'wardrobe',
|
||||
'wardrobeGarments'
|
||||
).toArray();
|
||||
const visible = locals
|
||||
.filter((row) => !row.deletedAt && !row.isArchived)
|
||||
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
|
||||
const decrypted = await decryptRecords('wardrobeGarments', visible);
|
||||
return decrypted.map(toGarment);
|
||||
}, [] as Garment[]);
|
||||
}
|
||||
|
||||
/** Garments filtered by category — used by the Category-Tabs view. */
|
||||
export function useGarmentsByCategory(category: GarmentCategory) {
|
||||
return useLiveQueryWithDefault<Garment[]>(async () => {
|
||||
const locals = await scopedForModule<LocalWardrobeGarment, string>(
|
||||
'wardrobe',
|
||||
'wardrobeGarments'
|
||||
)
|
||||
.and((row) => row.category === category)
|
||||
.toArray();
|
||||
const visible = locals
|
||||
.filter((row) => !row.deletedAt && !row.isArchived)
|
||||
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
|
||||
const decrypted = await decryptRecords('wardrobeGarments', visible);
|
||||
return decrypted.map(toGarment);
|
||||
}, [] as Garment[]);
|
||||
}
|
||||
|
||||
/** A single garment by id, live-updating. Null while loading / missing. */
|
||||
export function useGarment(id: string | null) {
|
||||
return useLiveQueryWithDefault<Garment | null>(async () => {
|
||||
if (!id) return null;
|
||||
const locals = await scopedForModule<LocalWardrobeGarment, string>(
|
||||
'wardrobe',
|
||||
'wardrobeGarments'
|
||||
)
|
||||
.and((row) => row.id === id)
|
||||
.toArray();
|
||||
const [local] = locals;
|
||||
if (!local || local.deletedAt) return null;
|
||||
const [decrypted] = await decryptRecords('wardrobeGarments', [local]);
|
||||
return toGarment(decrypted);
|
||||
}, null);
|
||||
}
|
||||
|
||||
// ─── Outfits ──────────────────────────────────────────────────────
|
||||
|
||||
/** All non-archived outfits in the active space. */
|
||||
export function useAllOutfits() {
|
||||
return useLiveQueryWithDefault<Outfit[]>(async () => {
|
||||
const locals = await scopedForModule<LocalWardrobeOutfit, string>(
|
||||
'wardrobe',
|
||||
'wardrobeOutfits'
|
||||
).toArray();
|
||||
const visible = locals
|
||||
.filter((row) => !row.deletedAt && !row.isArchived)
|
||||
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
|
||||
const decrypted = await decryptRecords('wardrobeOutfits', visible);
|
||||
return decrypted.map(toOutfit);
|
||||
}, [] as Outfit[]);
|
||||
}
|
||||
|
||||
export function useOutfitsByOccasion(occasion: OutfitOccasion) {
|
||||
return useLiveQueryWithDefault<Outfit[]>(async () => {
|
||||
const locals = await scopedForModule<LocalWardrobeOutfit, string>('wardrobe', 'wardrobeOutfits')
|
||||
.and((row) => row.occasion === occasion)
|
||||
.toArray();
|
||||
const visible = locals
|
||||
.filter((row) => !row.deletedAt && !row.isArchived)
|
||||
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
|
||||
const decrypted = await decryptRecords('wardrobeOutfits', visible);
|
||||
return decrypted.map(toOutfit);
|
||||
}, [] as Outfit[]);
|
||||
}
|
||||
|
||||
export function useOutfit(id: string | null) {
|
||||
return useLiveQueryWithDefault<Outfit | null>(async () => {
|
||||
if (!id) return null;
|
||||
const locals = await scopedForModule<LocalWardrobeOutfit, string>('wardrobe', 'wardrobeOutfits')
|
||||
.and((row) => row.id === id)
|
||||
.toArray();
|
||||
const [local] = locals;
|
||||
if (!local || local.deletedAt) return null;
|
||||
const [decrypted] = await decryptRecords('wardrobeOutfits', [local]);
|
||||
return toOutfit(decrypted);
|
||||
}, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every try-on ever rendered for an outfit, newest first. Pulls from
|
||||
* `picture.images` (filtered by `wardrobeOutfitId`) because that's where
|
||||
* generations physically land — see plan decision #1 (kein drittes Table
|
||||
* für Try-Ons). The outfit detail view renders these as a horizontal
|
||||
* strip under the current composition.
|
||||
*/
|
||||
export function useOutfitTryOns(outfitId: string | null) {
|
||||
return useLiveQueryWithDefault<Image[]>(async () => {
|
||||
if (!outfitId) return [];
|
||||
const locals = await scopedForModule<LocalImage, string>('picture', 'images')
|
||||
.and((row) => row.wardrobeOutfitId === outfitId)
|
||||
.toArray();
|
||||
const visible = locals
|
||||
.filter((row) => !row.deletedAt && !row.isArchived)
|
||||
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
|
||||
const decrypted = await decryptRecords('images', visible);
|
||||
return decrypted.map(toImage);
|
||||
}, [] as Image[]);
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* Garments store — mutation-only service.
|
||||
*
|
||||
* Reads happen via `queries.ts`; this module owns the write path so
|
||||
* encryption + domain events stay in one place. The Dexie creating-hook
|
||||
* stamps `spaceId`, `authorId`, `visibility` automatically — wardrobe
|
||||
* is NOT in USER_LEVEL_TABLES.
|
||||
*/
|
||||
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import { emitDomainEvent } from '$lib/data/events';
|
||||
import { wardrobeGarmentsTable } from '../collections';
|
||||
import { toGarment } from '../types';
|
||||
import type { Garment, GarmentCategory, LocalWardrobeGarment } from '../types';
|
||||
|
||||
export interface CreateGarmentInput {
|
||||
name: string;
|
||||
category: GarmentCategory;
|
||||
mediaIds: string[];
|
||||
brand?: string | null;
|
||||
color?: string | null;
|
||||
size?: string | null;
|
||||
material?: string | null;
|
||||
tags?: string[];
|
||||
notes?: string | null;
|
||||
purchasedAt?: string | null;
|
||||
priceCents?: number | null;
|
||||
currency?: string | null;
|
||||
}
|
||||
|
||||
export const wardrobeGarmentsStore = {
|
||||
async createGarment(input: CreateGarmentInput): Promise<Garment> {
|
||||
if (input.mediaIds.length === 0) {
|
||||
throw new Error('Garment needs at least one photo');
|
||||
}
|
||||
const newLocal: LocalWardrobeGarment = {
|
||||
id: crypto.randomUUID(),
|
||||
name: input.name,
|
||||
category: input.category,
|
||||
mediaIds: input.mediaIds,
|
||||
brand: input.brand ?? null,
|
||||
color: input.color ?? null,
|
||||
size: input.size ?? null,
|
||||
material: input.material ?? null,
|
||||
tags: input.tags ?? [],
|
||||
notes: input.notes ?? null,
|
||||
purchasedAt: input.purchasedAt ?? null,
|
||||
priceCents: input.priceCents ?? null,
|
||||
currency: input.currency ?? null,
|
||||
wearCount: 0,
|
||||
lastWornAt: null,
|
||||
};
|
||||
const snapshot = toGarment({ ...newLocal });
|
||||
await encryptRecord('wardrobeGarments', newLocal);
|
||||
await wardrobeGarmentsTable.add(newLocal);
|
||||
emitDomainEvent('WardrobeGarmentAdded', 'wardrobe', 'wardrobeGarments', newLocal.id, {
|
||||
garmentId: newLocal.id,
|
||||
category: input.category,
|
||||
});
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
async updateGarment(
|
||||
id: string,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
LocalWardrobeGarment,
|
||||
| 'name'
|
||||
| 'category'
|
||||
| 'mediaIds'
|
||||
| 'brand'
|
||||
| 'color'
|
||||
| 'size'
|
||||
| 'material'
|
||||
| 'tags'
|
||||
| 'notes'
|
||||
| 'purchasedAt'
|
||||
| 'priceCents'
|
||||
| 'currency'
|
||||
>
|
||||
>
|
||||
): Promise<void> {
|
||||
const wrapped = { ...patch } as Record<string, unknown>;
|
||||
await encryptRecord('wardrobeGarments', wrapped);
|
||||
await wardrobeGarmentsTable.update(id, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark a garment as worn today. Bumps the wear count + stamps
|
||||
* `lastWornAt`. The UI surfaces this as a one-tap button in the
|
||||
* detail view; M7 adds it to the card too.
|
||||
*/
|
||||
async markWornToday(id: string): Promise<void> {
|
||||
const existing = await wardrobeGarmentsTable.get(id);
|
||||
if (!existing) return;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
await wardrobeGarmentsTable.update(id, {
|
||||
wearCount: (existing.wearCount ?? 0) + 1,
|
||||
lastWornAt: today,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('WardrobeGarmentWorn', 'wardrobe', 'wardrobeGarments', id, {
|
||||
garmentId: id,
|
||||
wearCount: (existing.wearCount ?? 0) + 1,
|
||||
});
|
||||
},
|
||||
|
||||
async archiveGarment(id: string, archived: boolean): Promise<void> {
|
||||
await wardrobeGarmentsTable.update(id, {
|
||||
isArchived: archived,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteGarment(id: string): Promise<void> {
|
||||
const nowIso = new Date().toISOString();
|
||||
await wardrobeGarmentsTable.update(id, {
|
||||
deletedAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
});
|
||||
emitDomainEvent('WardrobeGarmentDeleted', 'wardrobe', 'wardrobeGarments', id, {
|
||||
garmentId: id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* Outfits store — mutation-only service.
|
||||
*
|
||||
* Outfits reference garments by id (plaintext array on the row). Try-On
|
||||
* results are stored in `picture.images` with `wardrobeOutfitId` back-
|
||||
* reference — the `lastTryOn` snapshot here is just a convenience pointer
|
||||
* so the outfit card can render the latest preview without a join query.
|
||||
*/
|
||||
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import { emitDomainEvent } from '$lib/data/events';
|
||||
import { wardrobeOutfitsTable } from '../collections';
|
||||
import { toOutfit } from '../types';
|
||||
import type {
|
||||
LocalWardrobeOutfit,
|
||||
Outfit,
|
||||
OutfitOccasion,
|
||||
OutfitSeason,
|
||||
OutfitTryOn,
|
||||
} from '../types';
|
||||
|
||||
export interface CreateOutfitInput {
|
||||
name: string;
|
||||
garmentIds: string[];
|
||||
description?: string | null;
|
||||
occasion?: OutfitOccasion | null;
|
||||
season?: OutfitSeason[];
|
||||
tags?: string[];
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
|
||||
export const wardrobeOutfitsStore = {
|
||||
async createOutfit(input: CreateOutfitInput): Promise<Outfit> {
|
||||
if (input.garmentIds.length === 0) {
|
||||
throw new Error('Outfit needs at least one garment');
|
||||
}
|
||||
const newLocal: LocalWardrobeOutfit = {
|
||||
id: crypto.randomUUID(),
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
garmentIds: input.garmentIds,
|
||||
occasion: input.occasion ?? null,
|
||||
season: input.season,
|
||||
tags: input.tags ?? [],
|
||||
isFavorite: input.isFavorite ?? false,
|
||||
};
|
||||
const snapshot = toOutfit({ ...newLocal });
|
||||
await encryptRecord('wardrobeOutfits', newLocal);
|
||||
await wardrobeOutfitsTable.add(newLocal);
|
||||
emitDomainEvent('WardrobeOutfitCreated', 'wardrobe', 'wardrobeOutfits', newLocal.id, {
|
||||
outfitId: newLocal.id,
|
||||
garmentCount: input.garmentIds.length,
|
||||
});
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
async updateOutfit(
|
||||
id: string,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
LocalWardrobeOutfit,
|
||||
'name' | 'description' | 'garmentIds' | 'occasion' | 'season' | 'tags'
|
||||
>
|
||||
>
|
||||
): Promise<void> {
|
||||
const wrapped = { ...patch } as Record<string, unknown>;
|
||||
await encryptRecord('wardrobeOutfits', wrapped);
|
||||
await wardrobeOutfitsTable.update(id, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async toggleFavorite(id: string): Promise<void> {
|
||||
const existing = await wardrobeOutfitsTable.get(id);
|
||||
if (!existing) return;
|
||||
await wardrobeOutfitsTable.update(id, {
|
||||
isFavorite: !existing.isFavorite,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async markWornToday(id: string): Promise<void> {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
await wardrobeOutfitsTable.update(id, {
|
||||
lastWornAt: today,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Pinning the most recent try-on. The `imageId` points at a
|
||||
* `picture.images` row written by the M4 runTryOn helper; this
|
||||
* method is called right after that row lands so the outfit card
|
||||
* can surface the latest preview.
|
||||
*/
|
||||
async setLastTryOn(id: string, tryOn: OutfitTryOn): Promise<void> {
|
||||
await wardrobeOutfitsTable.update(id, {
|
||||
lastTryOn: tryOn,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('WardrobeOutfitTryOn', 'wardrobe', 'wardrobeOutfits', id, {
|
||||
outfitId: id,
|
||||
imageId: tryOn.imageId,
|
||||
});
|
||||
},
|
||||
|
||||
async archiveOutfit(id: string, archived: boolean): Promise<void> {
|
||||
await wardrobeOutfitsTable.update(id, {
|
||||
isArchived: archived,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteOutfit(id: string): Promise<void> {
|
||||
const nowIso = new Date().toISOString();
|
||||
await wardrobeOutfitsTable.update(id, {
|
||||
deletedAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
});
|
||||
emitDomainEvent('WardrobeOutfitDeleted', 'wardrobe', 'wardrobeOutfits', id, {
|
||||
outfitId: id,
|
||||
});
|
||||
},
|
||||
};
|
||||
203
apps/mana/apps/web/src/lib/modules/wardrobe/types.ts
Normal file
203
apps/mana/apps/web/src/lib/modules/wardrobe/types.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* Wardrobe module types — two tables:
|
||||
*
|
||||
* - `wardrobeGarments`: individual clothing items / accessories, space-
|
||||
* scoped via the standard Spaces stamping. Brand spaces hold Merch,
|
||||
* clubs hold Trikots, families hold kid + parent wardrobes, etc.
|
||||
* - `wardrobeOutfits`: named compositions of garment refs. A try-on
|
||||
* snapshot points at a picture.images row (the generated image is
|
||||
* just another entry in the Picture module's gallery).
|
||||
*
|
||||
* Try-on results themselves live in `picture.images` with an additional
|
||||
* `wardrobeOutfitId` back-reference — see apps/mana/apps/web/src/lib/
|
||||
* modules/picture/types.ts. No third table in this module.
|
||||
*
|
||||
* Plan: docs/plans/wardrobe-module.md.
|
||||
*/
|
||||
|
||||
import type { BaseRecord } from '@mana/local-store';
|
||||
|
||||
// ─── Garment ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Closed enum of clothing/accessory categories. Drives the category
|
||||
* filter tabs in the UI and the try-on preset (`accessory`, `glasses`,
|
||||
* `jewelry`, `hat` go face-ref only — the others use face + fullbody).
|
||||
*/
|
||||
export type GarmentCategory =
|
||||
| 'top' // Hemd, T-Shirt, Bluse, Pullover
|
||||
| 'bottom' // Hose, Rock, Shorts
|
||||
| 'dress' // Kleid, Anzug-Einteiler
|
||||
| 'outerwear' // Jacke, Mantel
|
||||
| 'shoes'
|
||||
| 'accessory' // Schal, Gürtel, Tuch
|
||||
| 'glasses'
|
||||
| 'jewelry'
|
||||
| 'hat'
|
||||
| 'bag'
|
||||
| 'other';
|
||||
|
||||
/**
|
||||
* Accessory categories that skip the fullbody reference in try-on.
|
||||
* `accessoryOnly=true` in the M4 runTryOn helper flips to face-only
|
||||
* and a square prompt preset.
|
||||
*/
|
||||
export const FACE_ONLY_CATEGORIES: ReadonlySet<GarmentCategory> = new Set([
|
||||
'glasses',
|
||||
'jewelry',
|
||||
'hat',
|
||||
'accessory',
|
||||
]);
|
||||
|
||||
export interface LocalWardrobeGarment extends BaseRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
category: GarmentCategory;
|
||||
/**
|
||||
* mana-media ids, at least one. `mediaIds[0]` is the primary photo
|
||||
* used by try-on and tile thumbnails; additional ids are alternate
|
||||
* views (back, detail) rendered on the detail page in M7.
|
||||
*/
|
||||
mediaIds: string[];
|
||||
brand?: string | null;
|
||||
color?: string | null; // freeform — "navy", "hellgrau", "#2a4d6e"
|
||||
size?: string | null; // freeform — "M", "42", "US 10"
|
||||
material?: string | null;
|
||||
tags: string[];
|
||||
notes?: string | null;
|
||||
purchasedAt?: string | null; // ISO date (YYYY-MM-DD)
|
||||
priceCents?: number | null;
|
||||
currency?: string | null; // ISO 4217
|
||||
isArchived?: boolean;
|
||||
/** Incremented by the "heute getragen"-Button; null if never tracked. */
|
||||
wearCount?: number;
|
||||
lastWornAt?: string | null;
|
||||
}
|
||||
|
||||
export interface Garment {
|
||||
id: string;
|
||||
name: string;
|
||||
category: GarmentCategory;
|
||||
mediaIds: string[];
|
||||
brand?: string;
|
||||
color?: string;
|
||||
size?: string;
|
||||
material?: string;
|
||||
tags: string[];
|
||||
notes?: string;
|
||||
purchasedAt?: string;
|
||||
priceCents?: number;
|
||||
currency?: string;
|
||||
isArchived?: boolean;
|
||||
wearCount?: number;
|
||||
lastWornAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function toGarment(local: LocalWardrobeGarment): Garment {
|
||||
return {
|
||||
id: local.id,
|
||||
name: local.name,
|
||||
category: local.category,
|
||||
mediaIds: local.mediaIds ?? [],
|
||||
brand: local.brand ?? undefined,
|
||||
color: local.color ?? undefined,
|
||||
size: local.size ?? undefined,
|
||||
material: local.material ?? undefined,
|
||||
tags: local.tags ?? [],
|
||||
notes: local.notes ?? undefined,
|
||||
purchasedAt: local.purchasedAt ?? undefined,
|
||||
priceCents: local.priceCents ?? undefined,
|
||||
currency: local.currency ?? undefined,
|
||||
isArchived: local.isArchived ?? undefined,
|
||||
wearCount: local.wearCount ?? undefined,
|
||||
lastWornAt: local.lastWornAt ?? undefined,
|
||||
createdAt: local.createdAt ?? '',
|
||||
updatedAt: local.updatedAt ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/** Primary photo of a garment; `null` if the row somehow has no ids. */
|
||||
export function garmentPrimaryMediaId(garment: Pick<Garment, 'mediaIds'>): string | null {
|
||||
return garment.mediaIds[0] ?? null;
|
||||
}
|
||||
|
||||
// ─── Outfit ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Snapshot of the most recent try-on for an outfit. The full history
|
||||
* lives in `picture.images` filtered by `wardrobeOutfitId === outfit.id`
|
||||
* — this pointer exists so the outfit detail view can render the latest
|
||||
* preview without re-querying.
|
||||
*/
|
||||
export interface OutfitTryOn {
|
||||
imageId: string; // points at picture.images.id
|
||||
createdAt: string; // ISO
|
||||
prompt: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
/** Closed enum of occasions the outfit is appropriate for. Freeform
|
||||
* remains possible via tags; the enum keeps the primary filter small. */
|
||||
export type OutfitOccasion =
|
||||
| 'casual'
|
||||
| 'work'
|
||||
| 'formal'
|
||||
| 'workout'
|
||||
| 'date'
|
||||
| 'travel'
|
||||
| 'event'
|
||||
| 'sleep'
|
||||
| 'other';
|
||||
|
||||
export type OutfitSeason = 'spring' | 'summer' | 'autumn' | 'winter';
|
||||
|
||||
export interface LocalWardrobeOutfit extends BaseRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
/** References into `wardrobeGarments`. Must be in the same space. */
|
||||
garmentIds: string[];
|
||||
occasion?: OutfitOccasion | null;
|
||||
season?: OutfitSeason[];
|
||||
tags: string[];
|
||||
isFavorite?: boolean;
|
||||
isArchived?: boolean;
|
||||
lastTryOn?: OutfitTryOn | null;
|
||||
lastWornAt?: string | null;
|
||||
}
|
||||
|
||||
export interface Outfit {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
garmentIds: string[];
|
||||
occasion?: OutfitOccasion;
|
||||
season?: OutfitSeason[];
|
||||
tags: string[];
|
||||
isFavorite?: boolean;
|
||||
isArchived?: boolean;
|
||||
lastTryOn?: OutfitTryOn;
|
||||
lastWornAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function toOutfit(local: LocalWardrobeOutfit): Outfit {
|
||||
return {
|
||||
id: local.id,
|
||||
name: local.name,
|
||||
description: local.description ?? undefined,
|
||||
garmentIds: local.garmentIds ?? [],
|
||||
occasion: local.occasion ?? undefined,
|
||||
season: local.season,
|
||||
tags: local.tags ?? [],
|
||||
isFavorite: local.isFavorite,
|
||||
isArchived: local.isArchived,
|
||||
lastTryOn: local.lastTryOn ?? undefined,
|
||||
lastWornAt: local.lastWornAt ?? undefined,
|
||||
createdAt: local.createdAt ?? '',
|
||||
updatedAt: local.updatedAt ?? '',
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { sitesStore } from '../stores/sites.svelte';
|
||||
import { PublishError } from '../publish';
|
||||
import RollbackDialog from './RollbackDialog.svelte';
|
||||
import type { Website } from '../types';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -12,6 +13,7 @@
|
|||
let publishing = $state(false);
|
||||
let unpublishing = $state(false);
|
||||
let lastError = $state<string | null>(null);
|
||||
let showHistory = $state(false);
|
||||
|
||||
const hasDraftAhead = $derived.by(() => {
|
||||
if (!site.publishedVersion) return site.draftUpdatedAt !== null;
|
||||
|
|
@ -77,6 +79,13 @@
|
|||
|
||||
<div class="wb-publishbar__actions">
|
||||
{#if site.publishedVersion}
|
||||
<button
|
||||
class="wb-btn wb-btn--ghost"
|
||||
onclick={() => (showHistory = true)}
|
||||
title="Versionen einsehen / wiederherstellen"
|
||||
>
|
||||
Versionen
|
||||
</button>
|
||||
<button
|
||||
class="wb-btn wb-btn--ghost"
|
||||
onclick={onUnpublish}
|
||||
|
|
@ -103,6 +112,10 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showHistory}
|
||||
<RollbackDialog siteId={site.id} onClose={() => (showHistory = false)} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.wb-publishbar {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,256 @@
|
|||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { sitesStore } from '../stores/sites.svelte';
|
||||
import { fetchSnapshotHistory, PublishError, type SnapshotHistoryEntry } from '../publish';
|
||||
|
||||
interface Props {
|
||||
siteId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { siteId, onClose }: Props = $props();
|
||||
|
||||
let entries = $state<SnapshotHistoryEntry[] | null>(null);
|
||||
let loadError = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
let rollingBackId = $state<string | null>(null);
|
||||
let actionError = $state<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
loadError = null;
|
||||
try {
|
||||
const token = await authStore.getValidToken();
|
||||
if (!token) throw new Error('Nicht angemeldet');
|
||||
entries = await fetchSnapshotHistory(siteId, token);
|
||||
} catch (err) {
|
||||
loadError =
|
||||
err instanceof PublishError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
siteId;
|
||||
void load();
|
||||
});
|
||||
|
||||
async function onRollback(snapshotId: string) {
|
||||
if (!confirm('Diese Version als aktuell veröffentlicht setzen?')) return;
|
||||
rollingBackId = snapshotId;
|
||||
actionError = null;
|
||||
try {
|
||||
await sitesStore.rollback(siteId, snapshotId);
|
||||
await load();
|
||||
} catch (err) {
|
||||
actionError =
|
||||
err instanceof PublishError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
} finally {
|
||||
rollingBackId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('de-DE');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="wb-modal__backdrop"
|
||||
onclick={onClose}
|
||||
onkeydown={(e) => e.key === 'Escape' && onClose()}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label="Schließen"
|
||||
></div>
|
||||
|
||||
<div class="wb-modal" role="dialog" aria-modal="true" aria-labelledby="wb-rollback-title">
|
||||
<header class="wb-modal__head">
|
||||
<div>
|
||||
<h3 id="wb-rollback-title">Version-History</h3>
|
||||
<p>Wähle eine ältere veröffentlichte Version, um sie wieder live zu stellen.</p>
|
||||
</div>
|
||||
<button class="wb-modal__close" onclick={onClose} aria-label="Schließen">×</button>
|
||||
</header>
|
||||
|
||||
<div class="wb-modal__body">
|
||||
{#if loadError}
|
||||
<p class="wb-error">{loadError}</p>
|
||||
{:else if entries === null}
|
||||
<p class="wb-empty">Lade…</p>
|
||||
{:else if entries.length === 0}
|
||||
<p class="wb-empty">Noch keine veröffentlichten Versionen.</p>
|
||||
{:else}
|
||||
{#if actionError}
|
||||
<p class="wb-error">{actionError}</p>
|
||||
{/if}
|
||||
<ul class="wb-list">
|
||||
{#each entries as entry (entry.id)}
|
||||
<li class="wb-row" class:wb-row--current={entry.isCurrent}>
|
||||
<div class="wb-row__meta">
|
||||
<span class="wb-row__time">{formatDate(entry.publishedAt)}</span>
|
||||
<span class="wb-row__id">{entry.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
<div class="wb-row__actions">
|
||||
{#if entry.isCurrent}
|
||||
<span class="wb-pill wb-pill--current">Aktuell live</span>
|
||||
{:else}
|
||||
<button
|
||||
class="wb-btn wb-btn--primary"
|
||||
onclick={() => onRollback(entry.id)}
|
||||
disabled={rollingBackId === entry.id || loading}
|
||||
>
|
||||
{rollingBackId === entry.id ? 'Stelle wieder her…' : 'Wiederherstellen'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wb-modal__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 40;
|
||||
border: none;
|
||||
}
|
||||
.wb-modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(92vw, 32rem);
|
||||
max-height: 80vh;
|
||||
background: rgb(15, 18, 24);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.75rem;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wb-modal__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.wb-modal__head h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.wb-modal__head p {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.wb-modal__close {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: inherit;
|
||||
padding: 0.1rem 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wb-modal__body {
|
||||
padding: 1rem 1.25rem 1.25rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.wb-empty {
|
||||
margin: 1rem 0;
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
font-style: italic;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.wb-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.wb-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.wb-row--current {
|
||||
background: rgba(16, 185, 129, 0.06);
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
.wb-row__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.wb-row__time {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.wb-row__id {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.5;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.wb-row__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.wb-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wb-btn--primary {
|
||||
background: rgba(99, 102, 241, 0.9);
|
||||
color: white;
|
||||
}
|
||||
.wb-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.wb-pill {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.12rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.wb-pill--current {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: rgb(110, 231, 183);
|
||||
}
|
||||
.wb-error {
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
border-radius: 0.375rem;
|
||||
color: rgb(248, 113, 113);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
</style>
|
||||
159
apps/mana/apps/web/src/lib/modules/website/publish.test.ts
Normal file
159
apps/mana/apps/web/src/lib/modules/website/publish.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* Snapshot builder determinism test.
|
||||
*
|
||||
* Exit-criterion from docs/plans/website-builder.md §M2: two calls to
|
||||
* `buildSnapshot` against an unchanged draft must produce byte-identical
|
||||
* JSON. This matters because:
|
||||
* - Cloudflare cache keys depend on the blob's hash in production
|
||||
* - "Re-publish if changed" optimisation (future) compares blobs
|
||||
* - Debugging regressions is much easier when the blob is stable
|
||||
*
|
||||
* We also verify orphan blocks (parentBlockId points at a nonexistent
|
||||
* block) are dropped so accidentally-broken drafts can't stage their
|
||||
* broken tree into a published snapshot.
|
||||
*/
|
||||
|
||||
import 'fake-indexeddb/auto';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mirror the Dexie-hook side-effect stubs from sync.test.ts so loading
|
||||
// database.ts doesn't pull in unrelated runtime modules.
|
||||
vi.mock('$lib/stores/funnel-tracking', () => ({
|
||||
trackFirstContent: vi.fn(),
|
||||
}));
|
||||
vi.mock('$lib/triggers/registry', () => ({
|
||||
fire: vi.fn(),
|
||||
}));
|
||||
vi.mock('$lib/triggers/inline-suggest', () => ({
|
||||
checkInlineSuggestion: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
// Embed resolvers touch live data modules (picture/library). Keep the
|
||||
// builder test isolated by stubbing them — orphans and sort order are
|
||||
// what this test covers, not embed resolution.
|
||||
vi.mock('./embeds', () => ({
|
||||
resolveEmbed: vi.fn(async () => ({
|
||||
items: [],
|
||||
resolvedAt: '2026-04-23T00:00:00.000Z',
|
||||
})),
|
||||
}));
|
||||
|
||||
import { websitesTable, websitePagesTable, websiteBlocksTable } from './collections';
|
||||
import { buildSnapshot, buildBlockTree } from './publish';
|
||||
import type { LocalWebsite, LocalWebsitePage, LocalWebsiteBlock } from './types';
|
||||
|
||||
const SITE_ID = '11111111-1111-1111-1111-111111111111';
|
||||
const PAGE_ID = '22222222-2222-2222-2222-222222222222';
|
||||
|
||||
function localSite(): LocalWebsite {
|
||||
return {
|
||||
id: SITE_ID,
|
||||
slug: 'test-site',
|
||||
name: 'Test Site',
|
||||
theme: { preset: 'classic' },
|
||||
navConfig: { items: [] },
|
||||
footerConfig: { text: '', links: [] },
|
||||
settings: {},
|
||||
publishedVersion: null,
|
||||
draftUpdatedAt: '2026-04-23T00:00:00.000Z',
|
||||
createdAt: '2026-04-23T00:00:00.000Z',
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function localPage(overrides: Partial<LocalWebsitePage> = {}): LocalWebsitePage {
|
||||
return {
|
||||
id: PAGE_ID,
|
||||
siteId: SITE_ID,
|
||||
path: '/',
|
||||
title: 'Start',
|
||||
seo: {},
|
||||
order: 1024,
|
||||
createdAt: '2026-04-23T00:00:00.000Z',
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function localBlock(
|
||||
id: string,
|
||||
order: number,
|
||||
overrides: Partial<LocalWebsiteBlock> = {}
|
||||
): LocalWebsiteBlock {
|
||||
return {
|
||||
id,
|
||||
pageId: PAGE_ID,
|
||||
parentBlockId: null,
|
||||
slotKey: null,
|
||||
type: 'richText',
|
||||
props: { content: `Block ${id}`, align: 'left', size: 'md' },
|
||||
schemaVersion: 1,
|
||||
order,
|
||||
createdAt: '2026-04-23T00:00:00.000Z',
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildSnapshot — determinism', () => {
|
||||
beforeEach(async () => {
|
||||
await websitesTable.clear();
|
||||
await websitePagesTable.clear();
|
||||
await websiteBlocksTable.clear();
|
||||
});
|
||||
|
||||
it('two calls against the same draft produce byte-identical JSON', async () => {
|
||||
await websitesTable.add(localSite());
|
||||
await websitePagesTable.add(localPage());
|
||||
await websiteBlocksTable.bulkAdd([
|
||||
localBlock('b-gamma', 3072),
|
||||
localBlock('b-alpha', 1024),
|
||||
localBlock('b-beta', 2048),
|
||||
]);
|
||||
|
||||
const first = await buildSnapshot(SITE_ID);
|
||||
const second = await buildSnapshot(SITE_ID);
|
||||
|
||||
expect(JSON.stringify(first)).toBe(JSON.stringify(second));
|
||||
});
|
||||
|
||||
it('sorts blocks with equal order by id (stable tiebreaker)', async () => {
|
||||
await websitesTable.add(localSite());
|
||||
await websitePagesTable.add(localPage());
|
||||
await websiteBlocksTable.bulkAdd([
|
||||
localBlock('b-zzzzz', 1024),
|
||||
localBlock('b-aaaaa', 1024),
|
||||
localBlock('b-mmmmm', 1024),
|
||||
]);
|
||||
|
||||
const snap = await buildSnapshot(SITE_ID);
|
||||
const ids = snap.pages[0]!.blocks.map((b) => b.id);
|
||||
expect(ids).toEqual(['b-aaaaa', 'b-mmmmm', 'b-zzzzz']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBlockTree — orphan handling', () => {
|
||||
it('drops blocks whose parentBlockId does not resolve', () => {
|
||||
const blocks: LocalWebsiteBlock[] = [
|
||||
localBlock('b-root', 1024),
|
||||
localBlock('b-orphan', 2048, { parentBlockId: 'b-does-not-exist' }),
|
||||
localBlock('b-child', 3072, { parentBlockId: 'b-root' }),
|
||||
];
|
||||
const tree = buildBlockTree(blocks);
|
||||
// Top-level contains only b-root.
|
||||
expect(tree.map((t) => t.id)).toEqual(['b-root']);
|
||||
// b-root has exactly one child (b-child) — the orphan is dropped.
|
||||
expect(tree[0]!.children.map((c) => c.id)).toEqual(['b-child']);
|
||||
});
|
||||
|
||||
it('preserves nested children ordered by (order, id)', () => {
|
||||
const blocks: LocalWebsiteBlock[] = [
|
||||
localBlock('parent', 1024),
|
||||
localBlock('child-c', 2048, { parentBlockId: 'parent' }),
|
||||
localBlock('child-a', 1024, { parentBlockId: 'parent' }),
|
||||
localBlock('child-b', 1024, { parentBlockId: 'parent' }),
|
||||
];
|
||||
const tree = buildBlockTree(blocks);
|
||||
expect(tree[0]!.children.map((c) => c.id)).toEqual(['child-a', 'child-b', 'child-c']);
|
||||
});
|
||||
});
|
||||
84
docs/observability/website.md
Normal file
84
docs/observability/website.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Website Builder — Observability
|
||||
|
||||
_Shipped 2026-04-23 (M7)._
|
||||
|
||||
Every metric below lives on mana-api's `/metrics` scrape endpoint (port 3060, unauthenticated — relies on reverse-proxy to keep it off the public internet).
|
||||
|
||||
## Metrics
|
||||
|
||||
### Write path
|
||||
|
||||
| Metric | Type | Labels | What it tells you |
|
||||
|---|---|---|---|
|
||||
| `mana_api_website_publish_total` | Counter | `result` = success \| slug_taken \| invalid \| error | Publish-attempt outcome mix. |
|
||||
| `mana_api_website_publish_duration_seconds` | Histogram | — | End-to-end publish latency (validation + transaction). |
|
||||
| `mana_api_website_domain_verify_total` | Counter | `result` = verified \| failed | Custom-domain DNS check outcomes. |
|
||||
|
||||
### Public surface
|
||||
|
||||
| Metric | Type | Labels | What it tells you |
|
||||
|---|---|---|---|
|
||||
| `mana_api_website_public_reads_total` | Counter | `result` = hit \| not_found | Anonymous reads of `/public/sites/:slug`. |
|
||||
| `mana_api_website_public_read_age_seconds` | Histogram | — | Age of the served snapshot at read time. A bimodal distribution (many <10s AND many >1h) tells you the edge cache is working. |
|
||||
| `mana_api_website_host_resolve_total` | Counter | `result` = hit \| miss \| error | Custom-host → slug resolutions from the SvelteKit hook. |
|
||||
| `mana_api_website_submissions_total` | Counter | `result` = received \| spam \| rate_limit \| not_found \| invalid | Form submissions received. |
|
||||
|
||||
## Quick PromQL queries
|
||||
|
||||
**Publish success rate (30 min rolling):**
|
||||
```promql
|
||||
sum(rate(mana_api_website_publish_total{result="success"}[30m]))
|
||||
/
|
||||
sum(rate(mana_api_website_publish_total[30m]))
|
||||
```
|
||||
|
||||
**p95 publish latency:**
|
||||
```promql
|
||||
histogram_quantile(0.95, sum by (le) (rate(mana_api_website_publish_duration_seconds_bucket[10m])))
|
||||
```
|
||||
|
||||
**Custom-host resolve hit rate (production target: >98% once bindings stabilise):**
|
||||
```promql
|
||||
sum(rate(mana_api_website_host_resolve_total{result="hit"}[5m]))
|
||||
/
|
||||
sum(rate(mana_api_website_host_resolve_total[5m]))
|
||||
```
|
||||
|
||||
**Spam-to-received ratio (form submissions):**
|
||||
```promql
|
||||
sum(rate(mana_api_website_submissions_total{result="spam"}[1h]))
|
||||
/
|
||||
sum(rate(mana_api_website_submissions_total{result=~"received|spam"}[1h]))
|
||||
```
|
||||
|
||||
## Alerts (recommended)
|
||||
|
||||
- **`website-publish-failure-spike`** — fires when `rate(mana_api_website_publish_total{result="error"}[10m]) > 0.1/s`. Indicates DB trouble or an unhandled exception path.
|
||||
- **`website-public-cold`** — fires when `rate(mana_api_website_public_reads_total[1h]) > 10/s AND rate(mana_api_website_public_read_age_seconds_count{le="10"}[1h]) / rate(mana_api_website_public_read_age_seconds_count[1h]) > 0.5`. Half the traffic is hitting fresh snapshots = the edge cache isn't doing its job, usually a CF config drift.
|
||||
- **`website-domain-verify-failed-burst`** — fires when `increase(mana_api_website_domain_verify_total{result="failed"}[1h]) > 20`. Either ops broke the DNS target (CNAME not pointing anywhere) or one angry user is thrashing.
|
||||
- **`website-form-spam-storm`** — fires when `rate(mana_api_website_submissions_total{result="spam"}[5m]) > 1/s`. Honeypot is holding, but a motivated attacker might move on to CAPTCHA-busting next.
|
||||
|
||||
## Dashboard
|
||||
|
||||
Grafana dashboard lives at `grafana.internal/d/website-builder` (add it to the existing "Mana Services" folder). Panels: publish volume + outcome mix, publish latency heatmap, submissions/spam split, host-resolve hit ratio, domain-verify trend.
|
||||
|
||||
## Orphan-asset GC
|
||||
|
||||
Read-only scan script at `apps/api/scripts/gc-website-assets.ts`. Run manually for now:
|
||||
|
||||
```bash
|
||||
MANA_SERVICE_KEY=… DATABASE_URL=… bun apps/api/scripts/gc-website-assets.ts
|
||||
```
|
||||
|
||||
The script:
|
||||
1. Walks every `published_snapshots.blob` and `submissions.payload` to collect referenced `mediaId`s.
|
||||
2. Asks mana-media for everything scoped to `app=website`.
|
||||
3. Reports items older than 30 days that aren't referenced anywhere.
|
||||
|
||||
**Current status: report-only.** No deletion. After 2–3 weeks of production reports showing the candidate list is stable and doesn't include false positives, we flip a `--delete` flag in a follow-up commit.
|
||||
|
||||
## Future (M7.x)
|
||||
|
||||
- Per-site view counts. Would require a cheap counter table (`website.site_views { site_id, day, count }`) incremented from the public-read handler. Skipped in M7 first-pass because the analytics block already covers the per-visit needs; add when someone asks for a dashboard inside the editor.
|
||||
- Cloudflare hostname status reconciliation. Once the CF SaaS API is wired, a periodic poller should compare our `custom_domains.status` against CF's `hostname.ssl.status` and flag drift.
|
||||
- Submission-payload retention job. Fields are kept indefinitely today; when target-delivery lands (M4.x) the job runs after delivery and nulls the payload, keeping only IDs + status.
|
||||
|
|
@ -736,14 +736,16 @@ Der Service bleibt vorerst nebenher. Gründe:
|
|||
|
||||
### M7 — Observability, GC, Analytics
|
||||
|
||||
- [ ] Prometheus-Metrics: `website_publish_total`, `website_submissions_total`, `website_render_duration_seconds`, `website_cache_hit_ratio`
|
||||
- [ ] Orphan-Asset-GC: Job findet uload-Assets, die in keinem Block mehr referenziert sind, löscht nach 30d Grace-Period
|
||||
- [ ] `analytics`-Block-Typ: Plausible/Simple Analytics Snippet als Opt-In
|
||||
- [ ] Per-Site-Stats im Editor (Views/Tag, Top-Seiten)
|
||||
- [ ] Submission-Retention: `payload` nach erfolgreicher Weitergabe nullen (behält nur IDs + Status für Audit)
|
||||
- [ ] Dashboards in `docs/observability/website.md`
|
||||
- [x] Prometheus-Metrics: `mana_api_website_publish_total{result}`, `_publish_duration_seconds`, `_submissions_total{result}`, `_host_resolve_total{result}`, `_domain_verify_total{result}`, `_public_reads_total{result}`, `_public_read_age_seconds`. `/metrics`-Endpoint an apps/api root (unauth, verlässt sich auf Reverse-Proxy).
|
||||
- [x] Orphan-Asset-GC: Script `apps/api/scripts/gc-website-assets.ts` — **read-only** in M7 first-pass. Walks published_snapshots + submissions for mediaId refs, compares against mana-media's app=website listing, reports orphans older than 30d. Deletion-Toggle nach 2-3 Wochen stabiler Reports.
|
||||
- [x] `analytics`-Block-Typ: Plausible + Umami mit self-hosted Script-URL-Override. Unsichtbar im Editor, emittiert genau einen `<script>` im public-Mode. Keine Cookies, keine PII.
|
||||
- [ ] Per-Site-Stats im Editor (Views/Tag, Top-Seiten) — verschoben auf M7.x. Der Analytics-Block deckt die Visitor-Seite; Editor-Inline-Dashboard ist Backlog bis jemand konkret danach fragt.
|
||||
- [ ] Submission-Retention: `payload` nach erfolgreicher Weitergabe nullen — M4.x Voraussetzung (Target-Delivery ist noch nicht gewired).
|
||||
- [x] Dashboards in `docs/observability/website.md` — Metrics-Referenz, PromQL-Queries, Alert-Vorschläge, Grafana-Pfad.
|
||||
- [x] (Bonus) Rollback-UI — Version-History-Dialog in PublishBar, M2-Exit-Kriterium „Rollback funktioniert" jetzt auch im Editor abgehakt.
|
||||
- [x] (Bonus) Snapshot-Determinism-Test — vitest in `publish.test.ts` verifiziert byte-identische Outputs + Orphan-Drop.
|
||||
|
||||
**Exit criteria:** Betrieb ist beobachtbar, Storage wächst nicht unbegrenzt.
|
||||
**Exit criteria:** Betrieb ist beobachtbar (Metrics + PromQL), Storage-Wachstum ist überwacht (Scan-Script), Analytics-Opt-In steht.
|
||||
|
||||
## Risiken + Mitigation
|
||||
|
||||
|
|
@ -788,4 +790,5 @@ Der Service bleibt vorerst nebenher. Gründe:
|
|||
| M3 | 5 more blocks, containers, upload, themes | `7a4f8894e` |
|
||||
| M4 | Forms + moduleEmbed | `57be0f61b` |
|
||||
| M5 | AI tools + starter templates | `13efae8cd` |
|
||||
| M6 | Subdomain + custom-domain + tier gate + DNS verify + hooks-rewrite | (pending commit at end of M6 session) |
|
||||
| M6 | Subdomain + custom-domain + tier gate + DNS verify + hooks-rewrite | `3eca5ac20` |
|
||||
| M7 | Observability (Prom metrics, /metrics endpoint) + analytics block + orphan-asset GC script + rollback UI + determinism test + docs | (pending commit at end of M7 session) |
|
||||
|
|
|
|||
|
|
@ -75,6 +75,12 @@ const calcSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="n
|
|||
// Context icon (document/knowledge with sky blue gradient)
|
||||
const contextSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#contextGrad)"/><rect x="300" y="240" width="424" height="544" rx="24" fill="white"/><path d="M400 400H624" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round"/><path d="M400 480H580" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.6"/><path d="M400 560H540" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.4"/><path d="M400 640H600" stroke="#0ea5e9" stroke-width="24" stroke-linecap="round" stroke-opacity="0.3"/><path d="M620 240V380H760" stroke="white" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/><path d="M620 240L760 380" stroke="#0ea5e9" stroke-width="16" stroke-linecap="round" stroke-opacity="0.3"/><circle cx="680" cy="620" r="100" fill="#0ea5e9" fill-opacity="0.2" stroke="white" stroke-width="16"/><path d="M660 620L680 640L720 600" stroke="white" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/><defs><linearGradient id="contextGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#0ea5e9"/><stop offset="1" stop-color="#0284c7"/></linearGradient></defs></svg>`;
|
||||
|
||||
// Wardrobe icon — T-shirt on hanger with rose-violet gradient.
|
||||
// Rose/violet to sit between Picture (green) and Calc (pink) without
|
||||
// clashing; the hanger loop sits on the shoulder line so the silhouette
|
||||
// reads as "clothing" at any scale.
|
||||
const wardrobeSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#wardrobeGrad)"/><path d="M512 246c-34 0-62 28-62 62 0 15 6 28 15 37l-113 58c-14 7-22 22-22 37v40l-40 28c-10 7-13 20-8 31l20 40c5 10 16 16 27 13l46-12v256c0 18 14 32 32 32h250c18 0 32-14 32-32V580l46 12c11 3 22-3 27-13l20-40c5-11 2-24-8-31l-40-28v-40c0-15-8-30-22-37l-113-58c9-9 15-22 15-37 0-34-28-62-62-62zm0 44c18 0 32 14 32 32s-14 32-32 32-32-14-32-32 14-32 32-32z" fill="white"/><path d="M420 450c0 50 41 90 92 90s92-40 92-90" stroke="#be185d" stroke-width="6" stroke-linecap="round" fill="none" stroke-opacity="0.25"/><defs><linearGradient id="wardrobeGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#e11d48"/><stop offset="1" stop-color="#a21caf"/></linearGradient></defs></svg>`;
|
||||
|
||||
/**
|
||||
* App icons as data URLs
|
||||
* Use these directly in <img src={APP_ICONS.memoro}> or CSS background-image
|
||||
|
|
@ -102,6 +108,7 @@ export const APP_ICONS = {
|
|||
todo: svgToDataUrl(todoSvg),
|
||||
mail: svgToDataUrl(mailSvg),
|
||||
inventory: svgToDataUrl(inventorySvg),
|
||||
wardrobe: svgToDataUrl(wardrobeSvg),
|
||||
questions: svgToDataUrl(questionsSvg),
|
||||
context: svgToDataUrl(contextSvg),
|
||||
citycorners: svgToDataUrl(citycornersSvg),
|
||||
|
|
|
|||
|
|
@ -377,6 +377,23 @@ export const MANA_APPS: ManaApp[] = [
|
|||
status: 'beta',
|
||||
requiredTier: 'guest',
|
||||
},
|
||||
{
|
||||
id: 'wardrobe',
|
||||
name: 'Wardrobe',
|
||||
description: {
|
||||
de: 'Dein digitaler Kleiderschrank',
|
||||
en: 'Your digital wardrobe',
|
||||
},
|
||||
longDescription: {
|
||||
de: 'Fotografiere Kleidungsstücke, komponiere Outfits und probiere sie mit KI an dir selbst an — vom eigenen Schrank bis zu Brillen, Vereinstrikots und Merch.',
|
||||
en: 'Photograph garments, compose outfits, and try them on yourself with AI — from your own closet to glasses, club jerseys, and brand merch.',
|
||||
},
|
||||
icon: APP_ICONS.wardrobe,
|
||||
color: '#e11d48',
|
||||
comingSoon: false,
|
||||
status: 'beta',
|
||||
requiredTier: 'beta',
|
||||
},
|
||||
{
|
||||
id: 'questions',
|
||||
name: 'Questions',
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
|
|||
'invoices',
|
||||
'activity',
|
||||
'goals',
|
||||
'wardrobe', // Merch-Katalog (T-Shirts, Caps, Zip-Hoodies)
|
||||
],
|
||||
|
||||
club: [
|
||||
|
|
@ -115,6 +116,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
|
|||
'photos',
|
||||
'activity',
|
||||
'goals',
|
||||
'wardrobe', // Vereinstrikots, Club-Bekleidung
|
||||
],
|
||||
|
||||
family: [
|
||||
|
|
@ -139,6 +141,8 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
|
|||
'wetter',
|
||||
'wisekeep',
|
||||
'firsts',
|
||||
'wardrobe', // Familien-Kleiderschrank (Kinder inkl.); Try-On
|
||||
// rendert auf dem aufrufenden Elternteil, nicht auf Kindern
|
||||
],
|
||||
|
||||
team: [
|
||||
|
|
@ -164,6 +168,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
|
|||
'times',
|
||||
'activity',
|
||||
'goals',
|
||||
'wardrobe', // Bühnen-Kostüme, Uniformen, Produktions-Wardrobe
|
||||
],
|
||||
|
||||
practice: [
|
||||
|
|
@ -185,6 +190,7 @@ export const SPACE_MODULE_ALLOWLIST: Record<SpaceType, readonly SpaceModuleId[]
|
|||
'research-lab',
|
||||
'activity',
|
||||
'goals',
|
||||
'wardrobe', // Praxis-Kittel, Dresscode-Items
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
53
packages/website-blocks/src/analytics/Analytics.svelte
Normal file
53
packages/website-blocks/src/analytics/Analytics.svelte
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<script lang="ts">
|
||||
import type { BlockRenderProps } from '../types';
|
||||
import type { AnalyticsProps } from './schema';
|
||||
|
||||
let { block, mode }: BlockRenderProps<AnalyticsProps> = $props();
|
||||
|
||||
const isPublic = $derived(mode === 'public');
|
||||
const configured = $derived(Boolean(block.props.siteKey));
|
||||
|
||||
const plausibleSrc = $derived.by(() => {
|
||||
if (block.props.scriptUrl) return block.props.scriptUrl;
|
||||
return 'https://plausible.io/js/script.js';
|
||||
});
|
||||
|
||||
const umamiSrc = $derived.by(() => {
|
||||
if (block.props.scriptUrl) return block.props.scriptUrl;
|
||||
return 'https://cloud.umami.is/script.js';
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !isPublic}
|
||||
<!-- Silent placeholder in edit/preview — the block is invisible and
|
||||
exists only to emit a tracker snippet at publish time. -->
|
||||
<div class="wb-analytics-meta" data-mode={mode} aria-hidden="true">
|
||||
<span class="wb-analytics-meta__pill">
|
||||
📊 Analytics: {block.props.provider}
|
||||
{#if configured}({block.props.siteKey}){/if}
|
||||
</span>
|
||||
</div>
|
||||
{:else if configured}
|
||||
{#if block.props.provider === 'plausible'}
|
||||
<script defer data-domain={block.props.siteKey} src={plausibleSrc}></script>
|
||||
{:else if block.props.provider === 'umami'}
|
||||
<script defer data-website-id={block.props.siteKey} src={umamiSrc}></script>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.wb-analytics-meta {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.wb-analytics-meta__pill {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.2);
|
||||
border-radius: 9999px;
|
||||
opacity: 0.6;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
<script lang="ts">
|
||||
import type { BlockInspectorProps } from '../types';
|
||||
import type { AnalyticsProps } from './schema';
|
||||
|
||||
let { block, onChange }: BlockInspectorProps<AnalyticsProps> = $props();
|
||||
|
||||
const provider = $derived(block.props.provider);
|
||||
|
||||
const helpText = $derived.by(() => {
|
||||
if (provider === 'plausible') {
|
||||
return 'Trage hier die Domain ein, die du bei Plausible registriert hast (z.B. "meineseite.de"). Keine Cookies, DSGVO-konform.';
|
||||
}
|
||||
return 'Umami Website-ID (UUID). Keine Cookies, DSGVO-konform.';
|
||||
});
|
||||
|
||||
const keyLabel = $derived(provider === 'plausible' ? 'Domain' : 'Website-ID');
|
||||
const keyPlaceholder = $derived(provider === 'plausible' ? 'meineseite.de' : 'abc12345-1234-…');
|
||||
</script>
|
||||
|
||||
<div class="wb-inspector">
|
||||
<label class="wb-field">
|
||||
<span>Provider</span>
|
||||
<select
|
||||
value={block.props.provider}
|
||||
onchange={(e) => onChange({ provider: e.currentTarget.value as AnalyticsProps['provider'] })}
|
||||
>
|
||||
<option value="plausible">Plausible</option>
|
||||
<option value="umami">Umami</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>{keyLabel}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={block.props.siteKey}
|
||||
oninput={(e) => onChange({ siteKey: e.currentTarget.value.trim() })}
|
||||
placeholder={keyPlaceholder}
|
||||
/>
|
||||
<small>{helpText}</small>
|
||||
</label>
|
||||
|
||||
<label class="wb-field">
|
||||
<span>Script-URL (optional)</span>
|
||||
<input
|
||||
type="url"
|
||||
value={block.props.scriptUrl}
|
||||
oninput={(e) => onChange({ scriptUrl: e.currentTarget.value.trim() })}
|
||||
placeholder="https://analytics.deineseite.de/script.js"
|
||||
/>
|
||||
<small>Für selbst-gehostete Instanzen. Leer lassen für Default-CDN.</small>
|
||||
</label>
|
||||
|
||||
<p class="wb-hint">
|
||||
Der Block ist im Editor unsichtbar — er fügt auf der veröffentlichten Website einen einzigen
|
||||
<script>-Tag ein. Keine Cookies, keine PII.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wb-inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wb-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.wb-field > span {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.wb-field input,
|
||||
.wb-field select {
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.wb-field small {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.55;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.wb-hint {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.6;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
19
packages/website-blocks/src/analytics/index.ts
Normal file
19
packages/website-blocks/src/analytics/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { BlockSpec } from '../types';
|
||||
import Analytics from './Analytics.svelte';
|
||||
import AnalyticsInspector from './AnalyticsInspector.svelte';
|
||||
import { AnalyticsSchema, ANALYTICS_DEFAULTS, type AnalyticsProps } from './schema';
|
||||
|
||||
export const analyticsBlockSpec: BlockSpec<AnalyticsProps> = {
|
||||
type: 'analytics',
|
||||
label: 'Analytics',
|
||||
icon: 'chart',
|
||||
category: 'embed',
|
||||
schema: AnalyticsSchema,
|
||||
schemaVersion: 1,
|
||||
defaults: ANALYTICS_DEFAULTS,
|
||||
Component: Analytics,
|
||||
Inspector: AnalyticsInspector,
|
||||
};
|
||||
|
||||
export type { AnalyticsProps };
|
||||
export { AnalyticsSchema, ANALYTICS_DEFAULTS };
|
||||
29
packages/website-blocks/src/analytics/schema.ts
Normal file
29
packages/website-blocks/src/analytics/schema.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Analytics block — injects a tracking snippet into the published
|
||||
* page. Opt-in, no cookies by design (Plausible / Umami are
|
||||
* cookieless).
|
||||
*
|
||||
* The block renders nothing visible in edit/preview; in public mode
|
||||
* it emits a single <script> tag. No PII collection (no visitor IDs,
|
||||
* no fingerprinting), no admin UI access required.
|
||||
*/
|
||||
export const AnalyticsSchema = z.object({
|
||||
provider: z.enum(['plausible', 'umami']).default('plausible'),
|
||||
/** Plausible: the domain property; Umami: the website id (UUID). */
|
||||
siteKey: z.string().max(128).default(''),
|
||||
/**
|
||||
* Optional script-host override for self-hosted instances. Leave
|
||||
* empty for the default provider CDN. Validated as full https URL.
|
||||
*/
|
||||
scriptUrl: z.string().max(512).default(''),
|
||||
});
|
||||
|
||||
export type AnalyticsProps = z.infer<typeof AnalyticsSchema>;
|
||||
|
||||
export const ANALYTICS_DEFAULTS: AnalyticsProps = {
|
||||
provider: 'plausible',
|
||||
siteKey: '',
|
||||
scriptUrl: '',
|
||||
};
|
||||
|
|
@ -52,6 +52,12 @@ export {
|
|||
type EmbedItem,
|
||||
type EmbedSource,
|
||||
} from './moduleEmbed';
|
||||
export {
|
||||
analyticsBlockSpec,
|
||||
AnalyticsSchema,
|
||||
ANALYTICS_DEFAULTS,
|
||||
type AnalyticsProps,
|
||||
} from './analytics';
|
||||
|
||||
export {
|
||||
THEME_PRESETS,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { columnsBlockSpec } from './columns';
|
|||
import { galleryBlockSpec } from './gallery';
|
||||
import { formBlockSpec } from './form';
|
||||
import { moduleEmbedBlockSpec } from './moduleEmbed';
|
||||
import { analyticsBlockSpec } from './analytics';
|
||||
|
||||
/**
|
||||
* The block registry — single source of truth for every block type the
|
||||
|
|
@ -27,6 +28,7 @@ export const BLOCK_SPECS: readonly BlockSpec<unknown>[] = [
|
|||
faqBlockSpec,
|
||||
formBlockSpec,
|
||||
moduleEmbedBlockSpec,
|
||||
analyticsBlockSpec,
|
||||
columnsBlockSpec,
|
||||
spacerBlockSpec,
|
||||
] as unknown as readonly BlockSpec<unknown>[];
|
||||
|
|
|
|||
355
pnpm-lock.yaml
generated
355
pnpm-lock.yaml
generated
|
|
@ -99,6 +99,9 @@ importers:
|
|||
postgres:
|
||||
specifier: ^3.4.0
|
||||
version: 3.4.9
|
||||
prom-client:
|
||||
specifier: ^15.1.3
|
||||
version: 15.1.3
|
||||
rrule:
|
||||
specifier: ^2.8.1
|
||||
version: 2.8.1
|
||||
|
|
@ -138,14 +141,14 @@ importers:
|
|||
version: link:../../../../packages/shared-landing-ui
|
||||
astro:
|
||||
specifier: ^5.16.0
|
||||
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
typescript:
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.3
|
||||
devDependencies:
|
||||
'@astrojs/tailwind':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
|
||||
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
|
||||
'@tailwindcss/typography':
|
||||
specifier: ^0.5.18
|
||||
version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
|
||||
|
|
@ -154,13 +157,13 @@ importers:
|
|||
version: 20.19.39
|
||||
eslint:
|
||||
specifier: ^9.0.0
|
||||
version: 9.39.4(jiti@2.6.1)
|
||||
version: 9.39.4(jiti@1.21.7)
|
||||
eslint-config-prettier:
|
||||
specifier: ^9.1.0
|
||||
version: 9.1.2(eslint@9.39.4(jiti@2.6.1))
|
||||
version: 9.1.2(eslint@9.39.4(jiti@1.21.7))
|
||||
eslint-plugin-astro:
|
||||
specifier: ^1.0.0
|
||||
version: 1.6.0(eslint@9.39.4(jiti@2.6.1))
|
||||
version: 1.6.0(eslint@9.39.4(jiti@1.21.7))
|
||||
prettier:
|
||||
specifier: ^3.6.2
|
||||
version: 3.8.1
|
||||
|
|
@ -253,10 +256,10 @@ importers:
|
|||
version: 3.7.2
|
||||
'@astrojs/tailwind':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
|
||||
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
|
||||
astro:
|
||||
specifier: ^5.16.11
|
||||
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
tailwindcss:
|
||||
specifier: ^3.4.17
|
||||
version: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
|
||||
|
|
@ -2876,6 +2879,9 @@ importers:
|
|||
jose:
|
||||
specifier: ^6.1.2
|
||||
version: 6.2.2
|
||||
prom-client:
|
||||
specifier: ^15.1.3
|
||||
version: 15.1.3
|
||||
zod:
|
||||
specifier: ^3.25.76
|
||||
version: 3.25.76
|
||||
|
|
@ -17247,6 +17253,16 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
|
||||
dependencies:
|
||||
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
autoprefixer: 10.4.27(postcss@8.5.8)
|
||||
postcss: 8.5.8
|
||||
postcss-load-config: 4.0.2(postcss@8.5.8)
|
||||
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
|
||||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
|
||||
dependencies:
|
||||
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
|
|
@ -17267,16 +17283,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
|
||||
dependencies:
|
||||
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
autoprefixer: 10.4.27(postcss@8.5.8)
|
||||
postcss: 8.5.8
|
||||
postcss-load-config: 4.0.2(postcss@8.5.8)
|
||||
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
|
||||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
|
||||
dependencies:
|
||||
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
|
||||
|
|
@ -19436,6 +19442,11 @@ snapshots:
|
|||
'@esbuild/win32-x64@0.27.7':
|
||||
optional: true
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))':
|
||||
dependencies:
|
||||
eslint: 9.39.4(jiti@1.21.7)
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
|
||||
dependencies:
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
|
|
@ -24398,6 +24409,108 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
|
||||
dependencies:
|
||||
'@astrojs/compiler': 2.13.1
|
||||
'@astrojs/internal-helpers': 0.7.6
|
||||
'@astrojs/markdown-remark': 6.3.11
|
||||
'@astrojs/telemetry': 3.3.0
|
||||
'@capsizecss/unpack': 4.0.0
|
||||
'@oslojs/encoding': 1.1.0
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
|
||||
acorn: 8.16.0
|
||||
aria-query: 5.3.2
|
||||
axobject-query: 4.1.0
|
||||
boxen: 8.0.1
|
||||
ci-info: 4.4.0
|
||||
clsx: 2.1.1
|
||||
common-ancestor-path: 1.0.1
|
||||
cookie: 1.1.1
|
||||
cssesc: 3.0.0
|
||||
debug: 4.4.3
|
||||
deterministic-object-hash: 2.0.2
|
||||
devalue: 5.7.0
|
||||
diff: 8.0.4
|
||||
dlv: 1.1.3
|
||||
dset: 3.1.4
|
||||
es-module-lexer: 1.7.0
|
||||
esbuild: 0.27.7
|
||||
estree-walker: 3.0.3
|
||||
flattie: 1.1.1
|
||||
fontace: 0.4.1
|
||||
github-slugger: 2.0.0
|
||||
html-escaper: 3.0.3
|
||||
http-cache-semantics: 4.2.0
|
||||
import-meta-resolve: 4.2.0
|
||||
js-yaml: 4.1.1
|
||||
magic-string: 0.30.21
|
||||
magicast: 0.5.2
|
||||
mrmime: 2.0.1
|
||||
neotraverse: 0.6.18
|
||||
p-limit: 6.2.0
|
||||
p-queue: 8.1.1
|
||||
package-manager-detector: 1.6.0
|
||||
piccolore: 0.1.3
|
||||
picomatch: 4.0.4
|
||||
prompts: 2.4.2
|
||||
rehype: 13.0.2
|
||||
semver: 7.7.4
|
||||
shiki: 3.23.0
|
||||
smol-toml: 1.6.1
|
||||
svgo: 4.0.1
|
||||
tinyexec: 1.0.4
|
||||
tinyglobby: 0.2.15
|
||||
tsconfck: 3.1.6(typescript@5.9.3)
|
||||
ultrahtml: 1.6.0
|
||||
unifont: 0.7.4
|
||||
unist-util-visit: 5.1.0
|
||||
unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1)
|
||||
vfile: 6.0.3
|
||||
vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
vitefu: 1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
xxhash-wasm: 1.1.0
|
||||
yargs-parser: 21.1.1
|
||||
yocto-spinner: 0.2.3
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.25.2(zod@3.25.76)
|
||||
zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76)
|
||||
optionalDependencies:
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@azure/app-configuration'
|
||||
- '@azure/cosmos'
|
||||
- '@azure/data-tables'
|
||||
- '@azure/identity'
|
||||
- '@azure/keyvault-secrets'
|
||||
- '@azure/storage-blob'
|
||||
- '@capacitor/preferences'
|
||||
- '@deno/kv'
|
||||
- '@netlify/blobs'
|
||||
- '@planetscale/database'
|
||||
- '@types/node'
|
||||
- '@upstash/redis'
|
||||
- '@vercel/blob'
|
||||
- '@vercel/functions'
|
||||
- '@vercel/kv'
|
||||
- aws4fetch
|
||||
- db0
|
||||
- idb-keyval
|
||||
- ioredis
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- rollup
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- typescript
|
||||
- uploadthing
|
||||
- yaml
|
||||
|
||||
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
|
||||
dependencies:
|
||||
'@astrojs/compiler': 2.13.1
|
||||
|
|
@ -24602,108 +24715,6 @@ snapshots:
|
|||
- uploadthing
|
||||
- yaml
|
||||
|
||||
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
|
||||
dependencies:
|
||||
'@astrojs/compiler': 2.13.1
|
||||
'@astrojs/internal-helpers': 0.7.6
|
||||
'@astrojs/markdown-remark': 6.3.11
|
||||
'@astrojs/telemetry': 3.3.0
|
||||
'@capsizecss/unpack': 4.0.0
|
||||
'@oslojs/encoding': 1.1.0
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
|
||||
acorn: 8.16.0
|
||||
aria-query: 5.3.2
|
||||
axobject-query: 4.1.0
|
||||
boxen: 8.0.1
|
||||
ci-info: 4.4.0
|
||||
clsx: 2.1.1
|
||||
common-ancestor-path: 1.0.1
|
||||
cookie: 1.1.1
|
||||
cssesc: 3.0.0
|
||||
debug: 4.4.3
|
||||
deterministic-object-hash: 2.0.2
|
||||
devalue: 5.7.0
|
||||
diff: 8.0.4
|
||||
dlv: 1.1.3
|
||||
dset: 3.1.4
|
||||
es-module-lexer: 1.7.0
|
||||
esbuild: 0.27.7
|
||||
estree-walker: 3.0.3
|
||||
flattie: 1.1.1
|
||||
fontace: 0.4.1
|
||||
github-slugger: 2.0.0
|
||||
html-escaper: 3.0.3
|
||||
http-cache-semantics: 4.2.0
|
||||
import-meta-resolve: 4.2.0
|
||||
js-yaml: 4.1.1
|
||||
magic-string: 0.30.21
|
||||
magicast: 0.5.2
|
||||
mrmime: 2.0.1
|
||||
neotraverse: 0.6.18
|
||||
p-limit: 6.2.0
|
||||
p-queue: 8.1.1
|
||||
package-manager-detector: 1.6.0
|
||||
piccolore: 0.1.3
|
||||
picomatch: 4.0.4
|
||||
prompts: 2.4.2
|
||||
rehype: 13.0.2
|
||||
semver: 7.7.4
|
||||
shiki: 3.23.0
|
||||
smol-toml: 1.6.1
|
||||
svgo: 4.0.1
|
||||
tinyexec: 1.0.4
|
||||
tinyglobby: 0.2.15
|
||||
tsconfck: 3.1.6(typescript@5.9.3)
|
||||
ultrahtml: 1.6.0
|
||||
unifont: 0.7.4
|
||||
unist-util-visit: 5.1.0
|
||||
unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1)
|
||||
vfile: 6.0.3
|
||||
vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
vitefu: 1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
xxhash-wasm: 1.1.0
|
||||
yargs-parser: 21.1.1
|
||||
yocto-spinner: 0.2.3
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.25.2(zod@3.25.76)
|
||||
zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76)
|
||||
optionalDependencies:
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@azure/app-configuration'
|
||||
- '@azure/cosmos'
|
||||
- '@azure/data-tables'
|
||||
- '@azure/identity'
|
||||
- '@azure/keyvault-secrets'
|
||||
- '@azure/storage-blob'
|
||||
- '@capacitor/preferences'
|
||||
- '@deno/kv'
|
||||
- '@netlify/blobs'
|
||||
- '@planetscale/database'
|
||||
- '@types/node'
|
||||
- '@upstash/redis'
|
||||
- '@vercel/blob'
|
||||
- '@vercel/functions'
|
||||
- '@vercel/kv'
|
||||
- aws4fetch
|
||||
- db0
|
||||
- idb-keyval
|
||||
- ioredis
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- rollup
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- typescript
|
||||
- uploadthing
|
||||
- yaml
|
||||
|
||||
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
|
||||
dependencies:
|
||||
'@astrojs/compiler': 2.13.1
|
||||
|
|
@ -26535,6 +26546,11 @@ snapshots:
|
|||
eslint: 9.39.4(jiti@2.6.1)
|
||||
semver: 7.7.4
|
||||
|
||||
eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@1.21.7)):
|
||||
dependencies:
|
||||
eslint: 9.39.4(jiti@1.21.7)
|
||||
semver: 7.7.4
|
||||
|
||||
eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@2.6.1)):
|
||||
dependencies:
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
|
|
@ -26544,6 +26560,10 @@ snapshots:
|
|||
dependencies:
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
|
||||
eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@1.21.7)):
|
||||
dependencies:
|
||||
eslint: 9.39.4(jiti@1.21.7)
|
||||
|
||||
eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@2.6.1)):
|
||||
dependencies:
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
|
|
@ -26588,6 +26608,20 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@1.21.7)):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@typescript-eslint/types': 8.58.0
|
||||
astro-eslint-parser: 1.4.0
|
||||
eslint: 9.39.4(jiti@1.21.7)
|
||||
eslint-compat-utils: 0.6.5(eslint@9.39.4(jiti@1.21.7))
|
||||
globals: 16.5.0
|
||||
postcss: 8.5.8
|
||||
postcss-selector-parser: 7.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
||||
|
|
@ -26761,6 +26795,47 @@ snapshots:
|
|||
|
||||
eslint-visitor-keys@5.0.1: {}
|
||||
|
||||
eslint@9.39.4(jiti@1.21.7):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@eslint/config-array': 0.21.2
|
||||
'@eslint/config-helpers': 0.4.2
|
||||
'@eslint/core': 0.17.0
|
||||
'@eslint/eslintrc': 3.3.5
|
||||
'@eslint/js': 9.39.4
|
||||
'@eslint/plugin-kit': 0.4.1
|
||||
'@humanfs/node': 0.16.7
|
||||
'@humanwhocodes/module-importer': 1.0.1
|
||||
'@humanwhocodes/retry': 0.4.3
|
||||
'@types/estree': 1.0.8
|
||||
ajv: 6.14.0
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.3
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 8.4.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
espree: 10.4.0
|
||||
esquery: 1.7.0
|
||||
esutils: 2.0.3
|
||||
fast-deep-equal: 3.1.3
|
||||
file-entry-cache: 8.0.0
|
||||
find-up: 5.0.0
|
||||
glob-parent: 6.0.2
|
||||
ignore: 5.3.2
|
||||
imurmurhash: 0.1.4
|
||||
is-glob: 4.0.3
|
||||
json-stable-stringify-without-jsonify: 1.0.1
|
||||
lodash.merge: 4.6.2
|
||||
minimatch: 3.1.5
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.4
|
||||
optionalDependencies:
|
||||
jiti: 1.21.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint@9.39.4(jiti@2.6.1):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
|
||||
|
|
@ -33868,6 +33943,23 @@ snapshots:
|
|||
lightningcss: 1.32.0
|
||||
terser: 5.46.1
|
||||
|
||||
vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.8
|
||||
rollup: 4.60.1
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 20.19.39
|
||||
fsevents: 2.3.3
|
||||
jiti: 1.21.7
|
||||
lightningcss: 1.32.0
|
||||
terser: 5.46.1
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.3
|
||||
|
||||
vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
|
|
@ -33902,23 +33994,6 @@ snapshots:
|
|||
tsx: 4.21.0
|
||||
yaml: 2.8.3
|
||||
|
||||
vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.8
|
||||
rollup: 4.60.1
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 24.12.2
|
||||
fsevents: 2.3.3
|
||||
jiti: 1.21.7
|
||||
lightningcss: 1.32.0
|
||||
terser: 5.46.1
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.3
|
||||
|
||||
vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
|
|
@ -33936,6 +34011,10 @@ snapshots:
|
|||
tsx: 4.21.0
|
||||
yaml: 2.8.3
|
||||
|
||||
vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
|
||||
optionalDependencies:
|
||||
vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
|
||||
vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
|
||||
optionalDependencies:
|
||||
vite: 6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
|
|
@ -33944,10 +34023,6 @@ snapshots:
|
|||
optionalDependencies:
|
||||
vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
|
||||
vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
|
||||
optionalDependencies:
|
||||
vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
|
||||
vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
|
||||
optionalDependencies:
|
||||
vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue