mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 00:39:39 +02:00
Pre-launch theme system audit found multiple parallel layers in themes.css
(--theme-X full hsl strings, --X partial shadcn aliases, --color-X populated
by runtime store with raw channels) plus dead-code companion files. The
inconsistency caused light-mode regressions when scoped-CSS consumers
wrote `var(--color-X)` standalone — the variable holds raw HSL channels
which is invalid as a color value, browser fell back to inherited (white).
Rewrite to one consistent layer:
- Source of truth: --color-X defined as raw HSL channels (e.g.
`0 0% 17%`) in :root, .dark, and all variant [data-theme="..."]
blocks. Matches the format the runtime store
(@mana/shared-theme/src/utils.ts) writes, eliminating the
static-fallback-vs-runtime mismatch and the corresponding flash
of unstyled content on hydration.
- @theme inline uses self-reference + Tailwind v4 <alpha-value>
placeholder so utility classes generate correctly AND opacity
modifiers work: `text-foreground/50` → `hsl(var(--color-foreground) / 0.5)`.
- @layer components (.btn-primary, .card, .badge, etc.) wraps
var(--color-X) refs with hsl() — they were broken in light mode
too for the same reason.
Convention going forward (also documented in the file header):
1. Markup: use Tailwind utility classes (text-foreground, bg-card, …)
2. Scoped CSS: hsl(var(--color-X)) — always wrap with hsl()
3. NEVER raw var(--color-X) in CSS — that's the bug pattern
Net file: 692 → 580 LOC. Single source layer, no indirection.
Also delete dead companion files (zero imports anywhere):
- tailwind-v4.css (had broken self-reference, never imported)
- theme-variables.css (legacy hex-based palette)
- components.css (legacy component utilities)
- index.js / preset.js / colors.js (Tailwind v3 preset format,
irrelevant under Tailwind v4)
package.json exports map shrinks accordingly to just `./themes.css`.
Consumers using `hsl(var(--color-X))` (~379 files across mana-web,
manavoxel-web, arcade-web) keep working unchanged — the public API
name `--color-X` is preserved. Only the broken pattern `var(--color-X)`
(~61 files) needs a follow-up sweep, handled in a separate commit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
218 lines
6.8 KiB
TypeScript
218 lines
6.8 KiB
TypeScript
/**
|
|
* Guides module — Content import (URL/text/AI) + shareable links
|
|
* Ported from apps/guides/apps/server
|
|
*
|
|
* All CRUD is handled client-side via local-first + mana-sync.
|
|
* This module handles web import via mana-search + mana-llm, and share links.
|
|
*/
|
|
|
|
import { Hono, type Context } from 'hono';
|
|
import { logger } from '@mana/shared-hono';
|
|
|
|
const MANA_SEARCH_URL = process.env.MANA_SEARCH_URL ?? 'http://localhost:3021';
|
|
const MANA_LLM_URL = process.env.MANA_LLM_URL ?? 'http://localhost:3030';
|
|
|
|
const routes = new Hono();
|
|
|
|
// ─── Import: URL ────────────────────────────────────────────
|
|
|
|
routes.post('/import/url', async (c) => {
|
|
const body = await c.req.json<{ url: string }>();
|
|
const { url } = body;
|
|
|
|
if (!url || !URL.canParse(url)) {
|
|
return c.json({ error: 'Ungültige URL' }, 400);
|
|
}
|
|
|
|
// Extract content via mana-search
|
|
let extracted: { title?: string; content?: string; markdown?: string } = {};
|
|
try {
|
|
const res = await fetch(`${MANA_SEARCH_URL}/api/v1/extract`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url, options: { includeMarkdown: true } }),
|
|
});
|
|
if (res.ok) {
|
|
extracted = await res.json();
|
|
}
|
|
} catch (e) {
|
|
logger.error('guides.extract_failed', { error: e instanceof Error ? e.message : String(e) });
|
|
}
|
|
|
|
const content = extracted.markdown ?? extracted.content ?? '';
|
|
if (!content) {
|
|
return c.json({ error: 'Inhalt konnte nicht extrahiert werden' }, 422);
|
|
}
|
|
|
|
return await generateGuideFromText(c, {
|
|
title: extracted.title,
|
|
text: content,
|
|
sourceUrl: url,
|
|
});
|
|
});
|
|
|
|
// ─── Import: Text/Markdown ──────────────────────────────────
|
|
|
|
routes.post('/import/text', async (c) => {
|
|
const body = await c.req.json<{ text: string; title?: string }>();
|
|
const { text, title } = body;
|
|
|
|
if (!text?.trim()) {
|
|
return c.json({ error: 'Kein Text angegeben' }, 400);
|
|
}
|
|
|
|
return await generateGuideFromText(c, { text, title });
|
|
});
|
|
|
|
// ─── Import: AI Generation ──────────────────────────────────
|
|
|
|
routes.post('/import/ai', async (c) => {
|
|
const body = await c.req.json<{ prompt: string; title?: string }>();
|
|
const { prompt, title } = body;
|
|
|
|
if (!prompt?.trim()) {
|
|
return c.json({ error: 'Kein Prompt angegeben' }, 400);
|
|
}
|
|
|
|
return await generateGuideFromText(c, {
|
|
text: prompt,
|
|
title,
|
|
isAiPrompt: true,
|
|
});
|
|
});
|
|
|
|
// ─── Share: Create + Retrieve ───────────────────────────────
|
|
|
|
// In-memory store for shared guides (replace with DB later)
|
|
const sharedGuides = new Map<
|
|
string,
|
|
{ guide: unknown; sections: unknown[]; createdAt: string; expiresAt: string }
|
|
>();
|
|
|
|
routes.post('/share', async (c) => {
|
|
const body = await c.req.json<{ guide: unknown; sections: unknown[] }>();
|
|
|
|
if (!body.guide) {
|
|
return c.json({ error: 'Kein Guide-Inhalt angegeben' }, 400);
|
|
}
|
|
|
|
const token = crypto.randomUUID().replace(/-/g, '').slice(0, 12);
|
|
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); // 7 days
|
|
|
|
sharedGuides.set(token, {
|
|
guide: body.guide,
|
|
sections: body.sections ?? [],
|
|
createdAt: new Date().toISOString(),
|
|
expiresAt,
|
|
});
|
|
|
|
const baseUrl = process.env.PUBLIC_BASE_URL ?? 'http://localhost:5200';
|
|
return c.json({ token, url: `${baseUrl}/shared/${token}`, expiresAt });
|
|
});
|
|
|
|
routes.get('/share/:token', (c) => {
|
|
const { token } = c.req.param();
|
|
const shared = sharedGuides.get(token);
|
|
|
|
if (!shared) {
|
|
return c.json({ error: 'Guide nicht gefunden oder Link abgelaufen' }, 404);
|
|
}
|
|
|
|
if (new Date(shared.expiresAt) < new Date()) {
|
|
sharedGuides.delete(token);
|
|
return c.json({ error: 'Dieser Link ist abgelaufen' }, 410);
|
|
}
|
|
|
|
return c.json(shared);
|
|
});
|
|
|
|
// ─── Shared: LLM guide generation ──────────────────────────
|
|
|
|
async function generateGuideFromText(
|
|
c: Context,
|
|
opts: { text: string; title?: string; sourceUrl?: string; isAiPrompt?: boolean }
|
|
) {
|
|
const systemPrompt = `Du bist ein Experte für das Erstellen strukturierter Schritt-für-Schritt-Anleitungen.
|
|
Analysiere den folgenden Text und erstelle daraus eine strukturierte Anleitung im JSON-Format.
|
|
|
|
Antworte NUR mit einem validen JSON-Objekt in diesem exakten Format:
|
|
{
|
|
"title": "Titel der Anleitung",
|
|
"description": "Kurze Beschreibung (1-2 Sätze)",
|
|
"category": "Technik|Kochen|Sport|Lernen|Arbeit|Haushalt|Hobby|Allgemein",
|
|
"difficulty": "easy|medium|hard",
|
|
"estimatedMinutes": Zahl,
|
|
"tags": ["tag1", "tag2"],
|
|
"sections": [
|
|
{
|
|
"title": "Abschnitt-Titel (optional, leer lassen wenn keine Sections nötig)",
|
|
"steps": [
|
|
{
|
|
"title": "Schritt-Titel",
|
|
"content": "Optionale Details oder Code",
|
|
"type": "instruction|warning|tip|checkpoint|code"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
Regeln:
|
|
- Maximal 3-4 Abschnitte, maximal 8-10 Schritte pro Abschnitt
|
|
- type "warning" nur bei wirklichen Warnungen/Gefahren
|
|
- type "tip" für hilfreiche Hinweise
|
|
- type "code" wenn der Inhalt Kommandos oder Code enthält
|
|
- type "checkpoint" für Überprüfungsschritte
|
|
- Wenn kein sinnvolles Abschnitt-System, eine leere Section mit title ""
|
|
- Schritt-Titel: prägnant, maximal 80 Zeichen
|
|
- Auf Deutsch antworten`;
|
|
|
|
const userMessage = opts.isAiPrompt
|
|
? `Erstelle eine Anleitung für: ${opts.text}`
|
|
: `Hier ist der Inhalt, den du in eine Anleitung umwandeln sollst:\n\n${opts.text.slice(0, 8000)}`;
|
|
|
|
try {
|
|
const llmRes = await fetch(`${MANA_LLM_URL}/api/v1/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
{ role: 'user', content: userMessage },
|
|
],
|
|
model: 'claude-haiku-4-5-20251001',
|
|
temperature: 0.3,
|
|
maxTokens: 4096,
|
|
}),
|
|
});
|
|
|
|
if (!llmRes.ok) {
|
|
throw new Error(`LLM error: ${llmRes.status}`);
|
|
}
|
|
|
|
const llmData = (await llmRes.json()) as { content: string };
|
|
const rawJson = llmData.content.trim();
|
|
|
|
// Extract JSON from potential markdown code fences
|
|
const jsonMatch = rawJson.match(/```(?:json)?\s*([\s\S]*?)\s*```/) ?? [null, rawJson];
|
|
const parsed = JSON.parse(jsonMatch[1] ?? rawJson);
|
|
|
|
return c.json({
|
|
guide: {
|
|
title: opts.title ?? parsed.title,
|
|
description: parsed.description,
|
|
category: parsed.category ?? 'Allgemein',
|
|
difficulty: parsed.difficulty ?? 'medium',
|
|
estimatedMinutes: parsed.estimatedMinutes,
|
|
tags: parsed.tags ?? [],
|
|
sourceUrl: opts.sourceUrl,
|
|
},
|
|
sections: parsed.sections ?? [],
|
|
});
|
|
} catch (e) {
|
|
logger.error('guides.generate_failed', { error: e instanceof Error ? e.message : String(e) });
|
|
return c.json({ error: 'Guide-Generierung fehlgeschlagen', details: String(e) }, 500);
|
|
}
|
|
}
|
|
|
|
export { routes as guidesRoutes };
|