mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
feat(guides): ImportModal, share button, CLAUDE.md, server dev scripts
- ImportModal: 3-tab (URL/Text/AI) import UI with preview before saving - Guide detail: share button → generates 7-day shareable link with copy-to-clipboard - App layout: Import button in sidebar + dynamic ImportModal mount - Library page: Import button in header (desktop), openImportGuide context - Port corrected to 3027 (was 3025, conflict with CityCorners) - CLAUDE.md: full project docs (routes, collections, env vars, phase status) - Root package.json: dev:guides:server, updated dev:guides:app/local/full Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ec0af64fd2
commit
a02dceb51c
7 changed files with 543 additions and 11 deletions
108
apps/guides/CLAUDE.md
Normal file
108
apps/guides/CLAUDE.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Guides — CLAUDE.md
|
||||
|
||||
Mana Guides is a local-first step-by-step guide app (SOPs, recipes, tutorials, learning paths).
|
||||
Port: **5200** (web), **3027** (server)
|
||||
Theme: Teal `#0d9488`
|
||||
Tier: `beta`
|
||||
|
||||
## Apps
|
||||
|
||||
| App | Package | Port | Description |
|
||||
|-----|---------|------|-------------|
|
||||
| `apps/web` | `@guides/web` | 5200 | SvelteKit 5 local-first UI |
|
||||
| `apps/server` | `@guides/server` | 3025 | Hono/Bun compute server (import, share) |
|
||||
|
||||
## Dev Commands
|
||||
|
||||
```bash
|
||||
pnpm dev:guides:web # Web only
|
||||
pnpm dev:guides:server # Server only
|
||||
pnpm dev:guides:app # Server + web
|
||||
pnpm dev:guides:local # Sync + server + web (no auth)
|
||||
pnpm dev:guides:full # Auth + sync + server + web
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
**Local-first**: All CRUD goes through `guidesStore` (Dexie.js IndexedDB), synced via mana-sync.
|
||||
**Server (port 3025)**: Compute-only — web import via mana-search + mana-llm, shareable links.
|
||||
|
||||
### Data Model
|
||||
|
||||
```
|
||||
LocalGuide → has many LocalSection, LocalStep
|
||||
LocalSection → has many LocalStep (order field)
|
||||
LocalStep → belongs to LocalGuide, optional LocalSection
|
||||
LocalCollection → has many LocalGuide (ordered list)
|
||||
LocalRun → belongs to LocalGuide, stepStates: Record<stepId, StepState>
|
||||
```
|
||||
|
||||
### Collections
|
||||
|
||||
| Collection | Index | Description |
|
||||
|------------|-------|-------------|
|
||||
| `guides` | category, difficulty, collectionId | Guide library |
|
||||
| `sections` | guideId, order | Optional sections within a guide |
|
||||
| `steps` | guideId, sectionId, order | Steps (instruction/warning/tip/checkpoint/code) |
|
||||
| `collections` | type | Path or Library groupings |
|
||||
| `runs` | guideId, startedAt | Execution history |
|
||||
|
||||
## Routes
|
||||
|
||||
```
|
||||
/ Library (guide grid, search, filters)
|
||||
/guide/[id] Guide detail + edit mode + run history
|
||||
/guide/[id]/run Run mode (?mode=scroll|focus)
|
||||
/collections Collections grid
|
||||
/collections/[id] Collection detail with progress
|
||||
/history All run history
|
||||
/shared/[token] Public shared guide (no auth needed) — Phase 4
|
||||
/(auth)/login Login page
|
||||
```
|
||||
|
||||
## Server Routes (port 3025)
|
||||
|
||||
```
|
||||
POST /api/v1/import/url → fetch URL → mana-search extract → mana-llm → { guide, sections }
|
||||
POST /api/v1/import/text → raw text/markdown → mana-llm → { guide, sections }
|
||||
POST /api/v1/import/ai → AI prompt → mana-llm → { guide, sections }
|
||||
POST /api/v1/share → create shareable link (7-day TTL) → { token, url, expiresAt }
|
||||
GET /api/v1/share/:token → retrieve shared guide snapshot
|
||||
GET /health → service health check
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
# Web
|
||||
PUBLIC_SYNC_SERVER_URL=ws://localhost:3050
|
||||
PUBLIC_GUIDES_SERVER_URL=http://localhost:3027
|
||||
|
||||
# Server
|
||||
PORT=3027
|
||||
CORS_ORIGINS=http://localhost:5200
|
||||
MANA_SEARCH_URL=http://localhost:3021
|
||||
MANA_LLM_URL=http://localhost:3030
|
||||
PUBLIC_BASE_URL=http://localhost:5200
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `apps/web/src/lib/data/local-store.ts` | 5 Dexie collections with TypeScript types |
|
||||
| `apps/web/src/lib/data/guest-seed.ts` | 3 demo guides, 1 collection for onboarding |
|
||||
| `apps/web/src/lib/stores/guides.svelte.ts` | Guide/section/step/collection mutations |
|
||||
| `apps/web/src/lib/stores/runs.svelte.ts` | Run start/step state/complete mutations |
|
||||
| `apps/web/src/routes/(app)/guide/[id]/run/+page.svelte` | Scroll + focus run modes |
|
||||
| `apps/server/src/routes/import.ts` | URL/text/AI import via mana-llm |
|
||||
| `apps/server/src/routes/share.ts` | Shareable guide links (in-memory MVP) |
|
||||
|
||||
## Phase Status
|
||||
|
||||
| Phase | Status | Description |
|
||||
|-------|--------|-------------|
|
||||
| 1 | Done | Core CRUD, local-first, guest seed, library/detail/run views |
|
||||
| 2 | Done | Collections, StepEditorModal, CollectionEditModal, inline step add |
|
||||
| 3 | In progress | Hono server (import + share done), ImportModal frontend, share button |
|
||||
| 4 | Planned | DB persistence for shares, /shared/[token] public route, XP/gamification |
|
||||
|
|
@ -43,7 +43,7 @@ app.get('/health', (c) =>
|
|||
})
|
||||
);
|
||||
|
||||
const port = Number(process.env.PORT ?? 3025);
|
||||
const port = Number(process.env.PORT ?? 3027);
|
||||
console.log(`🚀 Guides server (Hono + Bun) starting on port ${port}`);
|
||||
|
||||
export default { port, fetch: app.fetch };
|
||||
|
|
|
|||
338
apps/guides/apps/web/src/lib/components/ImportModal.svelte
Normal file
338
apps/guides/apps/web/src/lib/components/ImportModal.svelte
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
<script lang="ts">
|
||||
import { guidesStore as dbStore } from '$lib/data/local-store.js';
|
||||
import { guidesStore } from '$lib/stores/guides.svelte.js';
|
||||
|
||||
const SERVER_URL = import.meta.env.PUBLIC_GUIDES_SERVER_URL || 'http://localhost:3027';
|
||||
|
||||
interface ImportedGuide {
|
||||
title: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
difficulty?: string;
|
||||
estimatedMinutes?: number;
|
||||
tags?: string[];
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
interface ImportedSection {
|
||||
title: string;
|
||||
steps: { title: string; content?: string; type?: string }[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onImported: (guideId: string) => void;
|
||||
}
|
||||
|
||||
let { open, onClose, onImported }: Props = $props();
|
||||
|
||||
type Tab = 'url' | 'text' | 'ai';
|
||||
let activeTab = $state<Tab>('url');
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
// URL tab
|
||||
let urlInput = $state('');
|
||||
|
||||
// Text tab
|
||||
let textInput = $state('');
|
||||
let textTitle = $state('');
|
||||
|
||||
// AI tab
|
||||
let aiPrompt = $state('');
|
||||
let aiTitle = $state('');
|
||||
|
||||
// Preview
|
||||
let preview = $state<{ guide: ImportedGuide; sections: ImportedSection[] } | null>(null);
|
||||
|
||||
function reset() {
|
||||
urlInput = '';
|
||||
textInput = '';
|
||||
textTitle = '';
|
||||
aiPrompt = '';
|
||||
aiTitle = '';
|
||||
preview = null;
|
||||
error = '';
|
||||
activeTab = 'url';
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function fetchImport(endpoint: string, body: Record<string, string>) {
|
||||
loading = true;
|
||||
error = '';
|
||||
preview = null;
|
||||
try {
|
||||
const res = await fetch(`${SERVER_URL}/api/v1/import/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json<{ guide?: ImportedGuide; sections?: ImportedSection[]; error?: string }>();
|
||||
if (!res.ok || data.error) {
|
||||
error = data.error ?? 'Import fehlgeschlagen';
|
||||
return;
|
||||
}
|
||||
if (data.guide) {
|
||||
preview = { guide: data.guide, sections: data.sections ?? [] };
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Server nicht erreichbar';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUrlImport() {
|
||||
if (!urlInput.trim()) return;
|
||||
await fetchImport('url', { url: urlInput.trim() });
|
||||
}
|
||||
|
||||
async function handleTextImport() {
|
||||
if (!textInput.trim()) return;
|
||||
await fetchImport('text', { text: textInput, title: textTitle });
|
||||
}
|
||||
|
||||
async function handleAiImport() {
|
||||
if (!aiPrompt.trim()) return;
|
||||
await fetchImport('ai', { prompt: aiPrompt, title: aiTitle });
|
||||
}
|
||||
|
||||
async function saveGuide() {
|
||||
if (!preview) return;
|
||||
loading = true;
|
||||
try {
|
||||
const { guide, sections } = preview;
|
||||
|
||||
// Create guide
|
||||
const guideId = await guidesStore.createGuide({
|
||||
title: guide.title,
|
||||
description: guide.description,
|
||||
category: guide.category ?? 'Allgemein',
|
||||
difficulty: (guide.difficulty as 'easy' | 'medium' | 'hard') ?? 'medium',
|
||||
estimatedMinutes: guide.estimatedMinutes,
|
||||
tags: guide.tags ?? [],
|
||||
coverEmoji: '📖',
|
||||
collectionId: undefined,
|
||||
orderInCollection: undefined,
|
||||
xpReward: undefined,
|
||||
skillId: undefined,
|
||||
});
|
||||
|
||||
// Create sections and steps
|
||||
let globalStepOrder = 0;
|
||||
for (let si = 0; si < sections.length; si++) {
|
||||
const sec = sections[si];
|
||||
let sectionId: string | undefined;
|
||||
|
||||
if (sections.length > 1 || sec.title) {
|
||||
sectionId = await guidesStore.createSection(guideId, {
|
||||
title: sec.title,
|
||||
order: si,
|
||||
});
|
||||
}
|
||||
|
||||
for (const step of sec.steps ?? []) {
|
||||
await guidesStore.createStep(guideId, {
|
||||
sectionId,
|
||||
order: globalStepOrder++,
|
||||
title: step.title,
|
||||
content: step.content,
|
||||
type: (step.type as 'instruction' | 'warning' | 'tip' | 'checkpoint' | 'code') ?? 'instruction',
|
||||
checkable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
reset();
|
||||
onImported(guideId);
|
||||
} catch (e) {
|
||||
error = 'Speichern fehlgeschlagen';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const difficultyLabel: Record<string, string> = { easy: 'Einfach', medium: 'Mittel', hard: 'Schwer' };
|
||||
const totalSteps = $derived(preview?.sections.reduce((n, s) => n + (s.steps?.length ?? 0), 0) ?? 0);
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onclick={handleClose}
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-lg bg-white dark:bg-neutral-900 rounded-t-2xl sm:rounded-2xl shadow-2xl max-h-[90vh] flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<h2 class="text-lg font-semibold dark:text-white">Guide importieren</h2>
|
||||
<button onclick={handleClose} class="p-1 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800" aria-label="Schließen">
|
||||
<svg width="20" height="20" viewBox="0 0 256 256" class="text-neutral-500"><path fill="currentColor" d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !preview}
|
||||
<!-- Tab bar -->
|
||||
<div class="flex border-b border-neutral-200 dark:border-neutral-700">
|
||||
{#each [['url', '🔗 URL'], ['text', '📝 Text'], ['ai', '✨ KI']] as [tab, label]}
|
||||
<button
|
||||
onclick={() => { activeTab = tab as Tab; error = ''; }}
|
||||
class="flex-1 py-3 text-sm font-medium transition-colors {activeTab === tab
|
||||
? 'text-teal-600 border-b-2 border-teal-600 dark:text-teal-400 dark:border-teal-400'
|
||||
: 'text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200'}"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Tab content -->
|
||||
<div class="flex-1 overflow-y-auto p-5 space-y-4">
|
||||
{#if activeTab === 'url'}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={urlInput}
|
||||
placeholder="https://example.com/tutorial"
|
||||
class="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
|
||||
onkeydown={(e) => e.key === 'Enter' && handleUrlImport()}
|
||||
/>
|
||||
<p class="mt-1 text-xs text-neutral-400">Webseite wird extrahiert und in eine Anleitung umgewandelt</p>
|
||||
</div>
|
||||
{:else if activeTab === 'text'}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Titel (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={textTitle}
|
||||
placeholder="Meine Anleitung"
|
||||
class="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Text / Markdown</label>
|
||||
<textarea
|
||||
bind:value={textInput}
|
||||
placeholder="Füge hier Text, Markdown oder eine Anleitung ein..."
|
||||
rows="8"
|
||||
class="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 dark:text-white text-sm font-mono focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
{:else if activeTab === 'ai'}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Titel (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={aiTitle}
|
||||
placeholder="z. B. Docker einrichten"
|
||||
class="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Was soll die Anleitung erklären?</label>
|
||||
<textarea
|
||||
bind:value={aiPrompt}
|
||||
placeholder="z. B. Wie richte ich einen Ubuntu-Server mit Nginx, Let's Encrypt und automatischen Updates ein?"
|
||||
rows="5"
|
||||
class="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none"
|
||||
onkeydown={(e) => e.key === 'Enter' && e.metaKey && handleAiImport()}
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-neutral-400">⌘↵ zum Generieren</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-red-500 bg-red-50 dark:bg-red-900/20 rounded-lg px-3 py-2">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-5 py-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<button
|
||||
onclick={activeTab === 'url' ? handleUrlImport : activeTab === 'text' ? handleTextImport : handleAiImport}
|
||||
disabled={loading || (activeTab === 'url' ? !urlInput.trim() : activeTab === 'text' ? !textInput.trim() : !aiPrompt.trim())}
|
||||
class="w-full py-2.5 rounded-xl bg-teal-600 text-white font-semibold text-sm hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if loading}
|
||||
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
Verarbeite…
|
||||
{:else}
|
||||
{activeTab === 'ai' ? '✨ Guide generieren' : '🔍 Importieren'}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- Preview -->
|
||||
<div class="flex-1 overflow-y-auto p-5 space-y-4">
|
||||
<div class="bg-teal-50 dark:bg-teal-900/20 rounded-xl p-4 space-y-1">
|
||||
<h3 class="font-semibold text-teal-900 dark:text-teal-100 text-base">{preview.guide.title}</h3>
|
||||
{#if preview.guide.description}
|
||||
<p class="text-sm text-teal-700 dark:text-teal-300">{preview.guide.description}</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-2 pt-1">
|
||||
<span class="text-xs bg-teal-100 dark:bg-teal-800 text-teal-700 dark:text-teal-200 px-2 py-0.5 rounded-full">{preview.guide.category ?? 'Allgemein'}</span>
|
||||
<span class="text-xs bg-teal-100 dark:bg-teal-800 text-teal-700 dark:text-teal-200 px-2 py-0.5 rounded-full">{difficultyLabel[preview.guide.difficulty ?? 'medium'] ?? preview.guide.difficulty}</span>
|
||||
{#if preview.guide.estimatedMinutes}
|
||||
<span class="text-xs bg-teal-100 dark:bg-teal-800 text-teal-700 dark:text-teal-200 px-2 py-0.5 rounded-full">⏱ {preview.guide.estimatedMinutes} Min.</span>
|
||||
{/if}
|
||||
<span class="text-xs bg-teal-100 dark:bg-teal-800 text-teal-700 dark:text-teal-200 px-2 py-0.5 rounded-full">{totalSteps} Schritte</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each preview.sections as section, si}
|
||||
{#if section.title}
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-neutral-400 dark:text-neutral-500 mt-3">{section.title}</p>
|
||||
{/if}
|
||||
{#each section.steps as step, i}
|
||||
<div class="flex gap-3 items-start">
|
||||
<span class="shrink-0 w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400 text-xs font-bold flex items-center justify-center">{i + 1}</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium dark:text-white">{step.title}</p>
|
||||
{#if step.content}
|
||||
<p class="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 line-clamp-2">{step.content}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-red-500 bg-red-50 dark:bg-red-900/20 rounded-lg px-3 py-2">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Preview footer -->
|
||||
<div class="px-5 py-4 border-t border-neutral-200 dark:border-neutral-700 flex gap-3">
|
||||
<button
|
||||
onclick={() => { preview = null; error = ''; }}
|
||||
class="flex-1 py-2.5 rounded-xl border border-neutral-300 dark:border-neutral-600 text-neutral-700 dark:text-neutral-300 font-semibold text-sm hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<button
|
||||
onclick={saveGuide}
|
||||
disabled={loading}
|
||||
class="flex-1 py-2.5 rounded-xl bg-teal-600 text-white font-semibold text-sm hover:bg-teal-700 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if loading}
|
||||
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
||||
{/if}
|
||||
Guide speichern
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -12,7 +12,9 @@
|
|||
|
||||
// Context for child pages
|
||||
let showCreateModal = $state(false);
|
||||
let showImportModal = $state(false);
|
||||
setContext('openCreateGuide', () => { showCreateModal = true; });
|
||||
setContext('openImportGuide', () => { showImportModal = true; });
|
||||
|
||||
// Nav items
|
||||
const navItems = [
|
||||
|
|
@ -57,7 +59,7 @@
|
|||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="p-3">
|
||||
<div class="p-3 flex flex-col gap-2">
|
||||
<button
|
||||
onclick={() => (showCreateModal = true)}
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-hover"
|
||||
|
|
@ -65,6 +67,12 @@
|
|||
<Plus class="h-4 w-4" />
|
||||
Neue Anleitung
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showImportModal = true)}
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
↓ Importieren
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
|
@ -115,3 +123,16 @@
|
|||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
{#if showImportModal}
|
||||
{#await import('$lib/components/ImportModal.svelte') then { default: ImportModal }}
|
||||
<ImportModal
|
||||
open={true}
|
||||
onClose={() => (showImportModal = false)}
|
||||
onImported={(id) => {
|
||||
showImportModal = false;
|
||||
goto(`/guide/${id}`);
|
||||
}}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@
|
|||
);
|
||||
|
||||
const openCreateGuide = getContext<() => void>('openCreateGuide');
|
||||
const openImportGuide = getContext<() => void>('openImportGuide');
|
||||
|
||||
const difficultyLabels = { easy: 'Einfach', medium: 'Mittel', hard: 'Schwer' };
|
||||
</script>
|
||||
|
|
@ -59,12 +60,20 @@
|
|||
<h1 class="text-2xl font-bold text-foreground">Bibliothek</h1>
|
||||
<p class="text-sm text-muted-foreground">{allGuides.length} Anleitungen</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={openCreateGuide}
|
||||
class="hidden rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary-hover md:flex items-center gap-2"
|
||||
>
|
||||
+ Neue Anleitung
|
||||
</button>
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<button
|
||||
onclick={openImportGuide}
|
||||
class="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
↓ Importieren
|
||||
</button>
|
||||
<button
|
||||
onclick={openCreateGuide}
|
||||
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary-hover flex items-center gap-2"
|
||||
>
|
||||
+ Neue Anleitung
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search & Filters -->
|
||||
|
|
|
|||
|
|
@ -164,6 +164,40 @@
|
|||
if (activeRun) goto(`/guide/${guideId}/run?runId=${activeRun.id}&mode=${activeRun.mode}`);
|
||||
}
|
||||
|
||||
// ── Share ────────────────────────────────────────────────
|
||||
|
||||
const SERVER_URL = import.meta.env.PUBLIC_GUIDES_SERVER_URL || 'http://localhost:3027';
|
||||
let shareUrl = $state<string | null>(null);
|
||||
let shareLoading = $state(false);
|
||||
let shareCopied = $state(false);
|
||||
|
||||
async function shareGuide() {
|
||||
if (!guide) return;
|
||||
shareLoading = true;
|
||||
try {
|
||||
const res = await fetch(`${SERVER_URL}/api/v1/share`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ guide, sections: steps }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json<{ url: string }>();
|
||||
shareUrl = data.url;
|
||||
}
|
||||
} catch {
|
||||
// silently fail — share is optional
|
||||
} finally {
|
||||
shareLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyShareUrl() {
|
||||
if (!shareUrl) return;
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
shareCopied = true;
|
||||
setTimeout(() => { shareCopied = false; }, 2000);
|
||||
}
|
||||
|
||||
// ── Display config ───────────────────────────────────────
|
||||
|
||||
const difficultyConfig = {
|
||||
|
|
@ -194,6 +228,14 @@
|
|||
← Bibliothek
|
||||
</a>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={shareGuide}
|
||||
disabled={shareLoading}
|
||||
class="rounded-lg px-3 py-1.5 text-xs font-medium border border-border text-muted-foreground hover:bg-accent transition-colors disabled:opacity-50"
|
||||
title="Guide teilen"
|
||||
>
|
||||
{shareLoading ? '…' : '↗ Teilen'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (editMode = !editMode)}
|
||||
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors
|
||||
|
|
@ -246,6 +288,19 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share URL banner -->
|
||||
{#if shareUrl}
|
||||
<div class="mb-4 flex items-center gap-2 rounded-xl border border-teal-200 bg-teal-50 dark:border-teal-800 dark:bg-teal-950/30 px-4 py-3">
|
||||
<span class="text-xs text-teal-700 dark:text-teal-300 flex-1 truncate">{shareUrl}</span>
|
||||
<button
|
||||
onclick={copyShareUrl}
|
||||
class="shrink-0 rounded-lg px-3 py-1 text-xs font-medium bg-teal-600 text-white hover:bg-teal-700 transition-colors"
|
||||
>
|
||||
{shareCopied ? '✓ Kopiert' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Active run banner -->
|
||||
{#if activeRun && !editMode}
|
||||
<div class="mb-6 rounded-xl border border-primary/30 bg-primary/5 p-4">
|
||||
|
|
|
|||
|
|
@ -141,9 +141,10 @@
|
|||
"dev:calc:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:calc:web\"",
|
||||
"guides:dev": "turbo run dev --filter=guides...",
|
||||
"dev:guides:web": "pnpm --filter @guides/web dev",
|
||||
"dev:guides:app": "pnpm dev:guides:web",
|
||||
"dev:guides:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:guides:web\"",
|
||||
"dev:guides:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:guides:web\"",
|
||||
"dev:guides:server": "pnpm --filter @guides/server dev",
|
||||
"dev:guides:app": "concurrently -n server,web -c yellow,cyan \"pnpm dev:guides:server\" \"pnpm dev:guides:web\"",
|
||||
"dev:guides:local": "concurrently -n sync,server,web -c magenta,yellow,cyan \"pnpm dev:sync\" \"pnpm dev:guides:server\" \"pnpm dev:guides:web\"",
|
||||
"dev:guides:full": "concurrently -n auth,sync,server,web -c blue,magenta,yellow,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:guides:server\" \"pnpm dev:guides:web\"",
|
||||
"moodlit:dev": "turbo run dev --filter=moodlit...",
|
||||
"dev:moodlit:mobile": "pnpm --filter @moodlit/mobile dev",
|
||||
"dev:moodlit:app": "concurrently -n server,web -c yellow,cyan \"pnpm dev:moodlit:server\" \"pnpm dev:moodlit:web\"",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue