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:
Till JS 2026-03-31 21:42:26 +02:00
parent ec0af64fd2
commit a02dceb51c
7 changed files with 543 additions and 11 deletions

108
apps/guides/CLAUDE.md Normal file
View 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 |

View file

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

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

View file

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

View file

@ -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 -->

View file

@ -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">

View file

@ -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\"",