mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
✨ feat(moodlit): add complete web app with fullscreen moods and sequences
- Add 24 default moods with various animation types (pulse, wave, candle, disco, etc.) - Implement fullscreen mood view with play/pause, timer, and keyboard controls - Add create mood dialog for custom moods with color picker and animation selection - Implement sequences page with demo sequences and playback functionality - Add MoodCard component with favorite toggle and animations - Integrate with shared-branding (MoodlitLogo, AppId, APP_BRANDING config) - Add i18n support (DE/EN) for all features - Setup auth pages using shared-auth-ui - Add feedback page with shared-feedback-service integration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ad0051a8fc
commit
b7eeae9590
115 changed files with 8104 additions and 2 deletions
295
apps/moodlit/CLAUDE.md
Normal file
295
apps/moodlit/CLAUDE.md
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
# Moodlit Project Guide
|
||||
|
||||
## Übersicht
|
||||
|
||||
**Moodlit** ist eine Ambient-Lighting-App, die es Benutzern ermöglicht, benutzerdefinierte Lichtstimmungen mit Farbverläufen und Animationen zu erstellen. Die App unterstützt sowohl bildschirmbasierte Beleuchtung als auch Geräte-Taschenlampensteuerung.
|
||||
|
||||
| App | Port | URL |
|
||||
|-----|------|-----|
|
||||
| Backend | 3012 | http://localhost:3012 |
|
||||
| Web App | 5182 | http://localhost:5182 |
|
||||
| Landing Page | 4332 | http://localhost:4332 |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/moodlit/
|
||||
├── apps/
|
||||
│ ├── backend/ # NestJS API server (@moodlit/backend)
|
||||
│ │ └── src/
|
||||
│ │ ├── main.ts
|
||||
│ │ ├── app.module.ts
|
||||
│ │ ├── db/
|
||||
│ │ │ ├── database.module.ts
|
||||
│ │ │ ├── connection.ts
|
||||
│ │ │ └── schema/
|
||||
│ │ │ ├── moods.schema.ts
|
||||
│ │ │ └── sequences.schema.ts
|
||||
│ │ ├── moods/
|
||||
│ │ │ ├── moods.module.ts
|
||||
│ │ │ ├── moods.controller.ts
|
||||
│ │ │ ├── moods.service.ts
|
||||
│ │ │ └── dto/
|
||||
│ │ ├── sequences/
|
||||
│ │ │ ├── sequences.module.ts
|
||||
│ │ │ ├── sequences.controller.ts
|
||||
│ │ │ ├── sequences.service.ts
|
||||
│ │ │ └── dto/
|
||||
│ │ └── health/
|
||||
│ │
|
||||
│ ├── web/ # SvelteKit web app (@moodlit/web)
|
||||
│ │ └── src/
|
||||
│ │ ├── app.html
|
||||
│ │ ├── app.css
|
||||
│ │ └── routes/
|
||||
│ │ ├── +layout.svelte
|
||||
│ │ └── +page.svelte
|
||||
│ │
|
||||
│ ├── mobile/ # Expo React Native app (@moodlit/mobile)
|
||||
│ │ ├── app/ # Expo Router routes
|
||||
│ │ ├── components/
|
||||
│ │ ├── hooks/
|
||||
│ │ ├── store/
|
||||
│ │ └── utils/
|
||||
│ │
|
||||
│ └── landing/ # Astro landing page (@moodlit/landing)
|
||||
│
|
||||
├── package.json
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Root Level (from monorepo root)
|
||||
|
||||
```bash
|
||||
# Alle Apps starten
|
||||
pnpm moodlit:dev # Run all moodlit apps
|
||||
|
||||
# Einzelne Apps starten
|
||||
pnpm dev:moodlit:backend # Start backend server (port 3012)
|
||||
pnpm dev:moodlit:web # Start web app (port 5182)
|
||||
pnpm dev:moodlit:mobile # Start mobile app
|
||||
pnpm dev:moodlit:landing # Start landing page (port 4332)
|
||||
pnpm dev:moodlit:app # Start web + backend together
|
||||
|
||||
# Datenbank
|
||||
pnpm moodlit:db:push # Push schema to database
|
||||
pnpm moodlit:db:studio # Open Drizzle Studio
|
||||
pnpm moodlit:db:seed # Seed initial data
|
||||
|
||||
# Deploy
|
||||
pnpm deploy:landing:moodlit # Deploy landing to Cloudflare Pages
|
||||
```
|
||||
|
||||
### Backend (apps/moodlit/apps/backend)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start with hot reload
|
||||
pnpm build # Build for production
|
||||
pnpm start:prod # Start production server
|
||||
pnpm db:push # Push schema to database
|
||||
pnpm db:studio # Open Drizzle Studio
|
||||
```
|
||||
|
||||
### Web App (apps/moodlit/apps/web)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server
|
||||
pnpm build # Build for production
|
||||
pnpm preview # Preview production build
|
||||
```
|
||||
|
||||
### Mobile App (apps/moodlit/apps/mobile)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start Expo dev server
|
||||
pnpm ios # Build and run iOS simulator
|
||||
pnpm android # Build and run Android emulator
|
||||
pnpm build:dev # EAS development build
|
||||
```
|
||||
|
||||
### Landing Page (apps/moodlit/apps/landing)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server (port 4332)
|
||||
pnpm build # Build for production
|
||||
pnpm preview # Preview build
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| **Backend** | NestJS 10, Drizzle ORM, PostgreSQL |
|
||||
| **Web** | SvelteKit 2.x, Svelte 5 (runes), Tailwind CSS 4 |
|
||||
| **Mobile** | Expo SDK 54, React Native 0.81, NativeWind, Zustand |
|
||||
| **Landing** | Astro 5.x, Tailwind CSS |
|
||||
| **Auth** | Mana Core Auth (JWT) |
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Mood Library
|
||||
- Vorkonfigurierte Lichtstimmungen (Fire, Breath, Northern Lights, Thunder, etc.)
|
||||
- Verschiedene Farbverläufe und Animationstypen
|
||||
- Standard-Moods für jeden Benutzer
|
||||
|
||||
### 2. Custom Moods
|
||||
- Erstelle eigene Lichtstimmungen
|
||||
- Anpassbare Farben und Animationen
|
||||
- Speichern und Wiederverwenden
|
||||
|
||||
### 3. Sequences
|
||||
- Mehrere Moods zu einer Sequenz verketten
|
||||
- Konfigurierbare Dauer und Übergänge
|
||||
- Automatische Wiedergabe
|
||||
|
||||
### 4. Dual Output
|
||||
- Bildschirmbasierte Beleuchtung
|
||||
- Geräte-Taschenlampensteuerung
|
||||
- Umschalten zwischen Modi
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Health
|
||||
```
|
||||
GET /api/v1/health # Health check
|
||||
```
|
||||
|
||||
### Moods
|
||||
```
|
||||
GET /api/v1/moods # List all moods
|
||||
POST /api/v1/moods # Create mood
|
||||
GET /api/v1/moods/:id # Get mood
|
||||
PUT /api/v1/moods/:id # Update mood
|
||||
DELETE /api/v1/moods/:id # Delete mood
|
||||
```
|
||||
|
||||
### Sequences
|
||||
```
|
||||
GET /api/v1/sequences # List all sequences
|
||||
POST /api/v1/sequences # Create sequence
|
||||
GET /api/v1/sequences/:id # Get sequence
|
||||
PUT /api/v1/sequences/:id # Update sequence
|
||||
DELETE /api/v1/sequences/:id # Delete sequence
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### moods
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | UUID | Primary key |
|
||||
| `user_id` | TEXT | Owner |
|
||||
| `name` | TEXT | Mood name |
|
||||
| `colors` | JSONB | Array of color hex codes |
|
||||
| `animation` | TEXT | Animation type |
|
||||
| `is_default` | BOOLEAN | Default mood flag |
|
||||
| `created_at` | TIMESTAMP | Created date |
|
||||
| `updated_at` | TIMESTAMP | Updated date |
|
||||
|
||||
### sequences
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | UUID | Primary key |
|
||||
| `user_id` | TEXT | Owner |
|
||||
| `name` | TEXT | Sequence name |
|
||||
| `mood_ids` | JSONB | Array of mood IDs |
|
||||
| `duration` | INTEGER | Duration per mood (seconds) |
|
||||
| `created_at` | TIMESTAMP | Created date |
|
||||
| `updated_at` | TIMESTAMP | Updated date |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend (.env)
|
||||
```env
|
||||
NODE_ENV=development
|
||||
PORT=3012
|
||||
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/moods
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:5182,http://localhost:8081
|
||||
DEV_BYPASS_AUTH=true
|
||||
DEV_USER_ID=your-test-user-id
|
||||
```
|
||||
|
||||
### Web (.env)
|
||||
```env
|
||||
PUBLIC_BACKEND_URL=http://localhost:3012
|
||||
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
### Mobile (.env)
|
||||
```env
|
||||
EXPO_PUBLIC_BACKEND_URL=http://localhost:3012
|
||||
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Datenbank erstellen
|
||||
|
||||
```bash
|
||||
# PostgreSQL Container muss laufen
|
||||
docker compose -f docker-compose.dev.yml up -d postgres
|
||||
|
||||
# Datenbank erstellen
|
||||
PGPASSWORD=devpassword psql -h localhost -U manacore -d postgres -c "CREATE DATABASE moods;"
|
||||
|
||||
# Schema pushen
|
||||
pnpm moodlit:db:push
|
||||
```
|
||||
|
||||
### 2. Apps starten
|
||||
|
||||
```bash
|
||||
# Backend + Web zusammen
|
||||
pnpm dev:moodlit:app
|
||||
|
||||
# Oder einzeln:
|
||||
pnpm dev:moodlit:backend # Terminal 1
|
||||
pnpm dev:moodlit:web # Terminal 2
|
||||
pnpm dev:moodlit:mobile # Terminal 3
|
||||
pnpm dev:moodlit:landing # Terminal 4 (optional)
|
||||
```
|
||||
|
||||
### 3. URLs öffnen
|
||||
|
||||
- Web App: http://localhost:5182
|
||||
- Landing: http://localhost:4332
|
||||
- API Health: http://localhost:3012/api/v1/health
|
||||
|
||||
## Testing API (mit curl)
|
||||
|
||||
```bash
|
||||
# Health Check
|
||||
curl http://localhost:3012/api/v1/health
|
||||
|
||||
# Login (get token)
|
||||
TOKEN=$(curl -s -X POST http://localhost:3001/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "test@example.com", "password": "password"}' | jq -r '.accessToken')
|
||||
|
||||
# Moods abrufen
|
||||
curl http://localhost:3012/api/v1/moods \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Neues Mood erstellen
|
||||
curl -X POST http://localhost:3012/api/v1/moods \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Sunset", "colors": ["#ff6b6b", "#feca57", "#ff9ff3"], "animation": "gradient"}'
|
||||
|
||||
# Sequence erstellen
|
||||
curl -X POST http://localhost:3012/api/v1/sequences \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Evening Flow", "moodIds": ["mood-id-1", "mood-id-2"], "duration": 30}'
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Authentication**: Nutzt Mana Core Auth (JWT im Authorization Header)
|
||||
2. **Database**: PostgreSQL mit Drizzle ORM (Port 5432)
|
||||
3. **Port**: Backend läuft auf Port 3012, Web auf 5182, Landing auf 4332
|
||||
4. **Mobile**: Verwendet Expo Dev Client (nicht Expo Go) wegen nativer Dependencies
|
||||
5. **Theme**: Purple/Violet als Primärfarbe für die Mood-Thematik
|
||||
12
apps/moodlit/apps/backend/drizzle.config.ts
Normal file
12
apps/moodlit/apps/backend/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
schema: './src/db/schema/index.ts',
|
||||
out: './src/db/migrations',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/moods',
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
||||
8
apps/moodlit/apps/backend/nest-cli.json
Normal file
8
apps/moodlit/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
53
apps/moodlit/apps/backend/package.json
Normal file
53
apps/moodlit/apps/backend/package.json
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"name": "@moodlit/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"dev": "nest start --watch",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit",
|
||||
"migration:generate": "drizzle-kit generate",
|
||||
"migration:run": "tsx src/db/migrate.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "tsx src/db/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-nestjs-auth": "workspace:*",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"postgres": "^3.4.5",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
||||
"@typescript-eslint/parser": "^8.18.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
20
apps/moodlit/apps/backend/src/app.module.ts
Normal file
20
apps/moodlit/apps/backend/src/app.module.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { DatabaseModule } from './db/database.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { MoodsModule } from './moods/moods.module';
|
||||
import { SequencesModule } from './sequences/sequences.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
DatabaseModule,
|
||||
HealthModule,
|
||||
MoodsModule,
|
||||
SequencesModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
38
apps/moodlit/apps/backend/src/db/connection.ts
Normal file
38
apps/moodlit/apps/backend/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import * as schema from './schema';
|
||||
|
||||
// Use require for postgres to avoid ESM/CommonJS interop issues
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const postgres = require('postgres');
|
||||
|
||||
let connection: ReturnType<typeof postgres> | null = null;
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
export function getConnection(databaseUrl: string) {
|
||||
if (!connection) {
|
||||
connection = postgres(databaseUrl, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export function getDb(databaseUrl: string) {
|
||||
if (!db) {
|
||||
const conn = getConnection(databaseUrl);
|
||||
db = drizzle(conn, { schema });
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function closeConnection() {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
28
apps/moodlit/apps/backend/src/db/database.module.ts
Normal file
28
apps/moodlit/apps/backend/src/db/database.module.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb, closeConnection, type Database } from './connection';
|
||||
|
||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: (configService: ConfigService): Database => {
|
||||
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
return getDb(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
async onModuleDestroy() {
|
||||
await closeConnection();
|
||||
}
|
||||
}
|
||||
2
apps/moodlit/apps/backend/src/db/schema/index.ts
Normal file
2
apps/moodlit/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './moods.schema';
|
||||
export * from './sequences.schema';
|
||||
15
apps/moodlit/apps/backend/src/db/schema/moods.schema.ts
Normal file
15
apps/moodlit/apps/backend/src/db/schema/moods.schema.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { pgTable, uuid, text, jsonb, boolean, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const moods = pgTable('moods', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
colors: jsonb('colors').notNull().$type<string[]>(),
|
||||
animation: text('animation'),
|
||||
isDefault: boolean('is_default').default(false),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow(),
|
||||
});
|
||||
|
||||
export type Mood = typeof moods.$inferSelect;
|
||||
export type NewMood = typeof moods.$inferInsert;
|
||||
14
apps/moodlit/apps/backend/src/db/schema/sequences.schema.ts
Normal file
14
apps/moodlit/apps/backend/src/db/schema/sequences.schema.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { pgTable, uuid, text, jsonb, integer, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const sequences = pgTable('sequences', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
moodIds: jsonb('mood_ids').notNull().$type<string[]>(),
|
||||
duration: integer('duration').default(30),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow(),
|
||||
});
|
||||
|
||||
export type Sequence = typeof sequences.$inferSelect;
|
||||
export type NewSequence = typeof sequences.$inferInsert;
|
||||
13
apps/moodlit/apps/backend/src/health/health.controller.ts
Normal file
13
apps/moodlit/apps/backend/src/health/health.controller.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'moods-backend',
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/moodlit/apps/backend/src/health/health.module.ts
Normal file
7
apps/moodlit/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
40
apps/moodlit/apps/backend/src/main.ts
Normal file
40
apps/moodlit/apps/backend/src/main.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Enable CORS for mobile and web apps
|
||||
const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((origin) => origin.trim()) || [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5182',
|
||||
'http://localhost:8081',
|
||||
'exp://localhost:8081',
|
||||
'http://localhost:3001',
|
||||
];
|
||||
|
||||
app.enableCors({
|
||||
origin: corsOrigins,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Enable validation
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Set global prefix for API routes
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
const port = process.env.PORT || 3012;
|
||||
await app.listen(port);
|
||||
console.log(`Moods backend running on http://localhost:${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
37
apps/moodlit/apps/backend/src/moods/dto/index.ts
Normal file
37
apps/moodlit/apps/backend/src/moods/dto/index.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { IsString, IsArray, IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateMoodDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
colors: string[];
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
animation?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateMoodDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
colors?: string[];
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
animation?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isDefault?: boolean;
|
||||
}
|
||||
40
apps/moodlit/apps/backend/src/moods/moods.controller.ts
Normal file
40
apps/moodlit/apps/backend/src/moods/moods.controller.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { MoodsService } from './moods.service';
|
||||
import { CreateMoodDto, UpdateMoodDto } from './dto';
|
||||
|
||||
@Controller('moods')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MoodsController {
|
||||
constructor(private readonly moodsService: MoodsService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
return this.moodsService.findAllByUser(user.userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||
return this.moodsService.findOne(id, user.userId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateMoodDto, @CurrentUser() user: CurrentUserData) {
|
||||
return this.moodsService.create(user.userId, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateMoodDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
) {
|
||||
return this.moodsService.update(id, user.userId, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||
await this.moodsService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
apps/moodlit/apps/backend/src/moods/moods.module.ts
Normal file
10
apps/moodlit/apps/backend/src/moods/moods.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MoodsController } from './moods.controller';
|
||||
import { MoodsService } from './moods.service';
|
||||
|
||||
@Module({
|
||||
controllers: [MoodsController],
|
||||
providers: [MoodsService],
|
||||
exports: [MoodsService],
|
||||
})
|
||||
export class MoodsModule {}
|
||||
64
apps/moodlit/apps/backend/src/moods/moods.service.ts
Normal file
64
apps/moodlit/apps/backend/src/moods/moods.service.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { moods, type Mood, type NewMood } from '../db/schema/moods.schema';
|
||||
import { CreateMoodDto, UpdateMoodDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class MoodsService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findAllByUser(userId: string): Promise<Mood[]> {
|
||||
return this.db.select().from(moods).where(eq(moods.userId, userId));
|
||||
}
|
||||
|
||||
async findOne(id: string, userId: string): Promise<Mood> {
|
||||
const [mood] = await this.db
|
||||
.select()
|
||||
.from(moods)
|
||||
.where(and(eq(moods.id, id), eq(moods.userId, userId)));
|
||||
|
||||
if (!mood) {
|
||||
throw new NotFoundException(`Mood with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return mood;
|
||||
}
|
||||
|
||||
async create(userId: string, dto: CreateMoodDto): Promise<Mood> {
|
||||
const newMood: NewMood = {
|
||||
userId,
|
||||
name: dto.name,
|
||||
colors: dto.colors,
|
||||
animation: dto.animation,
|
||||
isDefault: dto.isDefault ?? false,
|
||||
};
|
||||
|
||||
const [mood] = await this.db.insert(moods).values(newMood).returning();
|
||||
return mood;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, dto: UpdateMoodDto): Promise<Mood> {
|
||||
// Verify the mood exists and belongs to the user
|
||||
await this.findOne(id, userId);
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(moods)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(moods.id, id), eq(moods.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
// Verify the mood exists and belongs to the user
|
||||
await this.findOne(id, userId);
|
||||
|
||||
await this.db.delete(moods).where(and(eq(moods.id, id), eq(moods.userId, userId)));
|
||||
}
|
||||
}
|
||||
29
apps/moodlit/apps/backend/src/sequences/dto/index.ts
Normal file
29
apps/moodlit/apps/backend/src/sequences/dto/index.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { IsString, IsArray, IsNumber, IsOptional } from 'class-validator';
|
||||
|
||||
export class CreateSequenceDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
moodIds: string[];
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export class UpdateSequenceDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
moodIds?: string[];
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
duration?: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { SequencesService } from './sequences.service';
|
||||
import { CreateSequenceDto, UpdateSequenceDto } from './dto';
|
||||
|
||||
@Controller('sequences')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SequencesController {
|
||||
constructor(private readonly sequencesService: SequencesService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
return this.sequencesService.findAllByUser(user.userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||
return this.sequencesService.findOne(id, user.userId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateSequenceDto, @CurrentUser() user: CurrentUserData) {
|
||||
return this.sequencesService.create(user.userId, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateSequenceDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
) {
|
||||
return this.sequencesService.update(id, user.userId, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||
await this.sequencesService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
apps/moodlit/apps/backend/src/sequences/sequences.module.ts
Normal file
10
apps/moodlit/apps/backend/src/sequences/sequences.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SequencesController } from './sequences.controller';
|
||||
import { SequencesService } from './sequences.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SequencesController],
|
||||
providers: [SequencesService],
|
||||
exports: [SequencesService],
|
||||
})
|
||||
export class SequencesModule {}
|
||||
63
apps/moodlit/apps/backend/src/sequences/sequences.service.ts
Normal file
63
apps/moodlit/apps/backend/src/sequences/sequences.service.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { sequences, type Sequence, type NewSequence } from '../db/schema/sequences.schema';
|
||||
import { CreateSequenceDto, UpdateSequenceDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class SequencesService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findAllByUser(userId: string): Promise<Sequence[]> {
|
||||
return this.db.select().from(sequences).where(eq(sequences.userId, userId));
|
||||
}
|
||||
|
||||
async findOne(id: string, userId: string): Promise<Sequence> {
|
||||
const [sequence] = await this.db
|
||||
.select()
|
||||
.from(sequences)
|
||||
.where(and(eq(sequences.id, id), eq(sequences.userId, userId)));
|
||||
|
||||
if (!sequence) {
|
||||
throw new NotFoundException(`Sequence with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
async create(userId: string, dto: CreateSequenceDto): Promise<Sequence> {
|
||||
const newSequence: NewSequence = {
|
||||
userId,
|
||||
name: dto.name,
|
||||
moodIds: dto.moodIds,
|
||||
duration: dto.duration ?? 30,
|
||||
};
|
||||
|
||||
const [sequence] = await this.db.insert(sequences).values(newSequence).returning();
|
||||
return sequence;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, dto: UpdateSequenceDto): Promise<Sequence> {
|
||||
// Verify the sequence exists and belongs to the user
|
||||
await this.findOne(id, userId);
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(sequences)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(sequences.id, id), eq(sequences.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
// Verify the sequence exists and belongs to the user
|
||||
await this.findOne(id, userId);
|
||||
|
||||
await this.db.delete(sequences).where(and(eq(sequences.id, id), eq(sequences.userId, userId)));
|
||||
}
|
||||
}
|
||||
26
apps/moodlit/apps/backend/tsconfig.json
Normal file
26
apps/moodlit/apps/backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
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"
|
||||
25
apps/moodlit/apps/mobile/.gitignore
vendored
Normal file
25
apps/moodlit/apps/mobile/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
npm-debug.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
# expo router
|
||||
expo-env.d.ts
|
||||
|
||||
# firebase/supabase/vexo
|
||||
.env
|
||||
|
||||
ios
|
||||
android
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Temporary files created by Metro to check the health of the file watcher
|
||||
.metro-health-check*
|
||||
95
apps/moodlit/apps/mobile/CLAUDE.md
Normal file
95
apps/moodlit/apps/mobile/CLAUDE.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Moodlit** is a React Native mobile application built with Expo Router, targeting iOS and Android platforms. The app creates ambient lighting effects using the device's screen and flashlight with customizable color gradients and animations. It uses NativeWind (TailwindCSS for React Native) for styling and Zustand for state management.
|
||||
|
||||
### Key Features
|
||||
- **Mood Library**: Pre-configured lighting moods (Fire, Breath, Northern Lights, Thunder, etc.) with different color gradients and animation types
|
||||
- **Custom Moods**: Create custom lighting effects with personalized colors and animations
|
||||
- **Sequences**: Chain multiple moods together with configurable durations and transitions
|
||||
- **Dual Output**: Toggle between screen-based lighting and device flashlight
|
||||
- **Settings**: Adjustable animation speed, haptic feedback, brightness, and auto-timer functionality
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Starting the Development Server
|
||||
```bash
|
||||
npm start # Start Expo dev server with dev client
|
||||
npm run ios # Build and run on iOS simulator
|
||||
npm run android # Build and run on Android emulator
|
||||
npm run web # Run web version
|
||||
```
|
||||
|
||||
### Building
|
||||
```bash
|
||||
npm run prebuild # Generate native directories for iOS/Android
|
||||
npm run build:dev # Build development build via EAS
|
||||
npm run build:preview # Build preview version via EAS
|
||||
npm run build:prod # Build production version via EAS
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
npm run lint # Run ESLint and Prettier check
|
||||
npm run format # Auto-fix with ESLint and format with Prettier
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Routing
|
||||
- **Expo Router** (file-based routing): Routes are defined by file structure in the `app/` directory
|
||||
- `app/_layout.tsx`: Root layout component that wraps all screens
|
||||
- `app/index.tsx`: Home screen
|
||||
- `app/details.tsx`: Details screen
|
||||
- Route navigation uses `expo-router` Link component with typed routes enabled
|
||||
|
||||
### State Management
|
||||
- **Zustand**: Global state management in `store/store.ts`
|
||||
- Store definitions follow a pattern of state + action methods
|
||||
- Example store structure includes state interface and create function
|
||||
|
||||
### Backend Integration
|
||||
- **Supabase Client**: Configured in `utils/supabase.ts`
|
||||
- Uses AsyncStorage for session persistence
|
||||
- Environment variables required:
|
||||
- `EXPO_PUBLIC_SUPABASE_URL`
|
||||
- `EXPO_PUBLIC_SUPABASE_ANON_KEY`
|
||||
- Auto-refresh tokens and persistent sessions enabled
|
||||
|
||||
### Styling System
|
||||
- **NativeWind**: TailwindCSS for React Native
|
||||
- Global styles imported via `global.css` in root layout
|
||||
- Tailwind config includes `app/**` and `components/**` content paths
|
||||
- Styles defined as string literals with `className` prop (not `style`)
|
||||
- Example: `className="flex flex-1 bg-white"`
|
||||
|
||||
### Path Aliases
|
||||
- TypeScript configured with `@/*` path alias mapping to root directory
|
||||
- Import components/utils with `@/components/...` or `@/utils/...`
|
||||
|
||||
### Components Structure
|
||||
- Reusable components in `components/` directory:
|
||||
- `Button.tsx`: Touchable button component
|
||||
- `Container.tsx`: Layout wrapper
|
||||
- `ScreenContent.tsx`: Screen template with title and separator
|
||||
- `EditScreenInfo.tsx`: Info display component
|
||||
|
||||
## Key Configuration Files
|
||||
|
||||
- `app.json`: Expo configuration with typed routes and tsconfigPaths experiments enabled
|
||||
- `tsconfig.json`: TypeScript with strict mode and path aliases
|
||||
- `tailwind.config.js`: NativeWind preset with custom content paths
|
||||
- `babel.config.js`: Babel configuration for Expo
|
||||
- `metro.config.js`: Metro bundler configuration
|
||||
- `.env`: Environment variables (not committed, contains Supabase credentials)
|
||||
|
||||
## Development Notes
|
||||
|
||||
- This project uses React 19.1.0 and React Native 0.81.5
|
||||
- Expo SDK version 54
|
||||
- TypeScript strict mode is enabled
|
||||
- The app requires Expo Dev Client (not Expo Go) due to custom native dependencies
|
||||
- Web support is available via Metro bundler with static output
|
||||
81
apps/moodlit/apps/mobile/app.json
Normal file
81
apps/moodlit/apps/mobile/app.json
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "Moodlit",
|
||||
"slug": "moods",
|
||||
"version": "1.0.0",
|
||||
"scheme": "moods",
|
||||
"platforms": ["ios", "android"],
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Erlaubt $(PRODUCT_NAME) die Kamera für die Taschenlampen-Funktion zu nutzen."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"backgroundColor": "#000000",
|
||||
"image": "./assets/splash.png",
|
||||
"imageWidth": 200
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"tsconfigPaths": true
|
||||
},
|
||||
"orientation": "default",
|
||||
"icon": "./assets/mood-light-logo.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"requireFullScreen": false,
|
||||
"bundleIdentifier": "com.tilljs.moodlight",
|
||||
"icon": "./assets/mood-light.icon",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"UIRequiresFullScreen": false,
|
||||
"UIUserInterfaceStyle": "Dark",
|
||||
"UISupportedInterfaceOrientations": [
|
||||
"UIInterfaceOrientationPortrait",
|
||||
"UIInterfaceOrientationLandscapeLeft",
|
||||
"UIInterfaceOrientationLandscapeRight"
|
||||
],
|
||||
"UISupportedInterfaceOrientations~ipad": [
|
||||
"UIInterfaceOrientationPortrait",
|
||||
"UIInterfaceOrientationPortraitUpsideDown",
|
||||
"UIInterfaceOrientationLandscapeLeft",
|
||||
"UIInterfaceOrientationLandscapeRight"
|
||||
]
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/mood-light-logo.png",
|
||||
"backgroundColor": "#000000"
|
||||
},
|
||||
"package": "com.tilljs.moodlight",
|
||||
"permissions": [
|
||||
"CAMERA",
|
||||
"FLASHLIGHT",
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.RECORD_AUDIO"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "faec0f17-97e2-4be5-9a85-d281b5635e7a"
|
||||
}
|
||||
},
|
||||
"owner": "memoro"
|
||||
}
|
||||
}
|
||||
46
apps/moodlit/apps/mobile/app/+html.tsx
Normal file
46
apps/moodlit/apps/mobile/app/+html.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||
|
||||
// This file is web-only and used to configure the root HTML for every
|
||||
// web page during static rendering.
|
||||
// The contents of this function only run in Node.js environments and
|
||||
// do not have access to the DOM or browser APIs.
|
||||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
|
||||
{/*
|
||||
This viewport disables scaling which makes the mobile website act more like a native app.
|
||||
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
*/}
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
|
||||
/>
|
||||
{/*
|
||||
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||
*/}
|
||||
<ScrollViewStyleReset />
|
||||
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const responsiveBackground = `
|
||||
body {
|
||||
background-color: #fff;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000;
|
||||
}
|
||||
}`;
|
||||
26
apps/moodlit/apps/mobile/app/+not-found.tsx
Normal file
26
apps/moodlit/apps/mobile/app/+not-found.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Link, Stack } from 'expo-router';
|
||||
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
import { Container } from '@/components/Container';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<Container>
|
||||
<Text className={styles.title}>{"This screen doesn't exist."}</Text>
|
||||
<Link href="/" className={styles.link}>
|
||||
<Text className={styles.linkText}>Go to home screen!</Text>
|
||||
</Link>
|
||||
</Container>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: `flex flex-1 bg-black`,
|
||||
title: `text-xl font-bold text-white`,
|
||||
link: `mt-4 pt-4`,
|
||||
linkText: `text-base text-blue-500`,
|
||||
};
|
||||
22
apps/moodlit/apps/mobile/app/_layout.tsx
Normal file
22
apps/moodlit/apps/mobile/app/_layout.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import '../global.css';
|
||||
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: '#000000',
|
||||
},
|
||||
headerTintColor: '#ffffff',
|
||||
headerTitleStyle: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
contentStyle: {
|
||||
backgroundColor: '#000000',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
192
apps/moodlit/apps/mobile/app/create-mood.tsx
Normal file
192
apps/moodlit/apps/mobile/app/create-mood.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import React, { useState } from 'react';
|
||||
import { ScrollView, View, Text, TextInput, Pressable, Alert, StyleSheet } from 'react-native';
|
||||
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { useResponsive } from '@/hooks/useResponsive';
|
||||
import type { AnimationType } from '@/store/store';
|
||||
import { useStore } from '@/store/store';
|
||||
import { getThemeColors } from '@/utils/theme';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
['#FF6B6B', '#FFE66D'],
|
||||
['#4ECDC4', '#44A08D'],
|
||||
['#6B8DD6', '#8E37D7'],
|
||||
['#F857A6', '#FF5858'],
|
||||
['#2E3192', '#1BFFFF'],
|
||||
['#FFD89B', '#19547B'],
|
||||
['#00F260', '#0575E6'],
|
||||
['#FA8BFF', '#2BD2FF'],
|
||||
['#FEB692', '#EA5455'],
|
||||
['#A8EDEA', '#FED6E3'],
|
||||
];
|
||||
|
||||
const ANIMATION_TYPES: { label: string; value: AnimationType }[] = [
|
||||
{ label: 'Gradient', value: 'gradient' },
|
||||
{ label: 'Pulsieren', value: 'pulse' },
|
||||
{ label: 'Wellen', value: 'wave' },
|
||||
];
|
||||
|
||||
export default function CreateMood() {
|
||||
const router = useRouter();
|
||||
const addCustomMood = useStore((state) => state.addCustomMood);
|
||||
const settings = useStore((state) => state.settings);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [colors, setColors] = useState<string[]>(PRESET_COLORS[0]);
|
||||
const [animationType, setAnimationType] = useState<AnimationType>('gradient');
|
||||
|
||||
const theme = getThemeColors();
|
||||
const responsive = useResponsive();
|
||||
|
||||
const handleHaptic = () => {
|
||||
if (settings.hapticFeedback) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!name.trim()) {
|
||||
Alert.alert('Fehler', 'Bitte gib einen Namen ein');
|
||||
return;
|
||||
}
|
||||
|
||||
handleHaptic();
|
||||
addCustomMood({
|
||||
name: name.trim(),
|
||||
colors,
|
||||
animationType,
|
||||
});
|
||||
|
||||
Alert.alert('Erfolg', 'Mood wurde erstellt!', [
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className={`flex-1 ${theme.bg}`}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Mood erstellen',
|
||||
headerBackTitle: 'Zurück',
|
||||
}}
|
||||
/>
|
||||
<ScrollView className="flex-1" contentContainerClassName="items-center pb-8 pt-4">
|
||||
<View style={{ width: '100%', maxWidth: responsive.maxContentWidth }} className="px-4">
|
||||
{/* Preview */}
|
||||
<View className="mb-6 overflow-hidden rounded-3xl" style={styles.previewCard}>
|
||||
<LinearGradient
|
||||
colors={colors}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.previewGradient}
|
||||
>
|
||||
<View className="absolute left-5 right-5 top-5">
|
||||
<Text className="text-3xl font-bold text-white">{name || 'Vorschau'}</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
|
||||
{/* Name Input */}
|
||||
<View className={`${theme.cardBg} mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-2 text-lg font-semibold ${theme.text}`}>Name</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="z.B. Meditation"
|
||||
className={`p-3 text-base ${theme.input} rounded-lg ${theme.text}`}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
maxLength={30}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Color Selection */}
|
||||
<View className={`${theme.cardBg} mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${theme.text}`}>Farben</Text>
|
||||
<View className="flex-row flex-wrap gap-3">
|
||||
{PRESET_COLORS.map((colorPair, index) => (
|
||||
<Pressable
|
||||
key={index}
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
setColors(colorPair);
|
||||
}}
|
||||
style={[styles.colorBox, colors === colorPair && styles.selectedColorBox]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={colorPair}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
/>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Animation Type */}
|
||||
<View className={`${theme.cardBg} mb-6 rounded-2xl p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${theme.text}`}>Animation</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{ANIMATION_TYPES.map((type) => (
|
||||
<Pressable
|
||||
key={type.value}
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
setAnimationType(type.value);
|
||||
}}
|
||||
className={`rounded-full px-4 py-2 ${
|
||||
animationType === type.value ? 'bg-blue-500' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-medium ${
|
||||
animationType === type.value ? 'text-white' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{type.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Create Button */}
|
||||
<Pressable onPress={handleCreate} className="items-center rounded-2xl bg-blue-500 p-4">
|
||||
<Text className="text-lg font-semibold text-white">Mood erstellen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
previewCard: {
|
||||
aspectRatio: 16 / 9,
|
||||
width: '100%',
|
||||
},
|
||||
previewGradient: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
colorBox: {
|
||||
width: '30%',
|
||||
aspectRatio: 1,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
selectedColorBox: {
|
||||
borderWidth: 4,
|
||||
borderColor: '#3B82F6',
|
||||
},
|
||||
gradient: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
});
|
||||
249
apps/moodlit/apps/mobile/app/create-sequence.tsx
Normal file
249
apps/moodlit/apps/mobile/app/create-sequence.tsx
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import Slider from '@react-native-community/slider';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import React, { useState } from 'react';
|
||||
import { ScrollView, View, Text, TextInput, Pressable, Alert, Modal } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { useResponsive } from '@/hooks/useResponsive';
|
||||
import type { MoodSequenceItem } from '@/store/store';
|
||||
import { useStore } from '@/store/store';
|
||||
import { getThemeColors } from '@/utils/theme';
|
||||
|
||||
const TRANSITION_OPTIONS = [
|
||||
{ label: '2s', value: 2 },
|
||||
{ label: '5s', value: 5 },
|
||||
{ label: '10s', value: 10 },
|
||||
];
|
||||
|
||||
export default function CreateSequence() {
|
||||
const router = useRouter();
|
||||
const moods = useStore((state) => state.moods);
|
||||
const settings = useStore((state) => state.settings);
|
||||
const addSequence = useStore((state) => state.addSequence);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [transitionDuration, setTransitionDuration] = useState(5);
|
||||
const [items, setItems] = useState<MoodSequenceItem[]>([]);
|
||||
const [showMoodPicker, setShowMoodPicker] = useState(false);
|
||||
|
||||
const theme = getThemeColors();
|
||||
const responsive = useResponsive();
|
||||
|
||||
const handleHaptic = () => {
|
||||
if (settings.hapticFeedback) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
};
|
||||
|
||||
const addMoodToSequence = (moodId: string) => {
|
||||
handleHaptic();
|
||||
setItems([...items, { moodId, duration: 300 }]); // 5 Minuten default
|
||||
setShowMoodPicker(false);
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
if (mins === 0) {
|
||||
return `${secs} Sek`;
|
||||
} else if (secs === 0) {
|
||||
return `${mins} Min`;
|
||||
} else {
|
||||
return `${mins} Min ${secs} Sek`;
|
||||
}
|
||||
};
|
||||
|
||||
const removeMoodFromSequence = (index: number) => {
|
||||
handleHaptic();
|
||||
setItems(items.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateMoodDuration = (index: number, duration: number) => {
|
||||
handleHaptic();
|
||||
const newItems = [...items];
|
||||
newItems[index].duration = duration;
|
||||
setItems(newItems);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!name.trim()) {
|
||||
Alert.alert('Fehler', 'Bitte gib einen Namen ein');
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
Alert.alert('Fehler', 'Bitte füge mindestens einen Mood hinzu');
|
||||
return;
|
||||
}
|
||||
|
||||
handleHaptic();
|
||||
addSequence({
|
||||
name: name.trim(),
|
||||
items,
|
||||
transitionDuration,
|
||||
isCustom: true,
|
||||
});
|
||||
|
||||
Alert.alert('Erfolg', 'Sequenz wurde erstellt!', [
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const getMoodById = (id: string) => moods.find((m) => m.id === id);
|
||||
|
||||
return (
|
||||
<View className={`flex-1 ${theme.bg}`}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Neue Sequenz',
|
||||
headerBackTitle: 'Zurück',
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingTop: 16, paddingBottom: 32, paddingHorizontal: 16 }}
|
||||
>
|
||||
{/* Name Input */}
|
||||
<View className={`${theme.cardBg} mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-2 text-lg font-semibold ${theme.text}`}>Name</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="z.B. Morgen Routine"
|
||||
className={`p-3 text-base ${theme.input} rounded-lg ${theme.text}`}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
maxLength={30}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Transition Duration */}
|
||||
<View className={`${theme.cardBg} mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${theme.text}`}>Übergangsdauer</Text>
|
||||
<View className="flex-row gap-2">
|
||||
{TRANSITION_OPTIONS.map((option) => (
|
||||
<Pressable
|
||||
key={option.value}
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
setTransitionDuration(option.value);
|
||||
}}
|
||||
className={`rounded-full px-4 py-2 ${
|
||||
transitionDuration === option.value ? 'bg-blue-500' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-medium ${
|
||||
transitionDuration === option.value ? 'text-white' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Moods in Sequenz */}
|
||||
<View className={`${theme.cardBg} mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${theme.text}`}>Moods ({items.length})</Text>
|
||||
{items.map((item, index) => {
|
||||
const mood = getMoodById(item.moodId);
|
||||
if (!mood) return null;
|
||||
|
||||
return (
|
||||
<View key={index} className="mb-2 rounded-xl bg-gray-800 p-4">
|
||||
{/* Mood Name & Delete */}
|
||||
<View className="mb-3 flex-row items-center justify-between">
|
||||
<Text className="flex-1 text-base font-medium text-white">
|
||||
{index + 1}. {mood.name}
|
||||
</Text>
|
||||
<Pressable onPress={() => removeMoodFromSequence(index)} className="ml-2">
|
||||
<Icon name="close" size={18} color="#EF4444" weight="bold" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Duration Slider */}
|
||||
<View>
|
||||
<Text className="mb-2 text-sm text-white">
|
||||
Dauer: {formatDuration(item.duration)}
|
||||
</Text>
|
||||
<Slider
|
||||
minimumValue={1}
|
||||
maximumValue={3600}
|
||||
step={1}
|
||||
value={item.duration}
|
||||
onValueChange={(value) => updateMoodDuration(index, Math.round(value))}
|
||||
minimumTrackTintColor="#3B82F6"
|
||||
maximumTrackTintColor="#4B5563"
|
||||
thumbTintColor="#3B82F6"
|
||||
/>
|
||||
<View className="mt-1 flex-row justify-between">
|
||||
<Text className="text-xs text-gray-500">1 Sek</Text>
|
||||
<Text className="text-xs text-gray-500">60 Min</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add Mood Button */}
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
setShowMoodPicker(true);
|
||||
}}
|
||||
className="mt-2 items-center rounded-xl bg-gray-700 p-3"
|
||||
>
|
||||
<Text className="font-medium text-white">+ Mood hinzufügen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Create Button */}
|
||||
<Pressable onPress={handleCreate} className="items-center rounded-2xl bg-blue-500 p-4">
|
||||
<Text className="text-lg font-semibold text-white">Sequenz speichern</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
|
||||
{/* Mood Picker Modal */}
|
||||
<Modal
|
||||
visible={showMoodPicker}
|
||||
animationType="slide"
|
||||
transparent
|
||||
onRequestClose={() => setShowMoodPicker(false)}
|
||||
>
|
||||
<View className="flex-1 justify-end bg-black/50">
|
||||
<View className="max-h-[70%] rounded-t-3xl bg-gray-900 p-4">
|
||||
<View className="mb-4 flex-row items-center justify-between">
|
||||
<Text className="text-xl font-bold text-white">Mood auswählen</Text>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
setShowMoodPicker(false);
|
||||
}}
|
||||
>
|
||||
<Icon name="close" size={24} color="#fff" weight="bold" />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView>
|
||||
{moods.map((mood) => (
|
||||
<Pressable
|
||||
key={mood.id}
|
||||
onPress={() => addMoodToSequence(mood.id)}
|
||||
className="mb-2 rounded-xl bg-gray-800 p-4"
|
||||
>
|
||||
<Text className="text-lg font-medium text-white">{mood.name}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
23
apps/moodlit/apps/mobile/app/details.tsx
Normal file
23
apps/moodlit/apps/mobile/app/details.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { View } from 'react-native';
|
||||
|
||||
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||
|
||||
import { Container } from '@/components/Container';
|
||||
import { ScreenContent } from '@/components/ScreenContent';
|
||||
|
||||
export default function Details() {
|
||||
const { name } = useLocalSearchParams();
|
||||
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Stack.Screen options={{ title: 'Details' }} />
|
||||
<Container>
|
||||
<ScreenContent path="screens/details.tsx" title={`Showing details for user ${name}`} />
|
||||
</Container>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: 'flex flex-1 bg-black',
|
||||
};
|
||||
272
apps/moodlit/apps/mobile/app/edit-sequence/[id].tsx
Normal file
272
apps/moodlit/apps/mobile/app/edit-sequence/[id].tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import Slider from '@react-native-community/slider';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ScrollView, View, Text, TextInput, Pressable, Alert, Modal } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { useResponsive } from '@/hooks/useResponsive';
|
||||
import type { MoodSequenceItem } from '@/store/store';
|
||||
import { useStore } from '@/store/store';
|
||||
import { getThemeColors } from '@/utils/theme';
|
||||
|
||||
const TRANSITION_OPTIONS = [
|
||||
{ label: '2s', value: 2 },
|
||||
{ label: '5s', value: 5 },
|
||||
{ label: '10s', value: 10 },
|
||||
];
|
||||
|
||||
export default function EditSequence() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const moods = useStore((state) => state.moods);
|
||||
const sequences = useStore((state) => state.sequences);
|
||||
const settings = useStore((state) => state.settings);
|
||||
const updateSequence = useStore((state) => state.updateSequence);
|
||||
|
||||
const sequence = sequences.find((s) => s.id === id);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [transitionDuration, setTransitionDuration] = useState(5);
|
||||
const [items, setItems] = useState<MoodSequenceItem[]>([]);
|
||||
const [showMoodPicker, setShowMoodPicker] = useState(false);
|
||||
|
||||
const theme = getThemeColors();
|
||||
const responsive = useResponsive();
|
||||
|
||||
// Lade bestehende Sequenz-Daten
|
||||
useEffect(() => {
|
||||
if (sequence) {
|
||||
setName(sequence.name);
|
||||
setTransitionDuration(sequence.transitionDuration);
|
||||
setItems([...sequence.items]);
|
||||
}
|
||||
}, [sequence]);
|
||||
|
||||
const handleHaptic = () => {
|
||||
if (settings.hapticFeedback) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
};
|
||||
|
||||
const addMoodToSequence = (moodId: string) => {
|
||||
handleHaptic();
|
||||
setItems([...items, { moodId, duration: 300 }]); // 5 Minuten default
|
||||
setShowMoodPicker(false);
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
if (mins === 0) {
|
||||
return `${secs} Sek`;
|
||||
} else if (secs === 0) {
|
||||
return `${mins} Min`;
|
||||
} else {
|
||||
return `${mins} Min ${secs} Sek`;
|
||||
}
|
||||
};
|
||||
|
||||
const removeMoodFromSequence = (index: number) => {
|
||||
handleHaptic();
|
||||
setItems(items.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateMoodDuration = (index: number, duration: number) => {
|
||||
handleHaptic();
|
||||
const newItems = [...items];
|
||||
newItems[index].duration = duration;
|
||||
setItems(newItems);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim()) {
|
||||
Alert.alert('Fehler', 'Bitte gib einen Namen ein');
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
Alert.alert('Fehler', 'Bitte füge mindestens einen Mood hinzu');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!id) return;
|
||||
|
||||
handleHaptic();
|
||||
updateSequence(id, {
|
||||
name: name.trim(),
|
||||
items,
|
||||
transitionDuration,
|
||||
});
|
||||
|
||||
Alert.alert('Erfolg', 'Sequenz wurde aktualisiert!', [
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const getMoodById = (moodId: string) => moods.find((m) => m.id === moodId);
|
||||
|
||||
if (!sequence) {
|
||||
return (
|
||||
<View className={`flex-1 ${theme.bg} items-center justify-center`}>
|
||||
<Text className={`${theme.text} text-xl`}>Sequenz nicht gefunden</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`flex-1 ${theme.bg}`}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Sequenz bearbeiten',
|
||||
headerBackTitle: 'Zurück',
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScrollView className="flex-1" contentContainerClassName="items-center pb-8 pt-4">
|
||||
<View style={{ width: '100%', maxWidth: responsive.maxContentWidth }} className="px-6">
|
||||
{/* Name Input */}
|
||||
<View className={`${theme.cardBg} mx-2 mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-2 text-lg font-semibold ${theme.text}`}>Name</Text>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="z.B. Morgen Routine"
|
||||
className={`p-3 text-base ${theme.input} rounded-lg ${theme.text}`}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
maxLength={30}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Transition Duration */}
|
||||
<View className={`${theme.cardBg} mx-2 mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${theme.text}`}>Übergangsdauer</Text>
|
||||
<View className="flex-row gap-2">
|
||||
{TRANSITION_OPTIONS.map((option) => (
|
||||
<Pressable
|
||||
key={option.value}
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
setTransitionDuration(option.value);
|
||||
}}
|
||||
className={`rounded-full px-4 py-2 ${
|
||||
transitionDuration === option.value ? 'bg-blue-500' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-medium ${
|
||||
transitionDuration === option.value ? 'text-white' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Moods in Sequenz */}
|
||||
<View className={`${theme.cardBg} mx-2 mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${theme.text}`}>
|
||||
Moods ({items.length})
|
||||
</Text>
|
||||
{items.map((item, index) => {
|
||||
const mood = getMoodById(item.moodId);
|
||||
if (!mood) return null;
|
||||
|
||||
return (
|
||||
<View key={index} className="mb-2 rounded-xl bg-gray-800 p-4">
|
||||
{/* Mood Name & Delete */}
|
||||
<View className="mb-3 flex-row items-center justify-between">
|
||||
<Text className="flex-1 text-base font-medium text-white">
|
||||
{index + 1}. {mood.name}
|
||||
</Text>
|
||||
<Pressable onPress={() => removeMoodFromSequence(index)} className="ml-2">
|
||||
<Icon name="close" size={18} color="#EF4444" weight="bold" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Duration Slider */}
|
||||
<View>
|
||||
<Text className="mb-2 text-sm text-white">
|
||||
Dauer: {formatDuration(item.duration)}
|
||||
</Text>
|
||||
<Slider
|
||||
minimumValue={1}
|
||||
maximumValue={3600}
|
||||
step={1}
|
||||
value={item.duration}
|
||||
onValueChange={(value) => updateMoodDuration(index, Math.round(value))}
|
||||
minimumTrackTintColor="#3B82F6"
|
||||
maximumTrackTintColor="#4B5563"
|
||||
thumbTintColor="#3B82F6"
|
||||
/>
|
||||
<View className="mt-1 flex-row justify-between">
|
||||
<Text className="text-xs text-gray-500">1 Sek</Text>
|
||||
<Text className="text-xs text-gray-500">60 Min</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add Mood Button */}
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
setShowMoodPicker(true);
|
||||
}}
|
||||
className="mt-2 items-center rounded-xl bg-gray-700 p-3"
|
||||
>
|
||||
<Text className="font-medium text-white">+ Mood hinzufügen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Save Button */}
|
||||
<Pressable onPress={handleSave} className="mx-2 items-center rounded-2xl bg-blue-500 p-4">
|
||||
<Text className="text-lg font-semibold text-white">Änderungen speichern</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Mood Picker Modal */}
|
||||
<Modal
|
||||
visible={showMoodPicker}
|
||||
animationType="slide"
|
||||
transparent
|
||||
onRequestClose={() => setShowMoodPicker(false)}
|
||||
>
|
||||
<View className="flex-1 justify-end bg-black/50">
|
||||
<View className="max-h-[70%] rounded-t-3xl bg-gray-900 p-4">
|
||||
<View className="mb-4 flex-row items-center justify-between">
|
||||
<Text className="text-xl font-bold text-white">Mood auswählen</Text>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
setShowMoodPicker(false);
|
||||
}}
|
||||
>
|
||||
<Icon name="close" size={24} color="#fff" weight="bold" />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView>
|
||||
{moods.map((mood) => (
|
||||
<Pressable
|
||||
key={mood.id}
|
||||
onPress={() => addMoodToSequence(mood.id)}
|
||||
className="mb-2 rounded-xl bg-gray-800 p-4"
|
||||
>
|
||||
<Text className="text-lg font-medium text-white">{mood.name}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
196
apps/moodlit/apps/mobile/app/index.tsx
Normal file
196
apps/moodlit/apps/mobile/app/index.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { Stack, useRouter } from 'expo-router';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Text, Pressable, View, StyleSheet } from 'react-native';
|
||||
import DraggableFlatList, { ScaleDecorator } from 'react-native-draggable-flatlist';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { MoodCard } from '@/components/MoodCard';
|
||||
import { SequenceCard } from '@/components/SequenceCard';
|
||||
import { useStore } from '@/store/store';
|
||||
import { getThemeColors } from '@/utils/theme';
|
||||
import { useResponsive } from '@/hooks/useResponsive';
|
||||
import type { Mood } from '@/store/store';
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const moods = useStore((state) => state.moods);
|
||||
const sequences = useStore((state) => state.sequences);
|
||||
const settings = useStore((state) => state.settings);
|
||||
const reorderMoods = useStore((state) => state.reorderMoods);
|
||||
const updateSettings = useStore((state) => state.updateSettings);
|
||||
|
||||
const theme = getThemeColors();
|
||||
const responsive = useResponsive();
|
||||
|
||||
const handleHaptic = () => {
|
||||
if (settings.hapticFeedback) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleScreen = () => {
|
||||
handleHaptic();
|
||||
updateSettings({ screenEnabled: !settings.screenEnabled });
|
||||
};
|
||||
|
||||
const toggleFlashlight = () => {
|
||||
handleHaptic();
|
||||
updateSettings({ flashlightEnabled: !settings.flashlightEnabled });
|
||||
};
|
||||
|
||||
const handleMoodPress = (id: string) => {
|
||||
handleHaptic();
|
||||
router.push(`/mood/${id}`);
|
||||
};
|
||||
|
||||
const handleSequencePress = (id: string) => {
|
||||
handleHaptic();
|
||||
router.push(`/sequence/${id}`);
|
||||
};
|
||||
|
||||
const renderHeader = () => (
|
||||
<>
|
||||
<View className="flex-row items-center justify-between px-6 pb-4">
|
||||
<Text className={`text-3xl font-bold ${theme.text}`}>Moods</Text>
|
||||
<View className="flex-row gap-3">
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
router.push('/create-mood');
|
||||
}}
|
||||
className="p-2"
|
||||
>
|
||||
<Icon name="plus-circle" size={28} color="#fff" weight="regular" />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
router.push('/sequences');
|
||||
}}
|
||||
className="p-2"
|
||||
>
|
||||
<Icon name="square-stack" size={28} color="#fff" weight="regular" />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
router.push('/settings');
|
||||
}}
|
||||
className="p-2"
|
||||
>
|
||||
<Icon name="settings" size={28} color="#fff" weight="regular" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Sequenzen Liste */}
|
||||
{sequences.length > 0 && (
|
||||
<>
|
||||
{sequences.map((sequence) => (
|
||||
<SequenceCard
|
||||
key={sequence.id}
|
||||
sequence={sequence}
|
||||
moods={moods}
|
||||
onPress={() => handleSequencePress(sequence.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView className={`flex flex-1 ${theme.bg}`}>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
|
||||
<View className="flex-1 items-center">
|
||||
<View style={{ width: '100%', maxWidth: responsive.maxContentWidth }}>
|
||||
<DraggableFlatList
|
||||
data={moods}
|
||||
onDragEnd={({ data }) => reorderMoods(data)}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={{ paddingBottom: 100, paddingTop: 64 }}
|
||||
ListHeaderComponent={renderHeader}
|
||||
renderItem={({ item, drag, isActive }) => (
|
||||
<ScaleDecorator>
|
||||
<MoodCard
|
||||
mood={item}
|
||||
onPress={() => handleMoodPress(item.id)}
|
||||
onLongPress={drag}
|
||||
isActive={isActive}
|
||||
/>
|
||||
</ScaleDecorator>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Gradient am unteren Rand */}
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(0,0,0,0.2)', 'rgba(0,0,0,0.5)', 'rgba(0,0,0,0.7)']}
|
||||
locations={[0, 0.4, 0.7, 1]}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 250,
|
||||
}}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
{/* Toggle Buttons am unteren Rand */}
|
||||
<View className="absolute bottom-0 left-0 right-0 items-center pb-8 pt-4">
|
||||
<View style={{ width: '100%', maxWidth: responsive.maxContentWidth }} className="px-6">
|
||||
<View className="flex-row gap-3">
|
||||
<Pressable
|
||||
onPress={toggleScreen}
|
||||
className={`flex-1 flex-row items-center justify-center gap-2 rounded-2xl py-4 ${
|
||||
settings.screenEnabled
|
||||
? 'border-2 border-gray-200 bg-white'
|
||||
: 'border-2 border-gray-600 bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
name="phone-portrait"
|
||||
size={20}
|
||||
color={settings.screenEnabled ? '#000' : '#fff'}
|
||||
weight={settings.screenEnabled ? 'fill' : 'regular'}
|
||||
/>
|
||||
<Text
|
||||
className={`font-semibold ${settings.screenEnabled ? 'text-gray-900' : 'text-white'}`}
|
||||
>
|
||||
Bildschirm
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={toggleFlashlight}
|
||||
className={`flex-1 flex-row items-center justify-center gap-2 rounded-2xl py-4 ${
|
||||
settings.flashlightEnabled
|
||||
? 'border-2 border-gray-200 bg-white'
|
||||
: 'border-2 border-gray-600 bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
name="flashlight"
|
||||
size={20}
|
||||
color={settings.flashlightEnabled ? '#000' : '#fff'}
|
||||
weight={settings.flashlightEnabled ? 'fill' : 'regular'}
|
||||
/>
|
||||
<Text
|
||||
className={`font-semibold ${settings.flashlightEnabled ? 'text-gray-900' : 'text-white'}`}
|
||||
>
|
||||
Taschenlampe
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({});
|
||||
121
apps/moodlit/apps/mobile/app/mood/[id].tsx
Normal file
121
apps/moodlit/apps/mobile/app/mood/[id].tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import * as Brightness from 'expo-brightness';
|
||||
import { useKeepAwake } from 'expo-keep-awake';
|
||||
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Pressable, View, Text } from 'react-native';
|
||||
|
||||
import { AnimatedMoodBackground } from '@/components/AnimatedMoodBackground';
|
||||
import { useStore } from '@/store/store';
|
||||
import { useFlashlight } from '@/hooks/useFlashlight';
|
||||
|
||||
export default function MoodDetail() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const moods = useStore((state) => state.moods);
|
||||
const settings = useStore((state) => state.settings);
|
||||
|
||||
const [remainingTime, setRemainingTime] = useState<number | null>(null);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const autoSwitchRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Verhindert, dass der Bildschirm ausgeht
|
||||
useKeepAwake();
|
||||
|
||||
const mood = moods.find((m) => m.id === id);
|
||||
|
||||
// Flashlight-Hook mit Helligkeitssteuerung
|
||||
useFlashlight({
|
||||
enabled: settings.flashlightEnabled && !!mood,
|
||||
animationType: mood?.animationType || 'gradient',
|
||||
animationSpeed: settings.animationSpeed,
|
||||
brightness: settings.flashlightBrightness,
|
||||
});
|
||||
|
||||
// Helligkeit setzen
|
||||
useEffect(() => {
|
||||
const setBrightness = async () => {
|
||||
try {
|
||||
const { status } = await Brightness.requestPermissionsAsync();
|
||||
if (status === 'granted') {
|
||||
await Brightness.setBrightnessAsync(settings.brightness);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Brightness error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
setBrightness();
|
||||
|
||||
// Helligkeit beim Verlassen zurücksetzen
|
||||
return () => {
|
||||
Brightness.setBrightnessAsync(0.5).catch(() => {});
|
||||
};
|
||||
}, [settings.brightness]);
|
||||
|
||||
// Auto-Timer
|
||||
useEffect(() => {
|
||||
if (settings.autoTimer > 0) {
|
||||
setRemainingTime(settings.autoTimer * 60); // In Sekunden
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
setRemainingTime((prev) => {
|
||||
if (prev === null || prev <= 1) {
|
||||
router.back();
|
||||
return null;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [settings.autoTimer]);
|
||||
|
||||
// Automatischer Mood-Wechsel
|
||||
useEffect(() => {
|
||||
if (settings.autoMoodSwitch && mood) {
|
||||
const interval = settings.autoMoodSwitchInterval * 60 * 1000; // In Millisekunden
|
||||
|
||||
autoSwitchRef.current = setTimeout(() => {
|
||||
const currentIndex = moods.findIndex((m) => m.id === mood.id);
|
||||
const nextIndex = (currentIndex + 1) % moods.length;
|
||||
const nextMood = moods[nextIndex];
|
||||
|
||||
router.replace(`/mood/${nextMood.id}`);
|
||||
}, interval);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (autoSwitchRef.current) {
|
||||
clearTimeout(autoSwitchRef.current);
|
||||
}
|
||||
};
|
||||
}, [settings.autoMoodSwitch, settings.autoMoodSwitchInterval, mood, moods]);
|
||||
|
||||
if (!mood) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-gray-900">
|
||||
<Text className="text-xl text-white">Mood nicht gefunden</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable className="flex-1" onPress={() => router.back()}>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<StatusBar hidden />
|
||||
|
||||
{/* Bildschirm-Animation (nur wenn aktiviert) */}
|
||||
{settings.screenEnabled ? (
|
||||
<AnimatedMoodBackground mood={mood} animationSpeed={settings.animationSpeed} />
|
||||
) : (
|
||||
<View className="absolute inset-0 bg-black" />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
129
apps/moodlit/apps/mobile/app/reorder-moods.tsx
Normal file
129
apps/moodlit/apps/mobile/app/reorder-moods.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { View, Text, Pressable, Alert } from 'react-native';
|
||||
import DraggableFlatList, {
|
||||
ScaleDecorator,
|
||||
RenderItemParams,
|
||||
} from 'react-native-draggable-flatlist';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { useResponsive } from '@/hooks/useResponsive';
|
||||
import type { Mood } from '@/store/store';
|
||||
import { useStore } from '@/store/store';
|
||||
import { getThemeColors } from '@/utils/theme';
|
||||
|
||||
export default function ReorderMoods() {
|
||||
const router = useRouter();
|
||||
const moods = useStore((state) => state.moods);
|
||||
const reorderMoods = useStore((state) => state.reorderMoods);
|
||||
const removeMood = useStore((state) => state.removeMood);
|
||||
const settings = useStore((state) => state.settings);
|
||||
|
||||
const theme = getThemeColors();
|
||||
const responsive = useResponsive();
|
||||
|
||||
const handleHaptic = () => {
|
||||
if (settings.hapticFeedback) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (mood: Mood) => {
|
||||
if (!mood.isCustom) {
|
||||
Alert.alert('Hinweis', 'Standard-Moods können nicht gelöscht werden');
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert('Mood löschen', `"${mood.name}" wirklich löschen?`, [
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
handleHaptic();
|
||||
removeMood(mood.id);
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const renderItem = ({ item, drag, isActive }: RenderItemParams<Mood>) => {
|
||||
return (
|
||||
<ScaleDecorator>
|
||||
<View style={{ width: '100%', maxWidth: responsive.maxContentWidth, alignSelf: 'center' }}>
|
||||
<Pressable
|
||||
onLongPress={() => {
|
||||
handleHaptic();
|
||||
drag();
|
||||
}}
|
||||
disabled={isActive}
|
||||
className={`mx-2 mb-4 ${isActive ? 'opacity-80' : ''}`}
|
||||
>
|
||||
<View className="h-32 flex-row overflow-hidden rounded-3xl">
|
||||
<LinearGradient
|
||||
colors={item.colors}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
className="flex-1 flex-row items-center justify-between px-6"
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="text-xl font-bold text-white">{item.name}</Text>
|
||||
{item.isCustom && (
|
||||
<Text className="mt-1 text-xs text-white/80">Benutzerdefiniert</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex-row gap-3">
|
||||
<View className="rounded-full bg-white/20 px-3 py-2">
|
||||
<Text className="text-sm text-white">☰</Text>
|
||||
</View>
|
||||
{item.isCustom && (
|
||||
<Pressable
|
||||
onPress={() => handleDelete(item)}
|
||||
className="rounded-full bg-red-500/80 px-3 py-2"
|
||||
>
|
||||
<Text className="text-sm text-white">🗑️</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScaleDecorator>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView className={`flex-1 ${theme.bg}`}>
|
||||
<SafeAreaView className="flex-1" edges={['top']}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Moods sortieren',
|
||||
presentation: 'modal',
|
||||
}}
|
||||
/>
|
||||
<View className="flex-1">
|
||||
<View className={`p-4 ${theme.cardBg} border-b ${theme.border}`}>
|
||||
<Text className={`${theme.textSecondary} text-center`}>
|
||||
Halte einen Mood gedrückt zum Verschieben
|
||||
</Text>
|
||||
</View>
|
||||
<DraggableFlatList
|
||||
data={moods}
|
||||
onDragEnd={({ data }) => {
|
||||
handleHaptic();
|
||||
reorderMoods(data);
|
||||
}}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={{ paddingTop: 16, paddingBottom: 32 }}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
233
apps/moodlit/apps/mobile/app/sequence/[id].tsx
Normal file
233
apps/moodlit/apps/mobile/app/sequence/[id].tsx
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import Slider from '@react-native-community/slider';
|
||||
import * as Brightness from 'expo-brightness';
|
||||
import { useKeepAwake } from 'expo-keep-awake';
|
||||
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Pressable, View, Text } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
Easing,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { AnimatedMoodBackground } from '@/components/AnimatedMoodBackground';
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { useFlashlight } from '@/hooks/useFlashlight';
|
||||
import { useStore } from '@/store/store';
|
||||
|
||||
export default function SequencePlayer() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const sequences = useStore((state) => state.sequences);
|
||||
const moods = useStore((state) => state.moods);
|
||||
const settings = useStore((state) => state.settings);
|
||||
const updateSettings = useStore((state) => state.updateSettings);
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [remainingTime, setRemainingTime] = useState<number>(0);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const [localBrightness, setLocalBrightness] = useState(settings.brightness);
|
||||
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const transitionOpacity = useSharedValue(1);
|
||||
|
||||
useKeepAwake();
|
||||
|
||||
const sequence = sequences.find((s) => s.id === id);
|
||||
const currentItem = sequence?.items[currentIndex];
|
||||
const currentMood = currentItem ? moods.find((m) => m.id === currentItem.moodId) : null;
|
||||
const nextItem = sequence?.items[currentIndex + 1];
|
||||
const nextMood = nextItem ? moods.find((m) => m.id === nextItem.moodId) : null;
|
||||
|
||||
// Flashlight Hook mit Helligkeitssteuerung
|
||||
useFlashlight({
|
||||
enabled: settings.flashlightEnabled && !!currentMood && !isTransitioning,
|
||||
animationType: currentMood?.animationType || 'gradient',
|
||||
animationSpeed: settings.animationSpeed,
|
||||
brightness: settings.flashlightBrightness,
|
||||
});
|
||||
|
||||
// Helligkeit setzen
|
||||
useEffect(() => {
|
||||
const setBrightness = async () => {
|
||||
try {
|
||||
const { status } = await Brightness.requestPermissionsAsync();
|
||||
if (status === 'granted') {
|
||||
await Brightness.setBrightnessAsync(settings.brightness);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Brightness error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
setBrightness();
|
||||
|
||||
return () => {
|
||||
Brightness.setBrightnessAsync(0.5).catch(() => {});
|
||||
};
|
||||
}, [settings.brightness]);
|
||||
|
||||
// Sequenz Timer
|
||||
useEffect(() => {
|
||||
if (!sequence || !currentItem) return;
|
||||
|
||||
setRemainingTime(currentItem.duration); // duration ist bereits in Sekunden
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
setRemainingTime((prev) => {
|
||||
if (prev <= 1) {
|
||||
// Zeit abgelaufen, zum nächsten Mood oder Ende
|
||||
if (currentIndex < sequence.items.length - 1) {
|
||||
startTransition();
|
||||
} else {
|
||||
// Sequenz beendet
|
||||
router.back();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [currentIndex, sequence]);
|
||||
|
||||
const startTransition = () => {
|
||||
if (!sequence) return;
|
||||
|
||||
setIsTransitioning(true);
|
||||
transitionOpacity.value = 1;
|
||||
|
||||
// Fade out
|
||||
transitionOpacity.value = withTiming(
|
||||
0,
|
||||
{
|
||||
duration: sequence.transitionDuration * 1000,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
},
|
||||
() => {
|
||||
// Nach dem Fade, wechsle zum nächsten Mood
|
||||
setCurrentIndex((prev) => prev + 1);
|
||||
setIsTransitioning(false);
|
||||
transitionOpacity.value = 1;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const animatedTransitionStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: transitionOpacity.value,
|
||||
};
|
||||
});
|
||||
|
||||
const handleBrightnessChange = async (value: number) => {
|
||||
setLocalBrightness(value);
|
||||
try {
|
||||
await Brightness.setBrightnessAsync(value);
|
||||
updateSettings({ brightness: value });
|
||||
} catch (error) {
|
||||
console.log('Brightness change error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
if (!sequence || !currentMood) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-gray-900">
|
||||
<Text className="text-xl text-white">Sequenz nicht gefunden</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<StatusBar hidden />
|
||||
|
||||
{/* Current Mood Background */}
|
||||
{settings.screenEnabled ? (
|
||||
<>
|
||||
{/* Next Mood (darunter, für Crossfade) */}
|
||||
{isTransitioning && nextMood && (
|
||||
<View className="absolute inset-0">
|
||||
<AnimatedMoodBackground mood={nextMood} animationSpeed={settings.animationSpeed} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Current Mood (darüber, wird ausgeblendet) */}
|
||||
<Animated.View
|
||||
className="absolute inset-0"
|
||||
style={isTransitioning ? animatedTransitionStyle : undefined}
|
||||
>
|
||||
<AnimatedMoodBackground mood={currentMood} animationSpeed={settings.animationSpeed} />
|
||||
</Animated.View>
|
||||
</>
|
||||
) : (
|
||||
<View className="absolute inset-0 bg-black" />
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<View className="absolute left-4 right-4 top-12 flex-row items-center justify-between">
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
className="flex-row items-center gap-3 rounded-full bg-black/30 px-4 py-2 opacity-60"
|
||||
>
|
||||
<Icon name="close" size={16} color="#FFFFFF" weight="bold" />
|
||||
<Text className="text-xl font-bold text-white">{sequence.name}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Center Progress */}
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<View className="items-center rounded-2xl bg-black/40 px-6 py-4 opacity-60">
|
||||
<Text className="mb-2 text-2xl font-bold text-white">{currentMood.name}</Text>
|
||||
<View className="mb-3 flex-row gap-1">
|
||||
{sequence.items.map((_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
index === currentIndex ? 'bg-white' : 'bg-white/30'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text className="mb-1 text-lg text-white">{formatTime(remainingTime)}</Text>
|
||||
<Text className="text-sm text-white/70">
|
||||
Mood {currentIndex + 1} von {sequence.items.length}
|
||||
</Text>
|
||||
{nextMood && (
|
||||
<Text className="mt-2 text-xs text-white/70">Nächster: {nextMood.name}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Brightness Slider */}
|
||||
<View className="absolute bottom-12 left-4 right-4 flex-row items-center gap-3 rounded-2xl bg-black/30 px-4 py-3 opacity-60">
|
||||
<Icon name="sun" size={24} color="#FFFFFF" />
|
||||
<View className="flex-1">
|
||||
<Slider
|
||||
minimumValue={0.1}
|
||||
maximumValue={1}
|
||||
step={0.05}
|
||||
value={localBrightness}
|
||||
onValueChange={handleBrightnessChange}
|
||||
minimumTrackTintColor="#FFFFFF"
|
||||
maximumTrackTintColor="rgba(255, 255, 255, 0.3)"
|
||||
thumbTintColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
127
apps/moodlit/apps/mobile/app/sequences.tsx
Normal file
127
apps/moodlit/apps/mobile/app/sequences.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import * as Haptics from 'expo-haptics';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { ScrollView, View, Text, Pressable, Alert } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { useResponsive } from '@/hooks/useResponsive';
|
||||
import { useStore } from '@/store/store';
|
||||
import { getThemeColors } from '@/utils/theme';
|
||||
|
||||
export default function Sequences() {
|
||||
const router = useRouter();
|
||||
const sequences = useStore((state) => state.sequences);
|
||||
const moods = useStore((state) => state.moods);
|
||||
const settings = useStore((state) => state.settings);
|
||||
const removeSequence = useStore((state) => state.removeSequence);
|
||||
|
||||
const theme = getThemeColors();
|
||||
const responsive = useResponsive();
|
||||
|
||||
const handleHaptic = () => {
|
||||
if (settings.hapticFeedback) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: string, name: string) => {
|
||||
Alert.alert('Sequenz löschen', `Möchtest du "${name}" wirklich löschen?`, [
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
handleHaptic();
|
||||
removeSequence(id);
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const getTotalDuration = (sequence: (typeof sequences)[0]) => {
|
||||
const totalSeconds = sequence.items.reduce((sum, item) => sum + item.duration, 0);
|
||||
const mins = Math.floor(totalSeconds / 60);
|
||||
const secs = totalSeconds % 60;
|
||||
if (mins === 0) {
|
||||
return `${secs} Sek`;
|
||||
} else if (secs === 0) {
|
||||
return `${mins} Min`;
|
||||
} else {
|
||||
return `${mins} Min ${secs} Sek`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className={`flex-1 ${theme.bg}`}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Sequenzen',
|
||||
headerBackTitle: 'Zurück',
|
||||
}}
|
||||
/>
|
||||
<ScrollView className="flex-1" contentContainerClassName="items-center pb-4 pt-4">
|
||||
<View style={{ width: '100%', maxWidth: responsive.maxContentWidth }} className="px-6">
|
||||
{/* Sequenzen Liste */}
|
||||
{sequences.length > 0 ? (
|
||||
sequences.map((sequence) => (
|
||||
<View key={sequence.id} className={`${theme.cardBg} mx-2 mb-4 rounded-2xl p-4`}>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
router.push(`/sequence/${sequence.id}`);
|
||||
}}
|
||||
>
|
||||
<Text className={`text-xl font-bold ${theme.text} mb-2`}>{sequence.name}</Text>
|
||||
<Text className={`${theme.textSecondary} text-sm`}>
|
||||
{sequence.items.length} Moods · {getTotalDuration(sequence)} · Übergang{' '}
|
||||
{sequence.transitionDuration}s
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View className="mt-3 flex-row gap-2">
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
router.push(`/edit-sequence/${sequence.id}`);
|
||||
}}
|
||||
className="flex-1 flex-row items-center justify-center gap-2 rounded-xl bg-blue-500 py-3"
|
||||
>
|
||||
<Icon name="pencil" size={16} color="#fff" weight="bold" />
|
||||
<Text className="text-sm font-semibold text-white">Bearbeiten</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={() => handleDelete(sequence.id, sequence.name)}
|
||||
className="flex-1 flex-row items-center justify-center gap-2 rounded-xl bg-red-500 py-3"
|
||||
>
|
||||
<Icon name="trash" size={16} color="#fff" weight="bold" />
|
||||
<Text className="text-sm font-semibold text-white">Löschen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<View className="items-center px-2 py-8">
|
||||
<Text className={`${theme.textSecondary} mb-4 text-center`}>
|
||||
Noch keine Sequenzen erstellt
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Neue Sequenz Button */}
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
router.push('/create-sequence');
|
||||
}}
|
||||
className="mx-2 mt-2 items-center rounded-2xl bg-blue-500 p-4"
|
||||
>
|
||||
<Text className="text-lg font-semibold text-white">+ Neue Sequenz</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
212
apps/moodlit/apps/mobile/app/settings.tsx
Normal file
212
apps/moodlit/apps/mobile/app/settings.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import Slider from '@react-native-community/slider';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { ScrollView, View, Text, Switch, Pressable, Linking } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { useStore } from '@/store/store';
|
||||
import { getThemeColors } from '@/utils/theme';
|
||||
import { useResponsive } from '@/hooks/useResponsive';
|
||||
|
||||
export default function Settings() {
|
||||
const router = useRouter();
|
||||
const settings = useStore((state) => state.settings);
|
||||
const updateSettings = useStore((state) => state.updateSettings);
|
||||
|
||||
const handleHaptic = () => {
|
||||
if (settings.hapticFeedback) {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSettingChange = (key: string, value: any) => {
|
||||
handleHaptic();
|
||||
updateSettings({ [key]: value });
|
||||
};
|
||||
|
||||
const timerOptions = [
|
||||
{ label: 'Aus', value: 0 },
|
||||
{ label: '5 Min', value: 5 },
|
||||
{ label: '10 Min', value: 10 },
|
||||
{ label: '15 Min', value: 15 },
|
||||
{ label: '30 Min', value: 30 },
|
||||
{ label: '60 Min', value: 60 },
|
||||
];
|
||||
|
||||
const theme = getThemeColors();
|
||||
const responsive = useResponsive();
|
||||
|
||||
return (
|
||||
<View className={`flex-1 ${theme.bg}`}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Einstellungen',
|
||||
headerBackTitle: 'Zurück',
|
||||
}}
|
||||
/>
|
||||
<ScrollView className="flex-1" contentContainerClassName="items-center">
|
||||
<View
|
||||
style={{ width: '100%', maxWidth: responsive.maxContentWidth }}
|
||||
className="px-6 pb-4 pt-4"
|
||||
>
|
||||
{/* Animationsgeschwindigkeit */}
|
||||
<View className={`${theme.cardBg} mx-2 mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-2 text-lg font-semibold ${theme.text}`}>
|
||||
Animationsgeschwindigkeit
|
||||
</Text>
|
||||
<Text className={`${theme.textSecondary} mb-3`}>
|
||||
{settings.animationSpeed === 0.5
|
||||
? 'Langsam'
|
||||
: settings.animationSpeed === 1
|
||||
? 'Normal'
|
||||
: 'Schnell'}
|
||||
</Text>
|
||||
<Slider
|
||||
minimumValue={0.5}
|
||||
maximumValue={2}
|
||||
step={0.5}
|
||||
value={settings.animationSpeed}
|
||||
onValueChange={(value) => handleSettingChange('animationSpeed', value)}
|
||||
minimumTrackTintColor="#6B8DD6"
|
||||
maximumTrackTintColor="#E5E7EB"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Haptisches Feedback */}
|
||||
<View
|
||||
className={`${theme.cardBg} mx-2 mb-4 flex-row items-center justify-between rounded-2xl p-4`}
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className={`text-lg font-semibold ${theme.text}`}>Haptisches Feedback</Text>
|
||||
<Text className={`${theme.textSecondary} text-sm`}>Vibration beim Tippen</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.hapticFeedback}
|
||||
onValueChange={(value) => handleSettingChange('hapticFeedback', value)}
|
||||
trackColor={{ false: '#E5E7EB', true: '#6B8DD6' }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Helligkeit */}
|
||||
<View className={`${theme.cardBg} mx-2 mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-2 text-lg font-semibold ${theme.text}`}>
|
||||
Bildschirm-Helligkeit
|
||||
</Text>
|
||||
<Text className={`${theme.textSecondary} mb-3`}>
|
||||
{Math.round(settings.brightness * 100)}%
|
||||
</Text>
|
||||
<Slider
|
||||
minimumValue={0.1}
|
||||
maximumValue={1}
|
||||
step={0.1}
|
||||
value={settings.brightness}
|
||||
onValueChange={(value) => handleSettingChange('brightness', value)}
|
||||
minimumTrackTintColor="#6B8DD6"
|
||||
maximumTrackTintColor="#E5E7EB"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Taschenlampen-Helligkeit */}
|
||||
<View className={`${theme.cardBg} mx-2 mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-2 text-lg font-semibold ${theme.text}`}>
|
||||
Taschenlampen-Helligkeit
|
||||
</Text>
|
||||
<Text className={`${theme.textSecondary} mb-3`}>
|
||||
Stufe {settings.flashlightBrightness} von 10
|
||||
</Text>
|
||||
<Slider
|
||||
minimumValue={1}
|
||||
maximumValue={10}
|
||||
step={1}
|
||||
value={settings.flashlightBrightness}
|
||||
onValueChange={(value) => handleSettingChange('flashlightBrightness', value)}
|
||||
minimumTrackTintColor="#6B8DD6"
|
||||
maximumTrackTintColor="#E5E7EB"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Auto-Timer */}
|
||||
<View className={`${theme.cardBg} mx-2 mb-4 rounded-2xl p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${theme.text}`}>Auto-Timer</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{timerOptions.map((option) => (
|
||||
<Pressable
|
||||
key={option.value}
|
||||
onPress={() => handleSettingChange('autoTimer', option.value)}
|
||||
className={`rounded-full px-4 py-2 ${
|
||||
settings.autoTimer === option.value ? 'bg-blue-500' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-medium ${
|
||||
settings.autoTimer === option.value ? 'text-white' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Auto Mood Switch */}
|
||||
<View className={`${theme.cardBg} mx-2 mb-4 rounded-2xl p-4`}>
|
||||
<View className="mb-3 flex-row items-center justify-between">
|
||||
<View className="flex-1">
|
||||
<Text className={`text-lg font-semibold ${theme.text}`}>
|
||||
Automatischer Mood-Wechsel
|
||||
</Text>
|
||||
<Text className={`${theme.textSecondary} text-sm`}>Wechselt zwischen Moods</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.autoMoodSwitch}
|
||||
onValueChange={(value) => handleSettingChange('autoMoodSwitch', value)}
|
||||
trackColor={{ false: '#E5E7EB', true: '#6B8DD6' }}
|
||||
/>
|
||||
</View>
|
||||
{settings.autoMoodSwitch && (
|
||||
<>
|
||||
<Text className={`${theme.textSecondary} mb-2`}>
|
||||
Intervall: {settings.autoMoodSwitchInterval} Min
|
||||
</Text>
|
||||
<Slider
|
||||
minimumValue={1}
|
||||
maximumValue={30}
|
||||
step={1}
|
||||
value={settings.autoMoodSwitchInterval}
|
||||
onValueChange={(value) => handleSettingChange('autoMoodSwitchInterval', value)}
|
||||
minimumTrackTintColor="#6B8DD6"
|
||||
maximumTrackTintColor="#E5E7EB"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Credits */}
|
||||
<View className="mx-2 mb-4 mt-8 items-center">
|
||||
<View className={`${theme.cardBg} w-full items-center rounded-2xl p-5 shadow-sm`}>
|
||||
<Icon name="heart-fill" size={18} color="#EF4444" weight="fill" />
|
||||
<View className="mt-2 flex-row items-center">
|
||||
<Text className={`${theme.text} text-center text-sm font-medium`}>
|
||||
Made by Till Schneider for the{' '}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
handleHaptic();
|
||||
Linking.openURL('https://manacore.ai');
|
||||
}}
|
||||
>
|
||||
<Text className="text-sm font-semibold text-blue-500 underline">Manacore</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text className={`${theme.textSecondary} mt-1 text-xs`}>Free Forever</Text>
|
||||
<Text className={`${theme.textSecondary} mt-1 text-xs`}>Version 1.0</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
BIN
apps/moodlit/apps/mobile/assets/adaptive-icon.png
Normal file
BIN
apps/moodlit/apps/mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/moodlit/apps/mobile/assets/favicon.png
Normal file
BIN
apps/moodlit/apps/mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/moodlit/apps/mobile/assets/icon.png
Normal file
BIN
apps/moodlit/apps/mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/moodlit/apps/mobile/assets/mood-light-logo.png
Normal file
BIN
apps/moodlit/apps/mobile/assets/mood-light-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 430 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 430 KiB |
31
apps/moodlit/apps/mobile/assets/mood-light.icon/icon.json
Normal file
31
apps/moodlit/apps/mobile/assets/mood-light.icon/icon.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"fill": "system-dark",
|
||||
"groups": [
|
||||
{
|
||||
"layers": [
|
||||
{
|
||||
"glass": true,
|
||||
"hidden": false,
|
||||
"image-name": "moods-logo.png",
|
||||
"name": "moods-logo",
|
||||
"position": {
|
||||
"scale": 0.92,
|
||||
"translation-in-points": [0, 0]
|
||||
}
|
||||
}
|
||||
],
|
||||
"shadow": {
|
||||
"kind": "neutral",
|
||||
"opacity": 0.5
|
||||
},
|
||||
"translucency": {
|
||||
"enabled": true,
|
||||
"value": 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms": {
|
||||
"circles": ["watchOS"],
|
||||
"squares": "shared"
|
||||
}
|
||||
}
|
||||
BIN
apps/moodlit/apps/mobile/assets/splash.png
Normal file
BIN
apps/moodlit/apps/mobile/assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
12
apps/moodlit/apps/mobile/babel.config.js
Normal file
12
apps/moodlit/apps/mobile/babel.config.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
let plugins = [];
|
||||
|
||||
plugins.push('react-native-worklets/plugin');
|
||||
|
||||
return {
|
||||
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
|
||||
|
||||
plugins,
|
||||
};
|
||||
};
|
||||
46
apps/moodlit/apps/mobile/cesconfig.jsonc
Normal file
46
apps/moodlit/apps/mobile/cesconfig.jsonc
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// This is an optional configuration file used primarily for debugging purposes when reporting issues.
|
||||
// It is safe to delete this file as it does not affect the functionality of your application.
|
||||
{
|
||||
"cesVersion": "2.20.1",
|
||||
"projectName": "moods",
|
||||
"packages": [
|
||||
{
|
||||
"name": "expo-router",
|
||||
"type": "navigation",
|
||||
"options": {
|
||||
"type": "stack"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nativewind",
|
||||
"type": "styling"
|
||||
},
|
||||
{
|
||||
"name": "zustand",
|
||||
"type": "state-management"
|
||||
},
|
||||
{
|
||||
"name": "supabase",
|
||||
"type": "authentication"
|
||||
}
|
||||
],
|
||||
"flags": {
|
||||
"noGit": false,
|
||||
"noInstall": false,
|
||||
"overwrite": false,
|
||||
"importAlias": true,
|
||||
"packageManager": "npm",
|
||||
"eas": true,
|
||||
"publish": false
|
||||
},
|
||||
"packageManager": {
|
||||
"type": "npm",
|
||||
"version": "10.8.2"
|
||||
},
|
||||
"os": {
|
||||
"type": "Darwin",
|
||||
"platform": "darwin",
|
||||
"arch": "arm64",
|
||||
"kernelVersion": "25.1.0"
|
||||
}
|
||||
}
|
||||
532
apps/moodlit/apps/mobile/components/AnimatedMoodBackground.tsx
Normal file
532
apps/moodlit/apps/mobile/components/AnimatedMoodBackground.tsx
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
withSequence,
|
||||
Easing,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import type { Mood } from '@/store/store';
|
||||
|
||||
const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
|
||||
|
||||
interface AnimatedMoodBackgroundProps {
|
||||
mood: Mood;
|
||||
animationSpeed?: number; // 0.5 = langsam, 1 = normal, 2 = schnell
|
||||
}
|
||||
|
||||
export const AnimatedMoodBackground = ({
|
||||
mood,
|
||||
animationSpeed = 1,
|
||||
}: AnimatedMoodBackgroundProps) => {
|
||||
const opacity = useSharedValue(1);
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
// Für sunrise/sunset brauchen wir einen dedizierten Wert
|
||||
const needsGradientSequence = mood.animationType === 'sunrise' || mood.animationType === 'sunset';
|
||||
const colorIndex = useSharedValue(needsGradientSequence ? 0 : 0);
|
||||
|
||||
useEffect(() => {
|
||||
// Basis-Dauer angepasst an Geschwindigkeit
|
||||
const baseDuration = 2000 / animationSpeed;
|
||||
|
||||
if (mood.animationType === 'pulse') {
|
||||
// Pulsieren: Opacity und Scale ändern
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0.6, { duration: baseDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1, { duration: baseDuration, easing: Easing.inOut(Easing.ease) })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
|
||||
scale.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1.1, { duration: baseDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1, { duration: baseDuration, easing: Easing.inOut(Easing.ease) })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
} else if (mood.animationType === 'wave') {
|
||||
// Wellen: Nur Opacity
|
||||
const waveDuration = 3000 / animationSpeed;
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0.7, { duration: waveDuration, easing: Easing.sin }),
|
||||
withTiming(1, { duration: waveDuration, easing: Easing.sin })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
} else if (mood.animationType === 'flash') {
|
||||
// Flash: Schnelles Aufblitzen von schwarz zu weiß
|
||||
const flashDuration = 100 / animationSpeed; // Sehr kurz für Blitz-Effekt
|
||||
const pauseDuration = 500 / animationSpeed; // Pause zwischen Blitzen
|
||||
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0, { duration: 0 }), // Schwarz
|
||||
withTiming(0, { duration: pauseDuration }), // Pause
|
||||
withTiming(1, { duration: flashDuration, easing: Easing.linear }), // Blitz
|
||||
withTiming(0, { duration: flashDuration, easing: Easing.linear }), // Zurück zu Schwarz
|
||||
withTiming(0, { duration: pauseDuration }) // Pause
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else if (mood.animationType === 'sos') {
|
||||
// SOS: Morse-Code Pattern (· · · — — — · · ·)
|
||||
const shortFlash = 200 / animationSpeed; // Kurzes Signal
|
||||
const longFlash = 600 / animationSpeed; // Langes Signal
|
||||
const shortPause = 200 / animationSpeed; // Pause zwischen Signalen
|
||||
const letterPause = 600 / animationSpeed; // Pause zwischen Buchstaben
|
||||
const wordPause = 2000 / animationSpeed; // Pause nach SOS
|
||||
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
// S (drei kurze)
|
||||
withTiming(1, { duration: shortFlash, easing: Easing.linear }),
|
||||
withTiming(0, { duration: shortPause, easing: Easing.linear }),
|
||||
withTiming(1, { duration: shortFlash, easing: Easing.linear }),
|
||||
withTiming(0, { duration: shortPause, easing: Easing.linear }),
|
||||
withTiming(1, { duration: shortFlash, easing: Easing.linear }),
|
||||
withTiming(0, { duration: letterPause, easing: Easing.linear }),
|
||||
|
||||
// O (drei lange)
|
||||
withTiming(1, { duration: longFlash, easing: Easing.linear }),
|
||||
withTiming(0, { duration: shortPause, easing: Easing.linear }),
|
||||
withTiming(1, { duration: longFlash, easing: Easing.linear }),
|
||||
withTiming(0, { duration: shortPause, easing: Easing.linear }),
|
||||
withTiming(1, { duration: longFlash, easing: Easing.linear }),
|
||||
withTiming(0, { duration: letterPause, easing: Easing.linear }),
|
||||
|
||||
// S (drei kurze)
|
||||
withTiming(1, { duration: shortFlash, easing: Easing.linear }),
|
||||
withTiming(0, { duration: shortPause, easing: Easing.linear }),
|
||||
withTiming(1, { duration: shortFlash, easing: Easing.linear }),
|
||||
withTiming(0, { duration: shortPause, easing: Easing.linear }),
|
||||
withTiming(1, { duration: shortFlash, easing: Easing.linear }),
|
||||
withTiming(0, { duration: wordPause, easing: Easing.linear }) // Lange Pause
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else if (mood.animationType === 'candle') {
|
||||
// Kerze: Sanftes, langsames Flackern wie echte Kerzenflamme
|
||||
const flickerDuration = 400 / animationSpeed;
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0.92, { duration: flickerDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1, { duration: flickerDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(0.88, { duration: flickerDuration * 0.8, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(0.95, { duration: flickerDuration * 1.2, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1, { duration: flickerDuration, easing: Easing.inOut(Easing.ease) })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
scale.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0.99, { duration: flickerDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1, { duration: flickerDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(0.985, { duration: flickerDuration * 0.8, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(0.995, { duration: flickerDuration * 1.2, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1, { duration: flickerDuration, easing: Easing.inOut(Easing.ease) })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else if (mood.animationType === 'police') {
|
||||
// Polizei: Abwechselnd Blau und Rot
|
||||
const blinkDuration = 300 / animationSpeed;
|
||||
colorIndex.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0, { duration: 0 }),
|
||||
withTiming(0, { duration: blinkDuration }),
|
||||
withTiming(1, { duration: 0 }),
|
||||
withTiming(1, { duration: blinkDuration })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else if (mood.animationType === 'warning') {
|
||||
// Warnsignal: Blinkendes Orange/Gelb
|
||||
const warnDuration = 500 / animationSpeed;
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, { duration: warnDuration, easing: Easing.linear }),
|
||||
withTiming(0.3, { duration: warnDuration, easing: Easing.linear })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else if (mood.animationType === 'disco') {
|
||||
// Disco: Schnell wechselnde Farben
|
||||
const discoSpeed = 400 / animationSpeed;
|
||||
colorIndex.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0, { duration: 0 }),
|
||||
withTiming(0, { duration: discoSpeed }),
|
||||
withTiming(1, { duration: 0 }),
|
||||
withTiming(1, { duration: discoSpeed }),
|
||||
withTiming(2, { duration: 0 }),
|
||||
withTiming(2, { duration: discoSpeed }),
|
||||
withTiming(3, { duration: 0 }),
|
||||
withTiming(3, { duration: discoSpeed }),
|
||||
withTiming(4, { duration: 0 }),
|
||||
withTiming(4, { duration: discoSpeed }),
|
||||
withTiming(5, { duration: 0 }),
|
||||
withTiming(5, { duration: discoSpeed })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else if (mood.animationType === 'thunder') {
|
||||
// Gewitter: Zufällige Blitze
|
||||
const thunderPattern = () => {
|
||||
return withSequence(
|
||||
withTiming(1, { duration: 0 }), // Grau/Normal
|
||||
withTiming(1, { duration: 2000 / animationSpeed }), // Pause
|
||||
withTiming(3, { duration: 50 / animationSpeed }), // Kurzer Blitz
|
||||
withTiming(1, { duration: 100 / animationSpeed }),
|
||||
withTiming(3, { duration: 80 / animationSpeed }), // Zweiter Blitz
|
||||
withTiming(1, { duration: 3000 / animationSpeed }) // Längere Pause
|
||||
);
|
||||
};
|
||||
opacity.value = withRepeat(thunderPattern(), -1, false);
|
||||
} else if (mood.animationType === 'breath') {
|
||||
// Atem: 4 Sekunden einatmen, 4 Sekunden ausatmen
|
||||
const breathInDuration = 4000 / animationSpeed;
|
||||
const breathOutDuration = 4000 / animationSpeed;
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0.3, { duration: breathOutDuration, easing: Easing.inOut(Easing.ease) }), // Ausatmen
|
||||
withTiming(1, { duration: breathInDuration, easing: Easing.inOut(Easing.ease) }) // Einatmen
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
scale.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0.95, { duration: breathOutDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1, { duration: breathInDuration, easing: Easing.inOut(Easing.ease) })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
} else if (mood.animationType === 'rave') {
|
||||
// Rave: Sehr schnelle, chaotische Farbwechsel
|
||||
const raveSpeed = 150 / animationSpeed;
|
||||
colorIndex.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0, { duration: 0 }),
|
||||
withTiming(0, { duration: raveSpeed }),
|
||||
withTiming(1, { duration: 0 }),
|
||||
withTiming(1, { duration: raveSpeed }),
|
||||
withTiming(2, { duration: 0 }),
|
||||
withTiming(2, { duration: raveSpeed }),
|
||||
withTiming(3, { duration: 0 }),
|
||||
withTiming(3, { duration: raveSpeed }),
|
||||
withTiming(4, { duration: 0 }),
|
||||
withTiming(4, { duration: raveSpeed }),
|
||||
withTiming(5, { duration: 0 }),
|
||||
withTiming(5, { duration: raveSpeed }),
|
||||
withTiming(6, { duration: 0 }),
|
||||
withTiming(6, { duration: raveSpeed }),
|
||||
withTiming(7, { duration: 0 }),
|
||||
withTiming(7, { duration: raveSpeed })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else if (mood.animationType === 'scanner') {
|
||||
// Scanner: Lichtwelle die hin und her wandert
|
||||
const scanDuration = 2000 / animationSpeed;
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0, { duration: 0 }),
|
||||
withTiming(1, { duration: scanDuration / 4, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1, { duration: scanDuration / 4 }),
|
||||
withTiming(0, { duration: scanDuration / 4, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(0, { duration: scanDuration / 4 })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else if (mood.animationType === 'matrix') {
|
||||
// Matrix: Grün blinkend wie digitaler Code
|
||||
const matrixSpeed = 100 / animationSpeed;
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, { duration: matrixSpeed }),
|
||||
withTiming(0.7, { duration: matrixSpeed }),
|
||||
withTiming(1, { duration: matrixSpeed }),
|
||||
withTiming(0.85, { duration: matrixSpeed }),
|
||||
withTiming(1, { duration: matrixSpeed }),
|
||||
withTiming(0.6, { duration: matrixSpeed }),
|
||||
withTiming(1, { duration: matrixSpeed * 2 })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else if (mood.animationType === 'sunrise') {
|
||||
// Sonnenaufgang: Sehr sanfter, langsamer Durchlauf durch verschiedene Gradient-Phasen
|
||||
const phaseDuration = 20000 / animationSpeed; // 20 Sekunden pro Phase
|
||||
const transitionDuration = 8000 / animationSpeed; // 8 Sekunden Übergang
|
||||
|
||||
colorIndex.value = 0; // Start bei 0
|
||||
colorIndex.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(0.5, { duration: phaseDuration - transitionDuration * 2 }),
|
||||
withTiming(1.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1.5, { duration: phaseDuration - transitionDuration * 2 }),
|
||||
withTiming(2.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(2.5, { duration: phaseDuration - transitionDuration * 2 }),
|
||||
withTiming(3.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(3.5, { duration: phaseDuration - transitionDuration * 2 }),
|
||||
withTiming(4.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(4.5, { duration: phaseDuration - transitionDuration * 2 }),
|
||||
withTiming(5.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(5.5, { duration: phaseDuration - transitionDuration * 2 })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else if (mood.animationType === 'sunset') {
|
||||
// Sonnenuntergang: Sehr sanfter, langsamer Durchlauf durch verschiedene Gradient-Phasen
|
||||
const phaseDuration = 20000 / animationSpeed; // 20 Sekunden pro Phase
|
||||
const transitionDuration = 8000 / animationSpeed; // 8 Sekunden Übergang
|
||||
|
||||
colorIndex.value = 0;
|
||||
colorIndex.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(0.5, { duration: phaseDuration - transitionDuration * 2 }),
|
||||
withTiming(1.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1.5, { duration: phaseDuration - transitionDuration * 2 }),
|
||||
withTiming(2.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(2.5, { duration: phaseDuration - transitionDuration * 2 }),
|
||||
withTiming(3.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(3.5, { duration: phaseDuration - transitionDuration * 2 }),
|
||||
withTiming(4.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(4.5, { duration: phaseDuration - transitionDuration * 2 }),
|
||||
withTiming(5.5, { duration: transitionDuration, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(5.5, { duration: phaseDuration - transitionDuration * 2 })
|
||||
),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
}
|
||||
}, [mood.animationType, animationSpeed]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: opacity.value,
|
||||
transform: [{ scale: scale.value }],
|
||||
};
|
||||
});
|
||||
|
||||
// Farb-Logik für verschiedene Animationen
|
||||
const animatedColors = useAnimatedStyle(() => {
|
||||
// Für Polizei: Wechsel zwischen Blau und Rot
|
||||
if (mood.animationType === 'police') {
|
||||
const idx = Math.floor(colorIndex.value);
|
||||
const color = idx === 0 ? '#0000FF' : '#FF0000';
|
||||
return { backgroundColor: color };
|
||||
}
|
||||
|
||||
// Für Disco: Durchlaufe alle Farben
|
||||
if (mood.animationType === 'disco') {
|
||||
const idx = Math.floor(colorIndex.value);
|
||||
const colors = mood.colors;
|
||||
const color = colors[idx % colors.length] || colors[0];
|
||||
return { backgroundColor: color };
|
||||
}
|
||||
|
||||
// Für Rave: Durchlaufe alle Farben (schneller als Disco)
|
||||
if (mood.animationType === 'rave') {
|
||||
const idx = Math.floor(colorIndex.value);
|
||||
const colors = mood.colors;
|
||||
const color = colors[idx % colors.length] || colors[0];
|
||||
return { backgroundColor: color };
|
||||
}
|
||||
|
||||
// Für Gewitter: Normal grau, aber bei opacity > 2 wird es weiß (Blitz)
|
||||
if (mood.animationType === 'thunder') {
|
||||
const isFlash = opacity.value > 2;
|
||||
return { backgroundColor: isFlash ? '#FFFFFF' : '#34495E' };
|
||||
}
|
||||
|
||||
return {};
|
||||
});
|
||||
|
||||
// Für Sonnenaufgang/Sonnenuntergang: Sanfte Übergänge zwischen Phasen
|
||||
const phase0Opacity = useAnimatedStyle(() => {
|
||||
const idx = colorIndex.value;
|
||||
// Fade out when approaching phase 1
|
||||
if (idx < 0.5) return { opacity: 1 };
|
||||
if (idx < 1.5) return { opacity: Math.max(0, 1.5 - idx) };
|
||||
return { opacity: 0 };
|
||||
});
|
||||
|
||||
const phase1Opacity = useAnimatedStyle(() => {
|
||||
const idx = colorIndex.value;
|
||||
if (idx < 0.5) return { opacity: 0 };
|
||||
if (idx < 1.5) return { opacity: Math.min(1, idx - 0.5) };
|
||||
if (idx < 2.5) return { opacity: Math.max(0, 2.5 - idx) };
|
||||
return { opacity: 0 };
|
||||
});
|
||||
|
||||
const phase2Opacity = useAnimatedStyle(() => {
|
||||
const idx = colorIndex.value;
|
||||
if (idx < 1.5) return { opacity: 0 };
|
||||
if (idx < 2.5) return { opacity: Math.min(1, idx - 1.5) };
|
||||
if (idx < 3.5) return { opacity: Math.max(0, 3.5 - idx) };
|
||||
return { opacity: 0 };
|
||||
});
|
||||
|
||||
const phase3Opacity = useAnimatedStyle(() => {
|
||||
const idx = colorIndex.value;
|
||||
if (idx < 2.5) return { opacity: 0 };
|
||||
if (idx < 3.5) return { opacity: Math.min(1, idx - 2.5) };
|
||||
if (idx < 4.5) return { opacity: Math.max(0, 4.5 - idx) };
|
||||
return { opacity: 0 };
|
||||
});
|
||||
|
||||
const phase4Opacity = useAnimatedStyle(() => {
|
||||
const idx = colorIndex.value;
|
||||
if (idx < 3.5) return { opacity: 0 };
|
||||
if (idx < 4.5) return { opacity: Math.min(1, idx - 3.5) };
|
||||
if (idx < 5.5) return { opacity: Math.max(0, 5.5 - idx) };
|
||||
return { opacity: 0 };
|
||||
});
|
||||
|
||||
const phase5Opacity = useAnimatedStyle(() => {
|
||||
const idx = colorIndex.value;
|
||||
if (idx < 4.5) return { opacity: 0 };
|
||||
if (idx < 5.5) return { opacity: Math.min(1, idx - 4.5) };
|
||||
return { opacity: 1 }; // Stay visible at the end before looping
|
||||
});
|
||||
|
||||
const getColors = () => {
|
||||
if (mood.animationType === 'sos') {
|
||||
return ['#FF0000', '#FF0000']; // Einheitliches Rot
|
||||
}
|
||||
if (mood.animationType === 'flash') {
|
||||
return ['#FFFFFF', '#FFFFFF']; // Einheitliches Weiß
|
||||
}
|
||||
if (mood.animationType === 'scanner') {
|
||||
return ['#FF0000', '#FF0000']; // Einheitliches Rot für Scanner
|
||||
}
|
||||
if (mood.animationType === 'matrix') {
|
||||
return ['#00FF00', '#00FF00']; // Einheitliches Grün für Matrix
|
||||
}
|
||||
if (
|
||||
mood.animationType === 'police' ||
|
||||
mood.animationType === 'disco' ||
|
||||
mood.animationType === 'rave' ||
|
||||
mood.animationType === 'thunder' ||
|
||||
mood.animationType === 'sunrise' ||
|
||||
mood.animationType === 'sunset'
|
||||
) {
|
||||
// Für diese Modi verwenden wir animatedColors statt Gradient
|
||||
return ['transparent', 'transparent'];
|
||||
}
|
||||
return mood.colors;
|
||||
};
|
||||
|
||||
// Für SOS, Flash, Scanner und Matrix brauchen wir einen schwarzen Hintergrund
|
||||
const needsBlackBackground =
|
||||
mood.animationType === 'sos' ||
|
||||
mood.animationType === 'flash' ||
|
||||
mood.animationType === 'scanner' ||
|
||||
mood.animationType === 'matrix';
|
||||
const needsAnimatedBackground =
|
||||
mood.animationType === 'police' ||
|
||||
mood.animationType === 'disco' ||
|
||||
mood.animationType === 'rave' ||
|
||||
mood.animationType === 'thunder';
|
||||
|
||||
// Für Sonnenaufgang/Sonnenuntergang: Gradient-Paare extrahieren
|
||||
const getGradientPhases = () => {
|
||||
if (!needsGradientSequence) return [];
|
||||
const phases = [];
|
||||
const colors = mood.colors;
|
||||
for (let i = 0; i < colors.length; i += 2) {
|
||||
phases.push([colors[i], colors[i + 1] || colors[i]]);
|
||||
}
|
||||
return phases;
|
||||
};
|
||||
|
||||
const gradientPhases = getGradientPhases();
|
||||
|
||||
return (
|
||||
<>
|
||||
{needsBlackBackground && (
|
||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: '#000000' }]} />
|
||||
)}
|
||||
|
||||
{needsAnimatedBackground ? (
|
||||
<Animated.View style={[StyleSheet.absoluteFill, animatedStyle, animatedColors]} />
|
||||
) : needsGradientSequence ? (
|
||||
<>
|
||||
{/* Render all gradient phases, only one visible at a time */}
|
||||
{gradientPhases.length > 0 && (
|
||||
<>
|
||||
<AnimatedLinearGradient
|
||||
colors={gradientPhases[0]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[StyleSheet.absoluteFill, phase0Opacity]}
|
||||
/>
|
||||
<AnimatedLinearGradient
|
||||
colors={gradientPhases[1]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[StyleSheet.absoluteFill, phase1Opacity]}
|
||||
/>
|
||||
<AnimatedLinearGradient
|
||||
colors={gradientPhases[2]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[StyleSheet.absoluteFill, phase2Opacity]}
|
||||
/>
|
||||
<AnimatedLinearGradient
|
||||
colors={gradientPhases[3]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[StyleSheet.absoluteFill, phase3Opacity]}
|
||||
/>
|
||||
<AnimatedLinearGradient
|
||||
colors={gradientPhases[4]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[StyleSheet.absoluteFill, phase4Opacity]}
|
||||
/>
|
||||
<AnimatedLinearGradient
|
||||
colors={gradientPhases[5]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[StyleSheet.absoluteFill, phase5Opacity]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<AnimatedLinearGradient
|
||||
colors={getColors()}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[StyleSheet.absoluteFill, animatedStyle]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
25
apps/moodlit/apps/mobile/components/Button.tsx
Normal file
25
apps/moodlit/apps/mobile/components/Button.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { forwardRef } from 'react';
|
||||
import { Text, Pressable, PressableProps, View } from 'react-native';
|
||||
|
||||
type ButtonProps = {
|
||||
title: string;
|
||||
} & PressableProps;
|
||||
|
||||
export const Button = forwardRef<View, ButtonProps>(({ title, ...pressableProps }, ref) => {
|
||||
return (
|
||||
<Pressable
|
||||
ref={ref}
|
||||
{...pressableProps}
|
||||
className={`${styles.button} ${pressableProps.className}`}
|
||||
>
|
||||
<Text className={styles.buttonText}>{title}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
const styles = {
|
||||
button: 'items-center bg-indigo-500 rounded-[28px] shadow-md p-4',
|
||||
buttonText: 'text-white text-lg font-semibold text-center',
|
||||
};
|
||||
9
apps/moodlit/apps/mobile/components/Container.tsx
Normal file
9
apps/moodlit/apps/mobile/components/Container.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { SafeAreaView } from 'react-native';
|
||||
|
||||
export const Container = ({ children }: { children: React.ReactNode }) => {
|
||||
return <SafeAreaView className={styles.container}>{children}</SafeAreaView>;
|
||||
};
|
||||
|
||||
const styles = {
|
||||
container: 'flex flex-1 m-6',
|
||||
};
|
||||
29
apps/moodlit/apps/mobile/components/EditScreenInfo.tsx
Normal file
29
apps/moodlit/apps/mobile/components/EditScreenInfo.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Text, View } from 'react-native';
|
||||
|
||||
export const EditScreenInfo = ({ path }: { path: string }) => {
|
||||
const title = 'Open up the code for this screen:';
|
||||
const description =
|
||||
'Change any of the text, save the file, and your app will automatically update.';
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View className={styles.getStartedContainer}>
|
||||
<Text className={styles.getStartedText}>{title}</Text>
|
||||
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
|
||||
<Text className="text-white">{path}</Text>
|
||||
</View>
|
||||
<Text className={styles.getStartedText}>{description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
codeHighlightContainer: `rounded-md px-1 bg-gray-800`,
|
||||
getStartedContainer: `items-center mx-12`,
|
||||
getStartedText: `text-lg leading-6 text-center text-gray-400`,
|
||||
helpContainer: `items-center mx-5 mt-4`,
|
||||
helpLink: `py-4`,
|
||||
helpLinkText: `text-center`,
|
||||
homeScreenFilename: `my-2`,
|
||||
};
|
||||
58
apps/moodlit/apps/mobile/components/Icon.tsx
Normal file
58
apps/moodlit/apps/mobile/components/Icon.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
||||
import React from 'react';
|
||||
|
||||
type IconName =
|
||||
| 'settings'
|
||||
| 'settings-sliders'
|
||||
| 'close'
|
||||
| 'sun'
|
||||
| 'star'
|
||||
| 'star-fill'
|
||||
| 'arrow-clockwise'
|
||||
| 'chevron-left'
|
||||
| 'phone-portrait'
|
||||
| 'flashlight'
|
||||
| 'play-circle'
|
||||
| 'plus-circle'
|
||||
| 'square-stack'
|
||||
| 'list-bullet'
|
||||
| 'pencil'
|
||||
| 'trash';
|
||||
|
||||
interface IconProps {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
color?: string;
|
||||
weight?: SymbolWeight;
|
||||
}
|
||||
|
||||
const iconMap: Record<IconName, string> = {
|
||||
settings: 'gearshape',
|
||||
'settings-sliders': 'slider.horizontal.3',
|
||||
close: 'xmark',
|
||||
sun: 'sun.max.fill',
|
||||
star: 'star',
|
||||
'star-fill': 'star.fill',
|
||||
'arrow-clockwise': 'arrow.clockwise',
|
||||
'chevron-left': 'chevron.left',
|
||||
'phone-portrait': 'iphone',
|
||||
flashlight: 'flashlight.on.fill',
|
||||
'play-circle': 'play.circle',
|
||||
'plus-circle': 'plus.circle.fill',
|
||||
'square-stack': 'square.stack.fill',
|
||||
'list-bullet': 'list.bullet',
|
||||
pencil: 'pencil',
|
||||
trash: 'trash',
|
||||
};
|
||||
|
||||
export const Icon = ({ name, size = 24, color = '#FFFFFF', weight = 'regular' }: IconProps) => {
|
||||
return (
|
||||
<SymbolView
|
||||
name={iconMap[name]}
|
||||
size={size}
|
||||
type="hierarchical"
|
||||
tintColor={color}
|
||||
weight={weight}
|
||||
/>
|
||||
);
|
||||
};
|
||||
74
apps/moodlit/apps/mobile/components/MoodCard.tsx
Normal file
74
apps/moodlit/apps/mobile/components/MoodCard.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React from 'react';
|
||||
import { Text, Pressable, View, StyleSheet } from 'react-native';
|
||||
|
||||
import type { Mood } from '@/store/store';
|
||||
|
||||
interface MoodCardProps {
|
||||
mood: Mood;
|
||||
onPress: () => void;
|
||||
onFavoritePress?: () => void;
|
||||
onLongPress?: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export const MoodCard = ({
|
||||
mood,
|
||||
onPress,
|
||||
onFavoritePress,
|
||||
onLongPress,
|
||||
isActive,
|
||||
}: MoodCardProps) => {
|
||||
// Check if mood has light colors (for text color adjustment)
|
||||
const isLightMood = mood.name === 'Licht';
|
||||
const textColor = isLightMood ? 'text-gray-900' : 'text-white';
|
||||
const badgeBg = isLightMood ? 'bg-gray-900/20' : 'bg-white/20';
|
||||
const badgeText = isLightMood ? 'text-gray-900/90' : 'text-white/90';
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
className="mx-2 mb-6"
|
||||
disabled={isActive}
|
||||
>
|
||||
<View
|
||||
className="overflow-hidden rounded-3xl shadow-lg"
|
||||
style={[styles.card, isActive && styles.activeCard]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={mood.colors}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
>
|
||||
{/* Mood Info */}
|
||||
<View className="absolute bottom-4 left-5 right-5">
|
||||
<Text className={`${textColor} mb-1 text-3xl font-bold tracking-tight`}>
|
||||
{mood.name}
|
||||
</Text>
|
||||
{mood.isCustom && (
|
||||
<View className={`${badgeBg} mt-1 self-start rounded-full px-3 py-1`}>
|
||||
<Text className={`${badgeText} text-xs font-medium`}>Benutzerdefiniert</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
aspectRatio: 16 / 9,
|
||||
},
|
||||
gradient: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
activeCard: {
|
||||
opacity: 0.8,
|
||||
transform: [{ scale: 1.05 }],
|
||||
},
|
||||
});
|
||||
26
apps/moodlit/apps/mobile/components/ScreenContent.tsx
Normal file
26
apps/moodlit/apps/mobile/components/ScreenContent.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
import { EditScreenInfo } from './EditScreenInfo';
|
||||
|
||||
type ScreenContentProps = {
|
||||
title: string;
|
||||
path: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ScreenContent = ({ title, path, children }: ScreenContentProps) => {
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Text className={styles.title}>{title}</Text>
|
||||
<View className={styles.separator} />
|
||||
<EditScreenInfo path={path} />
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const styles = {
|
||||
container: `items-center flex-1 justify-center bg-black`,
|
||||
separator: `h-[1px] my-7 w-4/5 bg-gray-800`,
|
||||
title: `text-xl font-bold text-white`,
|
||||
};
|
||||
104
apps/moodlit/apps/mobile/components/SequenceCard.tsx
Normal file
104
apps/moodlit/apps/mobile/components/SequenceCard.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React from 'react';
|
||||
import { Text, Pressable, View, StyleSheet } from 'react-native';
|
||||
|
||||
import { Icon } from './Icon';
|
||||
|
||||
import type { Mood, MoodSequence } from '@/store/store';
|
||||
|
||||
interface SequenceCardProps {
|
||||
sequence: MoodSequence;
|
||||
moods: Mood[];
|
||||
onPress: () => void;
|
||||
onLongPress?: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export const SequenceCard = ({
|
||||
sequence,
|
||||
moods,
|
||||
onPress,
|
||||
onLongPress,
|
||||
isActive,
|
||||
}: SequenceCardProps) => {
|
||||
// Hole die Farben der ersten 3 Moods in der Sequenz
|
||||
const getGradientColors = () => {
|
||||
const colors: string[] = [];
|
||||
sequence.items.slice(0, 3).forEach((item) => {
|
||||
const mood = moods.find((m) => m.id === item.moodId);
|
||||
if (mood && mood.colors.length > 0) {
|
||||
colors.push(mood.colors[0]);
|
||||
}
|
||||
});
|
||||
// Falls weniger als 2 Farben, fülle mit Schwarz auf
|
||||
while (colors.length < 2) {
|
||||
colors.push('#000000');
|
||||
}
|
||||
return colors;
|
||||
};
|
||||
|
||||
const getTotalDuration = () => {
|
||||
const totalSeconds = sequence.items.reduce((sum, item) => sum + item.duration, 0);
|
||||
const mins = Math.floor(totalSeconds / 60);
|
||||
const secs = totalSeconds % 60;
|
||||
if (mins === 0) {
|
||||
return `${secs} Sek`;
|
||||
} else if (secs === 0) {
|
||||
return `${mins} Min`;
|
||||
} else {
|
||||
return `${mins} Min ${secs} Sek`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
className="mx-2 mb-6"
|
||||
disabled={isActive}
|
||||
>
|
||||
<View
|
||||
className="overflow-hidden rounded-3xl shadow-lg"
|
||||
style={[styles.card, isActive && styles.activeCard]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={getGradientColors()}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
>
|
||||
{/* Play Icon Overlay */}
|
||||
<View className="absolute right-4 top-4 rounded-full bg-black/30 p-2">
|
||||
<Icon name="play-circle" size={24} color="#fff" weight="fill" />
|
||||
</View>
|
||||
|
||||
{/* Sequence Info */}
|
||||
<View className="absolute bottom-4 left-5 right-5">
|
||||
<Text className="mb-1 text-3xl font-bold tracking-tight text-white">
|
||||
{sequence.name}
|
||||
</Text>
|
||||
<View className="mt-1 self-start rounded-full bg-white/20 px-3 py-1">
|
||||
<Text className="text-xs font-medium text-white/90">
|
||||
{sequence.items.length} Moods · {getTotalDuration()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
aspectRatio: 16 / 9,
|
||||
},
|
||||
gradient: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
activeCard: {
|
||||
opacity: 0.8,
|
||||
transform: [{ scale: 1.05 }],
|
||||
},
|
||||
});
|
||||
21
apps/moodlit/apps/mobile/eas.json
Normal file
21
apps/moodlit/apps/mobile/eas.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"cli": {
|
||||
"version": ">= 16.23.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
15
apps/moodlit/apps/mobile/eslint.config.js
Normal file
15
apps/moodlit/apps/mobile/eslint.config.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/* eslint-env node */
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const expoConfig = require('eslint-config-expo/flat');
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ['dist/*'],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'react/display-name': 'off',
|
||||
},
|
||||
},
|
||||
]);
|
||||
3
apps/moodlit/apps/mobile/global.css
Normal file
3
apps/moodlit/apps/mobile/global.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
177
apps/moodlit/apps/mobile/hooks/useFlashlight.ts
Normal file
177
apps/moodlit/apps/mobile/hooks/useFlashlight.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useTorch } from 'react-native-torch-nitro';
|
||||
|
||||
import type { AnimationType } from '@/store/store';
|
||||
|
||||
interface UseFlashlightProps {
|
||||
enabled: boolean;
|
||||
animationType: AnimationType;
|
||||
animationSpeed?: number;
|
||||
brightness?: number; // 1-10 (iOS), wird zu 0-maxLevel gemappt
|
||||
}
|
||||
|
||||
export const useFlashlight = ({
|
||||
enabled,
|
||||
animationType,
|
||||
animationSpeed = 1,
|
||||
brightness = 10,
|
||||
}: UseFlashlightProps) => {
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const sosTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const { on, off, setLevel, getMaxLevel } = useTorch({
|
||||
onError: (error) => {
|
||||
console.log('Torch error:', error.code);
|
||||
},
|
||||
});
|
||||
|
||||
const maxLevel = getMaxLevel() || 10;
|
||||
const targetLevel = Math.round((brightness / 10) * maxLevel);
|
||||
|
||||
useEffect(() => {
|
||||
// Cleanup function
|
||||
const cleanup = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
if (sosTimeoutRef.current) {
|
||||
clearTimeout(sosTimeoutRef.current);
|
||||
sosTimeoutRef.current = null;
|
||||
}
|
||||
off();
|
||||
};
|
||||
|
||||
if (!enabled) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (animationType === 'sos') {
|
||||
// SOS: Morse-Code Pattern (··· --- ···)
|
||||
const shortDuration = 200 / animationSpeed;
|
||||
const longDuration = 600 / animationSpeed;
|
||||
const charPause = 200 / animationSpeed;
|
||||
const wordPause = 1400 / animationSpeed;
|
||||
|
||||
const playSOSPattern = () => {
|
||||
let currentStep = 0;
|
||||
const steps = [
|
||||
// S - 3 kurze
|
||||
{ isOn: true, duration: shortDuration },
|
||||
{ isOn: false, duration: charPause },
|
||||
{ isOn: true, duration: shortDuration },
|
||||
{ isOn: false, duration: charPause },
|
||||
{ isOn: true, duration: shortDuration },
|
||||
{ isOn: false, duration: charPause + charPause },
|
||||
// O - 3 lange
|
||||
{ isOn: true, duration: longDuration },
|
||||
{ isOn: false, duration: charPause },
|
||||
{ isOn: true, duration: longDuration },
|
||||
{ isOn: false, duration: charPause },
|
||||
{ isOn: true, duration: longDuration },
|
||||
{ isOn: false, duration: charPause + charPause },
|
||||
// S - 3 kurze
|
||||
{ isOn: true, duration: shortDuration },
|
||||
{ isOn: false, duration: charPause },
|
||||
{ isOn: true, duration: shortDuration },
|
||||
{ isOn: false, duration: charPause },
|
||||
{ isOn: true, duration: shortDuration },
|
||||
{ isOn: false, duration: wordPause },
|
||||
];
|
||||
|
||||
const runStep = () => {
|
||||
if (!enabled || currentStep >= steps.length) {
|
||||
currentStep = 0;
|
||||
}
|
||||
|
||||
const step = steps[currentStep];
|
||||
if (step.isOn) {
|
||||
setLevel(targetLevel);
|
||||
} else {
|
||||
off();
|
||||
}
|
||||
currentStep++;
|
||||
|
||||
sosTimeoutRef.current = setTimeout(runStep, step.duration);
|
||||
};
|
||||
|
||||
runStep();
|
||||
};
|
||||
|
||||
playSOSPattern();
|
||||
} else if (animationType === 'warning') {
|
||||
// Warnsignal: Blinkendes Pattern
|
||||
const warnDuration = 500 / animationSpeed;
|
||||
let isOn = false;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
isOn = !isOn;
|
||||
if (isOn) {
|
||||
setLevel(targetLevel);
|
||||
} else {
|
||||
off();
|
||||
}
|
||||
}, warnDuration);
|
||||
} else if (animationType === 'thunder') {
|
||||
// Gewitter: Zufällige Blitze
|
||||
const runThunderPattern = () => {
|
||||
off(); // Meistens aus
|
||||
|
||||
// Zufälliger Blitz nach 2-4 Sekunden
|
||||
const waitTime = (2000 + Math.random() * 2000) / animationSpeed;
|
||||
|
||||
sosTimeoutRef.current = setTimeout(() => {
|
||||
// Kurzer Blitz
|
||||
setLevel(maxLevel); // Volle Helligkeit für Blitz
|
||||
setTimeout(() => {
|
||||
off();
|
||||
// Manchmal zweiter Blitz
|
||||
if (Math.random() > 0.5) {
|
||||
setTimeout(() => {
|
||||
setLevel(maxLevel);
|
||||
setTimeout(() => {
|
||||
off();
|
||||
runThunderPattern();
|
||||
}, 80 / animationSpeed);
|
||||
}, 100 / animationSpeed);
|
||||
} else {
|
||||
runThunderPattern();
|
||||
}
|
||||
}, 50 / animationSpeed);
|
||||
}, waitTime);
|
||||
};
|
||||
|
||||
runThunderPattern();
|
||||
} else if (animationType === 'pulse') {
|
||||
// Pulsieren zwischen niedrig und hoch
|
||||
let increasing = true;
|
||||
let currentBrightness = 1;
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (increasing) {
|
||||
currentBrightness += 1;
|
||||
if (currentBrightness >= targetLevel) {
|
||||
increasing = false;
|
||||
}
|
||||
} else {
|
||||
currentBrightness -= 1;
|
||||
if (currentBrightness <= 1) {
|
||||
increasing = true;
|
||||
}
|
||||
}
|
||||
setLevel(currentBrightness);
|
||||
}, 100 / animationSpeed);
|
||||
} else {
|
||||
// Alle anderen Moods: Taschenlampe konstant an
|
||||
setLevel(targetLevel);
|
||||
}
|
||||
|
||||
return cleanup;
|
||||
}, [enabled, animationType, animationSpeed, targetLevel, maxLevel]);
|
||||
|
||||
return {
|
||||
maxLevel,
|
||||
currentBrightness: brightness,
|
||||
};
|
||||
};
|
||||
43
apps/moodlit/apps/mobile/hooks/useResponsive.ts
Normal file
43
apps/moodlit/apps/mobile/hooks/useResponsive.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useWindowDimensions } from 'react-native';
|
||||
|
||||
export type ScreenSize = 'small' | 'medium' | 'large' | 'xlarge';
|
||||
|
||||
export const useResponsive = () => {
|
||||
const { width, height } = useWindowDimensions();
|
||||
|
||||
// Breakpoints
|
||||
const isSmall = width < 768; // Phone
|
||||
const isMedium = width >= 768 && width < 1024; // Tablet Portrait
|
||||
const isLarge = width >= 1024 && width < 1440; // Tablet Landscape / Small Desktop
|
||||
const isXLarge = width >= 1440; // Large Desktop / Mac
|
||||
|
||||
const screenSize: ScreenSize = isSmall
|
||||
? 'small'
|
||||
: isMedium
|
||||
? 'medium'
|
||||
: isLarge
|
||||
? 'large'
|
||||
: 'xlarge';
|
||||
|
||||
// Responsive values
|
||||
const maxContentWidth = isSmall ? width : isMedium ? 720 : isLarge ? 960 : 1200;
|
||||
const numColumns = isSmall ? 1 : isMedium ? 2 : isLarge ? 2 : 3;
|
||||
const horizontalPadding = isSmall ? 16 : isMedium ? 32 : 48;
|
||||
const cardAspectRatio = isSmall ? 16 / 9 : 2 / 1;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
isSmall,
|
||||
isMedium,
|
||||
isLarge,
|
||||
isXLarge,
|
||||
isTablet: isMedium || isLarge,
|
||||
isDesktop: isLarge || isXLarge,
|
||||
screenSize,
|
||||
maxContentWidth,
|
||||
numColumns,
|
||||
horizontalPadding,
|
||||
cardAspectRatio,
|
||||
};
|
||||
};
|
||||
10
apps/moodlit/apps/mobile/metro.config.js
Normal file
10
apps/moodlit/apps/mobile/metro.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
|
||||
const { withNativeWind } = require('nativewind/metro');
|
||||
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
module.exports = withNativeWind(config, { input: './global.css' });
|
||||
2
apps/moodlit/apps/mobile/nativewind-env.d.ts
vendored
Normal file
2
apps/moodlit/apps/mobile/nativewind-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// @ts-ignore
|
||||
/// <reference types="nativewind/types" />
|
||||
68
apps/moodlit/apps/mobile/package.json
Normal file
68
apps/moodlit/apps/mobile/package.json
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
{
|
||||
"name": "@moodlit/mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"dev": "expo start --dev-client",
|
||||
"start": "expo start --dev-client",
|
||||
"ios": "expo run:ios",
|
||||
"android": "expo run:android",
|
||||
"build:dev": "eas build --profile development",
|
||||
"build:preview": "eas build --profile preview",
|
||||
"build:prod": "eas build --profile production",
|
||||
"prebuild": "expo prebuild",
|
||||
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
|
||||
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/slider": "^5.1.0",
|
||||
"@react-navigation/native": "^7.1.6",
|
||||
"@supabase/supabase-js": "^2.38.4",
|
||||
"expo": "^54.0.0",
|
||||
"expo-av": "^16.0.7",
|
||||
"expo-brightness": "^14.0.7",
|
||||
"expo-camera": "^17.0.9",
|
||||
"expo-constants": "~18.0.9",
|
||||
"expo-dev-client": "~6.0.13",
|
||||
"expo-device": "~8.0.9",
|
||||
"expo-haptics": "^15.0.7",
|
||||
"expo-keep-awake": "^15.0.7",
|
||||
"expo-linear-gradient": "^15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-router": "~6.0.10",
|
||||
"expo-splash-screen": "^0.21.1",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-symbols": "^1.0.7",
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-web-browser": "~15.0.7",
|
||||
"nativewind": "latest",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-nitro-modules": "^0.31.4",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-torch-nitro": "^0.0.1",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"zustand": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@types/react": "~19.1.10",
|
||||
"eslint": "^9.25.1",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
10
apps/moodlit/apps/mobile/prettier.config.js
Normal file
10
apps/moodlit/apps/mobile/prettier.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
module.exports = {
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
singleQuote: true,
|
||||
bracketSameLine: true,
|
||||
trailingComma: 'es5',
|
||||
|
||||
plugins: [require.resolve('prettier-plugin-tailwindcss')],
|
||||
tailwindAttributes: ['className'],
|
||||
};
|
||||
312
apps/moodlit/apps/mobile/store/store.ts
Normal file
312
apps/moodlit/apps/mobile/store/store.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export type AnimationType =
|
||||
| 'gradient'
|
||||
| 'pulse'
|
||||
| 'wave'
|
||||
| 'flash'
|
||||
| 'sos'
|
||||
| 'candle'
|
||||
| 'police'
|
||||
| 'warning'
|
||||
| 'disco'
|
||||
| 'thunder'
|
||||
| 'breath'
|
||||
| 'rave'
|
||||
| 'scanner'
|
||||
| 'matrix'
|
||||
| 'sunrise'
|
||||
| 'sunset';
|
||||
|
||||
export interface Mood {
|
||||
id: string;
|
||||
name: string;
|
||||
colors: string[];
|
||||
animationType: AnimationType;
|
||||
isFavorite?: boolean;
|
||||
isCustom?: boolean;
|
||||
}
|
||||
|
||||
export interface MoodSequenceItem {
|
||||
moodId: string;
|
||||
duration: number; // in Sekunden
|
||||
}
|
||||
|
||||
export interface MoodSequence {
|
||||
id: string;
|
||||
name: string;
|
||||
items: MoodSequenceItem[];
|
||||
transitionDuration: number; // in Sekunden (2, 5, oder 10)
|
||||
isCustom: boolean;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
animationSpeed: number; // 0.5 = langsam, 1 = normal, 2 = schnell
|
||||
hapticFeedback: boolean;
|
||||
brightness: number; // 0-1 (Bildschirm-Helligkeit)
|
||||
autoTimer: number; // 0 = aus, sonst Minuten
|
||||
autoMoodSwitch: boolean;
|
||||
autoMoodSwitchInterval: number; // Minuten
|
||||
screenEnabled: boolean; // Bildschirm-Animation aktiviert
|
||||
flashlightEnabled: boolean; // Taschenlampe aktiviert
|
||||
flashlightBrightness: number; // 1-10 (Taschenlampen-Helligkeit)
|
||||
}
|
||||
|
||||
export interface MoodState {
|
||||
moods: Mood[];
|
||||
sequences: MoodSequence[];
|
||||
settings: Settings;
|
||||
addCustomMood: (mood: Omit<Mood, 'id'>) => void;
|
||||
removeMood: (id: string) => void;
|
||||
toggleFavorite: (id: string) => void;
|
||||
reorderMoods: (moods: Mood[]) => void;
|
||||
updateSettings: (settings: Partial<Settings>) => void;
|
||||
addSequence: (sequence: Omit<MoodSequence, 'id'>) => void;
|
||||
removeSequence: (id: string) => void;
|
||||
updateSequence: (id: string, sequence: Partial<MoodSequence>) => void;
|
||||
}
|
||||
|
||||
const defaultMoods: Mood[] = [
|
||||
{
|
||||
id: '7',
|
||||
name: 'Feuer',
|
||||
colors: ['#8B0000', '#FF4500', '#FF8C00', '#FFD700'],
|
||||
animationType: 'pulse',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '21',
|
||||
name: 'Atem',
|
||||
colors: ['#e3f2fd', '#90caf9', '#42a5f5'],
|
||||
animationType: 'breath',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '19',
|
||||
name: 'Nordlicht',
|
||||
colors: ['#00FF87', '#00D9FF', '#60EFFF', '#7B68EE', '#9D50BB'],
|
||||
animationType: 'wave',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '15',
|
||||
name: 'Gewitter',
|
||||
colors: ['#2C3E50', '#34495E'],
|
||||
animationType: 'thunder',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
name: 'Licht',
|
||||
colors: ['#FFFFFF', '#F5F5F5', '#E8E8E8'],
|
||||
animationType: 'gradient',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
name: 'Blitzlicht',
|
||||
colors: ['#000000', '#FFFFFF'],
|
||||
animationType: 'flash',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
name: 'SOS',
|
||||
colors: ['#000000', '#FF0000'],
|
||||
animationType: 'sos',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '16',
|
||||
name: 'Meer',
|
||||
colors: ['#006994', '#1E90FF', '#4682B4', '#87CEEB'],
|
||||
animationType: 'wave',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
name: 'Kerze',
|
||||
colors: ['#FF4500', '#FF6347', '#FF8C00', '#FFA500'],
|
||||
animationType: 'candle',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '12',
|
||||
name: 'Polizei',
|
||||
colors: ['#0000FF', '#FF0000'],
|
||||
animationType: 'police',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '13',
|
||||
name: 'Warnsignal',
|
||||
colors: ['#FFA500', '#FFD700'],
|
||||
animationType: 'warning',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '14',
|
||||
name: 'Disco',
|
||||
colors: ['#FF00FF', '#00FFFF', '#FFFF00', '#FF0000', '#00FF00', '#0000FF'],
|
||||
animationType: 'disco',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '17',
|
||||
name: 'Sonnenaufgang',
|
||||
colors: [
|
||||
'#0f0c29',
|
||||
'#302b63', // Nacht
|
||||
'#1a1a2e',
|
||||
'#0f3460', // Früher Morgen
|
||||
'#434343',
|
||||
'#000000', // Dämmerung
|
||||
'#e94560',
|
||||
'#0f3460', // Rosa/Blau
|
||||
'#f39c12',
|
||||
'#f1c40f', // Gelb/Gold
|
||||
'#FDB99B',
|
||||
'#FCE38A', // Helles Gelb
|
||||
],
|
||||
animationType: 'sunrise',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '18',
|
||||
name: 'Sonnenuntergang',
|
||||
colors: [
|
||||
'#FDB99B',
|
||||
'#FCE38A', // Helles Gelb
|
||||
'#FF6B35',
|
||||
'#F7931E', // Orange
|
||||
'#e94560',
|
||||
'#f39c12', // Rot/Orange
|
||||
'#C1666B',
|
||||
'#8B5A8B', // Rosa/Lila
|
||||
'#4A235A',
|
||||
'#000428', // Dunkelviolett/Blau
|
||||
'#0f0c29',
|
||||
'#302b63', // Nacht
|
||||
],
|
||||
animationType: 'sunset',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '20',
|
||||
name: 'Wald',
|
||||
colors: ['#2d5016', '#3d7317', '#4a9d26', '#66bb6a', '#81c784'],
|
||||
animationType: 'gradient',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '22',
|
||||
name: 'Rave',
|
||||
colors: [
|
||||
'#FF00FF',
|
||||
'#00FFFF',
|
||||
'#FFFF00',
|
||||
'#FF0000',
|
||||
'#00FF00',
|
||||
'#0000FF',
|
||||
'#FF6600',
|
||||
'#FF0080',
|
||||
],
|
||||
animationType: 'rave',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '23',
|
||||
name: 'Scanner',
|
||||
colors: ['#000000', '#FF0000'],
|
||||
animationType: 'scanner',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: '24',
|
||||
name: 'Matrix',
|
||||
colors: ['#000000', '#00FF00'],
|
||||
animationType: 'matrix',
|
||||
isFavorite: false,
|
||||
isCustom: false,
|
||||
},
|
||||
];
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
animationSpeed: 1,
|
||||
hapticFeedback: true,
|
||||
brightness: 1,
|
||||
autoTimer: 0,
|
||||
autoMoodSwitch: false,
|
||||
autoMoodSwitchInterval: 5,
|
||||
screenEnabled: true,
|
||||
flashlightEnabled: false,
|
||||
flashlightBrightness: 10, // Maximale Helligkeit als Standard
|
||||
};
|
||||
|
||||
export const useStore = create<MoodState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
moods: defaultMoods,
|
||||
sequences: [],
|
||||
settings: defaultSettings,
|
||||
addCustomMood: (mood) =>
|
||||
set((state) => ({
|
||||
moods: [
|
||||
...state.moods,
|
||||
{ ...mood, id: Date.now().toString(), isCustom: true, isFavorite: false },
|
||||
],
|
||||
})),
|
||||
removeMood: (id) =>
|
||||
set((state) => ({
|
||||
moods: state.moods.filter((m) => m.id !== id),
|
||||
})),
|
||||
toggleFavorite: (id) =>
|
||||
set((state) => ({
|
||||
moods: state.moods.map((m) => (m.id === id ? { ...m, isFavorite: !m.isFavorite } : m)),
|
||||
})),
|
||||
reorderMoods: (moods) => set({ moods }),
|
||||
updateSettings: (newSettings) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, ...newSettings },
|
||||
})),
|
||||
addSequence: (sequence) =>
|
||||
set((state) => ({
|
||||
sequences: [
|
||||
...state.sequences,
|
||||
{ ...sequence, id: Date.now().toString(), isCustom: true },
|
||||
],
|
||||
})),
|
||||
removeSequence: (id) =>
|
||||
set((state) => ({
|
||||
sequences: state.sequences.filter((s) => s.id !== id),
|
||||
})),
|
||||
updateSequence: (id, updates) =>
|
||||
set((state) => ({
|
||||
sequences: state.sequences.map((s) => (s.id === id ? { ...s, ...updates } : s)),
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: 'mood-light-storage-v16',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
14
apps/moodlit/apps/mobile/tailwind.config.js
Normal file
14
apps/moodlit/apps/mobile/tailwind.config.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}'],
|
||||
|
||||
presets: [require('nativewind/preset')],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'card-dark': '#2a2a2a',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
11
apps/moodlit/apps/mobile/tsconfig.json
Normal file
11
apps/moodlit/apps/mobile/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
|
||||
}
|
||||
14
apps/moodlit/apps/mobile/utils/supabase.ts
Normal file
14
apps/moodlit/apps/mobile/utils/supabase.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
storage: AsyncStorage,
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
});
|
||||
11
apps/moodlit/apps/mobile/utils/theme.ts
Normal file
11
apps/moodlit/apps/mobile/utils/theme.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// App ist permanent im Dark Mode mit komplett schwarzem Theme
|
||||
export const getThemeColors = () => {
|
||||
return {
|
||||
bg: 'bg-black',
|
||||
cardBg: 'bg-card-dark',
|
||||
text: 'text-white',
|
||||
textSecondary: 'text-gray-400',
|
||||
border: 'border-gray-800',
|
||||
input: 'bg-gray-800',
|
||||
};
|
||||
};
|
||||
47
apps/moodlit/apps/web/package.json
Normal file
47
apps/moodlit/apps/web/package.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "@moodlit/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"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'"
|
||||
},
|
||||
"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",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-feedback-service": "workspace:*",
|
||||
"@manacore/shared-feedback-ui": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-profile-ui": "workspace:*",
|
||||
"@manacore/shared-subscription-ui": "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"
|
||||
}
|
||||
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));
|
||||
}
|
||||
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>
|
||||
14
apps/moodlit/apps/web/src/lib/api/feedback.ts
Normal file
14
apps/moodlit/apps/web/src/lib/api/feedback.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Feedback Service Instance for Moodlit Web App
|
||||
*/
|
||||
|
||||
import { createFeedbackService } from '@manacore/shared-feedback-service';
|
||||
import { authStore } from '$lib/stores/authStore.svelte';
|
||||
|
||||
const MANA_AUTH_URL = 'http://localhost:3001';
|
||||
|
||||
export const feedbackService = createFeedbackService({
|
||||
apiUrl: MANA_AUTH_URL,
|
||||
appId: 'moodlit',
|
||||
getAuthToken: async () => authStore.getAccessToken(),
|
||||
});
|
||||
172
apps/moodlit/apps/web/src/lib/auth.ts
Normal file
172
apps/moodlit/apps/web/src/lib/auth.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* Moodlit Web Auth Configuration
|
||||
*
|
||||
* This file initializes the shared auth package for the moodlit web app.
|
||||
*/
|
||||
|
||||
import { PUBLIC_MANA_CORE_AUTH_URL, PUBLIC_BACKEND_URL } from '$env/static/public';
|
||||
import {
|
||||
createAuthService,
|
||||
createTokenManager,
|
||||
setStorageAdapter,
|
||||
setDeviceAdapter,
|
||||
setNetworkAdapter,
|
||||
setupFetchInterceptor,
|
||||
type StorageAdapter,
|
||||
type DeviceManagerAdapter,
|
||||
type NetworkAdapter,
|
||||
type DeviceInfo,
|
||||
} from '@manacore/shared-auth';
|
||||
|
||||
// Storage keys
|
||||
const STORAGE_KEYS = {
|
||||
APP_TOKEN: 'moodlit_appToken',
|
||||
REFRESH_TOKEN: 'moodlit_refreshToken',
|
||||
USER_EMAIL: 'moodlit_userEmail',
|
||||
DEVICE_ID: 'moodlit_device_id',
|
||||
};
|
||||
|
||||
/**
|
||||
* Session storage adapter for moodlit web
|
||||
* Uses sessionStorage for tokens (clears on tab close)
|
||||
* Uses localStorage for device ID (persists)
|
||||
*/
|
||||
const sessionStorageAdapter: StorageAdapter = {
|
||||
async getItem<T = string>(key: string): Promise<T | null> {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const value = sessionStorage.getItem(key);
|
||||
if (value === null) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return value as T;
|
||||
}
|
||||
},
|
||||
|
||||
async setItem(key: string, value: string): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
sessionStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
|
||||
},
|
||||
|
||||
async removeItem(key: string): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
sessionStorage.removeItem(key);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Device manager adapter for web
|
||||
*/
|
||||
const webDeviceAdapter: DeviceManagerAdapter = {
|
||||
async getDeviceInfo(): Promise<DeviceInfo> {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
deviceId: '',
|
||||
deviceName: 'Server',
|
||||
deviceType: 'web',
|
||||
};
|
||||
}
|
||||
|
||||
const deviceId = (await webDeviceAdapter.getStoredDeviceId()) || generateDeviceId();
|
||||
localStorage.setItem(STORAGE_KEYS.DEVICE_ID, deviceId);
|
||||
|
||||
const userAgent = navigator.userAgent;
|
||||
let deviceName = 'Web Browser';
|
||||
|
||||
if (userAgent.includes('Mac')) deviceName = 'Mac';
|
||||
else if (userAgent.includes('Windows')) deviceName = 'Windows';
|
||||
else if (userAgent.includes('Linux')) deviceName = 'Linux';
|
||||
|
||||
return {
|
||||
deviceId,
|
||||
deviceName,
|
||||
deviceType: 'web',
|
||||
platform: 'web',
|
||||
};
|
||||
},
|
||||
|
||||
async getStoredDeviceId(): Promise<string | null> {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(STORAGE_KEYS.DEVICE_ID);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Network adapter for web
|
||||
*/
|
||||
const webNetworkAdapter: NetworkAdapter = {
|
||||
async isDeviceConnected(): Promise<boolean> {
|
||||
if (typeof navigator === 'undefined') return true;
|
||||
return navigator.onLine;
|
||||
},
|
||||
|
||||
async hasStableConnection(): Promise<boolean> {
|
||||
if (typeof navigator === 'undefined') return true;
|
||||
return navigator.onLine;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a unique device ID
|
||||
*/
|
||||
function generateDeviceId(): string {
|
||||
return `moodlit_web_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
|
||||
}
|
||||
|
||||
// Initialize adapters
|
||||
setStorageAdapter(sessionStorageAdapter);
|
||||
setDeviceAdapter(webDeviceAdapter);
|
||||
setNetworkAdapter(webNetworkAdapter);
|
||||
|
||||
// Create auth service instance
|
||||
export const authService = createAuthService({
|
||||
baseUrl: PUBLIC_MANA_CORE_AUTH_URL,
|
||||
storageKeys: {
|
||||
APP_TOKEN: STORAGE_KEYS.APP_TOKEN,
|
||||
REFRESH_TOKEN: STORAGE_KEYS.REFRESH_TOKEN,
|
||||
USER_EMAIL: STORAGE_KEYS.USER_EMAIL,
|
||||
},
|
||||
endpoints: {
|
||||
signIn: '/api/v1/auth/login',
|
||||
signUp: '/api/v1/auth/register',
|
||||
signOut: '/api/v1/auth/logout',
|
||||
refresh: '/api/v1/auth/refresh',
|
||||
validate: '/api/v1/auth/validate',
|
||||
forgotPassword: '/api/v1/auth/forgot-password',
|
||||
googleSignIn: '/api/v1/auth/google-signin',
|
||||
appleSignIn: '/api/v1/auth/apple-signin',
|
||||
credits: '/api/v1/credits/balance',
|
||||
},
|
||||
});
|
||||
|
||||
// Create token manager instance
|
||||
export const tokenManager = createTokenManager(authService);
|
||||
|
||||
// Setup fetch interceptor (only in browser)
|
||||
if (typeof window !== 'undefined') {
|
||||
setupFetchInterceptor(authService, tokenManager, {
|
||||
backendUrl: PUBLIC_BACKEND_URL,
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export useful utilities from shared-auth
|
||||
export {
|
||||
decodeToken,
|
||||
isTokenValidLocally,
|
||||
isTokenExpired,
|
||||
getUserFromToken,
|
||||
isB2BUser,
|
||||
getB2BInfo,
|
||||
TokenState,
|
||||
} from '@manacore/shared-auth';
|
||||
|
||||
// Re-export types
|
||||
export type {
|
||||
UserData,
|
||||
DecodedToken,
|
||||
AuthResult,
|
||||
CreditBalance,
|
||||
B2BInfo,
|
||||
} from '@manacore/shared-auth';
|
||||
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" />
|
||||
|
|
@ -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(', ')})`;
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
118
apps/moodlit/apps/web/src/lib/stores/authStore.svelte.ts
Normal file
118
apps/moodlit/apps/web/src/lib/stores/authStore.svelte.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import type { MoodlitUser } from '$lib/types/auth';
|
||||
import { authService, type UserData } from '$lib/auth';
|
||||
|
||||
// Svelte 5 runes-based auth store
|
||||
let user = $state<MoodlitUser | null>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
/**
|
||||
* Convert UserData from shared-auth to MoodlitUser
|
||||
*/
|
||||
function toMoodlitUser(userData: UserData | null): MoodlitUser | null {
|
||||
if (!userData) return null;
|
||||
return {
|
||||
id: userData.id,
|
||||
email: userData.email,
|
||||
role: userData.role,
|
||||
};
|
||||
}
|
||||
|
||||
export const authStore = {
|
||||
get user() {
|
||||
return user;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get isAuthenticated() {
|
||||
return !!user;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
*/
|
||||
async initialize() {
|
||||
loading = true;
|
||||
try {
|
||||
const isAuth = await authService.isAuthenticated();
|
||||
if (isAuth) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = toMoodlitUser(userData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
user = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set user
|
||||
*/
|
||||
setUser(newUser: MoodlitUser | null) {
|
||||
user = newUser;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
async signOut() {
|
||||
try {
|
||||
await authService.signOut();
|
||||
user = null;
|
||||
} catch (error) {
|
||||
console.error('Sign out failed:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check authentication status
|
||||
*/
|
||||
async checkAuth() {
|
||||
const isAuth = await authService.isAuthenticated();
|
||||
if (!isAuth) {
|
||||
user = null;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
async signIn(email: string, password: string) {
|
||||
const result = await authService.signIn(email, password);
|
||||
if (result.success) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = toMoodlitUser(userData);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign up with email and password
|
||||
*/
|
||||
async signUp(email: string, password: string) {
|
||||
const result = await authService.signUp(email, password);
|
||||
if (result.success && !result.needsVerification) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = toMoodlitUser(userData);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
async forgotPassword(email: string) {
|
||||
return authService.forgotPassword(email);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get access token for API calls
|
||||
*/
|
||||
async getAccessToken(): Promise<string | null> {
|
||||
return authService.getAppToken();
|
||||
},
|
||||
};
|
||||
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' },
|
||||
];
|
||||
174
apps/moodlit/apps/web/src/routes/(app)/+layout.svelte
Normal file
174
apps/moodlit/apps/web/src/routes/(app)/+layout.svelte
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { locale, _ } from 'svelte-i18n';
|
||||
import { authStore } from '$lib/stores/authStore.svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import {
|
||||
isSidebarMode as sidebarModeStore,
|
||||
isNavCollapsed as collapsedStore,
|
||||
} from '$lib/stores/navigation';
|
||||
import { PillNavigation } from '@manacore/shared-ui';
|
||||
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
|
||||
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
|
||||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('moodlit');
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let isSidebarMode = $state(false);
|
||||
let isCollapsed = $state(false);
|
||||
|
||||
// Get theme state
|
||||
let isDark = $derived(theme.isDark);
|
||||
|
||||
// Navigation items for Moodlit
|
||||
const navItems: PillNavItem[] = [
|
||||
{ href: '/', label: 'Moods', icon: 'palette' },
|
||||
{ href: '/sequences', label: 'Sequences', icon: 'layers' },
|
||||
{ href: '/settings', label: 'Settings', icon: 'settings' },
|
||||
];
|
||||
|
||||
// Theme variant dropdown items
|
||||
let themeVariantItems = $derived<PillDropdownItem[]>([
|
||||
...theme.variants.map((variant) => ({
|
||||
id: variant,
|
||||
label: THEME_DEFINITIONS[variant]?.label || variant,
|
||||
icon: THEME_DEFINITIONS[variant]?.icon || 'circle',
|
||||
onClick: () => theme.setVariant(variant),
|
||||
active: theme.variant === variant,
|
||||
})),
|
||||
]);
|
||||
|
||||
// Current theme variant label
|
||||
let currentThemeVariantLabel = $derived(THEME_DEFINITIONS[theme.variant]?.label || theme.variant);
|
||||
|
||||
// Language selector items
|
||||
let currentLocale = $derived($locale || 'de');
|
||||
function handleLocaleChange(newLocale: string) {
|
||||
setLocale(newLocale as any);
|
||||
}
|
||||
let languageItems = $derived(
|
||||
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
|
||||
);
|
||||
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
|
||||
|
||||
// User email for user dropdown
|
||||
let userEmail = $derived(authStore.user?.email);
|
||||
|
||||
function handleModeChange(isSidebar: boolean) {
|
||||
isSidebarMode = isSidebar;
|
||||
sidebarModeStore.set(isSidebar);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('moodlit-nav-sidebar', String(isSidebar));
|
||||
}
|
||||
}
|
||||
|
||||
function handleCollapsedChange(collapsed: boolean) {
|
||||
isCollapsed = collapsed;
|
||||
collapsedStore.set(collapsed);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('moodlit-nav-collapsed', String(collapsed));
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleTheme() {
|
||||
theme.toggleMode();
|
||||
}
|
||||
|
||||
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
|
||||
theme.setMode(mode);
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
await authStore.signOut();
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await authStore.initialize();
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize sidebar mode from localStorage
|
||||
const savedSidebar = localStorage.getItem('moodlit-nav-sidebar');
|
||||
if (savedSidebar === 'true') {
|
||||
isSidebarMode = true;
|
||||
sidebarModeStore.set(true);
|
||||
}
|
||||
|
||||
// Initialize collapsed state from localStorage
|
||||
const savedCollapsed = localStorage.getItem('moodlit-nav-collapsed');
|
||||
if (savedCollapsed === 'true') {
|
||||
isCollapsed = true;
|
||||
collapsedStore.set(true);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if authStore.loading}
|
||||
<div class="min-h-screen flex items-center justify-center bg-background">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="inline-block animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"
|
||||
></div>
|
||||
<p class="mt-4 text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if authStore.isAuthenticated}
|
||||
<div class="min-h-screen bg-background">
|
||||
<!-- Pill Navigation -->
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Moodlit"
|
||||
homeRoute="/"
|
||||
onLogout={handleSignOut}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={true}
|
||||
primaryColor="#8b5cf6"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
allAppsHref="/apps"
|
||||
/>
|
||||
|
||||
<!-- Main content with dynamic padding -->
|
||||
<main
|
||||
class="transition-all duration-300 {isCollapsed
|
||||
? ''
|
||||
: isSidebarMode
|
||||
? 'pl-[180px]'
|
||||
: 'pt-20'}"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
177
apps/moodlit/apps/web/src/routes/(app)/+page.svelte
Normal file
177
apps/moodlit/apps/web/src/routes/(app)/+page.svelte
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { moodsStore } from '$lib/stores/moods.svelte';
|
||||
import { DEFAULT_MOODS, getMoodGradient } from '$lib/data/default-moods';
|
||||
import MoodCard from '$lib/components/mood/MoodCard.svelte';
|
||||
import MoodFullscreen from '$lib/components/mood/MoodFullscreen.svelte';
|
||||
import CreateMoodDialog from '$lib/components/mood/CreateMoodDialog.svelte';
|
||||
import { Plus } from '@manacore/shared-icons';
|
||||
import type { Mood, AnimationType } from '$lib/types/mood';
|
||||
|
||||
// Combine default moods with custom moods
|
||||
let allMoods = $derived([...DEFAULT_MOODS, ...moodsStore.customMoods]);
|
||||
|
||||
// Get favorites (moods that are in the favorites list)
|
||||
let favoriteMoods = $derived(allMoods.filter((m) => moodsStore.isFavorite(m.id)));
|
||||
|
||||
// Filter by category
|
||||
let selectedCategory = $state<'all' | 'favorites' | 'custom'>('all');
|
||||
|
||||
// Fullscreen state
|
||||
let showFullscreen = $state(false);
|
||||
let fullscreenMood = $state<Mood | null>(null);
|
||||
|
||||
// Create mood dialog state
|
||||
let showCreateDialog = $state(false);
|
||||
|
||||
let displayedMoods = $derived(() => {
|
||||
switch (selectedCategory) {
|
||||
case 'favorites':
|
||||
return favoriteMoods;
|
||||
case 'custom':
|
||||
return moodsStore.customMoods;
|
||||
default:
|
||||
return allMoods;
|
||||
}
|
||||
});
|
||||
|
||||
function handleMoodClick(mood: Mood) {
|
||||
fullscreenMood = mood;
|
||||
showFullscreen = true;
|
||||
moodsStore.setActiveMood(mood);
|
||||
}
|
||||
|
||||
function handleCloseFullscreen() {
|
||||
showFullscreen = false;
|
||||
moodsStore.setActiveMood(null);
|
||||
}
|
||||
|
||||
function handleFavoriteToggle(mood: Mood) {
|
||||
moodsStore.toggleFavorite(mood.id);
|
||||
}
|
||||
|
||||
function handleFullscreenFavoriteToggle() {
|
||||
if (fullscreenMood) {
|
||||
moodsStore.toggleFavorite(fullscreenMood.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateMood(moodData: {
|
||||
name: string;
|
||||
colors: string[];
|
||||
animationType: AnimationType;
|
||||
}) {
|
||||
const newMood: Mood = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: moodData.name,
|
||||
colors: moodData.colors,
|
||||
animationType: moodData.animationType,
|
||||
isCustom: true,
|
||||
order: moodsStore.customMoods.length,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
moodsStore.addMood(newMood);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Header -->
|
||||
<header>
|
||||
<h1 class="text-3xl font-bold">{$_('home.title')}</h1>
|
||||
<p class="text-[hsl(var(--color-muted-foreground))] mt-1">{$_('home.subtitle')}</p>
|
||||
</header>
|
||||
|
||||
<!-- Category Tabs -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="px-4 py-2 rounded-full text-sm font-medium transition-colors {selectedCategory ===
|
||||
'all'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted hover:bg-muted/80'}"
|
||||
onclick={() => (selectedCategory = 'all')}
|
||||
>
|
||||
{$_('home.all')}
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded-full text-sm font-medium transition-colors {selectedCategory ===
|
||||
'favorites'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted hover:bg-muted/80'}"
|
||||
onclick={() => (selectedCategory = 'favorites')}
|
||||
>
|
||||
{$_('home.favorites')} ({favoriteMoods.length})
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded-full text-sm font-medium transition-colors {selectedCategory ===
|
||||
'custom'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted hover:bg-muted/80'}"
|
||||
onclick={() => (selectedCategory = 'custom')}
|
||||
>
|
||||
{$_('home.custom')} ({moodsStore.customMoods.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Fullscreen Mood View -->
|
||||
{#if showFullscreen && fullscreenMood}
|
||||
<MoodFullscreen
|
||||
mood={fullscreenMood}
|
||||
isFavorite={moodsStore.isFavorite(fullscreenMood.id)}
|
||||
onClose={handleCloseFullscreen}
|
||||
onFavoriteToggle={handleFullscreenFavoriteToggle}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Mood Grid -->
|
||||
<section>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{#each displayedMoods() as mood (mood.id)}
|
||||
<MoodCard
|
||||
{mood}
|
||||
isActive={moodsStore.activeMood?.id === mood.id}
|
||||
isFavorite={moodsStore.isFavorite(mood.id)}
|
||||
onClick={() => handleMoodClick(mood)}
|
||||
onFavoriteToggle={() => handleFavoriteToggle(mood)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if displayedMoods().length === 0}
|
||||
<div class="text-center py-12 text-muted-foreground">
|
||||
{#if selectedCategory === 'favorites'}
|
||||
<p>No favorites yet. Click the heart icon on a mood to add it to favorites.</p>
|
||||
{:else if selectedCategory === 'custom'}
|
||||
<p>No custom moods yet. Create your own mood to get started.</p>
|
||||
{:else}
|
||||
<p>No moods available.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Sequences Section -->
|
||||
<section class="border-t border-border pt-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold">{$_('home.sequences')}</h2>
|
||||
<a href="/sequences" class="text-sm text-primary hover:underline"> View all </a>
|
||||
</div>
|
||||
<p class="text-muted-foreground">{$_('home.sequencesDescription')}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Floating Action Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="fixed bottom-24 right-6 z-30 p-4 rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 hover:scale-110 transition-all"
|
||||
onclick={() => (showCreateDialog = true)}
|
||||
aria-label={$_('createMood.title')}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
|
||||
<!-- Create Mood Dialog -->
|
||||
<CreateMoodDialog
|
||||
isOpen={showCreateDialog}
|
||||
onClose={() => (showCreateDialog = false)}
|
||||
onSave={handleCreateMood}
|
||||
/>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { FeedbackPage } from '@manacore/shared-feedback-ui';
|
||||
import { feedbackService } from '$lib/api/feedback';
|
||||
import { authStore } from '$lib/stores/authStore.svelte';
|
||||
</script>
|
||||
|
||||
<FeedbackPage {feedbackService} appName="Moodlit" currentUserId={authStore.user?.id} />
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue