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

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

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

View file

@ -60,6 +60,7 @@ For comprehensive guidelines on code patterns and conventions, see the `.claude/
| **wisekeep** | AI transcription & wisdom library | Server, Web, Landing |
| **reader** | Text-to-Speech with offline audio | Mobile |
| **bauntown** | Developer community website | Landing |
| **moodlit** | Ambient lighting & mood app | Server, Web, Landing |
| **calc** | Calculator & converter | Web |
| **playground** | LLM playground | Web |

View file

@ -1,295 +0,0 @@
# 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

@ -1,12 +0,0 @@
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

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

View file

@ -1,53 +0,0 @@
{
"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

@ -1,20 +0,0 @@
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

@ -1,38 +0,0 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
// Use require for postgres to avoid ESM/CommonJS interop issues
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

@ -1,29 +0,0 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection } from './connection';
import 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

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

View file

@ -1,15 +0,0 @@
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

@ -1,14 +0,0 @@
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

@ -1,13 +0,0 @@
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

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

View file

@ -1,40 +0,0 @@
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

@ -1,37 +0,0 @@
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

@ -1,40 +0,0 @@
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

@ -1,10 +0,0 @@
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

@ -1,64 +0,0 @@
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

@ -1,29 +0,0 @@
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

@ -1,40 +0,0 @@
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

@ -1,10 +0,0 @@
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

@ -1,63 +0,0 @@
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

@ -1,26 +0,0 @@
{
"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

@ -1,25 +0,0 @@
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

@ -1,95 +0,0 @@
# 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

@ -1,81 +0,0 @@
{
"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

@ -1,46 +0,0 @@
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

@ -1,26 +0,0 @@
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

@ -1,22 +0,0 @@
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

@ -1,192 +0,0 @@
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

@ -1,249 +0,0 @@
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

@ -1,23 +0,0 @@
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

@ -1,272 +0,0 @@
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

@ -1,196 +0,0 @@
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

@ -1,121 +0,0 @@
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

@ -1,129 +0,0 @@
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

@ -1,233 +0,0 @@
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

@ -1,127 +0,0 @@
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

@ -1,212 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

View file

@ -1,31 +0,0 @@
{
"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.

Before

Width:  |  Height:  |  Size: 228 KiB

View file

@ -1,12 +0,0 @@
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

@ -1,46 +0,0 @@
// 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

@ -1,532 +0,0 @@
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

@ -1,25 +0,0 @@
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

@ -1,9 +0,0 @@
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

@ -1,29 +0,0 @@
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

@ -1,58 +0,0 @@
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

@ -1,74 +0,0 @@
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

@ -1,26 +0,0 @@
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

@ -1,104 +0,0 @@
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

@ -1,21 +0,0 @@
{
"cli": {
"version": ">= 16.23.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}

View file

@ -1,15 +0,0 @@
/* 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

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

View file

@ -1,177 +0,0 @@
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

@ -1,43 +0,0 @@
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

@ -1,10 +0,0 @@
// 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

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

View file

@ -1,68 +0,0 @@
{
"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

@ -1,10 +0,0 @@
module.exports = {
printWidth: 100,
tabWidth: 2,
singleQuote: true,
bracketSameLine: true,
trailingComma: 'es5',
plugins: [require.resolve('prettier-plugin-tailwindcss')],
tailwindAttributes: ['className'],
};

View file

@ -1,312 +0,0 @@
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

@ -1,14 +0,0 @@
/** @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

@ -1,11 +0,0 @@
{
"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

@ -1,14 +0,0 @@
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

@ -1,11 +0,0 @@
// 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

@ -1,47 +0,0 @@
{
"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

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

View file

@ -1,172 +0,0 @@
/**
* 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

@ -1,118 +0,0 @@
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

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

View file

@ -1,177 +0,0 @@
<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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +0,0 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
},
};
export default config;

View file

@ -1,43 +0,0 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
port: 5182,
strictPort: true,
},
ssr: {
noExternal: [
'@manacore/shared-icons',
'@manacore/shared-ui',
'@manacore/shared-tailwind',
'@manacore/shared-theme',
'@manacore/shared-theme-ui',
'@manacore/shared-feedback-ui',
'@manacore/shared-feedback-service',
'@manacore/shared-feedback-types',
'@manacore/shared-auth',
'@manacore/shared-auth-ui',
'@manacore/shared-branding',
'@manacore/shared-subscription-ui',
],
},
optimizeDeps: {
exclude: [
'@manacore/shared-icons',
'@manacore/shared-ui',
'@manacore/shared-tailwind',
'@manacore/shared-theme',
'@manacore/shared-theme-ui',
'@manacore/shared-feedback-ui',
'@manacore/shared-feedback-service',
'@manacore/shared-feedback-types',
'@manacore/shared-auth',
'@manacore/shared-auth-ui',
'@manacore/shared-branding',
'@manacore/shared-subscription-ui',
],
},
});

View file

@ -1,16 +0,0 @@
{
"name": "moodlit",
"version": "1.0.0",
"private": true,
"description": "Moodlit - Ambient lighting & mood app",
"scripts": {
"dev": "turbo run dev",
"dev:backend": "pnpm --filter @moodlit/backend dev",
"dev:web": "pnpm --filter @moodlit/web dev",
"dev:mobile": "pnpm --filter @moodlit/mobile dev",
"dev:landing": "pnpm --filter @moodlit/landing dev",
"db:push": "pnpm --filter @moodlit/backend db:push",
"db:studio": "pnpm --filter @moodlit/backend db:studio"
},
"packageManager": "pnpm@9.15.0"
}

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

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

View file

@ -6,14 +6,14 @@ export default defineConfig({
integrations: [tailwind()],
output: 'static',
build: {
inlineStylesheets: 'auto'
inlineStylesheets: 'auto',
},
vite: {
resolve: {
alias: {
'@components': '/src/components',
'@layouts': '/src/layouts'
}
}
}
'@layouts': '/src/layouts',
},
},
},
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more