feat(theme): add ThemePage components and distinct background colors

- Add unique background colors for each theme variant:
  - Lume: warm cream/gold tint
  - Nature: green tint in dark mode
  - Stone: blue-gray tint in dark mode
  - Ocean: blue tint in dark mode

- Create shared-theme-ui components:
  - ThemeColorPreview: color circles preview component
  - ThemeCard: individual theme card with status support
  - ThemeGrid: responsive grid layout
  - ThemePage: full page component with mode selector

- Integrate theme page in Chat app:
  - Add /themes route with ThemePage component
  - Add "🎨 Alle Themes" link to PillNavigation dropdown
  - Add palette icon to shared-ui icon set

- Migrate Presi and Picture apps to shared-theme system
- Update semantic color usage across all apps

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-29 09:03:20 +01:00
parent 129692812b
commit 54383bf7c2
92 changed files with 1793 additions and 1936 deletions

View file

@ -0,0 +1,136 @@
<script lang="ts">
import type { ThemeVariant } from '@manacore/shared-theme';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import { Check, Lock, Clock, Star } from '@manacore/shared-icons';
import type { ThemeStatus } from '../types';
import ThemeColorPreview from './ThemeColorPreview.svelte';
interface Props {
variant: ThemeVariant;
isActive: boolean;
status?: ThemeStatus;
onClick?: () => void;
onUnlock?: () => void;
translations?: {
locked?: string;
comingSoon?: string;
premium?: string;
unlock?: string;
lightPreview?: string;
darkPreview?: string;
};
}
let {
variant,
isActive,
status = 'available',
onClick,
onUnlock,
translations = {},
}: Props = $props();
const t = {
locked: translations.locked ?? 'Gesperrt',
comingSoon: translations.comingSoon ?? 'Bald verfügbar',
premium: translations.premium ?? 'Premium',
unlock: translations.unlock ?? 'Freischalten',
lightPreview: translations.lightPreview ?? 'Hell',
darkPreview: translations.darkPreview ?? 'Dunkel',
};
const definition = $derived(THEME_DEFINITIONS[variant]);
const isAvailable = $derived(status === 'available');
const isLocked = $derived(status === 'locked');
const isComingSoon = $derived(status === 'coming_soon');
const isPremium = $derived(status === 'premium');
function handleClick() {
if (isAvailable && onClick) {
onClick();
}
}
function handleUnlock(e: MouseEvent) {
e.stopPropagation();
if (onUnlock) {
onUnlock();
}
}
</script>
<button
type="button"
onclick={handleClick}
disabled={!isAvailable}
class="relative w-full p-4 rounded-xl border-2 transition-all text-left
{isActive
? 'border-primary bg-primary/5 ring-2 ring-primary/20'
: 'border-border bg-surface hover:border-primary/50'}
{!isAvailable ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}
{isPremium ? 'border-yellow-500/50' : ''}"
>
<!-- Premium badge -->
{#if isPremium}
<div
class="absolute -top-2 -right-2 flex items-center gap-1 px-2 py-0.5
bg-yellow-500 text-yellow-950 text-xs font-medium rounded-full"
>
<Star size={12} weight="fill" />
{t.premium}
</div>
{/if}
<!-- Active checkmark -->
{#if isActive && isAvailable}
<div
class="absolute top-3 right-3 w-6 h-6 flex items-center justify-center
bg-primary text-primary-foreground rounded-full"
>
<Check size={14} weight="bold" />
</div>
{/if}
<!-- Header -->
<div class="flex items-center gap-2 mb-3">
<span class="text-xl">{definition.emoji}</span>
<span class="font-semibold text-foreground">{definition.label}</span>
</div>
<!-- Color previews -->
<div class="space-y-2 mb-3">
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground w-10">{t.lightPreview}</span>
<ThemeColorPreview {variant} mode="light" size="sm" />
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground w-10">{t.darkPreview}</span>
<ThemeColorPreview {variant} mode="dark" size="sm" />
</div>
</div>
<!-- Status badges -->
{#if isLocked}
<div class="flex items-center justify-between">
<div class="flex items-center gap-1 text-muted-foreground text-sm">
<Lock size={14} weight="bold" />
{t.locked}
</div>
{#if onUnlock}
<button
type="button"
onclick={handleUnlock}
class="px-2 py-1 text-xs font-medium text-primary bg-primary/10
rounded hover:bg-primary/20 transition-colors"
>
{t.unlock}
</button>
{/if}
</div>
{:else if isComingSoon}
<div class="flex items-center gap-1 text-muted-foreground text-sm">
<Clock size={14} weight="bold" />
{t.comingSoon}
</div>
{/if}
</button>

View file

@ -0,0 +1,48 @@
<script lang="ts">
import type { ThemeVariant } from '@manacore/shared-theme';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
interface Props {
variant: ThemeVariant;
mode: 'light' | 'dark';
size?: 'sm' | 'md' | 'lg';
}
let { variant, mode, size = 'md' }: Props = $props();
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6',
};
const gapClasses = {
sm: '-ml-1.5',
md: '-ml-2',
lg: '-ml-2.5',
};
const colors = $derived(() => {
const def = THEME_DEFINITIONS[variant];
const themeColors = mode === 'dark' ? def.dark : def.light;
return [
{ name: 'primary', value: themeColors.primary },
{ name: 'secondary', value: themeColors.secondary },
{ name: 'background', value: themeColors.background },
{ name: 'surface', value: themeColors.surface },
{ name: 'muted', value: themeColors.muted },
];
});
</script>
<div class="flex items-center">
{#each colors() as color, i}
<div
class="{sizeClasses[size]} rounded-full border border-white/20 shadow-sm {i > 0
? gapClasses[size]
: ''}"
style="background-color: hsl({color.value}); z-index: {5 - i};"
title={color.name}
></div>
{/each}
</div>

View file

@ -0,0 +1,58 @@
<script lang="ts">
import type { ThemeVariant } from '@manacore/shared-theme';
import { THEME_VARIANTS } from '@manacore/shared-theme';
import type { ThemeCardData, ThemePageTranslations } from '../types';
import ThemeCard from './ThemeCard.svelte';
interface Props {
currentVariant: ThemeVariant;
onSelect: (variant: ThemeVariant) => void;
themes?: ThemeCardData[];
onUnlock?: (variant: ThemeVariant) => void;
showLockedThemes?: boolean;
translations?: Partial<ThemePageTranslations>;
}
let {
currentVariant,
onSelect,
themes,
onUnlock,
showLockedThemes = true,
translations = {},
}: Props = $props();
// Build theme data - use provided themes or create defaults from THEME_VARIANTS
const themeData = $derived(() => {
if (themes) {
return showLockedThemes ? themes : themes.filter((t) => t.status === 'available');
}
// Default: all variants are available
return THEME_VARIANTS.map((variant) => ({
variant,
status: 'available' as const,
}));
});
const cardTranslations = $derived({
locked: translations.locked,
comingSoon: translations.comingSoon,
premium: translations.premium,
unlock: translations.unlock,
lightPreview: translations.lightPreview,
darkPreview: translations.darkPreview,
});
</script>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{#each themeData() 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}
translations={cardTranslations}
/>
{/each}
</div>