feat: major update with network graphs, themes, todo extensions, and more

## New Features

### Network Graph Visualization (Contacts, Calendar, Todo)
- D3.js force simulation for physics-based layout
- Zoom & pan with mouse/touchpad
- Keyboard shortcuts: +/- zoom, 0 reset, Esc deselect, / search, F focus
- Filtering by tags, company/location/project, connection strength
- Shared components in @manacore/shared-ui

### Central Tags API (mana-core-auth)
- CRUD endpoints for tags
- Schema: tags table with userId, name, color, app
- Shared tag components in @manacore/shared-ui

### Custom Themes System
- Theme editor with live preview and color picker
- Community theme gallery
- Theme sharing (public, unlisted, private)
- Backend API in mana-core-auth

### Todo App Extensions
- Glass-pill design for task input and items
- Settings page with 20+ preferences
- Task edit modal with inline editing
- Statistics page with visualizations
- PWA support with offline capabilities
- Multiple kanban boards

### Contacts App Features
- Duplicate detection
- Photo upload
- Batch operations
- Enhanced favorites page with multiple view modes
- Alphabet view improvements
- Search modal

### Help System
- @manacore/shared-help-content
- @manacore/shared-help-ui
- @manacore/shared-help-types

### Other Features
- Themes page for all apps
- Referral system frontend
- CommandBar (global search)
- Skeleton loaders
- Settings page improvements

## Bug Fixes
- Network graph simulation initialization
- Database schema TEXT for user_id columns (Better Auth compatibility)
- Various styling fixes

## Documentation
- Daily report for 2025-12-10
- CI/CD deployment guide

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-10 02:37:46 +01:00
parent e84371aa94
commit ee42b6cc76
381 changed files with 39284 additions and 6275 deletions

View file

@ -0,0 +1,32 @@
---
id: contact-support
title: Support kontaktieren
language: de
order: 1
supportEmail: support@manacore.app
responseTime: In der Regel innerhalb von 24 Stunden
---
## Brauchst du Hilfe?
Unser Support-Team hilft dir bei allen Fragen oder Problemen.
### Bevor du uns kontaktierst
- Schau in den **FAQ**-Bereich für schnelle Antworten
- Durchstöbere unsere **Erste Schritte** Anleitungen
- Nutze die Suchfunktion im Hilfe-Center
### Kontaktmöglichkeiten
- **E-Mail**: Schreib uns an support@manacore.app
- **Antwortzeit**: Wir antworten in der Regel innerhalb von 24 Stunden an Werktagen
### Was du angeben solltest
Wenn du den Support kontaktierst, gib bitte an:
1. Deine Konto-E-Mail-Adresse
2. Eine klare Beschreibung deines Problems
3. Schritte zur Reproduktion (falls zutreffend)
4. Screenshots, wenn sie bei der Erklärung helfen

View file

@ -0,0 +1,32 @@
---
id: contact-support
title: Contact Support
language: en
order: 1
supportEmail: support@manacore.app
responseTime: Usually within 24 hours
---
## Need Help?
Our support team is here to help you with any questions or issues.
### Before Contacting Us
- Check the **FAQ** section for quick answers
- Browse our **Getting Started** guides
- Search the help center using the search bar
### Contact Options
- **Email**: Send us a message at support@manacore.app
- **Response Time**: We typically respond within 24 hours on business days
### What to Include
When contacting support, please include:
1. Your account email address
2. A clear description of your issue
3. Steps to reproduce the problem (if applicable)
4. Screenshots if they help explain the issue

View file

@ -0,0 +1,21 @@
---
id: faq-account-001
question: Wie erstelle ich ein Konto?
category: account
order: 1
language: de
featured: true
tags:
- konto
- registrierung
- anmeldung
---
Die Kontoerstellung ist einfach:
1. Klicke auf **Registrieren** auf der Anmeldeseite
2. Gib deine E-Mail-Adresse ein und wähle ein sicheres Passwort
3. Bestätige deine E-Mail-Adresse durch Klick auf den Link, den wir dir senden
4. Vervollständige dein Profil
Du kannst dich auch mit deinem Google- oder Apple-Konto registrieren, um schneller loszulegen.

View file

@ -0,0 +1,21 @@
---
id: faq-billing-001
question: Wie kann ich mein Abo kündigen?
category: billing
order: 1
language: de
featured: true
tags:
- abo
- kündigung
- abrechnung
---
Du kannst dein Abo jederzeit kündigen:
1. Gehe zu **Einstellungen** > **Abonnement**
2. Klicke auf **Abo verwalten**
3. Wähle **Abo kündigen**
4. Bestätige die Kündigung
Dein Abo bleibt bis zum Ende des aktuellen Abrechnungszeitraums aktiv. Nach der Kündigung erfolgen keine weiteren Abbuchungen.

View file

@ -0,0 +1,23 @@
---
id: faq-privacy-001
question: Wie werden meine Daten geschützt?
category: privacy
order: 1
language: de
featured: true
tags:
- datenschutz
- daten
- sicherheit
- dsgvo
---
Wir nehmen deinen Datenschutz ernst:
- **Verschlüsselung**: Alle Daten werden bei der Übertragung (TLS) und im Ruhezustand verschlüsselt
- **DSGVO-konform**: Wir halten uns an die EU-Datenschutzverordnung
- **Kein Datenverkauf**: Wir verkaufen niemals deine persönlichen Daten an Dritte
- **Datenexport**: Du kannst jederzeit alle deine Daten exportieren
- **Kontolöschung**: Du kannst dein Konto und alle zugehörigen Daten dauerhaft löschen
Weitere Details findest du in unserer [Datenschutzerklärung](/privacy).

View file

@ -0,0 +1,21 @@
---
id: faq-account-001
question: How do I create an account?
category: account
order: 1
language: en
featured: true
tags:
- account
- registration
- signup
---
Creating an account is simple:
1. Click the **Sign Up** button on the login page
2. Enter your email address and choose a secure password
3. Verify your email address by clicking the link we send you
4. Complete your profile setup
You can also sign up using your Google or Apple account for faster registration.

View file

@ -0,0 +1,21 @@
---
id: faq-billing-001
question: How do I cancel my subscription?
category: billing
order: 1
language: en
featured: true
tags:
- subscription
- cancel
- billing
---
You can cancel your subscription at any time:
1. Go to **Settings** > **Subscription**
2. Click **Manage Subscription**
3. Select **Cancel Subscription**
4. Confirm your cancellation
Your subscription will remain active until the end of the current billing period. You won't be charged again after cancellation.

View file

@ -0,0 +1,23 @@
---
id: faq-privacy-001
question: How is my data protected?
category: privacy
order: 1
language: en
featured: true
tags:
- privacy
- data
- security
- gdpr
---
We take your privacy seriously:
- **Encryption**: All data is encrypted in transit (TLS) and at rest
- **GDPR Compliant**: We follow EU data protection regulations
- **No Data Selling**: We never sell your personal data to third parties
- **Data Export**: You can export all your data at any time
- **Account Deletion**: You can permanently delete your account and all associated data
For more details, please read our [Privacy Policy](/privacy).

View file

@ -0,0 +1,29 @@
---
id: guide-welcome
title: Erste Schritte
description: Lerne die Grundlagen und starte schnell durch
difficulty: beginner
estimatedTime: 5 Minuten
order: 1
language: de
---
## Konto erstellen
Beginne mit der Erstellung deines kostenlosen Kontos. Du kannst dich mit deiner E-Mail-Adresse registrieren oder Google/Apple für eine schnellere Anmeldung nutzen.
## Dashboard erkunden
Nach dem Einloggen siehst du dein Dashboard. Dies ist deine Zentrale, von der aus du auf alle Funktionen zugreifen und wichtige Informationen auf einen Blick sehen kannst.
## Einstellungen anpassen
Besuche die Einstellungen, um dein Erlebnis zu personalisieren. Du kannst anpassen:
- **Design**: Wähle zwischen hell, dunkel oder Systemmodus
- **Sprache**: Wähle deine bevorzugte Sprache
- **Benachrichtigungen**: Konfiguriere, wie du benachrichtigt werden möchtest
## Hilfe jederzeit verfügbar
Wenn du Hilfe brauchst, klicke auf das Hilfe-Symbol oder besuche den Hilfe-Bereich. Du kannst auch unser Support-Team kontaktieren, wenn du Fragen hast.

View file

@ -0,0 +1,29 @@
---
id: guide-welcome
title: Getting Started
description: Learn the basics and get up and running quickly
difficulty: beginner
estimatedTime: 5 minutes
order: 1
language: en
---
## Create Your Account
Start by creating your free account. You can sign up with your email address or use Google/Apple sign-in for a faster setup.
## Explore the Dashboard
After logging in, you'll see your dashboard. This is your home base where you can access all features and see important information at a glance.
## Customize Your Settings
Visit the Settings page to personalize your experience. You can adjust:
- **Theme**: Choose between light, dark, or system mode
- **Language**: Select your preferred language
- **Notifications**: Configure how you want to be notified
## Get Help Anytime
If you need assistance, click the help icon or visit the Help section. You can also reach out to our support team if you have questions.

View file

@ -0,0 +1,44 @@
{
"name": "@manacore/shared-help-content",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./loader": {
"types": "./src/loader.ts",
"default": "./src/loader.ts"
},
"./parser": {
"types": "./src/parser.ts",
"default": "./src/parser.ts"
},
"./search": {
"types": "./src/search.ts",
"default": "./src/search.ts"
},
"./merger": {
"types": "./src/merger.ts",
"default": "./src/merger.ts"
}
},
"scripts": {
"type-check": "tsc --noEmit"
},
"dependencies": {
"@manacore/shared-help-types": "workspace:*",
"fuse.js": "^7.0.0",
"gray-matter": "^4.0.3",
"marked": "^15.0.4"
},
"devDependencies": {
"@types/node": "^22.10.2",
"typescript": "^5.7.3",
"zod": "^3.24.1"
}
}

View file

@ -0,0 +1,52 @@
/**
* @manacore/shared-help-content
* Central help content and utilities for loading, parsing, and searching
*/
// Parser utilities
export {
parseMarkdown,
parseMarkdownFiles,
stripHtml,
generateExcerpt,
type ParsedContent,
type ParseOptions,
} from './parser.js';
// Content loader
export {
parseFAQContent,
parseFeatureContent,
parseShortcutsContent,
parseGettingStartedContent,
parseChangelogContent,
parseContactContent,
loadHelpContentFromFiles,
type LoaderOptions,
} from './loader.js';
// Content merger
export { mergeContent, createEmptyContent } from './merger.js';
// Search functionality
export { buildSearchIndex, search, createSearcher, flattenContentForSearch } from './search.js';
// Re-export types for convenience
export type {
HelpContent,
FAQItem,
FeatureItem,
ShortcutsItem,
GettingStartedItem,
ChangelogItem,
ContactInfo,
SupportedLanguage,
MergeContentOptions,
} from '@manacore/shared-help-types';
export type {
SearchResult,
SearchOptions,
SearchIndexConfig,
SearchableItem,
} from '@manacore/shared-help-types';

View file

@ -0,0 +1,292 @@
/**
* Content Loader
* Utilities for loading help content from various sources
*/
import type {
HelpContent,
FAQItem,
FeatureItem,
ShortcutsItem,
GettingStartedItem,
ChangelogItem,
ContactInfo,
SupportedLanguage,
} from '@manacore/shared-help-types';
import {
faqFrontmatterSchema,
featureFrontmatterSchema,
shortcutsFrontmatterSchema,
gettingStartedFrontmatterSchema,
changelogFrontmatterSchema,
contactFrontmatterSchema,
} from '@manacore/shared-help-types';
import { parseMarkdown } from './parser.js';
import { createEmptyContent } from './merger.js';
export interface LoaderOptions {
/** Locale to load */
locale: SupportedLanguage;
/** Fallback locale if content not found */
fallbackLocale?: SupportedLanguage;
}
/**
* Parse FAQ content from raw Markdown
*/
export function parseFAQContent(rawContent: string): FAQItem {
const parsed = parseMarkdown(rawContent, faqFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
return {
id: fm.id as string,
language: fm.language as SupportedLanguage,
order: fm.order as number | undefined,
appSpecific: fm.appSpecific as boolean | undefined,
apps: fm.apps as string[] | undefined,
lastUpdated: fm.lastUpdated as Date | undefined,
question: fm.question as string,
category: fm.category as FAQItem['category'],
featured: fm.featured as boolean | undefined,
tags: fm.tags as string[] | undefined,
relatedFaqs: fm.relatedFaqs as string[] | undefined,
answer: parsed.html,
};
}
/**
* Parse Feature content from raw Markdown
*/
export function parseFeatureContent(rawContent: string): FeatureItem {
const parsed = parseMarkdown(rawContent, featureFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
return {
id: fm.id as string,
language: fm.language as SupportedLanguage,
order: fm.order as number | undefined,
appSpecific: fm.appSpecific as boolean | undefined,
apps: fm.apps as string[] | undefined,
lastUpdated: fm.lastUpdated as Date | undefined,
title: fm.title as string,
description: fm.description as string,
icon: fm.icon as string | undefined,
category: fm.category as FeatureItem['category'],
available: fm.available as boolean | undefined,
comingSoon: fm.comingSoon as boolean | undefined,
highlights: fm.highlights as string[] | undefined,
learnMoreUrl: fm.learnMoreUrl as string | undefined,
content: parsed.html,
};
}
/**
* Parse Shortcuts content from raw Markdown
*/
export function parseShortcutsContent(rawContent: string): ShortcutsItem {
const parsed = parseMarkdown(rawContent, shortcutsFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
// Parse markdown table to extract shortcuts
const shortcuts = parseShortcutsTable(parsed.content);
return {
id: fm.id as string,
language: fm.language as SupportedLanguage,
order: fm.order as number | undefined,
appSpecific: fm.appSpecific as boolean | undefined,
apps: fm.apps as string[] | undefined,
lastUpdated: fm.lastUpdated as Date | undefined,
category: fm.category as ShortcutsItem['category'],
title: fm.title as string | undefined,
shortcuts,
};
}
/**
* Parse a markdown table into keyboard shortcuts
*/
function parseShortcutsTable(
content: string
): Array<{ shortcut: string; action: string; description?: string }> {
const shortcuts: Array<{ shortcut: string; action: string; description?: string }> = [];
const lines = content.split('\n');
let inTable = false;
for (const line of lines) {
const trimmed = line.trim();
// Skip header separator
if (trimmed.match(/^\|[-:\s|]+\|$/)) {
inTable = true;
continue;
}
// Parse table row
if (inTable && trimmed.startsWith('|') && trimmed.endsWith('|')) {
const cells = trimmed
.slice(1, -1)
.split('|')
.map((cell) => cell.trim());
if (cells.length >= 2) {
shortcuts.push({
shortcut: cells[0],
action: cells[1],
description: cells[2] || undefined,
});
}
} else if (inTable && !trimmed.startsWith('|')) {
// End of table
break;
}
}
return shortcuts;
}
/**
* Parse Getting Started guide content from raw Markdown
*/
export function parseGettingStartedContent(rawContent: string): GettingStartedItem {
const parsed = parseMarkdown(rawContent, gettingStartedFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
// Extract steps from content (h2 headers)
const steps = parseGuideSteps(parsed.content);
return {
id: fm.id as string,
language: fm.language as SupportedLanguage,
order: fm.order as number | undefined,
appSpecific: fm.appSpecific as boolean | undefined,
apps: fm.apps as string[] | undefined,
lastUpdated: fm.lastUpdated as Date | undefined,
title: fm.title as string,
description: fm.description as string,
difficulty: fm.difficulty as GettingStartedItem['difficulty'],
estimatedTime: fm.estimatedTime as string | undefined,
prerequisites: fm.prerequisites as string[] | undefined,
content: parsed.html,
steps,
};
}
/**
* Parse guide steps from markdown content (h2 headers)
*/
function parseGuideSteps(content: string): Array<{ title: string; content: string }> {
const steps: Array<{ title: string; content: string }> = [];
const sections = content.split(/^## /m);
for (let i = 1; i < sections.length; i++) {
const section = sections[i];
const newlineIndex = section.indexOf('\n');
const title = section.substring(0, newlineIndex).trim();
const stepContent = section.substring(newlineIndex + 1).trim();
steps.push({ title, content: stepContent });
}
return steps;
}
/**
* Parse Changelog content from raw Markdown
*/
export function parseChangelogContent(rawContent: string): ChangelogItem {
const parsed = parseMarkdown(rawContent, changelogFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
return {
id: fm.id as string,
language: fm.language as SupportedLanguage,
order: fm.order as number | undefined,
appSpecific: fm.appSpecific as boolean | undefined,
apps: fm.apps as string[] | undefined,
lastUpdated: fm.lastUpdated as Date | undefined,
version: fm.version as string,
title: fm.title as string,
releaseDate: fm.releaseDate as Date,
type: fm.type as ChangelogItem['type'],
summary: fm.summary as string | undefined,
highlighted: fm.highlighted as boolean | undefined,
changes: fm.changes as ChangelogItem['changes'],
platforms: fm.platforms as string[] | undefined,
content: parsed.html,
};
}
/**
* Parse Contact content from raw Markdown
*/
export function parseContactContent(rawContent: string): ContactInfo {
const parsed = parseMarkdown(rawContent, contactFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
return {
id: fm.id as string,
language: fm.language as SupportedLanguage,
order: fm.order as number | undefined,
appSpecific: fm.appSpecific as boolean | undefined,
apps: fm.apps as string[] | undefined,
lastUpdated: fm.lastUpdated as Date | undefined,
title: fm.title as string,
supportEmail: fm.supportEmail as string | undefined,
supportUrl: fm.supportUrl as string | undefined,
discordUrl: fm.discordUrl as string | undefined,
twitterUrl: fm.twitterUrl as string | undefined,
documentationUrl: fm.documentationUrl as string | undefined,
responseTime: fm.responseTime as string | undefined,
content: parsed.html,
};
}
/**
* Load help content from a map of file paths to content
* This is the main entry point for content loading
*/
export function loadHelpContentFromFiles(
files: Record<string, string>,
options: LoaderOptions
): HelpContent {
const content = createEmptyContent();
const { locale, fallbackLocale = 'en' } = options;
for (const [path, rawContent] of Object.entries(files)) {
try {
// Determine content type from path
if (path.includes('/faq/')) {
const faq = parseFAQContent(rawContent);
if (faq.language === locale || faq.language === fallbackLocale) {
content.faq.push(faq);
}
} else if (path.includes('/features/')) {
const feature = parseFeatureContent(rawContent);
if (feature.language === locale || feature.language === fallbackLocale) {
content.features.push(feature);
}
} else if (path.includes('/shortcuts/')) {
const shortcuts = parseShortcutsContent(rawContent);
if (shortcuts.language === locale || shortcuts.language === fallbackLocale) {
content.shortcuts.push(shortcuts);
}
} else if (path.includes('/getting-started/')) {
const guide = parseGettingStartedContent(rawContent);
if (guide.language === locale || guide.language === fallbackLocale) {
content.gettingStarted.push(guide);
}
} else if (path.includes('/changelog/')) {
const changelog = parseChangelogContent(rawContent);
if (changelog.language === locale || changelog.language === fallbackLocale) {
content.changelog.push(changelog);
}
} else if (path.includes('/contact/')) {
const contact = parseContactContent(rawContent);
if (contact.language === locale || contact.language === fallbackLocale) {
content.contact = contact;
}
}
} catch {
// Skip files that fail to parse
}
}
return content;
}

View file

@ -0,0 +1,119 @@
/**
* Content Merger
* Merges central help content with app-specific content
*/
import type { HelpContent, MergeContentOptions } from '@manacore/shared-help-types';
/**
* Filter content items by locale and app
*/
function filterItems<T extends { language: string; appSpecific?: boolean; apps?: string[] }>(
items: T[],
locale: string,
appId: string
): T[] {
return items.filter((item) => {
// Filter by language
if (item.language !== locale) {
return false;
}
// Include non-app-specific items
if (!item.appSpecific) {
return true;
}
// Include app-specific items for this app
return item.apps?.includes(appId) ?? false;
});
}
/**
* Merge two arrays, optionally replacing items with same ID
*/
function mergeArrays<T extends { id: string }>(
central: T[],
appSpecific: T[],
overrideById: boolean
): T[] {
if (!overrideById) {
return [...central, ...appSpecific];
}
const appIds = new Set(appSpecific.map((item) => item.id));
const filtered = central.filter((item) => !appIds.has(item.id));
return [...filtered, ...appSpecific];
}
/**
* Sort items by order property
*/
function sortByOrder<T extends { order?: number }>(items: T[]): T[] {
return [...items].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
/**
* Merge central help content with app-specific content
*/
export function mergeContent(
central: HelpContent,
appSpecific: Partial<HelpContent>,
options: MergeContentOptions
): HelpContent {
const { appId, locale, overrideById = true } = options;
// Filter central content by locale and app
const filteredCentral: HelpContent = {
faq: filterItems(central.faq, locale, appId),
features: filterItems(central.features, locale, appId),
shortcuts: filterItems(central.shortcuts, locale, appId),
gettingStarted: filterItems(central.gettingStarted, locale, appId),
changelog: filterItems(central.changelog, locale, appId),
contact: central.contact?.language === locale ? central.contact : null,
};
// Filter app-specific content
const filteredApp: Partial<HelpContent> = {
faq: appSpecific.faq ? filterItems(appSpecific.faq, locale, appId) : [],
features: appSpecific.features ? filterItems(appSpecific.features, locale, appId) : [],
shortcuts: appSpecific.shortcuts ? filterItems(appSpecific.shortcuts, locale, appId) : [],
gettingStarted: appSpecific.gettingStarted
? filterItems(appSpecific.gettingStarted, locale, appId)
: [],
changelog: appSpecific.changelog ? filterItems(appSpecific.changelog, locale, appId) : [],
contact: appSpecific.contact?.language === locale ? appSpecific.contact : null,
};
// Merge and sort
return {
faq: sortByOrder(mergeArrays(filteredCentral.faq, filteredApp.faq ?? [], overrideById)),
features: sortByOrder(
mergeArrays(filteredCentral.features, filteredApp.features ?? [], overrideById)
),
shortcuts: sortByOrder(
mergeArrays(filteredCentral.shortcuts, filteredApp.shortcuts ?? [], overrideById)
),
gettingStarted: sortByOrder(
mergeArrays(filteredCentral.gettingStarted, filteredApp.gettingStarted ?? [], overrideById)
),
changelog: sortByOrder(
mergeArrays(filteredCentral.changelog, filteredApp.changelog ?? [], overrideById)
),
contact: filteredApp.contact ?? filteredCentral.contact,
};
}
/**
* Create an empty HelpContent object
*/
export function createEmptyContent(): HelpContent {
return {
faq: [],
features: [],
shortcuts: [],
gettingStarted: [],
changelog: [],
contact: null,
};
}

View file

@ -0,0 +1,86 @@
/**
* Markdown + Frontmatter Parser
* Parses Markdown files with YAML frontmatter
*/
import matter from 'gray-matter';
import { marked } from 'marked';
import type { ZodSchema } from 'zod';
export interface ParsedContent<T> {
frontmatter: T;
content: string;
html: string;
}
export interface ParseOptions {
/** Convert Markdown to HTML */
renderHtml?: boolean;
}
/**
* Parse a Markdown file with frontmatter
*/
export function parseMarkdown<T>(
rawContent: string,
schema?: ZodSchema<T>,
options: ParseOptions = { renderHtml: true }
): ParsedContent<T> {
const { data, content } = matter(rawContent);
// Validate frontmatter if schema provided
let frontmatter: T;
if (schema) {
const result = schema.safeParse(data);
if (!result.success) {
throw new Error(`Invalid frontmatter: ${result.error.message}`);
}
frontmatter = result.data;
} else {
frontmatter = data as T;
}
// Render HTML if requested
const html = options.renderHtml ? (marked.parse(content) as string) : '';
return {
frontmatter,
content: content.trim(),
html,
};
}
/**
* Parse multiple Markdown files
*/
export function parseMarkdownFiles<T>(
files: { filename: string; content: string }[],
schema?: ZodSchema<T>,
options?: ParseOptions
): Array<ParsedContent<T> & { filename: string }> {
return files.map(({ filename, content }) => ({
filename,
...parseMarkdown<T>(content, schema, options),
}));
}
/**
* Extract text content from HTML (for search indexing)
*/
export function stripHtml(html: string): string {
return html
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Generate excerpt from content
*/
export function generateExcerpt(content: string, maxLength = 150): string {
const text = stripHtml(content);
if (text.length <= maxLength) {
return text;
}
return text.substring(0, maxLength).trim() + '...';
}

View file

@ -0,0 +1,209 @@
/**
* Search Functionality using Fuse.js
* Provides full-text search across help content
*/
import Fuse, { type IFuseOptions } from 'fuse.js';
import type {
HelpContent,
FAQItem,
FeatureItem,
GettingStartedItem,
ChangelogItem,
} from '@manacore/shared-help-types';
import type {
SearchableItem,
SearchResult,
SearchOptions,
SearchIndexConfig,
} from '@manacore/shared-help-types';
import { generateExcerpt, stripHtml } from './parser.js';
const DEFAULT_CONFIG: SearchIndexConfig = {
titleWeight: 2,
contentWeight: 1,
tagsWeight: 1.5,
threshold: 0.3,
minMatchCharLength: 2,
};
/**
* Convert HelpContent to searchable items
*/
export function flattenContentForSearch(content: HelpContent): SearchableItem[] {
const items: SearchableItem[] = [];
// FAQs
for (const faq of content.faq) {
items.push({
id: faq.id,
type: 'faq',
title: faq.question,
question: faq.question,
content: stripHtml(faq.answer),
tags: faq.tags,
});
}
// Features
for (const feature of content.features) {
items.push({
id: feature.id,
type: 'feature',
title: feature.title,
description: feature.description,
content: stripHtml(feature.content),
tags: feature.highlights,
});
}
// Getting Started Guides
for (const guide of content.gettingStarted) {
items.push({
id: guide.id,
type: 'guide',
title: guide.title,
description: guide.description,
content: stripHtml(guide.content),
});
}
// Changelog
for (const log of content.changelog) {
items.push({
id: log.id,
type: 'changelog',
title: `${log.version} - ${log.title}`,
content: stripHtml(log.content),
description: log.summary,
});
}
return items;
}
/**
* Build a Fuse.js search index from help content
*/
export function buildSearchIndex(
content: HelpContent,
config: SearchIndexConfig = DEFAULT_CONFIG
): Fuse<SearchableItem> {
const items = flattenContentForSearch(content);
const fuseOptions: IFuseOptions<SearchableItem> = {
keys: [
{ name: 'title', weight: config.titleWeight ?? 2 },
{ name: 'question', weight: config.titleWeight ?? 2 },
{ name: 'content', weight: config.contentWeight ?? 1 },
{ name: 'description', weight: config.contentWeight ?? 1 },
{ name: 'tags', weight: config.tagsWeight ?? 1.5 },
],
threshold: config.threshold ?? 0.3,
includeScore: true,
minMatchCharLength: config.minMatchCharLength ?? 2,
ignoreLocation: true,
};
return new Fuse(items, fuseOptions);
}
/**
* Find the original item from content
*/
function findOriginalItem(
id: string,
type: string,
content: HelpContent
): FAQItem | FeatureItem | GettingStartedItem | ChangelogItem | null {
switch (type) {
case 'faq':
return content.faq.find((item) => item.id === id) ?? null;
case 'feature':
return content.features.find((item) => item.id === id) ?? null;
case 'guide':
return content.gettingStarted.find((item) => item.id === id) ?? null;
case 'changelog':
return content.changelog.find((item) => item.id === id) ?? null;
default:
return null;
}
}
/**
* Highlight matching text in content
*/
function highlightMatch(text: string, query: string): string {
if (!query.trim()) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<mark>$1</mark>');
}
/**
* Search help content
*/
export function search(
index: Fuse<SearchableItem>,
query: string,
content: HelpContent,
options: SearchOptions = {}
): SearchResult[] {
const { limit = 10, threshold, types, appId } = options;
if (!query.trim()) {
return [];
}
let results = index.search(query, { limit: limit * 2 });
// Filter by type if specified
if (types && types.length > 0) {
results = results.filter((r) => types.includes(r.item.type));
}
// Filter by app if specified
if (appId) {
results = results.filter((r) => {
const originalItem = findOriginalItem(r.item.id, r.item.type, content);
if (!originalItem) return true;
if (!originalItem.appSpecific) return true;
return originalItem.apps?.includes(appId);
});
}
// Apply threshold filter if specified
if (threshold !== undefined) {
results = results.filter((r) => (r.score ?? 1) <= threshold);
}
// Limit results
results = results.slice(0, limit);
const mappedResults: SearchResult[] = [];
for (const result of results) {
const originalItem = findOriginalItem(result.item.id, result.item.type, content);
if (!originalItem) continue;
mappedResults.push({
id: result.item.id,
type: result.item.type,
title: result.item.title,
excerpt: generateExcerpt(result.item.content, 150),
score: result.score ?? 1,
highlight: highlightMatch(result.item.title, query),
item: originalItem,
});
}
return mappedResults;
}
/**
* Create a search function with pre-built index
*/
export function createSearcher(content: HelpContent, config?: SearchIndexConfig) {
const index = buildSearchIndex(content, config);
return (query: string, options?: SearchOptions) => search(index, query, content, options);
}

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,24 @@
{
"name": "@manacore/shared-help-mobile",
"version": "1.0.0",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"type-check": "echo 'Skipping type-check: @manacore/shared-help-mobile requires React Native environment'"
},
"dependencies": {
"@manacore/shared-help-types": "workspace:*",
"@manacore/shared-help-content": "workspace:*"
},
"devDependencies": {
"@types/react": "~18.3.12",
"typescript": "~5.8.3"
},
"peerDependencies": {
"expo": ">=52.0.0",
"nativewind": "^4.0.0",
"react": "18.3.1",
"react-native": ">=0.76.0"
}
}

View file

@ -0,0 +1,46 @@
/**
* Category Tabs component for mobile Help screen
*/
import React from 'react';
import { Text, TouchableOpacity, ScrollView } from 'react-native';
import type { HelpSection } from '../types';
interface CategoryTabsProps {
sections: Array<{ id: HelpSection; label: string; show: boolean }>;
activeSection: HelpSection;
onSectionChange: (section: HelpSection) => void;
}
export function CategoryTabs({ sections, activeSection, onSectionChange }: CategoryTabsProps) {
const visibleSections = sections.filter((s) => s.show);
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="mb-4"
contentContainerStyle={{ paddingHorizontal: 4 }}
>
{visibleSections.map((section) => (
<TouchableOpacity
key={section.id}
onPress={() => onSectionChange(section.id)}
className={`px-4 py-2 mr-2 rounded-full ${
activeSection === section.id
? 'bg-blue-500 dark:bg-blue-600'
: 'bg-gray-100 dark:bg-gray-800'
}`}
>
<Text
className={`text-sm font-medium ${
activeSection === section.id ? 'text-white' : 'text-gray-600 dark:text-gray-300'
}`}
>
{section.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
);
}

View file

@ -0,0 +1,71 @@
/**
* Contact Card component for mobile
*/
import React from 'react';
import { View, Text, TouchableOpacity, Linking } from 'react-native';
import type { ContactInfo } from '@manacore/shared-help-types';
import type { HelpTranslations } from '../types';
interface ContactCardProps {
contact: ContactInfo | null;
translations: Pick<HelpTranslations, 'contact'>;
}
export function ContactCard({ contact, translations }: ContactCardProps) {
if (!contact) {
return (
<View className="py-8 items-center">
<Text className="text-gray-500 dark:text-gray-400">{translations.contact.noInfo}</Text>
</View>
);
}
function handleEmailPress() {
if (contact.supportEmail) {
Linking.openURL(`mailto:${contact.supportEmail}`);
}
}
// Strip HTML tags for mobile display
const plainContent = contact.content.replace(/<[^>]*>/g, '').trim();
return (
<View>
{plainContent && (
<Text className="text-gray-600 dark:text-gray-300 text-sm leading-relaxed mb-4">
{plainContent}
</Text>
)}
{contact.supportEmail && (
<TouchableOpacity
onPress={handleEmailPress}
className="flex-row items-center bg-white dark:bg-gray-800 rounded-xl p-4 mb-3 border border-gray-200 dark:border-gray-700"
>
<View className="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30 items-center justify-center mr-3">
<Text></Text>
</View>
<View className="flex-1">
<Text className="font-medium text-gray-900 dark:text-gray-100">
{translations.contact.email}
</Text>
<Text className="text-sm text-gray-600 dark:text-gray-400">{contact.supportEmail}</Text>
</View>
</TouchableOpacity>
)}
{contact.responseTime && (
<View className="flex-row items-center bg-white dark:bg-gray-800 rounded-xl p-4 border border-gray-200 dark:border-gray-700">
<View className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30 items-center justify-center mr-3">
<Text></Text>
</View>
<View className="flex-1">
<Text className="font-medium text-gray-900 dark:text-gray-100">Response Time</Text>
<Text className="text-sm text-gray-600 dark:text-gray-400">{contact.responseTime}</Text>
</View>
</View>
)}
</View>
);
}

View file

@ -0,0 +1,57 @@
/**
* Expandable FAQ Item component for mobile
*/
import React from 'react';
import { View, Text, TouchableOpacity, LayoutAnimation, Platform, UIManager } from 'react-native';
import type { FAQItem as FAQItemType } from '@manacore/shared-help-types';
// Enable LayoutAnimation on Android
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
interface FAQItemProps {
item: FAQItemType;
expanded?: boolean;
onToggle?: () => void;
}
export function FAQItem({ item, expanded = false, onToggle }: FAQItemProps) {
function handlePress() {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
onToggle?.();
}
// Strip HTML tags for mobile display
const plainAnswer = item.answer.replace(/<[^>]*>/g, '').trim();
return (
<View className="border-b border-gray-200 dark:border-gray-700">
<TouchableOpacity
onPress={handlePress}
className="py-4 flex-row items-center justify-between"
accessibilityRole="button"
accessibilityState={{ expanded }}
>
<Text className="flex-1 pr-4 font-medium text-gray-900 dark:text-gray-100 text-base">
{item.question}
</Text>
<Text
className="text-gray-500 dark:text-gray-400"
style={{ transform: [{ rotate: expanded ? '180deg' : '0deg' }] }}
>
</Text>
</TouchableOpacity>
{expanded && (
<View className="pb-4">
<Text className="text-gray-600 dark:text-gray-300 text-sm leading-relaxed">
{plainAnswer}
</Text>
</View>
)}
</View>
);
}

View file

@ -0,0 +1,39 @@
/**
* FAQ List component for mobile
*/
import React, { useState } from 'react';
import { View, Text } from 'react-native';
import { FAQItem } from './FAQItem';
import type { FAQListProps } from '../types';
export function FAQList({ items, translations }: FAQListProps) {
const [expandedId, setExpandedId] = useState<string | null>(
items.length > 0 ? items[0].id : null
);
function toggleItem(id: string) {
setExpandedId(expandedId === id ? null : id);
}
if (items.length === 0) {
return (
<View className="py-8 items-center">
<Text className="text-gray-500 dark:text-gray-400">{translations.faq.noItems}</Text>
</View>
);
}
return (
<View>
{items.map((item) => (
<FAQItem
key={item.id}
item={item}
expanded={expandedId === item.id}
onToggle={() => toggleItem(item.id)}
/>
))}
</View>
);
}

View file

@ -0,0 +1,49 @@
/**
* Feature Card component for mobile
*/
import React from 'react';
import { View, Text } from 'react-native';
import type { FeatureItem } from '@manacore/shared-help-types';
interface FeatureCardProps {
item: FeatureItem;
comingSoonLabel?: string;
}
export function FeatureCard({ item, comingSoonLabel = 'Coming soon' }: FeatureCardProps) {
return (
<View className="bg-white dark:bg-gray-800 rounded-xl p-4 mb-3 border border-gray-200 dark:border-gray-700">
<View className="flex-row items-center mb-2">
{item.icon && <Text className="text-2xl mr-3">{item.icon}</Text>}
<View className="flex-1">
<View className="flex-row items-center">
<Text className="font-semibold text-gray-900 dark:text-gray-100 text-base">
{item.title}
</Text>
{item.comingSoon && (
<View className="ml-2 bg-amber-100 dark:bg-amber-900/30 px-2 py-0.5 rounded-full">
<Text className="text-xs text-amber-700 dark:text-amber-400">
{comingSoonLabel}
</Text>
</View>
)}
</View>
</View>
</View>
<Text className="text-gray-600 dark:text-gray-400 text-sm mb-2">{item.description}</Text>
{item.highlights && item.highlights.length > 0 && (
<View className="mt-2">
{item.highlights.map((highlight, index) => (
<View key={index} className="flex-row items-start mb-1">
<Text className="text-green-500 mr-2"></Text>
<Text className="text-gray-600 dark:text-gray-400 text-sm flex-1">{highlight}</Text>
</View>
))}
</View>
)}
</View>
);
}

View file

@ -0,0 +1,26 @@
/**
* Features List component for mobile
*/
import React from 'react';
import { View, Text } from 'react-native';
import { FeatureCard } from './FeatureCard';
import type { FeaturesListProps } from '../types';
export function FeaturesList({ items, translations }: FeaturesListProps) {
if (items.length === 0) {
return (
<View className="py-8 items-center">
<Text className="text-gray-500 dark:text-gray-400">{translations.features.noItems}</Text>
</View>
);
}
return (
<View>
{items.map((item) => (
<FeatureCard key={item.id} item={item} comingSoonLabel={translations.features.comingSoon} />
))}
</View>
);
}

View file

@ -0,0 +1,42 @@
/**
* Search Bar component for mobile Help screen
*/
import React, { useState } from 'react';
import { View, TextInput, TouchableOpacity, Text } from 'react-native';
import type { HelpSearchBarProps } from '../types';
export function HelpSearchBar({ placeholder, onSearch, onClear }: HelpSearchBarProps) {
const [query, setQuery] = useState('');
function handleChangeText(text: string) {
setQuery(text);
onSearch(text);
}
function handleClear() {
setQuery('');
onClear();
}
return (
<View className="flex-row items-center bg-gray-100 dark:bg-gray-800 rounded-xl px-4 py-3 mb-4">
<Text className="text-gray-400 mr-2">🔍</Text>
<TextInput
className="flex-1 text-gray-900 dark:text-gray-100 text-base"
placeholder={placeholder}
placeholderTextColor="#9CA3AF"
value={query}
onChangeText={handleChangeText}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
{query.length > 0 && (
<TouchableOpacity onPress={handleClear} className="ml-2">
<Text className="text-gray-400"></Text>
</TouchableOpacity>
)}
</View>
);
}

View file

@ -0,0 +1,51 @@
/**
* Hook for loading and managing help content in mobile apps
*/
import { useState, useMemo } from 'react';
import type { HelpContent } from '@manacore/shared-help-types';
import { mergeContent, createEmptyContent, createSearcher } from '@manacore/shared-help-content';
import type { UseHelpContentOptions, UseHelpContentResult } from '../types';
export function useHelpContent(options: UseHelpContentOptions): UseHelpContentResult {
const { appId, locale, centralContent, appContent } = options;
const [loading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Merge central and app-specific content
const content = useMemo(() => {
try {
const base = centralContent ?? createEmptyContent();
if (appContent) {
return mergeContent(base, appContent, {
appId,
locale,
});
}
return base;
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to merge content'));
return createEmptyContent();
}
}, [centralContent, appContent, appId, locale]);
return {
content,
loading,
error,
};
}
/**
* Hook for searching help content
*/
export function useHelpSearch(content: HelpContent) {
const searcher = useMemo(() => createSearcher(content), [content]);
return {
search: (query: string, limit?: number) => {
if (!query.trim()) return [];
return searcher(query, { limit: limit ?? 10 });
},
};
}

View file

@ -0,0 +1,32 @@
/**
* @manacore/shared-help-mobile
* React Native components for the Help system
*/
// Main screen
export { HelpScreen } from './screens/HelpScreen';
// Components
export { FAQList } from './components/FAQList';
export { FAQItem } from './components/FAQItem';
export { FeaturesList } from './components/FeaturesList';
export { FeatureCard } from './components/FeatureCard';
export { HelpSearchBar } from './components/HelpSearchBar';
export { CategoryTabs } from './components/CategoryTabs';
export { ContactCard } from './components/ContactCard';
// Hooks
export { useHelpContent, useHelpSearch } from './hooks/useHelpContent';
// Types
export type {
HelpScreenProps,
HelpTranslations,
HelpSection,
UseHelpContentOptions,
UseHelpContentResult,
FAQListProps,
FeaturesListProps,
HelpSearchBarProps,
HelpSearchResultsProps,
} from './types';

View file

@ -0,0 +1,206 @@
/**
* Main Help Screen component for mobile apps
*/
import React, { useState, useMemo } from 'react';
import { View, Text, ScrollView, SafeAreaView } from 'react-native';
import type { HelpScreenProps, HelpSection } from '../types';
import { HelpSearchBar } from '../components/HelpSearchBar';
import { CategoryTabs } from '../components/CategoryTabs';
import { FAQList } from '../components/FAQList';
import { FeaturesList } from '../components/FeaturesList';
import { ContactCard } from '../components/ContactCard';
import { useHelpSearch } from '../hooks/useHelpContent';
import type { SearchResult } from '@manacore/shared-help-types';
export function HelpScreen({
content,
appName,
appId: _appId,
translations,
onBack: _onBack,
defaultSection = 'faq',
}: HelpScreenProps) {
const [activeSection, setActiveSection] = useState<HelpSection>(defaultSection);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const { search } = useHelpSearch(content);
// Define available sections
const sections = useMemo(
() => [
{ id: 'faq' as HelpSection, label: translations.sections.faq, show: content.faq.length > 0 },
{
id: 'features' as HelpSection,
label: translations.sections.features,
show: content.features.length > 0,
},
{
id: 'shortcuts' as HelpSection,
label: translations.sections.shortcuts,
show: content.shortcuts.length > 0,
},
{
id: 'getting-started' as HelpSection,
label: translations.sections.gettingStarted,
show: content.gettingStarted.length > 0,
},
{
id: 'changelog' as HelpSection,
label: translations.sections.changelog,
show: content.changelog.length > 0,
},
{
id: 'contact' as HelpSection,
label: translations.sections.contact,
show: !!content.contact,
},
],
[content, translations]
);
function handleSearch(query: string) {
setSearchQuery(query);
if (query.trim().length >= 2) {
const results = search(query, 10);
setSearchResults(results);
} else {
setSearchResults([]);
}
}
function handleClearSearch() {
setSearchQuery('');
setSearchResults([]);
}
function handleResultPress(result: SearchResult) {
// Navigate to appropriate section
switch (result.type) {
case 'faq':
setActiveSection('faq');
break;
case 'feature':
setActiveSection('features');
break;
case 'guide':
setActiveSection('getting-started');
break;
case 'changelog':
setActiveSection('changelog');
break;
}
handleClearSearch();
}
// Use handleResultPress in search results (currently just viewing results)
void handleResultPress;
function renderContent() {
// Show search results if searching
if (searchQuery.length >= 2) {
if (searchResults.length === 0) {
return (
<View className="py-8 items-center">
<Text className="text-gray-500 dark:text-gray-400">
{translations.search.noResults.replace('{query}', searchQuery)}
</Text>
</View>
);
}
return (
<View>
<Text className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{translations.search.resultsCount.replace('{count}', String(searchResults.length))}
</Text>
{searchResults.map((result) => (
<View
key={result.id}
className="bg-white dark:bg-gray-800 rounded-xl p-4 mb-2 border border-gray-200 dark:border-gray-700"
>
<Text className="font-medium text-gray-900 dark:text-gray-100">{result.title}</Text>
<Text className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{result.excerpt}
</Text>
</View>
))}
</View>
);
}
// Show section content
switch (activeSection) {
case 'faq':
return <FAQList items={content.faq} translations={translations} />;
case 'features':
return <FeaturesList items={content.features} translations={translations} />;
case 'contact':
return <ContactCard contact={content.contact} translations={translations} />;
case 'shortcuts':
return (
<View className="py-8 items-center">
<Text className="text-gray-500 dark:text-gray-400">
{translations.shortcuts.noItems}
</Text>
</View>
);
case 'getting-started':
return (
<View className="py-8 items-center">
<Text className="text-gray-500 dark:text-gray-400">
{translations.gettingStarted.noItems}
</Text>
</View>
);
case 'changelog':
return (
<View className="py-8 items-center">
<Text className="text-gray-500 dark:text-gray-400">
{translations.changelog.noItems}
</Text>
</View>
);
default:
return null;
}
}
return (
<SafeAreaView className="flex-1 bg-gray-50 dark:bg-gray-900">
<ScrollView className="flex-1 px-4 pt-4">
{/* Header */}
<View className="mb-6">
<Text className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{translations.title}
</Text>
{translations.subtitle && (
<Text className="text-gray-600 dark:text-gray-400 mt-1">
{translations.subtitle} - {appName}
</Text>
)}
</View>
{/* Search */}
<HelpSearchBar
placeholder={translations.searchPlaceholder}
onSearch={handleSearch}
onClear={handleClearSearch}
/>
{/* Category Tabs */}
{searchQuery.length < 2 && (
<CategoryTabs
sections={sections}
activeSection={activeSection}
onSectionChange={setActiveSection}
/>
)}
{/* Content */}
<View className="pb-8">{renderContent()}</View>
</ScrollView>
</SafeAreaView>
);
}

View file

@ -0,0 +1,95 @@
/**
* Mobile-specific types for Help components
*/
import type { HelpContent, SearchResult, SupportedLanguage } from '@manacore/shared-help-types';
export type HelpSection =
| 'faq'
| 'features'
| 'shortcuts'
| 'getting-started'
| 'changelog'
| 'contact';
export interface HelpScreenProps {
content: HelpContent;
appName: string;
appId: string;
translations: HelpTranslations;
onBack?: () => void;
defaultSection?: HelpSection;
}
export interface HelpTranslations {
title: string;
subtitle?: string;
searchPlaceholder: string;
sections: {
faq: string;
features: string;
shortcuts: string;
gettingStarted: string;
changelog: string;
contact: string;
};
search: {
noResults: string;
resultsCount: string;
};
faq: {
noItems: string;
};
features: {
noItems: string;
comingSoon: string;
};
shortcuts: {
noItems: string;
};
gettingStarted: {
noItems: string;
};
changelog: {
noItems: string;
};
contact: {
noInfo: string;
email: string;
};
}
export interface UseHelpContentOptions {
appId: string;
locale: SupportedLanguage;
centralContent?: HelpContent;
appContent?: Partial<HelpContent>;
}
export interface UseHelpContentResult {
content: HelpContent;
loading: boolean;
error: Error | null;
}
export interface FAQListProps {
items: HelpContent['faq'];
translations: Pick<HelpTranslations, 'faq'>;
}
export interface FeaturesListProps {
items: HelpContent['features'];
translations: Pick<HelpTranslations, 'features'>;
}
export interface HelpSearchBarProps {
placeholder?: string;
onSearch: (query: string) => void;
onClear: () => void;
}
export interface HelpSearchResultsProps {
results: SearchResult[];
onResultPress: (result: SearchResult) => void;
translations: Pick<HelpTranslations, 'search'>;
}

View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,35 @@
{
"name": "@manacore/shared-help-types",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./content": {
"types": "./src/content.ts",
"default": "./src/content.ts"
},
"./schemas": {
"types": "./src/schemas.ts",
"default": "./src/schemas.ts"
},
"./search": {
"types": "./src/search.ts",
"default": "./src/search.ts"
}
},
"scripts": {
"type-check": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.24.1"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1,152 @@
/**
* Help Content Type Definitions
* Defines the structure for all help content types
*/
// ============================================================================
// Base Types
// ============================================================================
export type SupportedLanguage = 'en' | 'de' | 'fr' | 'it' | 'es';
export type FAQCategory = 'general' | 'account' | 'billing' | 'features' | 'technical' | 'privacy';
export type FeatureCategory = 'getting-started' | 'core' | 'advanced' | 'integration';
export type GuideDifficulty = 'beginner' | 'intermediate' | 'advanced';
export type ChangelogType = 'major' | 'minor' | 'patch' | 'beta';
export type ShortcutCategory = 'navigation' | 'editing' | 'general' | 'app-specific';
// ============================================================================
// Content Item Types
// ============================================================================
export interface BaseContentItem {
id: string;
language: SupportedLanguage;
order?: number;
appSpecific?: boolean;
apps?: string[];
lastUpdated?: Date;
}
export interface FAQItem extends BaseContentItem {
question: string;
answer: string;
category: FAQCategory;
featured?: boolean;
tags?: string[];
relatedFaqs?: string[];
}
export interface FeatureItem extends BaseContentItem {
title: string;
description: string;
content: string;
icon?: string;
category: FeatureCategory;
available?: boolean;
comingSoon?: boolean;
highlights?: string[];
learnMoreUrl?: string;
}
export interface KeyboardShortcut {
shortcut: string;
action: string;
description?: string;
}
export interface ShortcutsItem extends BaseContentItem {
category: ShortcutCategory;
title?: string;
shortcuts: KeyboardShortcut[];
}
export interface GuideStep {
title: string;
content: string;
duration?: string;
}
export interface GettingStartedItem extends BaseContentItem {
title: string;
description: string;
content: string;
difficulty: GuideDifficulty;
estimatedTime?: string;
prerequisites?: string[];
steps?: GuideStep[];
}
export interface ChangelogChange {
title: string;
description?: string;
category?: string;
}
export interface ChangelogItem extends BaseContentItem {
version: string;
title: string;
releaseDate: Date;
type: ChangelogType;
summary?: string;
content: string;
highlighted?: boolean;
changes?: {
features?: ChangelogChange[];
improvements?: ChangelogChange[];
bugfixes?: ChangelogChange[];
};
platforms?: string[];
}
export interface ContactInfo extends BaseContentItem {
title: string;
content: string;
supportEmail?: string;
supportUrl?: string;
discordUrl?: string;
twitterUrl?: string;
documentationUrl?: string;
responseTime?: string;
}
// ============================================================================
// Aggregated Content Types
// ============================================================================
export interface HelpContent {
faq: FAQItem[];
features: FeatureItem[];
shortcuts: ShortcutsItem[];
gettingStarted: GettingStartedItem[];
changelog: ChangelogItem[];
contact: ContactInfo | null;
}
export interface AppHelpContent {
appId: string;
appName: string;
content: HelpContent;
}
// ============================================================================
// Configuration Types
// ============================================================================
export interface HelpContentConfig {
appId: string;
locale: SupportedLanguage;
fallbackLocale?: SupportedLanguage;
includeAppSpecific?: boolean;
}
export interface MergeContentOptions {
appId: string;
locale: SupportedLanguage;
/** If true, app-specific content replaces central content with same ID */
overrideById?: boolean;
}

View file

@ -0,0 +1,13 @@
/**
* @manacore/shared-help-types
* Shared TypeScript types and Zod schemas for Help content
*/
// Content types
export * from './content.js';
// Zod schemas for validation
export * from './schemas.js';
// Search types
export * from './search.js';

View file

@ -0,0 +1,130 @@
/**
* Zod Schemas for Help Content Validation
* Used to validate Markdown frontmatter
*/
import { z } from 'zod';
// ============================================================================
// Base Schemas
// ============================================================================
export const supportedLanguageSchema = z.enum(['en', 'de', 'fr', 'it', 'es']);
export const faqCategorySchema = z.enum([
'general',
'account',
'billing',
'features',
'technical',
'privacy',
]);
export const featureCategorySchema = z.enum(['getting-started', 'core', 'advanced', 'integration']);
export const guideDifficultySchema = z.enum(['beginner', 'intermediate', 'advanced']);
export const changelogTypeSchema = z.enum(['major', 'minor', 'patch', 'beta']);
export const shortcutCategorySchema = z.enum(['navigation', 'editing', 'general', 'app-specific']);
// ============================================================================
// Content Item Schemas (for Frontmatter)
// ============================================================================
const baseContentSchema = z.object({
id: z.string().min(1),
language: supportedLanguageSchema,
order: z.number().optional().default(0),
appSpecific: z.boolean().optional().default(false),
apps: z.array(z.string()).optional().default([]),
lastUpdated: z.coerce.date().optional(),
});
export const faqFrontmatterSchema = baseContentSchema.extend({
question: z.string().min(1),
category: faqCategorySchema,
featured: z.boolean().optional().default(false),
tags: z.array(z.string()).optional().default([]),
relatedFaqs: z.array(z.string()).optional().default([]),
});
export const featureFrontmatterSchema = baseContentSchema.extend({
title: z.string().min(1),
description: z.string().min(1),
icon: z.string().optional(),
category: featureCategorySchema,
available: z.boolean().optional().default(true),
comingSoon: z.boolean().optional().default(false),
highlights: z.array(z.string()).optional().default([]),
learnMoreUrl: z.string().url().optional(),
});
export const shortcutSchema = z.object({
shortcut: z.string().min(1),
action: z.string().min(1),
description: z.string().optional(),
});
export const shortcutsFrontmatterSchema = baseContentSchema.extend({
category: shortcutCategorySchema,
title: z.string().optional(),
});
export const guideStepSchema = z.object({
title: z.string().min(1),
content: z.string().min(1),
duration: z.string().optional(),
});
export const gettingStartedFrontmatterSchema = baseContentSchema.extend({
title: z.string().min(1),
description: z.string().min(1),
difficulty: guideDifficultySchema,
estimatedTime: z.string().optional(),
prerequisites: z.array(z.string()).optional().default([]),
});
export const changelogChangeSchema = z.object({
title: z.string().min(1),
description: z.string().optional(),
category: z.string().optional(),
});
export const changelogFrontmatterSchema = baseContentSchema.extend({
version: z.string().min(1),
title: z.string().min(1),
releaseDate: z.coerce.date(),
type: changelogTypeSchema,
summary: z.string().optional(),
highlighted: z.boolean().optional().default(false),
changes: z
.object({
features: z.array(changelogChangeSchema).optional(),
improvements: z.array(changelogChangeSchema).optional(),
bugfixes: z.array(changelogChangeSchema).optional(),
})
.optional(),
platforms: z.array(z.string()).optional().default(['all']),
});
export const contactFrontmatterSchema = baseContentSchema.extend({
title: z.string().min(1),
supportEmail: z.string().email().optional(),
supportUrl: z.string().url().optional(),
discordUrl: z.string().url().optional(),
twitterUrl: z.string().url().optional(),
documentationUrl: z.string().url().optional(),
responseTime: z.string().optional(),
});
// ============================================================================
// Type Exports from Schemas
// ============================================================================
export type FAQFrontmatter = z.infer<typeof faqFrontmatterSchema>;
export type FeatureFrontmatter = z.infer<typeof featureFrontmatterSchema>;
export type ShortcutsFrontmatter = z.infer<typeof shortcutsFrontmatterSchema>;
export type GettingStartedFrontmatter = z.infer<typeof gettingStartedFrontmatterSchema>;
export type ChangelogFrontmatter = z.infer<typeof changelogFrontmatterSchema>;
export type ContactFrontmatter = z.infer<typeof contactFrontmatterSchema>;

View file

@ -0,0 +1,71 @@
/**
* Search-related Type Definitions
*/
import type { FAQItem, FeatureItem, GettingStartedItem, ChangelogItem } from './content.js';
// ============================================================================
// Searchable Item Types
// ============================================================================
export type SearchableContentType = 'faq' | 'feature' | 'guide' | 'changelog';
export interface SearchableItem {
id: string;
type: SearchableContentType;
title: string;
content: string;
tags?: string[];
question?: string;
description?: string;
}
// ============================================================================
// Search Result Types
// ============================================================================
export interface SearchResult {
id: string;
type: SearchableContentType;
title: string;
excerpt: string;
score: number;
highlight?: string;
/** Original item reference */
item: FAQItem | FeatureItem | GettingStartedItem | ChangelogItem;
}
export interface SearchOptions {
/** Maximum number of results to return */
limit?: number;
/** Minimum score threshold (0-1, lower is more strict) */
threshold?: number;
/** Filter by content type */
types?: SearchableContentType[];
/** Filter by app ID (for app-specific content) */
appId?: string;
}
export interface SearchIndexConfig {
/** Weight for title/question field */
titleWeight?: number;
/** Weight for content field */
contentWeight?: number;
/** Weight for tags field */
tagsWeight?: number;
/** Fuzzy match threshold (0-1, lower is more strict) */
threshold?: number;
/** Minimum characters to start searching */
minMatchCharLength?: number;
}
// ============================================================================
// Search State Types (for UI)
// ============================================================================
export interface SearchState {
query: string;
results: SearchResult[];
isSearching: boolean;
hasSearched: boolean;
}

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,65 @@
{
"name": "@manacore/shared-help-ui",
"version": "1.0.0",
"private": true,
"type": "module",
"svelte": "./src/index.ts",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"svelte": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./HelpPage.svelte": {
"svelte": "./src/pages/HelpPage.svelte",
"default": "./src/pages/HelpPage.svelte"
},
"./FAQSection.svelte": {
"svelte": "./src/components/FAQSection.svelte",
"default": "./src/components/FAQSection.svelte"
},
"./FeaturesOverview.svelte": {
"svelte": "./src/components/FeaturesOverview.svelte",
"default": "./src/components/FeaturesOverview.svelte"
},
"./KeyboardShortcuts.svelte": {
"svelte": "./src/components/KeyboardShortcuts.svelte",
"default": "./src/components/KeyboardShortcuts.svelte"
},
"./GettingStartedGuide.svelte": {
"svelte": "./src/components/GettingStartedGuide.svelte",
"default": "./src/components/GettingStartedGuide.svelte"
},
"./ChangelogSection.svelte": {
"svelte": "./src/components/ChangelogSection.svelte",
"default": "./src/components/ChangelogSection.svelte"
},
"./ContactSection.svelte": {
"svelte": "./src/components/ContactSection.svelte",
"default": "./src/components/ContactSection.svelte"
},
"./HelpSearch.svelte": {
"svelte": "./src/components/HelpSearch.svelte",
"default": "./src/components/HelpSearch.svelte"
}
},
"scripts": {
"check": "svelte-check --tsconfig ./tsconfig.json",
"lint": "eslint ."
},
"dependencies": {
"@manacore/shared-help-types": "workspace:*",
"@manacore/shared-help-content": "workspace:*",
"@manacore/shared-icons": "workspace:*"
},
"devDependencies": {
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.7.3"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
}

View file

@ -0,0 +1,152 @@
<script lang="ts">
import type { ChangelogItem } from '@manacore/shared-help-types';
interface Props {
item: ChangelogItem;
typeLabels: Record<string, string>;
}
let { item, typeLabels }: Props = $props();
let expanded = $state(false);
function getTypeColor(type: string): string {
switch (type) {
case 'major':
return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400';
case 'minor':
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
case 'patch':
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
case 'beta':
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
default:
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400';
}
}
function formatDate(date: Date): string {
return new Date(date).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
</script>
<div class="border-b border-gray-200 py-4 dark:border-gray-700">
<button
type="button"
class="flex w-full items-start justify-between text-left"
onclick={() => (expanded = !expanded)}
>
<div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-lg font-semibold text-gray-900 dark:text-gray-100">
v{item.version}
</span>
<span class={`rounded-full px-2 py-0.5 text-xs font-medium ${getTypeColor(item.type)}`}>
{typeLabels[item.type] ?? item.type}
</span>
{#if item.highlighted}
<span
class="rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
>
Featured
</span>
{/if}
</div>
<h4 class="mt-1 font-medium text-gray-800 dark:text-gray-200">
{item.title}
</h4>
<p class="text-sm text-gray-500 dark:text-gray-400">
{formatDate(item.releaseDate)}
</p>
</div>
<span
class="mt-1 text-gray-500 transition-transform duration-200 dark:text-gray-400"
class:rotate-180={expanded}
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</span>
</button>
{#if expanded}
<div class="mt-4 space-y-4">
{#if item.summary}
<p class="text-gray-600 dark:text-gray-400">{item.summary}</p>
{/if}
{#if item.changes}
{#if item.changes.features && item.changes.features.length > 0}
<div>
<h5 class="mb-2 text-sm font-semibold text-green-600 dark:text-green-400">
New Features
</h5>
<ul class="space-y-1">
{#each item.changes.features as change}
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
<span class="text-green-500">+</span>
<span>
<strong>{change.title}</strong>
{#if change.description}
- {change.description}
{/if}
</span>
</li>
{/each}
</ul>
</div>
{/if}
{#if item.changes.improvements && item.changes.improvements.length > 0}
<div>
<h5 class="mb-2 text-sm font-semibold text-blue-600 dark:text-blue-400">
Improvements
</h5>
<ul class="space-y-1">
{#each item.changes.improvements as change}
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
<span class="text-blue-500"></span>
<span>
<strong>{change.title}</strong>
{#if change.description}
- {change.description}
{/if}
</span>
</li>
{/each}
</ul>
</div>
{/if}
{#if item.changes.bugfixes && item.changes.bugfixes.length > 0}
<div>
<h5 class="mb-2 text-sm font-semibold text-red-600 dark:text-red-400">Bug Fixes</h5>
<ul class="space-y-1">
{#each item.changes.bugfixes as change}
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
<span class="text-red-500">×</span>
<span>
<strong>{change.title}</strong>
{#if change.description}
- {change.description}
{/if}
</span>
</li>
{/each}
</ul>
</div>
{/if}
{/if}
{#if item.content}
<div class="prose prose-sm dark:prose-invert max-w-none">
{@html item.content}
</div>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,45 @@
<script lang="ts">
import type { ChangelogSectionProps } from '../types.js';
import ChangelogEntry from './ChangelogEntry.svelte';
let { items, translations, maxItems = 10 }: ChangelogSectionProps = $props();
let showAll = $state(false);
const sortedItems = $derived(() => {
return [...items].sort(
(a, b) => new Date(b.releaseDate).getTime() - new Date(a.releaseDate).getTime()
);
});
const displayedItems = $derived(() => {
if (showAll) return sortedItems();
return sortedItems().slice(0, maxItems);
});
const hasMore = $derived(items.length > maxItems && !showAll);
</script>
{#if items.length === 0}
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
{translations.changelog.noItems}
</p>
{:else}
<div>
{#each displayedItems() as item (item.id)}
<ChangelogEntry {item} typeLabels={translations.changelog.types} />
{/each}
{#if hasMore}
<div class="pt-4 text-center">
<button
type="button"
class="text-primary-600 dark:text-primary-400 text-sm font-medium hover:underline"
onclick={() => (showAll = true)}
>
Show all releases ({items.length - maxItems} more)
</button>
</div>
{/if}
</div>
{/if}

View file

@ -0,0 +1,123 @@
<script lang="ts">
import type { ContactSectionProps } from '../types.js';
let { contact, translations }: ContactSectionProps = $props();
</script>
{#if !contact}
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
{translations.contact.noInfo}
</p>
{:else}
<div class="space-y-6">
<div class="prose prose-sm dark:prose-invert max-w-none">
{@html contact.content}
</div>
<div class="grid gap-4 sm:grid-cols-2">
{#if contact.supportEmail}
<a
href={`mailto:${contact.supportEmail}`}
class="flex items-center gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
>
<div
class="bg-primary-100 text-primary-600 dark:bg-primary-900 dark:text-primary-400 flex h-10 w-10 items-center justify-center rounded-full"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<div>
<p class="font-medium text-gray-900 dark:text-gray-100">
{translations.contact.email}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
{contact.supportEmail}
</p>
</div>
</a>
{/if}
{#if contact.responseTime}
<div
class="flex items-center gap-3 rounded-lg border border-gray-200 p-4 dark:border-gray-700"
>
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<p class="font-medium text-gray-900 dark:text-gray-100">
{translations.contact.responseTime}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
{contact.responseTime}
</p>
</div>
</div>
{/if}
{#if contact.discordUrl}
<a
href={contact.discordUrl}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
>
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-indigo-100 text-indigo-600 dark:bg-indigo-900 dark:text-indigo-400"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path
d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
/>
</svg>
</div>
<div>
<p class="font-medium text-gray-900 dark:text-gray-100">Discord</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Join our community</p>
</div>
</a>
{/if}
{#if contact.documentationUrl}
<a
href={contact.documentationUrl}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
>
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
</div>
<div>
<p class="font-medium text-gray-900 dark:text-gray-100">Documentation</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Read the docs</p>
</div>
</a>
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1,46 @@
<script lang="ts">
import type { FAQItem } from '@manacore/shared-help-types';
interface Props {
item: FAQItem;
expanded?: boolean;
onToggle?: () => void;
}
let { item, expanded = false, onToggle }: Props = $props();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggle?.();
}
}
</script>
<div class="border-b border-gray-200 dark:border-gray-700">
<button
type="button"
class="flex w-full items-center justify-between py-4 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/50"
aria-expanded={expanded}
onclick={onToggle}
onkeydown={handleKeyDown}
>
<span class="pr-4 font-medium text-gray-900 dark:text-gray-100">
{item.question}
</span>
<span
class="flex-shrink-0 text-gray-500 transition-transform duration-200 dark:text-gray-400"
class:rotate-180={expanded}
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</span>
</button>
{#if expanded}
<div class="prose prose-sm dark:prose-invert max-w-none pb-4 text-gray-600 dark:text-gray-300">
{@html item.answer}
</div>
{/if}
</div>

View file

@ -0,0 +1,117 @@
<script lang="ts">
import type { FAQItem, FAQCategory } from '@manacore/shared-help-types';
import type { FAQSectionProps } from '../types.js';
import FAQItemComponent from './FAQItem.svelte';
let {
items,
translations,
showCategories = true,
maxItems,
expandFirst = false,
}: FAQSectionProps = $props();
let expandedId = $state<string | null>(expandFirst && items.length > 0 ? items[0].id : null);
let selectedCategory = $state<FAQCategory | 'all'>('all');
let showAll = $state(false);
const categories: FAQCategory[] = [
'general',
'account',
'billing',
'features',
'technical',
'privacy',
];
const filteredItems = $derived(() => {
let result = items;
if (selectedCategory !== 'all') {
result = result.filter((item) => item.category === selectedCategory);
}
if (maxItems && !showAll) {
result = result.slice(0, maxItems);
}
return result;
});
const hasMore = $derived(maxItems ? items.length > maxItems && !showAll : false);
function toggleItem(id: string) {
expandedId = expandedId === id ? null : id;
}
function getCategoryLabel(category: FAQCategory): string {
return translations.faq.categories[category] ?? category;
}
</script>
<div class="space-y-4">
{#if showCategories && items.length > 0}
<div class="flex flex-wrap gap-2">
<button
type="button"
class="rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
class:bg-primary-100={selectedCategory === 'all'}
class:text-primary-700={selectedCategory === 'all'}
class:dark:bg-primary-900={selectedCategory === 'all'}
class:dark:text-primary-300={selectedCategory === 'all'}
class:bg-gray-100={selectedCategory !== 'all'}
class:text-gray-600={selectedCategory !== 'all'}
class:dark:bg-gray-800={selectedCategory !== 'all'}
class:dark:text-gray-400={selectedCategory !== 'all'}
onclick={() => (selectedCategory = 'all')}
>
All
</button>
{#each categories as category}
{@const hasItems = items.some((item) => item.category === category)}
{#if hasItems}
<button
type="button"
class="rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
class:bg-primary-100={selectedCategory === category}
class:text-primary-700={selectedCategory === category}
class:dark:bg-primary-900={selectedCategory === category}
class:dark:text-primary-300={selectedCategory === category}
class:bg-gray-100={selectedCategory !== category}
class:text-gray-600={selectedCategory !== category}
class:dark:bg-gray-800={selectedCategory !== category}
class:dark:text-gray-400={selectedCategory !== category}
onclick={() => (selectedCategory = category)}
>
{getCategoryLabel(category)}
</button>
{/if}
{/each}
</div>
{/if}
{#if filteredItems().length === 0}
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
{translations.faq.noItems}
</p>
{:else}
<div class="divide-y divide-gray-200 dark:divide-gray-700">
{#each filteredItems() as item (item.id)}
<FAQItemComponent
{item}
expanded={expandedId === item.id}
onToggle={() => toggleItem(item.id)}
/>
{/each}
</div>
{/if}
{#if hasMore}
<div class="pt-4 text-center">
<button
type="button"
class="text-primary-600 dark:text-primary-400 text-sm font-medium hover:underline"
onclick={() => (showAll = true)}
>
{translations.common.showMore}
</button>
</div>
{/if}
</div>

View file

@ -0,0 +1,68 @@
<script lang="ts">
import type { FeatureItem } from '@manacore/shared-help-types';
interface Props {
item: FeatureItem;
learnMoreLabel?: string;
comingSoonLabel?: string;
}
let { item, learnMoreLabel = 'Learn more', comingSoonLabel = 'Coming soon' }: Props = $props();
</script>
<div
class="relative rounded-lg border border-gray-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
>
{#if item.comingSoon}
<span
class="absolute right-3 top-3 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
>
{comingSoonLabel}
</span>
{/if}
<div class="mb-3 flex items-center gap-3">
{#if item.icon}
<span class="text-2xl">{item.icon}</span>
{/if}
<h3 class="font-semibold text-gray-900 dark:text-gray-100">
{item.title}
</h3>
</div>
<p class="mb-3 text-sm text-gray-600 dark:text-gray-400">
{item.description}
</p>
{#if item.highlights && item.highlights.length > 0}
<ul class="mb-3 space-y-1">
{#each item.highlights as highlight}
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
<svg
class="text-primary-500 mt-0.5 h-4 w-4 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{highlight}
</li>
{/each}
</ul>
{/if}
{#if item.learnMoreUrl}
<a
href={item.learnMoreUrl}
class="text-primary-600 dark:text-primary-400 text-sm font-medium hover:underline"
>
{learnMoreLabel}
</a>
{/if}
</div>

View file

@ -0,0 +1,50 @@
<script lang="ts">
import type { FeaturesOverviewProps } from '../types.js';
import FeatureCard from './FeatureCard.svelte';
let { items, translations }: FeaturesOverviewProps = $props();
const groupedItems = $derived(() => {
const groups: Record<string, typeof items> = {
'getting-started': [],
core: [],
advanced: [],
integration: [],
};
for (const item of items) {
const category = item.category || 'core';
if (groups[category]) {
groups[category].push(item);
} else {
groups.core.push(item);
}
}
return groups;
});
const hasItems = $derived(items.length > 0);
</script>
{#if !hasItems}
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
{translations.features.noItems}
</p>
{:else}
<div class="space-y-8">
{#each Object.entries(groupedItems()) as [_category, categoryItems]}
{#if categoryItems.length > 0}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each categoryItems as item (item.id)}
<FeatureCard
{item}
learnMoreLabel={translations.features.learnMore}
comingSoonLabel={translations.features.comingSoon}
/>
{/each}
</div>
{/if}
{/each}
</div>
{/if}

View file

@ -0,0 +1,111 @@
<script lang="ts">
import type { GettingStartedGuideProps } from '../types.js';
let { items, translations }: GettingStartedGuideProps = $props();
let selectedGuideId = $state<string | null>(items.length > 0 ? items[0].id : null);
const selectedGuide = $derived(() => {
return items.find((item) => item.id === selectedGuideId) ?? null;
});
function getDifficultyLabel(difficulty: string): string {
return (
translations.gettingStarted.difficulty[
difficulty as keyof typeof translations.gettingStarted.difficulty
] ?? difficulty
);
}
function getDifficultyColor(difficulty: string): string {
switch (difficulty) {
case 'beginner':
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
case 'intermediate':
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
case 'advanced':
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
default:
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400';
}
}
</script>
{#if items.length === 0}
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
{translations.gettingStarted.noItems}
</p>
{:else}
<div class="flex flex-col gap-6 lg:flex-row">
<!-- Guide List -->
<div class="w-full space-y-2 lg:w-64">
{#each items as item (item.id)}
<button
type="button"
class="w-full rounded-lg border p-3 text-left transition-colors {selectedGuideId ===
item.id
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800'}"
onclick={() => (selectedGuideId = item.id)}
>
<h4 class="font-medium text-gray-900 dark:text-gray-100">
{item.title}
</h4>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span class={`rounded-full px-2 py-0.5 ${getDifficultyColor(item.difficulty)}`}>
{getDifficultyLabel(item.difficulty)}
</span>
{#if item.estimatedTime}
<span class="text-gray-500 dark:text-gray-400">
{item.estimatedTime}
</span>
{/if}
</div>
</button>
{/each}
</div>
<!-- Guide Content -->
<div class="flex-1">
{#if selectedGuide()}
{@const guide = selectedGuide()}
<div>
<h3 class="mb-2 text-xl font-semibold text-gray-900 dark:text-gray-100">
{guide.title}
</h3>
<p class="mb-4 text-gray-600 dark:text-gray-400">
{guide.description}
</p>
{#if guide.steps && guide.steps.length > 0}
<div class="space-y-6">
{#each guide.steps as step, index}
<div class="flex gap-4">
<div
class="bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-sm font-semibold"
>
{index + 1}
</div>
<div class="flex-1">
<h4 class="mb-2 font-medium text-gray-900 dark:text-gray-100">
{step.title}
</h4>
<div
class="prose prose-sm dark:prose-invert max-w-none text-gray-600 dark:text-gray-400"
>
{step.content}
</div>
</div>
</div>
{/each}
</div>
{:else}
<div class="prose prose-sm dark:prose-invert max-w-none">
{@html guide.content}
</div>
{/if}
</div>
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1,198 @@
<script lang="ts">
import type { HelpSearchProps } from '../types.js';
import type { SearchResult } from '@manacore/shared-help-types';
import { createSearcher } from '@manacore/shared-help-content';
let { content, translations, placeholder, onResultSelect }: HelpSearchProps = $props();
let query = $state('');
let results = $state<SearchResult[]>([]);
let isSearching = $state(false);
let showResults = $state(false);
let selectedIndex = $state(-1);
const searcher = $derived(createSearcher(content));
let debounceTimer: ReturnType<typeof setTimeout>;
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
query = target.value;
selectedIndex = -1;
clearTimeout(debounceTimer);
if (query.trim().length < 2) {
results = [];
showResults = false;
return;
}
isSearching = true;
debounceTimer = setTimeout(() => {
results = searcher(query, { limit: 8 });
isSearching = false;
showResults = true;
}, 300);
}
function handleKeyDown(event: KeyboardEvent) {
if (!showResults || results.length === 0) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
break;
case 'ArrowUp':
event.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, -1);
break;
case 'Enter':
event.preventDefault();
if (selectedIndex >= 0 && results[selectedIndex]) {
selectResult(results[selectedIndex]);
}
break;
case 'Escape':
showResults = false;
selectedIndex = -1;
break;
}
}
function selectResult(result: SearchResult) {
onResultSelect(result);
query = '';
results = [];
showResults = false;
selectedIndex = -1;
}
function handleBlur() {
// Delay to allow click on result
setTimeout(() => {
showResults = false;
}, 200);
}
function getTypeIcon(type: string): string {
switch (type) {
case 'faq':
return '?';
case 'feature':
return '★';
case 'guide':
return '📖';
case 'changelog':
return '📋';
default:
return '•';
}
}
function getTypeLabel(type: string): string {
switch (type) {
case 'faq':
return 'FAQ';
case 'feature':
return 'Feature';
case 'guide':
return 'Guide';
case 'changelog':
return 'Changelog';
default:
return type;
}
}
</script>
<div class="relative">
<div class="relative">
<input
type="text"
value={query}
oninput={handleInput}
onkeydown={handleKeyDown}
onfocus={() => query.length >= 2 && (showResults = true)}
onblur={handleBlur}
placeholder={placeholder ?? translations.search.noResults}
class="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-10 pr-4 text-sm text-gray-900 placeholder-gray-500 transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
/>
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
{#if isSearching}
<svg class="h-5 w-5 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else}
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{/if}
</div>
</div>
{#if showResults}
<div
class="absolute z-50 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
>
{#if results.length === 0}
<div class="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
{translations.search.noResults.replace('{query}', query)}
</div>
{:else}
<ul class="max-h-96 overflow-auto py-2">
{#each results as result, index (result.id)}
<li>
<button
type="button"
class="flex w-full items-start gap-3 px-4 py-2 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 {selectedIndex ===
index
? 'bg-primary-50 dark:bg-primary-900/20'
: ''}"
onclick={() => selectResult(result)}
>
<span
class="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-gray-100 text-xs dark:bg-gray-700"
>
{getTypeIcon(result.type)}
</span>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate font-medium text-gray-900 dark:text-gray-100">
{@html result.highlight ?? result.title}
</span>
<span
class="flex-shrink-0 rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-400"
>
{getTypeLabel(result.type)}
</span>
</div>
<p class="mt-0.5 truncate text-sm text-gray-500 dark:text-gray-400">
{result.excerpt}
</p>
</div>
</button>
</li>
{/each}
</ul>
<div
class="border-t border-gray-200 px-4 py-2 text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400"
>
{translations.search.resultsCount.replace('{count}', String(results.length))}
</div>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,54 @@
<script lang="ts">
import type { KeyboardShortcutsProps } from '../types.js';
let { items, translations }: KeyboardShortcutsProps = $props();
const allShortcuts = $derived(() => {
return items.flatMap((item) =>
item.shortcuts.map((shortcut) => ({
...shortcut,
category: item.category,
title: item.title,
}))
);
});
const hasItems = $derived(allShortcuts().length > 0);
</script>
{#if !hasItems}
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
{translations.shortcuts.noItems}
</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="pb-3 pr-4 font-semibold text-gray-900 dark:text-gray-100">Shortcut</th>
<th class="pb-3 pr-4 font-semibold text-gray-900 dark:text-gray-100">Action</th>
<th class="pb-3 font-semibold text-gray-900 dark:text-gray-100">Description</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
{#each allShortcuts() as shortcut}
<tr>
<td class="py-3 pr-4">
<kbd
class="rounded bg-gray-100 px-2 py-1 font-mono text-xs text-gray-800 dark:bg-gray-800 dark:text-gray-200"
>
{shortcut.shortcut}
</kbd>
</td>
<td class="py-3 pr-4 text-gray-900 dark:text-gray-100">
{shortcut.action}
</td>
<td class="py-3 text-gray-600 dark:text-gray-400">
{shortcut.description || '-'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}

View file

@ -0,0 +1,33 @@
/**
* @manacore/shared-help-ui
* Svelte 5 components for the Help page system
*/
// Main page component
export { default as HelpPage } from './pages/HelpPage.svelte';
// Section components
export { default as FAQSection } from './components/FAQSection.svelte';
export { default as FAQItem } from './components/FAQItem.svelte';
export { default as FeaturesOverview } from './components/FeaturesOverview.svelte';
export { default as FeatureCard } from './components/FeatureCard.svelte';
export { default as KeyboardShortcuts } from './components/KeyboardShortcuts.svelte';
export { default as GettingStartedGuide } from './components/GettingStartedGuide.svelte';
export { default as ChangelogSection } from './components/ChangelogSection.svelte';
export { default as ChangelogEntry } from './components/ChangelogEntry.svelte';
export { default as ContactSection } from './components/ContactSection.svelte';
export { default as HelpSearch } from './components/HelpSearch.svelte';
// Types
export type {
HelpPageProps,
HelpPageTranslations,
HelpSection,
FAQSectionProps,
FeaturesOverviewProps,
KeyboardShortcutsProps,
GettingStartedGuideProps,
ChangelogSectionProps,
ContactSectionProps,
HelpSearchProps,
} from './types.js';

View file

@ -0,0 +1,169 @@
<script lang="ts">
import type { HelpPageProps, HelpSection } from '../types.js';
import type { SearchResult } from '@manacore/shared-help-types';
import HelpSearch from '../components/HelpSearch.svelte';
import FAQSection from '../components/FAQSection.svelte';
import FeaturesOverview from '../components/FeaturesOverview.svelte';
import KeyboardShortcuts from '../components/KeyboardShortcuts.svelte';
import GettingStartedGuide from '../components/GettingStartedGuide.svelte';
import ChangelogSection from '../components/ChangelogSection.svelte';
import ContactSection from '../components/ContactSection.svelte';
let {
content,
appName,
appId: _appId,
translations,
searchEnabled = true,
showFAQ = true,
showFeatures = true,
showShortcuts = true,
showGettingStarted = true,
showChangelog = true,
showContact = true,
defaultSection = 'faq',
showBackButton = false,
onBack,
onSectionChange,
onSearch,
}: HelpPageProps = $props();
let activeSection = $state<HelpSection>(defaultSection);
const sections: { id: HelpSection; label: string; show: boolean }[] = $derived([
{ id: 'faq', label: translations.sections.faq, show: showFAQ && content.faq.length > 0 },
{
id: 'features',
label: translations.sections.features,
show: showFeatures && content.features.length > 0,
},
{
id: 'shortcuts',
label: translations.sections.shortcuts,
show: showShortcuts && content.shortcuts.length > 0,
},
{
id: 'getting-started',
label: translations.sections.gettingStarted,
show: showGettingStarted && content.gettingStarted.length > 0,
},
{
id: 'changelog',
label: translations.sections.changelog,
show: showChangelog && content.changelog.length > 0,
},
{ id: 'contact', label: translations.sections.contact, show: showContact && !!content.contact },
]);
const visibleSections = $derived(sections.filter((s) => s.show));
function setActiveSection(section: HelpSection) {
activeSection = section;
onSectionChange?.(section);
}
function handleSearchResultSelect(result: SearchResult) {
// Navigate to the appropriate section based on result type
switch (result.type) {
case 'faq':
setActiveSection('faq');
break;
case 'feature':
setActiveSection('features');
break;
case 'guide':
setActiveSection('getting-started');
break;
case 'changelog':
setActiveSection('changelog');
break;
}
onSearch?.(result.title, [result]);
}
</script>
<div class="mx-auto max-w-4xl px-4 py-8">
<!-- Header -->
<div class="mb-8">
{#if showBackButton}
<button
type="button"
class="mb-4 flex items-center gap-1 text-sm text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
onclick={onBack}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
{translations.common.back}
</button>
{/if}
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
{translations.title}
</h1>
{#if translations.subtitle}
<p class="mt-1 text-gray-600 dark:text-gray-400">
{translations.subtitle} - {appName}
</p>
{/if}
</div>
<!-- Search -->
{#if searchEnabled}
<div class="mb-8">
<HelpSearch
{content}
{translations}
placeholder={translations.searchPlaceholder}
onResultSelect={handleSearchResultSelect}
/>
</div>
{/if}
<!-- Navigation Tabs -->
{#if visibleSections.length > 1}
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
<nav class="-mb-px flex space-x-4 overflow-x-auto" aria-label="Help sections">
{#each visibleSections as section (section.id)}
<button
type="button"
class="whitespace-nowrap border-b-2 px-1 py-3 text-sm font-medium transition-colors"
class:border-primary-500={activeSection === section.id}
class:text-primary-600={activeSection === section.id}
class:dark:text-primary-400={activeSection === section.id}
class:border-transparent={activeSection !== section.id}
class:text-gray-500={activeSection !== section.id}
class:hover:text-gray-700={activeSection !== section.id}
class:dark:text-gray-400={activeSection !== section.id}
class:dark:hover:text-gray-300={activeSection !== section.id}
onclick={() => setActiveSection(section.id)}
>
{section.label}
</button>
{/each}
</nav>
</div>
{/if}
<!-- Content -->
<div class="min-h-[400px]">
{#if activeSection === 'faq' && showFAQ}
<FAQSection items={content.faq} {translations} expandFirst />
{:else if activeSection === 'features' && showFeatures}
<FeaturesOverview items={content.features} {translations} />
{:else if activeSection === 'shortcuts' && showShortcuts}
<KeyboardShortcuts items={content.shortcuts} {translations} />
{:else if activeSection === 'getting-started' && showGettingStarted}
<GettingStartedGuide items={content.gettingStarted} {translations} />
{:else if activeSection === 'changelog' && showChangelog}
<ChangelogSection items={content.changelog} {translations} />
{:else if activeSection === 'contact' && showContact}
<ContactSection contact={content.contact} {translations} />
{/if}
</div>
</div>

View file

@ -0,0 +1,147 @@
/**
* Component Props and Translation Types
*/
import type { HelpContent, SearchResult } from '@manacore/shared-help-types';
// ============================================================================
// Translation Types
// ============================================================================
export interface HelpPageTranslations {
title: string;
subtitle?: string;
searchPlaceholder: string;
sections: {
faq: string;
features: string;
shortcuts: string;
gettingStarted: string;
changelog: string;
contact: string;
};
search: {
noResults: string;
resultsCount: string;
searching: string;
};
faq: {
noItems: string;
categories: {
general: string;
account: string;
billing: string;
features: string;
technical: string;
privacy: string;
};
};
features: {
noItems: string;
comingSoon: string;
learnMore: string;
};
shortcuts: {
noItems: string;
};
gettingStarted: {
noItems: string;
estimatedTime: string;
difficulty: {
beginner: string;
intermediate: string;
advanced: string;
};
};
changelog: {
noItems: string;
types: {
major: string;
minor: string;
patch: string;
beta: string;
};
};
contact: {
noInfo: string;
email: string;
responseTime: string;
};
common: {
back: string;
showMore: string;
showLess: string;
};
}
// ============================================================================
// Component Props
// ============================================================================
export type HelpSection =
| 'faq'
| 'features'
| 'shortcuts'
| 'getting-started'
| 'changelog'
| 'contact';
export interface HelpPageProps {
content: HelpContent;
appName: string;
appId: string;
translations: HelpPageTranslations;
searchEnabled?: boolean;
showFAQ?: boolean;
showFeatures?: boolean;
showShortcuts?: boolean;
showGettingStarted?: boolean;
showChangelog?: boolean;
showContact?: boolean;
defaultSection?: HelpSection;
showBackButton?: boolean;
onBack?: () => void;
onSectionChange?: (section: HelpSection) => void;
onSearch?: (query: string, results: SearchResult[]) => void;
}
export interface FAQSectionProps {
items: HelpContent['faq'];
translations: Pick<HelpPageTranslations, 'faq' | 'common'>;
showCategories?: boolean;
maxItems?: number;
expandFirst?: boolean;
}
export interface FeaturesOverviewProps {
items: HelpContent['features'];
translations: Pick<HelpPageTranslations, 'features'>;
}
export interface KeyboardShortcutsProps {
items: HelpContent['shortcuts'];
translations: Pick<HelpPageTranslations, 'shortcuts'>;
}
export interface GettingStartedGuideProps {
items: HelpContent['gettingStarted'];
translations: Pick<HelpPageTranslations, 'gettingStarted'>;
}
export interface ChangelogSectionProps {
items: HelpContent['changelog'];
translations: Pick<HelpPageTranslations, 'changelog'>;
maxItems?: number;
}
export interface ContactSectionProps {
contact: HelpContent['contact'];
translations: Pick<HelpPageTranslations, 'contact'>;
}
export interface HelpSearchProps {
content: HelpContent;
translations: Pick<HelpPageTranslations, 'search'>;
placeholder?: string;
onResultSelect: (result: SearchResult) => void;
}

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -65,5 +65,18 @@ export {
getForgotPasswordTranslations,
} from './translations/auth';
// Help translations
export {
en as helpTranslationsEn,
de as helpTranslationsDe,
it as helpTranslationsIt,
fr as helpTranslationsFr,
es as helpTranslationsEs,
type HelpTranslations,
type HelpLocale,
helpTranslations,
getHelpTranslations,
} from './translations/help';
// Components
export { LanguageSelector } from './components';

View file

@ -0,0 +1,65 @@
{
"title": "Hilfe & Support",
"subtitle": "Finde Antworten und lerne die App kennen",
"searchPlaceholder": "Hilfe durchsuchen...",
"sections": {
"faq": "FAQ",
"features": "Features",
"shortcuts": "Tastenkürzel",
"gettingStarted": "Erste Schritte",
"changelog": "Neuigkeiten",
"contact": "Kontakt"
},
"search": {
"noResults": "Keine Ergebnisse für \"{query}\"",
"resultsCount": "{count} Ergebnisse gefunden",
"searching": "Suche..."
},
"faq": {
"noItems": "Keine FAQs verfügbar",
"categories": {
"general": "Allgemein",
"account": "Konto",
"billing": "Abrechnung",
"features": "Funktionen",
"technical": "Technik",
"privacy": "Datenschutz"
}
},
"features": {
"noItems": "Noch keine Features dokumentiert",
"comingSoon": "Demnächst",
"learnMore": "Mehr erfahren"
},
"shortcuts": {
"noItems": "Keine Tastenkürzel verfügbar"
},
"gettingStarted": {
"noItems": "Noch keine Anleitungen verfügbar",
"estimatedTime": "Geschätzte Zeit",
"difficulty": {
"beginner": "Anfänger",
"intermediate": "Fortgeschritten",
"advanced": "Experte"
}
},
"changelog": {
"noItems": "Noch keine Updates",
"types": {
"major": "Haupt-Update",
"minor": "Kleines Update",
"patch": "Fehlerbehebung",
"beta": "Beta"
}
},
"contact": {
"noInfo": "Kontaktinformationen nicht verfügbar",
"email": "E-Mail senden",
"responseTime": "Antwortzeit"
},
"common": {
"back": "Zurück",
"showMore": "Mehr anzeigen",
"showLess": "Weniger anzeigen"
}
}

View file

@ -0,0 +1,65 @@
{
"title": "Help & Support",
"subtitle": "Find answers and learn how to use the app",
"searchPlaceholder": "Search help articles...",
"sections": {
"faq": "FAQ",
"features": "Features",
"shortcuts": "Keyboard Shortcuts",
"gettingStarted": "Getting Started",
"changelog": "What's New",
"contact": "Contact Us"
},
"search": {
"noResults": "No results found for \"{query}\"",
"resultsCount": "{count} results found",
"searching": "Searching..."
},
"faq": {
"noItems": "No FAQs available",
"categories": {
"general": "General",
"account": "Account",
"billing": "Billing",
"features": "Features",
"technical": "Technical",
"privacy": "Privacy"
}
},
"features": {
"noItems": "No features documented yet",
"comingSoon": "Coming soon",
"learnMore": "Learn more"
},
"shortcuts": {
"noItems": "No keyboard shortcuts available"
},
"gettingStarted": {
"noItems": "No guides available yet",
"estimatedTime": "Estimated time",
"difficulty": {
"beginner": "Beginner",
"intermediate": "Intermediate",
"advanced": "Advanced"
}
},
"changelog": {
"noItems": "No updates yet",
"types": {
"major": "Major",
"minor": "Minor",
"patch": "Patch",
"beta": "Beta"
}
},
"contact": {
"noInfo": "Contact information not available",
"email": "Email us",
"responseTime": "Response time"
},
"common": {
"back": "Back",
"showMore": "Show more",
"showLess": "Show less"
}
}

View file

@ -0,0 +1,65 @@
{
"title": "Ayuda y Soporte",
"subtitle": "Encuentra respuestas y aprende a usar la aplicación",
"searchPlaceholder": "Buscar en la ayuda...",
"sections": {
"faq": "FAQ",
"features": "Características",
"shortcuts": "Atajos de teclado",
"gettingStarted": "Primeros pasos",
"changelog": "Novedades",
"contact": "Contacto"
},
"search": {
"noResults": "Sin resultados para \"{query}\"",
"resultsCount": "{count} resultados encontrados",
"searching": "Buscando..."
},
"faq": {
"noItems": "No hay preguntas frecuentes disponibles",
"categories": {
"general": "General",
"account": "Cuenta",
"billing": "Facturación",
"features": "Características",
"technical": "Técnico",
"privacy": "Privacidad"
}
},
"features": {
"noItems": "No hay características documentadas",
"comingSoon": "Próximamente",
"learnMore": "Saber más"
},
"shortcuts": {
"noItems": "No hay atajos de teclado disponibles"
},
"gettingStarted": {
"noItems": "No hay guías disponibles",
"estimatedTime": "Tiempo estimado",
"difficulty": {
"beginner": "Principiante",
"intermediate": "Intermedio",
"advanced": "Avanzado"
}
},
"changelog": {
"noItems": "Sin actualizaciones",
"types": {
"major": "Principal",
"minor": "Menor",
"patch": "Corrección",
"beta": "Beta"
}
},
"contact": {
"noInfo": "Información de contacto no disponible",
"email": "Envíanos un correo",
"responseTime": "Tiempo de respuesta"
},
"common": {
"back": "Volver",
"showMore": "Ver más",
"showLess": "Ver menos"
}
}

View file

@ -0,0 +1,65 @@
{
"title": "Aide & Support",
"subtitle": "Trouvez des réponses et apprenez à utiliser l'application",
"searchPlaceholder": "Rechercher dans l'aide...",
"sections": {
"faq": "FAQ",
"features": "Fonctionnalités",
"shortcuts": "Raccourcis clavier",
"gettingStarted": "Premiers pas",
"changelog": "Nouveautés",
"contact": "Contact"
},
"search": {
"noResults": "Aucun résultat pour \"{query}\"",
"resultsCount": "{count} résultats trouvés",
"searching": "Recherche..."
},
"faq": {
"noItems": "Aucune FAQ disponible",
"categories": {
"general": "Général",
"account": "Compte",
"billing": "Facturation",
"features": "Fonctionnalités",
"technical": "Technique",
"privacy": "Confidentialité"
}
},
"features": {
"noItems": "Aucune fonctionnalité documentée",
"comingSoon": "Bientôt disponible",
"learnMore": "En savoir plus"
},
"shortcuts": {
"noItems": "Aucun raccourci clavier disponible"
},
"gettingStarted": {
"noItems": "Aucun guide disponible",
"estimatedTime": "Temps estimé",
"difficulty": {
"beginner": "Débutant",
"intermediate": "Intermédiaire",
"advanced": "Avancé"
}
},
"changelog": {
"noItems": "Aucune mise à jour",
"types": {
"major": "Majeure",
"minor": "Mineure",
"patch": "Correctif",
"beta": "Bêta"
}
},
"contact": {
"noInfo": "Informations de contact non disponibles",
"email": "Nous contacter",
"responseTime": "Délai de réponse"
},
"common": {
"back": "Retour",
"showMore": "Voir plus",
"showLess": "Voir moins"
}
}

View file

@ -0,0 +1,108 @@
/**
* Help translations exports
*/
import en from './en.json';
import de from './de.json';
import it from './it.json';
import fr from './fr.json';
import es from './es.json';
export { en, de, it, fr, es };
/**
* Help translations type structure
*/
export interface HelpTranslations {
title: string;
subtitle: string;
searchPlaceholder: string;
sections: {
faq: string;
features: string;
shortcuts: string;
gettingStarted: string;
changelog: string;
contact: string;
};
search: {
noResults: string;
resultsCount: string;
searching: string;
};
faq: {
noItems: string;
categories: {
general: string;
account: string;
billing: string;
features: string;
technical: string;
privacy: string;
};
};
features: {
noItems: string;
comingSoon: string;
learnMore: string;
};
shortcuts: {
noItems: string;
};
gettingStarted: {
noItems: string;
estimatedTime: string;
difficulty: {
beginner: string;
intermediate: string;
advanced: string;
};
};
changelog: {
noItems: string;
types: {
major: string;
minor: string;
patch: string;
beta: string;
};
};
contact: {
noInfo: string;
email: string;
responseTime: string;
};
common: {
back: string;
showMore: string;
showLess: string;
};
}
/**
* Supported help locales
*/
export type HelpLocale = 'en' | 'de' | 'it' | 'fr' | 'es';
/**
* All help translations by locale
*/
export const helpTranslations: Record<HelpLocale, HelpTranslations> = {
en,
de,
it,
fr,
es,
};
/**
* Get help translations by locale
*/
export function getHelpTranslations(locale: string): HelpTranslations {
const supportedLocale = locale as HelpLocale;
if (supportedLocale in helpTranslations) {
return helpTranslations[supportedLocale];
}
// Default to English
return helpTranslations.en;
}

View file

@ -0,0 +1,65 @@
{
"title": "Aiuto & Supporto",
"subtitle": "Trova risposte e impara a usare l'app",
"searchPlaceholder": "Cerca nell'aiuto...",
"sections": {
"faq": "FAQ",
"features": "Funzionalità",
"shortcuts": "Scorciatoie",
"gettingStarted": "Primi passi",
"changelog": "Novità",
"contact": "Contatti"
},
"search": {
"noResults": "Nessun risultato per \"{query}\"",
"resultsCount": "{count} risultati trovati",
"searching": "Ricerca..."
},
"faq": {
"noItems": "Nessuna FAQ disponibile",
"categories": {
"general": "Generale",
"account": "Account",
"billing": "Fatturazione",
"features": "Funzionalità",
"technical": "Tecnico",
"privacy": "Privacy"
}
},
"features": {
"noItems": "Nessuna funzionalità documentata",
"comingSoon": "Prossimamente",
"learnMore": "Scopri di più"
},
"shortcuts": {
"noItems": "Nessuna scorciatoia disponibile"
},
"gettingStarted": {
"noItems": "Nessuna guida disponibile",
"estimatedTime": "Tempo stimato",
"difficulty": {
"beginner": "Principiante",
"intermediate": "Intermedio",
"advanced": "Avanzato"
}
},
"changelog": {
"noItems": "Nessun aggiornamento",
"types": {
"major": "Principale",
"minor": "Secondario",
"patch": "Correzione",
"beta": "Beta"
}
},
"contact": {
"noInfo": "Informazioni di contatto non disponibili",
"email": "Inviaci un'email",
"responseTime": "Tempo di risposta"
},
"common": {
"back": "Indietro",
"showMore": "Mostra di più",
"showLess": "Mostra meno"
}
}

View file

@ -0,0 +1,27 @@
{
"name": "@manacore/shared-tags",
"version": "0.1.0",
"description": "Shared tags client for Manacore apps - connects to central tags service",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"type-check": "tsc --noEmit",
"lint": "eslint ."
},
"dependencies": {},
"devDependencies": {
"typescript": "^5.9.3"
},
"keywords": [
"manacore",
"tags",
"labels"
],
"license": "MIT"
}

View file

@ -0,0 +1,145 @@
import type { Tag, TagResponse, CreateTagInput, UpdateTagInput } from './types';
/**
* Configuration for TagsClient
*/
export interface TagsClientConfig {
/**
* Base URL of mana-core-auth service (e.g., 'http://localhost:3001')
*/
authUrl: string;
/**
* Function to get the current auth token
*/
getToken: () => Promise<string> | string;
}
/**
* Client for interacting with the central Tags API in mana-core-auth.
* Used by all Manacore apps to manage user tags.
*/
export class TagsClient {
private authUrl: string;
private getToken: () => Promise<string> | string;
constructor(config: TagsClientConfig) {
this.authUrl = config.authUrl.replace(/\/$/, ''); // Remove trailing slash
this.getToken = config.getToken;
}
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = await this.getToken();
const response = await fetch(`${this.authUrl}/api/v1${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...options.headers,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `Request failed: ${response.status}`);
}
// Handle 204 No Content
if (response.status === 204) {
return undefined as T;
}
return response.json();
}
/**
* Get all tags for the current user
*/
async getAll(): Promise<Tag[]> {
const tags = await this.request<TagResponse[]>('/tags');
return tags.map(this.normalizeTag);
}
/**
* Get a single tag by ID
*/
async getById(id: string): Promise<Tag | null> {
try {
const tag = await this.request<TagResponse>(`/tags/${id}`);
return tag ? this.normalizeTag(tag) : null;
} catch {
return null;
}
}
/**
* Get multiple tags by their IDs.
* Useful for resolving tagIds stored in junction tables.
*/
async getByIds(ids: string[]): Promise<Tag[]> {
if (ids.length === 0) return [];
const tags = await this.request<TagResponse[]>(`/tags/by-ids?ids=${ids.join(',')}`);
return tags.map(this.normalizeTag);
}
/**
* Create a new tag
*/
async create(data: CreateTagInput): Promise<Tag> {
const tag = await this.request<TagResponse>('/tags', {
method: 'POST',
body: JSON.stringify(data),
});
return this.normalizeTag(tag);
}
/**
* Update an existing tag
*/
async update(id: string, data: UpdateTagInput): Promise<Tag> {
const tag = await this.request<TagResponse>(`/tags/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
return this.normalizeTag(tag);
}
/**
* Delete a tag
*/
async delete(id: string): Promise<void> {
await this.request(`/tags/${id}`, {
method: 'DELETE',
});
}
/**
* Create default tags for the user (if not already created)
*/
async createDefaults(): Promise<Tag[]> {
const tags = await this.request<TagResponse[]>('/tags/defaults', {
method: 'POST',
});
return tags.map(this.normalizeTag);
}
/**
* Normalize API response to Tag type
*/
private normalizeTag(tag: TagResponse): Tag {
return {
...tag,
createdAt: new Date(tag.createdAt),
updatedAt: new Date(tag.updatedAt),
};
}
}
/**
* Create a TagsClient instance
*/
export function createTagsClient(config: TagsClientConfig): TagsClient {
return new TagsClient(config);
}

View file

@ -0,0 +1,2 @@
export * from './types';
export * from './client';

View file

@ -0,0 +1,39 @@
/**
* Tag entity from the central mana-core-auth service.
* Used across all Manacore apps (Todo, Calendar, Contacts, etc.)
*/
export interface Tag {
id: string;
userId: string;
name: string;
color: string;
icon?: string | null;
createdAt: Date | string;
updatedAt: Date | string;
}
/**
* Input for creating a new tag
*/
export interface CreateTagInput {
name: string;
color?: string;
icon?: string;
}
/**
* Input for updating an existing tag
*/
export interface UpdateTagInput {
name?: string;
color?: string;
icon?: string;
}
/**
* API response type that might have date strings instead of Date objects
*/
export type TagResponse = Omit<Tag, 'createdAt' | 'updatedAt'> & {
createdAt: string;
updatedAt: string;
};

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,7 +1,21 @@
<script lang="ts">
import type { ThemeVariant } from '@manacore/shared-theme';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import { Check, Lock, Clock, Star, Sparkle, Leaf, Hexagon, Waves } from '@manacore/shared-icons';
import {
Check,
Lock,
Clock,
Star,
Sparkle,
Leaf,
Hexagon,
Waves,
PushPin,
PushPinSlash,
Sun,
Moon,
Flower,
} from '@manacore/shared-icons';
import type { ThemeStatus } from '../types';
import ThemeColorPreview from './ThemeColorPreview.svelte';
@ -11,6 +25,9 @@
leaf: Leaf,
hexagon: Hexagon,
waves: Waves,
sun: Sun,
moon: Moon,
flower: Flower,
} as const;
interface Props {
@ -19,6 +36,12 @@
status?: ThemeStatus;
onClick?: () => void;
onUnlock?: () => void;
/** Whether this theme can be pinned (extended themes only) */
canPin?: boolean;
/** Whether this theme is currently pinned */
isPinned?: boolean;
/** Callback when pin status changes */
onTogglePin?: (variant: ThemeVariant) => void;
translations?: {
locked?: string;
comingSoon?: string;
@ -26,6 +49,8 @@
unlock?: string;
lightPreview?: string;
darkPreview?: string;
pin?: string;
unpin?: string;
};
}
@ -35,6 +60,9 @@
status = 'available',
onClick,
onUnlock,
canPin = false,
isPinned = false,
onTogglePin,
translations = {},
}: Props = $props();
@ -45,6 +73,8 @@
unlock: translations.unlock ?? 'Freischalten',
lightPreview: translations.lightPreview ?? 'Hell',
darkPreview: translations.darkPreview ?? 'Dunkel',
pin: translations.pin ?? 'Anpinnen',
unpin: translations.unpin ?? 'Lösen',
};
const definition = $derived(THEME_DEFINITIONS[variant]);
@ -65,6 +95,13 @@
onUnlock();
}
}
function handleTogglePin(e: MouseEvent) {
e.stopPropagation();
if (onTogglePin) {
onTogglePin(variant);
}
}
</script>
<button
@ -99,6 +136,28 @@
</div>
{/if}
<!-- Pin button for extended themes -->
{#if canPin && onTogglePin && isAvailable}
<button
type="button"
onclick={handleTogglePin}
class="absolute top-3 {isActive
? 'right-11'
: 'right-3'} w-6 h-6 flex items-center justify-center
{isPinned
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground'}
rounded-full transition-colors"
title={isPinned ? t.unpin : t.pin}
>
{#if isPinned}
<PushPin size={12} weight="fill" />
{:else}
<PushPinSlash size={12} />
{/if}
</button>
{/if}
<!-- Header -->
<div class="flex items-center gap-2 mb-3">
{#if definition.icon && themeIcons[definition.icon as keyof typeof themeIcons]}

View file

@ -1,6 +1,6 @@
<script lang="ts">
import type { ThemeVariant } from '@manacore/shared-theme';
import { THEME_VARIANTS } from '@manacore/shared-theme';
import { DEFAULT_THEME_VARIANTS, EXTENDED_THEME_VARIANTS } from '@manacore/shared-theme';
import type { ThemeCardData, ThemePageTranslations } from '../types';
import ThemeCard from './ThemeCard.svelte';
@ -11,6 +11,12 @@
onUnlock?: (variant: ThemeVariant) => void;
showLockedThemes?: boolean;
translations?: Partial<ThemePageTranslations>;
/** Currently pinned themes (extended themes only) */
pinnedThemes?: ThemeVariant[];
/** Callback when pin status changes */
onTogglePin?: (variant: ThemeVariant) => void;
/** Whether to show extended themes section */
showExtendedThemes?: boolean;
}
let {
@ -20,15 +26,34 @@
onUnlock,
showLockedThemes = true,
translations = {},
pinnedThemes = [],
onTogglePin,
showExtendedThemes = true,
}: Props = $props();
// Build theme data - use provided themes or create defaults from THEME_VARIANTS
const themeData = $derived(() => {
// Build theme data for default themes
const defaultThemeData = $derived(() => {
if (themes) {
return showLockedThemes ? themes : themes.filter((t) => t.status === 'available');
const defaultThemes = themes.filter((t) => DEFAULT_THEME_VARIANTS.includes(t.variant));
return showLockedThemes
? defaultThemes
: defaultThemes.filter((t) => t.status === 'available');
}
// Default: all variants are available
return THEME_VARIANTS.map((variant) => ({
return DEFAULT_THEME_VARIANTS.map((variant) => ({
variant,
status: 'available' as const,
}));
});
// Build theme data for extended themes
const extendedThemeData = $derived(() => {
if (themes) {
const extendedThemes = themes.filter((t) => EXTENDED_THEME_VARIANTS.includes(t.variant));
return showLockedThemes
? extendedThemes
: extendedThemes.filter((t) => t.status === 'available');
}
return EXTENDED_THEME_VARIANTS.map((variant) => ({
variant,
status: 'available' as const,
}));
@ -42,10 +67,15 @@
lightPreview: translations.lightPreview,
darkPreview: translations.darkPreview,
});
function isThemePinned(variant: ThemeVariant): boolean {
return pinnedThemes.includes(variant);
}
</script>
<!-- Default Themes -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{#each themeData() as theme (theme.variant)}
{#each defaultThemeData() as theme (theme.variant)}
<ThemeCard
variant={theme.variant}
isActive={currentVariant === theme.variant}
@ -56,3 +86,28 @@
/>
{/each}
</div>
<!-- Extended Themes -->
{#if showExtendedThemes && extendedThemeData().length > 0}
<div class="mt-8">
<h3 class="text-sm font-medium text-muted-foreground mb-4 flex items-center gap-2">
Weitere Themes
<span class="text-xs text-muted-foreground/70">(zum Anpinnen in der Navigation)</span>
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{#each extendedThemeData() as theme (theme.variant)}
<ThemeCard
variant={theme.variant}
isActive={currentVariant === theme.variant}
status={theme.status}
onClick={() => onSelect(theme.variant)}
onUnlock={onUnlock ? () => onUnlock(theme.variant) : undefined}
canPin={true}
isPinned={isThemePinned(theme.variant)}
{onTogglePin}
translations={cardTranslations}
/>
{/each}
</div>
</div>
{/if}

View file

@ -0,0 +1,266 @@
<script lang="ts">
import type { CommunityTheme, CommunityThemeQuery } from '@manacore/shared-theme';
import {
MagnifyingGlass,
SortAscending,
Funnel,
Star,
Fire,
Clock,
DownloadSimple,
CaretLeft,
CaretRight,
} from '@manacore/shared-icons';
import ThemeCommunityCard from './ThemeCommunityCard.svelte';
interface Props {
/** List of community themes */
themes: CommunityTheme[];
/** Pagination info */
pagination?: {
page: number;
totalPages: number;
total: number;
};
/** Current query */
currentQuery?: CommunityThemeQuery;
/** Loading state */
loading?: boolean;
/** Current user's effective mode */
effectiveMode?: 'light' | 'dark';
/** Callback when query changes */
onQueryChange?: (query: CommunityThemeQuery) => void;
/** Callback when theme is selected */
onSelectTheme?: (theme: CommunityTheme) => void;
/** Callback when download is clicked */
onDownloadTheme?: (theme: CommunityTheme) => void;
/** Callback when favorite is toggled */
onToggleFavorite?: (theme: CommunityTheme) => void;
/** Callback when theme is rated */
onRateTheme?: (theme: CommunityTheme, rating: number) => void;
}
let {
themes,
pagination = { page: 1, totalPages: 1, total: 0 },
currentQuery = {},
loading = false,
effectiveMode = 'light',
onQueryChange,
onSelectTheme,
onDownloadTheme,
onToggleFavorite,
onRateTheme,
}: Props = $props();
// Local state for search input
let searchInput = $state(currentQuery.search ?? '');
let searchTimeout: ReturnType<typeof setTimeout>;
// Sort options
const sortOptions: { value: CommunityThemeQuery['sort']; label: string; icon: typeof Star }[] = [
{ value: 'popular', label: 'Beliebt', icon: Fire },
{ value: 'recent', label: 'Neueste', icon: Clock },
{ value: 'rating', label: 'Bestbewertet', icon: Star },
{ value: 'downloads', label: 'Downloads', icon: DownloadSimple },
];
// Common tags for filtering
const commonTags = ['minimal', 'dark', 'colorful', 'professional', 'nature', 'warm', 'cool'];
// Handle search with debounce
function handleSearchInput(e: Event) {
const target = e.target as HTMLInputElement;
searchInput = target.value;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
onQueryChange?.({ ...currentQuery, search: searchInput || undefined, page: 1 });
}, 300);
}
function handleSortChange(sort: CommunityThemeQuery['sort']) {
onQueryChange?.({ ...currentQuery, sort, page: 1 });
}
function handleTagToggle(tag: string) {
const currentTags = currentQuery.tags ?? [];
const newTags = currentTags.includes(tag)
? currentTags.filter((t) => t !== tag)
: [...currentTags, tag];
onQueryChange?.({ ...currentQuery, tags: newTags.length ? newTags : undefined, page: 1 });
}
function handleFeaturedToggle() {
onQueryChange?.({
...currentQuery,
featuredOnly: !currentQuery.featuredOnly,
page: 1,
});
}
function handlePageChange(page: number) {
if (page < 1 || page > pagination.totalPages) return;
onQueryChange?.({ ...currentQuery, page });
}
function clearFilters() {
searchInput = '';
onQueryChange?.({ page: 1, sort: 'popular' });
}
// Check if any filters are active
let hasActiveFilters = $derived(
!!currentQuery.search || !!currentQuery.tags?.length || !!currentQuery.featuredOnly
);
</script>
<div class="flex flex-col gap-4">
<!-- Search & Filters -->
<div class="flex flex-wrap gap-3 items-center">
<!-- Search -->
<div class="flex-1 min-w-[200px] relative">
<MagnifyingGlass
size={18}
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none"
/>
<input
type="search"
class="w-full py-2.5 px-3 pl-10 text-sm bg-input border border-border rounded-lg text-foreground transition-all focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 placeholder:text-muted-foreground"
placeholder="Themes suchen..."
value={searchInput}
oninput={handleSearchInput}
/>
</div>
<!-- Sort -->
<div class="flex items-center gap-2">
<SortAscending size={16} class="text-muted-foreground" />
<select
class="py-2 px-3 text-sm bg-input border border-border rounded-lg text-foreground cursor-pointer focus:outline-none focus:border-primary"
value={currentQuery.sort ?? 'popular'}
onchange={(e) =>
handleSortChange((e.target as HTMLSelectElement).value as CommunityThemeQuery['sort'])}
>
{#each sortOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<!-- Featured Toggle -->
<button
type="button"
class="flex items-center gap-1.5 py-2 px-3 text-sm font-medium border rounded-lg cursor-pointer transition-all
{currentQuery.featuredOnly
? 'bg-primary/10 border-primary text-primary'
: 'bg-muted border-border text-foreground hover:bg-muted/80'}"
onclick={handleFeaturedToggle}
>
<Star size={16} weight={currentQuery.featuredOnly ? 'fill' : 'regular'} />
Featured
</button>
</div>
<!-- Tag Filters -->
<div class="flex flex-wrap items-center gap-2">
<Funnel size={14} class="text-muted-foreground" />
{#each commonTags as tag}
<button
type="button"
class="px-2.5 py-1.5 text-xs font-medium rounded-full cursor-pointer transition-all
{currentQuery.tags?.includes(tag)
? 'bg-primary/10 border border-primary/30 text-primary'
: 'bg-muted/50 border border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'}"
onclick={() => handleTagToggle(tag)}
>
{tag}
</button>
{/each}
{#if hasActiveFilters}
<button
type="button"
class="px-2.5 py-1.5 text-xs font-medium bg-transparent border border-dashed border-border rounded-full text-muted-foreground cursor-pointer transition-all hover:border-primary hover:text-primary"
onclick={clearFilters}
>
Filter löschen
</button>
{/if}
</div>
<!-- Results Info -->
<div class="py-2">
{#if loading}
<span class="text-sm text-muted-foreground">Lade...</span>
{:else}
<span class="text-sm text-muted-foreground">{pagination.total} Themes gefunden</span>
{/if}
</div>
<!-- Theme Grid -->
<div
class="grid gap-4 transition-opacity duration-200
{loading ? 'opacity-60 pointer-events-none' : ''}
grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
>
{#if themes.length === 0 && !loading}
<div class="col-span-full text-center py-12">
<p class="text-lg font-semibold text-foreground mb-2">Keine Themes gefunden</p>
<p class="text-sm text-muted-foreground mb-4">Versuche andere Suchbegriffe oder Filter.</p>
{#if hasActiveFilters}
<button
type="button"
class="py-2 px-4 text-sm font-medium bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
onclick={clearFilters}
>
Filter zurücksetzen
</button>
{/if}
</div>
{:else}
{#each themes as theme (theme.id)}
<ThemeCommunityCard
{theme}
{effectiveMode}
onSelect={onSelectTheme}
onDownload={onDownloadTheme}
{onToggleFavorite}
onRate={onRateTheme}
/>
{/each}
{/if}
</div>
<!-- Pagination -->
{#if pagination.totalPages > 1}
<div class="flex justify-center items-center gap-4 pt-4">
<button
type="button"
class="flex items-center justify-center w-9 h-9 bg-muted border border-border rounded-lg text-foreground transition-all hover:bg-muted/80 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={pagination.page <= 1}
onclick={() => handlePageChange(pagination.page - 1)}
aria-label="Vorherige Seite"
>
<CaretLeft size={18} />
</button>
<div class="flex items-center gap-1 text-sm">
<span class="font-semibold text-foreground">{pagination.page}</span>
<span class="text-muted-foreground">/</span>
<span class="text-muted-foreground">{pagination.totalPages}</span>
</div>
<button
type="button"
class="flex items-center justify-center w-9 h-9 bg-muted border border-border rounded-lg text-foreground transition-all hover:bg-muted/80 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={pagination.page >= pagination.totalPages}
onclick={() => handlePageChange(pagination.page + 1)}
aria-label="Nächste Seite"
>
<CaretRight size={18} />
</button>
</div>
{/if}
</div>

View file

@ -0,0 +1,197 @@
<script lang="ts">
import type { CommunityTheme, ThemeColors } from '@manacore/shared-theme';
import { Heart, DownloadSimple, Star, Crown } from '@manacore/shared-icons';
interface Props {
/** The community theme to display */
theme: CommunityTheme;
/** Current user's effective mode */
effectiveMode?: 'light' | 'dark';
/** Callback when theme is selected */
onSelect?: (theme: CommunityTheme) => void;
/** Callback when download is clicked */
onDownload?: (theme: CommunityTheme) => void;
/** Callback when favorite is toggled */
onToggleFavorite?: (theme: CommunityTheme) => void;
/** Callback when theme is rated */
onRate?: (theme: CommunityTheme, rating: number) => void;
/** Show download button */
showDownload?: boolean;
/** Show favorite button */
showFavorite?: boolean;
}
let {
theme,
effectiveMode = 'light',
onSelect,
onDownload,
onToggleFavorite,
onRate,
showDownload = true,
showFavorite = true,
}: Props = $props();
// Get preview colors based on effective mode
let previewColors = $derived(
effectiveMode === 'dark' ? theme.darkColors : theme.lightColors
) as ThemeColors;
// Format download count
function formatCount(count: number): string {
if (count >= 1000000) {
return `${(count / 1000000).toFixed(1)}M`;
}
if (count >= 1000) {
return `${(count / 1000).toFixed(1)}k`;
}
return String(count);
}
let hoverRating = $state<number | null>(null);
function handleStarClick(rating: number) {
onRate?.(theme, rating);
}
function handleStarHover(rating: number | null) {
hoverRating = rating;
}
</script>
<article
class="bg-surface border border-border rounded-xl overflow-hidden cursor-pointer transition-all hover:border-border-strong hover:shadow-md hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2
{theme.isFeatured ? 'border-primary/30 bg-gradient-to-b from-primary/5 to-surface' : ''}"
onclick={() => onSelect?.(theme)}
onkeypress={(e) => e.key === 'Enter' && onSelect?.(theme)}
role="button"
tabindex="0"
>
<!-- Color Preview -->
<div class="flex h-12 relative">
<div class="flex-1" style="background-color: hsl({previewColors.primary})"></div>
<div class="flex-1" style="background-color: hsl({previewColors.background})"></div>
<div class="flex-1" style="background-color: hsl({previewColors.surface})"></div>
<div class="flex-1" style="background-color: hsl({previewColors.foreground})"></div>
<div class="flex-1" style="background-color: hsl({previewColors.success})"></div>
<div class="flex-1" style="background-color: hsl({previewColors.error})"></div>
{#if theme.isFeatured}
<div
class="absolute top-2 right-2 flex items-center gap-1 px-2 py-1 text-[10px] font-semibold bg-primary text-primary-foreground rounded shadow-md"
>
<Crown size={12} weight="fill" />
Featured
</div>
{/if}
</div>
<!-- Card Content -->
<div class="p-4 flex flex-col gap-3">
<div class="flex items-start gap-3">
<span class="text-2xl leading-none">{theme.emoji}</span>
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-foreground truncate m-0">{theme.name}</h3>
{#if theme.authorName}
<span class="text-xs text-muted-foreground">von {theme.authorName}</span>
{/if}
</div>
</div>
{#if theme.description}
<p class="text-sm text-muted-foreground line-clamp-2 m-0">{theme.description}</p>
{/if}
<!-- Tags -->
{#if theme.tags.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each theme.tags.slice(0, 3) as tag}
<span class="px-2 py-1 text-[10px] font-medium bg-muted text-muted-foreground rounded"
>{tag}</span
>
{/each}
{#if theme.tags.length > 3}
<span class="px-2 py-1 text-[10px] font-medium bg-primary/10 text-primary rounded"
>+{theme.tags.length - 3}</span
>
{/if}
</div>
{/if}
<!-- Stats -->
<div class="flex items-center gap-4">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
<DownloadSimple size={14} />
<span>{formatCount(theme.downloadCount)}</span>
</div>
<div class="flex items-center gap-0.5" role="group" aria-label="Bewertung">
{#each [1, 2, 3, 4, 5] as star}
<button
type="button"
class="bg-transparent border-none p-0 cursor-pointer transition-transform hover:scale-110
{star <= (hoverRating ?? theme.userRating ?? 0) ? 'text-yellow-500' : ''}
{!hoverRating && !theme.userRating && star <= Math.round(theme.averageRating)
? 'text-yellow-500/60'
: ''}
{star > (hoverRating ?? theme.userRating ?? Math.round(theme.averageRating))
? 'text-muted-foreground/40'
: ''}"
onclick={(e) => {
e.stopPropagation();
handleStarClick(star);
}}
onmouseenter={() => handleStarHover(star)}
onmouseleave={() => handleStarHover(null)}
aria-label={`${star} Stern${star > 1 ? 'e' : ''}`}
>
<Star
size={14}
weight={star <= (hoverRating ?? theme.userRating ?? Math.round(theme.averageRating))
? 'fill'
: 'regular'}
/>
</button>
{/each}
<span class="ml-1 text-[10px] text-muted-foreground">({theme.ratingCount})</span>
</div>
</div>
<!-- Actions -->
<div class="flex gap-2 mt-1">
{#if showDownload}
<button
type="button"
class="flex-1 flex items-center justify-center gap-1.5 py-2 px-3.5 text-[13px] font-medium rounded-lg transition-colors
{theme.isDownloaded
? 'bg-success/10 border border-success/30 text-success'
: 'bg-primary border border-primary text-primary-foreground hover:bg-primary/90'}"
onclick={(e) => {
e.stopPropagation();
onDownload?.(theme);
}}
>
<DownloadSimple size={16} />
{theme.isDownloaded ? 'Installiert' : 'Installieren'}
</button>
{/if}
{#if showFavorite}
<button
type="button"
class="flex items-center justify-center p-2 rounded-lg border transition-colors flex-shrink-0
{theme.isFavorited
? 'text-red-500 bg-red-500/10 border-red-500/30'
: 'bg-muted border-border text-foreground hover:bg-muted/80'}"
onclick={(e) => {
e.stopPropagation();
onToggleFavorite?.(theme);
}}
aria-label={theme.isFavorited ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen'}
>
<Heart size={18} weight={theme.isFavorited ? 'fill' : 'regular'} />
</button>
{/if}
</div>
</div>
</article>

View file

@ -0,0 +1,349 @@
<script lang="ts">
import type { HSLValue } from '@manacore/shared-theme';
interface Props {
/** Current HSL value (format: "H S% L%") */
value: HSLValue;
/** Callback when color changes */
onChange: (value: HSLValue) => void;
/** Label for the color picker */
label?: string;
/** Show hex input field */
showHexInput?: boolean;
/** Compact mode (smaller size) */
compact?: boolean;
}
let { value, onChange, label, showHexInput = true, compact = false }: Props = $props();
// Parse HSL value to components
function parseHSL(hsl: HSLValue): { h: number; s: number; l: number } {
const match = hsl.match(/^(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%?\s+(\d+(?:\.\d+)?)%?$/);
if (match) {
return {
h: parseFloat(match[1]),
s: parseFloat(match[2]),
l: parseFloat(match[3]),
};
}
return { h: 0, s: 50, l: 50 };
}
// Convert HSL to hex
function hslToHex(h: number, s: number, l: number): string {
s /= 100;
l /= 100;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color)
.toString(16)
.padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`;
}
// Convert hex to HSL
function hexToHSL(hex: string): { h: number; s: number; l: number } {
// Remove # if present
hex = hex.replace(/^#/, '');
// Parse hex to RGB
const r = parseInt(hex.substring(0, 2), 16) / 255;
const g = parseInt(hex.substring(2, 4), 16) / 255;
const b = parseInt(hex.substring(4, 6), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
case b:
h = ((r - g) / d + 4) / 6;
break;
}
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100),
};
}
// Current color values
let hsl = $derived(parseHSL(value));
let hex = $derived(hslToHex(hsl.h, hsl.s, hsl.l));
let hexInput = $state('');
// Keep hex input in sync
$effect(() => {
hexInput = hex;
});
function updateColor(h: number, s: number, l: number) {
const newValue = `${h} ${s}% ${l}%` as HSLValue;
onChange(newValue);
}
function handleHueChange(e: Event) {
const target = e.target as HTMLInputElement;
updateColor(parseInt(target.value), hsl.s, hsl.l);
}
function handleSaturationChange(e: Event) {
const target = e.target as HTMLInputElement;
updateColor(hsl.h, parseInt(target.value), hsl.l);
}
function handleLightnessChange(e: Event) {
const target = e.target as HTMLInputElement;
updateColor(hsl.h, hsl.s, parseInt(target.value));
}
function handleHexChange(e: Event) {
const target = e.target as HTMLInputElement;
const newHex = target.value;
hexInput = newHex;
// Only update if valid hex
if (/^#?[0-9A-Fa-f]{6}$/.test(newHex)) {
const newHSL = hexToHSL(newHex);
updateColor(newHSL.h, newHSL.s, newHSL.l);
}
}
// Gradient backgrounds for sliders
const hueGradient = `linear-gradient(to right,
hsl(0, 100%, 50%),
hsl(60, 100%, 50%),
hsl(120, 100%, 50%),
hsl(180, 100%, 50%),
hsl(240, 100%, 50%),
hsl(300, 100%, 50%),
hsl(360, 100%, 50%)
)`;
let saturationGradient = $derived(
`linear-gradient(to right, hsl(${hsl.h}, 0%, ${hsl.l}%), hsl(${hsl.h}, 100%, ${hsl.l}%))`
);
let lightnessGradient = $derived(
`linear-gradient(to right, hsl(${hsl.h}, ${hsl.s}%, 0%), hsl(${hsl.h}, ${hsl.s}%, 50%), hsl(${hsl.h}, ${hsl.s}%, 100%))`
);
</script>
<div class="color-picker" class:compact>
{#if label}
<label class="label">{label}</label>
{/if}
<div class="color-display">
<!-- Color preview swatch -->
<div class="swatch" style="background-color: hsl({hsl.h}, {hsl.s}%, {hsl.l}%)"></div>
{#if showHexInput}
<input
type="text"
class="hex-input"
value={hexInput}
oninput={handleHexChange}
placeholder="#000000"
maxlength="7"
/>
{/if}
</div>
<div class="sliders">
<!-- Hue slider -->
<div class="slider-group">
<span class="slider-label">H</span>
<input
type="range"
min="0"
max="360"
value={hsl.h}
oninput={handleHueChange}
class="slider hue-slider"
style="--slider-bg: {hueGradient}"
/>
<span class="slider-value">{hsl.h}</span>
</div>
<!-- Saturation slider -->
<div class="slider-group">
<span class="slider-label">S</span>
<input
type="range"
min="0"
max="100"
value={hsl.s}
oninput={handleSaturationChange}
class="slider"
style="--slider-bg: {saturationGradient}"
/>
<span class="slider-value">{hsl.s}%</span>
</div>
<!-- Lightness slider -->
<div class="slider-group">
<span class="slider-label">L</span>
<input
type="range"
min="0"
max="100"
value={hsl.l}
oninput={handleLightnessChange}
class="slider"
style="--slider-bg: {lightnessGradient}"
/>
<span class="slider-value">{hsl.l}%</span>
</div>
</div>
</div>
<style>
.color-picker {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.color-picker.compact {
gap: 0.5rem;
}
.label {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--foreground));
}
.color-display {
display: flex;
align-items: center;
gap: 0.75rem;
}
.swatch {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.5rem;
border: 2px solid hsl(var(--border));
flex-shrink: 0;
}
.compact .swatch {
width: 2rem;
height: 2rem;
}
.hex-input {
flex: 1;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-family: ui-monospace, monospace;
background: hsl(var(--input));
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
color: hsl(var(--foreground));
max-width: 100px;
}
.hex-input:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.2);
}
.sliders {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.slider-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.slider-label {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
width: 1rem;
text-align: center;
}
.slider {
flex: 1;
height: 0.5rem;
-webkit-appearance: none;
appearance: none;
background: var(--slider-bg);
border-radius: 0.25rem;
cursor: pointer;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 1rem;
height: 1rem;
background: white;
border-radius: 50%;
border: 2px solid hsl(var(--border-strong));
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.slider::-moz-range-thumb {
width: 1rem;
height: 1rem;
background: white;
border-radius: 50%;
border: 2px solid hsl(var(--border-strong));
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.slider-value {
font-size: 0.75rem;
font-family: ui-monospace, monospace;
color: hsl(var(--muted-foreground));
width: 2.5rem;
text-align: right;
}
.compact .slider-group {
gap: 0.375rem;
}
.compact .slider {
height: 0.375rem;
}
.compact .slider::-webkit-slider-thumb {
width: 0.75rem;
height: 0.75rem;
}
.compact .slider::-moz-range-thumb {
width: 0.75rem;
height: 0.75rem;
}
</style>

View file

@ -0,0 +1,542 @@
<script lang="ts">
import type {
ThemeColors,
ThemeVariant,
EffectiveMode,
CreateCustomThemeInput,
HSLValue,
} from '@manacore/shared-theme';
import {
THEME_DEFINITIONS,
THEME_VARIANTS,
MAIN_THEME_COLORS,
EXTENDED_THEME_COLORS,
THEME_COLOR_LABELS,
} from '@manacore/shared-theme';
import {
Sun,
Moon,
CaretDown,
CaretUp,
FloppyDisk,
ArrowCounterClockwise,
} from '@manacore/shared-icons';
import ColorPicker from './ColorPicker.svelte';
interface Props {
/** Initial theme data (for editing existing theme) */
initialTheme?: Partial<CreateCustomThemeInput>;
/** Current effective mode for preview */
effectiveMode?: EffectiveMode;
/** Callback when theme changes */
onThemeChange?: (theme: Partial<CreateCustomThemeInput>) => void;
/** Callback when save is triggered */
onSave?: (theme: CreateCustomThemeInput) => void;
/** Callback to preview theme */
onPreview?: (colors: ThemeColors, mode: EffectiveMode) => void;
/** Callback to stop preview */
onStopPreview?: () => void;
/** Show save button */
showSaveButton?: boolean;
/** Is saving in progress */
isSaving?: boolean;
}
let {
initialTheme,
effectiveMode = 'light',
onThemeChange,
onSave,
onPreview,
onStopPreview,
showSaveButton = true,
isSaving = false,
}: Props = $props();
// Get default colors from a base variant
function getDefaultColors(variant: ThemeVariant = 'ocean'): {
light: ThemeColors;
dark: ThemeColors;
} {
const definition = THEME_DEFINITIONS[variant];
if (definition) {
return { light: definition.light, dark: definition.dark };
}
// Fallback to ocean
const ocean = THEME_DEFINITIONS['ocean'];
return { light: ocean.light, dark: ocean.dark };
}
// Initialize theme state
const defaultColors = getDefaultColors(initialTheme?.baseVariant);
let name = $state(initialTheme?.name ?? '');
let description = $state(initialTheme?.description ?? '');
let baseVariant = $state<ThemeVariant | undefined>(initialTheme?.baseVariant);
let lightColors = $state<ThemeColors>(
(initialTheme?.lightColors as ThemeColors) ?? { ...defaultColors.light }
);
let darkColors = $state<ThemeColors>(
(initialTheme?.darkColors as ThemeColors) ?? { ...defaultColors.dark }
);
// UI State
let editingMode = $state<EffectiveMode>(effectiveMode);
let showExtendedColors = $state(false);
// Current colors based on editing mode
let currentColors = $derived(editingMode === 'light' ? lightColors : darkColors);
// Build the theme input object
let themeInput = $derived<Partial<CreateCustomThemeInput>>({
name: name || undefined,
description: description || undefined,
baseVariant,
lightColors,
darkColors,
});
// Notify parent of changes
$effect(() => {
onThemeChange?.(themeInput);
});
// Check if theme is valid for saving
let isValid = $derived(name.trim().length > 0);
function updateColor(key: keyof ThemeColors, value: HSLValue) {
if (editingMode === 'light') {
lightColors = { ...lightColors, [key]: value };
} else {
darkColors = { ...darkColors, [key]: value };
}
}
function handleBaseVariantChange(variant: ThemeVariant) {
baseVariant = variant;
const colors = getDefaultColors(variant);
lightColors = { ...colors.light };
darkColors = { ...colors.dark };
}
function resetToBase() {
if (baseVariant) {
const colors = getDefaultColors(baseVariant);
lightColors = { ...colors.light };
darkColors = { ...colors.dark };
}
}
function handleSave() {
if (!isValid) return;
const theme: CreateCustomThemeInput = {
name: name.trim(),
description: description.trim() || undefined,
lightColors,
darkColors,
baseVariant,
};
onSave?.(theme);
}
</script>
<div class="theme-editor">
<!-- Theme Info + Base Variant in one row -->
<section class="editor-section compact">
<div class="info-row">
<div class="form-group name-group">
<label for="theme-name" class="form-label">Name *</label>
<input
id="theme-name"
type="text"
class="form-input"
bind:value={name}
placeholder="Mein Theme"
required
/>
</div>
<div class="form-group desc-group">
<label for="theme-description" class="form-label">Beschreibung</label>
<input
id="theme-description"
type="text"
class="form-input"
bind:value={description}
placeholder="Kurze Beschreibung..."
/>
</div>
</div>
<div class="variant-row">
<span class="variant-label">Basis:</span>
<div class="variant-buttons">
{#each THEME_VARIANTS as variantName}
{@const variant = THEME_DEFINITIONS[variantName]}
<button
type="button"
class="variant-button"
class:selected={baseVariant === variantName}
onclick={() => handleBaseVariantChange(variantName)}
title={variant.label}
>
{variant.emoji}
</button>
{/each}
</div>
</div>
</section>
<!-- Colors Section -->
<section class="editor-section">
<div class="mode-header">
<h3 class="section-title">Farben</h3>
<div class="mode-toggle">
<button
type="button"
class="mode-button"
class:active={editingMode === 'light'}
onclick={() => (editingMode = 'light')}
>
<Sun size={14} weight={editingMode === 'light' ? 'fill' : 'regular'} />
Hell
</button>
<button
type="button"
class="mode-button"
class:active={editingMode === 'dark'}
onclick={() => (editingMode = 'dark')}
>
<Moon size={14} weight={editingMode === 'dark' ? 'fill' : 'regular'} />
Dunkel
</button>
</div>
</div>
<!-- Main Colors -->
<div class="colors-grid">
{#each MAIN_THEME_COLORS as colorKey}
<div class="color-item">
<ColorPicker
label={THEME_COLOR_LABELS[colorKey]}
value={currentColors[colorKey]}
onChange={(value) => updateColor(colorKey, value)}
compact
/>
</div>
{/each}
</div>
<!-- Extended Colors (collapsible) -->
<button
type="button"
class="colors-toggle"
onclick={() => (showExtendedColors = !showExtendedColors)}
>
<span>Erweiterte Farben ({EXTENDED_THEME_COLORS.length})</span>
{#if showExtendedColors}
<CaretUp size={14} />
{:else}
<CaretDown size={14} />
{/if}
</button>
{#if showExtendedColors}
<div class="colors-grid extended">
{#each EXTENDED_THEME_COLORS as colorKey}
<div class="color-item">
<ColorPicker
label={THEME_COLOR_LABELS[colorKey]}
value={currentColors[colorKey]}
onChange={(value) => updateColor(colorKey, value)}
compact
/>
</div>
{/each}
</div>
{/if}
</section>
<!-- Actions -->
<div class="editor-actions">
{#if baseVariant}
<button type="button" class="action-button secondary" onclick={resetToBase}>
<ArrowCounterClockwise size={14} />
Reset
</button>
{/if}
{#if showSaveButton && onSave}
<button
type="button"
class="action-button primary"
onclick={handleSave}
disabled={!isValid || isSaving}
>
<FloppyDisk size={14} />
{isSaving ? 'Speichern...' : 'Speichern'}
</button>
{/if}
</div>
</div>
<style>
.theme-editor {
display: flex;
flex-direction: column;
gap: 1rem;
}
.editor-section {
background: hsl(var(--surface));
border-radius: 0.5rem;
padding: 1rem;
border: 1px solid hsl(var(--border));
}
.editor-section.compact {
padding: 0.875rem 1rem;
}
.section-title {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0;
}
/* Info Row - Name + Description side by side */
.info-row {
display: grid;
grid-template-columns: 200px 1fr;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-label {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
}
.form-input {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
background: hsl(var(--input));
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
color: hsl(var(--foreground));
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.form-input:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.15);
}
/* Variant Row */
.variant-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.variant-label {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
}
.variant-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.variant-button {
width: 1.75rem;
height: 1.75rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
background: hsl(var(--muted));
border: 1px solid transparent;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s;
}
.variant-button:hover {
background: hsl(var(--muted) / 0.7);
transform: scale(1.05);
}
.variant-button.selected {
background: hsl(var(--primary) / 0.15);
border-color: hsl(var(--primary));
}
/* Mode Toggle */
.mode-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.mode-toggle {
display: flex;
background: hsl(var(--muted));
border-radius: 0.375rem;
padding: 0.125rem;
}
.mode-button {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
background: transparent;
border: none;
border-radius: 0.25rem;
color: hsl(var(--muted-foreground));
cursor: pointer;
transition: all 0.15s;
}
.mode-button:hover {
color: hsl(var(--foreground));
}
.mode-button.active {
background: hsl(var(--surface));
color: hsl(var(--foreground));
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
/* Colors Grid */
.colors-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.5rem;
}
.colors-grid.extended {
margin-top: 0.5rem;
}
.color-item {
background: hsl(var(--background));
padding: 0.5rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--border));
}
/* Colors Toggle */
.colors-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.5rem 0.75rem;
margin-top: 0.75rem;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.5);
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s;
}
.colors-toggle:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
/* Actions */
.editor-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.action-button {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-weight: 500;
background: hsl(var(--muted));
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
color: hsl(var(--foreground));
cursor: pointer;
transition: all 0.15s;
}
.action-button:hover:not(:disabled) {
background: hsl(var(--muted) / 0.8);
}
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-button.primary {
background: hsl(var(--primary));
border-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.action-button.primary:hover:not(:disabled) {
background: hsl(var(--primary) / 0.9);
}
.action-button.secondary {
background: transparent;
}
/* Responsive */
@media (max-width: 640px) {
.info-row {
grid-template-columns: 1fr;
}
.mode-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.colors-grid {
grid-template-columns: 1fr;
}
.editor-actions {
flex-wrap: wrap;
}
.action-button {
flex: 1;
justify-content: center;
}
}
</style>

View file

@ -0,0 +1,485 @@
<script lang="ts">
import type { ThemeColors, EffectiveMode } from '@manacore/shared-theme';
import {
Bell,
Heart,
MagnifyingGlass,
House,
User,
Gear,
Check,
X,
Sun,
Moon,
} from '@manacore/shared-icons';
interface Props {
/** Theme colors to preview */
colors: ThemeColors;
/** Preview mode (light/dark) */
mode?: EffectiveMode;
/** Preview title */
title?: string;
/** Callback when mode changes */
onModeChange?: (mode: EffectiveMode) => void;
}
let { colors, mode = 'light', title = 'Live-Vorschau', onModeChange }: Props = $props();
// Map from camelCase to CSS variable names
const colorToCssVar: Record<string, string> = {
primary: 'primary',
primaryForeground: 'primary-foreground',
secondary: 'secondary',
secondaryForeground: 'secondary-foreground',
background: 'background',
foreground: 'foreground',
surface: 'surface',
surfaceHover: 'surface-hover',
surfaceElevated: 'surface-elevated',
muted: 'muted',
mutedForeground: 'muted-foreground',
border: 'border',
input: 'input',
ring: 'ring',
success: 'success',
successForeground: 'success-foreground',
warning: 'warning',
warningForeground: 'warning-foreground',
error: 'error',
errorForeground: 'error-foreground',
info: 'info',
infoForeground: 'info-foreground',
};
// Convert colors to CSS variables for inline style
function colorsToStyle(colors: ThemeColors): string {
return Object.entries(colors)
.map(([key, value]) => {
const cssVar = colorToCssVar[key] || key.replace(/([A-Z])/g, '-$1').toLowerCase();
return `--${cssVar}: ${value}`;
})
.join('; ');
}
let styleVars = $derived(colorsToStyle(colors));
</script>
<div class="preview-container" class:dark={mode === 'dark'}>
<div class="preview-header">
<span class="preview-title">{title}</span>
{#if onModeChange}
<div class="mode-toggle">
<button
type="button"
class="mode-btn"
class:active={mode === 'light'}
onclick={() => onModeChange?.('light')}
aria-label="Hell"
>
<Sun size={14} weight={mode === 'light' ? 'fill' : 'regular'} />
</button>
<button
type="button"
class="mode-btn"
class:active={mode === 'dark'}
onclick={() => onModeChange?.('dark')}
aria-label="Dunkel"
>
<Moon size={14} weight={mode === 'dark' ? 'fill' : 'regular'} />
</button>
</div>
{:else}
<span class="preview-mode">{mode === 'light' ? 'Hell' : 'Dunkel'}</span>
{/if}
</div>
<div class="preview-frame" style={styleVars}>
<!-- App Header -->
<div class="app-header">
<div class="app-logo">
<div class="logo-icon"></div>
<span class="app-name">Mana App</span>
</div>
<div class="header-actions">
<button class="icon-button">
<MagnifyingGlass size={16} />
</button>
<button class="icon-button">
<Bell size={16} />
</button>
</div>
</div>
<!-- Main Content -->
<div class="app-content">
<!-- Card -->
<div class="preview-card">
<div class="card-header">
<div class="avatar"></div>
<div class="card-info">
<div class="card-title">Max Mustermann</div>
<div class="card-subtitle">Beispiel-Kontakt</div>
</div>
<button class="icon-button favorite">
<Heart size={16} weight="fill" />
</button>
</div>
<div class="card-content">
<p class="card-text">Dies ist eine Vorschau, wie dein Theme in der App aussehen wird.</p>
</div>
<div class="card-actions">
<button class="btn btn-primary">
<Check size={14} />
Bestätigen
</button>
<button class="btn btn-secondary">
<X size={14} />
Abbrechen
</button>
</div>
</div>
<!-- Status Badges -->
<div class="status-row">
<span class="badge success">Erfolgreich</span>
<span class="badge warning">Ausstehend</span>
<span class="badge error">Fehler</span>
</div>
<!-- Input Preview -->
<div class="input-preview">
<input type="text" class="preview-input" placeholder="Suchbegriff eingeben..." />
</div>
</div>
<!-- Bottom Navigation -->
<div class="app-nav">
<button class="nav-item active">
<House size={18} weight="fill" />
<span>Start</span>
</button>
<button class="nav-item">
<User size={18} />
<span>Profil</span>
</button>
<button class="nav-item">
<Gear size={18} />
<span>Einstellungen</span>
</button>
</div>
</div>
</div>
<style>
.preview-container {
border-radius: 0.75rem;
overflow: hidden;
border: 1px solid hsl(var(--border));
background: hsl(var(--surface));
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: hsl(var(--muted));
border-bottom: 1px solid hsl(var(--border));
}
.preview-title {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.preview-mode {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
padding: 0.25rem 0.5rem;
background: hsl(var(--surface));
border-radius: 0.25rem;
}
.mode-toggle {
display: flex;
background: hsl(var(--surface));
border-radius: 0.375rem;
padding: 0.125rem;
gap: 0.125rem;
}
.mode-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.5rem;
background: transparent;
border: none;
border-radius: 0.25rem;
color: hsl(var(--muted-foreground));
cursor: pointer;
transition: all 0.15s;
}
.mode-btn:hover {
color: hsl(var(--foreground));
}
.mode-btn.active {
background: hsl(var(--background));
color: hsl(var(--foreground));
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
/* Preview Frame - uses inline CSS variables */
.preview-frame {
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.8125rem;
min-height: 420px;
display: flex;
flex-direction: column;
}
/* App Header */
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: hsl(var(--surface));
border-bottom: 1px solid hsl(var(--border));
}
.app-logo {
display: flex;
align-items: center;
gap: 0.5rem;
}
.logo-icon {
width: 1.25rem;
height: 1.25rem;
background: hsl(var(--primary));
border-radius: 0.25rem;
}
.app-name {
font-weight: 600;
color: hsl(var(--foreground));
}
.header-actions {
display: flex;
gap: 0.25rem;
}
.icon-button {
width: 1.75rem;
height: 1.75rem;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 0.375rem;
color: hsl(var(--muted-foreground));
cursor: pointer;
transition: all 0.15s;
}
.icon-button:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.icon-button.favorite {
color: hsl(var(--primary));
}
/* App Content */
.app-content {
flex: 1;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Preview Card */
.preview-card {
background: hsl(var(--surface));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
overflow: hidden;
}
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem;
border-bottom: 1px solid hsl(var(--border));
}
.avatar {
width: 1.75rem;
height: 1.75rem;
background: hsl(var(--muted));
border-radius: 50%;
flex-shrink: 0;
}
.card-info {
flex: 1;
min-width: 0;
}
.card-title {
font-weight: 600;
color: hsl(var(--foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-subtitle {
font-size: 0.625rem;
color: hsl(var(--muted-foreground));
}
.card-content {
padding: 0.625rem;
}
.card-text {
color: hsl(var(--muted-foreground));
line-height: 1.4;
}
.card-actions {
display: flex;
gap: 0.5rem;
padding: 0.625rem;
border-top: 1px solid hsl(var(--border));
}
/* Buttons */
.btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.625rem;
font-size: 0.625rem;
font-weight: 500;
border-radius: 0.375rem;
border: none;
cursor: pointer;
transition: all 0.15s;
}
.btn-primary {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.btn-primary:hover {
background: hsl(var(--primary) / 0.9);
}
.btn-secondary {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.btn-secondary:hover {
background: hsl(var(--muted) / 0.8);
}
/* Status Row */
.status-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.badge {
padding: 0.25rem 0.5rem;
font-size: 0.625rem;
font-weight: 500;
border-radius: 0.25rem;
}
.badge.success {
background: hsl(var(--success) / 0.15);
color: hsl(var(--success));
}
.badge.warning {
background: hsl(var(--warning) / 0.15);
color: hsl(var(--warning));
}
.badge.error {
background: hsl(var(--error) / 0.15);
color: hsl(var(--error));
}
/* Input Preview */
.input-preview {
margin-top: auto;
}
.preview-input {
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
background: hsl(var(--input));
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
color: hsl(var(--foreground));
}
.preview-input::placeholder {
color: hsl(var(--muted-foreground));
}
.preview-input:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--ring) / 0.2);
}
/* Bottom Navigation */
.app-nav {
display: flex;
background: hsl(var(--surface));
border-top: 1px solid hsl(var(--border));
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
padding: 0.5rem;
font-size: 0.5rem;
background: transparent;
border: none;
color: hsl(var(--muted-foreground));
cursor: pointer;
transition: all 0.15s;
}
.nav-item:hover {
color: hsl(var(--foreground));
}
.nav-item.active {
color: hsl(var(--primary));
}
</style>

View file

@ -12,8 +12,19 @@ export { default as ThemeGrid } from './components/ThemeGrid.svelte';
export { default as A11ySettings } from './components/A11ySettings.svelte';
export { default as A11yQuickToggles } from './components/A11yQuickToggles.svelte';
// Theme Editor Components
export { default as ColorPicker } from './components/editor/ColorPicker.svelte';
export { default as ThemeEditor } from './components/editor/ThemeEditor.svelte';
export { default as ThemeLivePreview } from './components/editor/ThemeLivePreview.svelte';
// Community Theme Components
export { default as ThemeCommunityCard } from './components/community/ThemeCommunityCard.svelte';
export { default as CommunityThemeGallery } from './components/community/CommunityThemeGallery.svelte';
// Pages
export { default as ThemePage } from './pages/ThemePage.svelte';
export { default as ThemeEditorPage } from './pages/ThemeEditorPage.svelte';
export { default as CommunityThemesPage } from './pages/CommunityThemesPage.svelte';
// Types
export type {

View file

@ -0,0 +1,312 @@
<script lang="ts">
import type {
CommunityTheme,
CommunityThemeQuery,
PaginatedCommunityThemes,
CustomThemesStore,
} from '@manacore/shared-theme';
import { ArrowLeft, Heart, DownloadSimple } from '@manacore/shared-icons';
import CommunityThemeGallery from '../components/community/CommunityThemeGallery.svelte';
type TabType = 'browse' | 'favorites' | 'downloaded';
interface Props {
/** Custom themes store */
store: CustomThemesStore;
/** Current effective mode */
effectiveMode?: 'light' | 'dark';
/** Callback to navigate back */
onBack?: () => void;
/** Callback when a theme is selected for details */
onSelectTheme?: (theme: CommunityTheme) => void;
/** Page title */
title?: string;
/** Initial tab */
initialTab?: TabType;
}
let {
store,
effectiveMode = 'light',
onBack,
onSelectTheme,
title = 'Community Themes',
initialTab = 'browse',
}: Props = $props();
// Active tab
let activeTab = $state<TabType>(initialTab);
// Current query for browsing
let currentQuery = $state<CommunityThemeQuery>({
page: 1,
sort: 'popular',
});
// Load data based on active tab
$effect(() => {
if (activeTab === 'browse') {
store.browseCommunity(currentQuery);
} else if (activeTab === 'favorites') {
store.loadFavorites();
} else if (activeTab === 'downloaded') {
store.loadDownloaded();
}
});
function handleQueryChange(query: CommunityThemeQuery) {
currentQuery = query;
store.browseCommunity(query);
}
async function handleDownload(theme: CommunityTheme) {
await store.downloadTheme(theme.id);
}
async function handleToggleFavorite(theme: CommunityTheme) {
await store.toggleFavorite(theme.id);
}
async function handleRate(theme: CommunityTheme, rating: number) {
await store.rateTheme(theme.id, rating);
}
function handleApplyTheme(theme: CommunityTheme) {
store.applyCustomTheme(theme);
}
// Tab definitions
const tabs: { id: TabType; label: string; icon: typeof Heart }[] = [
{ id: 'browse', label: 'Entdecken', icon: DownloadSimple },
{ id: 'favorites', label: 'Favoriten', icon: Heart },
{ id: 'downloaded', label: 'Installiert', icon: DownloadSimple },
];
// Get themes for current tab
let displayThemes = $derived(() => {
switch (activeTab) {
case 'browse':
return store.communityThemes;
case 'favorites':
return store.favorites;
case 'downloaded':
return store.downloaded;
default:
return [];
}
});
</script>
<div class="min-h-screen bg-background">
<!-- Header -->
<header class="flex items-start gap-4 p-6 border-b border-border bg-surface">
{#if onBack}
<button
type="button"
class="flex items-center justify-center w-10 h-10 bg-muted rounded-lg text-foreground hover:bg-muted/80 transition-colors flex-shrink-0"
onclick={onBack}
aria-label="Zurück"
>
<ArrowLeft size={20} weight="bold" />
</button>
{/if}
<div class="flex-1">
<h1 class="text-2xl font-bold text-foreground">{title}</h1>
<p class="text-sm text-muted-foreground mt-1">Entdecke von der Community erstellte Themes</p>
</div>
</header>
<!-- Tabs -->
<div class="bg-surface border-b border-border px-6">
<nav class="flex gap-2 overflow-x-auto" role="tablist">
{#each tabs as tab}
<button
type="button"
class="flex items-center gap-2 px-5 py-4 text-sm font-medium border-b-2 whitespace-nowrap transition-colors
{activeTab === tab.id
? 'text-primary border-primary'
: 'text-muted-foreground border-transparent hover:text-foreground'}"
onclick={() => (activeTab = tab.id)}
role="tab"
aria-selected={activeTab === tab.id}
>
<svelte:component
this={tab.icon}
size={16}
weight={activeTab === tab.id ? 'fill' : 'regular'}
/>
{tab.label}
{#if tab.id === 'favorites' && store.favorites.length > 0}
<span
class="px-2 py-0.5 text-xs font-semibold rounded-full
{activeTab === tab.id ? 'bg-primary/10 text-primary' : 'bg-muted'}"
>
{store.favorites.length}
</span>
{:else if tab.id === 'downloaded' && store.downloaded.length > 0}
<span
class="px-2 py-0.5 text-xs font-semibold rounded-full
{activeTab === tab.id ? 'bg-primary/10 text-primary' : 'bg-muted'}"
>
{store.downloaded.length}
</span>
{/if}
</button>
{/each}
</nav>
</div>
<!-- Content -->
<div class="p-6 max-w-6xl mx-auto">
{#if activeTab === 'browse'}
<CommunityThemeGallery
themes={store.communityThemes}
pagination={store.pagination}
{currentQuery}
loading={store.loading}
{effectiveMode}
onQueryChange={handleQueryChange}
{onSelectTheme}
onDownloadTheme={handleDownload}
onToggleFavorite={handleToggleFavorite}
onRateTheme={handleRate}
/>
{:else if activeTab === 'favorites'}
{#if store.favorites.length === 0 && !store.loading}
<div class="text-center py-16 text-muted-foreground">
<Heart size={48} weight="light" class="mx-auto mb-4" />
<h3 class="text-xl font-semibold text-foreground mb-2">Keine Favoriten</h3>
<p class="text-sm mb-6">Themes, die du favorisierst, werden hier angezeigt.</p>
<button
type="button"
class="px-5 py-2.5 text-sm font-medium bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
onclick={() => (activeTab = 'browse')}
>
Themes entdecken
</button>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each store.favorites as theme (theme.id)}
{@const colors = effectiveMode === 'dark' ? theme.darkColors : theme.lightColors}
<div
class="cursor-pointer outline-none group"
onclick={() => onSelectTheme?.(theme)}
onkeypress={(e) => e.key === 'Enter' && onSelectTheme?.(theme)}
role="button"
tabindex="0"
>
<div
class="bg-surface border border-border rounded-xl p-4 flex items-center gap-4 transition-all hover:border-border-strong hover:shadow-sm group-focus-visible:ring-2 group-focus-visible:ring-primary group-focus-visible:ring-offset-2"
>
<div class="flex gap-1">
<div
class="w-5 h-5 rounded-full border border-border"
style="background-color: hsl({colors.primary})"
></div>
<div
class="w-5 h-5 rounded-full border border-border"
style="background-color: hsl({colors.background})"
></div>
<div
class="w-5 h-5 rounded-full border border-border"
style="background-color: hsl({colors.surface})"
></div>
</div>
<div class="flex-1 flex items-center gap-2 min-w-0">
<span class="text-xl">{theme.emoji}</span>
<span class="font-semibold text-foreground truncate">{theme.name}</span>
</div>
<div class="flex gap-2">
<button
type="button"
class="px-3 py-2 text-xs font-medium bg-muted border border-border rounded-md text-foreground hover:bg-muted/80 transition-colors"
onclick={(e) => {
e.stopPropagation();
handleApplyTheme(theme);
}}
>
Anwenden
</button>
<button
type="button"
class="p-2 text-red-500 bg-muted border border-border rounded-md hover:bg-red-500/10 transition-colors"
onclick={(e) => {
e.stopPropagation();
handleToggleFavorite(theme);
}}
>
<Heart size={18} weight="fill" />
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
{:else if activeTab === 'downloaded'}
{#if store.downloaded.length === 0 && !store.loading}
<div class="text-center py-16 text-muted-foreground">
<DownloadSimple size={48} weight="light" class="mx-auto mb-4" />
<h3 class="text-xl font-semibold text-foreground mb-2">Keine installierten Themes</h3>
<p class="text-sm mb-6">Themes, die du installierst, werden hier angezeigt.</p>
<button
type="button"
class="px-5 py-2.5 text-sm font-medium bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
onclick={() => (activeTab = 'browse')}
>
Themes entdecken
</button>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each store.downloaded as theme (theme.id)}
{@const colors = effectiveMode === 'dark' ? theme.darkColors : theme.lightColors}
<div
class="cursor-pointer outline-none group"
onclick={() => onSelectTheme?.(theme)}
onkeypress={(e) => e.key === 'Enter' && onSelectTheme?.(theme)}
role="button"
tabindex="0"
>
<div
class="bg-surface border border-border rounded-xl p-4 flex items-center gap-4 transition-all hover:border-border-strong hover:shadow-sm group-focus-visible:ring-2 group-focus-visible:ring-primary group-focus-visible:ring-offset-2"
>
<div class="flex gap-1">
<div
class="w-5 h-5 rounded-full border border-border"
style="background-color: hsl({colors.primary})"
></div>
<div
class="w-5 h-5 rounded-full border border-border"
style="background-color: hsl({colors.background})"
></div>
<div
class="w-5 h-5 rounded-full border border-border"
style="background-color: hsl({colors.surface})"
></div>
</div>
<div class="flex-1 flex items-center gap-2 min-w-0">
<span class="text-xl">{theme.emoji}</span>
<span class="font-semibold text-foreground truncate">{theme.name}</span>
</div>
<div class="flex gap-2">
<button
type="button"
class="px-3 py-2 text-xs font-medium bg-primary text-primary-foreground border border-primary rounded-md hover:bg-primary/90 transition-colors"
onclick={(e) => {
e.stopPropagation();
handleApplyTheme(theme);
}}
>
Anwenden
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>
</div>

View file

@ -0,0 +1,276 @@
<script lang="ts">
import type {
ThemeColors,
EffectiveMode,
CreateCustomThemeInput,
CustomTheme,
} from '@manacore/shared-theme';
import { ArrowLeft } from '@manacore/shared-icons';
import ThemeEditor from '../components/editor/ThemeEditor.svelte';
import ThemeLivePreview from '../components/editor/ThemeLivePreview.svelte';
interface Props {
/** Theme to edit (for update mode) */
editTheme?: CustomTheme;
/** Current effective mode */
effectiveMode?: EffectiveMode;
/** Callback when theme is saved */
onSave?: (theme: CreateCustomThemeInput) => Promise<void>;
/** Callback to navigate back */
onBack?: () => void;
/** Is saving in progress */
isSaving?: boolean;
/** Page title */
title?: string;
}
let {
editTheme,
effectiveMode = 'light',
onSave,
onBack,
isSaving = false,
title,
}: Props = $props();
// Track current theme state for preview
let currentTheme = $state<Partial<CreateCustomThemeInput>>(
editTheme
? {
name: editTheme.name,
description: editTheme.description,
emoji: editTheme.emoji,
icon: editTheme.icon,
lightColors: editTheme.lightColors,
darkColors: editTheme.darkColors,
baseVariant: editTheme.baseVariant,
}
: {}
);
// Preview mode state - syncs with editor mode
let previewMode = $state<EffectiveMode>(effectiveMode);
// Get colors for preview - always show current theme colors
let displayColors = $derived<ThemeColors | null>(
currentTheme.lightColors && currentTheme.darkColors
? previewMode === 'dark'
? (currentTheme.darkColors as ThemeColors)
: (currentTheme.lightColors as ThemeColors)
: null
);
function handleThemeChange(theme: Partial<CreateCustomThemeInput>) {
currentTheme = theme;
}
function handlePreview(colors: ThemeColors, mode: EffectiveMode) {
// Update preview mode when user clicks preview in editor
previewMode = mode;
}
function handleStopPreview() {
// No-op - preview always shows current colors
}
async function handleSave(theme: CreateCustomThemeInput) {
await onSave?.(theme);
}
let pageTitle = $derived(
title ?? (editTheme ? `"${editTheme.name}" bearbeiten` : 'Neues Theme erstellen')
);
</script>
<div class="editor-page">
<!-- Header -->
<header class="page-header">
{#if onBack}
<button type="button" class="back-btn" onclick={onBack} aria-label="Zurück">
<ArrowLeft size={20} weight="bold" />
</button>
{/if}
<div class="header-content">
<h1 class="page-title">{pageTitle}</h1>
<p class="page-subtitle">Gestalte dein eigenes Theme mit individuellen Farben</p>
</div>
</header>
<!-- Split Layout: Editor + Preview -->
<div class="editor-layout">
<!-- Editor Panel -->
<div class="editor-panel">
<ThemeEditor
initialTheme={editTheme
? {
name: editTheme.name,
description: editTheme.description,
emoji: editTheme.emoji,
icon: editTheme.icon,
lightColors: editTheme.lightColors,
darkColors: editTheme.darkColors,
baseVariant: editTheme.baseVariant,
}
: undefined}
{effectiveMode}
onThemeChange={handleThemeChange}
onSave={handleSave}
onPreview={handlePreview}
onStopPreview={handleStopPreview}
{isSaving}
/>
</div>
<!-- Preview Panel -->
<div class="preview-panel">
<div class="preview-sticky">
{#if displayColors}
<ThemeLivePreview
colors={displayColors}
mode={previewMode}
onModeChange={(m) => (previewMode = m)}
/>
{:else}
<div class="preview-placeholder">
<p>Wähle eine Basis-Variante oder passe Farben an, um eine Vorschau zu sehen</p>
</div>
{/if}
</div>
</div>
</div>
</div>
<style>
.editor-page {
min-height: 100vh;
background: hsl(var(--background));
}
/* Header */
.page-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--surface));
}
.back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: hsl(var(--muted));
border: none;
border-radius: 0.375rem;
color: hsl(var(--foreground));
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.back-btn:hover {
background: hsl(var(--muted) / 0.8);
}
.header-content {
flex: 1;
}
.page-title {
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0;
}
.page-subtitle {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
margin: 0.125rem 0 0;
}
/* Layout - Full width on desktop */
.editor-layout {
display: grid;
grid-template-columns: 1fr 400px;
gap: 1.5rem;
padding: 1.5rem;
}
@media (min-width: 1400px) {
.editor-layout {
grid-template-columns: 1fr 440px;
padding: 1.5rem 2.5rem;
}
}
@media (min-width: 1600px) {
.editor-layout {
grid-template-columns: 1fr 480px;
padding: 1.5rem 3rem;
}
}
@media (min-width: 1920px) {
.editor-layout {
grid-template-columns: 1fr 520px;
padding: 2rem 4rem;
}
}
.editor-panel {
min-width: 0;
}
.preview-panel {
min-width: 0;
}
.preview-sticky {
position: sticky;
top: 1.5rem;
}
.preview-placeholder {
background: hsl(var(--surface));
border: 1px dashed hsl(var(--border));
border-radius: 0.5rem;
padding: 2rem 1rem;
text-align: center;
}
.preview-placeholder p {
color: hsl(var(--muted-foreground));
font-size: 0.8125rem;
margin: 0;
}
/* Responsive */
@media (max-width: 1024px) {
.editor-layout {
grid-template-columns: 1fr;
}
.preview-sticky {
position: static;
}
}
@media (max-width: 640px) {
.page-header {
padding: 1rem;
}
.page-title {
font-size: 1.25rem;
}
.editor-layout {
padding: 1rem;
gap: 1rem;
}
}
</style>

View file

@ -1,11 +1,29 @@
<script lang="ts">
import type { ThemeVariant, ThemeMode, A11yStore } from '@manacore/shared-theme';
import { ArrowLeft, Sun, Moon, Desktop } from '@manacore/shared-icons';
import type {
ThemeVariant,
ThemeMode,
A11yStore,
CustomThemesStore,
CustomTheme,
UserSettingsStore,
} from '@manacore/shared-theme';
import {
ArrowLeft,
Sun,
Moon,
Desktop,
Plus,
PaintBrush,
Users,
Palette,
} from '@manacore/shared-icons';
import type { ThemeCardData, ThemePageTranslations, A11yTranslations } from '../types';
import { defaultTranslations, defaultA11yTranslations } from '../types';
import ThemeGrid from '../components/ThemeGrid.svelte';
import A11ySettings from '../components/A11ySettings.svelte';
type TabType = 'themes' | 'custom' | 'community';
interface Props {
// Theme Store Integration
currentVariant: ThemeVariant;
@ -36,6 +54,18 @@
a11yStore?: A11yStore;
showA11ySettings?: boolean;
a11yTranslations?: Partial<A11yTranslations>;
// Custom Themes (new)
customThemesStore?: CustomThemesStore;
showCustomThemes?: boolean;
onCreateTheme?: () => void;
onEditTheme?: (theme: CustomTheme) => void;
onCommunityThemes?: () => void;
// Theme Pinning (user settings)
userSettingsStore?: UserSettingsStore;
pinnedThemes?: ThemeVariant[];
onTogglePin?: (variant: ThemeVariant) => void;
}
let {
@ -55,8 +85,26 @@
a11yStore,
showA11ySettings = false,
a11yTranslations = {},
customThemesStore,
showCustomThemes = false,
onCreateTheme,
onEditTheme,
onCommunityThemes,
userSettingsStore,
pinnedThemes = [],
onTogglePin,
}: Props = $props();
// Active tab state
let activeTab = $state<TabType>('themes');
// Load custom themes when tab becomes active
$effect(() => {
if (activeTab === 'custom' && customThemesStore) {
customThemesStore.loadCustomThemes();
}
});
// Merge translations with defaults
const t = $derived({ ...defaultTranslations, ...translations });
const a11yT = $derived({ ...defaultA11yTranslations, ...a11yTranslations });
@ -66,12 +114,29 @@
{ mode: 'dark', icon: Moon, label: t.darkMode },
{ mode: 'system', icon: Desktop, label: t.systemMode },
]);
// Tab definitions
const tabs: { id: TabType; label: string; icon: typeof Palette }[] = [
{ id: 'themes', label: 'Themes', icon: Palette },
{ id: 'custom', label: 'Meine Themes', icon: PaintBrush },
{ id: 'community', label: 'Community', icon: Users },
];
function handleApplyCustomTheme(theme: CustomTheme) {
customThemesStore?.applyCustomTheme(theme);
}
async function handleDeleteTheme(theme: CustomTheme) {
if (confirm(`Theme "${theme.name}" wirklich löschen?`)) {
await customThemesStore?.deleteTheme(theme.id);
}
}
</script>
<div class="min-h-screen bg-background">
<div class="max-w-4xl mx-auto px-4 py-8">
<!-- Header -->
<header class="mb-8">
<header class="mb-6">
<div class="flex items-center gap-3 mb-2">
{#if showBackButton && onBack}
<button
@ -93,9 +158,34 @@
</p>
</header>
<!-- Tabs (if custom themes enabled) -->
{#if showCustomThemes && customThemesStore}
<nav class="flex gap-1 mb-6 p-1 bg-muted rounded-lg" role="tablist">
{#each tabs as tab}
<button
type="button"
class="flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors flex-1 justify-center
{activeTab === tab.id
? 'bg-surface text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
onclick={() => (activeTab = tab.id)}
role="tab"
aria-selected={activeTab === tab.id}
>
<svelte:component
this={tab.icon}
size={16}
weight={activeTab === tab.id ? 'fill' : 'regular'}
/>
{tab.label}
</button>
{/each}
</nav>
{/if}
<!-- Mode Selector -->
{#if showModeSelector && onModeChange}
<section class="mb-8">
<section class="mb-6">
<h2 class="text-sm font-medium text-muted-foreground mb-3">
{t.modeLabel}
</h2>
@ -117,20 +207,154 @@
</section>
{/if}
<!-- Theme Grid -->
<section>
<h2 class="text-sm font-medium text-muted-foreground mb-4">
{t.currentTheme}
</h2>
<ThemeGrid
{currentVariant}
onSelect={onSelectTheme}
{themes}
onUnlock={onUnlockTheme}
{showLockedThemes}
{translations}
/>
</section>
<!-- Tab Content -->
{#if !showCustomThemes || activeTab === 'themes'}
<!-- Theme Grid -->
<section>
<h2 class="text-sm font-medium text-muted-foreground mb-4">
{t.currentTheme}
</h2>
<ThemeGrid
{currentVariant}
onSelect={onSelectTheme}
{themes}
onUnlock={onUnlockTheme}
{showLockedThemes}
{translations}
{pinnedThemes}
{onTogglePin}
/>
</section>
{:else if activeTab === 'custom' && customThemesStore}
<!-- Custom Themes -->
<section>
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-medium text-muted-foreground">Meine Themes</h2>
{#if onCreateTheme}
<button
type="button"
onclick={onCreateTheme}
class="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus size={16} />
Neues Theme
</button>
{/if}
</div>
{#if customThemesStore.loading}
<div class="text-center py-8 text-muted-foreground">Lade...</div>
{:else if customThemesStore.customThemes.length === 0}
<div class="text-center py-12 border border-dashed border-border rounded-xl">
<PaintBrush size={48} class="mx-auto mb-4 text-muted-foreground" weight="light" />
<h3 class="text-lg font-semibold text-foreground mb-2">Noch keine eigenen Themes</h3>
<p class="text-muted-foreground mb-4">
Erstelle dein erstes eigenes Theme mit individuellen Farben.
</p>
{#if onCreateTheme}
<button
type="button"
onclick={onCreateTheme}
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus size={16} />
Theme erstellen
</button>
{/if}
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2">
{#each customThemesStore.customThemes as theme (theme.id)}
<div class="bg-surface border border-border rounded-xl overflow-hidden">
<!-- Color preview bar -->
<div class="h-8 flex">
<div
class="flex-1"
style="background-color: hsl({theme.lightColors.primary})"
></div>
<div
class="flex-1"
style="background-color: hsl({theme.lightColors.background})"
></div>
<div
class="flex-1"
style="background-color: hsl({theme.lightColors.surface})"
></div>
<div
class="flex-1"
style="background-color: hsl({theme.lightColors.foreground})"
></div>
</div>
<div class="p-4">
<div class="flex items-center gap-3 mb-2">
<span class="text-xl">{theme.emoji}</span>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-foreground truncate">{theme.name}</h3>
{#if theme.description}
<p class="text-sm text-muted-foreground truncate">{theme.description}</p>
{/if}
</div>
{#if theme.isPublished}
<span
class="px-2 py-0.5 text-xs font-medium bg-success/10 text-success rounded"
>
Veröffentlicht
</span>
{/if}
</div>
<div class="flex gap-2 mt-3">
<button
type="button"
onclick={() => handleApplyCustomTheme(theme)}
class="flex-1 px-3 py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
>
Anwenden
</button>
{#if onEditTheme}
<button
type="button"
onclick={() => onEditTheme(theme)}
class="px-3 py-2 bg-muted text-foreground rounded-lg text-sm font-medium hover:bg-muted/80 transition-colors"
>
Bearbeiten
</button>
{/if}
<button
type="button"
onclick={() => handleDeleteTheme(theme)}
class="px-3 py-2 bg-muted text-error rounded-lg text-sm font-medium hover:bg-error/10 transition-colors"
>
Löschen
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</section>
{:else if activeTab === 'community'}
<!-- Community Themes -->
<section>
<div class="text-center py-12 border border-dashed border-border rounded-xl">
<Users size={48} class="mx-auto mb-4 text-muted-foreground" weight="light" />
<h3 class="text-lg font-semibold text-foreground mb-2">Community Themes</h3>
<p class="text-muted-foreground mb-4">
Entdecke Themes, die von anderen Nutzern erstellt wurden.
</p>
{#if onCommunityThemes}
<button
type="button"
onclick={onCommunityThemes}
class="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Users size={16} />
Community durchsuchen
</button>
{/if}
</div>
</section>
{/if}
<!-- A11y Settings -->
{#if showA11ySettings && a11yStore}

View file

@ -8,6 +8,10 @@ export const THEME_VARIANTS: readonly ThemeVariant[] = [
'nature',
'stone',
'ocean',
'sunset',
'midnight',
'rose',
'lavender',
] as const;
/**
@ -194,6 +198,178 @@ const oceanDark: ThemeColors = {
ring: '199 98% 48%',
};
// ============================================================================
// Extended Themes: Sunset, Midnight, Rose, Lavender
// ============================================================================
const sunsetLight: ThemeColors = {
primary: '15 90% 55%', // Coral/Orange
primaryForeground: '0 0% 100%',
secondary: '25 100% 60%', // Warm orange
secondaryForeground: '0 0% 0%',
background: '30 50% 97%', // Warm cream
foreground: '15 50% 20%', // Dark warm brown
surface: '0 0% 100%',
surfaceHover: '30 40% 95%',
surfaceElevated: '0 0% 100%',
muted: '30 30% 93%',
mutedForeground: '15 20% 45%',
border: '30 25% 88%',
borderStrong: '30 30% 75%',
error: '0 72% 55%',
success: '145 63% 42%',
warning: '36 100% 50%',
input: '0 0% 100%',
ring: '15 90% 55%',
};
const sunsetDark: ThemeColors = {
primary: '15 85% 58%', // Brighter coral in dark
primaryForeground: '0 0% 0%',
secondary: '25 60% 35%',
secondaryForeground: '0 0% 100%',
background: '15 20% 8%', // Dark with warm tint
foreground: '0 0% 100%',
surface: '15 15% 12%',
surfaceHover: '15 15% 16%',
surfaceElevated: '15 15% 14%',
muted: '15 12% 20%',
mutedForeground: '15 10% 60%',
border: '15 12% 25%',
borderStrong: '15 12% 35%',
error: '0 72% 55%',
success: '145 63% 49%',
warning: '48 100% 50%',
input: '15 20% 14%',
ring: '15 85% 58%',
};
const midnightLight: ThemeColors = {
primary: '260 70% 55%', // Deep purple/violet
primaryForeground: '0 0% 100%',
secondary: '270 60% 70%', // Lighter purple
secondaryForeground: '0 0% 0%',
background: '260 30% 97%', // Very light purple tint
foreground: '260 50% 20%', // Dark purple text
surface: '0 0% 100%',
surfaceHover: '260 25% 95%',
surfaceElevated: '0 0% 100%',
muted: '260 20% 93%',
mutedForeground: '260 20% 45%',
border: '260 20% 88%',
borderStrong: '260 25% 75%',
error: '0 72% 55%',
success: '145 63% 42%',
warning: '36 100% 50%',
input: '0 0% 100%',
ring: '260 70% 55%',
};
const midnightDark: ThemeColors = {
primary: '260 65% 60%', // Brighter purple in dark
primaryForeground: '0 0% 100%',
secondary: '270 40% 35%',
secondaryForeground: '0 0% 100%',
background: '260 25% 7%', // Deep dark purple
foreground: '0 0% 100%',
surface: '260 20% 11%',
surfaceHover: '260 20% 15%',
surfaceElevated: '260 20% 13%',
muted: '260 15% 19%',
mutedForeground: '260 12% 60%',
border: '260 15% 24%',
borderStrong: '260 15% 34%',
error: '0 72% 55%',
success: '145 63% 49%',
warning: '48 100% 50%',
input: '260 25% 14%',
ring: '260 65% 60%',
};
const roseLight: ThemeColors = {
primary: '340 80% 55%', // Pink/Magenta
primaryForeground: '0 0% 100%',
secondary: '350 70% 70%', // Lighter pink
secondaryForeground: '0 0% 0%',
background: '340 40% 97%', // Very light pink tint
foreground: '340 50% 20%', // Dark rose text
surface: '0 0% 100%',
surfaceHover: '340 30% 95%',
surfaceElevated: '0 0% 100%',
muted: '340 25% 93%',
mutedForeground: '340 20% 45%',
border: '340 25% 88%',
borderStrong: '340 30% 75%',
error: '0 72% 55%',
success: '145 63% 42%',
warning: '36 100% 50%',
input: '0 0% 100%',
ring: '340 80% 55%',
};
const roseDark: ThemeColors = {
primary: '340 75% 60%', // Brighter pink in dark
primaryForeground: '0 0% 100%',
secondary: '350 45% 35%',
secondaryForeground: '0 0% 100%',
background: '340 20% 8%', // Dark with pink tint
foreground: '0 0% 100%',
surface: '340 15% 12%',
surfaceHover: '340 15% 16%',
surfaceElevated: '340 15% 14%',
muted: '340 12% 20%',
mutedForeground: '340 10% 60%',
border: '340 12% 25%',
borderStrong: '340 12% 35%',
error: '0 72% 55%',
success: '145 63% 49%',
warning: '48 100% 50%',
input: '340 20% 14%',
ring: '340 75% 60%',
};
const lavenderLight: ThemeColors = {
primary: '270 60% 60%', // Lavender/Light purple
primaryForeground: '0 0% 100%',
secondary: '280 50% 75%', // Softer purple
secondaryForeground: '0 0% 0%',
background: '270 35% 97%', // Very light lavender
foreground: '270 40% 22%', // Dark lavender text
surface: '0 0% 100%',
surfaceHover: '270 25% 95%',
surfaceElevated: '0 0% 100%',
muted: '270 20% 93%',
mutedForeground: '270 18% 45%',
border: '270 20% 88%',
borderStrong: '270 25% 78%',
error: '0 72% 55%',
success: '145 63% 42%',
warning: '36 100% 50%',
input: '0 0% 100%',
ring: '270 60% 60%',
};
const lavenderDark: ThemeColors = {
primary: '270 55% 65%', // Brighter lavender in dark
primaryForeground: '0 0% 0%',
secondary: '280 35% 38%',
secondaryForeground: '0 0% 100%',
background: '270 20% 8%', // Dark with lavender tint
foreground: '0 0% 100%',
surface: '270 15% 12%',
surfaceHover: '270 15% 16%',
surfaceElevated: '270 15% 14%',
muted: '270 12% 20%',
mutedForeground: '270 10% 60%',
border: '270 12% 25%',
borderStrong: '270 12% 35%',
error: '0 72% 55%',
success: '145 63% 49%',
warning: '48 100% 50%',
input: '270 20% 14%',
ring: '270 55% 65%',
};
/**
* Complete theme variant definitions
*/
@ -234,6 +410,43 @@ export const THEME_DEFINITIONS: Record<ThemeVariant, ThemeVariantDefinition> = {
light: oceanLight,
dark: oceanDark,
},
// Extended themes (not in PillNav by default, can be pinned)
sunset: {
name: 'sunset',
label: 'Sunset',
emoji: '🌅',
icon: 'sun',
hue: 15,
light: sunsetLight,
dark: sunsetDark,
},
midnight: {
name: 'midnight',
label: 'Midnight',
emoji: '🌙',
icon: 'moon',
hue: 260,
light: midnightLight,
dark: midnightDark,
},
rose: {
name: 'rose',
label: 'Rose',
emoji: '🌹',
icon: 'flower',
hue: 340,
light: roseLight,
dark: roseDark,
},
lavender: {
name: 'lavender',
label: 'Lavender',
emoji: '💜',
icon: 'sparkle',
hue: 270,
light: lavenderLight,
dark: lavenderDark,
},
};
/**

View file

@ -0,0 +1,506 @@
import type {
CustomTheme,
CommunityTheme,
CreateCustomThemeInput,
UpdateCustomThemeInput,
PublishThemeInput,
CommunityThemeQuery,
PaginatedCommunityThemes,
CustomThemesStore,
CustomThemesStoreConfig,
ThemeColors,
EffectiveMode,
} from './types';
import { isBrowser } from './utils';
/**
* Apply a custom theme's colors to the document as CSS variables
*/
function applyCustomThemeToDocument(
colors: ThemeColors,
effectiveMode: EffectiveMode = 'light'
): void {
if (!isBrowser()) return;
const root = document.documentElement;
// Apply all color variables
Object.entries(colors).forEach(([key, value]) => {
// Convert camelCase to kebab-case
const cssVar = key.replace(/([A-Z])/g, '-$1').toLowerCase();
root.style.setProperty(`--${cssVar}`, value);
});
// Set mode class
root.classList.remove('light', 'dark');
root.classList.add(effectiveMode);
// Mark as custom theme
root.setAttribute('data-custom-theme', 'true');
}
/**
* Clear custom theme and revert to standard theme
*/
function clearCustomThemeFromDocument(): void {
if (!isBrowser()) return;
const root = document.documentElement;
// Remove custom theme marker
root.removeAttribute('data-custom-theme');
// Clear inline styles (CSS vars will fall back to theme variant)
root.style.cssText = '';
}
/**
* Create a custom themes store for managing user's custom themes and community themes
*
* @example
* ```typescript
* import { createCustomThemesStore } from '@manacore/shared-theme';
* import { authStore } from '$lib/stores/auth.svelte';
*
* export const customThemesStore = createCustomThemesStore({
* authUrl: import.meta.env.PUBLIC_AUTH_URL,
* getAccessToken: () => authStore.getAccessToken(),
* });
* ```
*/
export function createCustomThemesStore(config: CustomThemesStoreConfig): CustomThemesStore {
const { authUrl, getAccessToken } = config;
// State
let customThemes = $state<CustomTheme[]>([]);
let communityThemes = $state<CommunityTheme[]>([]);
let favorites = $state<CommunityTheme[]>([]);
let downloaded = $state<CommunityTheme[]>([]);
let pagination = $state({ page: 1, totalPages: 1, total: 0 });
let loading = $state(false);
let error = $state<string | null>(null);
// Track currently applied custom theme
let appliedThemeId = $state<string | null>(null);
/**
* Make an authenticated API request
*/
async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = await getAccessToken();
if (!token) {
throw new Error('Not authenticated');
}
const url = `${authUrl}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...options.headers,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `Request failed: ${response.status}`);
}
// Handle 204 No Content
if (response.status === 204) {
return undefined as T;
}
return response.json();
}
/**
* Make a public API request (no auth required)
*/
async function publicApiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${authUrl}${endpoint}`;
const token = await getAccessToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Add auth if available (for user-specific data like favorites)
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(url, {
...options,
headers: {
...headers,
...options.headers,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `Request failed: ${response.status}`);
}
return response.json();
}
// ==================== Custom Theme Operations ====================
/**
* Load user's custom themes
*/
async function loadCustomThemes(): Promise<void> {
loading = true;
error = null;
try {
customThemes = await apiRequest<CustomTheme[]>('/api/v1/themes');
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load themes';
throw err;
} finally {
loading = false;
}
}
/**
* Create a new custom theme
*/
async function createTheme(input: CreateCustomThemeInput): Promise<CustomTheme> {
loading = true;
error = null;
try {
const theme = await apiRequest<CustomTheme>('/api/v1/themes', {
method: 'POST',
body: JSON.stringify(input),
});
customThemes = [...customThemes, theme];
return theme;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create theme';
throw err;
} finally {
loading = false;
}
}
/**
* Update an existing custom theme
*/
async function updateTheme(id: string, input: UpdateCustomThemeInput): Promise<CustomTheme> {
loading = true;
error = null;
try {
const theme = await apiRequest<CustomTheme>(`/api/v1/themes/${id}`, {
method: 'PATCH',
body: JSON.stringify(input),
});
customThemes = customThemes.map((t) => (t.id === id ? theme : t));
return theme;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to update theme';
throw err;
} finally {
loading = false;
}
}
/**
* Delete a custom theme
*/
async function deleteTheme(id: string): Promise<void> {
loading = true;
error = null;
try {
await apiRequest(`/api/v1/themes/${id}`, {
method: 'DELETE',
});
customThemes = customThemes.filter((t) => t.id !== id);
// Clear applied theme if it was the deleted one
if (appliedThemeId === id) {
clearCustomTheme();
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to delete theme';
throw err;
} finally {
loading = false;
}
}
/**
* Publish a custom theme to the community
*/
async function publishTheme(id: string, input?: PublishThemeInput): Promise<CommunityTheme> {
loading = true;
error = null;
try {
const communityTheme = await apiRequest<CommunityTheme>(`/api/v1/themes/${id}/publish`, {
method: 'POST',
body: JSON.stringify(input || {}),
});
// Update the custom theme's isPublished status
customThemes = customThemes.map((t) => (t.id === id ? { ...t, isPublished: true } : t));
return communityTheme;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to publish theme';
throw err;
} finally {
loading = false;
}
}
// ==================== Community Theme Operations ====================
/**
* Browse community themes with filtering/sorting
*/
async function browseCommunity(query?: CommunityThemeQuery): Promise<void> {
loading = true;
error = null;
try {
const params = new URLSearchParams();
if (query?.page) params.set('page', String(query.page));
if (query?.limit) params.set('limit', String(query.limit));
if (query?.sort) params.set('sort', query.sort);
if (query?.search) params.set('search', query.search);
if (query?.authorId) params.set('authorId', query.authorId);
if (query?.featuredOnly) params.set('featuredOnly', 'true');
if (query?.tags?.length) {
query.tags.forEach((tag) => params.append('tags', tag));
}
const queryString = params.toString();
const endpoint = `/api/v1/community-themes${queryString ? `?${queryString}` : ''}`;
const result = await publicApiRequest<PaginatedCommunityThemes>(endpoint);
communityThemes = result.themes;
pagination = {
page: result.page,
totalPages: result.totalPages,
total: result.total,
};
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to browse community themes';
throw err;
} finally {
loading = false;
}
}
/**
* Download/install a community theme
*/
async function downloadTheme(id: string): Promise<CommunityTheme> {
loading = true;
error = null;
try {
const theme = await apiRequest<CommunityTheme>(`/api/v1/community-themes/${id}/download`, {
method: 'POST',
});
// Update download status in community themes list
communityThemes = communityThemes.map((t) =>
t.id === id ? { ...t, isDownloaded: true, downloadCount: theme.downloadCount } : t
);
// Add to downloaded list if not already there
if (!downloaded.some((t) => t.id === id)) {
downloaded = [...downloaded, theme];
}
return theme;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to download theme';
throw err;
} finally {
loading = false;
}
}
/**
* Rate a community theme
*/
async function rateTheme(
id: string,
rating: number
): Promise<{ averageRating: number; ratingCount: number }> {
error = null;
try {
const result = await apiRequest<{ averageRating: number; ratingCount: number }>(
`/api/v1/community-themes/${id}/rate`,
{
method: 'POST',
body: JSON.stringify({ rating }),
}
);
// Update rating in community themes list
communityThemes = communityThemes.map((t) =>
t.id === id
? {
...t,
averageRating: result.averageRating,
ratingCount: result.ratingCount,
userRating: rating,
}
: t
);
return result;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to rate theme';
throw err;
}
}
/**
* Toggle favorite status for a community theme
*/
async function toggleFavorite(id: string): Promise<{ isFavorited: boolean }> {
error = null;
try {
const result = await apiRequest<{ isFavorited: boolean }>(
`/api/v1/community-themes/${id}/favorite`,
{ method: 'POST' }
);
// Update favorite status in community themes list
communityThemes = communityThemes.map((t) =>
t.id === id ? { ...t, isFavorited: result.isFavorited } : t
);
// Update favorites list
if (result.isFavorited) {
const theme = communityThemes.find((t) => t.id === id);
if (theme && !favorites.some((t) => t.id === id)) {
favorites = [...favorites, { ...theme, isFavorited: true }];
}
} else {
favorites = favorites.filter((t) => t.id !== id);
}
return result;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to toggle favorite';
throw err;
}
}
/**
* Load user's favorite themes
*/
async function loadFavorites(): Promise<void> {
loading = true;
error = null;
try {
favorites = await apiRequest<CommunityTheme[]>('/api/v1/community-themes/favorites');
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load favorites';
throw err;
} finally {
loading = false;
}
}
/**
* Load user's downloaded themes
*/
async function loadDownloaded(): Promise<void> {
loading = true;
error = null;
try {
downloaded = await apiRequest<CommunityTheme[]>('/api/v1/community-themes/downloaded');
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load downloaded themes';
throw err;
} finally {
loading = false;
}
}
// ==================== Apply Theme ====================
/**
* Apply a custom or community theme to the document
*/
function applyCustomTheme(theme: CustomTheme | CommunityTheme): void {
// Determine effective mode from system or stored preference
const effectiveMode: EffectiveMode = isBrowser()
? window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: 'light';
const colors = effectiveMode === 'dark' ? theme.darkColors : theme.lightColors;
applyCustomThemeToDocument(colors as ThemeColors, effectiveMode);
appliedThemeId = theme.id;
}
/**
* Clear the applied custom theme and revert to standard theme
*/
function clearCustomTheme(): void {
clearCustomThemeFromDocument();
appliedThemeId = null;
}
return {
get customThemes() {
return customThemes;
},
get communityThemes() {
return communityThemes;
},
get favorites() {
return favorites;
},
get downloaded() {
return downloaded;
},
get pagination() {
return pagination;
},
get loading() {
return loading;
},
get error() {
return error;
},
// Custom theme operations
loadCustomThemes,
createTheme,
updateTheme,
deleteTheme,
publishTheme,
// Community theme operations
browseCommunity,
downloadTheme,
rateTheme,
toggleFavorite,
loadFavorites,
loadDownloaded,
// Apply theme
applyCustomTheme,
clearCustomTheme,
};
}

View file

@ -28,11 +28,29 @@ export type {
StartPageConfig,
WeekStartDay,
GeneralSettings,
// Custom & Community Themes Types
ThemeColorsInput,
CustomTheme,
CreateCustomThemeInput,
UpdateCustomThemeInput,
CommunityTheme,
CommunityThemeQuery,
PaginatedCommunityThemes,
PublishThemeInput,
ThemeEditorState,
CustomThemesStore,
CustomThemesStoreConfig,
} from './types';
// User Settings Constants
export { DEFAULT_GLOBAL_SETTINGS, DEFAULT_GENERAL_SETTINGS } from './types';
// Theme Variant Categories
export { DEFAULT_THEME_VARIANTS, EXTENDED_THEME_VARIANTS } from './types';
// Custom Theme Constants
export { MAIN_THEME_COLORS, EXTENDED_THEME_COLORS, THEME_COLOR_LABELS } from './types';
// Constants
export {
THEME_VARIANTS,
@ -63,6 +81,9 @@ export { createA11yStore } from './a11y-store.svelte';
// User Settings Store
export { createUserSettingsStore } from './user-settings-store.svelte';
// Custom Themes Store
export { createCustomThemesStore } from './custom-themes-store.svelte';
// Utils
export {
isBrowser,

View file

@ -5,9 +5,28 @@ export type ThemeMode = 'light' | 'dark' | 'system';
/**
* Theme variant - visual style/color scheme
* All apps share the same 4 variants
* Default variants (shown in PillNav): lume, nature, stone, ocean
* Extended variants (only on themes page, can be pinned): sunset, midnight, rose, lavender
*/
export type ThemeVariant = 'lume' | 'nature' | 'stone' | 'ocean';
export type ThemeVariant =
| 'lume'
| 'nature'
| 'stone'
| 'ocean'
| 'sunset'
| 'midnight'
| 'rose'
| 'lavender';
/**
* Default theme variants - always visible in PillNav
*/
export const DEFAULT_THEME_VARIANTS: ThemeVariant[] = ['lume', 'nature', 'stone', 'ocean'];
/**
* Extended theme variants - only on themes page, can be pinned
*/
export const EXTENDED_THEME_VARIANTS: ThemeVariant[] = ['sunset', 'midnight', 'rose', 'lavender'];
/**
* Effective mode - the actual rendered mode (resolved from system preference)
@ -258,6 +277,8 @@ export interface ThemeSettings {
mode: ThemeMode;
/** Color scheme / variant */
colorScheme: string;
/** Pinned themes to show in PillNav (extended themes only) */
pinnedThemes?: ThemeVariant[];
}
/**
@ -303,7 +324,7 @@ export const DEFAULT_GENERAL_SETTINGS: GeneralSettings = {
*/
export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
nav: { desktopPosition: 'top', sidebarCollapsed: false },
theme: { mode: 'system', colorScheme: 'ocean' },
theme: { mode: 'system', colorScheme: 'ocean', pinnedThemes: [] },
locale: 'de',
general: DEFAULT_GENERAL_SETTINGS,
};
@ -356,3 +377,260 @@ export interface UserSettingsStoreConfig {
/** Function to get current access token */
getAccessToken: () => Promise<string | null>;
}
// ============================================================================
// Custom & Community Themes Types
// ============================================================================
/**
* Partial theme colors for API DTOs (some fields optional)
*/
export interface ThemeColorsInput {
primary: HSLValue;
primaryForeground?: HSLValue;
background: HSLValue;
foreground: HSLValue;
surface: HSLValue;
surfaceHover?: HSLValue;
surfaceElevated?: HSLValue;
muted?: HSLValue;
mutedForeground?: HSLValue;
border?: HSLValue;
borderStrong?: HSLValue;
secondary?: HSLValue;
secondaryForeground?: HSLValue;
input?: HSLValue;
ring?: HSLValue;
error: HSLValue;
success: HSLValue;
warning: HSLValue;
}
/**
* User-created custom theme
*/
export interface CustomTheme {
id: string;
userId: string;
name: string;
description?: string;
emoji: string;
icon: string;
lightColors: ThemeColors;
darkColors: ThemeColors;
baseVariant?: ThemeVariant;
isPublished: boolean;
createdAt: Date;
updatedAt: Date;
}
/**
* Input for creating a new custom theme
*/
export interface CreateCustomThemeInput {
name: string;
description?: string;
emoji?: string;
icon?: string;
lightColors: ThemeColorsInput;
darkColors: ThemeColorsInput;
baseVariant?: ThemeVariant;
}
/**
* Input for updating a custom theme
*/
export interface UpdateCustomThemeInput {
name?: string;
description?: string;
emoji?: string;
icon?: string;
lightColors?: ThemeColorsInput;
darkColors?: ThemeColorsInput;
baseVariant?: ThemeVariant;
}
/**
* Community theme shared publicly
*/
export interface CommunityTheme {
id: string;
authorId?: string;
authorName?: string;
name: string;
description?: string;
emoji: string;
icon: string;
lightColors: ThemeColors;
darkColors: ThemeColors;
baseVariant?: ThemeVariant;
downloadCount: number;
averageRating: number;
ratingCount: number;
status: 'pending' | 'approved' | 'rejected' | 'featured';
isFeatured: boolean;
tags: string[];
createdAt: Date;
publishedAt?: Date;
/** User-specific fields (when authenticated) */
isFavorited?: boolean;
isDownloaded?: boolean;
userRating?: number;
}
/**
* Query parameters for browsing community themes
*/
export interface CommunityThemeQuery {
page?: number;
limit?: number;
sort?: 'popular' | 'recent' | 'rating' | 'downloads';
search?: string;
tags?: string[];
authorId?: string;
featuredOnly?: boolean;
}
/**
* Paginated response for community themes
*/
export interface PaginatedCommunityThemes {
themes: CommunityTheme[];
total: number;
page: number;
limit: number;
totalPages: number;
}
/**
* Input for publishing a theme to the community
*/
export interface PublishThemeInput {
tags?: string[];
description?: string;
}
/**
* Theme editor state for UI
*/
export interface ThemeEditorState {
/** Theme being edited */
theme: Partial<CreateCustomThemeInput>;
/** Currently editing light or dark colors */
editingMode: EffectiveMode;
/** Currently selected color key */
selectedColorKey: keyof ThemeColors | null;
/** Is preview mode active */
isPreviewing: boolean;
/** Has unsaved changes */
isDirty: boolean;
}
/**
* Custom themes store interface
*/
export interface CustomThemesStore {
/** User's custom themes */
readonly customThemes: CustomTheme[];
/** Community themes (from current query) */
readonly communityThemes: CommunityTheme[];
/** User's favorited themes */
readonly favorites: CommunityTheme[];
/** User's downloaded themes */
readonly downloaded: CommunityTheme[];
/** Pagination info */
readonly pagination: { page: number; totalPages: number; total: number };
/** Loading state */
readonly loading: boolean;
/** Error state */
readonly error: string | null;
// Custom theme operations
loadCustomThemes: () => Promise<void>;
createTheme: (input: CreateCustomThemeInput) => Promise<CustomTheme>;
updateTheme: (id: string, input: UpdateCustomThemeInput) => Promise<CustomTheme>;
deleteTheme: (id: string) => Promise<void>;
publishTheme: (id: string, input?: PublishThemeInput) => Promise<CommunityTheme>;
// Community theme operations
browseCommunity: (query?: CommunityThemeQuery) => Promise<void>;
downloadTheme: (id: string) => Promise<CommunityTheme>;
rateTheme: (
id: string,
rating: number
) => Promise<{ averageRating: number; ratingCount: number }>;
toggleFavorite: (id: string) => Promise<{ isFavorited: boolean }>;
loadFavorites: () => Promise<void>;
loadDownloaded: () => Promise<void>;
// Apply theme
applyCustomTheme: (theme: CustomTheme | CommunityTheme) => void;
clearCustomTheme: () => void;
}
/**
* Custom themes store configuration
*/
export interface CustomThemesStoreConfig {
/** Auth service base URL */
authUrl: string;
/** Function to get current access token */
getAccessToken: () => Promise<string | null>;
/** Theme store to apply custom themes to */
themeStore?: ThemeStore;
}
/**
* Main colors for the simplified editor view
* These are the 7 most important colors users typically want to customize
*/
export const MAIN_THEME_COLORS: (keyof ThemeColors)[] = [
'primary',
'background',
'surface',
'foreground',
'error',
'success',
'warning',
];
/**
* Extended/advanced colors (collapsed by default in editor)
*/
export const EXTENDED_THEME_COLORS: (keyof ThemeColors)[] = [
'primaryForeground',
'secondary',
'secondaryForeground',
'surfaceHover',
'surfaceElevated',
'muted',
'mutedForeground',
'border',
'borderStrong',
'input',
'ring',
];
/**
* Color labels for the editor UI
*/
export const THEME_COLOR_LABELS: Record<keyof ThemeColors, string> = {
primary: 'Primary',
primaryForeground: 'Primary Text',
secondary: 'Secondary',
secondaryForeground: 'Secondary Text',
background: 'Background',
foreground: 'Text',
surface: 'Surface',
surfaceHover: 'Surface Hover',
surfaceElevated: 'Elevated Surface',
muted: 'Muted',
mutedForeground: 'Muted Text',
border: 'Border',
borderStrong: 'Border Strong',
error: 'Error',
success: 'Success',
warning: 'Warning',
input: 'Input',
ring: 'Focus Ring',
};

View file

@ -37,6 +37,17 @@
"dependencies": {
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-theme": "workspace:*"
"@manacore/shared-theme": "workspace:*",
"d3-force": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-transition": "^3.0.0",
"d3-zoom": "^3.0.0",
"lucide-svelte": "^0.468.0"
},
"devDependencies": {
"@types/d3-force": "^3.0.10",
"@types/d3-selection": "^3.0.11",
"@types/d3-transition": "^3.0.9",
"@types/d3-zoom": "^3.0.8"
}
}

View file

@ -0,0 +1,601 @@
<script lang="ts">
import { goto } from '$app/navigation';
export interface CommandBarItem {
id: string;
title: string;
subtitle?: string;
icon?: string;
imageUrl?: string;
isFavorite?: boolean;
}
export interface QuickAction {
id: string;
label: string;
href?: string;
icon: string;
shortcut?: string;
onclick?: () => void;
}
interface Props {
open: boolean;
onClose: () => void;
onSearch: (query: string) => Promise<CommandBarItem[]>;
onSelect: (item: CommandBarItem) => void;
quickActions?: QuickAction[];
placeholder?: string;
emptyText?: string;
searchingText?: string;
}
let {
open = $bindable(),
onClose,
onSearch,
onSelect,
quickActions = [],
placeholder = 'Suchen...',
emptyText = 'Keine Ergebnisse gefunden',
searchingText = 'Suche...',
}: Props = $props();
let searchQuery = $state('');
let results = $state<CommandBarItem[]>([]);
let loading = $state(false);
let selectedIndex = $state(0);
let searchTimeout: ReturnType<typeof setTimeout>;
let inputElement: HTMLInputElement;
// Reset state when modal opens
$effect(() => {
if (open) {
searchQuery = '';
results = [];
selectedIndex = 0;
setTimeout(() => inputElement?.focus(), 50);
}
});
async function handleSearch() {
clearTimeout(searchTimeout);
if (!searchQuery.trim()) {
results = [];
loading = false;
return;
}
loading = true;
searchTimeout = setTimeout(async () => {
try {
results = await onSearch(searchQuery);
selectedIndex = 0;
} catch (e) {
console.error('Search error:', e);
results = [];
} finally {
loading = false;
}
}, 150);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
const maxIndex = searchQuery.trim() ? results.length - 1 : quickActions.length - 1;
selectedIndex = Math.min(selectedIndex + 1, maxIndex);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
return;
}
if (event.key === 'Enter') {
event.preventDefault();
if (searchQuery.trim() && results.length > 0) {
selectItem(results[selectedIndex]);
} else if (!searchQuery.trim() && quickActions.length > 0) {
const action = quickActions[selectedIndex];
if (action.href) {
goto(action.href);
onClose();
} else if (action.onclick) {
action.onclick();
onClose();
}
}
return;
}
}
function selectItem(item: CommandBarItem) {
onSelect(item);
onClose();
}
function getInitials(item: CommandBarItem): string {
const parts = item.title.split(' ');
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return item.title.substring(0, 2).toUpperCase();
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function handleQuickAction(action: QuickAction) {
if (action.href) {
goto(action.href);
} else if (action.onclick) {
action.onclick();
}
onClose();
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="command-backdrop"
role="dialog"
aria-modal="true"
aria-label="Suchen"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
>
<div class="command-modal">
<!-- Search Input -->
<div class="command-input-wrapper">
<svg class="command-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
bind:this={inputElement}
type="text"
{placeholder}
bind:value={searchQuery}
oninput={handleSearch}
class="command-input"
/>
<kbd class="command-shortcut">ESC</kbd>
</div>
<!-- Results -->
{#if searchQuery.trim()}
<div class="command-results">
{#if loading}
<div class="command-loading">
<div class="loading-spinner"></div>
<span>{searchingText}</span>
</div>
{:else if results.length === 0}
<div class="command-empty">
<span>{emptyText}</span>
</div>
{:else}
{#each results as item, index (item.id)}
<button
type="button"
class="command-result"
class:selected={index === selectedIndex}
onclick={() => selectItem(item)}
onmouseenter={() => (selectedIndex = index)}
>
<div class="result-avatar">
{#if item.imageUrl}
<img
src={item.imageUrl}
alt={item.title}
class="w-full h-full rounded-full object-cover"
/>
{:else}
{getInitials(item)}
{/if}
</div>
<div class="result-info">
<div class="result-name">{item.title}</div>
{#if item.subtitle}
<div class="result-details">
<span>{item.subtitle}</span>
</div>
{/if}
</div>
{#if item.isFavorite}
<svg class="result-favorite" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
{/if}
</button>
{/each}
{/if}
</div>
{:else if quickActions.length > 0}
<!-- Quick Actions when no search -->
<div class="quick-actions-list">
{#each quickActions as action, index (action.id)}
<button
type="button"
class="quick-action"
class:selected={index === selectedIndex}
onclick={() => handleQuickAction(action)}
onmouseenter={() => (selectedIndex = index)}
>
<svg class="quick-action-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if action.icon === 'plus'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
{:else if action.icon === 'heart'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
{:else if action.icon === 'tag'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
{:else if action.icon === 'upload'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
{:else if action.icon === 'calendar'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
{:else if action.icon === 'clock'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
{:else if action.icon === 'check'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
{:else if action.icon === 'settings'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
{:else if action.icon === 'list'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
{/if}
</svg>
<span>{action.label}</span>
{#if action.shortcut}
<kbd>{action.shortcut}</kbd>
{/if}
</button>
{/each}
</div>
{/if}
<!-- Footer -->
<div class="command-footer">
<div class="footer-hints">
<span><kbd>↑↓</kbd> Navigation</span>
<span><kbd></kbd> Öffnen</span>
<span><kbd>ESC</kbd> Schließen</span>
</div>
</div>
</div>
</div>
{/if}
<style>
.command-backdrop {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 15vh;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.command-modal {
width: 100%;
max-width: 560px;
margin: 0 1rem;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.1);
overflow: hidden;
animation: slideIn 0.2s ease;
color: #e5e5e5;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.command-input-wrapper {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid #333;
}
.command-icon {
width: 1.25rem;
height: 1.25rem;
color: #888;
flex-shrink: 0;
}
.command-input {
flex: 1;
border: none;
background: transparent;
font-size: 1rem;
color: #fff;
outline: none;
}
.command-input::placeholder {
color: #666;
}
.command-shortcut {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-family: inherit;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 4px;
color: #888;
}
.command-results {
max-height: 320px;
overflow-y: auto;
}
.command-loading,
.command-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem;
color: #888;
font-size: 0.875rem;
}
.loading-spinner {
width: 1.25rem;
height: 1.25rem;
border: 2px solid #333;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.command-result {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1.25rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.1s ease;
color: #e5e5e5;
}
.command-result:hover,
.command-result.selected {
background: #2a2a2a;
}
.result-avatar {
width: 40px;
height: 40px;
min-width: 40px;
border-radius: 9999px;
background: #3b82f6;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.875rem;
}
.result-info {
flex: 1;
min-width: 0;
}
.result-name {
font-weight: 500;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-details {
display: flex;
gap: 0.5rem;
font-size: 0.8125rem;
color: #888;
}
.result-details span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-favorite {
width: 1rem;
height: 1rem;
color: #ef4444;
flex-shrink: 0;
}
.quick-actions-list {
padding: 0.5rem;
}
.quick-action {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
border-radius: 8px;
color: #e5e5e5;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.1s ease;
}
.quick-action:hover,
.quick-action.selected {
background: #2a2a2a;
}
.quick-action-icon {
width: 1.25rem;
height: 1.25rem;
color: #888;
}
.quick-action span {
flex: 1;
font-size: 0.9375rem;
}
.quick-action kbd {
padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
font-family: inherit;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 4px;
color: #888;
}
.command-footer {
padding: 0.75rem 1.25rem;
border-top: 1px solid #333;
background: #141414;
}
.footer-hints {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: #666;
}
.footer-hints kbd {
padding: 0.125rem 0.25rem;
font-family: inherit;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 3px;
margin-right: 0.25rem;
}
@media (max-width: 640px) {
.command-backdrop {
padding-top: 5vh;
}
.footer-hints {
flex-wrap: wrap;
gap: 0.5rem;
}
}
</style>

View file

@ -0,0 +1,2 @@
export { default as CommandBar } from './CommandBar.svelte';
export type { CommandBarItem, QuickAction } from './CommandBar.svelte';

View file

@ -9,13 +9,32 @@ export type { SelectOption } from './molecules';
export { GlassCard, StatRow } from './molecules';
// Tags
export { TagBadge } from './molecules';
export {
TagBadge,
TagColorPicker,
TagEditModal,
TagSelector,
TagList,
TAG_COLORS,
DEFAULT_TAG_COLOR,
getRandomTagColor,
getTagColorByName,
} from './molecules';
export type { Tag, TagData, TagColorName, TagColorHex } from './molecules';
// Media
export { AudioPlayer } from './molecules';
// Loading/Skeletons
export { SkeletonBox, SkeletonText } from './molecules';
export {
SkeletonBox,
SkeletonText,
SkeletonAvatar,
SkeletonRow,
SkeletonList,
SkeletonCard,
SkeletonGrid,
} from './molecules';
// Feedback
export { EmptyState } from './molecules';
@ -27,6 +46,28 @@ export { ModalFooter, DataCard, PageHeader, KeyboardShortcutsPanel } from './mol
export { Modal, ConfirmationModal, FormModal, AppSlider } from './organisms';
export type { AppItem } from './organisms';
// Network Graph
export {
NetworkGraph,
NetworkControls,
stringToColor,
getInitials,
SIMULATION_CONFIG,
NODE_CONFIG,
LABEL_CONFIG,
} from './organisms';
export type {
NetworkNode,
NetworkLink,
NetworkTag,
NetworkTransform,
NetworkGraphProps,
NetworkControlsProps,
NetworkGraphResponse,
SimulationNode,
SimulationLink,
} from './organisms';
// Navigation
export {
NavLink,
@ -63,5 +104,9 @@ export {
GlobalSettingsSection,
} from './settings';
// Command Bar
export { CommandBar } from './command-bar';
export type { CommandBarItem, QuickAction } from './command-bar';
// Pages
export { default as AppsPage } from './pages/AppsPage.svelte';

View file

@ -9,13 +9,32 @@ export type { SelectOption } from './Select.types';
export { GlassCard, StatRow } from './stats';
// Tag components
export { TagBadge } from './tags';
export {
TagBadge,
TagColorPicker,
TagEditModal,
TagSelector,
TagList,
TAG_COLORS,
DEFAULT_TAG_COLOR,
getRandomTagColor,
getTagColorByName,
} from './tags';
export type { Tag, TagData, TagColorName, TagColorHex } from './tags';
// Media components
export { AudioPlayer } from './media';
// Loading components
export { SkeletonBox, SkeletonText } from './loaders';
export {
SkeletonBox,
SkeletonText,
SkeletonAvatar,
SkeletonRow,
SkeletonList,
SkeletonCard,
SkeletonGrid,
} from './loaders';
// Feedback components
export { EmptyState } from './feedback';

View file

@ -0,0 +1,24 @@
<script lang="ts">
/**
* SkeletonAvatar - Circular skeleton for profile pictures/avatars
*
* @example
* ```svelte
* <SkeletonAvatar size="40px" />
* <SkeletonAvatar size="64px" />
* ```
*/
import SkeletonBox from './SkeletonBox.svelte';
interface Props {
/** Size of the avatar (width & height) */
size?: string;
/** Additional CSS classes */
class?: string;
}
let { size = '40px', class: className = '' }: Props = $props();
</script>
<SkeletonBox width={size} height={size} circle class={className} />

View file

@ -0,0 +1,69 @@
<script lang="ts">
/**
* SkeletonCard - Configurable card skeleton with avatar, title, body, footer
*
* @example
* ```svelte
* <SkeletonCard showAvatar titleLines={1} bodyLines={2} />
* <SkeletonCard showFooter />
* ```
*/
import SkeletonBox from './SkeletonBox.svelte';
import SkeletonText from './SkeletonText.svelte';
import SkeletonAvatar from './SkeletonAvatar.svelte';
interface Props {
/** Show avatar/image placeholder */
showAvatar?: boolean;
/** Avatar size */
avatarSize?: string;
/** Number of title lines */
titleLines?: number;
/** Number of body text lines */
bodyLines?: number;
/** Show footer section */
showFooter?: boolean;
/** Opacity for fade effect in lists */
opacity?: number;
/** Additional CSS classes */
class?: string;
}
let {
showAvatar = false,
avatarSize = '48px',
titleLines = 1,
bodyLines = 2,
showFooter = false,
opacity = 1,
class: className = '',
}: Props = $props();
</script>
<div
class="skeleton-card rounded-lg border border-border bg-card p-4 {className}"
style="opacity: {opacity};"
>
<div class="flex gap-3">
{#if showAvatar}
<SkeletonAvatar size={avatarSize} />
{/if}
<div class="flex-1 min-w-0">
{#if titleLines > 0}
<SkeletonText lines={titleLines} lineHeight="18px" gap="6px" lastLineWidth="60%" />
{/if}
{#if bodyLines > 0}
<div class="mt-2">
<SkeletonText lines={bodyLines} lineHeight="14px" gap="6px" lastLineWidth="80%" />
</div>
{/if}
</div>
</div>
{#if showFooter}
<div class="mt-4 flex items-center justify-between border-t border-border pt-4">
<SkeletonBox width="80px" height="14px" />
<SkeletonBox width="60px" height="14px" />
</div>
{/if}
</div>

View file

@ -0,0 +1,61 @@
<script lang="ts">
/**
* SkeletonGrid - Grid of skeleton cards with fade effect
*
* @example
* ```svelte
* <SkeletonGrid count={6} columns={3} />
* <SkeletonGrid count={8} columns={4} showAvatar />
* ```
*/
import SkeletonCard from './SkeletonCard.svelte';
interface Props {
/** Number of cards to show */
count?: number;
/** Number of columns (CSS grid) */
columns?: number;
/** Show avatar in cards */
showAvatar?: boolean;
/** Avatar size */
avatarSize?: string;
/** Number of body lines per card */
bodyLines?: number;
/** Apply cascading fade effect */
fadeEffect?: boolean;
/** Minimum opacity for fade effect */
minOpacity?: number;
/** Gap between cards */
gap?: string;
/** Additional CSS classes */
class?: string;
}
let {
count = 6,
columns = 3,
showAvatar = true,
avatarSize = '48px',
bodyLines = 2,
fadeEffect = true,
minOpacity = 0.4,
gap = '1rem',
class: className = '',
}: Props = $props();
function calculateOpacity(index: number): number {
if (!fadeEffect) return 1;
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
return Math.max(minOpacity, 1 - index * fadeStep);
}
</script>
<div
class="skeleton-grid grid {className}"
style="grid-template-columns: repeat({columns}, minmax(0, 1fr)); gap: {gap};"
>
{#each Array(count) as _, i}
<SkeletonCard {showAvatar} {avatarSize} {bodyLines} opacity={calculateOpacity(i)} />
{/each}
</div>

View file

@ -0,0 +1,52 @@
<script lang="ts">
/**
* SkeletonList - List of skeleton rows with cascading fade effect
*
* @example
* ```svelte
* <SkeletonList count={5} />
* <SkeletonList count={10} showAvatar fadeEffect />
* ```
*/
import SkeletonRow from './SkeletonRow.svelte';
interface Props {
/** Number of rows to show */
count?: number;
/** Show avatar in each row */
showAvatar?: boolean;
/** Avatar size */
avatarSize?: string;
/** Apply cascading fade effect (rows fade out towards bottom) */
fadeEffect?: boolean;
/** Minimum opacity for fade effect */
minOpacity?: number;
/** Gap between rows */
gap?: string;
/** Additional CSS classes */
class?: string;
}
let {
count = 5,
showAvatar = true,
avatarSize = '40px',
fadeEffect = true,
minOpacity = 0.3,
gap = '0',
class: className = '',
}: Props = $props();
function calculateOpacity(index: number): number {
if (!fadeEffect) return 1;
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
return Math.max(minOpacity, 1 - index * fadeStep);
}
</script>
<div class="skeleton-list flex flex-col {className}" style="gap: {gap};">
{#each Array(count) as _, i}
<SkeletonRow {showAvatar} {avatarSize} opacity={calculateOpacity(i)} />
{/each}
</div>

View file

@ -0,0 +1,60 @@
<script lang="ts">
/**
* SkeletonRow - Single row skeleton with avatar and text
*
* @example
* ```svelte
* <SkeletonRow showAvatar />
* <SkeletonRow opacity={0.5} />
* ```
*/
import SkeletonBox from './SkeletonBox.svelte';
import SkeletonAvatar from './SkeletonAvatar.svelte';
interface Props {
/** Show avatar placeholder */
showAvatar?: boolean;
/** Avatar size */
avatarSize?: string;
/** Opacity for fade effect */
opacity?: number;
/** Show secondary line */
showSecondaryLine?: boolean;
/** Show right-side content */
showRightContent?: boolean;
/** Additional CSS classes */
class?: string;
}
let {
showAvatar = true,
avatarSize = '40px',
opacity = 1,
showSecondaryLine = true,
showRightContent = true,
class: className = '',
}: Props = $props();
</script>
<div
class="skeleton-row flex items-center gap-3 border-b border-border px-4 py-3 {className}"
style="opacity: {opacity};"
role="status"
aria-label="Loading"
>
{#if showAvatar}
<SkeletonAvatar size={avatarSize} />
{/if}
<div class="flex-1 min-w-0">
<SkeletonBox width="45%" height="16px" />
{#if showSecondaryLine}
<div class="mt-1.5">
<SkeletonBox width="65%" height="13px" />
</div>
{/if}
</div>
{#if showRightContent}
<SkeletonBox width="70px" height="13px" />
{/if}
</div>

View file

@ -1,5 +1,25 @@
/**
* Loading state components
*
* Primitives:
* - SkeletonBox: Base rectangular skeleton with shimmer
* - SkeletonText: Multi-line text skeleton
* - SkeletonAvatar: Circular avatar skeleton
*
* Composites:
* - SkeletonRow: Single list row with avatar + text
* - SkeletonList: Multiple rows with fade effect
* - SkeletonCard: Card with avatar, title, body, footer
* - SkeletonGrid: Grid of cards with fade effect
*/
// Primitives
export { default as SkeletonBox } from './SkeletonBox.svelte';
export { default as SkeletonText } from './SkeletonText.svelte';
export { default as SkeletonAvatar } from './SkeletonAvatar.svelte';
// Composites
export { default as SkeletonRow } from './SkeletonRow.svelte';
export { default as SkeletonList } from './SkeletonList.svelte';
export { default as SkeletonCard } from './SkeletonCard.svelte';
export { default as SkeletonGrid } from './SkeletonGrid.svelte';

View file

@ -0,0 +1,70 @@
<script lang="ts">
import { Check } from '@manacore/shared-icons';
import { TAG_COLORS, DEFAULT_TAG_COLOR } from './constants';
interface Props {
selectedColor?: string;
onColorChange: (color: string) => void;
size?: 'sm' | 'md' | 'lg';
}
let { selectedColor = DEFAULT_TAG_COLOR, onColorChange, size = 'md' }: Props = $props();
const sizeClasses = {
sm: 'w-6 h-6',
md: 'w-8 h-8',
lg: 'w-10 h-10',
};
const iconSizes = {
sm: 12,
md: 14,
lg: 18,
};
const gapClasses = {
sm: 'gap-1.5',
md: 'gap-2',
lg: 'gap-2.5',
};
function handleColorSelect(hex: string) {
onColorChange(hex);
}
function handleKeyDown(e: KeyboardEvent, hex: string) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleColorSelect(hex);
}
}
</script>
<div class="flex flex-wrap {gapClasses[size]}" role="radiogroup" aria-label="Tag color">
{#each TAG_COLORS as color}
{@const isSelected = selectedColor?.toLowerCase() === color.hex.toLowerCase()}
<button
type="button"
class="
{sizeClasses[size]}
rounded-full
flex items-center justify-center
transition-all duration-150
ring-offset-2 ring-offset-white dark:ring-offset-gray-900
focus:outline-none focus:ring-2 focus:ring-primary
{isSelected ? 'ring-2 ring-black/30 dark:ring-white/50 scale-110' : 'hover:scale-110'}
"
style="background-color: {color.hex}"
onclick={() => handleColorSelect(color.hex)}
onkeydown={(e) => handleKeyDown(e, color.hex)}
role="radio"
aria-checked={isSelected}
aria-label={color.name}
title={color.name}
>
{#if isSelected}
<Check size={iconSizes[size]} weight="bold" class="text-white drop-shadow-sm" />
{/if}
</button>
{/each}
</div>

View file

@ -0,0 +1,143 @@
<script lang="ts">
import { Modal } from '../../organisms';
import { Input } from '../../molecules';
import TagBadge from './TagBadge.svelte';
import TagColorPicker from './TagColorPicker.svelte';
import { DEFAULT_TAG_COLOR } from './constants';
import type { Tag } from './constants';
interface Props {
tag?: Tag | null;
isOpen: boolean;
onClose: () => void;
onSave: (name: string, color: string) => void;
onDelete?: () => void;
usageCount?: number;
title?: string;
saveLabel?: string;
deleteLabel?: string;
cancelLabel?: string;
namePlaceholder?: string;
colorLabel?: string;
previewLabel?: string;
usageLabel?: string;
deleteConfirmMessage?: string;
}
let {
tag = null,
isOpen,
onClose,
onSave,
onDelete,
usageCount,
title = 'Tag bearbeiten',
saveLabel = 'Speichern',
deleteLabel = 'Löschen',
cancelLabel = 'Abbrechen',
namePlaceholder = 'Tag Name',
colorLabel = 'Farbe',
previewLabel = 'Vorschau',
usageLabel = 'Verwendung',
deleteConfirmMessage = 'Möchtest du diesen Tag wirklich löschen?',
}: Props = $props();
let name = $state(tag?.name ?? '');
let color = $state(tag?.color ?? tag?.style?.color ?? DEFAULT_TAG_COLOR);
// Reset form when tag changes
$effect(() => {
if (isOpen) {
name = tag?.name ?? '';
color = tag?.color ?? tag?.style?.color ?? DEFAULT_TAG_COLOR;
}
});
function handleSave() {
if (name.trim()) {
onSave(name.trim(), color);
}
}
function handleDelete() {
if (onDelete && confirm(deleteConfirmMessage)) {
onDelete();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' && name.trim()) {
e.preventDefault();
handleSave();
}
}
const previewTag = $derived({ name: name || namePlaceholder, color });
</script>
<Modal visible={isOpen} {onClose} {title} maxWidth="sm">
<div class="space-y-6">
<!-- Name Input -->
<div>
<Input bind:value={name} placeholder={namePlaceholder} onkeydown={handleKeyDown} autofocus />
</div>
<!-- Color Picker -->
<div>
<label class="block text-sm font-medium text-muted-foreground mb-3">
{colorLabel}
</label>
<TagColorPicker selectedColor={color} onColorChange={(c) => (color = c)} />
</div>
<!-- Preview -->
<div>
<label class="block text-sm font-medium text-muted-foreground mb-3">
{previewLabel}
</label>
<div class="flex items-center gap-2">
<TagBadge tag={previewTag} />
</div>
</div>
<!-- Usage Count (optional) -->
{#if usageCount !== undefined && usageCount > 0}
<div class="text-sm text-muted-foreground">
{usageLabel}: {usageCount}
</div>
{/if}
</div>
{#snippet footer()}
<div class="flex items-center justify-between">
<div>
{#if onDelete && tag}
<button
type="button"
onclick={handleDelete}
class="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
{deleteLabel}
</button>
{/if}
</div>
<div class="flex items-center gap-3">
<button
type="button"
onclick={onClose}
class="px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/10 rounded-lg transition-colors"
>
{cancelLabel}
</button>
<button
type="button"
onclick={handleSave}
disabled={!name.trim()}
class="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
{saveLabel}
</button>
</div>
</div>
{/snippet}
</Modal>

View file

@ -0,0 +1,166 @@
<script lang="ts">
import { PencilSimple, Trash } from '@manacore/shared-icons';
import { DEFAULT_TAG_COLOR } from './constants';
import type { Tag } from './constants';
interface Props {
tags: Tag[];
onEdit?: (tag: Tag) => void;
onDelete?: (tag: Tag) => void;
onClick?: (tag: Tag) => void;
layout?: 'grid' | 'list';
emptyMessage?: string;
emptyDescription?: string;
loading?: boolean;
}
let {
tags,
onEdit,
onDelete,
onClick,
layout = 'grid',
emptyMessage = 'Keine Tags vorhanden',
emptyDescription = 'Erstelle deinen ersten Tag',
loading = false,
}: Props = $props();
function getTagColor(tag: Tag): string {
return tag.color ?? tag.style?.color ?? DEFAULT_TAG_COLOR;
}
function handleKeyDown(e: KeyboardEvent, tag: Tag, action: 'click' | 'edit' | 'delete') {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (action === 'click' && onClick) onClick(tag);
if (action === 'edit' && onEdit) onEdit(tag);
if (action === 'delete' && onDelete) onDelete(tag);
}
}
const gridClasses = 'grid grid-cols-1 sm:grid-cols-2 gap-4';
const listClasses = 'flex flex-col gap-3';
</script>
{#if loading}
<!-- Loading Skeleton -->
<div class={layout === 'grid' ? gridClasses : listClasses}>
{#each Array(6) as _, i}
<div
class="
flex items-center gap-3 p-4
bg-gray-100 dark:bg-gray-800
rounded-xl animate-pulse
"
>
<div class="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
<div class="flex-1">
<div class="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
</div>
{/each}
</div>
{:else if tags.length === 0}
<!-- Empty State -->
<div class="flex flex-col items-center justify-center py-12 text-center">
<div
class="w-16 h-16 mb-4 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center"
>
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
></path>
</svg>
</div>
<h3 class="text-lg font-medium text-foreground mb-1">{emptyMessage}</h3>
<p class="text-sm text-muted-foreground">{emptyDescription}</p>
</div>
{:else}
<!-- Tag Grid/List -->
<div class={layout === 'grid' ? gridClasses : listClasses}>
{#each tags as tag (tag.id)}
{@const color = getTagColor(tag)}
<div
class="
group relative flex items-center gap-3 p-4
bg-white dark:bg-white/5
border border-gray-200 dark:border-white/10
rounded-xl
transition-all duration-200
hover:shadow-md hover:border-gray-300 dark:hover:border-white/20
{onClick ? 'cursor-pointer' : ''}
"
onclick={() => onClick?.(tag)}
onkeydown={(e) => handleKeyDown(e, tag, 'click')}
role={onClick ? 'button' : undefined}
tabindex={onClick ? 0 : undefined}
>
<!-- Color Icon -->
<div
class="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
style="background-color: {color}20"
>
<div class="w-4 h-4 rounded-full" style="background-color: {color}"></div>
</div>
<!-- Tag Name -->
<span class="flex-1 font-medium text-foreground min-w-0">
{tag.name}
</span>
<!-- Actions -->
{#if onEdit || onDelete}
<div class="flex items-center gap-1 flex-shrink-0">
{#if onEdit}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
onEdit(tag);
}}
onkeydown={(e) => {
e.stopPropagation();
handleKeyDown(e, tag, 'edit');
}}
class="
p-2 rounded-lg
text-muted-foreground hover:text-foreground
hover:bg-gray-100 dark:hover:bg-white/10
transition-colors
"
aria-label="Tag bearbeiten"
>
<PencilSimple size={16} />
</button>
{/if}
{#if onDelete}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
onDelete(tag);
}}
onkeydown={(e) => {
e.stopPropagation();
handleKeyDown(e, tag, 'delete');
}}
class="
p-2 rounded-lg
text-muted-foreground hover:text-red-500
hover:bg-red-50 dark:hover:bg-red-900/20
transition-colors
"
aria-label="Tag löschen"
>
<Trash size={16} />
</button>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}

View file

@ -0,0 +1,257 @@
<script lang="ts">
import { Plus, MagnifyingGlass, X } from '@manacore/shared-icons';
import TagBadge from './TagBadge.svelte';
import TagColorPicker from './TagColorPicker.svelte';
import { DEFAULT_TAG_COLOR } from './constants';
import type { Tag } from './constants';
interface Props {
tags: Tag[];
selectedTags: Tag[];
onTagsChange: (tags: Tag[]) => void;
onCreateTag?: (name: string, color: string) => Promise<Tag>;
placeholder?: string;
addTagLabel?: string;
searchPlaceholder?: string;
createLabel?: string;
maxTags?: number;
}
let {
tags,
selectedTags,
onTagsChange,
onCreateTag,
placeholder = 'Tags hinzufügen...',
addTagLabel = 'Tag hinzufügen',
searchPlaceholder = 'Tag suchen...',
createLabel = 'Erstellen',
maxTags,
}: Props = $props();
let isOpen = $state(false);
let searchQuery = $state('');
let isCreating = $state(false);
let newTagName = $state('');
let newTagColor = $state(DEFAULT_TAG_COLOR);
const filteredTags = $derived.by(() => {
const selectedIds = new Set(selectedTags.map((t) => t.id));
return tags
.filter((tag) => !selectedIds.has(tag.id))
.filter((tag) => tag.name.toLowerCase().includes(searchQuery.toLowerCase()));
});
const canAddMore = $derived(!maxTags || selectedTags.length < maxTags);
function handleSelectTag(tag: Tag) {
if (canAddMore) {
onTagsChange([...selectedTags, tag]);
searchQuery = '';
}
}
function handleRemoveTag(tag: Tag) {
onTagsChange(selectedTags.filter((t) => t.id !== tag.id));
}
async function handleCreateTag() {
if (!onCreateTag || !newTagName.trim()) return;
try {
const createdTag = await onCreateTag(newTagName.trim(), newTagColor);
onTagsChange([...selectedTags, createdTag]);
newTagName = '';
newTagColor = DEFAULT_TAG_COLOR;
isCreating = false;
searchQuery = '';
} catch (error) {
console.error('Failed to create tag:', error);
}
}
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.tag-selector-container')) {
isOpen = false;
isCreating = false;
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
isOpen = false;
isCreating = false;
}
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeyDown} />
<div class="tag-selector-container relative">
<!-- Selected Tags Display -->
<div class="flex flex-wrap items-center gap-2 mb-2">
{#each selectedTags as tag (tag.id)}
<TagBadge {tag} removable onRemove={() => handleRemoveTag(tag)} />
{/each}
{#if canAddMore}
<button
type="button"
onclick={() => (isOpen = !isOpen)}
class="
inline-flex items-center gap-1.5 px-3 py-1.5
text-sm text-muted-foreground
border border-dashed border-gray-300 dark:border-gray-600
rounded-full
hover:border-gray-400 dark:hover:border-gray-500
hover:text-foreground
transition-colors
"
>
<Plus size={14} weight="bold" />
<span>{addTagLabel}</span>
</button>
{/if}
</div>
<!-- Dropdown -->
{#if isOpen}
<div
class="
absolute z-50 mt-1 w-full min-w-[280px]
bg-white dark:bg-gray-800
border border-gray-200 dark:border-gray-700
rounded-xl shadow-lg
overflow-hidden
"
>
<!-- Search Input -->
<div class="p-3 border-b border-gray-100 dark:border-gray-700">
<div class="relative">
<MagnifyingGlass
size={16}
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
type="text"
bind:value={searchQuery}
placeholder={searchPlaceholder}
class="
w-full pl-9 pr-3 py-2
text-sm
bg-gray-50 dark:bg-gray-900
border border-gray-200 dark:border-gray-700
rounded-lg
focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent
"
/>
</div>
</div>
<!-- Tag List -->
<div class="max-h-48 overflow-y-auto p-2">
{#if filteredTags.length > 0}
{#each filteredTags as tag (tag.id)}
<button
type="button"
onclick={() => handleSelectTag(tag)}
class="
w-full flex items-center gap-2 px-3 py-2
text-sm text-left
rounded-lg
hover:bg-gray-100 dark:hover:bg-gray-700
transition-colors
"
>
<span
class="w-3 h-3 rounded-full flex-shrink-0"
style="background-color: {tag.color ?? tag.style?.color ?? DEFAULT_TAG_COLOR}"
></span>
<span class="truncate">{tag.name}</span>
</button>
{/each}
{:else if searchQuery && !isCreating}
<div class="px-3 py-2 text-sm text-muted-foreground text-center">Kein Tag gefunden</div>
{/if}
</div>
<!-- Create New Tag -->
{#if onCreateTag}
<div class="p-3 border-t border-gray-100 dark:border-gray-700">
{#if isCreating}
<div class="space-y-3">
<input
type="text"
bind:value={newTagName}
{placeholder}
class="
w-full px-3 py-2
text-sm
bg-gray-50 dark:bg-gray-900
border border-gray-200 dark:border-gray-700
rounded-lg
focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent
"
onkeydown={(e) => e.key === 'Enter' && handleCreateTag()}
/>
<TagColorPicker
selectedColor={newTagColor}
onColorChange={(c) => (newTagColor = c)}
size="sm"
/>
<div class="flex items-center gap-2">
<button
type="button"
onclick={() => (isCreating = false)}
class="
flex-1 px-3 py-1.5
text-sm text-muted-foreground
hover:bg-gray-100 dark:hover:bg-gray-700
rounded-lg transition-colors
"
>
<X size={14} class="inline mr-1" />
Abbrechen
</button>
<button
type="button"
onclick={handleCreateTag}
disabled={!newTagName.trim()}
class="
flex-1 px-3 py-1.5
text-sm font-medium
bg-primary text-primary-foreground
rounded-lg
hover:opacity-90 transition-opacity
disabled:opacity-50 disabled:cursor-not-allowed
"
>
{createLabel}
</button>
</div>
</div>
{:else}
<button
type="button"
onclick={() => {
isCreating = true;
newTagName = searchQuery;
}}
class="
w-full flex items-center justify-center gap-2 px-3 py-2
text-sm font-medium
text-primary
hover:bg-primary/10
rounded-lg transition-colors
"
>
<Plus size={16} weight="bold" />
<span>Neuen Tag erstellen</span>
</button>
{/if}
</div>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,56 @@
/**
* Centralized tag constants and types for @manacore/shared-ui
*/
export const TAG_COLORS = [
{ name: 'red', hex: '#ef4444' },
{ name: 'orange', hex: '#f97316' },
{ name: 'amber', hex: '#f59e0b' },
{ name: 'lime', hex: '#84cc16' },
{ name: 'green', hex: '#22c55e' },
{ name: 'teal', hex: '#14b8a6' },
{ name: 'cyan', hex: '#06b6d4' },
{ name: 'blue', hex: '#3b82f6' },
{ name: 'indigo', hex: '#6366f1' },
{ name: 'violet', hex: '#8b5cf6' },
{ name: 'pink', hex: '#ec4899' },
{ name: 'slate', hex: '#64748b' },
] as const;
export const DEFAULT_TAG_COLOR = '#3b82f6'; // blue
export type TagColorName = (typeof TAG_COLORS)[number]['name'];
export type TagColorHex = (typeof TAG_COLORS)[number]['hex'];
export interface Tag {
id: string;
name: string;
color?: string | null;
style?: { color?: string };
}
export interface TagData {
name?: string;
text?: string;
color?: string;
style?: { color?: string };
}
/**
* Get a random color from the palette
*/
export function getRandomTagColor(): string {
return TAG_COLORS[Math.floor(Math.random() * TAG_COLORS.length)].hex;
}
/**
* Get color by name
*/
export function getTagColorByName(name: TagColorName): string {
for (const color of TAG_COLORS) {
if (color.name === name) {
return color.hex;
}
}
return DEFAULT_TAG_COLOR;
}

View file

@ -1 +1,10 @@
// Components
export { default as TagBadge } from './TagBadge.svelte';
export { default as TagColorPicker } from './TagColorPicker.svelte';
export { default as TagEditModal } from './TagEditModal.svelte';
export { default as TagSelector } from './TagSelector.svelte';
export { default as TagList } from './TagList.svelte';
// Constants and Types
export { TAG_COLORS, DEFAULT_TAG_COLOR, getRandomTagColor, getTagColorByName } from './constants';
export type { Tag, TagData, TagColorName, TagColorHex } from './constants';

View file

@ -9,6 +9,98 @@
} from './types';
import PillDropdown from './PillDropdown.svelte';
import PillTabGroup from './PillTabGroup.svelte';
// Phosphor Icons (via shared-icons)
import {
House,
Users,
Tag,
Heart,
Gear,
ChatCircle,
Question,
ShareNetwork,
Bell,
Clock,
Timer,
Target,
Globe,
Tray,
Check,
CheckCircle,
Plus,
Columns,
Microphone,
CalendarBlank,
Folder,
Archive,
Upload,
MusicNote,
File,
ChartBar,
MagnifyingGlass,
List,
Compass,
Moon,
Sun,
SignOut,
CaretDown,
CaretUp,
CaretLeft,
Fire,
GridFour,
Palette,
CreditCard,
Buildings,
User,
} from '@manacore/shared-icons';
// Map icon names to Phosphor components
const phosphorIcons: Record<string, any> = {
home: House,
users: Users,
user: User,
tag: Tag,
heart: Heart,
settings: Gear,
chat: ChatCircle,
'help-circle': Question,
'share-2': ShareNetwork,
bell: Bell,
clock: Clock,
timer: Timer,
target: Target,
globe: Globe,
inbox: Tray,
check: Check,
checkCircle: CheckCircle,
plus: Plus,
columns: Columns,
kanban: Columns,
mic: Microphone,
calendar: CalendarBlank,
folder: Folder,
archive: Archive,
upload: Upload,
music: MusicNote,
document: File,
chart: ChartBar,
search: MagnifyingGlass,
list: List,
compass: Compass,
moon: Moon,
sun: Sun,
logout: SignOut,
chevronDown: CaretDown,
chevronUp: CaretUp,
chevronLeft: CaretLeft,
menu: List,
fire: Fire,
grid: GridFour,
gridSmall: GridFour,
palette: Palette,
creditCard: CreditCard,
building: Buildings,
};
// Convert app items to dropdown items (will be computed as derived)
function createAppDropdownItems(
@ -325,6 +417,10 @@
palette:
'M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01',
chat: 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z',
'help-circle':
'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
'share-2':
'M18 8a3 3 0 100-6 3 3 0 000 6zM6 15a3 3 0 100-6 3 3 0 000 6zM18 22a3 3 0 100-6 3 3 0 000 6zM8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98',
};
function getIconPath(name: string): string {
@ -368,23 +464,10 @@
d="M12.3047 1C12.3392 1.04573 19.608 10.6706 19.6084 14.6953C19.6084 18.7293 16.3386 21.9998 12.3047 22C8.27061 22 5 18.7294 5 14.6953C5.00041 10.661 12.3047 1 12.3047 1ZM12.3047 7.3916C12.2811 7.42276 8.65234 12.2288 8.65234 14.2393C8.65241 16.2562 10.2877 17.8916 12.3047 17.8916C14.3217 17.8916 15.957 16.2562 15.957 14.2393C15.957 12.2301 12.3331 7.42917 12.3047 7.3916Z"
/>
</svg>
{:else if item.icon === 'settings'}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIconPath('settings')}
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIconPath('settingsInner')}
/>
</svg>
{:else if item.iconSvg}
{@html item.iconSvg}
{:else if phosphorIcons[item.icon]}
<svelte:component this={phosphorIcons[item.icon]} size={18} class="pill-icon" />
{:else}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -416,14 +499,18 @@
{:else if isNavItem(element)}
<a href={element.href} class="pill glass-pill" class:active={isActive(element.href)}>
{#if element.icon}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIconPath(element.icon)}
/>
</svg>
{#if phosphorIcons[element.icon]}
<svelte:component this={phosphorIcons[element.icon]} size={18} class="pill-icon" />
{:else}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIconPath(element.icon)}
/>
</svg>
{/if}
{/if}
<span class="pill-label">{element.label}</span>
</a>

View file

@ -3,3 +3,25 @@ export { default as ConfirmationModal } from './ConfirmationModal.svelte';
export { default as FormModal } from './FormModal.svelte';
export { default as AppSlider } from './AppSlider.svelte';
export type { AppItem } from './AppSlider.types';
// Network Graph
export {
NetworkGraph,
NetworkControls,
stringToColor,
getInitials,
SIMULATION_CONFIG,
NODE_CONFIG,
LABEL_CONFIG,
} from './network';
export type {
NetworkNode,
NetworkLink,
NetworkTag,
NetworkTransform,
NetworkGraphProps,
NetworkControlsProps,
NetworkGraphResponse,
SimulationNode,
SimulationLink,
} from './network';

View file

@ -0,0 +1,604 @@
<script lang="ts">
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter, X, Focus, Keyboard } from 'lucide-svelte';
import type { NetworkTag } from './network.types';
interface Props {
searchQuery?: string;
tags?: NetworkTag[];
selectedTagId?: string | null;
subtitles?: string[];
selectedSubtitle?: string | null;
subtitleLabel?: string;
nodeCount?: number;
linkCount?: number;
nodeLabel?: string;
linkLabel?: string;
searchPlaceholder?: string;
minStrength?: number;
onSearch?: (query: string) => void;
onTagFilter?: (tagId: string | null) => void;
onSubtitleFilter?: (subtitle: string | null) => void;
onStrengthFilter?: (minStrength: number) => void;
onZoomIn?: () => void;
onZoomOut?: () => void;
onResetZoom?: () => void;
onFocusSelected?: () => void;
onClearFilters?: () => void;
}
let {
searchQuery = '',
tags = [],
selectedTagId = null,
subtitles = [],
selectedSubtitle = null,
subtitleLabel = 'Filter',
nodeCount = 0,
linkCount = 0,
nodeLabel = 'Elemente',
linkLabel = 'Verbindungen',
searchPlaceholder = 'Suchen...',
minStrength = 0,
onSearch,
onTagFilter,
onSubtitleFilter,
onStrengthFilter,
onZoomIn,
onZoomOut,
onResetZoom,
onFocusSelected,
onClearFilters,
}: Props = $props();
let searchInput = $state(searchQuery);
let showFilters = $state(false);
let showKeyboardHelp = $state(false);
let strengthValue = $state(minStrength);
let searchInputElement: HTMLInputElement;
// Sync searchInput with external searchQuery
$effect(() => {
searchInput = searchQuery;
});
// Sync strength with external minStrength
$effect(() => {
strengthValue = minStrength;
});
function handleSearchInput(event: Event) {
const target = event.target as HTMLInputElement;
searchInput = target.value;
onSearch?.(target.value);
}
function clearSearch() {
searchInput = '';
onSearch?.('');
}
function handleTagChange(event: Event) {
const target = event.target as HTMLSelectElement;
onTagFilter?.(target.value || null);
}
function handleSubtitleChange(event: Event) {
const target = event.target as HTMLSelectElement;
onSubtitleFilter?.(target.value || null);
}
function clearAllFilters() {
searchInput = '';
strengthValue = 0;
onClearFilters?.();
}
function handleStrengthChange(event: Event) {
const target = event.target as HTMLInputElement;
strengthValue = parseInt(target.value, 10);
onStrengthFilter?.(strengthValue);
}
function focusSearch() {
searchInputElement?.focus();
}
const hasActiveFilters = $derived(
searchQuery || selectedTagId || selectedSubtitle || minStrength > 0
);
// Keyboard shortcuts info
const keyboardShortcuts = [
{ key: '+/-', description: 'Zoom in/out' },
{ key: '0', description: 'Reset zoom' },
{ key: 'F', description: 'Fokus auf Auswahl' },
{ key: '/', description: 'Suche fokussieren' },
{ key: 'Esc', description: 'Auswahl aufheben' },
];
// Export focus function for parent
export { focusSearch };
</script>
<div class="network-controls">
<!-- Search bar -->
<div class="search-container">
<Search size={18} class="search-icon" />
<input
bind:this={searchInputElement}
type="text"
placeholder={searchPlaceholder}
value={searchInput}
oninput={handleSearchInput}
class="search-input"
/>
{#if searchInput}
<button onclick={clearSearch} class="clear-btn" aria-label="Suche löschen">
<X size={16} />
</button>
{/if}
</div>
<!-- Filter toggle -->
{#if tags.length > 0 || subtitles.length > 0}
<button
onclick={() => (showFilters = !showFilters)}
class="control-btn"
class:active={showFilters || hasActiveFilters}
aria-label="Filter anzeigen"
title="Filter"
>
<Filter size={18} />
{#if hasActiveFilters}
<span class="filter-badge"></span>
{/if}
</button>
{/if}
<!-- Zoom controls -->
<div class="zoom-controls">
<button onclick={onZoomIn} class="control-btn" aria-label="Vergrößern" title="Vergrößern (+)">
<ZoomIn size={18} />
</button>
<button
onclick={onZoomOut}
class="control-btn"
aria-label="Verkleinern"
title="Verkleinern (-)"
>
<ZoomOut size={18} />
</button>
<button
onclick={onResetZoom}
class="control-btn"
aria-label="Ansicht zurücksetzen"
title="Zurücksetzen (0)"
>
<RotateCcw size={18} />
</button>
<button
onclick={onFocusSelected}
class="control-btn"
aria-label="Auf Auswahl fokussieren"
title="Fokus auf Auswahl (F)"
>
<Focus size={18} />
</button>
</div>
<!-- Keyboard help toggle -->
<button
onclick={() => (showKeyboardHelp = !showKeyboardHelp)}
class="control-btn"
class:active={showKeyboardHelp}
aria-label="Tastaturkürzel anzeigen"
title="Tastaturkürzel"
>
<Keyboard size={18} />
</button>
<!-- Stats -->
<div class="stats">
<span class="stat">
{nodeCount}
{nodeLabel}
</span>
<span class="stat-divider"></span>
<span class="stat">
{linkCount}
{linkLabel}
</span>
</div>
</div>
<!-- Keyboard shortcuts help -->
{#if showKeyboardHelp}
<div class="keyboard-help">
<div class="keyboard-help-title">Tastaturkürzel</div>
<div class="keyboard-shortcuts">
{#each keyboardShortcuts as shortcut}
<div class="shortcut">
<kbd class="shortcut-key">{shortcut.key}</kbd>
<span class="shortcut-desc">{shortcut.description}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Filter panel -->
{#if showFilters}
<div class="filter-panel">
<div class="filter-row">
<!-- Tag filter -->
{#if tags.length > 0}
<div class="filter-group">
<label for="tag-filter" class="filter-label">Tag</label>
<select
id="tag-filter"
onchange={handleTagChange}
value={selectedTagId || ''}
class="filter-select"
>
<option value="">Alle Tags</option>
{#each tags as tag}
<option value={tag.id}>
{tag.name}
</option>
{/each}
</select>
</div>
{/if}
<!-- Subtitle filter (e.g., Company, Project) -->
{#if subtitles.length > 0}
<div class="filter-group">
<label for="subtitle-filter" class="filter-label">{subtitleLabel}</label>
<select
id="subtitle-filter"
onchange={handleSubtitleChange}
value={selectedSubtitle || ''}
class="filter-select"
>
<option value="">Alle</option>
{#each subtitles as subtitle}
<option value={subtitle}>
{subtitle}
</option>
{/each}
</select>
</div>
{/if}
<!-- Strength filter slider -->
<div class="filter-group strength-group">
<label for="strength-filter" class="filter-label">
Min. Stärke: {strengthValue}%
</label>
<input
id="strength-filter"
type="range"
min="0"
max="100"
step="10"
value={strengthValue}
oninput={handleStrengthChange}
class="strength-slider"
/>
</div>
<!-- Clear filters button -->
{#if hasActiveFilters}
<button onclick={clearAllFilters} class="clear-filters-btn">
<X size={14} />
Filter löschen
</button>
{/if}
</div>
</div>
{/if}
<style>
.network-controls {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: hsl(var(--card) / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
border-radius: 9999px;
flex-wrap: wrap;
}
.search-container {
position: relative;
flex: 1;
min-width: 200px;
max-width: 300px;
}
.search-container :global(.search-icon) {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: hsl(var(--muted-foreground));
pointer-events: none;
}
.search-input {
width: 100%;
padding: 0.5rem 2rem 0.5rem 2.5rem;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.875rem;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.search-input:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.1);
}
.search-input::placeholder {
color: hsl(var(--muted-foreground));
}
.clear-btn {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
padding: 0.25rem;
background: none;
border: none;
color: hsl(var(--muted-foreground));
cursor: pointer;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.clear-btn:hover {
color: hsl(var(--foreground));
background: hsl(var(--muted));
}
.control-btn {
position: relative;
padding: 0.5rem;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
color: hsl(var(--muted-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.control-btn:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.control-btn.active {
background: hsl(var(--primary) / 0.1);
border-color: hsl(var(--primary));
color: hsl(var(--primary));
}
.filter-badge {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background: hsl(var(--primary));
border-radius: 50%;
}
.zoom-controls {
display: flex;
gap: 0.25rem;
padding-left: 0.5rem;
border-left: 1px solid hsl(var(--border));
}
.stats {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.stat-divider {
opacity: 0.5;
}
/* Filter panel */
.filter-panel {
margin-top: 0.5rem;
padding: 0.75rem 1rem;
background: hsl(var(--card) / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
border-radius: 1rem;
}
.filter-row {
display: flex;
align-items: flex-end;
gap: 1rem;
flex-wrap: wrap;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 150px;
}
.filter-label {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
}
.filter-select {
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.875rem;
cursor: pointer;
}
.filter-select:focus {
outline: none;
border-color: hsl(var(--primary));
}
.clear-filters-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
background: hsl(var(--destructive) / 0.1);
border: 1px solid hsl(var(--destructive) / 0.2);
border-radius: 0.5rem;
color: hsl(var(--destructive));
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.clear-filters-btn:hover {
background: hsl(var(--destructive) / 0.15);
}
/* Strength slider */
.strength-group {
min-width: 180px;
}
.strength-slider {
width: 100%;
height: 6px;
border-radius: 3px;
background: hsl(var(--muted));
appearance: none;
cursor: pointer;
}
.strength-slider::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: hsl(var(--primary));
cursor: pointer;
transition: transform 0.1s;
}
.strength-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.strength-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border: none;
border-radius: 50%;
background: hsl(var(--primary));
cursor: pointer;
}
/* Keyboard help panel */
.keyboard-help {
margin-top: 0.5rem;
padding: 0.75rem 1rem;
background: hsl(var(--card) / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
border-radius: 1rem;
}
.keyboard-help-title {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.keyboard-shortcuts {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.shortcut {
display: flex;
align-items: center;
gap: 0.5rem;
}
.shortcut-key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.75rem;
padding: 0.25rem 0.5rem;
background: hsl(var(--muted));
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
font-family: monospace;
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.shortcut-desc {
font-size: 0.8125rem;
color: hsl(var(--muted-foreground));
}
@media (max-width: 640px) {
.network-controls {
flex-direction: column;
align-items: stretch;
}
.search-container {
max-width: none;
}
.zoom-controls {
padding-left: 0;
border-left: none;
justify-content: center;
}
.stats {
justify-content: center;
margin-left: 0;
}
}
</style>

View file

@ -0,0 +1,671 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { zoom, zoomIdentity, type ZoomBehavior } from 'd3-zoom';
import { select, type Selection } from 'd3-selection';
import 'd3-transition'; // Side-effect import for .transition() method
import type { SimulationNode, SimulationLink, NetworkTransform } from './network.types';
import { stringToColor, getInitials, NODE_CONFIG, LABEL_CONFIG } from './constants';
interface Props {
nodes: SimulationNode[];
links: SimulationLink[];
selectedNodeId?: string | null;
onNodeClick?: (node: SimulationNode) => void;
onNodeDoubleClick?: (node: SimulationNode) => void;
onBackgroundClick?: () => void;
onDragStart?: (node: SimulationNode) => void;
onDrag?: (node: SimulationNode, x: number, y: number) => void;
onDragEnd?: (node: SimulationNode) => void;
onFocusSearch?: () => void;
}
let {
nodes,
links,
selectedNodeId = null,
onNodeClick,
onNodeDoubleClick,
onBackgroundClick,
onDragStart,
onDrag,
onDragEnd,
onFocusSearch,
}: Props = $props();
let svgElement: SVGSVGElement;
let containerElement: HTMLDivElement;
let zoomBehavior: ZoomBehavior<SVGSVGElement, unknown> | null = null;
let transform = $state<NetworkTransform>({ x: 0, y: 0, k: 1 });
let draggedNode: SimulationNode | null = null;
// Tooltip state
let hoveredLink = $state<SimulationLink | null>(null);
let tooltipPosition = $state({ x: 0, y: 0 });
// Setup zoom behavior
$effect(() => {
if (svgElement) {
zoomBehavior = zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
transform = {
x: event.transform.x,
y: event.transform.y,
k: event.transform.k,
};
});
select(svgElement).call(zoomBehavior);
}
});
function handleNodeClick(node: SimulationNode) {
onNodeClick?.(node);
}
function handleBackgroundClick(event: MouseEvent) {
if (event.target === svgElement) {
onBackgroundClick?.();
}
}
function handleNodeDoubleClick(node: SimulationNode) {
onNodeDoubleClick?.(node);
}
function handleDragStart(event: MouseEvent, node: SimulationNode) {
event.stopPropagation();
draggedNode = node;
onDragStart?.(node);
}
function handleDrag(event: MouseEvent) {
if (!draggedNode || !svgElement) return;
const rect = svgElement.getBoundingClientRect();
const x = (event.clientX - rect.left - transform.x) / transform.k;
const y = (event.clientY - rect.top - transform.y) / transform.k;
onDrag?.(draggedNode, x, y);
}
function handleDragEnd() {
if (draggedNode) {
onDragEnd?.(draggedNode);
draggedNode = null;
}
}
function resetZoom() {
if (svgElement && zoomBehavior) {
select(svgElement).transition().duration(300).call(zoomBehavior.transform, zoomIdentity);
}
}
function zoomIn() {
if (svgElement && zoomBehavior) {
select(svgElement).transition().duration(200).call(zoomBehavior.scaleBy, 1.3);
}
}
function zoomOut() {
if (svgElement && zoomBehavior) {
select(svgElement).transition().duration(200).call(zoomBehavior.scaleBy, 0.7);
}
}
function focusOnSelectedNode() {
if (!selectedNodeId || !svgElement || !zoomBehavior || !containerElement) return;
const node = nodes.find((n) => n.id === selectedNodeId);
if (!node || node.x === undefined || node.y === undefined) return;
const rect = containerElement.getBoundingClientRect();
const centerX = rect.width / 2;
const centerY = rect.height / 2;
// Calculate transform to center on node
const scale = 1.5;
const x = centerX - node.x * scale;
const y = centerY - node.y * scale;
select(svgElement)
.transition()
.duration(500)
.call(zoomBehavior.transform, zoomIdentity.translate(x, y).scale(scale));
}
// Keyboard shortcuts
function handleKeydown(event: KeyboardEvent) {
// Ignore if typing in an input
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
event.target instanceof HTMLSelectElement
) {
return;
}
switch (event.key) {
case '+':
case '=':
event.preventDefault();
zoomIn();
break;
case '-':
case '_':
event.preventDefault();
zoomOut();
break;
case '0':
event.preventDefault();
resetZoom();
break;
case 'Escape':
event.preventDefault();
onBackgroundClick?.();
break;
case 'f':
case 'F':
if (!event.ctrlKey && !event.metaKey) {
event.preventDefault();
focusOnSelectedNode();
}
break;
case '/':
event.preventDefault();
onFocusSearch?.();
break;
}
}
// Setup keyboard listener
$effect(() => {
if (typeof window !== 'undefined') {
window.addEventListener('keydown', handleKeydown);
return () => {
window.removeEventListener('keydown', handleKeydown);
};
}
});
// Get link coordinates
function getLinkCoords(link: SimulationLink) {
const source = link.source as SimulationNode;
const target = link.target as SimulationNode;
return {
x1: source.x ?? 0,
y1: source.y ?? 0,
x2: target.x ?? 0,
y2: target.y ?? 0,
};
}
// Link hover handlers
function handleLinkMouseEnter(event: MouseEvent, link: SimulationLink) {
hoveredLink = link;
updateTooltipPosition(event);
}
function handleLinkMouseMove(event: MouseEvent) {
if (hoveredLink) {
updateTooltipPosition(event);
}
}
function handleLinkMouseLeave() {
hoveredLink = null;
}
function updateTooltipPosition(event: MouseEvent) {
if (!containerElement) return;
const rect = containerElement.getBoundingClientRect();
tooltipPosition = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
}
// Get node names for tooltip
function getLinkNodeNames(link: SimulationLink): { source: string; target: string } {
const source =
typeof link.source === 'string' ? nodes.find((n) => n.id === link.source) : link.source;
const target =
typeof link.target === 'string' ? nodes.find((n) => n.id === link.target) : link.target;
return {
source: source?.name ?? 'Unknown',
target: target?.name ?? 'Unknown',
};
}
// Check if a node is connected to selected node
function isConnectedToSelected(nodeId: string): boolean {
if (!selectedNodeId) return false;
if (nodeId === selectedNodeId) return true;
return links.some((link) => {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
return (
(sourceId === selectedNodeId && targetId === nodeId) ||
(targetId === selectedNodeId && sourceId === nodeId)
);
});
}
// Export functions for parent component
export { resetZoom, zoomIn, zoomOut, focusOnSelectedNode };
</script>
<div
bind:this={containerElement}
class="network-graph-container"
onmousemove={handleDrag}
onmouseup={handleDragEnd}
onmouseleave={handleDragEnd}
role="application"
aria-label="Network Graph"
>
<svg
bind:this={svgElement}
class="network-graph-svg"
style="width: 100%; height: 100%;"
onclick={handleBackgroundClick}
>
<g transform="translate({transform.x}, {transform.y}) scale({transform.k})">
<!-- Links -->
<g class="links">
{#each links as link}
{@const coords = getLinkCoords(link)}
{@const sourceId = typeof link.source === 'string' ? link.source : link.source.id}
{@const targetId = typeof link.target === 'string' ? link.target : link.target.id}
{@const isHighlighted =
selectedNodeId && (sourceId === selectedNodeId || targetId === selectedNodeId)}
<!-- Invisible wider line for easier hover -->
<line
x1={coords.x1}
y1={coords.y1}
x2={coords.x2}
y2={coords.y2}
stroke="transparent"
stroke-width="20"
class="link-hitbox"
onmouseenter={(e) => handleLinkMouseEnter(e, link)}
onmousemove={handleLinkMouseMove}
onmouseleave={handleLinkMouseLeave}
/>
<!-- Visible link -->
<line
x1={coords.x1}
y1={coords.y1}
x2={coords.x2}
y2={coords.y2}
stroke-width={Math.max(1, link.strength / 25)}
class="link"
class:highlighted={isHighlighted}
class:dimmed={selectedNodeId && !isHighlighted}
class:hovered={hoveredLink === link}
pointer-events="none"
/>
{/each}
</g>
<!-- Nodes -->
<g class="nodes">
{#each nodes as node (node.id)}
{@const isSelected = node.id === selectedNodeId}
{@const isConnected = isConnectedToSelected(node.id)}
{@const isDimmed = selectedNodeId && !isConnected}
{@const nodeRadius = isSelected ? NODE_CONFIG.selectedRadius : NODE_CONFIG.radius}
{@const avatarRadius = isSelected
? NODE_CONFIG.selectedAvatarRadius
: NODE_CONFIG.avatarRadius}
{@const badgeOffset = isSelected
? NODE_CONFIG.selectedBadgeOffset
: NODE_CONFIG.badgeOffset}
<g
transform="translate({node.x ?? 0}, {node.y ?? 0})"
class="node"
class:selected={isSelected}
class:connected={isConnected && !isSelected}
class:dimmed={isDimmed}
onmousedown={(e) => handleDragStart(e, node)}
onclick={() => handleNodeClick(node)}
ondblclick={() => handleNodeDoubleClick(node)}
role="button"
tabindex="0"
aria-label={node.name}
>
<!-- Node circle -->
<circle r={nodeRadius} fill={stringToColor(node.name)} class="node-circle" />
<!-- Avatar image or initials -->
{#if node.photoUrl}
<clipPath id="clip-{node.id}">
<circle r={avatarRadius} />
</clipPath>
<image
href={node.photoUrl}
x={-avatarRadius}
y={-avatarRadius}
width={avatarRadius * 2}
height={avatarRadius * 2}
clip-path="url(#clip-{node.id})"
preserveAspectRatio="xMidYMid slice"
/>
{:else}
<text
class="node-initials"
text-anchor="middle"
dominant-baseline="central"
fill="white"
font-size={isSelected
? LABEL_CONFIG.selectedInitialsFontSize
: LABEL_CONFIG.initialsFontSize}
font-weight="600"
>
{getInitials(node.name)}
</text>
{/if}
<!-- Favorite indicator -->
{#if node.isFavorite}
<circle cx={badgeOffset} cy={-badgeOffset} r="10" fill="hsl(var(--background))" />
<text
x={badgeOffset}
y={-badgeOffset}
text-anchor="middle"
dominant-baseline="central"
font-size="12"
>
&#11088;
</text>
{/if}
<!-- Connection count badge -->
{#if node.connectionCount > 0}
<circle cx={-badgeOffset} cy={-badgeOffset} r="12" fill="hsl(var(--primary))" />
<text
x={-badgeOffset}
y={-badgeOffset}
text-anchor="middle"
dominant-baseline="central"
fill="white"
font-size="11"
font-weight="600"
>
{node.connectionCount}
</text>
{/if}
<!-- Node label (counter-scaled for zoom independence) -->
<g transform="scale({1 / transform.k})">
<text
y={(isSelected ? LABEL_CONFIG.selectedNameOffset : LABEL_CONFIG.nameOffset) *
transform.k}
class="node-label"
text-anchor="middle"
font-size={isSelected
? LABEL_CONFIG.selectedNameFontSize
: LABEL_CONFIG.nameFontSize}
font-weight={isSelected ? '600' : '500'}
>
{node.name}
</text>
<!-- Subtitle label (e.g., company) -->
{#if node.subtitle}
{@const labelOffset =
(isSelected ? LABEL_CONFIG.selectedNameOffset : LABEL_CONFIG.nameOffset) *
transform.k}
<text
y={labelOffset + LABEL_CONFIG.subtitleGap}
class="node-subtitle"
text-anchor="middle"
font-size={LABEL_CONFIG.subtitleFontSize}
>
{node.subtitle}
</text>
{/if}
</g>
</g>
{/each}
</g>
</g>
</svg>
<!-- Empty state -->
{#if nodes.length === 0}
<div class="empty-state">
<div class="empty-icon">&#128279;</div>
<p class="empty-title">Keine Verbindungen gefunden</p>
<p class="empty-description">Elemente werden verbunden, wenn sie gemeinsame Tags haben.</p>
</div>
{/if}
<!-- Link tooltip -->
{#if hoveredLink}
{@const names = getLinkNodeNames(hoveredLink)}
<div class="link-tooltip" style="left: {tooltipPosition.x}px; top: {tooltipPosition.y}px;">
<div class="tooltip-header">
<span class="tooltip-source">{names.source}</span>
<span class="tooltip-arrow"></span>
<span class="tooltip-target">{names.target}</span>
</div>
<div class="tooltip-strength">
<span class="strength-label">Stärke:</span>
<span class="strength-value">{hoveredLink.strength}%</span>
<div class="strength-bar">
<div class="strength-fill" style="width: {hoveredLink.strength}%"></div>
</div>
</div>
<div class="tooltip-tags">
{#each hoveredLink.sharedTags as tag}
<span class="tooltip-tag">{tag}</span>
{/each}
</div>
</div>
{/if}
</div>
<style>
.network-graph-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
background: hsl(var(--background));
}
.network-graph-svg {
display: block;
cursor: grab;
}
.network-graph-svg:active {
cursor: grabbing;
}
/* Links */
.link {
stroke: hsl(var(--muted-foreground) / 0.3);
transition:
stroke 0.2s,
stroke-width 0.2s,
opacity 0.2s;
}
.link.highlighted {
stroke: hsl(var(--primary));
stroke-width: 3 !important;
}
.link.dimmed {
opacity: 0.1;
}
.link.hovered {
stroke: hsl(var(--primary));
stroke-width: 3 !important;
}
.link-hitbox {
cursor: pointer;
}
/* Nodes */
.node {
cursor: pointer;
transition: opacity 0.2s;
}
.node:hover .node-circle {
filter: brightness(1.1);
}
.node.selected .node-circle {
stroke: hsl(var(--primary));
stroke-width: 4;
}
.node.connected .node-circle {
stroke: hsl(var(--primary) / 0.5);
stroke-width: 2;
}
.node.dimmed {
opacity: 0.3;
}
.node-circle {
transition:
r 0.2s,
stroke 0.2s,
stroke-width 0.2s,
filter 0.2s;
}
.node-initials {
pointer-events: none;
user-select: none;
}
.node-label {
fill: hsl(var(--foreground));
pointer-events: none;
user-select: none;
}
.node-subtitle {
fill: hsl(var(--muted-foreground));
pointer-events: none;
user-select: none;
}
/* Empty state */
.empty-state {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
padding: 2rem;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.empty-title {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 0.5rem;
}
.empty-description {
color: hsl(var(--muted-foreground));
max-width: 300px;
line-height: 1.5;
}
/* Link tooltip */
.link-tooltip {
position: absolute;
transform: translate(-50%, -100%) translateY(-12px);
padding: 0.75rem 1rem;
background: hsl(var(--popover));
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
box-shadow: 0 4px 12px hsl(var(--foreground) / 0.1);
pointer-events: none;
z-index: 100;
min-width: 200px;
max-width: 300px;
}
.tooltip-header {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--foreground));
}
.tooltip-arrow {
color: hsl(var(--muted-foreground));
}
.tooltip-strength {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.625rem;
font-size: 0.75rem;
}
.strength-label {
color: hsl(var(--muted-foreground));
}
.strength-value {
font-weight: 600;
color: hsl(var(--primary));
}
.strength-bar {
flex: 1;
height: 4px;
background: hsl(var(--muted));
border-radius: 2px;
overflow: hidden;
}
.strength-fill {
height: 100%;
background: hsl(var(--primary));
border-radius: 2px;
transition: width 0.2s;
}
.tooltip-tags {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.tooltip-tag {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
background: hsl(var(--primary) / 0.1);
border: 1px solid hsl(var(--primary) / 0.2);
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--primary));
}
</style>

View file

@ -0,0 +1,86 @@
/**
* Generate a consistent HSL color from a string
* @param str - Input string (e.g., name)
* @returns HSL color string
*/
export function stringToColor(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = hash % 360;
return `hsl(${hue}, 70%, 50%)`;
}
/**
* Get initials from a name
* @param name - Full name
* @returns 1-2 character initials
*/
export function getInitials(name: string): string {
const parts = name.trim().split(' ').filter(Boolean);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
}
/**
* D3 Force simulation default parameters
*/
export const SIMULATION_CONFIG = {
/** Distance between linked nodes */
linkDistance: 100,
/** Strength of links (0-1) */
linkStrength: 0.5,
/** Charge strength (negative = repulsion) */
chargeStrength: -300,
/** Collision radius for nodes */
collisionRadius: 50,
/** Initial alpha for simulation */
initialAlpha: 1,
/** Alpha for reheating simulation */
reheatAlpha: 0.3,
/** Zoom scale extent */
zoomExtent: [0.1, 4] as [number, number],
} as const;
/**
* Node size configuration
*/
export const NODE_CONFIG = {
/** Default node radius */
radius: 36,
/** Selected node radius */
selectedRadius: 40,
/** Avatar clip radius (slightly smaller than node) */
avatarRadius: 34,
/** Selected avatar clip radius */
selectedAvatarRadius: 38,
/** Badge offset from center */
badgeOffset: 25,
/** Selected badge offset */
selectedBadgeOffset: 28,
} as const;
/**
* Label configuration
*/
export const LABEL_CONFIG = {
/** Font size for name label */
nameFontSize: 18,
/** Selected name font size */
selectedNameFontSize: 20,
/** Font size for subtitle label */
subtitleFontSize: 14,
/** Y offset for name label */
nameOffset: 58,
/** Selected name Y offset */
selectedNameOffset: 62,
/** Gap between name and subtitle */
subtitleGap: 22,
/** Font size for initials */
initialsFontSize: 18,
/** Selected initials font size */
selectedInitialsFontSize: 20,
} as const;

Some files were not shown because too many files have changed in this diff Show more