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:
Till-JS 2025-12-04 16:53:56 +01:00
parent ad0051a8fc
commit b7eeae9590
115 changed files with 8104 additions and 2 deletions

295
apps/moodlit/CLAUDE.md Normal file
View 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

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

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View 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"
}
}

View 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 {}

View 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>;

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

View file

@ -0,0 +1,2 @@
export * from './moods.schema';
export * from './sequences.schema';

View 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;

View 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;

View 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',
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View 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();

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

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

View 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 {}

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

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

View 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 { 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 };
}
}

View 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 {}

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

View 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"]
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

25
apps/moodlit/apps/mobile/.gitignore vendored Normal file
View 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*

View 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

View 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"
}
}

View 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;
}
}`;

View 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`,
};

View 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',
},
}}
/>
);
}

View 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%',
},
});

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

View 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',
};

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

View 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({});

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

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

View 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,
};
};

View 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"
}
}

View 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]}
/>
)}
</>
);
};

View 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',
};

View 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',
};

View 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`,
};

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

View 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 }],
},
});

View 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`,
};

View 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 }],
},
});

View 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": {}
}
}

View 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',
},
},
]);

View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View 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,
};
};

View 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,
};
};

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

View file

@ -0,0 +1,2 @@
// @ts-ignore
/// <reference types="nativewind/types" />

View 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
}

View 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'],
};

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

View 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: [],
};

View 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"]
}

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

View 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',
};
};

View 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"
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,174 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale, _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/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}

View file

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

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { FeedbackPage } from '@manacore/shared-feedback-ui';
import { feedbackService } from '$lib/api/feedback';
import { authStore } from '$lib/stores/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