refactor(theme): remove custom theme editor and community themes

Remove unused custom theme functionality:
- Delete custom-themes-store.svelte.ts from shared-theme
- Remove ThemeEditor, ColorPicker, ThemeLivePreview components
- Remove CommunityThemeGallery, ThemeCommunityCard components
- Remove ThemeEditorPage, CommunityThemesPage
- Simplify ThemePage to show only built-in themes
- Remove editor and community routes from contacts app
- Update THEMING.md documentation

The built-in theme variants (default, ocean, forest, sunset, etc.)
provide sufficient customization. Custom theme creation was never
fully implemented and added unnecessary complexity.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-12 02:34:43 +01:00
parent db701fd648
commit 12ba2cf824
17 changed files with 44 additions and 3395 deletions

View file

@ -1,15 +0,0 @@
/**
* Custom Themes Store - Manages user's custom themes and community themes
*/
import { createCustomThemesStore } from '@manacore/shared-theme';
import { authStore } from './auth.svelte';
// Auth URL for theme API calls
const MANA_AUTH_URL = 'http://localhost:3001';
// Create the custom themes store
export const customThemesStore = createCustomThemesStore({
authUrl: MANA_AUTH_URL,
getAccessToken: () => authStore.getAccessToken(),
});

View file

@ -2,7 +2,6 @@
import { goto } from '$app/navigation';
import { ThemePage } from '@manacore/shared-theme-ui';
import { theme } from '$lib/stores/theme';
import { customThemesStore } from '$lib/stores/custom-themes.svelte';
</script>
<svelte:head>
@ -17,9 +16,4 @@
onModeChange={(m) => theme.setMode(m)}
showBackButton={true}
onBack={() => goto('/')}
showCustomThemes={true}
{customThemesStore}
onCreateTheme={() => goto('/themes/editor')}
onEditTheme={(t) => goto(`/themes/editor?id=${t.id}`)}
onCommunityThemes={() => goto('/themes/community')}
/>

View file

@ -1,29 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { CommunityThemesPage } from '@manacore/shared-theme-ui';
import { customThemesStore } from '$lib/stores/custom-themes.svelte';
import { theme } from '$lib/stores/theme';
// Get effective mode from theme store
let effectiveMode = $derived(
theme.mode === 'system'
? typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: theme.mode
) as 'light' | 'dark';
</script>
<svelte:head>
<title>Community Themes | Contacts</title>
</svelte:head>
<CommunityThemesPage
store={customThemesStore}
{effectiveMode}
onBack={() => goto('/themes')}
onSelectTheme={(t) => {
// Could open a detail modal here
console.log('Selected theme:', t);
}}
/>

View file

@ -1,75 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { ThemeEditorPage } from '@manacore/shared-theme-ui';
import { customThemesStore } from '$lib/stores/custom-themes.svelte';
import { theme } from '$lib/stores/theme';
import { onMount } from 'svelte';
import type { CustomTheme } from '@manacore/shared-theme';
// Get theme ID from URL if editing
let themeId = $derived($page.url.searchParams.get('id'));
let editingTheme = $state<CustomTheme | undefined>(undefined);
// Load theme data if editing
onMount(async () => {
if (themeId) {
await customThemesStore.loadCustomThemes();
editingTheme = customThemesStore.customThemes.find((t) => t.id === themeId);
}
});
// Get effective mode from theme store
let effectiveMode = $derived(
theme.mode === 'system'
? typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: theme.mode
) as 'light' | 'dark';
async function handleSave(themeData: {
name: string;
description?: string;
emoji: string;
lightColors: any;
darkColors: any;
}) {
if (themeId && editingTheme) {
await customThemesStore.updateTheme(themeId, themeData);
} else {
await customThemesStore.createTheme(themeData);
}
goto('/themes');
}
async function handlePublish(themeData: {
name: string;
description?: string;
emoji: string;
lightColors: any;
darkColors: any;
tags?: string[];
}) {
let theme: CustomTheme;
if (themeId && editingTheme) {
theme = await customThemesStore.updateTheme(themeId, themeData);
} else {
theme = await customThemesStore.createTheme(themeData);
}
await customThemesStore.publishTheme(theme.id, { tags: themeData.tags });
goto('/themes');
}
</script>
<svelte:head>
<title>{themeId ? 'Theme bearbeiten' : 'Neues Theme'} | Contacts</title>
</svelte:head>
<ThemeEditorPage
{effectiveMode}
existingTheme={editingTheme}
onBack={() => goto('/themes')}
onSave={handleSave}
onPublish={handlePublish}
/>

View file

@ -1,6 +1,6 @@
# Central Theming System
Das zentrale Theming-System ermöglicht einheitliches Aussehen und Benutzereinstellungen über alle Manacore-Apps hinweg. Es besteht aus mehreren Schichten: Theme-Varianten, Light/Dark-Modus, Accessibility-Einstellungen und Custom Themes.
Das zentrale Theming-System ermöglicht einheitliches Aussehen und Benutzereinstellungen über alle Manacore-Apps hinweg. Es besteht aus mehreren Schichten: Theme-Varianten, Light/Dark-Modus und Accessibility-Einstellungen.
## Architektur
@ -14,11 +14,6 @@ Das zentrale Theming-System ermöglicht einheitliches Aussehen und Benutzereinst
│ │ - locale: "de" │ │
│ │ - general: { startPages, sounds, etc. } │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ custom_themes Tabelle (Community Themes) │ │
│ │ - lightColors, darkColors, author, downloads, etc. │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌────────────────────┼────────────────────┐
@ -45,18 +40,18 @@ Es gibt 8 vordefinierte Theme-Varianten:
### Standard-Varianten (PillNav)
| Name | Farbe | Icon | Hue |
|------|-------|------|-----|
| `lume` | Gold | sparkle | 47 |
| `nature` | Grün 🌿 | leaf | 122 |
| `stone` | Blau-Grau 🪨 | hexagon | 200 |
| `ocean` | Blau 🌊 | waves | 199 |
| `lume` | Gold | sparkle | 47 |
| `nature` | Grün | leaf | 122 |
| `stone` | Blau-Grau | hexagon | 200 |
| `ocean` | Blau | waves | 199 |
### Erweiterte Varianten (Themes-Seite)
| Name | Farbe | Icon | Hue |
|------|-------|------|-----|
| `sunset` | Coral/Orange 🌅 | sun | 15 |
| `midnight` | Violett 🌙 | moon | 260 |
| `rose` | Pink 🌹 | flower | 340 |
| `lavender` | Lavendel 💜 | sparkle | 270 |
| `sunset` | Coral/Orange | sun | 15 |
| `midnight` | Violett | moon | 260 |
| `rose` | Pink | flower | 340 |
| `lavender` | Lavendel | sparkle | 270 |
## Theme-Modus
@ -223,55 +218,6 @@ a11y.resetAll();
- Respektiert `prefers-reduced-motion`
- Kann manuell überschrieben werden
## Custom Themes
### Custom Themes Store
```typescript
import { createCustomThemesStore } from '@manacore/shared-theme';
export const customThemes = createCustomThemesStore({
authUrl: 'http://localhost:3001',
getAccessToken: () => authStore.getAccessToken(),
});
// Eigene Themes laden
await customThemes.loadCustomThemes();
// Theme erstellen
const newTheme = await customThemes.createTheme({
name: 'Mein Theme',
emoji: '🎨',
lightColors: { primary: '200 80% 50%', ... },
darkColors: { primary: '200 70% 60%', ... },
});
// Community Themes durchsuchen
await customThemes.browseCommunity({
sort: 'popular',
search: 'dark',
});
// Theme herunterladen
await customThemes.downloadTheme(themeId);
// Theme anwenden
customThemes.applyCustomTheme(theme);
```
### Theme Editor
Der Theme Editor erlaubt das visuelle Erstellen von Themes:
**Hauptfarben (immer sichtbar):**
- Primary, Background, Surface, Foreground
- Error, Success, Warning
**Erweiterte Farben (zugeklappt):**
- PrimaryForeground, Secondary, SecondaryForeground
- SurfaceHover, SurfaceElevated, Muted, MutedForeground
- Border, BorderStrong, Input, Ring
## UI-Komponenten
### ThemePage
@ -285,10 +231,13 @@ Vollständige Themes-Seite mit allen Optionen:
</script>
<ThemePage
themeStore={theme}
currentVariant={theme.variant}
onSelectTheme={(v) => theme.setVariant(v)}
showModeSelector={true}
currentMode={theme.mode}
onModeChange={(m) => theme.setMode(m)}
a11yStore={a11y}
showAccessibility={true}
showPinnedThemes={true}
showA11ySettings={true}
/>
```
@ -406,7 +355,6 @@ theme: {
| `src/a11y-constants.ts` | A11y Konstanten |
| `src/a11y-utils.ts` | A11y Helper |
| `src/user-settings-store.svelte.ts` | Server-Sync Store |
| `src/custom-themes-store.svelte.ts` | Custom Themes Store |
| `src/utils.ts` | Theme Utilities |
| `src/app-routes.ts` | Start-Page Konfiguration |
@ -420,11 +368,7 @@ theme: {
| `src/components/ThemeCard.svelte` | Theme-Vorschau Karte |
| `src/components/ThemeGrid.svelte` | Grid von Theme-Karten |
| `src/components/A11ySettings.svelte` | A11y Einstellungen |
| `src/components/editor/` | Theme Editor Komponenten |
| `src/components/community/` | Community Themes Komponenten |
| `src/pages/ThemePage.svelte` | Vollständige Themes-Seite |
| `src/pages/ThemeEditorPage.svelte` | Theme Editor Seite |
| `src/pages/CommunityThemesPage.svelte` | Community Themes |
## Integration in eine App
@ -485,7 +429,15 @@ export const a11y = createA11yStore({
import { theme, a11y } from '$lib/stores/theme';
</script>
<ThemePage themeStore={theme} a11yStore={a11y} />
<ThemePage
currentVariant={theme.variant}
onSelectTheme={(v) => theme.setVariant(v)}
showModeSelector={true}
currentMode={theme.mode}
onModeChange={(m) => theme.setMode(m)}
a11yStore={a11y}
showA11ySettings={true}
/>
```
## Vorteile
@ -493,5 +445,4 @@ export const a11y = createA11yStore({
- **Konsistenz:** Alle Apps sehen einheitlich aus
- **User Experience:** Theme-Einstellungen werden gespeichert
- **Accessibility:** Barrierefreiheit ist eingebaut
- **Anpassbarkeit:** Nutzer können eigene Themes erstellen
- **Community:** Themes können geteilt werden
- **Einfachheit:** 8 vordefinierte Themes zur Auswahl

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,29 +1,11 @@
<script lang="ts">
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 { ThemeVariant, ThemeMode, A11yStore } from '@manacore/shared-theme';
import { ArrowLeft, Sun, Moon, Desktop } 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;
@ -55,13 +37,6 @@
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[];
@ -85,26 +60,10 @@
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 });
@ -114,23 +73,6 @@
{ 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">
@ -158,31 +100,6 @@
</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-6">
@ -207,154 +124,22 @@
</section>
{/if}
<!-- 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}
<!-- 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>
<!-- A11y Settings -->
{#if showA11ySettings && a11yStore}

View file

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

View file

@ -28,18 +28,6 @@ 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
@ -48,9 +36,6 @@ 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,
@ -81,9 +66,6 @@ export { createA11yStore } from './a11y-store.svelte';
// User Settings Store
export { createUserSettingsStore } from './user-settings-store.svelte';
// Custom Themes Store
export { createCustomThemesStore } from './custom-themes-store.svelte';
// Utils
export {
isBrowser,

View file

@ -359,7 +359,7 @@ export const DEFAULT_GENERAL_SETTINGS: GeneralSettings = {
* Default global settings
*/
export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
nav: { desktopPosition: 'top', sidebarCollapsed: false, hiddenNavItems: {} },
nav: { desktopPosition: 'bottom', sidebarCollapsed: false, hiddenNavItems: {} },
theme: { mode: 'system', colorScheme: 'ocean', pinnedThemes: [] },
locale: 'de',
general: DEFAULT_GENERAL_SETTINGS,