mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
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:
parent
e84371aa94
commit
ee42b6cc76
381 changed files with 39284 additions and 6275 deletions
32
packages/shared-help-content/content/contact/de/support.md
Normal file
32
packages/shared-help-content/content/contact/de/support.md
Normal 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
|
||||
32
packages/shared-help-content/content/contact/en/support.md
Normal file
32
packages/shared-help-content/content/contact/en/support.md
Normal 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
|
||||
21
packages/shared-help-content/content/faq/de/account.md
Normal file
21
packages/shared-help-content/content/faq/de/account.md
Normal 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.
|
||||
21
packages/shared-help-content/content/faq/de/billing.md
Normal file
21
packages/shared-help-content/content/faq/de/billing.md
Normal 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.
|
||||
23
packages/shared-help-content/content/faq/de/privacy.md
Normal file
23
packages/shared-help-content/content/faq/de/privacy.md
Normal 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).
|
||||
21
packages/shared-help-content/content/faq/en/account.md
Normal file
21
packages/shared-help-content/content/faq/en/account.md
Normal 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.
|
||||
21
packages/shared-help-content/content/faq/en/billing.md
Normal file
21
packages/shared-help-content/content/faq/en/billing.md
Normal 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.
|
||||
23
packages/shared-help-content/content/faq/en/privacy.md
Normal file
23
packages/shared-help-content/content/faq/en/privacy.md
Normal 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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
44
packages/shared-help-content/package.json
Normal file
44
packages/shared-help-content/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
52
packages/shared-help-content/src/index.ts
Normal file
52
packages/shared-help-content/src/index.ts
Normal 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';
|
||||
292
packages/shared-help-content/src/loader.ts
Normal file
292
packages/shared-help-content/src/loader.ts
Normal 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;
|
||||
}
|
||||
119
packages/shared-help-content/src/merger.ts
Normal file
119
packages/shared-help-content/src/merger.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
86
packages/shared-help-content/src/parser.ts
Normal file
86
packages/shared-help-content/src/parser.ts
Normal 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() + '...';
|
||||
}
|
||||
209
packages/shared-help-content/src/search.ts
Normal file
209
packages/shared-help-content/src/search.ts
Normal 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);
|
||||
}
|
||||
16
packages/shared-help-content/tsconfig.json
Normal file
16
packages/shared-help-content/tsconfig.json
Normal 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"]
|
||||
}
|
||||
24
packages/shared-help-mobile/package.json
Normal file
24
packages/shared-help-mobile/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
46
packages/shared-help-mobile/src/components/CategoryTabs.tsx
Normal file
46
packages/shared-help-mobile/src/components/CategoryTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
packages/shared-help-mobile/src/components/ContactCard.tsx
Normal file
71
packages/shared-help-mobile/src/components/ContactCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
packages/shared-help-mobile/src/components/FAQItem.tsx
Normal file
57
packages/shared-help-mobile/src/components/FAQItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
packages/shared-help-mobile/src/components/FAQList.tsx
Normal file
39
packages/shared-help-mobile/src/components/FAQList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
packages/shared-help-mobile/src/components/FeatureCard.tsx
Normal file
49
packages/shared-help-mobile/src/components/FeatureCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
packages/shared-help-mobile/src/components/FeaturesList.tsx
Normal file
26
packages/shared-help-mobile/src/components/FeaturesList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
packages/shared-help-mobile/src/components/HelpSearchBar.tsx
Normal file
42
packages/shared-help-mobile/src/components/HelpSearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
packages/shared-help-mobile/src/hooks/useHelpContent.ts
Normal file
51
packages/shared-help-mobile/src/hooks/useHelpContent.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
32
packages/shared-help-mobile/src/index.ts
Normal file
32
packages/shared-help-mobile/src/index.ts
Normal 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';
|
||||
206
packages/shared-help-mobile/src/screens/HelpScreen.tsx
Normal file
206
packages/shared-help-mobile/src/screens/HelpScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
packages/shared-help-mobile/src/types.ts
Normal file
95
packages/shared-help-mobile/src/types.ts
Normal 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'>;
|
||||
}
|
||||
17
packages/shared-help-mobile/tsconfig.json
Normal file
17
packages/shared-help-mobile/tsconfig.json
Normal 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"]
|
||||
}
|
||||
35
packages/shared-help-types/package.json
Normal file
35
packages/shared-help-types/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
152
packages/shared-help-types/src/content.ts
Normal file
152
packages/shared-help-types/src/content.ts
Normal 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;
|
||||
}
|
||||
13
packages/shared-help-types/src/index.ts
Normal file
13
packages/shared-help-types/src/index.ts
Normal 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';
|
||||
130
packages/shared-help-types/src/schemas.ts
Normal file
130
packages/shared-help-types/src/schemas.ts
Normal 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>;
|
||||
71
packages/shared-help-types/src/search.ts
Normal file
71
packages/shared-help-types/src/search.ts
Normal 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;
|
||||
}
|
||||
16
packages/shared-help-types/tsconfig.json
Normal file
16
packages/shared-help-types/tsconfig.json
Normal 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"]
|
||||
}
|
||||
65
packages/shared-help-ui/package.json
Normal file
65
packages/shared-help-ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
152
packages/shared-help-ui/src/components/ChangelogEntry.svelte
Normal file
152
packages/shared-help-ui/src/components/ChangelogEntry.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
123
packages/shared-help-ui/src/components/ContactSection.svelte
Normal file
123
packages/shared-help-ui/src/components/ContactSection.svelte
Normal 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}
|
||||
46
packages/shared-help-ui/src/components/FAQItem.svelte
Normal file
46
packages/shared-help-ui/src/components/FAQItem.svelte
Normal 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>
|
||||
117
packages/shared-help-ui/src/components/FAQSection.svelte
Normal file
117
packages/shared-help-ui/src/components/FAQSection.svelte
Normal 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>
|
||||
68
packages/shared-help-ui/src/components/FeatureCard.svelte
Normal file
68
packages/shared-help-ui/src/components/FeatureCard.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
198
packages/shared-help-ui/src/components/HelpSearch.svelte
Normal file
198
packages/shared-help-ui/src/components/HelpSearch.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
33
packages/shared-help-ui/src/index.ts
Normal file
33
packages/shared-help-ui/src/index.ts
Normal 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';
|
||||
169
packages/shared-help-ui/src/pages/HelpPage.svelte
Normal file
169
packages/shared-help-ui/src/pages/HelpPage.svelte
Normal 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>
|
||||
147
packages/shared-help-ui/src/types.ts
Normal file
147
packages/shared-help-ui/src/types.ts
Normal 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;
|
||||
}
|
||||
16
packages/shared-help-ui/tsconfig.json
Normal file
16
packages/shared-help-ui/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
65
packages/shared-i18n/src/translations/help/de.json
Normal file
65
packages/shared-i18n/src/translations/help/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
65
packages/shared-i18n/src/translations/help/en.json
Normal file
65
packages/shared-i18n/src/translations/help/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
65
packages/shared-i18n/src/translations/help/es.json
Normal file
65
packages/shared-i18n/src/translations/help/es.json
Normal 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"
|
||||
}
|
||||
}
|
||||
65
packages/shared-i18n/src/translations/help/fr.json
Normal file
65
packages/shared-i18n/src/translations/help/fr.json
Normal 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"
|
||||
}
|
||||
}
|
||||
108
packages/shared-i18n/src/translations/help/index.ts
Normal file
108
packages/shared-i18n/src/translations/help/index.ts
Normal 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;
|
||||
}
|
||||
65
packages/shared-i18n/src/translations/help/it.json
Normal file
65
packages/shared-i18n/src/translations/help/it.json
Normal 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"
|
||||
}
|
||||
}
|
||||
27
packages/shared-tags/package.json
Normal file
27
packages/shared-tags/package.json
Normal 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"
|
||||
}
|
||||
145
packages/shared-tags/src/client.ts
Normal file
145
packages/shared-tags/src/client.ts
Normal 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);
|
||||
}
|
||||
2
packages/shared-tags/src/index.ts
Normal file
2
packages/shared-tags/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './types';
|
||||
export * from './client';
|
||||
39
packages/shared-tags/src/types.ts
Normal file
39
packages/shared-tags/src/types.ts
Normal 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;
|
||||
};
|
||||
19
packages/shared-tags/tsconfig.json
Normal file
19
packages/shared-tags/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
312
packages/shared-theme-ui/src/pages/CommunityThemesPage.svelte
Normal file
312
packages/shared-theme-ui/src/pages/CommunityThemesPage.svelte
Normal 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>
|
||||
276
packages/shared-theme-ui/src/pages/ThemeEditorPage.svelte
Normal file
276
packages/shared-theme-ui/src/pages/ThemeEditorPage.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
506
packages/shared-theme/src/custom-themes-store.svelte.ts
Normal file
506
packages/shared-theme/src/custom-themes-store.svelte.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
601
packages/shared-ui/src/command-bar/CommandBar.svelte
Normal file
601
packages/shared-ui/src/command-bar/CommandBar.svelte
Normal 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>
|
||||
2
packages/shared-ui/src/command-bar/index.ts
Normal file
2
packages/shared-ui/src/command-bar/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as CommandBar } from './CommandBar.svelte';
|
||||
export type { CommandBarItem, QuickAction } from './CommandBar.svelte';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
69
packages/shared-ui/src/molecules/loaders/SkeletonCard.svelte
Normal file
69
packages/shared-ui/src/molecules/loaders/SkeletonCard.svelte
Normal 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>
|
||||
61
packages/shared-ui/src/molecules/loaders/SkeletonGrid.svelte
Normal file
61
packages/shared-ui/src/molecules/loaders/SkeletonGrid.svelte
Normal 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>
|
||||
52
packages/shared-ui/src/molecules/loaders/SkeletonList.svelte
Normal file
52
packages/shared-ui/src/molecules/loaders/SkeletonList.svelte
Normal 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>
|
||||
60
packages/shared-ui/src/molecules/loaders/SkeletonRow.svelte
Normal file
60
packages/shared-ui/src/molecules/loaders/SkeletonRow.svelte
Normal 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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
70
packages/shared-ui/src/molecules/tags/TagColorPicker.svelte
Normal file
70
packages/shared-ui/src/molecules/tags/TagColorPicker.svelte
Normal 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>
|
||||
143
packages/shared-ui/src/molecules/tags/TagEditModal.svelte
Normal file
143
packages/shared-ui/src/molecules/tags/TagEditModal.svelte
Normal 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>
|
||||
166
packages/shared-ui/src/molecules/tags/TagList.svelte
Normal file
166
packages/shared-ui/src/molecules/tags/TagList.svelte
Normal 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}
|
||||
257
packages/shared-ui/src/molecules/tags/TagSelector.svelte
Normal file
257
packages/shared-ui/src/molecules/tags/TagSelector.svelte
Normal 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>
|
||||
56
packages/shared-ui/src/molecules/tags/constants.ts
Normal file
56
packages/shared-ui/src/molecules/tags/constants.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
604
packages/shared-ui/src/organisms/network/NetworkControls.svelte
Normal file
604
packages/shared-ui/src/organisms/network/NetworkControls.svelte
Normal 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>
|
||||
671
packages/shared-ui/src/organisms/network/NetworkGraph.svelte
Normal file
671
packages/shared-ui/src/organisms/network/NetworkGraph.svelte
Normal 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"
|
||||
>
|
||||
⭐
|
||||
</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">🔗</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>
|
||||
86
packages/shared-ui/src/organisms/network/constants.ts
Normal file
86
packages/shared-ui/src/organisms/network/constants.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue