feat(moodlit): restore from git history, migrate to local-first + Hono

- Restore from git history (was deleted in 079b55a79)
- Delete NestJS backend and mobile app
- Create Hono/Bun server with preset moods API
- Create local-first store (moods, sequences) with 8 preset moods
- Rewrite web app: Moods page with color gradient cards and activation,
  Sequences page with CRUD, auth via shared-auth-ui with guest mode
- Add CLAUDE.md, dev scripts, root CLAUDE.md entry
- 0 type errors on both server and web

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-30 15:03:19 +02:00
parent 7f2b9f893b
commit 72da55d3d0
139 changed files with 5607 additions and 5877 deletions

37
apps/moodlit/CLAUDE.md Normal file
View file

@ -0,0 +1,37 @@
# Moodlit — Ambient Lighting & Mood App
## Architecture
Local-first for moods/sequences, Hono/Bun server for preset library.
```
Browser → IndexedDB (Moods, Sequences)
↕ sync
mana-sync → PostgreSQL
```
## Project Structure
```
apps/moodlit/
├── apps/
│ ├── web/ # SvelteKit web app (local-first)
│ ├── server/ # Hono/Bun (preset moods API)
│ └── landing/ # Astro landing page
└── package.json
```
## Commands
```bash
pnpm dev:moodlit:web # SvelteKit dev server
pnpm dev:moodlit:server # Hono/Bun server (port 3073)
pnpm dev:moodlit:landing # Landing page
```
## Local-First Collections
| Collection | Fields |
|-----------|--------|
| `moods` | name, colors (hex array), animation, isDefault |
| `sequences` | name, moodIds, duration (seconds) |

View file

@ -0,0 +1,19 @@
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
// https://astro.build/config
export default defineConfig({
integrations: [tailwind()],
output: 'static',
build: {
inlineStylesheets: 'auto',
},
vite: {
resolve: {
alias: {
'@components': '/src/components',
'@layouts': '/src/layouts',
},
},
},
});

View file

@ -0,0 +1,35 @@
{
"name": "@moodlit/landing",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev --port 4332",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro",
"type-check": "astro check",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"clean": "rm -rf dist .astro node_modules"
},
"dependencies": {
"@astrojs/check": "^0.9.0",
"@manacore/shared-landing-ui": "workspace:*",
"astro": "^5.16.0",
"typescript": "^5.9.2"
},
"devDependencies": {
"@astrojs/tailwind": "^6.0.2",
"@tailwindcss/typography": "^0.5.18",
"@types/node": "^20.0.0",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-astro": "^1.0.0",
"prettier": "^3.6.2",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^3.4.0"
}
}

View file

@ -0,0 +1,35 @@
---
interface Props {
title: string;
}
const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="description"
content="Moodlit - Transform your space with ambient lighting. Create custom moods and sequences."
/>
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body>
<slot />
</body>
</html>
<style is:global>
html {
font-family: system-ui, sans-serif;
scroll-behavior: smooth;
}
body {
margin: 0;
}
</style>

View file

@ -0,0 +1,117 @@
---
import Layout from '../layouts/Layout.astro';
---
<Layout title="Moodlit - Ambient Lighting App">
<main
class="min-h-screen bg-gradient-to-br from-purple-900 via-violet-800 to-fuchsia-900 text-white"
>
<!-- Hero Section -->
<section class="container mx-auto px-4 py-20 text-center">
<h1
class="text-5xl md:text-7xl font-bold mb-6 bg-clip-text text-transparent bg-gradient-to-r from-pink-300 via-purple-300 to-cyan-300"
>
Moodlit
</h1>
<p class="text-xl md:text-2xl text-purple-200 mb-8 max-w-2xl mx-auto">
Transform your space with ambient lighting. Create custom moods, chain sequences, and let
the colors flow.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="#download"
class="px-8 py-4 bg-white text-purple-900 rounded-full font-semibold hover:bg-purple-100 transition-colors"
>
Download App
</a>
<a
href="#features"
class="px-8 py-4 border-2 border-white/30 rounded-full font-semibold hover:bg-white/10 transition-colors"
>
Learn More
</a>
</div>
</section>
<!-- Features Section -->
<section id="features" class="container mx-auto px-4 py-20">
<h2 class="text-3xl md:text-4xl font-bold text-center mb-16">Features</h2>
<div class="grid md:grid-cols-3 gap-8">
<div class="bg-white/10 backdrop-blur-lg rounded-2xl p-8 text-center">
<div
class="w-16 h-16 bg-gradient-to-br from-pink-500 to-purple-500 rounded-full mx-auto mb-6 flex items-center justify-center"
>
<span class="text-2xl">🎨</span>
</div>
<h3 class="text-xl font-semibold mb-4">Custom Moods</h3>
<p class="text-purple-200">
Create your own lighting effects with custom colors and animations.
</p>
</div>
<div class="bg-white/10 backdrop-blur-lg rounded-2xl p-8 text-center">
<div
class="w-16 h-16 bg-gradient-to-br from-cyan-500 to-blue-500 rounded-full mx-auto mb-6 flex items-center justify-center"
>
<span class="text-2xl">🔗</span>
</div>
<h3 class="text-xl font-semibold mb-4">Sequences</h3>
<p class="text-purple-200">
Chain multiple moods together with configurable durations and transitions.
</p>
</div>
<div class="bg-white/10 backdrop-blur-lg rounded-2xl p-8 text-center">
<div
class="w-16 h-16 bg-gradient-to-br from-amber-500 to-orange-500 rounded-full mx-auto mb-6 flex items-center justify-center"
>
<span class="text-2xl">🔦</span>
</div>
<h3 class="text-xl font-semibold mb-4">Dual Output</h3>
<p class="text-purple-200">Toggle between screen-based lighting and device flashlight.</p>
</div>
</div>
</section>
<!-- CTA Section -->
<section id="download" class="container mx-auto px-4 py-20 text-center">
<h2 class="text-3xl md:text-4xl font-bold mb-8">Ready to set the mood?</h2>
<p class="text-xl text-purple-200 mb-8">Download Moodlit and transform your environment.</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="#"
class="inline-flex items-center px-8 py-4 bg-black rounded-xl hover:bg-gray-900 transition-colors"
>
<svg class="w-8 h-8 mr-3" viewBox="0 0 24 24" fill="currentColor">
<path
d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"
></path>
</svg>
<div class="text-left">
<div class="text-xs">Download on the</div>
<div class="text-lg font-semibold">App Store</div>
</div>
</a>
<a
href="#"
class="inline-flex items-center px-8 py-4 bg-black rounded-xl hover:bg-gray-900 transition-colors"
>
<svg class="w-8 h-8 mr-3" viewBox="0 0 24 24" fill="currentColor">
<path
d="M3,20.5V3.5C3,2.91 3.34,2.39 3.84,2.15L13.69,12L3.84,21.85C3.34,21.6 3,21.09 3,20.5M16.81,15.12L6.05,21.34L14.54,12.85L16.81,15.12M20.16,10.81C20.5,11.08 20.75,11.5 20.75,12C20.75,12.5 20.53,12.9 20.18,13.18L17.89,14.5L15.39,12L17.89,9.5L20.16,10.81M6.05,2.66L16.81,8.88L14.54,11.15L6.05,2.66Z"
></path>
</svg>
<div class="text-left">
<div class="text-xs">Get it on</div>
<div class="text-lg font-semibold">Google Play</div>
</div>
</a>
</div>
</section>
<!-- Footer -->
<footer class="border-t border-white/10 py-8">
<div class="container mx-auto px-4 text-center text-purple-300">
<p>&copy; 2024 Moodlit. All rights reserved.</p>
</div>
</footer>
</main>
</Layout>

View file

@ -0,0 +1,24 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {
colors: {
primary: {
50: '#fdf4ff',
100: '#fae8ff',
200: '#f5d0fe',
300: '#f0abfc',
400: '#e879f9',
500: '#d946ef',
600: '#c026d3',
700: '#a21caf',
800: '#86198f',
900: '#701a75',
950: '#4a044e',
},
},
},
},
plugins: [require('@tailwindcss/typography')],
};

View file

@ -0,0 +1,10 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"]
}
}
}

View file

@ -0,0 +1,3 @@
name = "moodlit-landing"
compatibility_date = "2024-12-01"
pages_build_output_dir = "dist"

View file

@ -0,0 +1,21 @@
{
"name": "@moodlit/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"type-check": "tsc --noEmit"
},
"dependencies": {
"drizzle-orm": "^0.44.7",
"hono": "^4.7.0",
"jose": "^6.1.2",
"postgres": "^3.4.7"
},
"devDependencies": {
"@types/bun": "^1.2.0",
"typescript": "^5.0.0"
}
}

View file

@ -0,0 +1,16 @@
export interface Config {
port: number;
databaseUrl: string;
manaAuthUrl: string;
cors: { origins: string[] };
}
export function loadConfig(): Config {
return {
port: parseInt(process.env.PORT || '3073', 10),
databaseUrl:
process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/mana_sync',
manaAuthUrl: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001',
cors: { origins: (process.env.CORS_ORIGINS || 'http://localhost:5173').split(',') },
};
}

View file

@ -0,0 +1,17 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { loadConfig } from './config';
import { errorHandler } from './middleware/error-handler';
import { healthRoutes } from './routes/health';
import { presetRoutes } from './routes/presets';
const config = loadConfig();
const app = new Hono();
app.onError(errorHandler);
app.use('*', cors({ origin: config.cors.origins, credentials: true }));
app.route('/health', healthRoutes);
app.route('/api/v1/presets', presetRoutes);
export default { port: config.port, fetch: app.fetch };

View file

@ -0,0 +1,19 @@
import { HTTPException } from 'hono/http-exception';
export class NotFoundError extends HTTPException {
constructor(message = 'Not found') {
super(404, { message });
}
}
export class BadRequestError extends HTTPException {
constructor(message = 'Bad request') {
super(400, { message });
}
}
export class UnauthorizedError extends HTTPException {
constructor(message = 'Unauthorized') {
super(401, { message });
}
}

View file

@ -0,0 +1,11 @@
import type { ErrorHandler } from 'hono';
import { HTTPException } from 'hono/http-exception';
export const errorHandler: ErrorHandler = (err, c) => {
if (err instanceof HTTPException) {
return c.json({ statusCode: err.status, message: err.message }, err.status);
}
console.error('Unhandled error:', err);
return c.json({ statusCode: 500, message: 'Internal server error' }, 500);
};

View file

@ -0,0 +1,46 @@
import type { MiddlewareHandler } from 'hono';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { UnauthorizedError } from '../lib/errors';
export interface AuthUser {
userId: string;
email: string;
role: string;
}
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
function getJwks(authUrl: string) {
if (!jwks) {
jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl));
}
return jwks;
}
export function jwtAuth(authUrl: string): MiddlewareHandler {
return async (c, next) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedError('Missing or invalid Authorization header');
}
const token = authHeader.slice(7);
try {
const { payload } = await jwtVerify(token, getJwks(authUrl), {
issuer: authUrl,
audience: 'manacore',
});
const user: AuthUser = {
userId: payload.sub || '',
email: (payload.email as string) || '',
role: (payload.role as string) || 'user',
};
c.set('user', user);
await next();
} catch {
throw new UnauthorizedError('Invalid or expired token');
}
};
}

View file

@ -0,0 +1,10 @@
import { Hono } from 'hono';
export const healthRoutes = new Hono().get('/', (c) =>
c.json({
status: 'ok',
service: 'moodlit-server',
runtime: 'bun',
timestamp: new Date().toISOString(),
})
);

View file

@ -0,0 +1,29 @@
import { Hono } from 'hono';
const DEFAULT_MOODS = [
{ id: 'fire', name: 'Fire', colors: ['#ff6b35', '#f72585', '#ff006e'], animation: 'flicker' },
{ id: 'breath', name: 'Breath', colors: ['#4361ee', '#3a0ca3', '#7209b7'], animation: 'pulse' },
{
id: 'northern-lights',
name: 'Northern Lights',
colors: ['#06d6a0', '#118ab2', '#073b4c'],
animation: 'aurora',
},
{ id: 'thunder', name: 'Thunder', colors: ['#14213d', '#fca311', '#e5e5e5'], animation: 'flash' },
{
id: 'sunset',
name: 'Sunset',
colors: ['#ff6b6b', '#feca57', '#ff9ff3'],
animation: 'gradient',
},
{ id: 'ocean', name: 'Ocean', colors: ['#0077b6', '#00b4d8', '#90e0ef'], animation: 'wave' },
{ id: 'forest', name: 'Forest', colors: ['#2d6a4f', '#40916c', '#52b788'], animation: 'sway' },
{
id: 'lavender',
name: 'Lavender',
colors: ['#7b2cbf', '#9d4edd', '#c77dff'],
animation: 'pulse',
},
];
export const presetRoutes = new Hono().get('/', (c) => c.json(DEFAULT_MOODS));

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["bun-types"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -1,46 +1,33 @@
{
"name": "@moodlit/web",
"version": "1.0.0",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"format": "prettier --write .",
"type-check": "echo 'Skipping type-check for now'"
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.7",
"@types/node": "^20.0.0",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"@sveltejs/adapter-node": "^5.0.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^5.0.4",
"@tailwindcss/vite": "^4.1.11",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.1.7",
"tslib": "^2.4.1",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^6.0.0"
},
"dependencies": {
"@manacore/local-store": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-stores": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/feedback": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/subscriptions": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"svelte-i18n": "^4.0.1"
},
"type": "module"
"svelte-sonner": "^1.0.5"
}
}

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));
}

4
apps/moodlit/apps/web/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare global {
namespace App {}
}
export {};

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,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,11 @@
<script lang="ts">
let { size = 48, color = '#7c3aed' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" rx="22" fill={color} />
<circle cx="50" cy="50" r="20" fill="none" stroke="white" stroke-width="4" />
<circle cx="50" cy="50" r="10" fill="white" opacity="0.6" />
<circle cx="35" cy="35" r="5" fill="white" opacity="0.4" />
<circle cx="65" cy="35" r="5" fill="white" opacity="0.4" />
</svg>

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,70 @@
import type { LocalMood, LocalSequence } from './local-store';
export const guestMoods: LocalMood[] = [
{
id: 'fire',
name: 'Fire',
colors: ['#ff6b35', '#f72585', '#ff006e'],
animation: 'flicker',
isDefault: true,
},
{
id: 'breath',
name: 'Breath',
colors: ['#4361ee', '#3a0ca3', '#7209b7'],
animation: 'pulse',
isDefault: true,
},
{
id: 'northern-lights',
name: 'Northern Lights',
colors: ['#06d6a0', '#118ab2', '#073b4c'],
animation: 'aurora',
isDefault: true,
},
{
id: 'sunset',
name: 'Sunset',
colors: ['#ff6b6b', '#feca57', '#ff9ff3'],
animation: 'gradient',
isDefault: true,
},
{
id: 'ocean',
name: 'Ocean',
colors: ['#0077b6', '#00b4d8', '#90e0ef'],
animation: 'wave',
isDefault: true,
},
{
id: 'forest',
name: 'Forest',
colors: ['#2d6a4f', '#40916c', '#52b788'],
animation: 'sway',
isDefault: true,
},
{
id: 'lavender',
name: 'Lavender',
colors: ['#7b2cbf', '#9d4edd', '#c77dff'],
animation: 'pulse',
isDefault: true,
},
{
id: 'thunder',
name: 'Thunder',
colors: ['#14213d', '#fca311', '#e5e5e5'],
animation: 'flash',
isDefault: true,
},
];
export const guestSequences: LocalSequence[] = [
{
id: 'evening-flow',
name: 'Evening Flow',
moodIds: ['sunset', 'lavender', 'breath'],
duration: 30,
},
{ id: 'nature', name: 'Nature', moodIds: ['forest', 'ocean', 'northern-lights'], duration: 45 },
];

View file

@ -0,0 +1,38 @@
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export interface LocalMood extends BaseRecord {
name: string;
colors: string[];
animation: string;
isDefault: boolean;
}
export interface LocalSequence extends BaseRecord {
name: string;
moodIds: string[];
duration: number;
}
import { guestMoods, guestSequences } from './guest-seed';
export const moodlitStore = createLocalStore({
appId: 'moodlit',
collections: [
{
name: 'moods',
indexes: ['name', 'animation', 'isDefault'],
guestSeed: guestMoods,
},
{
name: 'sequences',
indexes: ['name'],
guestSeed: guestSequences,
},
],
sync: { serverUrl: SYNC_SERVER_URL },
});
export const moodCollection = moodlitStore.collection<LocalMood>('moods');
export const sequenceCollection = moodlitStore.collection<LocalSequence>('sequences');

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,3 @@
import { createManaAuthStore } from '@manacore/shared-auth-stores';
export const authStore = createManaAuthStore({ devBackendPort: 3073 });

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,90 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem } from '@manacore/shared-ui';
import { getPillAppItems } from '@manacore/shared-branding';
import { AuthGate, GuestWelcomeModal, SessionExpiredBanner } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { moodlitStore } from '$lib/data/local-store';
let { children } = $props();
const appItems = getPillAppItems();
let userEmail = $derived(authStore.isAuthenticated ? (authStore.user?.email ?? '') : '');
let showGuestWelcome = $state(false);
let isCollapsed = $state(false);
let isDark = $state(true);
const navItems: PillNavItem[] = [
{ href: '/moods', label: 'Moods', icon: 'palette' },
{ href: '/sequences', label: 'Sequences', icon: 'list' },
];
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
localStorage?.setItem('moodlit-collapsed', String(collapsed));
}
function handleToggleTheme() {
isDark = !isDark;
document.documentElement.classList.toggle('dark', isDark);
}
async function handleLogout() {
moodlitStore.stopSync();
await authStore.signOut();
goto('/auth/login');
}
function handleAuthReady() {
if (authStore.isAuthenticated) moodlitStore.startSync(() => authStore.getValidToken());
if (!authStore.isAuthenticated && shouldShowGuestWelcome('moodlit')) showGuestWelcome = true;
const c = localStorage?.getItem('moodlit-collapsed');
if (c === 'true') isCollapsed = true;
}
</script>
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
<div class="flex min-h-screen flex-col">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Moodlit"
homeRoute="/moods"
onLogout={handleLogout}
onToggleTheme={handleToggleTheme}
{isDark}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
primaryColor="#7c3aed"
showAppSwitcher={true}
{appItems}
{userEmail}
>
{#snippet logo()}
<span class="text-xl">🌈</span>
<span class="pill-label font-bold">Moodlit</span>
{/snippet}
</PillNavigation>
<main class="main-content flex-1 transition-all duration-300 {isCollapsed ? '' : 'pt-20'}">
<div class="container mx-auto px-4 py-8">
{@render children()}
</div>
</main>
</div>
<GuestWelcomeModal
appId="moodlit"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/auth/login')}
onRegister={() => goto('/auth/register')}
locale="de"
/>
{#if authStore.isAuthenticated}
<SessionExpiredBanner locale="de" loginHref="/auth/login" />
{/if}
</AuthGate>

View file

@ -0,0 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => goto('/moods'));
</script>

View file

@ -0,0 +1,173 @@
<script lang="ts">
import { useLiveQuery } from '@manacore/local-store/svelte';
import { moodCollection } from '$lib/data/local-store';
import type { LocalMood } from '$lib/data/local-store';
import { toast } from 'svelte-sonner';
const moods = useLiveQuery(() => moodCollection.getAll());
let showCreate = $state(false);
let newName = $state('');
let newColors = $state(['#7c3aed', '#a78bfa', '#c4b5fd']);
let newAnimation = $state('gradient');
let activeMood = $state<LocalMood | null>(null);
async function createMood() {
if (!newName) return;
await moodCollection.insert({
id: crypto.randomUUID(),
name: newName,
colors: newColors,
animation: newAnimation,
isDefault: false,
});
toast.success(`"${newName}" erstellt`);
newName = '';
showCreate = false;
}
async function deleteMood(mood: LocalMood) {
if (mood.isDefault) {
toast.error('Standard-Moods können nicht gelöscht werden');
return;
}
await moodCollection.delete(mood.id);
if (activeMood?.id === mood.id) activeMood = null;
toast.success('Gelöscht');
}
function activateMood(mood: LocalMood) {
activeMood = activeMood?.id === mood.id ? null : mood;
}
</script>
<div class="mx-auto max-w-4xl">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-3xl font-bold">Moods</h1>
<button
onclick={() => (showCreate = !showCreate)}
class="rounded-lg bg-purple-600 px-4 py-2 font-medium text-white hover:bg-purple-700"
>
{showCreate ? 'Schliessen' : '+ Neues Mood'}
</button>
</div>
<!-- Active Mood Display -->
{#if activeMood}
<div
class="mb-6 overflow-hidden rounded-2xl p-8 text-center transition-all duration-1000"
style="background: linear-gradient(135deg, {activeMood.colors.join(', ')})"
>
<h2 class="text-4xl font-bold text-white drop-shadow-lg">{activeMood.name}</h2>
<p class="mt-2 text-white/70">{activeMood.animation}</p>
<button
onclick={() => (activeMood = null)}
class="mt-4 rounded-lg bg-white/20 px-4 py-2 text-sm text-white backdrop-blur hover:bg-white/30"
>Stoppen</button
>
</div>
{/if}
{#if showCreate}
<div class="mb-6 rounded-xl border border-gray-800 bg-gray-900 p-6">
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">Name</label>
<input
type="text"
bind:value={newName}
placeholder="Mein Mood"
class="w-full rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-gray-100"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-300">Animation</label>
<select
bind:value={newAnimation}
class="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100"
>
<option value="gradient">Gradient</option>
<option value="pulse">Pulse</option>
<option value="wave">Wave</option>
<option value="flicker">Flicker</option>
<option value="aurora">Aurora</option>
</select>
</div>
<div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium text-gray-300">Farben</label>
<div class="flex gap-2">
{#each newColors as color, i}
<input
type="color"
bind:value={newColors[i]}
class="h-10 w-14 cursor-pointer rounded border border-gray-700"
/>
{/each}
<button
onclick={() => (newColors = [...newColors, '#ffffff'])}
class="rounded border border-gray-700 px-3 text-sm text-gray-400 hover:bg-gray-800"
>+</button
>
</div>
</div>
</div>
<div
class="mt-2 h-4 rounded-full"
style="background: linear-gradient(90deg, {newColors.join(', ')})"
></div>
<button
onclick={createMood}
disabled={!newName}
class="mt-4 rounded-lg bg-purple-600 px-6 py-2 font-medium text-white hover:bg-purple-700 disabled:opacity-50"
>Erstellen</button
>
</div>
{/if}
{#if moods.loading}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each Array(6) as _}
<div class="h-32 animate-pulse rounded-xl bg-gray-800"></div>
{/each}
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each moods.value ?? [] as mood (mood.id)}
<button
onclick={() => activateMood(mood)}
class="group relative overflow-hidden rounded-xl border-2 p-6 text-left transition-all hover:scale-[1.02] {activeMood?.id ===
mood.id
? 'border-white shadow-lg shadow-purple-500/20'
: 'border-gray-800 hover:border-gray-700'}"
style="background: linear-gradient(135deg, {mood.colors.map((c) => c + '40').join(', ')})"
>
<h3 class="text-lg font-bold text-white">{mood.name}</h3>
<p class="mt-1 text-xs text-gray-400">{mood.animation}</p>
<div class="mt-3 flex gap-1">
{#each mood.colors as color}
<div class="h-4 w-4 rounded-full" style="background: {color}"></div>
{/each}
</div>
{#if !mood.isDefault}
<button
onclick={(e) => {
e.stopPropagation();
deleteMood(mood);
}}
class="absolute right-2 top-2 rounded-full p-1 text-gray-500 opacity-0 hover:bg-gray-800 hover:text-red-400 group-hover:opacity-100"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/></svg
>
</button>
{/if}
</button>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,117 @@
<script lang="ts">
import { useLiveQuery } from '@manacore/local-store/svelte';
import { sequenceCollection, moodCollection } from '$lib/data/local-store';
import { toast } from 'svelte-sonner';
const sequences = useLiveQuery(() => sequenceCollection.getAll());
const moods = useLiveQuery(() => moodCollection.getAll());
let newName = $state('');
let newDuration = $state(30);
let showCreate = $state(false);
async function createSequence() {
if (!newName) return;
const allMoods = moods.value ?? [];
await sequenceCollection.insert({
id: crypto.randomUUID(),
name: newName,
moodIds: allMoods.slice(0, 3).map((m) => m.id),
duration: newDuration,
});
toast.success(`"${newName}" erstellt`);
newName = '';
showCreate = false;
}
async function deleteSequence(id: string, name: string) {
if (!confirm(`"${name}" löschen?`)) return;
await sequenceCollection.delete(id);
toast.success('Gelöscht');
}
function getMoodName(moodId: string): string {
return (moods.value ?? []).find((m) => m.id === moodId)?.name ?? moodId;
}
</script>
<div class="mx-auto max-w-2xl">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-3xl font-bold">Sequences</h1>
<button
onclick={() => (showCreate = !showCreate)}
class="rounded-lg bg-purple-600 px-4 py-2 font-medium text-white hover:bg-purple-700"
>
{showCreate ? 'Schliessen' : '+ Neue Sequence'}
</button>
</div>
{#if showCreate}
<div class="mb-6 rounded-xl border border-gray-800 bg-gray-900 p-5">
<div class="flex gap-3">
<input
type="text"
bind:value={newName}
placeholder="Name"
class="flex-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100"
/>
<input
type="number"
bind:value={newDuration}
min="5"
max="300"
class="w-20 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100"
/>
<span class="self-center text-sm text-gray-500">Sek.</span>
<button
onclick={createSequence}
disabled={!newName}
class="rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>Erstellen</button
>
</div>
</div>
{/if}
{#if !sequences.value?.length}
<div class="rounded-xl border-2 border-dashed border-gray-700 p-12 text-center">
<p class="text-lg font-medium text-gray-400">Keine Sequences</p>
<p class="mt-1 text-sm text-gray-500">
Verkette mehrere Moods zu einer automatischen Sequenz.
</p>
</div>
{:else}
<div class="space-y-3">
{#each sequences.value as seq (seq.id)}
<div class="group rounded-xl border border-gray-800 bg-gray-900 p-4 hover:border-gray-700">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold">{seq.name}</h3>
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
{#each seq.moodIds as moodId}
<span class="rounded bg-purple-900/50 px-2 py-0.5 text-purple-300"
>{getMoodName(moodId)}</span
>
{/each}
<span>· {seq.duration}s pro Mood</span>
</div>
</div>
<button
onclick={() => deleteSequence(seq.id, seq.name)}
class="rounded p-1 text-gray-500 opacity-0 hover:text-red-400 group-hover:opacity-100"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/></svg
>
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>

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,37 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { Toaster } from 'svelte-sonner';
import { authStore } from '$lib/stores/auth.svelte';
import { moodlitStore } from '$lib/data/local-store';
let { children } = $props();
let loading = $state(true);
onMount(async () => {
await authStore.initialize();
await moodlitStore.initialize();
loading = false;
});
</script>
{#if loading}
<div class="flex min-h-screen items-center justify-center bg-gray-950">
<div
class="inline-block h-10 w-10 animate-spin rounded-full border-4 border-solid border-purple-500 border-r-transparent"
></div>
</div>
{:else}
<div class="min-h-screen bg-gray-950 text-gray-100">
{@render children()}
</div>
{/if}
<Toaster
position="bottom-right"
expand={false}
richColors
closeButton
duration={4000}
visibleToasts={3}
/>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { LoginPage } from '@manacore/shared-auth-ui';
import MoodlitLogo from '$lib/components/MoodlitLogo.svelte';
import { authStore } from '$lib/stores/auth.svelte';
</script>
<LoginPage
appName="Moodlit"
logo={MoodlitLogo}
primaryColor="#7c3aed"
onSignIn={(e, p) => authStore.signIn(e, p)}
onResendVerification={(e) => authStore.resendVerificationEmail(e)}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => authStore.signInWithPasskey()}
onVerifyTwoFactor={(c, t) => authStore.verifyTwoFactor(c, t)}
onVerifyBackupCode={(c) => authStore.verifyBackupCode(c)}
onSendMagicLink={(e) => authStore.sendMagicLink(e)}
{goto}
successRedirect="/moods"
registerPath="/auth/register"
/>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { RegisterPage } from '@manacore/shared-auth-ui';
import MoodlitLogo from '$lib/components/MoodlitLogo.svelte';
import { authStore } from '$lib/stores/auth.svelte';
</script>
<RegisterPage
appName="Moodlit"
logo={MoodlitLogo}
primaryColor="#7c3aed"
onSignUp={(e, p) => authStore.signUp(e, p)}
{goto}
successRedirect="/moods"
loginPath="/auth/login"
/>

View file

@ -0,0 +1,9 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const config = {
preprocess: [vitePreprocess()],
kit: { adapter: adapter() },
};
export default config;

View file

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View file

@ -0,0 +1,7 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
});

View file

@ -0,0 +1,8 @@
{
"name": "@manacore/moodlit",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "turbo run dev"
}
}