feat: add i18n localization with language switcher to all web apps

- Add svelte-i18n configuration with SSR support to all web apps
- Create LanguageSelector component for each app with brand colors
- Add German and English locale files
- Integrate language switcher into login pages via headerControls snippet
- Fix Tailwind v4 @source directives for shared package scanning
- Update AppSlider styling to match login container design

Apps updated:
- Memoro (gold #f8d62b)
- Märchenzauber (pink #FF6B9D)
- ManaDeck (purple #8b5cf6)
- ManaCore (indigo #6366f1)

🤖 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-25 01:41:25 +01:00
parent bd869dfe09
commit 926ca231b5
147 changed files with 7090 additions and 2276 deletions

View file

@ -41,6 +41,7 @@
const isInteractive = $derived(interactive || !!onclick);
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="card card--{variant} card--padding-{padding} {isInteractive ? 'card--interactive' : ''} {fullWidth ? 'card--full-width' : ''} {className}"
{onclick}

View file

@ -1,5 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
type TextVariant = 'body' | 'body-secondary' | 'small' | 'large' | 'muted';
type TextAlign = 'left' | 'center' | 'right';
@ -10,7 +11,7 @@
align?: TextAlign;
weight?: TextWeight;
class?: string;
children?: any;
children?: Snippet;
}
let {

View file

@ -137,6 +137,8 @@
<!-- Actions -->
{#if actions}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="data-card__actions flex-shrink-0 flex items-center gap-1" onclick={(e) => e.stopPropagation()}>
{@render actions()}
</div>

View file

@ -13,6 +13,8 @@
required?: boolean;
autocomplete?: HTMLInputAttributes['autocomplete'];
class?: string;
id?: string;
name?: string;
}
let {
@ -26,7 +28,9 @@
disabled = false,
required = false,
autocomplete,
class: className = ''
class: className = '',
id = `input-${Math.random().toString(36).slice(2, 9)}`,
name
}: Props = $props();
function handleInput(e: Event) {
@ -43,15 +47,17 @@
<div class="flex flex-col gap-1.5 {className}">
{#if label}
<label class="text-sm font-medium text-theme">
<label for={id} class="text-sm font-medium text-theme">
{label}
{#if required}
<span class="text-red-500">*</span>
{/if}
</label>
{/if}
<input
{id}
{name}
{type}
{value}
{placeholder}

View file

@ -1,9 +1,5 @@
<script lang="ts">
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
import type { SelectOption } from './Select.types';
interface Props {
/** Current selected value */
@ -24,6 +20,8 @@
required?: boolean;
/** Additional CSS classes */
class?: string;
/** Unique ID for accessibility */
id?: string;
}
let {
@ -35,7 +33,8 @@
error,
disabled = false,
required = false,
class: className = ''
class: className = '',
id = `select-${Math.random().toString(36).slice(2, 9)}`
}: Props = $props();
function handleChange(e: Event) {
@ -47,7 +46,7 @@
<div class="select-wrapper {className}">
{#if label}
<label class="select-label">
<label for={id} class="select-label">
{label}
{#if required}
<span class="select-required">*</span>
@ -57,6 +56,7 @@
<div class="select-container">
<select
{id}
{value}
{disabled}
{required}

View file

@ -0,0 +1,5 @@
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}

View file

@ -26,6 +26,8 @@
autoResize?: boolean;
/** Additional CSS classes */
class?: string;
/** Unique ID for accessibility */
id?: string;
}
let {
@ -41,7 +43,8 @@
disabled = false,
required = false,
autoResize = false,
class: className = ''
class: className = '',
id = `textarea-${Math.random().toString(36).slice(2, 9)}`
}: Props = $props();
let textareaElement: HTMLTextAreaElement | null = $state(null);
@ -68,7 +71,7 @@
<div class="textarea-wrapper {className}">
{#if label}
<label class="textarea-label">
<label for={id} class="textarea-label">
{label}
{#if required}
<span class="textarea-required">*</span>
@ -77,6 +80,7 @@
{/if}
<textarea
{id}
bind:this={textareaElement}
{value}
{placeholder}

View file

@ -27,6 +27,7 @@
: 'bg-menu'} {disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}"
role="switch"
aria-checked={isOn}
aria-label="Toggle"
{disabled}
>
<span

View file

@ -69,7 +69,7 @@
onclick={handleClick}
onkeydown={handleKeyDown}
role={clickable ? 'button' : undefined}
tabindex={clickable ? 0 : -1}
tabindex={clickable ? 0 : undefined}
>
<!-- Color indicator dot -->
<div class="h-2 w-2 rounded-full" style="background-color: {tagColor}"></div>

View file

@ -1,15 +1,5 @@
<script lang="ts">
import type { Snippet } from 'svelte';
export interface AppItem {
name: string;
description: string;
longDescription: string;
icon?: string;
color: string;
comingSoon?: boolean;
status: 'published' | 'beta' | 'development' | 'planning';
}
import type { AppItem } from './AppSlider.types';
interface Props {
apps: AppItem[];
@ -120,17 +110,10 @@
<div class="flex gap-4 justify-center overflow-x-auto pb-6 scrollbar-hide snap-x snap-mandatory scroll-smooth px-4 py-4" style="perspective: 1000px;">
{#each apps as app, index}
<button
class="group relative flex-shrink-0 rounded-2xl p-5 cursor-pointer snap-center"
style="width: 160px; background-color: {hoveredApp === index
? isDark
? 'rgba(255, 255, 255, 0.08)'
: 'rgba(0, 0, 0, 0.08)'
: isDark
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.05)'}; border: 1px solid {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}; box-shadow: 0 4px 20px rgba(0, 0, 0, {isDark ? '0.3' : '0.15'}); transform: perspective(1000px) rotateX({cardRotations[index]?.rotateX || 0}deg) rotateY({cardRotations[index]?.rotateY || 0}deg); transform-style: preserve-3d; transition: transform 0.1s ease-out, background-color 0.2s ease-out;"
class="group relative flex-shrink-0 rounded-xl p-5 cursor-pointer snap-center transition-transform hover:scale-105"
style="width: 160px; background-color: {isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.7)'}; backdrop-filter: blur(10px); border: 1px solid {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"
onmouseenter={() => hoveredApp = index}
onmousemove={(e) => handleCardMouseMove(e, index, e.currentTarget)}
onmouseleave={() => { handleCardMouseLeave(index); hoveredApp = null; }}
onmouseleave={() => hoveredApp = null}
onclick={() => openModal(index)}
>
<div

View file

@ -0,0 +1,9 @@
export interface AppItem {
name: string;
description: string;
longDescription: string;
icon?: string;
color: string;
comingSoon?: boolean;
status: 'published' | 'beta' | 'development' | 'planning';
}

View file

@ -42,16 +42,21 @@
{#if visible}
<!-- Modal Backdrop -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Enter' && handleBackdropClick(e as unknown as MouseEvent)}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<!-- Modal Content -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative flex max-h-[90vh] w-full {maxWidthClasses[maxWidth]} flex-col rounded-xl border border-theme bg-menu shadow-xl"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
{#if showHeader}
<!-- Header -->