mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 12:53:35 +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
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue