chore: archive finance, mail, moodlit apps and rename voxel-lava

- Move finance, mail, moodlit to apps-archived for later development
- Rename games/voxel-lava to games/voxelava

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-05 13:13:15 +01:00
parent c3c272abc9
commit ace7fa8f7f
427 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,153 @@
@import "tailwindcss";
@import "@manacore/shared-tailwind/themes.css";
/* Scan shared packages for Tailwind classes */
@source "../../../../../packages/shared-ui/src";
@source "../../../../../packages/shared-theme-ui/src";
/* Moods-specific CSS Variables */
@layer base {
:root {
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Border Radius */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-full: 9999px;
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
}
}
/* Mood Card Styles */
.mood-card {
background-color: hsl(var(--color-surface));
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
border: 1px solid hsl(var(--color-border));
transition: all var(--transition-base);
cursor: pointer;
}
.mood-card:hover {
border-color: hsl(var(--color-primary) / 0.5);
transform: translateY(-2px);
}
/* Color Preview */
.color-preview {
width: 100%;
height: 120px;
border-radius: var(--radius-md);
overflow: hidden;
}
/* Animated Background */
.animated-background {
background-size: 400% 400%;
animation: gradient-shift 8s ease infinite;
}
@keyframes gradient-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* Color Picker */
.color-picker-swatch {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
border: 2px solid hsl(var(--color-border));
cursor: pointer;
transition: all var(--transition-fast);
}
.color-picker-swatch:hover {
border-color: hsl(var(--color-primary));
transform: scale(1.05);
}
/* Card styles */
.card {
background-color: hsl(var(--color-surface));
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
border: 1px solid hsl(var(--color-border));
}
/* Button styles */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-weight: 500;
font-size: 0.875rem;
transition: all var(--transition-base);
cursor: pointer;
border: none;
background: transparent;
}
.btn-primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.btn-primary:hover {
background: hsl(var(--color-primary) / 0.9);
}
.btn-secondary {
background: hsl(var(--color-secondary));
color: hsl(var(--color-secondary-foreground));
}
.btn-secondary:hover {
background: hsl(var(--color-secondary) / 0.8);
}
.btn-ghost {
background: transparent;
color: hsl(var(--color-foreground));
}
.btn-ghost:hover {
background: hsl(var(--color-muted));
}
/* Input styles */
.input {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
border: 2px solid hsl(var(--color-border));
border-radius: var(--radius-md);
background-color: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
transition: border-color var(--transition-fast);
}
.input:focus {
outline: none;
border-color: hsl(var(--color-primary));
}
.input::placeholder {
color: hsl(var(--color-muted-foreground));
}

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Moodlit</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,14 @@
/**
* Feedback Service Instance for Moodlit Web App
*/
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { authStore } from '$lib/stores/auth.svelte';
const MANA_AUTH_URL = 'http://localhost:3001';
export const feedbackService = createFeedbackService({
apiUrl: MANA_AUTH_URL,
appId: 'moodlit',
getAuthToken: async () => authStore.getAccessToken(),
});

View file

@ -0,0 +1,172 @@
/**
* Moodlit Web Auth Configuration
*
* This file initializes the shared auth package for the moodlit web app.
*/
import { PUBLIC_MANA_CORE_AUTH_URL, PUBLIC_BACKEND_URL } from '$env/static/public';
import {
createAuthService,
createTokenManager,
setStorageAdapter,
setDeviceAdapter,
setNetworkAdapter,
setupFetchInterceptor,
type StorageAdapter,
type DeviceManagerAdapter,
type NetworkAdapter,
type DeviceInfo,
} from '@manacore/shared-auth';
// Storage keys
const STORAGE_KEYS = {
APP_TOKEN: 'moodlit_appToken',
REFRESH_TOKEN: 'moodlit_refreshToken',
USER_EMAIL: 'moodlit_userEmail',
DEVICE_ID: 'moodlit_device_id',
};
/**
* Session storage adapter for moodlit web
* Uses sessionStorage for tokens (clears on tab close)
* Uses localStorage for device ID (persists)
*/
const sessionStorageAdapter: StorageAdapter = {
async getItem<T = string>(key: string): Promise<T | null> {
if (typeof window === 'undefined') return null;
const value = sessionStorage.getItem(key);
if (value === null) return null;
try {
return JSON.parse(value) as T;
} catch {
return value as T;
}
},
async setItem(key: string, value: string): Promise<void> {
if (typeof window === 'undefined') return;
sessionStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
},
async removeItem(key: string): Promise<void> {
if (typeof window === 'undefined') return;
sessionStorage.removeItem(key);
},
};
/**
* Device manager adapter for web
*/
const webDeviceAdapter: DeviceManagerAdapter = {
async getDeviceInfo(): Promise<DeviceInfo> {
if (typeof window === 'undefined') {
return {
deviceId: '',
deviceName: 'Server',
deviceType: 'web',
};
}
const deviceId = (await webDeviceAdapter.getStoredDeviceId()) || generateDeviceId();
localStorage.setItem(STORAGE_KEYS.DEVICE_ID, deviceId);
const userAgent = navigator.userAgent;
let deviceName = 'Web Browser';
if (userAgent.includes('Mac')) deviceName = 'Mac';
else if (userAgent.includes('Windows')) deviceName = 'Windows';
else if (userAgent.includes('Linux')) deviceName = 'Linux';
return {
deviceId,
deviceName,
deviceType: 'web',
platform: 'web',
};
},
async getStoredDeviceId(): Promise<string | null> {
if (typeof window === 'undefined') return null;
return localStorage.getItem(STORAGE_KEYS.DEVICE_ID);
},
};
/**
* Network adapter for web
*/
const webNetworkAdapter: NetworkAdapter = {
async isDeviceConnected(): Promise<boolean> {
if (typeof navigator === 'undefined') return true;
return navigator.onLine;
},
async hasStableConnection(): Promise<boolean> {
if (typeof navigator === 'undefined') return true;
return navigator.onLine;
},
};
/**
* Generate a unique device ID
*/
function generateDeviceId(): string {
return `moodlit_web_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
}
// Initialize adapters
setStorageAdapter(sessionStorageAdapter);
setDeviceAdapter(webDeviceAdapter);
setNetworkAdapter(webNetworkAdapter);
// Create auth service instance
export const authService = createAuthService({
baseUrl: PUBLIC_MANA_CORE_AUTH_URL,
storageKeys: {
APP_TOKEN: STORAGE_KEYS.APP_TOKEN,
REFRESH_TOKEN: STORAGE_KEYS.REFRESH_TOKEN,
USER_EMAIL: STORAGE_KEYS.USER_EMAIL,
},
endpoints: {
signIn: '/api/v1/auth/login',
signUp: '/api/v1/auth/register',
signOut: '/api/v1/auth/logout',
refresh: '/api/v1/auth/refresh',
validate: '/api/v1/auth/validate',
forgotPassword: '/api/v1/auth/forgot-password',
googleSignIn: '/api/v1/auth/google-signin',
appleSignIn: '/api/v1/auth/apple-signin',
credits: '/api/v1/credits/balance',
},
});
// Create token manager instance
export const tokenManager = createTokenManager(authService);
// Setup fetch interceptor (only in browser)
if (typeof window !== 'undefined') {
setupFetchInterceptor(authService, tokenManager, {
backendUrl: PUBLIC_BACKEND_URL,
});
}
// Re-export useful utilities from shared-auth
export {
decodeToken,
isTokenValidLocally,
isTokenExpired,
getUserFromToken,
isB2BUser,
getB2BInfo,
TokenState,
} from '@manacore/shared-auth';
// Re-export types
export type {
UserData,
DecodedToken,
AuthResult,
CreditBalance,
B2BInfo,
} from '@manacore/shared-auth';

View file

@ -0,0 +1,43 @@
<script lang="ts">
import { locale } from 'svelte-i18n';
import { AppSlider, type AppItem } from '@manacore/shared-ui';
import {
MANA_APPS,
APP_STATUS_LABELS,
APP_SLIDER_LABELS,
getActiveManaApps,
} from '@manacore/shared-branding';
// Get current language
let currentLocale = $derived(($locale || 'de') as 'de' | 'en');
// Convert MANA_APPS to AppItem format (based on current locale)
let apps = $derived<AppItem[]>(
getActiveManaApps().map((app) => ({
name: app.name,
description: app.description[currentLocale],
longDescription: app.longDescription[currentLocale],
icon: app.icon,
color: app.color,
comingSoon: app.comingSoon,
status: app.status,
}))
);
let statusLabels = $derived(APP_STATUS_LABELS[currentLocale]);
let labels = $derived(APP_SLIDER_LABELS[currentLocale]);
function handleAppClick(app: AppItem, index: number) {
console.log('Opening app:', app.name);
}
</script>
<AppSlider
{apps}
title={labels.title}
isDark={false}
{statusLabels}
comingSoonLabel={labels.comingSoon}
openAppLabel={labels.openApp}
onAppClick={handleAppClick}
/>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { locale } from 'svelte-i18n';
import { PillDropdown } from '@manacore/shared-ui';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { setLocale, supportedLocales } from '$lib/i18n';
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as any);
}
let languageItems = $derived(
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
);
let currentLabel = $derived(getCurrentLanguageLabel(currentLocale));
</script>
<PillDropdown items={languageItems} label={currentLabel} direction="down" />

View file

@ -0,0 +1,223 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { X, Plus, Trash } from '@manacore/shared-icons';
import type { Mood, AnimationType } from '$lib/types/mood';
import { ANIMATIONS } from '$lib/types/mood';
import { getMoodGradient } from '$lib/data/default-moods';
interface Props {
isOpen: boolean;
onClose: () => void;
onSave: (mood: Omit<Mood, 'id' | 'isCustom' | 'order' | 'createdAt'>) => void;
editMood?: Mood | null;
}
let { isOpen, onClose, onSave, editMood = null }: Props = $props();
let name = $state('');
let colors = $state<string[]>(['#667eea', '#764ba2']);
let animationType = $state<AnimationType>('gradient');
// Preview mood
let previewMood = $derived<Mood>({
id: 'preview',
name: name || 'Preview',
colors,
animationType,
});
// Reset form when dialog opens/closes or when editing different mood
$effect(() => {
if (isOpen) {
if (editMood) {
name = editMood.name;
colors = [...editMood.colors];
animationType = editMood.animationType;
} else {
name = '';
colors = ['#667eea', '#764ba2'];
animationType = 'gradient';
}
}
});
function addColor() {
if (colors.length < 8) {
// Generate a random color
const randomColor =
'#' +
Math.floor(Math.random() * 16777215)
.toString(16)
.padStart(6, '0');
colors = [...colors, randomColor];
}
}
function removeColor(index: number) {
if (colors.length > 1) {
colors = colors.filter((_, i) => i !== index);
}
}
function updateColor(index: number, value: string) {
colors = colors.map((c, i) => (i === index ? value : c));
}
function handleSubmit() {
if (!name.trim()) return;
if (colors.length === 0) return;
onSave({
name: name.trim(),
colors,
animationType,
});
onClose();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
{#if isOpen}
<!-- Backdrop -->
<div
class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
onclick={onClose}
role="presentation"
></div>
<!-- Dialog -->
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<div
class="bg-[hsl(var(--color-background))] rounded-2xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto pointer-events-auto"
role="dialog"
aria-modal="true"
>
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-border">
<h2 class="text-xl font-semibold">
{editMood ? $_('createMood.editTitle') : $_('createMood.title')}
</h2>
<button
type="button"
class="p-2 rounded-lg hover:bg-muted transition-colors"
onclick={onClose}
aria-label="Close"
>
<X size={20} />
</button>
</div>
<!-- Content -->
<div class="p-4 space-y-6">
<!-- Preview -->
<div class="relative rounded-xl overflow-hidden aspect-video">
<div class="absolute inset-0" style="background: {getMoodGradient(previewMood)};"></div>
<div
class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent"
></div>
<div class="absolute inset-x-0 bottom-0 p-4">
<h3 class="text-lg font-semibold text-white drop-shadow-md">
{previewMood.name}
</h3>
<p class="text-sm text-white/70 capitalize">{previewMood.animationType}</p>
</div>
</div>
<!-- Name Input -->
<div class="space-y-2">
<label for="mood-name" class="text-sm font-medium">
{$_('createMood.name')}
</label>
<input
id="mood-name"
type="text"
bind:value={name}
placeholder={$_('createMood.namePlaceholder')}
class="w-full px-4 py-2 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
<!-- Colors -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium">{$_('createMood.colors')}</label>
<button
type="button"
class="flex items-center gap-1 px-2 py-1 text-sm rounded-lg hover:bg-muted transition-colors"
onclick={addColor}
disabled={colors.length >= 8}
>
<Plus size={16} />
{$_('createMood.addColor')}
</button>
</div>
<div class="flex flex-wrap gap-2">
{#each colors as color, i}
<div class="flex items-center gap-1">
<input
type="color"
value={color}
onchange={(e) => updateColor(i, e.currentTarget.value)}
class="w-10 h-10 rounded-lg border border-border cursor-pointer"
/>
{#if colors.length > 1}
<button
type="button"
class="p-1 rounded hover:bg-red-500/20 text-red-500 transition-colors"
onclick={() => removeColor(i)}
aria-label="Remove color"
>
<Trash size={16} />
</button>
{/if}
</div>
{/each}
</div>
</div>
<!-- Animation Type -->
<div class="space-y-2">
<label for="animation-type" class="text-sm font-medium">
{$_('createMood.animation')}
</label>
<select
id="animation-type"
bind:value={animationType}
class="w-full px-4 py-2 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary/50"
>
{#each ANIMATIONS as anim}
<option value={anim.id}>{anim.name} - {anim.description}</option>
{/each}
</select>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 p-4 border-t border-border">
<button
type="button"
class="px-4 py-2 rounded-lg hover:bg-muted transition-colors"
onclick={onClose}
>
{$_('common.cancel')}
</button>
<button
type="button"
class="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={handleSubmit}
disabled={!name.trim() || colors.length === 0}
>
{$_('common.save')}
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,188 @@
<script lang="ts">
import type { Mood } from '$lib/types/mood';
import { getMoodGradient } from '$lib/data/default-moods';
import { Heart } from '@manacore/shared-icons';
interface Props {
mood: Mood;
isActive?: boolean;
isFavorite?: boolean;
showFavorite?: boolean;
onClick?: () => void;
onFavoriteToggle?: () => void;
}
let {
mood,
isActive = false,
isFavorite = false,
showFavorite = true,
onClick,
onFavoriteToggle,
}: Props = $props();
const gradient = $derived(getMoodGradient(mood));
const animationClass = $derived(getAnimationClass(mood.animationType));
function getAnimationClass(type: string): string {
switch (type) {
case 'pulse':
case 'breath':
return 'animate-pulse-slow';
case 'wave':
return 'animate-wave';
case 'candle':
return 'animate-candle';
case 'disco':
case 'rave':
return 'animate-disco';
case 'thunder':
return 'animate-thunder';
default:
return '';
}
}
function handleFavoriteClick(e: MouseEvent) {
e.stopPropagation();
onFavoriteToggle?.();
}
function handleClick() {
onClick?.();
}
</script>
<button
type="button"
class="mood-card group relative w-full overflow-hidden rounded-2xl transition-all duration-200 hover:scale-[1.02] hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-primary/50"
class:ring-2={isActive}
class:ring-primary={isActive}
onclick={handleClick}
>
<!-- Gradient Background -->
<div class="aspect-[16/10] w-full {animationClass}" style="background: {gradient};"></div>
<!-- Overlay gradient for text readability -->
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent"></div>
<!-- Content -->
<div class="absolute inset-x-0 bottom-0 p-4">
<div class="flex items-end justify-between">
<div class="text-left">
<h3 class="font-semibold text-white drop-shadow-md">{mood.name}</h3>
<p class="text-xs text-white/70 capitalize">{mood.animationType}</p>
</div>
{#if showFavorite}
<button
type="button"
class="rounded-full p-1.5 transition-colors hover:bg-white/20"
onclick={handleFavoriteClick}
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
size={20}
weight={isFavorite ? 'fill' : 'regular'}
class={isFavorite ? 'text-red-500' : 'text-white/70'}
/>
</button>
{/if}
</div>
</div>
<!-- Custom badge -->
{#if mood.isCustom}
<div class="absolute right-2 top-2">
<span class="rounded-full bg-primary/80 px-2 py-0.5 text-xs font-medium text-white">
Custom
</span>
</div>
{/if}
</button>
<style>
@keyframes pulse-slow {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.85;
transform: scale(1.01);
}
}
@keyframes wave {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
@keyframes candle {
0%,
100% {
opacity: 1;
filter: brightness(1);
}
25% {
opacity: 0.9;
filter: brightness(0.95);
}
50% {
opacity: 0.85;
filter: brightness(1.05);
}
75% {
opacity: 0.95;
filter: brightness(0.9);
}
}
@keyframes disco {
0%,
100% {
filter: hue-rotate(0deg);
}
50% {
filter: hue-rotate(180deg);
}
}
@keyframes thunder {
0%,
95%,
100% {
opacity: 1;
}
97% {
opacity: 1;
filter: brightness(3);
}
}
.animate-pulse-slow {
animation: pulse-slow 4s ease-in-out infinite;
}
.animate-wave {
animation: wave 3s ease-in-out infinite;
}
.animate-candle {
animation: candle 0.8s ease-in-out infinite;
}
.animate-disco {
animation: disco 2s linear infinite;
}
.animate-thunder {
animation: thunder 5s ease-in-out infinite;
}
</style>

View file

@ -0,0 +1,589 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { Mood } from '$lib/types/mood';
import { getMoodGradient } from '$lib/data/default-moods';
import { X, Pause, Play, Heart, Timer } from '@manacore/shared-icons';
interface Props {
mood: Mood;
isFavorite?: boolean;
onClose: () => void;
onFavoriteToggle?: () => void;
}
let { mood, isFavorite = false, onClose, onFavoriteToggle }: Props = $props();
let isPlaying = $state(true);
let showControls = $state(true);
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
let timerActive = $state(false);
let timerMinutes = $state(5);
let timerRemaining = $state(0);
let timerInterval: ReturnType<typeof setInterval> | null = null;
const gradient = $derived(getMoodGradient(mood));
const animationClass = $derived(getAnimationClass(mood.animationType));
function getAnimationClass(type: string): string {
switch (type) {
case 'pulse':
case 'breath':
return 'animate-breath';
case 'wave':
return 'animate-wave';
case 'candle':
case 'fire':
return 'animate-candle';
case 'disco':
case 'rave':
return 'animate-disco';
case 'thunder':
return 'animate-thunder';
case 'police':
return 'animate-police';
case 'warning':
return 'animate-warning';
case 'flash':
return 'animate-flash';
case 'sos':
return 'animate-sos';
case 'scanner':
return 'animate-scanner';
case 'matrix':
return 'animate-matrix';
case 'sunrise':
return 'animate-sunrise';
case 'sunset':
return 'animate-sunset';
default:
return 'animate-gradient';
}
}
function showControlsTemporarily() {
showControls = true;
if (controlsTimeout) {
clearTimeout(controlsTimeout);
}
controlsTimeout = setTimeout(() => {
if (isPlaying) {
showControls = false;
}
}, 3000);
}
function togglePlay() {
isPlaying = !isPlaying;
if (isPlaying) {
showControlsTemporarily();
} else {
showControls = true;
}
}
function startTimer() {
timerActive = true;
timerRemaining = timerMinutes * 60;
timerInterval = setInterval(() => {
timerRemaining--;
if (timerRemaining <= 0) {
stopTimer();
onClose();
}
}, 1000);
}
function stopTimer() {
timerActive = false;
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
} else if (e.key === ' ') {
e.preventDefault();
togglePlay();
}
}
$effect(() => {
showControlsTemporarily();
return () => {
if (controlsTimeout) clearTimeout(controlsTimeout);
if (timerInterval) clearInterval(timerInterval);
};
});
</script>
<svelte:window on:keydown={handleKeydown} />
<div
class="fixed inset-0 z-50 flex items-center justify-center cursor-pointer select-none"
onclick={showControlsTemporarily}
onmousemove={showControlsTemporarily}
role="presentation"
>
<!-- Animated Background -->
<div
class="absolute inset-0 {animationClass}"
class:paused={!isPlaying}
style="background: {gradient}; background-size: 400% 400%;"
></div>
<!-- Particle Effects for certain animations -->
{#if mood.animationType === 'sparkle' || mood.animationType === 'matrix'}
<div class="particles absolute inset-0 pointer-events-none overflow-hidden">
{#each Array(20) as _, i}
<div
class="particle absolute w-1 h-1 bg-white/60 rounded-full"
style="left: {Math.random() * 100}%; animation-delay: {Math.random() *
5}s; animation-duration: {3 + Math.random() * 2}s;"
></div>
{/each}
</div>
{/if}
<!-- Controls Overlay -->
<div
class="absolute inset-0 flex flex-col transition-opacity duration-300 pointer-events-none"
class:opacity-0={!showControls}
class:opacity-100={showControls}
>
<!-- Top Bar -->
<div
class="flex items-center justify-between p-4 bg-gradient-to-b from-black/40 to-transparent pointer-events-auto"
>
<div class="flex items-center gap-3">
<button
type="button"
class="p-2 rounded-full bg-white/20 hover:bg-white/30 backdrop-blur-sm transition-colors"
onclick={(e) => {
e.stopPropagation();
onClose();
}}
aria-label="Close"
>
<X size={24} class="text-white" />
</button>
<div>
<h1 class="text-xl font-bold text-white drop-shadow-lg">{mood.name}</h1>
<p class="text-sm text-white/70 capitalize">{mood.animationType}</p>
</div>
</div>
<div class="flex items-center gap-2">
{#if timerActive}
<div class="px-3 py-1.5 rounded-full bg-white/20 backdrop-blur-sm text-white font-mono">
{formatTime(timerRemaining)}
</div>
{/if}
<button
type="button"
class="p-2 rounded-full bg-white/20 hover:bg-white/30 backdrop-blur-sm transition-colors"
onclick={(e) => {
e.stopPropagation();
onFavoriteToggle?.();
}}
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
size={20}
weight={isFavorite ? 'fill' : 'regular'}
class={isFavorite ? 'text-red-500' : 'text-white'}
/>
</button>
</div>
</div>
<!-- Center Play/Pause -->
<div class="flex-1 flex items-center justify-center pointer-events-auto">
<button
type="button"
class="p-6 rounded-full bg-white/20 hover:bg-white/30 backdrop-blur-sm transition-all hover:scale-110"
onclick={(e) => {
e.stopPropagation();
togglePlay();
}}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{#if isPlaying}
<Pause size={48} class="text-white" />
{:else}
<Play size={48} class="text-white" />
{/if}
</button>
</div>
<!-- Bottom Bar -->
<div class="p-4 bg-gradient-to-t from-black/40 to-transparent pointer-events-auto">
<div class="flex items-center justify-center gap-4">
{#if !timerActive}
<div class="flex items-center gap-2 bg-white/20 backdrop-blur-sm rounded-full px-4 py-2">
<Timer size={20} class="text-white" />
<select
class="bg-transparent text-white border-none outline-none cursor-pointer"
bind:value={timerMinutes}
onclick={(e) => e.stopPropagation()}
>
<option value={1}>1 min</option>
<option value={5}>5 min</option>
<option value={10}>10 min</option>
<option value={15}>15 min</option>
<option value={30}>30 min</option>
<option value={60}>60 min</option>
</select>
<button
type="button"
class="px-3 py-1 bg-white/20 hover:bg-white/30 rounded-full text-sm text-white transition-colors"
onclick={(e) => {
e.stopPropagation();
startTimer();
}}
>
{$_('mood.startTimer')}
</button>
</div>
{:else}
<button
type="button"
class="px-4 py-2 bg-white/20 hover:bg-white/30 backdrop-blur-sm rounded-full text-white transition-colors"
onclick={(e) => {
e.stopPropagation();
stopTimer();
}}
>
{$_('mood.stopTimer')}
</button>
{/if}
</div>
</div>
</div>
</div>
<style>
/* Base animation styles */
.animate-gradient {
animation: gradient-shift 8s ease infinite;
}
.animate-breath {
animation: breath 4s ease-in-out infinite;
}
.animate-wave {
animation: wave 3s ease-in-out infinite;
}
.animate-candle {
animation: candle 0.5s ease-in-out infinite;
}
.animate-disco {
animation: disco 0.5s linear infinite;
}
.animate-thunder {
animation: thunder 5s ease-in-out infinite;
}
.animate-police {
animation: police 0.5s linear infinite;
}
.animate-warning {
animation: warning 0.8s ease-in-out infinite;
}
.animate-flash {
animation: flash 0.2s linear infinite;
}
.animate-sos {
animation: sos 2.5s linear infinite;
}
.animate-scanner {
animation: scanner 2s ease-in-out infinite;
}
.animate-matrix {
animation: matrix 0.1s steps(2) infinite;
}
.animate-sunrise {
animation: sunrise 30s ease-in-out infinite;
}
.animate-sunset {
animation: sunset 30s ease-in-out infinite;
}
.paused {
animation-play-state: paused !important;
}
@keyframes gradient-shift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
@keyframes breath {
0%,
100% {
opacity: 0.7;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.02);
}
}
@keyframes wave {
0%,
100% {
background-position: 0% 50%;
opacity: 1;
}
50% {
background-position: 100% 50%;
opacity: 0.85;
}
}
@keyframes candle {
0%,
100% {
opacity: 1;
filter: brightness(1);
}
25% {
opacity: 0.9;
filter: brightness(0.95);
}
50% {
opacity: 0.85;
filter: brightness(1.1);
}
75% {
opacity: 0.95;
filter: brightness(0.92);
}
}
@keyframes disco {
0% {
filter: hue-rotate(0deg) saturate(1.2);
}
100% {
filter: hue-rotate(360deg) saturate(1.2);
}
}
@keyframes thunder {
0%,
94%,
100% {
opacity: 1;
filter: brightness(1);
}
95%,
97% {
opacity: 1;
filter: brightness(3);
}
}
@keyframes police {
0%,
49% {
filter: hue-rotate(0deg);
}
50%,
100% {
filter: hue-rotate(180deg);
}
}
@keyframes warning {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
@keyframes flash {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
@keyframes sos {
/* S: ... */
0%,
5% {
opacity: 1;
}
5.1%,
10% {
opacity: 0;
}
10.1%,
15% {
opacity: 1;
}
15.1%,
20% {
opacity: 0;
}
20.1%,
25% {
opacity: 1;
}
25.1%,
35% {
opacity: 0;
}
/* O: --- */
35.1%,
45% {
opacity: 1;
}
45.1%,
50% {
opacity: 0;
}
50.1%,
60% {
opacity: 1;
}
60.1%,
65% {
opacity: 0;
}
65.1%,
75% {
opacity: 1;
}
75.1%,
80% {
opacity: 0;
}
/* S: ... */
80.1%,
82% {
opacity: 1;
}
82.1%,
85% {
opacity: 0;
}
85.1%,
87% {
opacity: 1;
}
87.1%,
90% {
opacity: 0;
}
90.1%,
92% {
opacity: 1;
}
92.1%,
100% {
opacity: 0;
}
}
@keyframes scanner {
0%,
100% {
filter: brightness(0.8);
}
50% {
filter: brightness(1.5);
}
}
@keyframes matrix {
0% {
filter: brightness(1) contrast(1.1);
}
50% {
filter: brightness(0.8) contrast(1.2);
}
}
@keyframes sunrise {
0% {
filter: brightness(0.3) saturate(0.5);
}
50% {
filter: brightness(1) saturate(1);
}
100% {
filter: brightness(1.2) saturate(1.2);
}
}
@keyframes sunset {
0% {
filter: brightness(1.2) saturate(1.2);
}
50% {
filter: brightness(0.8) saturate(1.5);
}
100% {
filter: brightness(0.3) saturate(0.5);
}
}
/* Particle animation */
.particle {
animation: float-up linear infinite;
}
@keyframes float-up {
0% {
transform: translateY(100vh) scale(0);
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
transform: translateY(-10vh) scale(1);
opacity: 0;
}
}
</style>

View file

@ -0,0 +1,195 @@
import type { Mood } from '$lib/types/mood';
// 24 preset moods matching the mobile app
export const DEFAULT_MOODS: Mood[] = [
{
id: 'fire',
name: 'Fire',
colors: ['#ff6b35', '#ff4500', '#dc143c', '#8b0000'],
animationType: 'candle',
order: 0,
},
{
id: 'breath',
name: 'Breath',
colors: ['#667eea', '#764ba2', '#f093fb'],
animationType: 'breath',
order: 1,
},
{
id: 'northern-lights',
name: 'Northern Lights',
colors: ['#5f27cd', '#341f97', '#8854d0', '#a29bfe'],
animationType: 'wave',
order: 2,
},
{
id: 'thunder',
name: 'Thunder',
colors: ['#2c3e50', '#34495e', '#ffffff', '#95a5a6'],
animationType: 'thunder',
order: 3,
},
{
id: 'light',
name: 'Light',
colors: ['#ffffff', '#f8f9fa', '#e9ecef'],
animationType: 'gradient',
order: 4,
},
{
id: 'flash',
name: 'Flash',
colors: ['#ffffff'],
animationType: 'flash',
order: 5,
},
{
id: 'sos',
name: 'SOS',
colors: ['#ffffff'],
animationType: 'sos',
order: 6,
},
{
id: 'ocean',
name: 'Ocean',
colors: ['#48dbfb', '#0abde3', '#10ac84', '#1dd1a1'],
animationType: 'wave',
order: 7,
},
{
id: 'candle',
name: 'Candle',
colors: ['#ff9f43', '#ee5a24', '#ffeaa7'],
animationType: 'candle',
order: 8,
},
{
id: 'police',
name: 'Police',
colors: ['#e74c3c', '#3498db'],
animationType: 'police',
order: 9,
},
{
id: 'warning',
name: 'Warning',
colors: ['#f39c12', '#e67e22'],
animationType: 'warning',
order: 10,
},
{
id: 'disco',
name: 'Disco',
colors: ['#e74c3c', '#9b59b6', '#3498db', '#1abc9c', '#f1c40f', '#e67e22'],
animationType: 'disco',
order: 11,
},
{
id: 'sunrise',
name: 'Sunrise',
colors: ['#1a1a2e', '#16213e', '#e94560', '#ff6b6b', '#feca57', '#fffacd'],
animationType: 'sunrise',
order: 12,
},
{
id: 'sunset',
name: 'Sunset',
colors: ['#ff6b6b', '#feca57', '#ff9ff3', '#a29bfe', '#341f97', '#1a1a2e'],
animationType: 'sunset',
order: 13,
},
{
id: 'forest',
name: 'Forest',
colors: ['#27ae60', '#2ecc71', '#1abc9c', '#16a085'],
animationType: 'pulse',
order: 14,
},
{
id: 'rave',
name: 'Rave',
colors: [
'#ff0000',
'#ff00ff',
'#00ffff',
'#00ff00',
'#ffff00',
'#ff6600',
'#0066ff',
'#ff0066',
],
animationType: 'rave',
order: 15,
},
{
id: 'scanner',
name: 'Scanner',
colors: ['#e74c3c'],
animationType: 'scanner',
order: 16,
},
{
id: 'matrix',
name: 'Matrix',
colors: ['#00ff00'],
animationType: 'matrix',
order: 17,
},
{
id: 'lavender',
name: 'Lavender',
colors: ['#e6e6fa', '#dda0dd', '#da70d6', '#ba55d3'],
animationType: 'pulse',
order: 18,
},
{
id: 'cherry-blossom',
name: 'Cherry Blossom',
colors: ['#ffb7c5', '#ff69b4', '#ff1493', '#db7093'],
animationType: 'wave',
order: 19,
},
{
id: 'autumn',
name: 'Autumn',
colors: ['#d35400', '#e67e22', '#f39c12', '#c0392b'],
animationType: 'gradient',
order: 20,
},
{
id: 'ice',
name: 'Ice',
colors: ['#74b9ff', '#0984e3', '#81ecec', '#00cec9'],
animationType: 'wave',
order: 21,
},
{
id: 'romance',
name: 'Romance',
colors: ['#fd79a8', '#e84393', '#d63031', '#ff7675'],
animationType: 'pulse',
order: 22,
},
{
id: 'midnight',
name: 'Midnight',
colors: ['#0c0c0c', '#1a1a2e', '#16213e', '#0f3460'],
animationType: 'breath',
order: 23,
},
];
// Get mood by ID
export function getMoodById(id: string): Mood | undefined {
return DEFAULT_MOODS.find((m) => m.id === id);
}
// Get gradient CSS for a mood
export function getMoodGradient(mood: Mood): string {
if (mood.colors.length === 1) {
return mood.colors[0];
}
return `linear-gradient(135deg, ${mood.colors.join(', ')})`;
}

View file

@ -0,0 +1,49 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
// List of supported locales
export const supportedLocales = ['de', 'en'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
// Default locale
const defaultLocale = 'de';
// Register all available locales
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
// Get initial locale from browser or localStorage
function getInitialLocale(): SupportedLocale {
if (browser) {
// Check localStorage first
const stored = localStorage.getItem('moodlit_locale');
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
return stored as SupportedLocale;
}
// Fall back to browser language
const browserLang = navigator.language.split('-')[0];
if (supportedLocales.includes(browserLang as SupportedLocale)) {
return browserLang as SupportedLocale;
}
}
return defaultLocale;
}
// Initialize i18n at module scope (required for SSR)
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale(),
});
// Set locale and persist to localStorage
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('moodlit_locale', newLocale);
}
}
// Wait for locale to be loaded (useful for SSR)
export { waitLocale };

View file

@ -0,0 +1,78 @@
{
"app": {
"name": "Moodlit",
"tagline": "Ambient Lighting & Moods"
},
"nav": {
"home": "Startseite",
"moods": "Moods",
"sequences": "Sequenzen",
"settings": "Einstellungen",
"feedback": "Feedback"
},
"home": {
"title": "Deine Moods",
"subtitle": "Wähle eine Lichtstimmung",
"sequences": "Sequenzen",
"sequencesDescription": "Verkette mehrere Moods zu einer Sequenz",
"favorites": "Favoriten",
"all": "Alle Moods",
"custom": "Eigene Moods"
},
"sequences": {
"title": "Sequenzen",
"subtitle": "Spiele mehrere Moods nacheinander ab",
"moods": "Moods",
"empty": "Noch keine Sequenzen",
"emptyDescription": "Erstelle eine Sequenz, indem du mehrere Moods verkettest."
},
"mood": {
"play": "Abspielen",
"pause": "Pause",
"edit": "Bearbeiten",
"delete": "Löschen",
"addToFavorites": "Zu Favoriten",
"removeFromFavorites": "Aus Favoriten",
"animation": "Animation",
"colors": "Farben",
"startTimer": "Start",
"stopTimer": "Timer stoppen",
"timerRunning": "Timer läuft",
"stop": "Stopp"
},
"settings": {
"title": "Einstellungen",
"animationSpeed": "Animationsgeschwindigkeit",
"slow": "Langsam",
"normal": "Normal",
"fast": "Schnell",
"brightness": "Helligkeit",
"autoTimer": "Auto-Timer",
"autoTimerOff": "Aus",
"autoTimerMinutes": "{minutes} Minuten",
"autoMoodSwitch": "Auto-Mood-Wechsel",
"autoMoodSwitchInterval": "Wechsel-Intervall",
"reset": "Zurücksetzen",
"resetConfirm": "Alle Einstellungen zurücksetzen?"
},
"createMood": {
"title": "Mood erstellen",
"editTitle": "Mood bearbeiten",
"name": "Name",
"namePlaceholder": "Mood-Name eingeben...",
"colors": "Farben",
"addColor": "Farbe hinzufügen",
"animation": "Animationstyp",
"preview": "Vorschau"
},
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"confirm": "Bestätigen",
"loading": "Lädt...",
"error": "Fehler",
"success": "Erfolgreich",
"create": "Erstellen"
}
}

View file

@ -0,0 +1,78 @@
{
"app": {
"name": "Moodlit",
"tagline": "Ambient Lighting & Moods"
},
"nav": {
"home": "Home",
"moods": "Moods",
"sequences": "Sequences",
"settings": "Settings",
"feedback": "Feedback"
},
"home": {
"title": "Your Moods",
"subtitle": "Choose a lighting mood",
"sequences": "Sequences",
"sequencesDescription": "Chain multiple moods into a sequence",
"favorites": "Favorites",
"all": "All Moods",
"custom": "Custom Moods"
},
"sequences": {
"title": "Sequences",
"subtitle": "Play multiple moods in sequence",
"moods": "moods",
"empty": "No Sequences Yet",
"emptyDescription": "Create a sequence by chaining multiple moods together."
},
"mood": {
"play": "Play",
"pause": "Pause",
"edit": "Edit",
"delete": "Delete",
"addToFavorites": "Add to Favorites",
"removeFromFavorites": "Remove from Favorites",
"animation": "Animation",
"colors": "Colors",
"startTimer": "Start",
"stopTimer": "Stop Timer",
"timerRunning": "Timer running",
"stop": "Stop"
},
"settings": {
"title": "Settings",
"animationSpeed": "Animation Speed",
"slow": "Slow",
"normal": "Normal",
"fast": "Fast",
"brightness": "Brightness",
"autoTimer": "Auto Timer",
"autoTimerOff": "Off",
"autoTimerMinutes": "{minutes} minutes",
"autoMoodSwitch": "Auto Mood Switch",
"autoMoodSwitchInterval": "Switch Interval",
"reset": "Reset",
"resetConfirm": "Reset all settings?"
},
"createMood": {
"title": "Create Mood",
"editTitle": "Edit Mood",
"name": "Name",
"namePlaceholder": "Enter mood name...",
"colors": "Colors",
"addColor": "Add Color",
"animation": "Animation Type",
"preview": "Preview"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"confirm": "Confirm",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"create": "Create"
}
}

View file

@ -0,0 +1,118 @@
import type { MoodlitUser } from '$lib/types/auth';
import { authService, type UserData } from '$lib/auth';
// Svelte 5 runes-based auth store
let user = $state<MoodlitUser | null>(null);
let loading = $state(true);
/**
* Convert UserData from shared-auth to MoodlitUser
*/
function toMoodlitUser(userData: UserData | null): MoodlitUser | null {
if (!userData) return null;
return {
id: userData.id,
email: userData.email,
role: userData.role,
};
}
export const authStore = {
get user() {
return user;
},
get loading() {
return loading;
},
get isAuthenticated() {
return !!user;
},
/**
* Initialize auth state from stored tokens
*/
async initialize() {
loading = true;
try {
const isAuth = await authService.isAuthenticated();
if (isAuth) {
const userData = await authService.getUserFromToken();
user = toMoodlitUser(userData);
}
} catch (error) {
console.error('Failed to initialize auth:', error);
user = null;
} finally {
loading = false;
}
},
/**
* Set user
*/
setUser(newUser: MoodlitUser | null) {
user = newUser;
},
/**
* Sign out
*/
async signOut() {
try {
await authService.signOut();
user = null;
} catch (error) {
console.error('Sign out failed:', error);
}
},
/**
* Check authentication status
*/
async checkAuth() {
const isAuth = await authService.isAuthenticated();
if (!isAuth) {
user = null;
return false;
}
return true;
},
/**
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const result = await authService.signIn(email, password);
if (result.success) {
const userData = await authService.getUserFromToken();
user = toMoodlitUser(userData);
}
return result;
},
/**
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const result = await authService.signUp(email, password);
if (result.success && !result.needsVerification) {
const userData = await authService.getUserFromToken();
user = toMoodlitUser(userData);
}
return result;
},
/**
* Send password reset email
*/
async forgotPassword(email: string) {
return authService.forgotPassword(email);
},
/**
* Get access token for API calls
*/
async getAccessToken(): Promise<string | null> {
return authService.getAppToken();
},
};

View file

@ -0,0 +1,116 @@
import type { Mood, MoodSettings } from '$lib/types/mood';
// Default settings
const DEFAULT_SETTINGS: MoodSettings = {
animationSpeed: 'normal',
brightness: 100,
autoTimer: 0,
autoMoodSwitch: false,
autoMoodSwitchInterval: 5,
};
// Moods store using Svelte 5 runes
function createMoodsStore() {
let customMoods = $state<Mood[]>([]);
let favoriteIds = $state<string[]>([]);
let settings = $state<MoodSettings>({ ...DEFAULT_SETTINGS });
let activeMood = $state<Mood | null>(null);
// Load from localStorage on init
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('moodlit-store');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.customMoods) customMoods = parsed.customMoods;
if (parsed.favoriteIds) favoriteIds = parsed.favoriteIds;
if (parsed.settings) settings = { ...DEFAULT_SETTINGS, ...parsed.settings };
} catch (e) {
console.error('Failed to load moods from localStorage', e);
}
}
}
// Save to localStorage
function persist() {
if (typeof window !== 'undefined') {
localStorage.setItem('moodlit-store', JSON.stringify({ customMoods, favoriteIds, settings }));
}
}
return {
get customMoods() {
return customMoods;
},
get favoriteIds() {
return favoriteIds;
},
get settings() {
return settings;
},
get activeMood() {
return activeMood;
},
// Check if a mood is a favorite
isFavorite(moodId: string): boolean {
return favoriteIds.includes(moodId);
},
setActiveMood(mood: Mood | null) {
activeMood = mood;
},
addMood(mood: Mood) {
customMoods = [...customMoods, mood];
persist();
},
updateMood(id: string, updates: Partial<Mood>) {
customMoods = customMoods.map((m) => (m.id === id ? { ...m, ...updates } : m));
persist();
},
removeMood(id: string) {
customMoods = customMoods.filter((m) => m.id !== id);
// Also remove from favorites
favoriteIds = favoriteIds.filter((fid) => fid !== id);
persist();
},
toggleFavorite(moodId: string) {
if (favoriteIds.includes(moodId)) {
favoriteIds = favoriteIds.filter((id) => id !== moodId);
} else {
favoriteIds = [...favoriteIds, moodId];
}
persist();
},
addToFavorites(moodId: string) {
if (!favoriteIds.includes(moodId)) {
favoriteIds = [...favoriteIds, moodId];
persist();
}
},
removeFromFavorites(moodId: string) {
favoriteIds = favoriteIds.filter((id) => id !== moodId);
persist();
},
updateSettings(updates: Partial<MoodSettings>) {
settings = { ...settings, ...updates };
persist();
},
resetToDefaults() {
customMoods = [];
favoriteIds = [];
settings = { ...DEFAULT_SETTINGS };
persist();
},
};
}
export const moodsStore = createMoodsStore();

View file

@ -0,0 +1,7 @@
import { writable } from 'svelte/store';
// Store for sidebar mode (pill vs sidebar navigation)
export const isSidebarMode = writable(false);
// Store for collapsed state
export const isNavCollapsed = writable(false);

View file

@ -0,0 +1,129 @@
import type { MoodSequence } from '$lib/types/mood';
// Default sequences for demo purposes
const DEFAULT_SEQUENCES: MoodSequence[] = [
{
id: 'relaxation',
name: 'Relaxation',
items: [
{ moodId: 'breath', duration: 60 },
{ moodId: 'ocean', duration: 60 },
{ moodId: 'lavender', duration: 60 },
],
transitionDuration: 5,
},
{
id: 'focus',
name: 'Focus Flow',
items: [
{ moodId: 'forest', duration: 120 },
{ moodId: 'northern-lights', duration: 120 },
],
transitionDuration: 10,
},
{
id: 'party',
name: 'Party Mode',
items: [
{ moodId: 'disco', duration: 30 },
{ moodId: 'rave', duration: 30 },
{ moodId: 'police', duration: 15 },
],
transitionDuration: 2,
},
];
// Sequences store using Svelte 5 runes
function createSequencesStore() {
let sequences = $state<MoodSequence[]>([...DEFAULT_SEQUENCES]);
let customSequences = $state<MoodSequence[]>([]);
let activeSequence = $state<MoodSequence | null>(null);
let currentItemIndex = $state(0);
let isPlaying = $state(false);
// Load from localStorage on init
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('moodlit-sequences');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.customSequences) customSequences = parsed.customSequences;
} catch (e) {
console.error('Failed to load sequences from localStorage', e);
}
}
}
// Save to localStorage
function persist() {
if (typeof window !== 'undefined') {
localStorage.setItem('moodlit-sequences', JSON.stringify({ customSequences }));
}
}
return {
get sequences() {
return [...sequences, ...customSequences];
},
get customSequences() {
return customSequences;
},
get activeSequence() {
return activeSequence;
},
get currentItemIndex() {
return currentItemIndex;
},
get isPlaying() {
return isPlaying;
},
addSequence(sequence: MoodSequence) {
customSequences = [...customSequences, { ...sequence, isCustom: true }];
persist();
},
updateSequence(id: string, updates: Partial<MoodSequence>) {
customSequences = customSequences.map((s) => (s.id === id ? { ...s, ...updates } : s));
persist();
},
removeSequence(id: string) {
customSequences = customSequences.filter((s) => s.id !== id);
persist();
},
playSequence(sequence: MoodSequence) {
activeSequence = sequence;
currentItemIndex = 0;
isPlaying = true;
},
stopSequence() {
activeSequence = null;
currentItemIndex = 0;
isPlaying = false;
},
nextItem() {
if (activeSequence && currentItemIndex < activeSequence.items.length - 1) {
currentItemIndex++;
} else {
// Loop back to start
currentItemIndex = 0;
}
},
previousItem() {
if (currentItemIndex > 0) {
currentItemIndex--;
}
},
togglePlay() {
isPlaying = !isPlaying;
},
};
}
export const sequencesStore = createSequencesStore();

View file

@ -0,0 +1,8 @@
import { createThemeStore } from '@manacore/shared-theme';
// Create the theme store for Moodlit
export const theme = createThemeStore({
appId: 'moodlit',
defaultMode: 'system',
defaultVariant: 'lume',
});

View file

@ -0,0 +1,9 @@
/**
* Auth types for Moodlit
*/
export interface MoodlitUser {
id: string;
email: string;
role: string;
}

View file

@ -0,0 +1,90 @@
// Animation types available for moods
export type AnimationType =
| 'gradient'
| 'pulse'
| 'wave'
| 'flash'
| 'sos'
| 'candle'
| 'police'
| 'warning'
| 'disco'
| 'thunder'
| 'breath'
| 'rave'
| 'scanner'
| 'matrix'
| 'sunrise'
| 'sunset'
| 'aurora'
| 'fire'
| 'ocean'
| 'forest'
| 'sparkle';
// Mood interface
export interface Mood {
id: string;
name: string;
colors: string[];
animationType: AnimationType;
isCustom?: boolean;
order?: number;
createdAt?: string;
}
// Sequence item (mood with duration)
export interface MoodSequenceItem {
moodId: string;
duration: number; // seconds
}
// Mood sequence
export interface MoodSequence {
id: string;
name: string;
items: MoodSequenceItem[];
transitionDuration: number; // 2, 5, or 10 seconds
isCustom?: boolean;
}
// Settings
export interface MoodSettings {
animationSpeed: 'slow' | 'normal' | 'fast';
brightness: number; // 0-100
autoTimer: number; // 0 = off, else minutes
autoMoodSwitch: boolean;
autoMoodSwitchInterval: number; // minutes
}
// Animation metadata for UI
export interface AnimationInfo {
id: AnimationType;
name: string;
description: string;
}
// Available animations with descriptions
export const ANIMATIONS: AnimationInfo[] = [
{ id: 'gradient', name: 'Gradient', description: 'Smooth color gradient' },
{ id: 'pulse', name: 'Pulse', description: 'Breathing opacity effect' },
{ id: 'wave', name: 'Wave', description: 'Smooth wave oscillation' },
{ id: 'breath', name: 'Breath', description: '4-second breathing cycle' },
{ id: 'aurora', name: 'Aurora', description: 'Northern lights effect' },
{ id: 'fire', name: 'Fire', description: 'Warm flickering flames' },
{ id: 'candle', name: 'Candle', description: 'Soft candlelight flicker' },
{ id: 'ocean', name: 'Ocean', description: 'Calm ocean waves' },
{ id: 'forest', name: 'Forest', description: 'Peaceful forest ambience' },
{ id: 'thunder', name: 'Thunder', description: 'Random lightning flashes' },
{ id: 'sparkle', name: 'Sparkle', description: 'Twinkling star effect' },
{ id: 'sunrise', name: 'Sunrise', description: 'Slow warming colors' },
{ id: 'sunset', name: 'Sunset', description: 'Evening color transition' },
{ id: 'disco', name: 'Disco', description: 'Fast color cycling' },
{ id: 'rave', name: 'Rave', description: 'Very fast chaotic colors' },
{ id: 'scanner', name: 'Scanner', description: 'Light wave sweep' },
{ id: 'matrix', name: 'Matrix', description: 'Digital green blinking' },
{ id: 'flash', name: 'Flash', description: 'Quick white flashes' },
{ id: 'sos', name: 'SOS', description: 'Morse code pattern' },
{ id: 'police', name: 'Police', description: 'Red/blue alternating' },
{ id: 'warning', name: 'Warning', description: 'Blinking orange/yellow' },
];

View file

@ -0,0 +1,174 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale, _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { theme } from '$lib/stores/theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
} from '$lib/stores/navigation';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
// App switcher items
const appItems = getPillAppItems('moodlit');
let { children } = $props();
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
// Get theme state
let isDark = $derived(theme.isDark);
// Navigation items for Moodlit
const navItems: PillNavItem[] = [
{ href: '/', label: 'Moods', icon: 'palette' },
{ href: '/sequences', label: 'Sequences', icon: 'layers' },
{ href: '/settings', label: 'Settings', icon: 'settings' },
];
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
...theme.variants.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant]?.label || variant,
icon: THEME_DEFINITIONS[variant]?.icon || 'circle',
onClick: () => theme.setVariant(variant),
active: theme.variant === variant,
})),
]);
// Current theme variant label
let currentThemeVariantLabel = $derived(THEME_DEFINITIONS[theme.variant]?.label || theme.variant);
// Language selector items
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as any);
}
let languageItems = $derived(
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// User email for user dropdown
let userEmail = $derived(authStore.user?.email);
function handleModeChange(isSidebar: boolean) {
isSidebarMode = isSidebar;
sidebarModeStore.set(isSidebar);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('moodlit-nav-sidebar', String(isSidebar));
}
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
collapsedStore.set(collapsed);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('moodlit-nav-collapsed', String(collapsed));
}
}
function handleToggleTheme() {
theme.toggleMode();
}
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
theme.setMode(mode);
}
async function handleSignOut() {
await authStore.signOut();
goto('/login');
}
onMount(async () => {
await authStore.initialize();
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
// Initialize sidebar mode from localStorage
const savedSidebar = localStorage.getItem('moodlit-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
sidebarModeStore.set(true);
}
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('moodlit-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
collapsedStore.set(true);
}
});
</script>
{#if authStore.loading}
<div class="min-h-screen flex items-center justify-center bg-background">
<div class="text-center">
<div
class="inline-block animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"
></div>
<p class="mt-4 text-muted-foreground">Loading...</p>
</div>
</div>
{:else if authStore.isAuthenticated}
<div class="min-h-screen bg-background">
<!-- Pill Navigation -->
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Moodlit"
homeRoute="/"
onLogout={handleSignOut}
onToggleTheme={handleToggleTheme}
{isDark}
{isSidebarMode}
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={true}
primaryColor="#8b5cf6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
/>
<!-- Main content with dynamic padding -->
<main
class="transition-all duration-300 {isCollapsed
? ''
: isSidebarMode
? 'pl-[180px]'
: 'pt-20'}"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{@render children()}
</div>
</main>
</div>
{/if}

View file

@ -0,0 +1,177 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { moodsStore } from '$lib/stores/moods.svelte';
import { DEFAULT_MOODS, getMoodGradient } from '$lib/data/default-moods';
import MoodCard from '$lib/components/mood/MoodCard.svelte';
import MoodFullscreen from '$lib/components/mood/MoodFullscreen.svelte';
import CreateMoodDialog from '$lib/components/mood/CreateMoodDialog.svelte';
import { Plus } from '@manacore/shared-icons';
import type { Mood, AnimationType } from '$lib/types/mood';
// Combine default moods with custom moods
let allMoods = $derived([...DEFAULT_MOODS, ...moodsStore.customMoods]);
// Get favorites (moods that are in the favorites list)
let favoriteMoods = $derived(allMoods.filter((m) => moodsStore.isFavorite(m.id)));
// Filter by category
let selectedCategory = $state<'all' | 'favorites' | 'custom'>('all');
// Fullscreen state
let showFullscreen = $state(false);
let fullscreenMood = $state<Mood | null>(null);
// Create mood dialog state
let showCreateDialog = $state(false);
let displayedMoods = $derived(() => {
switch (selectedCategory) {
case 'favorites':
return favoriteMoods;
case 'custom':
return moodsStore.customMoods;
default:
return allMoods;
}
});
function handleMoodClick(mood: Mood) {
fullscreenMood = mood;
showFullscreen = true;
moodsStore.setActiveMood(mood);
}
function handleCloseFullscreen() {
showFullscreen = false;
moodsStore.setActiveMood(null);
}
function handleFavoriteToggle(mood: Mood) {
moodsStore.toggleFavorite(mood.id);
}
function handleFullscreenFavoriteToggle() {
if (fullscreenMood) {
moodsStore.toggleFavorite(fullscreenMood.id);
}
}
function handleCreateMood(moodData: {
name: string;
colors: string[];
animationType: AnimationType;
}) {
const newMood: Mood = {
id: `custom-${Date.now()}`,
name: moodData.name,
colors: moodData.colors,
animationType: moodData.animationType,
isCustom: true,
order: moodsStore.customMoods.length,
createdAt: new Date().toISOString(),
};
moodsStore.addMood(newMood);
}
</script>
<div class="space-y-8">
<!-- Header -->
<header>
<h1 class="text-3xl font-bold">{$_('home.title')}</h1>
<p class="text-[hsl(var(--color-muted-foreground))] mt-1">{$_('home.subtitle')}</p>
</header>
<!-- Category Tabs -->
<div class="flex gap-2">
<button
class="px-4 py-2 rounded-full text-sm font-medium transition-colors {selectedCategory ===
'all'
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80'}"
onclick={() => (selectedCategory = 'all')}
>
{$_('home.all')}
</button>
<button
class="px-4 py-2 rounded-full text-sm font-medium transition-colors {selectedCategory ===
'favorites'
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80'}"
onclick={() => (selectedCategory = 'favorites')}
>
{$_('home.favorites')} ({favoriteMoods.length})
</button>
<button
class="px-4 py-2 rounded-full text-sm font-medium transition-colors {selectedCategory ===
'custom'
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80'}"
onclick={() => (selectedCategory = 'custom')}
>
{$_('home.custom')} ({moodsStore.customMoods.length})
</button>
</div>
<!-- Fullscreen Mood View -->
{#if showFullscreen && fullscreenMood}
<MoodFullscreen
mood={fullscreenMood}
isFavorite={moodsStore.isFavorite(fullscreenMood.id)}
onClose={handleCloseFullscreen}
onFavoriteToggle={handleFullscreenFavoriteToggle}
/>
{/if}
<!-- Mood Grid -->
<section>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{#each displayedMoods() as mood (mood.id)}
<MoodCard
{mood}
isActive={moodsStore.activeMood?.id === mood.id}
isFavorite={moodsStore.isFavorite(mood.id)}
onClick={() => handleMoodClick(mood)}
onFavoriteToggle={() => handleFavoriteToggle(mood)}
/>
{/each}
</div>
{#if displayedMoods().length === 0}
<div class="text-center py-12 text-muted-foreground">
{#if selectedCategory === 'favorites'}
<p>No favorites yet. Click the heart icon on a mood to add it to favorites.</p>
{:else if selectedCategory === 'custom'}
<p>No custom moods yet. Create your own mood to get started.</p>
{:else}
<p>No moods available.</p>
{/if}
</div>
{/if}
</section>
<!-- Sequences Section -->
<section class="border-t border-border pt-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold">{$_('home.sequences')}</h2>
<a href="/sequences" class="text-sm text-primary hover:underline"> View all </a>
</div>
<p class="text-muted-foreground">{$_('home.sequencesDescription')}</p>
</section>
</div>
<!-- Floating Action Button -->
<button
type="button"
class="fixed bottom-24 right-6 z-30 p-4 rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 hover:scale-110 transition-all"
onclick={() => (showCreateDialog = true)}
aria-label={$_('createMood.title')}
>
<Plus size={24} />
</button>
<!-- Create Mood Dialog -->
<CreateMoodDialog
isOpen={showCreateDialog}
onClose={() => (showCreateDialog = false)}
onSave={handleCreateMood}
/>

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { FeedbackPage } from '@manacore/shared-feedback-ui';
import { feedbackService } from '$lib/api/feedback';
import { authStore } from '$lib/stores/auth.svelte';
</script>
<FeedbackPage {feedbackService} appName="Moodlit" currentUserId={authStore.user?.id} />

View file

@ -0,0 +1,206 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { sequencesStore } from '$lib/stores/sequences.svelte';
import { DEFAULT_MOODS, getMoodById, getMoodGradient } from '$lib/data/default-moods';
import { moodsStore } from '$lib/stores/moods.svelte';
import { Play, Pause, Plus, Trash, Clock } from '@manacore/shared-icons';
import type { MoodSequence, Mood } from '$lib/types/mood';
import MoodFullscreen from '$lib/components/mood/MoodFullscreen.svelte';
// Get mood by ID from both default and custom moods
function getMood(moodId: string): Mood | undefined {
return getMoodById(moodId) || moodsStore.customMoods.find((m) => m.id === moodId);
}
// Get sequence preview gradient (first 3 moods)
function getSequenceGradient(sequence: MoodSequence): string {
const colors = sequence.items.slice(0, 3).map((item) => {
const mood = getMood(item.moodId);
return mood?.colors[0] || '#8b5cf6';
});
return `linear-gradient(135deg, ${colors.join(', ')})`;
}
// Get total duration
function getTotalDuration(sequence: MoodSequence): number {
return sequence.items.reduce((sum, item) => sum + item.duration, 0);
}
// Format duration
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
}
// Active sequence player state
let showPlayer = $state(false);
let playerMood = $state<Mood | null>(null);
let sequenceInterval: ReturnType<typeof setInterval> | null = null;
function playSequence(sequence: MoodSequence) {
sequencesStore.playSequence(sequence);
startPlayback();
}
function startPlayback() {
if (!sequencesStore.activeSequence) return;
const currentItem = sequencesStore.activeSequence.items[sequencesStore.currentItemIndex];
const mood = getMood(currentItem.moodId);
if (mood) {
playerMood = mood;
showPlayer = true;
}
// Clear any existing interval
if (sequenceInterval) clearInterval(sequenceInterval);
// Start timer for current item
sequenceInterval = setInterval(() => {
if (sequencesStore.isPlaying && sequencesStore.activeSequence) {
sequencesStore.nextItem();
const nextItem = sequencesStore.activeSequence.items[sequencesStore.currentItemIndex];
const nextMood = getMood(nextItem.moodId);
if (nextMood) {
playerMood = nextMood;
}
}
}, currentItem.duration * 1000);
}
function stopPlayback() {
if (sequenceInterval) {
clearInterval(sequenceInterval);
sequenceInterval = null;
}
sequencesStore.stopSequence();
showPlayer = false;
playerMood = null;
}
function handlePlayerClose() {
stopPlayback();
}
// Cleanup on unmount
$effect(() => {
return () => {
if (sequenceInterval) clearInterval(sequenceInterval);
};
});
</script>
<div class="space-y-8">
<header class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold">{$_('sequences.title')}</h1>
<p class="text-muted-foreground mt-1">{$_('sequences.subtitle')}</p>
</div>
</header>
<!-- Sequences Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{#each sequencesStore.sequences as sequence (sequence.id)}
<div
class="relative rounded-2xl overflow-hidden transition-all hover:scale-[1.02] hover:shadow-lg"
>
<!-- Gradient Background -->
<div class="aspect-video" style="background: {getSequenceGradient(sequence)};"></div>
<!-- Overlay -->
<div
class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent"
></div>
<!-- Content -->
<div class="absolute inset-0 p-4 flex flex-col justify-between">
<div class="flex items-start justify-between">
<div class="flex items-center gap-2 text-white/80 text-sm">
<Clock size={16} />
{formatDuration(getTotalDuration(sequence))}
</div>
{#if sequence.isCustom}
<button
type="button"
class="p-1.5 rounded-full bg-white/20 hover:bg-red-500/50 transition-colors"
onclick={() => sequencesStore.removeSequence(sequence.id)}
aria-label="Delete sequence"
>
<Trash size={16} class="text-white" />
</button>
{/if}
</div>
<div class="flex items-end justify-between">
<div>
<h3 class="text-lg font-semibold text-white drop-shadow-md">
{sequence.name}
</h3>
<p class="text-sm text-white/70">
{sequence.items.length}
{$_('sequences.moods')}
</p>
</div>
<button
type="button"
class="p-3 rounded-full bg-white/20 hover:bg-white/30 backdrop-blur-sm transition-all hover:scale-110"
onclick={() => playSequence(sequence)}
aria-label="Play sequence"
>
<Play size={24} class="text-white" />
</button>
</div>
</div>
<!-- Mood Preview Dots -->
<div class="absolute bottom-16 left-4 flex gap-1">
{#each sequence.items.slice(0, 5) as item}
{@const mood = getMood(item.moodId)}
{#if mood}
<div
class="w-4 h-4 rounded-full border-2 border-white/50"
style="background: {mood.colors[0]};"
title={mood.name}
></div>
{/if}
{/each}
{#if sequence.items.length > 5}
<div
class="w-4 h-4 rounded-full bg-white/30 flex items-center justify-center text-[8px] text-white font-bold"
>
+{sequence.items.length - 5}
</div>
{/if}
</div>
</div>
{/each}
</div>
{#if sequencesStore.sequences.length === 0}
<section class="bg-muted/50 rounded-2xl p-8 text-center">
<div class="max-w-md mx-auto">
<div
class="w-16 h-16 mx-auto mb-4 rounded-full bg-primary/20 flex items-center justify-center"
>
<Plus size={32} class="text-primary" />
</div>
<h2 class="text-xl font-semibold mb-2">{$_('sequences.empty')}</h2>
<p class="text-muted-foreground">{$_('sequences.emptyDescription')}</p>
</div>
</section>
{/if}
</div>
<!-- Sequence Player (Fullscreen) -->
{#if showPlayer && playerMood}
<MoodFullscreen
mood={playerMood}
isFavorite={moodsStore.isFavorite(playerMood.id)}
onClose={handlePlayerClose}
onFavoriteToggle={() => moodsStore.toggleFavorite(playerMood?.id || '')}
/>
{/if}

View file

@ -0,0 +1,150 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { moodsStore } from '$lib/stores/moods.svelte';
import { theme } from '$lib/stores/theme';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
// Animation speed options
const speedOptions = [
{ value: 'slow', label: 'Slow' },
{ value: 'normal', label: 'Normal' },
{ value: 'fast', label: 'Fast' },
];
// Auto timer options (in minutes)
const autoTimerOptions = [
{ value: 0, label: 'Off' },
{ value: 15, label: '15 min' },
{ value: 30, label: '30 min' },
{ value: 60, label: '1 hour' },
{ value: 120, label: '2 hours' },
];
function handleBrightnessChange(event: Event) {
const target = event.target as HTMLInputElement;
moodsStore.updateSettings({ brightness: parseInt(target.value) });
}
function handleSpeedChange(speed: string) {
moodsStore.updateSettings({ animationSpeed: speed as 'slow' | 'normal' | 'fast' });
}
function handleAutoTimerChange(minutes: number) {
moodsStore.updateSettings({ autoTimer: minutes });
}
function handleResetSettings() {
if (confirm($_('settings.resetConfirm'))) {
moodsStore.updateSettings({
brightness: 100,
animationSpeed: 'normal',
autoTimer: 0,
});
}
}
</script>
<div class="space-y-8 max-w-2xl">
<header>
<h1 class="text-3xl font-bold">{$_('settings.title')}</h1>
</header>
<!-- Brightness -->
<section class="space-y-4">
<h2 class="text-lg font-semibold">{$_('settings.brightness')}</h2>
<div class="flex items-center gap-4">
<input
type="range"
min="10"
max="100"
step="5"
value={moodsStore.settings.brightness}
onchange={handleBrightnessChange}
class="flex-1 h-2 bg-muted rounded-full appearance-none cursor-pointer accent-primary"
/>
<span class="w-12 text-right text-sm text-muted-foreground">
{moodsStore.settings.brightness}%
</span>
</div>
</section>
<!-- Animation Speed -->
<section class="space-y-4">
<h2 class="text-lg font-semibold">{$_('settings.animationSpeed')}</h2>
<div class="flex gap-2">
{#each speedOptions as option}
<button
class="flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors {moodsStore
.settings.animationSpeed === option.value
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80'}"
onclick={() => handleSpeedChange(option.value)}
>
{$_(`settings.${option.value}`)}
</button>
{/each}
</div>
</section>
<!-- Auto Timer -->
<section class="space-y-4">
<h2 class="text-lg font-semibold">{$_('settings.autoTimer')}</h2>
<div class="flex flex-wrap gap-2">
{#each autoTimerOptions as option}
<button
class="py-2 px-4 rounded-lg text-sm font-medium transition-colors {moodsStore.settings
.autoTimer === option.value
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80'}"
onclick={() => handleAutoTimerChange(option.value)}
>
{option.label}
</button>
{/each}
</div>
</section>
<!-- Theme -->
<section class="space-y-4">
<h2 class="text-lg font-semibold">Theme</h2>
<div class="flex gap-2">
<button
class="flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors {theme.mode ===
'light'
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80'}"
onclick={() => theme.setMode('light')}
>
Light
</button>
<button
class="flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors {theme.mode ===
'dark'
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80'}"
onclick={() => theme.setMode('dark')}
>
Dark
</button>
<button
class="flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors {theme.mode ===
'system'
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80'}"
onclick={() => theme.setMode('system')}
>
System
</button>
</div>
</section>
<!-- Reset -->
<section class="pt-4 border-t border-border">
<button
class="py-2 px-4 rounded-lg text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors"
onclick={handleResetSettings}
>
{$_('settings.reset')}
</button>
</section>
</div>

View file

@ -0,0 +1,36 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { MoodlitLogo } from '@manacore/shared-branding';
import { getForgotPasswordTranslations } from '@manacore/shared-i18n';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { authStore } from '$lib/stores/auth.svelte';
// Get translations based on current locale
const translations = $derived(getForgotPasswordTranslations($locale || 'de'));
async function handleForgotPassword(email: string) {
return authStore.forgotPassword(email);
}
</script>
<ForgotPasswordPage
appName="Moodlit"
logo={MoodlitLogo}
primaryColor="#8b5cf6"
onForgotPassword={handleForgotPassword}
{goto}
loginPath="/login"
lightBackground="#faf5ff"
darkBackground="#1a1625"
{translations}
>
{#snippet headerControls()}
<LanguageSelector />
{/snippet}
{#snippet appSlider()}
<AppSlider />
{/snippet}
</ForgotPasswordPage>

View file

@ -0,0 +1,40 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { LoginPage } from '@manacore/shared-auth-ui';
import { MoodlitLogo } from '@manacore/shared-branding';
import { getLoginTranslations } from '@manacore/shared-i18n';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { authStore } from '$lib/stores/auth.svelte';
// Get translations based on current locale
const translations = $derived(getLoginTranslations($locale || 'de'));
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
</script>
<LoginPage
appName="Moodlit"
logo={MoodlitLogo}
primaryColor="#8b5cf6"
onSignIn={handleSignIn}
{goto}
enableGoogle={false}
enableApple={false}
successRedirect="/"
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#faf5ff"
darkBackground="#1a1625"
{translations}
>
{#snippet headerControls()}
<LanguageSelector />
{/snippet}
{#snippet appSlider()}
<AppSlider />
{/snippet}
</LoginPage>

View file

@ -0,0 +1,37 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { MoodlitLogo } from '@manacore/shared-branding';
import { getRegisterTranslations } from '@manacore/shared-i18n';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { authStore } from '$lib/stores/auth.svelte';
// Get translations based on current locale
const translations = $derived(getRegisterTranslations($locale || 'de'));
async function handleSignUp(email: string, password: string) {
return authStore.signUp(email, password);
}
</script>
<RegisterPage
appName="Moodlit"
logo={MoodlitLogo}
primaryColor="#8b5cf6"
onSignUp={handleSignUp}
{goto}
successRedirect="/"
loginPath="/login"
lightBackground="#faf5ff"
darkBackground="#1a1625"
{translations}
>
{#snippet headerControls()}
<LanguageSelector />
{/snippet}
{#snippet appSlider()}
<AppSlider />
{/snippet}
</RegisterPage>

View file

@ -0,0 +1,10 @@
<script lang="ts">
import '../app.css';
import '$lib/i18n';
let { children } = $props();
</script>
<div class="min-h-screen bg-[hsl(var(--color-background))] text-[hsl(var(--color-foreground))]">
{@render children()}
</div>