managarten/apps/api/src/modules/guides/routes.ts
Till JS 919fcca4b7 refactor(shared-tailwind): rewrite themes.css to single-layer shadcn convention
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>
2026-04-09 01:13:06 +02:00

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 };