mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 06:46:42 +02:00
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:
parent
7f2b9f893b
commit
72da55d3d0
139 changed files with 5607 additions and 5877 deletions
37
apps/moodlit/CLAUDE.md
Normal file
37
apps/moodlit/CLAUDE.md
Normal 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) |
|
||||
19
apps/moodlit/apps/landing/astro.config.mjs
Normal file
19
apps/moodlit/apps/landing/astro.config.mjs
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
35
apps/moodlit/apps/landing/package.json
Normal file
35
apps/moodlit/apps/landing/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
35
apps/moodlit/apps/landing/src/layouts/Layout.astro
Normal file
35
apps/moodlit/apps/landing/src/layouts/Layout.astro
Normal 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>
|
||||
117
apps/moodlit/apps/landing/src/pages/index.astro
Normal file
117
apps/moodlit/apps/landing/src/pages/index.astro
Normal 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>© 2024 Moodlit. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</Layout>
|
||||
24
apps/moodlit/apps/landing/tailwind.config.mjs
Normal file
24
apps/moodlit/apps/landing/tailwind.config.mjs
Normal 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')],
|
||||
};
|
||||
10
apps/moodlit/apps/landing/tsconfig.json
Normal file
10
apps/moodlit/apps/landing/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@components/*": ["src/components/*"],
|
||||
"@layouts/*": ["src/layouts/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
3
apps/moodlit/apps/landing/wrangler.toml
Normal file
3
apps/moodlit/apps/landing/wrangler.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
name = "moodlit-landing"
|
||||
compatibility_date = "2024-12-01"
|
||||
pages_build_output_dir = "dist"
|
||||
21
apps/moodlit/apps/server/package.json
Normal file
21
apps/moodlit/apps/server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
16
apps/moodlit/apps/server/src/config.ts
Normal file
16
apps/moodlit/apps/server/src/config.ts
Normal 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(',') },
|
||||
};
|
||||
}
|
||||
17
apps/moodlit/apps/server/src/index.ts
Normal file
17
apps/moodlit/apps/server/src/index.ts
Normal 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 };
|
||||
19
apps/moodlit/apps/server/src/lib/errors.ts
Normal file
19
apps/moodlit/apps/server/src/lib/errors.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
11
apps/moodlit/apps/server/src/middleware/error-handler.ts
Normal file
11
apps/moodlit/apps/server/src/middleware/error-handler.ts
Normal 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);
|
||||
};
|
||||
46
apps/moodlit/apps/server/src/middleware/jwt-auth.ts
Normal file
46
apps/moodlit/apps/server/src/middleware/jwt-auth.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
}
|
||||
10
apps/moodlit/apps/server/src/routes/health.ts
Normal file
10
apps/moodlit/apps/server/src/routes/health.ts
Normal 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(),
|
||||
})
|
||||
);
|
||||
29
apps/moodlit/apps/server/src/routes/presets.ts
Normal file
29
apps/moodlit/apps/server/src/routes/presets.ts
Normal 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));
|
||||
16
apps/moodlit/apps/server/tsconfig.json
Normal file
16
apps/moodlit/apps/server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
153
apps/moodlit/apps/web/src/app.css
Normal file
153
apps/moodlit/apps/web/src/app.css
Normal 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
4
apps/moodlit/apps/web/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
declare global {
|
||||
namespace App {}
|
||||
}
|
||||
export {};
|
||||
13
apps/moodlit/apps/web/src/app.html
Normal file
13
apps/moodlit/apps/web/src/app.html
Normal 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>
|
||||
43
apps/moodlit/apps/web/src/lib/components/AppSlider.svelte
Normal file
43
apps/moodlit/apps/web/src/lib/components/AppSlider.svelte
Normal 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}
|
||||
/>
|
||||
|
|
@ -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" />
|
||||
11
apps/moodlit/apps/web/src/lib/components/MoodlitLogo.svelte
Normal file
11
apps/moodlit/apps/web/src/lib/components/MoodlitLogo.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
188
apps/moodlit/apps/web/src/lib/components/mood/MoodCard.svelte
Normal file
188
apps/moodlit/apps/web/src/lib/components/mood/MoodCard.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
195
apps/moodlit/apps/web/src/lib/data/default-moods.ts
Normal file
195
apps/moodlit/apps/web/src/lib/data/default-moods.ts
Normal 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(', ')})`;
|
||||
}
|
||||
70
apps/moodlit/apps/web/src/lib/data/guest-seed.ts
Normal file
70
apps/moodlit/apps/web/src/lib/data/guest-seed.ts
Normal 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 },
|
||||
];
|
||||
38
apps/moodlit/apps/web/src/lib/data/local-store.ts
Normal file
38
apps/moodlit/apps/web/src/lib/data/local-store.ts
Normal 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');
|
||||
49
apps/moodlit/apps/web/src/lib/i18n/index.ts
Normal file
49
apps/moodlit/apps/web/src/lib/i18n/index.ts
Normal 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 };
|
||||
78
apps/moodlit/apps/web/src/lib/i18n/locales/de.json
Normal file
78
apps/moodlit/apps/web/src/lib/i18n/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
78
apps/moodlit/apps/web/src/lib/i18n/locales/en.json
Normal file
78
apps/moodlit/apps/web/src/lib/i18n/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
apps/moodlit/apps/web/src/lib/stores/auth.svelte.ts
Normal file
3
apps/moodlit/apps/web/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { createManaAuthStore } from '@manacore/shared-auth-stores';
|
||||
|
||||
export const authStore = createManaAuthStore({ devBackendPort: 3073 });
|
||||
116
apps/moodlit/apps/web/src/lib/stores/moods.svelte.ts
Normal file
116
apps/moodlit/apps/web/src/lib/stores/moods.svelte.ts
Normal 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();
|
||||
7
apps/moodlit/apps/web/src/lib/stores/navigation.ts
Normal file
7
apps/moodlit/apps/web/src/lib/stores/navigation.ts
Normal 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);
|
||||
129
apps/moodlit/apps/web/src/lib/stores/sequences.svelte.ts
Normal file
129
apps/moodlit/apps/web/src/lib/stores/sequences.svelte.ts
Normal 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();
|
||||
8
apps/moodlit/apps/web/src/lib/stores/theme.ts
Normal file
8
apps/moodlit/apps/web/src/lib/stores/theme.ts
Normal 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',
|
||||
});
|
||||
9
apps/moodlit/apps/web/src/lib/types/auth.ts
Normal file
9
apps/moodlit/apps/web/src/lib/types/auth.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* Auth types for Moodlit
|
||||
*/
|
||||
|
||||
export interface MoodlitUser {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
90
apps/moodlit/apps/web/src/lib/types/mood.ts
Normal file
90
apps/moodlit/apps/web/src/lib/types/mood.ts
Normal 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' },
|
||||
];
|
||||
90
apps/moodlit/apps/web/src/routes/(app)/+layout.svelte
Normal file
90
apps/moodlit/apps/web/src/routes/(app)/+layout.svelte
Normal 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>
|
||||
5
apps/moodlit/apps/web/src/routes/(app)/+page.svelte
Normal file
5
apps/moodlit/apps/web/src/routes/(app)/+page.svelte
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
onMount(() => goto('/moods'));
|
||||
</script>
|
||||
173
apps/moodlit/apps/web/src/routes/(app)/moods/+page.svelte
Normal file
173
apps/moodlit/apps/web/src/routes/(app)/moods/+page.svelte
Normal 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>
|
||||
117
apps/moodlit/apps/web/src/routes/(app)/sequences/+page.svelte
Normal file
117
apps/moodlit/apps/web/src/routes/(app)/sequences/+page.svelte
Normal 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>
|
||||
150
apps/moodlit/apps/web/src/routes/(app)/settings/+page.svelte
Normal file
150
apps/moodlit/apps/web/src/routes/(app)/settings/+page.svelte
Normal 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>
|
||||
37
apps/moodlit/apps/web/src/routes/+layout.svelte
Normal file
37
apps/moodlit/apps/web/src/routes/+layout.svelte
Normal 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}
|
||||
/>
|
||||
22
apps/moodlit/apps/web/src/routes/auth/login/+page.svelte
Normal file
22
apps/moodlit/apps/web/src/routes/auth/login/+page.svelte
Normal 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"
|
||||
/>
|
||||
16
apps/moodlit/apps/web/src/routes/auth/register/+page.svelte
Normal file
16
apps/moodlit/apps/web/src/routes/auth/register/+page.svelte
Normal 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"
|
||||
/>
|
||||
9
apps/moodlit/apps/web/svelte.config.js
Normal file
9
apps/moodlit/apps/web/svelte.config.js
Normal 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;
|
||||
14
apps/moodlit/apps/web/tsconfig.json
Normal file
14
apps/moodlit/apps/web/tsconfig.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
apps/moodlit/apps/web/vite.config.ts
Normal file
7
apps/moodlit/apps/web/vite.config.ts
Normal 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()],
|
||||
});
|
||||
8
apps/moodlit/package.json
Normal file
8
apps/moodlit/package.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "@manacore/moodlit",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run dev"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue