mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
✨ feat(contacts): add import/export with Google Contacts integration
- Add vCard/CSV file import with duplicate detection and merge options - Add Google Contacts OAuth2 integration for importing from Google - Add vCard/CSV export with format selection and filtering options - Add connected_accounts table for OAuth token storage - Add FileUploader, ImportPreview, GoogleImport components - Add ExportModal with format selection (vCard/CSV) - Add i18n translations for import/export (DE/EN)
This commit is contained in:
parent
32eef4005d
commit
79b629b820
98 changed files with 5302 additions and 6 deletions
|
|
@ -39,6 +39,7 @@ For comprehensive guidelines on code patterns and conventions, see the `.claude/
|
|||
| **zitare** | Daily inspiration quotes | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
|
||||
| **presi** | Presentation tool | NestJS backend, Expo mobile, SvelteKit web |
|
||||
| **contacts** | Contact management | NestJS backend, SvelteKit web |
|
||||
| **mail** | Email client with AI | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
|
||||
|
||||
### Archived Projects (`apps-archived/`)
|
||||
|
||||
|
|
@ -69,6 +70,7 @@ pnpm run chat:dev
|
|||
pnpm run zitare:dev
|
||||
pnpm run presi:dev
|
||||
pnpm run contacts:dev
|
||||
pnpm run mail:dev
|
||||
|
||||
# Start specific app within project
|
||||
pnpm run dev:chat:mobile # Just mobile app
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
| App | Port | URL |
|
||||
|-----|------|-----|
|
||||
| Backend | 3017 | http://localhost:3017 |
|
||||
| Web App | 5186 | http://localhost:5186 |
|
||||
| Web App | 5187 | http://localhost:5187 |
|
||||
| Landing Page | 4323 | http://localhost:4323 |
|
||||
|
||||
## Project Structure
|
||||
|
|
@ -72,7 +72,7 @@ pnpm clock:dev # Run all clock apps
|
|||
|
||||
# Einzelne Apps starten
|
||||
pnpm dev:clock:backend # Start backend server (port 3017)
|
||||
pnpm dev:clock:web # Start web app (port 5186)
|
||||
pnpm dev:clock:web # Start web app (port 5187)
|
||||
pnpm dev:clock:landing # Start landing page (port 4323)
|
||||
pnpm dev:clock:app # Start web + backend together
|
||||
|
||||
|
|
@ -340,7 +340,7 @@ pnpm dev:clock:landing # Terminal 3 (optional)
|
|||
|
||||
### 3. URLs öffnen
|
||||
|
||||
- Web App: http://localhost:5186
|
||||
- Web App: http://localhost:5187
|
||||
- Landing: http://localhost:4323
|
||||
- API Health: http://localhost:3017/api/v1/health
|
||||
|
||||
|
|
@ -379,7 +379,7 @@ curl -X POST http://localhost:3017/api/v1/timers/$TIMER_ID/start \
|
|||
|
||||
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 3017, Web auf 5186, Landing auf 4323
|
||||
3. **Port**: Backend läuft auf Port 3017, Web auf 5187, Landing auf 4323
|
||||
4. **i18n**: 5 Sprachen unterstützt (DE, EN, FR, ES, IT)
|
||||
5. **Theme**: Amber/Orange (#f59e0b) als Primärfarbe
|
||||
6. **Local Features**: Stoppuhr und Pomodoro laufen lokal ohne Backend-Sync
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { defineConfig } from 'vite';
|
|||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
port: 5186,
|
||||
port: 5187,
|
||||
strictPort: true,
|
||||
},
|
||||
ssr: {
|
||||
|
|
|
|||
249
apps/todo/CLAUDE.md
Normal file
249
apps/todo/CLAUDE.md
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
# Todo Project Guide
|
||||
|
||||
## Overview
|
||||
|
||||
**Todo** is a full-featured task management application for the ManaCore ecosystem. It supports projects, tasks with subtasks, labels, recurring tasks, reminders, and calendar integration.
|
||||
|
||||
| App | Port | URL |
|
||||
|-----|------|-----|
|
||||
| Backend | 3018 | http://localhost:3018 |
|
||||
| Web App | 5188 | http://localhost:5188 |
|
||||
| Landing Page | 4323 | http://localhost:4323 |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/todo/
|
||||
├── apps/
|
||||
│ ├── backend/ # NestJS API server (@todo/backend)
|
||||
│ ├── web/ # SvelteKit web application (@todo/web)
|
||||
│ └── landing/ # Astro marketing landing page (@todo/landing)
|
||||
├── packages/
|
||||
│ └── shared/ # Shared types, utils, constants (@todo/shared)
|
||||
├── package.json
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Root Level (from monorepo root)
|
||||
|
||||
```bash
|
||||
# All apps
|
||||
pnpm todo:dev # Run all todo apps
|
||||
|
||||
# Individual apps
|
||||
pnpm dev:todo:backend # Start backend server (port 3018)
|
||||
pnpm dev:todo:web # Start web app (port 5188)
|
||||
pnpm dev:todo:landing # Start landing page (port 4323)
|
||||
pnpm dev:todo:app # Start web + backend together
|
||||
|
||||
# Database
|
||||
pnpm todo:db:push # Push schema to database
|
||||
pnpm todo:db:studio # Open Drizzle Studio
|
||||
pnpm todo:db:seed # Seed initial data
|
||||
```
|
||||
|
||||
### Backend (apps/todo/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
|
||||
pnpm db:seed # Seed initial data
|
||||
```
|
||||
|
||||
### Web App (apps/todo/apps/web)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server
|
||||
pnpm build # Build for production
|
||||
pnpm preview # Preview production build
|
||||
```
|
||||
|
||||
### Landing Page (apps/todo/apps/landing)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server (port 4323)
|
||||
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 mode), Tailwind CSS 4 |
|
||||
| **Landing** | Astro 5.x, Tailwind CSS |
|
||||
| **Auth** | Mana Core Auth (JWT) |
|
||||
| **i18n** | svelte-i18n (DE, EN) |
|
||||
| **Dates** | date-fns |
|
||||
|
||||
## Core Features
|
||||
|
||||
1. **Projects** - Organize tasks into color-coded projects
|
||||
2. **Tasks** - Full CRUD with priority, due dates, and status
|
||||
3. **Subtasks** - Checklist items within tasks
|
||||
4. **Labels** - Tag tasks with colored labels
|
||||
5. **Recurring Tasks** - Daily, weekly, monthly (RFC 5545 RRULE)
|
||||
6. **Reminders** - Push and email notifications
|
||||
7. **Calendar Integration** - Sync tasks with Calendar app
|
||||
8. **Quick Add** - Natural language task creation
|
||||
|
||||
## Views
|
||||
|
||||
| View | Route | Description |
|
||||
|------|-------|-------------|
|
||||
| **Inbox** | `/` (default) | Tasks without a project |
|
||||
| **Today** | `/today` | Due today + overdue |
|
||||
| **Upcoming** | `/upcoming` | Next 7 days, grouped by date |
|
||||
| **Project** | `/project/[id]` | Tasks in specific project |
|
||||
| **Label** | `/label/[id]` | Tasks with specific label |
|
||||
| **Completed** | `/completed` | Completed tasks archive |
|
||||
| **Search** | `/search` | Full-text search |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Projects
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/projects` | GET | List user's projects |
|
||||
| `/api/v1/projects` | POST | Create project |
|
||||
| `/api/v1/projects/:id` | GET | Get project details |
|
||||
| `/api/v1/projects/:id` | PUT | Update project |
|
||||
| `/api/v1/projects/:id` | DELETE | Delete project |
|
||||
| `/api/v1/projects/:id/archive` | POST | Archive project |
|
||||
| `/api/v1/projects/reorder` | PUT | Reorder projects |
|
||||
|
||||
### Tasks
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/tasks` | GET | Query tasks (filters) |
|
||||
| `/api/v1/tasks` | POST | Create task |
|
||||
| `/api/v1/tasks/:id` | GET | Get task details |
|
||||
| `/api/v1/tasks/:id` | PUT | Update task |
|
||||
| `/api/v1/tasks/:id` | DELETE | Delete task |
|
||||
| `/api/v1/tasks/:id/complete` | POST | Mark complete |
|
||||
| `/api/v1/tasks/:id/uncomplete` | POST | Mark incomplete |
|
||||
| `/api/v1/tasks/:id/move` | POST | Move to project |
|
||||
| `/api/v1/tasks/:id/labels` | PUT | Update labels |
|
||||
| `/api/v1/tasks/inbox` | GET | Inbox tasks |
|
||||
| `/api/v1/tasks/today` | GET | Today's tasks |
|
||||
| `/api/v1/tasks/upcoming` | GET | Upcoming tasks |
|
||||
| `/api/v1/tasks/reorder` | PUT | Reorder tasks |
|
||||
|
||||
### Labels
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/labels` | GET | List labels |
|
||||
| `/api/v1/labels` | POST | Create label |
|
||||
| `/api/v1/labels/:id` | PUT | Update label |
|
||||
| `/api/v1/labels/:id` | DELETE | Delete label |
|
||||
|
||||
### Reminders
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/tasks/:taskId/reminders` | GET | List reminders |
|
||||
| `/api/v1/tasks/:taskId/reminders` | POST | Add reminder |
|
||||
| `/api/v1/reminders/:id` | DELETE | Delete reminder |
|
||||
|
||||
## Database Schema
|
||||
|
||||
### projects
|
||||
- `id` (UUID) - Primary key
|
||||
- `user_id` (UUID) - Owner
|
||||
- `name` (VARCHAR) - Project name
|
||||
- `color` (VARCHAR) - Hex color
|
||||
- `icon` (VARCHAR) - Icon name
|
||||
- `order` (INTEGER) - Sort order
|
||||
- `is_archived` (BOOLEAN) - Archive flag
|
||||
- `is_default` (BOOLEAN) - Inbox project
|
||||
|
||||
### tasks
|
||||
- `id` (UUID) - Primary key
|
||||
- `project_id` (UUID) - FK to projects (nullable = Inbox)
|
||||
- `user_id` (UUID) - Owner
|
||||
- `title` (VARCHAR) - Task title
|
||||
- `description` (TEXT) - Description
|
||||
- `due_date` (TIMESTAMP) - Due date
|
||||
- `priority` (VARCHAR) - low/medium/high/urgent
|
||||
- `is_completed` (BOOLEAN) - Completion flag
|
||||
- `order` (INTEGER) - Sort order
|
||||
- `recurrence_rule` (VARCHAR) - RFC 5545 RRULE
|
||||
- `subtasks` (JSONB) - Subtask array
|
||||
- `metadata` (JSONB) - Extra data
|
||||
|
||||
### labels
|
||||
- `id` (UUID) - Primary key
|
||||
- `user_id` (UUID) - Owner
|
||||
- `name` (VARCHAR) - Label name
|
||||
- `color` (VARCHAR) - Hex color
|
||||
|
||||
### task_labels
|
||||
- `task_id` (UUID) - FK to tasks
|
||||
- `label_id` (UUID) - FK to labels
|
||||
|
||||
### reminders
|
||||
- `id` (UUID) - Primary key
|
||||
- `task_id` (UUID) - FK to tasks
|
||||
- `minutes_before` (INTEGER) - Offset
|
||||
- `type` (VARCHAR) - push/email/both
|
||||
- `status` (VARCHAR) - pending/sent/failed
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend (.env)
|
||||
|
||||
```env
|
||||
NODE_ENV=development
|
||||
PORT=3018
|
||||
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/todo
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:5186,http://localhost:8081
|
||||
```
|
||||
|
||||
### Web (.env)
|
||||
|
||||
```env
|
||||
PUBLIC_BACKEND_URL=http://localhost:3018
|
||||
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
## Quick Add Syntax
|
||||
|
||||
Natural language task creation:
|
||||
|
||||
```
|
||||
"Meeting morgen um 14 Uhr !hoch @Arbeit #wichtig"
|
||||
```
|
||||
|
||||
Recognized patterns:
|
||||
- **Date**: heute, morgen, nächsten Montag, 15.12.
|
||||
- **Time**: um 14 Uhr, 14:00
|
||||
- **Priority**: !hoch, !niedrig, !dringend, !!!
|
||||
- **Project**: @Projektname
|
||||
- **Labels**: #label1 #label2
|
||||
- **Recurrence**: jeden Tag, wöchentlich, monatlich
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
- **TypeScript**: Strict typing with interfaces
|
||||
- **Web**: Svelte 5 runes mode (`$state`, `$derived`, `$effect`)
|
||||
- **Styling**: Tailwind CSS with CSS variables
|
||||
- **Formatting**: Prettier with project config
|
||||
- **i18n**: All UI text in locale files
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Authentication**: Uses Mana Core Auth (JWT in Authorization header)
|
||||
2. **Database**: PostgreSQL with Drizzle ORM (port 5432)
|
||||
3. **Ports**: Backend=3018, Web=5188, Landing=4323
|
||||
4. **Recurrence**: Uses RFC 5545 RRULE format
|
||||
5. **Calendar**: Tasks can sync bidirectionally with Calendar app
|
||||
13
apps/todo/apps/backend/drizzle.config.ts
Normal file
13
apps/todo/apps/backend/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { defineConfig } from 'drizzle-kit';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/db/schema/index.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
||||
8
apps/todo/apps/backend/nest-cli.json
Normal file
8
apps/todo/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
42
apps/todo/apps/backend/package.json
Normal file
42
apps/todo/apps/backend/package.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"name": "@todo/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Todo Backend API",
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "tsx src/db/seed.ts",
|
||||
"db:generate": "drizzle-kit generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@todo/shared": "workspace:*",
|
||||
"@manacore/shared-nestjs-auth": "workspace:*",
|
||||
"@nestjs/common": "^10.4.9",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.9",
|
||||
"@nestjs/platform-express": "^10.4.9",
|
||||
"@nestjs/schedule": "^4.1.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"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.1",
|
||||
"@types/node": "^22.15.21",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
26
apps/todo/apps/backend/src/app.module.ts
Normal file
26
apps/todo/apps/backend/src/app.module.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { DatabaseModule } from './db/database.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { ProjectModule } from './project/project.module';
|
||||
import { TaskModule } from './task/task.module';
|
||||
import { LabelModule } from './label/label.module';
|
||||
import { ReminderModule } from './reminder/reminder.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
DatabaseModule,
|
||||
HealthModule,
|
||||
ProjectModule,
|
||||
TaskModule,
|
||||
LabelModule,
|
||||
ReminderModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
38
apps/todo/apps/backend/src/db/connection.ts
Normal file
38
apps/todo/apps/backend/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema';
|
||||
|
||||
let db: ReturnType<typeof drizzle<typeof schema>> | null = null;
|
||||
let connection: ReturnType<typeof postgres> | null = null;
|
||||
|
||||
export function getDb(connectionString?: string) {
|
||||
if (db) return db;
|
||||
|
||||
const url = connectionString || process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
throw new Error('DATABASE_URL is not defined');
|
||||
}
|
||||
|
||||
connection = postgres(url, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
|
||||
db = drizzle(connection, { schema });
|
||||
return db;
|
||||
}
|
||||
|
||||
export function getConnection() {
|
||||
return connection;
|
||||
}
|
||||
|
||||
export async function closeConnection() {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
21
apps/todo/apps/backend/src/db/database.module.ts
Normal file
21
apps/todo/apps/backend/src/db/database.module.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb } from './connection';
|
||||
|
||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||
return getDb(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
5
apps/todo/apps/backend/src/db/schema/index.ts
Normal file
5
apps/todo/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from './projects.schema';
|
||||
export * from './tasks.schema';
|
||||
export * from './labels.schema';
|
||||
export * from './task-labels.schema';
|
||||
export * from './reminders.schema';
|
||||
19
apps/todo/apps/backend/src/db/schema/labels.schema.ts
Normal file
19
apps/todo/apps/backend/src/db/schema/labels.schema.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { pgTable, uuid, timestamp, varchar, index } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const labels = pgTable(
|
||||
'labels',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
name: varchar('name', { length: 100 }).notNull(),
|
||||
color: varchar('color', { length: 7 }).default('#6B7280'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdx: index('labels_user_idx').on(table.userId),
|
||||
})
|
||||
);
|
||||
|
||||
export type Label = typeof labels.$inferSelect;
|
||||
export type NewLabel = typeof labels.$inferInsert;
|
||||
43
apps/todo/apps/backend/src/db/schema/projects.schema.ts
Normal file
43
apps/todo/apps/backend/src/db/schema/projects.schema.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
timestamp,
|
||||
varchar,
|
||||
text,
|
||||
boolean,
|
||||
integer,
|
||||
jsonb,
|
||||
index,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
export interface ProjectSettings {
|
||||
defaultView?: 'list' | 'board';
|
||||
showCompletedTasks?: boolean;
|
||||
sortBy?: 'dueDate' | 'priority' | 'createdAt' | 'order';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export const projects = pgTable(
|
||||
'projects',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
description: text('description'),
|
||||
color: varchar('color', { length: 7 }).default('#3B82F6'),
|
||||
icon: varchar('icon', { length: 50 }),
|
||||
order: integer('order').default(0),
|
||||
isArchived: boolean('is_archived').default(false),
|
||||
isDefault: boolean('is_default').default(false),
|
||||
settings: jsonb('settings').$type<ProjectSettings>(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdx: index('projects_user_idx').on(table.userId),
|
||||
orderIdx: index('projects_order_idx').on(table.userId, table.order),
|
||||
})
|
||||
);
|
||||
|
||||
export type Project = typeof projects.$inferSelect;
|
||||
export type NewProject = typeof projects.$inferInsert;
|
||||
37
apps/todo/apps/backend/src/db/schema/reminders.schema.ts
Normal file
37
apps/todo/apps/backend/src/db/schema/reminders.schema.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { pgTable, uuid, timestamp, varchar, integer, index } from 'drizzle-orm/pg-core';
|
||||
import { tasks } from './tasks.schema';
|
||||
|
||||
export type ReminderType = 'push' | 'email' | 'both';
|
||||
export type ReminderStatus = 'pending' | 'sent' | 'failed' | 'cancelled';
|
||||
|
||||
export const reminders = pgTable(
|
||||
'reminders',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
taskId: uuid('task_id')
|
||||
.notNull()
|
||||
.references(() => tasks.id, { onDelete: 'cascade' }),
|
||||
userId: uuid('user_id').notNull(),
|
||||
|
||||
// Timing
|
||||
minutesBefore: integer('minutes_before').notNull(),
|
||||
reminderTime: timestamp('reminder_time', { withTimezone: true }).notNull(),
|
||||
|
||||
// Type
|
||||
type: varchar('type', { length: 20 }).default('push').$type<ReminderType>(),
|
||||
|
||||
// Status
|
||||
status: varchar('status', { length: 20 }).default('pending').$type<ReminderStatus>(),
|
||||
sentAt: timestamp('sent_at', { withTimezone: true }),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
taskIdx: index('reminders_task_idx').on(table.taskId),
|
||||
userIdx: index('reminders_user_idx').on(table.userId),
|
||||
pendingIdx: index('reminders_pending_idx').on(table.status, table.reminderTime),
|
||||
})
|
||||
);
|
||||
|
||||
export type Reminder = typeof reminders.$inferSelect;
|
||||
export type NewReminder = typeof reminders.$inferInsert;
|
||||
23
apps/todo/apps/backend/src/db/schema/task-labels.schema.ts
Normal file
23
apps/todo/apps/backend/src/db/schema/task-labels.schema.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { pgTable, uuid, primaryKey, index } from 'drizzle-orm/pg-core';
|
||||
import { tasks } from './tasks.schema';
|
||||
import { labels } from './labels.schema';
|
||||
|
||||
export const taskLabels = pgTable(
|
||||
'task_labels',
|
||||
{
|
||||
taskId: uuid('task_id')
|
||||
.notNull()
|
||||
.references(() => tasks.id, { onDelete: 'cascade' }),
|
||||
labelId: uuid('label_id')
|
||||
.notNull()
|
||||
.references(() => labels.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({ columns: [table.taskId, table.labelId] }),
|
||||
taskIdx: index('task_labels_task_idx').on(table.taskId),
|
||||
labelIdx: index('task_labels_label_idx').on(table.labelId),
|
||||
})
|
||||
);
|
||||
|
||||
export type TaskLabel = typeof taskLabels.$inferSelect;
|
||||
export type NewTaskLabel = typeof taskLabels.$inferInsert;
|
||||
84
apps/todo/apps/backend/src/db/schema/tasks.schema.ts
Normal file
84
apps/todo/apps/backend/src/db/schema/tasks.schema.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
timestamp,
|
||||
varchar,
|
||||
text,
|
||||
boolean,
|
||||
integer,
|
||||
jsonb,
|
||||
index,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { projects } from './projects.schema';
|
||||
|
||||
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
|
||||
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
|
||||
|
||||
export interface Subtask {
|
||||
id: string;
|
||||
title: string;
|
||||
isCompleted: boolean;
|
||||
completedAt?: string | null;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface TaskMetadata {
|
||||
notes?: string;
|
||||
attachments?: string[];
|
||||
linkedCalendarEventId?: string | null;
|
||||
}
|
||||
|
||||
export const tasks = pgTable(
|
||||
'tasks',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
|
||||
userId: uuid('user_id').notNull(),
|
||||
parentTaskId: uuid('parent_task_id'),
|
||||
|
||||
// Content
|
||||
title: varchar('title', { length: 500 }).notNull(),
|
||||
description: text('description'),
|
||||
|
||||
// Scheduling
|
||||
dueDate: timestamp('due_date', { withTimezone: true }),
|
||||
dueTime: varchar('due_time', { length: 5 }),
|
||||
startDate: timestamp('start_date', { withTimezone: true }),
|
||||
|
||||
// Priority & Status
|
||||
priority: varchar('priority', { length: 10 }).default('medium').$type<TaskPriority>(),
|
||||
status: varchar('status', { length: 20 }).default('pending').$type<TaskStatus>(),
|
||||
|
||||
// Completion
|
||||
isCompleted: boolean('is_completed').default(false),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
|
||||
// Ordering
|
||||
order: integer('order').default(0),
|
||||
|
||||
// Recurrence (RFC 5545 RRULE format)
|
||||
recurrenceRule: varchar('recurrence_rule', { length: 500 }),
|
||||
recurrenceEndDate: timestamp('recurrence_end_date', { withTimezone: true }),
|
||||
lastOccurrence: timestamp('last_occurrence', { withTimezone: true }),
|
||||
|
||||
// Subtasks stored as JSONB
|
||||
subtasks: jsonb('subtasks').$type<Subtask[]>(),
|
||||
|
||||
// Metadata
|
||||
metadata: jsonb('metadata').$type<TaskMetadata>(),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
projectIdx: index('tasks_project_idx').on(table.projectId),
|
||||
userIdx: index('tasks_user_idx').on(table.userId),
|
||||
dueDateIdx: index('tasks_due_date_idx').on(table.dueDate),
|
||||
statusIdx: index('tasks_status_idx').on(table.isCompleted, table.status),
|
||||
parentIdx: index('tasks_parent_idx').on(table.parentTaskId),
|
||||
orderIdx: index('tasks_order_idx').on(table.projectId, table.order),
|
||||
})
|
||||
);
|
||||
|
||||
export type Task = typeof tasks.$inferSelect;
|
||||
export type NewTask = typeof tasks.$inferInsert;
|
||||
13
apps/todo/apps/backend/src/health/health.controller.ts
Normal file
13
apps/todo/apps/backend/src/health/health.controller.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'todo-backend',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/todo/apps/backend/src/health/health.module.ts
Normal file
7
apps/todo/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
12
apps/todo/apps/backend/src/label/dto/create-label.dto.ts
Normal file
12
apps/todo/apps/backend/src/label/dto/create-label.dto.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateLabelDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(7)
|
||||
color?: string;
|
||||
}
|
||||
2
apps/todo/apps/backend/src/label/dto/index.ts
Normal file
2
apps/todo/apps/backend/src/label/dto/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './create-label.dto';
|
||||
export * from './update-label.dto';
|
||||
13
apps/todo/apps/backend/src/label/dto/update-label.dto.ts
Normal file
13
apps/todo/apps/backend/src/label/dto/update-label.dto.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdateLabelDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(7)
|
||||
color?: string;
|
||||
}
|
||||
44
apps/todo/apps/backend/src/label/label.controller.ts
Normal file
44
apps/todo/apps/backend/src/label/label.controller.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { LabelService } from './label.service';
|
||||
import { CreateLabelDto, UpdateLabelDto } from './dto';
|
||||
|
||||
@Controller('labels')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class LabelController {
|
||||
constructor(private readonly labelService: LabelService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
const labels = await this.labelService.findAll(user.userId);
|
||||
return { labels };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
const label = await this.labelService.findByIdOrThrow(id, user.userId);
|
||||
return { label };
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateLabelDto) {
|
||||
const label = await this.labelService.create(user.userId, dto);
|
||||
return { label };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateLabelDto
|
||||
) {
|
||||
const label = await this.labelService.update(id, user.userId, dto);
|
||||
return { label };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.labelService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
apps/todo/apps/backend/src/label/label.module.ts
Normal file
10
apps/todo/apps/backend/src/label/label.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { LabelController } from './label.controller';
|
||||
import { LabelService } from './label.service';
|
||||
|
||||
@Module({
|
||||
controllers: [LabelController],
|
||||
providers: [LabelService],
|
||||
exports: [LabelService],
|
||||
})
|
||||
export class LabelModule {}
|
||||
64
apps/todo/apps/backend/src/label/label.service.ts
Normal file
64
apps/todo/apps/backend/src/label/label.service.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, asc } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { labels, type Label, type NewLabel } from '../db/schema';
|
||||
import { CreateLabelDto, UpdateLabelDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class LabelService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findAll(userId: string): Promise<Label[]> {
|
||||
return this.db.query.labels.findMany({
|
||||
where: eq(labels.userId, userId),
|
||||
orderBy: [asc(labels.name)],
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Label | null> {
|
||||
const result = await this.db.query.labels.findFirst({
|
||||
where: and(eq(labels.id, id), eq(labels.userId, userId)),
|
||||
});
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
async findByIdOrThrow(id: string, userId: string): Promise<Label> {
|
||||
const label = await this.findById(id, userId);
|
||||
if (!label) {
|
||||
throw new NotFoundException(`Label with id ${id} not found`);
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
async create(userId: string, dto: CreateLabelDto): Promise<Label> {
|
||||
const newLabel: NewLabel = {
|
||||
userId,
|
||||
name: dto.name,
|
||||
color: dto.color ?? '#6B7280',
|
||||
};
|
||||
|
||||
const [created] = await this.db.insert(labels).values(newLabel).returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, dto: UpdateLabelDto): Promise<Label> {
|
||||
await this.findByIdOrThrow(id, userId);
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(labels)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(labels.id, id), eq(labels.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
await this.findByIdOrThrow(id, userId);
|
||||
await this.db.delete(labels).where(and(eq(labels.id, id), eq(labels.userId, userId)));
|
||||
}
|
||||
}
|
||||
65
apps/todo/apps/backend/src/main.ts
Normal file
65
apps/todo/apps/backend/src/main.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Enable CORS for all platforms
|
||||
app.enableCors({
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no origin (mobile apps, curl, etc.)
|
||||
if (!origin) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedOrigins = process.env.CORS_ORIGINS?.split(',') || [
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5186',
|
||||
'http://localhost:8081',
|
||||
'http://localhost:19006',
|
||||
];
|
||||
|
||||
// Allow all localhost ports in development
|
||||
if (process.env.NODE_ENV === 'development' && origin.includes('localhost')) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
logger.warn(`Blocked request from origin: ${origin}`);
|
||||
callback(new Error('Not allowed by CORS'), false);
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
});
|
||||
|
||||
// Global validation pipe
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// API prefix
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
const port = process.env.PORT || 3017;
|
||||
await app.listen(port);
|
||||
|
||||
logger.log(`Todo API is running on: http://localhost:${port}`);
|
||||
logger.log(`Health check: http://localhost:${port}/api/v1/health`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
30
apps/todo/apps/backend/src/project/dto/create-project.dto.ts
Normal file
30
apps/todo/apps/backend/src/project/dto/create-project.dto.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { IsString, IsOptional, IsBoolean, MaxLength, IsObject } from 'class-validator';
|
||||
import type { ProjectSettings } from '../../db/schema/projects.schema';
|
||||
|
||||
export class CreateProjectDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(7)
|
||||
color?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
icon?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
settings?: ProjectSettings;
|
||||
}
|
||||
3
apps/todo/apps/backend/src/project/dto/index.ts
Normal file
3
apps/todo/apps/backend/src/project/dto/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './create-project.dto';
|
||||
export * from './update-project.dto';
|
||||
export * from './reorder-projects.dto';
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class ReorderProjectsDto {
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
projectIds: string[];
|
||||
}
|
||||
35
apps/todo/apps/backend/src/project/dto/update-project.dto.ts
Normal file
35
apps/todo/apps/backend/src/project/dto/update-project.dto.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { IsString, IsOptional, IsBoolean, MaxLength, IsObject } from 'class-validator';
|
||||
import type { ProjectSettings } from '../../db/schema/projects.schema';
|
||||
|
||||
export class UpdateProjectDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(7)
|
||||
color?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
icon?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isArchived?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
settings?: ProjectSettings | null;
|
||||
}
|
||||
64
apps/todo/apps/backend/src/project/project.controller.ts
Normal file
64
apps/todo/apps/backend/src/project/project.controller.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { ProjectService } from './project.service';
|
||||
import { CreateProjectDto, UpdateProjectDto, ReorderProjectsDto } from './dto';
|
||||
|
||||
@Controller('projects')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ProjectController {
|
||||
constructor(private readonly projectService: ProjectService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
// Ensure user has at least an inbox project
|
||||
await this.projectService.getOrCreateDefaultProject(user.userId);
|
||||
const projects = await this.projectService.findAll(user.userId);
|
||||
return { projects };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
const project = await this.projectService.findByIdOrThrow(id, user.userId);
|
||||
return { project };
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateProjectDto) {
|
||||
const project = await this.projectService.create(user.userId, dto);
|
||||
return { project };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateProjectDto
|
||||
) {
|
||||
const project = await this.projectService.update(id, user.userId, dto);
|
||||
return { project };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.projectService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/archive')
|
||||
async archive(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
const project = await this.projectService.archive(id, user.userId);
|
||||
return { project };
|
||||
}
|
||||
|
||||
@Post(':id/unarchive')
|
||||
async unarchive(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
const project = await this.projectService.unarchive(id, user.userId);
|
||||
return { project };
|
||||
}
|
||||
|
||||
@Put('reorder')
|
||||
async reorder(@CurrentUser() user: CurrentUserData, @Body() dto: ReorderProjectsDto) {
|
||||
const projects = await this.projectService.reorder(user.userId, dto.projectIds);
|
||||
return { projects };
|
||||
}
|
||||
}
|
||||
10
apps/todo/apps/backend/src/project/project.module.ts
Normal file
10
apps/todo/apps/backend/src/project/project.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ProjectController } from './project.controller';
|
||||
import { ProjectService } from './project.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ProjectController],
|
||||
providers: [ProjectService],
|
||||
exports: [ProjectService],
|
||||
})
|
||||
export class ProjectModule {}
|
||||
140
apps/todo/apps/backend/src/project/project.service.ts
Normal file
140
apps/todo/apps/backend/src/project/project.service.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, asc } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { projects, type Project, type NewProject } from '../db/schema';
|
||||
import { CreateProjectDto, UpdateProjectDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class ProjectService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findAll(userId: string): Promise<Project[]> {
|
||||
return this.db.query.projects.findMany({
|
||||
where: eq(projects.userId, userId),
|
||||
orderBy: [asc(projects.order), asc(projects.createdAt)],
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Project | null> {
|
||||
const result = await this.db.query.projects.findFirst({
|
||||
where: and(eq(projects.id, id), eq(projects.userId, userId)),
|
||||
});
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
async findByIdOrThrow(id: string, userId: string): Promise<Project> {
|
||||
const project = await this.findById(id, userId);
|
||||
if (!project) {
|
||||
throw new NotFoundException(`Project with id ${id} not found`);
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
async create(userId: string, dto: CreateProjectDto): Promise<Project> {
|
||||
// Get the highest order value
|
||||
const existingProjects = await this.findAll(userId);
|
||||
const maxOrder = existingProjects.reduce((max, p) => Math.max(max, p.order ?? 0), -1);
|
||||
|
||||
// If this is the first project, make it default
|
||||
const isDefault = dto.isDefault ?? existingProjects.length === 0;
|
||||
|
||||
// If this project is default, clear other defaults
|
||||
if (isDefault) {
|
||||
await this.clearDefaultProject(userId);
|
||||
}
|
||||
|
||||
const newProject: NewProject = {
|
||||
userId,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
color: dto.color ?? '#3B82F6',
|
||||
icon: dto.icon,
|
||||
order: maxOrder + 1,
|
||||
isDefault,
|
||||
settings: dto.settings,
|
||||
};
|
||||
|
||||
const [created] = await this.db.insert(projects).values(newProject).returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, dto: UpdateProjectDto): Promise<Project> {
|
||||
await this.findByIdOrThrow(id, userId);
|
||||
|
||||
// If setting as default, clear other defaults first
|
||||
if (dto.isDefault) {
|
||||
await this.clearDefaultProject(userId);
|
||||
}
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(projects)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(projects.id, id), eq(projects.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
const project = await this.findByIdOrThrow(id, userId);
|
||||
|
||||
// Don't allow deleting the default inbox project
|
||||
if (project.isDefault) {
|
||||
throw new NotFoundException('Cannot delete the default inbox project');
|
||||
}
|
||||
|
||||
await this.db.delete(projects).where(and(eq(projects.id, id), eq(projects.userId, userId)));
|
||||
}
|
||||
|
||||
async archive(id: string, userId: string): Promise<Project> {
|
||||
return this.update(id, userId, { isArchived: true });
|
||||
}
|
||||
|
||||
async unarchive(id: string, userId: string): Promise<Project> {
|
||||
return this.update(id, userId, { isArchived: false });
|
||||
}
|
||||
|
||||
async reorder(userId: string, projectIds: string[]): Promise<Project[]> {
|
||||
// Update order for each project
|
||||
const updates = projectIds.map((id, index) =>
|
||||
this.db
|
||||
.update(projects)
|
||||
.set({ order: index, updatedAt: new Date() })
|
||||
.where(and(eq(projects.id, id), eq(projects.userId, userId)))
|
||||
);
|
||||
|
||||
await Promise.all(updates);
|
||||
|
||||
return this.findAll(userId);
|
||||
}
|
||||
|
||||
async getOrCreateDefaultProject(userId: string): Promise<Project> {
|
||||
// Try to find existing default project
|
||||
const defaultProject = await this.db.query.projects.findFirst({
|
||||
where: and(eq(projects.userId, userId), eq(projects.isDefault, true)),
|
||||
});
|
||||
|
||||
if (defaultProject) {
|
||||
return defaultProject;
|
||||
}
|
||||
|
||||
// Create default inbox project
|
||||
return this.create(userId, {
|
||||
name: 'Inbox',
|
||||
color: '#6B7280',
|
||||
icon: 'inbox',
|
||||
isDefault: true,
|
||||
});
|
||||
}
|
||||
|
||||
private async clearDefaultProject(userId: string): Promise<void> {
|
||||
await this.db
|
||||
.update(projects)
|
||||
.set({ isDefault: false, updatedAt: new Date() })
|
||||
.where(and(eq(projects.userId, userId), eq(projects.isDefault, true)));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { IsNumber, IsOptional, IsEnum, Min } from 'class-validator';
|
||||
import type { ReminderType } from '../../db/schema/reminders.schema';
|
||||
|
||||
export class CreateReminderDto {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
minutesBefore: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['push', 'email', 'both'])
|
||||
type?: ReminderType;
|
||||
}
|
||||
1
apps/todo/apps/backend/src/reminder/dto/index.ts
Normal file
1
apps/todo/apps/backend/src/reminder/dto/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './create-reminder.dto';
|
||||
32
apps/todo/apps/backend/src/reminder/reminder.controller.ts
Normal file
32
apps/todo/apps/backend/src/reminder/reminder.controller.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { ReminderService } from './reminder.service';
|
||||
import { CreateReminderDto } from './dto';
|
||||
|
||||
@Controller()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ReminderController {
|
||||
constructor(private readonly reminderService: ReminderService) {}
|
||||
|
||||
@Get('tasks/:taskId/reminders')
|
||||
async findByTask(@CurrentUser() user: CurrentUserData, @Param('taskId') taskId: string) {
|
||||
const reminders = await this.reminderService.findByTask(taskId, user.userId);
|
||||
return { reminders };
|
||||
}
|
||||
|
||||
@Post('tasks/:taskId/reminders')
|
||||
async create(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('taskId') taskId: string,
|
||||
@Body() dto: CreateReminderDto
|
||||
) {
|
||||
const reminder = await this.reminderService.create(taskId, user.userId, dto);
|
||||
return { reminder };
|
||||
}
|
||||
|
||||
@Delete('reminders/:id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.reminderService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
12
apps/todo/apps/backend/src/reminder/reminder.module.ts
Normal file
12
apps/todo/apps/backend/src/reminder/reminder.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ReminderController } from './reminder.controller';
|
||||
import { ReminderService } from './reminder.service';
|
||||
import { TaskModule } from '../task/task.module';
|
||||
|
||||
@Module({
|
||||
imports: [TaskModule],
|
||||
controllers: [ReminderController],
|
||||
providers: [ReminderService],
|
||||
exports: [ReminderService],
|
||||
})
|
||||
export class ReminderModule {}
|
||||
75
apps/todo/apps/backend/src/reminder/reminder.service.ts
Normal file
75
apps/todo/apps/backend/src/reminder/reminder.service.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { eq, and, asc } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { reminders, type Reminder, type NewReminder } from '../db/schema';
|
||||
import { TaskService } from '../task/task.service';
|
||||
import { CreateReminderDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class ReminderService {
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private db: Database,
|
||||
private taskService: TaskService
|
||||
) {}
|
||||
|
||||
async findByTask(taskId: string, userId: string): Promise<Reminder[]> {
|
||||
// Verify task belongs to user
|
||||
await this.taskService.findByIdOrThrow(taskId, userId);
|
||||
|
||||
return this.db.query.reminders.findMany({
|
||||
where: and(eq(reminders.taskId, taskId), eq(reminders.userId, userId)),
|
||||
orderBy: [asc(reminders.minutesBefore)],
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Reminder | null> {
|
||||
const result = await this.db.query.reminders.findFirst({
|
||||
where: and(eq(reminders.id, id), eq(reminders.userId, userId)),
|
||||
});
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
async findByIdOrThrow(id: string, userId: string): Promise<Reminder> {
|
||||
const reminder = await this.findById(id, userId);
|
||||
if (!reminder) {
|
||||
throw new NotFoundException(`Reminder with id ${id} not found`);
|
||||
}
|
||||
return reminder;
|
||||
}
|
||||
|
||||
async create(taskId: string, userId: string, dto: CreateReminderDto): Promise<Reminder> {
|
||||
// Verify task belongs to user and get due date
|
||||
const task = await this.taskService.findByIdOrThrow(taskId, userId);
|
||||
|
||||
if (!task.dueDate) {
|
||||
throw new BadRequestException('Cannot create reminder for task without due date');
|
||||
}
|
||||
|
||||
// Calculate reminder time
|
||||
const dueDate = new Date(task.dueDate);
|
||||
const reminderTime = new Date(dueDate.getTime() - dto.minutesBefore * 60 * 1000);
|
||||
|
||||
const newReminder: NewReminder = {
|
||||
taskId,
|
||||
userId,
|
||||
minutesBefore: dto.minutesBefore,
|
||||
reminderTime,
|
||||
type: dto.type ?? 'push',
|
||||
};
|
||||
|
||||
const [created] = await this.db.insert(reminders).values(newReminder).returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
await this.findByIdOrThrow(id, userId);
|
||||
await this.db.delete(reminders).where(and(eq(reminders.id, id), eq(reminders.userId, userId)));
|
||||
}
|
||||
|
||||
async deleteByTask(taskId: string, userId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(reminders)
|
||||
.where(and(eq(reminders.taskId, taskId), eq(reminders.userId, userId)));
|
||||
}
|
||||
}
|
||||
67
apps/todo/apps/backend/src/task/dto/create-task.dto.ts
Normal file
67
apps/todo/apps/backend/src/task/dto/create-task.dto.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
IsEnum,
|
||||
IsArray,
|
||||
IsObject,
|
||||
MaxLength,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
import type { TaskPriority, Subtask, TaskMetadata } from '../../db/schema/tasks.schema';
|
||||
|
||||
export class CreateTaskDto {
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
title: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentTaskId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueDate?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5)
|
||||
dueTime?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['low', 'medium', 'high', 'urgent'])
|
||||
priority?: TaskPriority;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
recurrenceRule?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
recurrenceEndDate?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
subtasks?: Omit<Subtask, 'id'>[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
labelIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: TaskMetadata;
|
||||
}
|
||||
3
apps/todo/apps/backend/src/task/dto/index.ts
Normal file
3
apps/todo/apps/backend/src/task/dto/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './create-task.dto';
|
||||
export * from './update-task.dto';
|
||||
export * from './query-tasks.dto';
|
||||
64
apps/todo/apps/backend/src/task/dto/query-tasks.dto.ts
Normal file
64
apps/todo/apps/backend/src/task/dto/query-tasks.dto.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import {
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
IsEnum,
|
||||
IsBoolean,
|
||||
IsString,
|
||||
IsNumber,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import type { TaskPriority, TaskStatus } from '../../db/schema/tasks.schema';
|
||||
|
||||
export class QueryTasksDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
labelId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['low', 'medium', 'high', 'urgent'])
|
||||
priority?: TaskPriority;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['pending', 'in_progress', 'completed', 'cancelled'])
|
||||
status?: TaskStatus;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@IsBoolean()
|
||||
isCompleted?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueDateFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueDateTo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['dueDate', 'priority', 'createdAt', 'order'])
|
||||
sortBy?: 'dueDate' | 'priority' | 'createdAt' | 'order';
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['asc', 'desc'])
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
@IsNumber()
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
@IsNumber()
|
||||
offset?: number;
|
||||
}
|
||||
77
apps/todo/apps/backend/src/task/dto/update-task.dto.ts
Normal file
77
apps/todo/apps/backend/src/task/dto/update-task.dto.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
IsEnum,
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsArray,
|
||||
IsObject,
|
||||
MaxLength,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
import type { TaskPriority, TaskStatus, Subtask, TaskMetadata } from '../../db/schema/tasks.schema';
|
||||
|
||||
export class UpdateTaskDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentTaskId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueDate?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5)
|
||||
dueTime?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['low', 'medium', 'high', 'urgent'])
|
||||
priority?: TaskPriority;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['pending', 'in_progress', 'completed', 'cancelled'])
|
||||
status?: TaskStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isCompleted?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
order?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
recurrenceRule?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
recurrenceEndDate?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
subtasks?: Subtask[] | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: TaskMetadata | null;
|
||||
}
|
||||
111
apps/todo/apps/backend/src/task/task.controller.ts
Normal file
111
apps/todo/apps/backend/src/task/task.controller.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { TaskService } from './task.service';
|
||||
import { CreateTaskDto, UpdateTaskDto, QueryTasksDto } from './dto';
|
||||
|
||||
@Controller('tasks')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TaskController {
|
||||
constructor(private readonly taskService: TaskService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData, @Query() query: QueryTasksDto) {
|
||||
const tasks = await this.taskService.findAll(user.userId, query);
|
||||
return { tasks };
|
||||
}
|
||||
|
||||
@Get('inbox')
|
||||
async getInbox(@CurrentUser() user: CurrentUserData) {
|
||||
const tasks = await this.taskService.getInboxTasks(user.userId);
|
||||
return { tasks };
|
||||
}
|
||||
|
||||
@Get('today')
|
||||
async getToday(@CurrentUser() user: CurrentUserData) {
|
||||
const tasks = await this.taskService.getTodayTasks(user.userId);
|
||||
return { tasks };
|
||||
}
|
||||
|
||||
@Get('upcoming')
|
||||
async getUpcoming(@CurrentUser() user: CurrentUserData, @Query('days') days?: number) {
|
||||
const tasks = await this.taskService.getUpcomingTasks(user.userId, days ?? 7);
|
||||
return { tasks };
|
||||
}
|
||||
|
||||
@Get('completed')
|
||||
async getCompleted(@CurrentUser() user: CurrentUserData, @Query('limit') limit?: number) {
|
||||
const tasks = await this.taskService.getCompletedTasks(user.userId, limit ?? 50);
|
||||
return { tasks };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
const task = await this.taskService.findByIdOrThrow(id, user.userId);
|
||||
return { task };
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateTaskDto) {
|
||||
const task = await this.taskService.create(user.userId, dto);
|
||||
return { task };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateTaskDto
|
||||
) {
|
||||
const task = await this.taskService.update(id, user.userId, dto);
|
||||
return { task };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.taskService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/complete')
|
||||
async complete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
const task = await this.taskService.complete(id, user.userId);
|
||||
return { task };
|
||||
}
|
||||
|
||||
@Post(':id/uncomplete')
|
||||
async uncomplete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
const task = await this.taskService.uncomplete(id, user.userId);
|
||||
return { task };
|
||||
}
|
||||
|
||||
@Post(':id/move')
|
||||
async move(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body('projectId') projectId: string | null
|
||||
) {
|
||||
const task = await this.taskService.move(id, user.userId, projectId);
|
||||
return { task };
|
||||
}
|
||||
|
||||
@Put(':id/labels')
|
||||
async updateLabels(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body('labelIds') labelIds: string[]
|
||||
) {
|
||||
await this.taskService.updateTaskLabels(id, user.userId, labelIds);
|
||||
const task = await this.taskService.findByIdOrThrow(id, user.userId);
|
||||
return { task };
|
||||
}
|
||||
|
||||
@Put('reorder')
|
||||
async reorder(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body('taskIds') taskIds: string[],
|
||||
@Body('projectId') projectId?: string | null
|
||||
) {
|
||||
const tasks = await this.taskService.reorder(user.userId, taskIds, projectId);
|
||||
return { tasks };
|
||||
}
|
||||
}
|
||||
12
apps/todo/apps/backend/src/task/task.module.ts
Normal file
12
apps/todo/apps/backend/src/task/task.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TaskController } from './task.controller';
|
||||
import { TaskService } from './task.service';
|
||||
import { ProjectModule } from '../project/project.module';
|
||||
|
||||
@Module({
|
||||
imports: [ProjectModule],
|
||||
controllers: [TaskController],
|
||||
providers: [TaskService],
|
||||
exports: [TaskService],
|
||||
})
|
||||
export class TaskModule {}
|
||||
337
apps/todo/apps/backend/src/task/task.service.ts
Normal file
337
apps/todo/apps/backend/src/task/task.service.ts
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { tasks, taskLabels, labels, type Task, type NewTask, type Subtask } from '../db/schema';
|
||||
import { ProjectService } from '../project/project.service';
|
||||
import { CreateTaskDto, UpdateTaskDto, QueryTasksDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class TaskService {
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private db: Database,
|
||||
private projectService: ProjectService
|
||||
) {}
|
||||
|
||||
async findAll(userId: string, query: QueryTasksDto = {}): Promise<Task[]> {
|
||||
const conditions: SQL[] = [eq(tasks.userId, userId)];
|
||||
|
||||
if (query.projectId) {
|
||||
conditions.push(eq(tasks.projectId, query.projectId));
|
||||
}
|
||||
|
||||
if (query.priority) {
|
||||
conditions.push(eq(tasks.priority, query.priority));
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
conditions.push(eq(tasks.status, query.status));
|
||||
}
|
||||
|
||||
if (query.isCompleted !== undefined) {
|
||||
conditions.push(eq(tasks.isCompleted, query.isCompleted));
|
||||
}
|
||||
|
||||
if (query.dueDateFrom) {
|
||||
conditions.push(gte(tasks.dueDate, new Date(query.dueDateFrom)));
|
||||
}
|
||||
|
||||
if (query.dueDateTo) {
|
||||
conditions.push(lte(tasks.dueDate, new Date(query.dueDateTo)));
|
||||
}
|
||||
|
||||
if (query.search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(tasks.title, `%${query.search}%`),
|
||||
ilike(tasks.description, `%${query.search}%`)
|
||||
) as SQL
|
||||
);
|
||||
}
|
||||
|
||||
// Build order by clause
|
||||
let orderBy: SQL[];
|
||||
switch (query.sortBy) {
|
||||
case 'dueDate':
|
||||
orderBy = query.sortOrder === 'desc' ? [desc(tasks.dueDate)] : [asc(tasks.dueDate)];
|
||||
break;
|
||||
case 'priority':
|
||||
// Priority order: urgent > high > medium > low
|
||||
orderBy = query.sortOrder === 'desc' ? [asc(tasks.priority)] : [desc(tasks.priority)];
|
||||
break;
|
||||
case 'createdAt':
|
||||
orderBy = query.sortOrder === 'desc' ? [desc(tasks.createdAt)] : [asc(tasks.createdAt)];
|
||||
break;
|
||||
default:
|
||||
orderBy = [asc(tasks.order), asc(tasks.createdAt)];
|
||||
}
|
||||
|
||||
const result = await this.db.query.tasks.findMany({
|
||||
where: and(...conditions),
|
||||
orderBy,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
});
|
||||
|
||||
// Load labels for each task
|
||||
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Task | null> {
|
||||
const result = await this.db.query.tasks.findFirst({
|
||||
where: and(eq(tasks.id, id), eq(tasks.userId, userId)),
|
||||
});
|
||||
|
||||
if (!result) return null;
|
||||
return this.loadTaskLabels(result);
|
||||
}
|
||||
|
||||
async findByIdOrThrow(id: string, userId: string): Promise<Task> {
|
||||
const task = await this.findById(id, userId);
|
||||
if (!task) {
|
||||
throw new NotFoundException(`Task with id ${id} not found`);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
async create(userId: string, dto: CreateTaskDto): Promise<Task> {
|
||||
// Verify project belongs to user if provided
|
||||
if (dto.projectId) {
|
||||
await this.projectService.findByIdOrThrow(dto.projectId, userId);
|
||||
}
|
||||
|
||||
// Get the highest order value for the project
|
||||
const existingTasks = await this.findAll(userId, { projectId: dto.projectId ?? undefined });
|
||||
const maxOrder = existingTasks.reduce((max, t) => Math.max(max, t.order ?? 0), -1);
|
||||
|
||||
// Generate IDs for subtasks
|
||||
const subtasksWithIds: Subtask[] | undefined = dto.subtasks?.map((s, i) => ({
|
||||
id: `subtask_${Date.now()}_${i}`,
|
||||
title: s.title,
|
||||
isCompleted: s.isCompleted ?? false,
|
||||
order: s.order ?? i,
|
||||
}));
|
||||
|
||||
const newTask: NewTask = {
|
||||
userId,
|
||||
projectId: dto.projectId,
|
||||
parentTaskId: dto.parentTaskId,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
dueDate: dto.dueDate ? new Date(dto.dueDate) : null,
|
||||
dueTime: dto.dueTime,
|
||||
startDate: dto.startDate ? new Date(dto.startDate) : null,
|
||||
priority: dto.priority ?? 'medium',
|
||||
recurrenceRule: dto.recurrenceRule,
|
||||
recurrenceEndDate: dto.recurrenceEndDate ? new Date(dto.recurrenceEndDate) : null,
|
||||
subtasks: subtasksWithIds,
|
||||
metadata: dto.metadata,
|
||||
order: maxOrder + 1,
|
||||
};
|
||||
|
||||
const [created] = await this.db.insert(tasks).values(newTask).returning();
|
||||
|
||||
// Add labels if provided
|
||||
if (dto.labelIds?.length) {
|
||||
await this.updateTaskLabels(created.id, userId, dto.labelIds);
|
||||
}
|
||||
|
||||
return this.loadTaskLabels(created);
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, dto: UpdateTaskDto): Promise<Task> {
|
||||
await this.findByIdOrThrow(id, userId);
|
||||
|
||||
// Verify project belongs to user if changing project
|
||||
if (dto.projectId) {
|
||||
await this.projectService.findByIdOrThrow(dto.projectId, userId);
|
||||
}
|
||||
|
||||
const updateData: Partial<NewTask> = {
|
||||
...dto,
|
||||
dueDate: dto.dueDate ? new Date(dto.dueDate) : dto.dueDate === null ? null : undefined,
|
||||
startDate: dto.startDate
|
||||
? new Date(dto.startDate)
|
||||
: dto.startDate === null
|
||||
? null
|
||||
: undefined,
|
||||
recurrenceEndDate: dto.recurrenceEndDate
|
||||
? new Date(dto.recurrenceEndDate)
|
||||
: dto.recurrenceEndDate === null
|
||||
? null
|
||||
: undefined,
|
||||
completedAt: dto.isCompleted ? new Date() : dto.isCompleted === false ? null : undefined,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Remove undefined values
|
||||
Object.keys(updateData).forEach((key) => {
|
||||
if (updateData[key as keyof typeof updateData] === undefined) {
|
||||
delete updateData[key as keyof typeof updateData];
|
||||
}
|
||||
});
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(tasks)
|
||||
.set(updateData)
|
||||
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return this.loadTaskLabels(updated);
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
await this.findByIdOrThrow(id, userId);
|
||||
await this.db.delete(tasks).where(and(eq(tasks.id, id), eq(tasks.userId, userId)));
|
||||
}
|
||||
|
||||
async complete(id: string, userId: string): Promise<Task> {
|
||||
const task = await this.findByIdOrThrow(id, userId);
|
||||
|
||||
// If task has recurrence, create next occurrence instead of completing
|
||||
if (task.recurrenceRule) {
|
||||
// TODO: Implement recurrence handling
|
||||
// For now, just mark as complete
|
||||
}
|
||||
|
||||
return this.update(id, userId, {
|
||||
isCompleted: true,
|
||||
status: 'completed',
|
||||
});
|
||||
}
|
||||
|
||||
async uncomplete(id: string, userId: string): Promise<Task> {
|
||||
return this.update(id, userId, {
|
||||
isCompleted: false,
|
||||
status: 'pending',
|
||||
});
|
||||
}
|
||||
|
||||
async move(id: string, userId: string, projectId: string | null): Promise<Task> {
|
||||
// Verify new project if provided
|
||||
if (projectId) {
|
||||
await this.projectService.findByIdOrThrow(projectId, userId);
|
||||
}
|
||||
|
||||
// Get order in new project
|
||||
const existingTasks = await this.findAll(userId, { projectId: projectId ?? undefined });
|
||||
const maxOrder = existingTasks.reduce((max, t) => Math.max(max, t.order ?? 0), -1);
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(tasks)
|
||||
.set({
|
||||
projectId,
|
||||
order: maxOrder + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return this.loadTaskLabels(updated);
|
||||
}
|
||||
|
||||
async updateTaskLabels(taskId: string, userId: string, labelIds: string[]): Promise<void> {
|
||||
await this.findByIdOrThrow(taskId, userId);
|
||||
|
||||
// Delete existing labels
|
||||
await this.db.delete(taskLabels).where(eq(taskLabels.taskId, taskId));
|
||||
|
||||
// Insert new labels
|
||||
if (labelIds.length > 0) {
|
||||
await this.db.insert(taskLabels).values(
|
||||
labelIds.map((labelId) => ({
|
||||
taskId,
|
||||
labelId,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getInboxTasks(userId: string): Promise<Task[]> {
|
||||
return this.findAll(userId, { isCompleted: false });
|
||||
}
|
||||
|
||||
async getTodayTasks(userId: string): Promise<Task[]> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
// Get tasks due today or overdue
|
||||
const result = await this.db.query.tasks.findMany({
|
||||
where: and(
|
||||
eq(tasks.userId, userId),
|
||||
eq(tasks.isCompleted, false),
|
||||
or(
|
||||
and(gte(tasks.dueDate, today), lte(tasks.dueDate, tomorrow)),
|
||||
lte(tasks.dueDate, today) // Overdue
|
||||
)
|
||||
),
|
||||
orderBy: [asc(tasks.dueDate), asc(tasks.order)],
|
||||
});
|
||||
|
||||
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
|
||||
}
|
||||
|
||||
async getUpcomingTasks(userId: string, days: number = 7): Promise<Task[]> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const endDate = new Date(today);
|
||||
endDate.setDate(endDate.getDate() + days);
|
||||
|
||||
const result = await this.db.query.tasks.findMany({
|
||||
where: and(
|
||||
eq(tasks.userId, userId),
|
||||
eq(tasks.isCompleted, false),
|
||||
gte(tasks.dueDate, today),
|
||||
lte(tasks.dueDate, endDate)
|
||||
),
|
||||
orderBy: [asc(tasks.dueDate), asc(tasks.order)],
|
||||
});
|
||||
|
||||
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
|
||||
}
|
||||
|
||||
async getCompletedTasks(userId: string, limit: number = 50): Promise<Task[]> {
|
||||
const result = await this.db.query.tasks.findMany({
|
||||
where: and(eq(tasks.userId, userId), eq(tasks.isCompleted, true)),
|
||||
orderBy: [desc(tasks.completedAt)],
|
||||
limit,
|
||||
});
|
||||
|
||||
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
|
||||
}
|
||||
|
||||
async reorder(userId: string, taskIds: string[], projectId?: string | null): Promise<Task[]> {
|
||||
// Update order for each task
|
||||
const updates = taskIds.map((id, index) =>
|
||||
this.db
|
||||
.update(tasks)
|
||||
.set({ order: index, updatedAt: new Date() })
|
||||
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)))
|
||||
);
|
||||
|
||||
await Promise.all(updates);
|
||||
|
||||
return this.findAll(userId, { projectId: projectId ?? undefined });
|
||||
}
|
||||
|
||||
private async loadTaskLabels(
|
||||
task: Task
|
||||
): Promise<Task & { labels: (typeof labels.$inferSelect)[] }> {
|
||||
const taskLabelRows = await this.db.query.taskLabels.findMany({
|
||||
where: eq(taskLabels.taskId, task.id),
|
||||
});
|
||||
|
||||
if (taskLabelRows.length === 0) {
|
||||
return { ...task, labels: [] };
|
||||
}
|
||||
|
||||
const labelIds = taskLabelRows.map((tl) => tl.labelId);
|
||||
const taskLabelsData = await this.db.query.labels.findMany({
|
||||
where: or(...labelIds.map((id) => eq(labels.id, id))),
|
||||
});
|
||||
|
||||
return { ...task, labels: taskLabelsData };
|
||||
}
|
||||
}
|
||||
28
apps/todo/apps/backend/tsconfig.json
Normal file
28
apps/todo/apps/backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"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": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
50
apps/todo/apps/web/package.json
Normal file
50
apps/todo/apps/web/package.json
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"name": "@todo/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": {
|
||||
"@todo/shared": "workspace:*",
|
||||
"@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:*",
|
||||
"date-fns": "^4.1.0",
|
||||
"svelte-dnd-action": "^0.9.68",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
135
apps/todo/apps/web/src/app.css
Normal file
135
apps/todo/apps/web/src/app.css
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
@import 'tailwindcss';
|
||||
@import '@manacore/shared-tailwind/themes.css';
|
||||
|
||||
/* Scan shared packages for Tailwind classes */
|
||||
@source "../../../packages/shared/src";
|
||||
@source "../../../../../packages/shared-ui/src";
|
||||
@source "../../../../../packages/shared-theme-ui/src";
|
||||
|
||||
:root {
|
||||
/* Todo App - Purple/Violet Theme */
|
||||
--color-primary: #8b5cf6;
|
||||
--color-primary-hover: #7c3aed;
|
||||
--color-primary-light: #a78bfa;
|
||||
--color-primary-dark: #6d28d9;
|
||||
|
||||
--color-secondary: #f3e8ff;
|
||||
--color-secondary-hover: #e9d5ff;
|
||||
|
||||
--color-accent: #c084fc;
|
||||
--color-accent-hover: #a855f7;
|
||||
|
||||
/* Task priorities */
|
||||
--color-priority-low: #22c55e;
|
||||
--color-priority-medium: #eab308;
|
||||
--color-priority-high: #f97316;
|
||||
--color-priority-urgent: #ef4444;
|
||||
|
||||
/* Status colors */
|
||||
--color-pending: #6b7280;
|
||||
--color-in-progress: #3b82f6;
|
||||
--color-completed: #22c55e;
|
||||
--color-cancelled: #ef4444;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
:root.dark {
|
||||
--color-secondary: #1e1b4b;
|
||||
--color-secondary-hover: #2e1065;
|
||||
}
|
||||
|
||||
/* Task item transitions */
|
||||
.task-item {
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Checkbox animations */
|
||||
.task-checkbox {
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
transform 0.1s ease;
|
||||
}
|
||||
|
||||
.task-checkbox:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
/* Subtask list */
|
||||
.subtask-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* Due date styling */
|
||||
.due-today {
|
||||
color: var(--color-priority-high);
|
||||
}
|
||||
|
||||
.due-overdue {
|
||||
color: var(--color-priority-urgent);
|
||||
}
|
||||
|
||||
/* Priority indicators */
|
||||
.priority-indicator {
|
||||
width: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.priority-low {
|
||||
background-color: var(--color-priority-low);
|
||||
}
|
||||
|
||||
.priority-medium {
|
||||
background-color: var(--color-priority-medium);
|
||||
}
|
||||
|
||||
.priority-high {
|
||||
background-color: var(--color-priority-high);
|
||||
}
|
||||
|
||||
.priority-urgent {
|
||||
background-color: var(--color-priority-urgent);
|
||||
}
|
||||
|
||||
/* Drag and drop */
|
||||
.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.drop-target {
|
||||
border: 2px dashed var(--color-primary);
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
|
||||
/* Quick add input */
|
||||
.quick-add-input {
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.quick-add-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.2);
|
||||
}
|
||||
|
||||
/* Project sidebar */
|
||||
.project-item {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.project-item:hover {
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.project-item.active {
|
||||
background-color: var(--color-secondary);
|
||||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
12
apps/todo/apps/web/src/app.html
Normal file
12
apps/todo/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
90
apps/todo/apps/web/src/lib/api/client.ts
Normal file
90
apps/todo/apps/web/src/lib/api/client.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { PUBLIC_BACKEND_URL } from '$env/static/public';
|
||||
|
||||
interface ApiOptions {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ApiError {
|
||||
message: string;
|
||||
statusCode: number;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
private accessToken: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = PUBLIC_BACKEND_URL || 'http://localhost:3018';
|
||||
}
|
||||
|
||||
setAccessToken(token: string | null) {
|
||||
this.accessToken = token;
|
||||
}
|
||||
|
||||
getAccessToken(): string | null {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
async fetch<T>(endpoint: string, options: ApiOptions = {}): Promise<T> {
|
||||
const { method = 'GET', body, headers = {} } = options;
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
};
|
||||
|
||||
if (this.accessToken) {
|
||||
requestHeaders['Authorization'] = `Bearer ${this.accessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'An error occurred';
|
||||
try {
|
||||
const errorData = (await response.json()) as ApiError;
|
||||
errorMessage = errorData.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = response.statusText || errorMessage;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Handle 204 No Content
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
get<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
|
||||
return this.fetch<T>(endpoint, { method: 'GET', headers });
|
||||
}
|
||||
|
||||
post<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
|
||||
return this.fetch<T>(endpoint, { method: 'POST', body, headers });
|
||||
}
|
||||
|
||||
put<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
|
||||
return this.fetch<T>(endpoint, { method: 'PUT', body, headers });
|
||||
}
|
||||
|
||||
patch<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
|
||||
return this.fetch<T>(endpoint, { method: 'PATCH', body, headers });
|
||||
}
|
||||
|
||||
delete<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
|
||||
return this.fetch<T>(endpoint, { method: 'DELETE', headers });
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
5
apps/todo/apps/web/src/lib/api/index.ts
Normal file
5
apps/todo/apps/web/src/lib/api/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { apiClient } from './client';
|
||||
export * from './projects';
|
||||
export * from './tasks';
|
||||
export * from './labels';
|
||||
export * from './reminders';
|
||||
39
apps/todo/apps/web/src/lib/api/labels.ts
Normal file
39
apps/todo/apps/web/src/lib/api/labels.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { apiClient } from './client';
|
||||
import type { Label } from '@todo/shared';
|
||||
|
||||
interface CreateLabelDto {
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface UpdateLabelDto {
|
||||
name?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface LabelsResponse {
|
||||
labels: Label[];
|
||||
}
|
||||
|
||||
interface LabelResponse {
|
||||
label: Label;
|
||||
}
|
||||
|
||||
export async function getLabels(): Promise<Label[]> {
|
||||
const response = await apiClient.get<LabelsResponse>('/api/v1/labels');
|
||||
return response.labels;
|
||||
}
|
||||
|
||||
export async function createLabel(data: CreateLabelDto): Promise<Label> {
|
||||
const response = await apiClient.post<LabelResponse>('/api/v1/labels', data);
|
||||
return response.label;
|
||||
}
|
||||
|
||||
export async function updateLabel(id: string, data: UpdateLabelDto): Promise<Label> {
|
||||
const response = await apiClient.put<LabelResponse>(`/api/v1/labels/${id}`, data);
|
||||
return response.label;
|
||||
}
|
||||
|
||||
export async function deleteLabel(id: string): Promise<void> {
|
||||
await apiClient.delete(`/api/v1/labels/${id}`);
|
||||
}
|
||||
62
apps/todo/apps/web/src/lib/api/projects.ts
Normal file
62
apps/todo/apps/web/src/lib/api/projects.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { apiClient } from './client';
|
||||
import type { Project } from '@todo/shared';
|
||||
|
||||
interface CreateProjectDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface UpdateProjectDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
isArchived?: boolean;
|
||||
}
|
||||
|
||||
interface ReorderProjectsDto {
|
||||
projectIds: string[];
|
||||
}
|
||||
|
||||
interface ProjectsResponse {
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
interface ProjectResponse {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
export async function getProjects(): Promise<Project[]> {
|
||||
const response = await apiClient.get<ProjectsResponse>('/api/v1/projects');
|
||||
return response.projects;
|
||||
}
|
||||
|
||||
export async function getProject(id: string): Promise<Project> {
|
||||
const response = await apiClient.get<ProjectResponse>(`/api/v1/projects/${id}`);
|
||||
return response.project;
|
||||
}
|
||||
|
||||
export async function createProject(data: CreateProjectDto): Promise<Project> {
|
||||
const response = await apiClient.post<ProjectResponse>('/api/v1/projects', data);
|
||||
return response.project;
|
||||
}
|
||||
|
||||
export async function updateProject(id: string, data: UpdateProjectDto): Promise<Project> {
|
||||
const response = await apiClient.put<ProjectResponse>(`/api/v1/projects/${id}`, data);
|
||||
return response.project;
|
||||
}
|
||||
|
||||
export async function deleteProject(id: string): Promise<void> {
|
||||
await apiClient.delete(`/api/v1/projects/${id}`);
|
||||
}
|
||||
|
||||
export async function archiveProject(id: string): Promise<Project> {
|
||||
const response = await apiClient.post<ProjectResponse>(`/api/v1/projects/${id}/archive`);
|
||||
return response.project;
|
||||
}
|
||||
|
||||
export async function reorderProjects(projectIds: string[]): Promise<void> {
|
||||
await apiClient.put('/api/v1/projects/reorder', { projectIds } as ReorderProjectsDto);
|
||||
}
|
||||
32
apps/todo/apps/web/src/lib/api/reminders.ts
Normal file
32
apps/todo/apps/web/src/lib/api/reminders.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { apiClient } from './client';
|
||||
import type { Reminder, ReminderType } from '@todo/shared';
|
||||
|
||||
interface CreateReminderDto {
|
||||
minutesBefore: number;
|
||||
type?: ReminderType;
|
||||
}
|
||||
|
||||
interface RemindersResponse {
|
||||
reminders: Reminder[];
|
||||
}
|
||||
|
||||
interface ReminderResponse {
|
||||
reminder: Reminder;
|
||||
}
|
||||
|
||||
export async function getReminders(taskId: string): Promise<Reminder[]> {
|
||||
const response = await apiClient.get<RemindersResponse>(`/api/v1/tasks/${taskId}/reminders`);
|
||||
return response.reminders;
|
||||
}
|
||||
|
||||
export async function createReminder(taskId: string, data: CreateReminderDto): Promise<Reminder> {
|
||||
const response = await apiClient.post<ReminderResponse>(
|
||||
`/api/v1/tasks/${taskId}/reminders`,
|
||||
data
|
||||
);
|
||||
return response.reminder;
|
||||
}
|
||||
|
||||
export async function deleteReminder(id: string): Promise<void> {
|
||||
await apiClient.delete(`/api/v1/reminders/${id}`);
|
||||
}
|
||||
123
apps/todo/apps/web/src/lib/api/tasks.ts
Normal file
123
apps/todo/apps/web/src/lib/api/tasks.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { apiClient } from './client';
|
||||
import type { Task, TaskPriority, TaskStatus, Subtask, Label } from '@todo/shared';
|
||||
|
||||
interface CreateTaskDto {
|
||||
title: string;
|
||||
description?: string;
|
||||
projectId?: string;
|
||||
dueDate?: string;
|
||||
priority?: TaskPriority;
|
||||
labelIds?: string[];
|
||||
subtasks?: Subtask[];
|
||||
recurrenceRule?: string;
|
||||
}
|
||||
|
||||
interface UpdateTaskDto {
|
||||
title?: string;
|
||||
description?: string;
|
||||
projectId?: string | null;
|
||||
dueDate?: string | null;
|
||||
priority?: TaskPriority;
|
||||
status?: TaskStatus;
|
||||
subtasks?: Subtask[];
|
||||
recurrenceRule?: string | null;
|
||||
}
|
||||
|
||||
interface TaskQuery {
|
||||
projectId?: string;
|
||||
labelId?: string;
|
||||
priority?: TaskPriority;
|
||||
status?: TaskStatus;
|
||||
dueBefore?: string;
|
||||
dueAfter?: string;
|
||||
isCompleted?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
interface TasksResponse {
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
interface TaskResponse {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
function buildQueryString(query: TaskQuery): string {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
});
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
export async function getTasks(query: TaskQuery = {}): Promise<Task[]> {
|
||||
const queryString = buildQueryString(query);
|
||||
const response = await apiClient.get<TasksResponse>(`/api/v1/tasks${queryString}`);
|
||||
return response.tasks;
|
||||
}
|
||||
|
||||
export async function getTask(id: string): Promise<Task> {
|
||||
const response = await apiClient.get<TaskResponse>(`/api/v1/tasks/${id}`);
|
||||
return response.task;
|
||||
}
|
||||
|
||||
export async function createTask(data: CreateTaskDto): Promise<Task> {
|
||||
const response = await apiClient.post<TaskResponse>('/api/v1/tasks', data);
|
||||
return response.task;
|
||||
}
|
||||
|
||||
export async function updateTask(id: string, data: UpdateTaskDto): Promise<Task> {
|
||||
const response = await apiClient.put<TaskResponse>(`/api/v1/tasks/${id}`, data);
|
||||
return response.task;
|
||||
}
|
||||
|
||||
export async function deleteTask(id: string): Promise<void> {
|
||||
await apiClient.delete(`/api/v1/tasks/${id}`);
|
||||
}
|
||||
|
||||
export async function completeTask(id: string): Promise<Task> {
|
||||
const response = await apiClient.post<TaskResponse>(`/api/v1/tasks/${id}/complete`);
|
||||
return response.task;
|
||||
}
|
||||
|
||||
export async function uncompleteTask(id: string): Promise<Task> {
|
||||
const response = await apiClient.post<TaskResponse>(`/api/v1/tasks/${id}/uncomplete`);
|
||||
return response.task;
|
||||
}
|
||||
|
||||
export async function moveTask(id: string, projectId: string | null): Promise<Task> {
|
||||
const response = await apiClient.post<TaskResponse>(`/api/v1/tasks/${id}/move`, { projectId });
|
||||
return response.task;
|
||||
}
|
||||
|
||||
export async function updateTaskLabels(id: string, labelIds: string[]): Promise<Task> {
|
||||
const response = await apiClient.put<TaskResponse>(`/api/v1/tasks/${id}/labels`, { labelIds });
|
||||
return response.task;
|
||||
}
|
||||
|
||||
export async function updateSubtasks(id: string, subtasks: Subtask[]): Promise<Task> {
|
||||
const response = await apiClient.put<TaskResponse>(`/api/v1/tasks/${id}/subtasks`, { subtasks });
|
||||
return response.task;
|
||||
}
|
||||
|
||||
export async function getInboxTasks(): Promise<Task[]> {
|
||||
const response = await apiClient.get<TasksResponse>('/api/v1/tasks/inbox');
|
||||
return response.tasks;
|
||||
}
|
||||
|
||||
export async function getTodayTasks(): Promise<Task[]> {
|
||||
const response = await apiClient.get<TasksResponse>('/api/v1/tasks/today');
|
||||
return response.tasks;
|
||||
}
|
||||
|
||||
export async function getUpcomingTasks(): Promise<Task[]> {
|
||||
const response = await apiClient.get<TasksResponse>('/api/v1/tasks/upcoming');
|
||||
return response.tasks;
|
||||
}
|
||||
|
||||
export async function reorderTasks(taskIds: string[]): Promise<void> {
|
||||
await apiClient.put('/api/v1/tasks/reorder', { taskIds });
|
||||
}
|
||||
32
apps/todo/apps/web/src/lib/components/AppSlider.svelte
Normal file
32
apps/todo/apps/web/src/lib/components/AppSlider.svelte
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts">
|
||||
import { AppSlider, type AppItem } from '@manacore/shared-ui';
|
||||
import { MANA_APPS, APP_STATUS_LABELS, APP_SLIDER_LABELS } from '@manacore/shared-branding';
|
||||
|
||||
// Convert MANA_APPS to AppItem format (German)
|
||||
const apps: AppItem[] = MANA_APPS.map((app) => ({
|
||||
name: app.name,
|
||||
description: app.description.de,
|
||||
longDescription: app.longDescription.de,
|
||||
icon: app.icon,
|
||||
color: app.color,
|
||||
comingSoon: app.comingSoon,
|
||||
status: app.status,
|
||||
}));
|
||||
|
||||
const statusLabels = APP_STATUS_LABELS.de;
|
||||
const labels = APP_SLIDER_LABELS.de;
|
||||
|
||||
function handleAppClick(app: AppItem, index: number) {
|
||||
console.log('Opening app:', app.name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppSlider
|
||||
{apps}
|
||||
title={labels.title}
|
||||
isDark={false}
|
||||
{statusLabels}
|
||||
comingSoonLabel={labels.comingSoon}
|
||||
openAppLabel={labels.openApp}
|
||||
onAppClick={handleAppClick}
|
||||
/>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { PillDropdown } from '@manacore/shared-ui';
|
||||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||
|
||||
const supportedLocales = ['de', 'en'];
|
||||
|
||||
let currentLocale = $derived($locale || 'de');
|
||||
|
||||
function handleLocaleChange(newLocale: string) {
|
||||
locale.set(newLocale);
|
||||
}
|
||||
|
||||
let languageItems = $derived(
|
||||
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
|
||||
);
|
||||
let currentLabel = $derived(getCurrentLanguageLabel(currentLocale));
|
||||
</script>
|
||||
|
||||
<PillDropdown items={languageItems} label={currentLabel} direction="down" />
|
||||
68
apps/todo/apps/web/src/lib/components/QuickAddTask.svelte
Normal file
68
apps/todo/apps/web/src/lib/components/QuickAddTask.svelte
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<script lang="ts">
|
||||
import { tasksStore } from '$lib/stores/tasks.svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
|
||||
let inputValue = $state('');
|
||||
let isLoading = $state(false);
|
||||
let inputRef: HTMLInputElement;
|
||||
|
||||
async function handleSubmit(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
const title = inputValue.trim();
|
||||
if (!title || isLoading) return;
|
||||
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
// Create task with current project if in project view
|
||||
const projectId =
|
||||
viewStore.currentView === 'project' ? viewStore.currentProjectId : undefined;
|
||||
|
||||
await tasksStore.createTask({
|
||||
title,
|
||||
projectId: projectId || undefined,
|
||||
});
|
||||
|
||||
inputValue = '';
|
||||
inputRef?.focus();
|
||||
} catch (error) {
|
||||
console.error('Failed to create task:', error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
inputValue = '';
|
||||
inputRef?.blur();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSubmit} class="mb-6">
|
||||
<div class="relative">
|
||||
<div class="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
bind:this={inputRef}
|
||||
bind:value={inputValue}
|
||||
onkeydown={handleKeydown}
|
||||
type="text"
|
||||
placeholder="Neue Aufgabe hinzufügen..."
|
||||
class="quick-add-input w-full pl-12 pr-4 py-3 rounded-lg border border-border bg-card text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{#if isLoading}
|
||||
<div class="absolute right-4 top-1/2 -translate-y-1/2">
|
||||
<div
|
||||
class="animate-spin h-5 w-5 border-2 border-primary border-r-transparent rounded-full"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
181
apps/todo/apps/web/src/lib/components/TaskItem.svelte
Normal file
181
apps/todo/apps/web/src/lib/components/TaskItem.svelte
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<script lang="ts">
|
||||
import type { Task } from '@todo/shared';
|
||||
import { format, isToday, isPast, isTomorrow } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { projectsStore } from '$lib/stores/projects.svelte';
|
||||
|
||||
interface Props {
|
||||
task: Task;
|
||||
showCompleted?: boolean;
|
||||
onToggleComplete: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let { task, showCompleted = false, onToggleComplete, onDelete }: Props = $props();
|
||||
|
||||
// Priority colors
|
||||
const priorityColors: Record<string, string> = {
|
||||
low: 'bg-green-500',
|
||||
medium: 'bg-yellow-500',
|
||||
high: 'bg-orange-500',
|
||||
urgent: 'bg-red-500',
|
||||
};
|
||||
|
||||
// Format due date
|
||||
let dueDateText = $derived(() => {
|
||||
if (!task.dueDate) return null;
|
||||
const date = new Date(task.dueDate);
|
||||
if (isToday(date)) return 'Heute';
|
||||
if (isTomorrow(date)) return 'Morgen';
|
||||
return format(date, 'dd. MMM', { locale: de });
|
||||
});
|
||||
|
||||
// Check if overdue
|
||||
let isOverdue = $derived(() => {
|
||||
if (!task.dueDate || task.isCompleted) return false;
|
||||
const date = new Date(task.dueDate);
|
||||
return isPast(date) && !isToday(date);
|
||||
});
|
||||
|
||||
// Get project color
|
||||
let projectColor = $derived(() => {
|
||||
if (!task.projectId) return null;
|
||||
return projectsStore.getColor(task.projectId);
|
||||
});
|
||||
|
||||
// Subtasks progress
|
||||
let subtaskProgress = $derived(() => {
|
||||
if (!task.subtasks || task.subtasks.length === 0) return null;
|
||||
const completed = task.subtasks.filter((s) => s.isCompleted).length;
|
||||
return `${completed}/${task.subtasks.length}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="task-item group flex items-start gap-3 p-3 rounded-lg border border-border bg-card hover:bg-accent/5 transition-colors"
|
||||
class:opacity-60={task.isCompleted}
|
||||
>
|
||||
<!-- Priority indicator -->
|
||||
<div class="priority-indicator {priorityColors[task.priority]} h-full min-h-[40px]"></div>
|
||||
|
||||
<!-- Checkbox -->
|
||||
<button
|
||||
class="task-checkbox flex-shrink-0 w-5 h-5 rounded-full border-2 border-muted-foreground hover:border-primary flex items-center justify-center mt-0.5"
|
||||
class:bg-primary={task.isCompleted}
|
||||
class:border-primary={task.isCompleted}
|
||||
onclick={onToggleComplete}
|
||||
>
|
||||
{#if task.isCompleted}
|
||||
<svg class="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3
|
||||
class="text-sm font-medium text-foreground truncate"
|
||||
class:line-through={task.isCompleted}
|
||||
>
|
||||
{task.title}
|
||||
</h3>
|
||||
|
||||
<!-- Delete button (hidden by default, shown on hover) -->
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-red-500 transition-opacity"
|
||||
onclick={onDelete}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if task.description}
|
||||
<p class="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{task.description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Meta info -->
|
||||
<div class="flex items-center gap-3 mt-2 flex-wrap">
|
||||
{#if dueDateText()}
|
||||
<span
|
||||
class="text-xs flex items-center gap-1"
|
||||
class:text-red-500={isOverdue()}
|
||||
class:text-orange-500={isToday(new Date(task.dueDate || 0))}
|
||||
class:text-muted-foreground={!isOverdue() && !isToday(new Date(task.dueDate || 0))}
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{dueDateText()}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if subtaskProgress()}
|
||||
<span class="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
{subtaskProgress()}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if task.labels && task.labels.length > 0}
|
||||
<div class="flex items-center gap-1">
|
||||
{#each task.labels.slice(0, 3) as label}
|
||||
<span
|
||||
class="text-xs px-1.5 py-0.5 rounded"
|
||||
style="background-color: {label.color}20; color: {label.color}"
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
{/each}
|
||||
{#if task.labels.length > 3}
|
||||
<span class="text-xs text-muted-foreground">+{task.labels.length - 3}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if task.recurrenceRule}
|
||||
<span class="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Wiederkehrend
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project color indicator -->
|
||||
{#if projectColor()}
|
||||
<div
|
||||
class="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style="background-color: {projectColor()}"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
35
apps/todo/apps/web/src/lib/components/TaskList.svelte
Normal file
35
apps/todo/apps/web/src/lib/components/TaskList.svelte
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<script lang="ts">
|
||||
import type { Task } from '@todo/shared';
|
||||
import TaskItem from './TaskItem.svelte';
|
||||
import { tasksStore } from '$lib/stores/tasks.svelte';
|
||||
|
||||
interface Props {
|
||||
tasks: Task[];
|
||||
showCompleted?: boolean;
|
||||
}
|
||||
|
||||
let { tasks, showCompleted = false }: Props = $props();
|
||||
|
||||
async function handleToggleComplete(task: Task) {
|
||||
if (task.isCompleted) {
|
||||
await tasksStore.uncompleteTask(task.id);
|
||||
} else {
|
||||
await tasksStore.completeTask(task.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(taskId: string) {
|
||||
await tasksStore.deleteTask(taskId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="task-list space-y-2">
|
||||
{#each tasks as task (task.id)}
|
||||
<TaskItem
|
||||
{task}
|
||||
{showCompleted}
|
||||
onToggleComplete={() => handleToggleComplete(task)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
201
apps/todo/apps/web/src/lib/stores/auth.svelte.ts
Normal file
201
apps/todo/apps/web/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
/**
|
||||
* Auth Store - Manages authentication state using Svelte 5 runes
|
||||
* Uses Mana Core Auth
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
|
||||
import { apiClient } from '$lib/api/client';
|
||||
|
||||
// Initialize Mana Core Auth only on the client side
|
||||
const MANA_AUTH_URL = 'http://localhost:3001';
|
||||
|
||||
// Lazy initialization to avoid SSR issues with localStorage
|
||||
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
|
||||
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
|
||||
|
||||
function getAuthService() {
|
||||
if (!browser) return null;
|
||||
if (!_authService) {
|
||||
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
|
||||
_authService = auth.authService;
|
||||
_tokenManager = auth.tokenManager;
|
||||
}
|
||||
return _authService;
|
||||
}
|
||||
|
||||
// State
|
||||
let user = $state<UserData | null>(null);
|
||||
let loading = $state(true);
|
||||
let initialized = $state(false);
|
||||
|
||||
export const authStore = {
|
||||
// Getters
|
||||
get user() {
|
||||
return user;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get isAuthenticated() {
|
||||
return !!user;
|
||||
},
|
||||
get initialized() {
|
||||
return initialized;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
*/
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
initialized = true;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
||||
// Set token for API client
|
||||
const token = await authService.getAppToken();
|
||||
if (token) {
|
||||
apiClient.setAccessToken(token);
|
||||
}
|
||||
}
|
||||
initialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
user = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
async signIn(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signIn(email, password);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Login failed' };
|
||||
}
|
||||
|
||||
// Get user data from token
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
||||
// Set token for API client
|
||||
const token = await authService.getAppToken();
|
||||
if (token) {
|
||||
apiClient.setAccessToken(token);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign up with email and password
|
||||
*/
|
||||
async signUp(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server', needsVerification: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
}
|
||||
|
||||
// Mana Core Auth requires separate login after signup
|
||||
if (result.needsVerification) {
|
||||
return { success: true, needsVerification: true };
|
||||
}
|
||||
|
||||
// Auto sign in after successful signup
|
||||
const signInResult = await this.signIn(email, password);
|
||||
return { ...signInResult, needsVerification: false };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage, needsVerification: false };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
async signOut() {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
user = null;
|
||||
apiClient.setAccessToken(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authService.signOut();
|
||||
user = null;
|
||||
apiClient.setAccessToken(null);
|
||||
} catch (error) {
|
||||
console.error('Sign out error:', error);
|
||||
// Clear user even if sign out fails
|
||||
user = null;
|
||||
apiClient.setAccessToken(null);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
async resetPassword(email: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.forgotPassword(email);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Password reset failed' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get access token for API calls
|
||||
*/
|
||||
async getAccessToken() {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return null;
|
||||
}
|
||||
return await authService.getAppToken();
|
||||
},
|
||||
};
|
||||
6
apps/todo/apps/web/src/lib/stores/index.ts
Normal file
6
apps/todo/apps/web/src/lib/stores/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export { authStore } from './auth.svelte';
|
||||
export { projectsStore } from './projects.svelte';
|
||||
export { tasksStore } from './tasks.svelte';
|
||||
export { labelsStore } from './labels.svelte';
|
||||
export { viewStore } from './view.svelte';
|
||||
export type { ViewType, SortBy, SortOrder } from './view.svelte';
|
||||
114
apps/todo/apps/web/src/lib/stores/labels.svelte.ts
Normal file
114
apps/todo/apps/web/src/lib/stores/labels.svelte.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Labels Store - Manages label state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import type { Label } from '@todo/shared';
|
||||
import * as labelsApi from '$lib/api/labels';
|
||||
|
||||
// State
|
||||
let labels = $state<Label[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const labelsStore = {
|
||||
// Getters
|
||||
get labels() {
|
||||
return labels;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all labels from API
|
||||
*/
|
||||
async fetchLabels() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
labels = await labelsApi.getLabels();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch labels';
|
||||
console.error('Failed to fetch labels:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get label by ID
|
||||
*/
|
||||
getById(id: string): Label | undefined {
|
||||
return labels.find((l) => l.id === id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get label color by ID
|
||||
*/
|
||||
getColor(labelId: string): string {
|
||||
const label = labels.find((l) => l.id === labelId);
|
||||
return label?.color || '#6b7280';
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new label
|
||||
*/
|
||||
async createLabel(data: { name: string; color?: string }) {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const newLabel = await labelsApi.createLabel(data);
|
||||
labels = [...labels, newLabel];
|
||||
return newLabel;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create label';
|
||||
console.error('Failed to create label:', e);
|
||||
throw e;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing label
|
||||
*/
|
||||
async updateLabel(id: string, data: { name?: string; color?: string }) {
|
||||
error = null;
|
||||
try {
|
||||
const updatedLabel = await labelsApi.updateLabel(id, data);
|
||||
labels = labels.map((l) => (l.id === id ? updatedLabel : l));
|
||||
return updatedLabel;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update label';
|
||||
console.error('Failed to update label:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a label
|
||||
*/
|
||||
async deleteLabel(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
await labelsApi.deleteLabel(id);
|
||||
labels = labels.filter((l) => l.id !== id);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete label';
|
||||
console.error('Failed to delete label:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all state (for logout)
|
||||
*/
|
||||
clear() {
|
||||
labels = [];
|
||||
loading = false;
|
||||
error = null;
|
||||
},
|
||||
};
|
||||
173
apps/todo/apps/web/src/lib/stores/projects.svelte.ts
Normal file
173
apps/todo/apps/web/src/lib/stores/projects.svelte.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
/**
|
||||
* Projects Store - Manages project state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import type { Project } from '@todo/shared';
|
||||
import * as projectsApi from '$lib/api/projects';
|
||||
|
||||
// State
|
||||
let projects = $state<Project[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const projectsStore = {
|
||||
// Getters
|
||||
get projects() {
|
||||
return projects;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get inbox project (default project)
|
||||
*/
|
||||
get inboxProject() {
|
||||
return projects.find((p) => p.isDefault);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get non-archived projects sorted by order
|
||||
*/
|
||||
get activeProjects() {
|
||||
return projects.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get archived projects
|
||||
*/
|
||||
get archivedProjects() {
|
||||
return projects.filter((p) => p.isArchived);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all projects from API
|
||||
*/
|
||||
async fetchProjects() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
projects = await projectsApi.getProjects();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch projects';
|
||||
console.error('Failed to fetch projects:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get project by ID
|
||||
*/
|
||||
getById(id: string): Project | undefined {
|
||||
return projects.find((p) => p.id === id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get project color by ID
|
||||
*/
|
||||
getColor(projectId: string): string {
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
return project?.color || '#6b7280';
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new project
|
||||
*/
|
||||
async createProject(data: { name: string; description?: string; color?: string; icon?: string }) {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const newProject = await projectsApi.createProject(data);
|
||||
projects = [...projects, newProject];
|
||||
return newProject;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create project';
|
||||
console.error('Failed to create project:', e);
|
||||
throw e;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing project
|
||||
*/
|
||||
async updateProject(
|
||||
id: string,
|
||||
data: { name?: string; description?: string; color?: string; icon?: string }
|
||||
) {
|
||||
error = null;
|
||||
try {
|
||||
const updatedProject = await projectsApi.updateProject(id, data);
|
||||
projects = projects.map((p) => (p.id === id ? updatedProject : p));
|
||||
return updatedProject;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update project';
|
||||
console.error('Failed to update project:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a project
|
||||
*/
|
||||
async deleteProject(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
await projectsApi.deleteProject(id);
|
||||
projects = projects.filter((p) => p.id !== id);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete project';
|
||||
console.error('Failed to delete project:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Archive a project
|
||||
*/
|
||||
async archiveProject(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
const archivedProject = await projectsApi.archiveProject(id);
|
||||
projects = projects.map((p) => (p.id === id ? archivedProject : p));
|
||||
return archivedProject;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to archive project';
|
||||
console.error('Failed to archive project:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder projects
|
||||
*/
|
||||
async reorderProjects(projectIds: string[]) {
|
||||
error = null;
|
||||
try {
|
||||
await projectsApi.reorderProjects(projectIds);
|
||||
// Update local order
|
||||
projects = projects.map((p) => {
|
||||
const newOrder = projectIds.indexOf(p.id);
|
||||
return newOrder !== -1 ? { ...p, order: newOrder } : p;
|
||||
});
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to reorder projects';
|
||||
console.error('Failed to reorder projects:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all state (for logout)
|
||||
*/
|
||||
clear() {
|
||||
projects = [];
|
||||
loading = false;
|
||||
error = null;
|
||||
},
|
||||
};
|
||||
341
apps/todo/apps/web/src/lib/stores/tasks.svelte.ts
Normal file
341
apps/todo/apps/web/src/lib/stores/tasks.svelte.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
/**
|
||||
* Tasks Store - Manages task state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import type { Task, TaskPriority, TaskStatus, Subtask } from '@todo/shared';
|
||||
import * as tasksApi from '$lib/api/tasks';
|
||||
import { isToday, isPast, isFuture, startOfDay, addDays } from 'date-fns';
|
||||
|
||||
// State
|
||||
let tasks = $state<Task[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const tasksStore = {
|
||||
// Getters
|
||||
get tasks() {
|
||||
return tasks;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get incomplete tasks
|
||||
*/
|
||||
get incompleteTasks() {
|
||||
return tasks.filter((t) => !t.isCompleted);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get completed tasks
|
||||
*/
|
||||
get completedTasks() {
|
||||
return tasks.filter((t) => t.isCompleted);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch tasks with optional filters
|
||||
*/
|
||||
async fetchTasks(
|
||||
query: {
|
||||
projectId?: string;
|
||||
labelId?: string;
|
||||
priority?: TaskPriority;
|
||||
status?: TaskStatus;
|
||||
dueBefore?: string;
|
||||
dueAfter?: string;
|
||||
isCompleted?: boolean;
|
||||
search?: string;
|
||||
} = {}
|
||||
) {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
tasks = await tasksApi.getTasks(query);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch tasks';
|
||||
console.error('Failed to fetch tasks:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch inbox tasks (tasks without project)
|
||||
*/
|
||||
async fetchInboxTasks() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
tasks = await tasksApi.getInboxTasks();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch inbox tasks';
|
||||
console.error('Failed to fetch inbox tasks:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch today's tasks
|
||||
*/
|
||||
async fetchTodayTasks() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
tasks = await tasksApi.getTodayTasks();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch today tasks';
|
||||
console.error('Failed to fetch today tasks:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch upcoming tasks
|
||||
*/
|
||||
async fetchUpcomingTasks() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
tasks = await tasksApi.getUpcomingTasks();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch upcoming tasks';
|
||||
console.error('Failed to fetch upcoming tasks:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tasks for a specific project
|
||||
*/
|
||||
getTasksByProject(projectId: string | null): Task[] {
|
||||
if (projectId === null) {
|
||||
return tasks.filter((t) => !t.projectId);
|
||||
}
|
||||
return tasks.filter((t) => t.projectId === projectId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tasks with a specific label
|
||||
*/
|
||||
getTasksByLabel(labelId: string): Task[] {
|
||||
return tasks.filter((t) => t.labels?.some((l) => l.id === labelId));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get overdue tasks
|
||||
*/
|
||||
get overdueTasks(): Task[] {
|
||||
return tasks.filter((t) => {
|
||||
if (!t.dueDate || t.isCompleted) return false;
|
||||
const dueDate = new Date(t.dueDate);
|
||||
return isPast(startOfDay(dueDate)) && !isToday(dueDate);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tasks due today
|
||||
*/
|
||||
get todayTasks(): Task[] {
|
||||
return tasks.filter((t) => {
|
||||
if (!t.dueDate || t.isCompleted) return false;
|
||||
return isToday(new Date(t.dueDate));
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tasks for next 7 days
|
||||
*/
|
||||
get upcomingTasks(): Task[] {
|
||||
const today = startOfDay(new Date());
|
||||
const weekFromNow = addDays(today, 7);
|
||||
return tasks.filter((t) => {
|
||||
if (!t.dueDate || t.isCompleted) return false;
|
||||
const dueDate = new Date(t.dueDate);
|
||||
return isFuture(dueDate) && dueDate <= weekFromNow;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new task
|
||||
*/
|
||||
async createTask(data: {
|
||||
title: string;
|
||||
description?: string;
|
||||
projectId?: string;
|
||||
dueDate?: string;
|
||||
priority?: TaskPriority;
|
||||
labelIds?: string[];
|
||||
subtasks?: Subtask[];
|
||||
recurrenceRule?: string;
|
||||
}) {
|
||||
error = null;
|
||||
try {
|
||||
const newTask = await tasksApi.createTask(data);
|
||||
tasks = [...tasks, newTask];
|
||||
return newTask;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create task';
|
||||
console.error('Failed to create task:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing task
|
||||
*/
|
||||
async updateTask(
|
||||
id: string,
|
||||
data: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
projectId?: string | null;
|
||||
dueDate?: string | null;
|
||||
priority?: TaskPriority;
|
||||
status?: TaskStatus;
|
||||
subtasks?: Subtask[];
|
||||
recurrenceRule?: string | null;
|
||||
}
|
||||
) {
|
||||
error = null;
|
||||
try {
|
||||
const updatedTask = await tasksApi.updateTask(id, data);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
return updatedTask;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update task';
|
||||
console.error('Failed to update task:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a task
|
||||
*/
|
||||
async deleteTask(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
await tasksApi.deleteTask(id);
|
||||
tasks = tasks.filter((t) => t.id !== id);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete task';
|
||||
console.error('Failed to delete task:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark task as complete
|
||||
*/
|
||||
async completeTask(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
const completedTask = await tasksApi.completeTask(id);
|
||||
tasks = tasks.map((t) => (t.id === id ? completedTask : t));
|
||||
return completedTask;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to complete task';
|
||||
console.error('Failed to complete task:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark task as incomplete
|
||||
*/
|
||||
async uncompleteTask(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
const uncompletedTask = await tasksApi.uncompleteTask(id);
|
||||
tasks = tasks.map((t) => (t.id === id ? uncompletedTask : t));
|
||||
return uncompletedTask;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to uncomplete task';
|
||||
console.error('Failed to uncomplete task:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Move task to a different project
|
||||
*/
|
||||
async moveTask(id: string, projectId: string | null) {
|
||||
error = null;
|
||||
try {
|
||||
const movedTask = await tasksApi.moveTask(id, projectId);
|
||||
tasks = tasks.map((t) => (t.id === id ? movedTask : t));
|
||||
return movedTask;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to move task';
|
||||
console.error('Failed to move task:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update task labels
|
||||
*/
|
||||
async updateLabels(id: string, labelIds: string[]) {
|
||||
error = null;
|
||||
try {
|
||||
const updatedTask = await tasksApi.updateTaskLabels(id, labelIds);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
return updatedTask;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update labels';
|
||||
console.error('Failed to update labels:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update subtasks
|
||||
*/
|
||||
async updateSubtasks(id: string, subtasks: Subtask[]) {
|
||||
error = null;
|
||||
try {
|
||||
const updatedTask = await tasksApi.updateSubtasks(id, subtasks);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
return updatedTask;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update subtasks';
|
||||
console.error('Failed to update subtasks:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder tasks
|
||||
*/
|
||||
async reorderTasks(taskIds: string[]) {
|
||||
error = null;
|
||||
try {
|
||||
await tasksApi.reorderTasks(taskIds);
|
||||
// Update local order
|
||||
tasks = tasks.map((t) => {
|
||||
const newOrder = taskIds.indexOf(t.id);
|
||||
return newOrder !== -1 ? { ...t, order: newOrder } : t;
|
||||
});
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to reorder tasks';
|
||||
console.error('Failed to reorder tasks:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all state (for logout)
|
||||
*/
|
||||
clear() {
|
||||
tasks = [];
|
||||
loading = false;
|
||||
error = null;
|
||||
},
|
||||
};
|
||||
7
apps/todo/apps/web/src/lib/stores/theme.ts
Normal file
7
apps/todo/apps/web/src/lib/stores/theme.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { createThemeStore } from '@manacore/shared-theme';
|
||||
|
||||
// Create theme store with Todo's primary color (purple/violet)
|
||||
export const theme = createThemeStore({
|
||||
appId: 'todo',
|
||||
defaultVariant: 'amethyst',
|
||||
});
|
||||
160
apps/todo/apps/web/src/lib/stores/view.svelte.ts
Normal file
160
apps/todo/apps/web/src/lib/stores/view.svelte.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* View Store - Manages current view state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
export type ViewType =
|
||||
| 'inbox'
|
||||
| 'today'
|
||||
| 'upcoming'
|
||||
| 'project'
|
||||
| 'label'
|
||||
| 'completed'
|
||||
| 'search';
|
||||
export type SortBy = 'dueDate' | 'priority' | 'title' | 'createdAt' | 'order';
|
||||
export type SortOrder = 'asc' | 'desc';
|
||||
|
||||
// State
|
||||
let currentView = $state<ViewType>('inbox');
|
||||
let currentProjectId = $state<string | null>(null);
|
||||
let currentLabelId = $state<string | null>(null);
|
||||
let searchQuery = $state('');
|
||||
let sortBy = $state<SortBy>('order');
|
||||
let sortOrder = $state<SortOrder>('asc');
|
||||
let showCompleted = $state(false);
|
||||
|
||||
export const viewStore = {
|
||||
// Getters
|
||||
get currentView() {
|
||||
return currentView;
|
||||
},
|
||||
get currentProjectId() {
|
||||
return currentProjectId;
|
||||
},
|
||||
get currentLabelId() {
|
||||
return currentLabelId;
|
||||
},
|
||||
get searchQuery() {
|
||||
return searchQuery;
|
||||
},
|
||||
get sortBy() {
|
||||
return sortBy;
|
||||
},
|
||||
get sortOrder() {
|
||||
return sortOrder;
|
||||
},
|
||||
get showCompleted() {
|
||||
return showCompleted;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set current view to inbox
|
||||
*/
|
||||
setInbox() {
|
||||
currentView = 'inbox';
|
||||
currentProjectId = null;
|
||||
currentLabelId = null;
|
||||
searchQuery = '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Set current view to today
|
||||
*/
|
||||
setToday() {
|
||||
currentView = 'today';
|
||||
currentProjectId = null;
|
||||
currentLabelId = null;
|
||||
searchQuery = '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Set current view to upcoming
|
||||
*/
|
||||
setUpcoming() {
|
||||
currentView = 'upcoming';
|
||||
currentProjectId = null;
|
||||
currentLabelId = null;
|
||||
searchQuery = '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Set current view to a specific project
|
||||
*/
|
||||
setProject(projectId: string) {
|
||||
currentView = 'project';
|
||||
currentProjectId = projectId;
|
||||
currentLabelId = null;
|
||||
searchQuery = '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Set current view to a specific label
|
||||
*/
|
||||
setLabel(labelId: string) {
|
||||
currentView = 'label';
|
||||
currentProjectId = null;
|
||||
currentLabelId = labelId;
|
||||
searchQuery = '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Set current view to completed
|
||||
*/
|
||||
setCompleted() {
|
||||
currentView = 'completed';
|
||||
currentProjectId = null;
|
||||
currentLabelId = null;
|
||||
searchQuery = '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Set current view to search
|
||||
*/
|
||||
setSearch(query: string) {
|
||||
currentView = 'search';
|
||||
currentProjectId = null;
|
||||
currentLabelId = null;
|
||||
searchQuery = query;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update search query
|
||||
*/
|
||||
updateSearchQuery(query: string) {
|
||||
searchQuery = query;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set sort options
|
||||
*/
|
||||
setSort(by: SortBy, order: SortOrder = 'asc') {
|
||||
sortBy = by;
|
||||
sortOrder = order;
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle sort order
|
||||
*/
|
||||
toggleSortOrder() {
|
||||
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle show completed
|
||||
*/
|
||||
toggleShowCompleted() {
|
||||
showCompleted = !showCompleted;
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset to default state
|
||||
*/
|
||||
reset() {
|
||||
currentView = 'inbox';
|
||||
currentProjectId = null;
|
||||
currentLabelId = null;
|
||||
searchQuery = '';
|
||||
sortBy = 'order';
|
||||
sortOrder = 'asc';
|
||||
showCompleted = false;
|
||||
},
|
||||
};
|
||||
5
apps/todo/apps/web/src/routes/(auth)/+layout.svelte
Normal file
5
apps/todo/apps/web/src/routes/(auth)/+layout.svelte
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
|
||||
import { getForgotPasswordTranslations } from '@manacore/shared-i18n';
|
||||
import { TodoLogo } from '@manacore/shared-branding';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import AppSlider from '$lib/components/AppSlider.svelte';
|
||||
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
||||
|
||||
// Get translations based on current locale
|
||||
const translations = $derived(getForgotPasswordTranslations($locale || 'de'));
|
||||
|
||||
async function handleResetPassword(email: string) {
|
||||
return authStore.resetPassword(email);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{translations.title} | Todo</title>
|
||||
</svelte:head>
|
||||
|
||||
<ForgotPasswordPage
|
||||
appName="Todo"
|
||||
logo={TodoLogo}
|
||||
primaryColor="#8b5cf6"
|
||||
onResetPassword={handleResetPassword}
|
||||
{goto}
|
||||
loginPath="/login"
|
||||
lightBackground="#f3e8ff"
|
||||
darkBackground="#1e1b4b"
|
||||
{translations}
|
||||
>
|
||||
{#snippet headerControls()}
|
||||
<LanguageSelector />
|
||||
{/snippet}
|
||||
{#snippet appSlider()}
|
||||
<AppSlider />
|
||||
{/snippet}
|
||||
</ForgotPasswordPage>
|
||||
48
apps/todo/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
48
apps/todo/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||
import { getLoginTranslations } from '@manacore/shared-i18n';
|
||||
import { TodoLogo } from '@manacore/shared-branding';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import AppSlider from '$lib/components/AppSlider.svelte';
|
||||
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
||||
|
||||
// Get redirect URL from query params
|
||||
const redirectTo = $derived($page.url.searchParams.get('redirectTo') || '/');
|
||||
|
||||
// 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>
|
||||
|
||||
<svelte:head>
|
||||
<title>{translations.title} | Todo</title>
|
||||
</svelte:head>
|
||||
|
||||
<LoginPage
|
||||
appName="Todo"
|
||||
logo={TodoLogo}
|
||||
primaryColor="#8b5cf6"
|
||||
onSignIn={handleSignIn}
|
||||
{goto}
|
||||
enableGoogle={false}
|
||||
enableApple={false}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
forgotPasswordPath="/forgot-password"
|
||||
lightBackground="#f3e8ff"
|
||||
darkBackground="#1e1b4b"
|
||||
{translations}
|
||||
>
|
||||
{#snippet headerControls()}
|
||||
<LanguageSelector />
|
||||
{/snippet}
|
||||
{#snippet appSlider()}
|
||||
<AppSlider />
|
||||
{/snippet}
|
||||
</LoginPage>
|
||||
43
apps/todo/apps/web/src/routes/(auth)/register/+page.svelte
Normal file
43
apps/todo/apps/web/src/routes/(auth)/register/+page.svelte
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import { getRegisterTranslations } from '@manacore/shared-i18n';
|
||||
import { TodoLogo } from '@manacore/shared-branding';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import AppSlider from '$lib/components/AppSlider.svelte';
|
||||
import LanguageSelector from '$lib/components/LanguageSelector.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>
|
||||
|
||||
<svelte:head>
|
||||
<title>{translations.title} | Todo</title>
|
||||
</svelte:head>
|
||||
|
||||
<RegisterPage
|
||||
appName="Todo"
|
||||
logo={TodoLogo}
|
||||
primaryColor="#8b5cf6"
|
||||
onSignUp={handleSignUp}
|
||||
{goto}
|
||||
enableGoogle={false}
|
||||
enableApple={false}
|
||||
successRedirect="/"
|
||||
loginPath="/login"
|
||||
lightBackground="#f3e8ff"
|
||||
darkBackground="#1e1b4b"
|
||||
{translations}
|
||||
>
|
||||
{#snippet headerControls()}
|
||||
<LanguageSelector />
|
||||
{/snippet}
|
||||
{#snippet appSlider()}
|
||||
<AppSlider />
|
||||
{/snippet}
|
||||
</RegisterPage>
|
||||
262
apps/todo/apps/web/src/routes/+layout.svelte
Normal file
262
apps/todo/apps/web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { PillNavigation } from '@manacore/shared-ui';
|
||||
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { projectsStore } from '$lib/stores/projects.svelte';
|
||||
import { labelsStore } from '$lib/stores/labels.svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
|
||||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
import '../app.css';
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('todo');
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
let isSidebarMode = $state(false);
|
||||
let isCollapsed = $state(false);
|
||||
|
||||
// Use theme store's isDark directly
|
||||
let isDark = $derived(theme.isDark);
|
||||
|
||||
// Theme variant dropdown items
|
||||
let themeVariantItems = $derived<PillDropdownItem[]>([
|
||||
...theme.variants.map((variant) => ({
|
||||
id: variant,
|
||||
label: THEME_DEFINITIONS[variant].label,
|
||||
icon: THEME_DEFINITIONS[variant].icon,
|
||||
onClick: () => theme.setVariant(variant),
|
||||
active: theme.variant === variant,
|
||||
})),
|
||||
{
|
||||
id: 'all-themes',
|
||||
label: 'Alle Themes',
|
||||
icon: 'palette',
|
||||
onClick: () => goto('/themes'),
|
||||
active: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// Current theme variant label
|
||||
let currentThemeVariantLabel = $derived(THEME_DEFINITIONS[theme.variant].label);
|
||||
|
||||
// Language selector items
|
||||
let currentLocale = $derived($locale || 'de');
|
||||
function handleLocaleChange(newLocale: string) {
|
||||
locale.set(newLocale);
|
||||
}
|
||||
let languageItems = $derived(
|
||||
getLanguageDropdownItems(['de', 'en'], currentLocale, handleLocaleChange)
|
||||
);
|
||||
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
|
||||
|
||||
// User email for user dropdown
|
||||
let userEmail = $derived(authStore.user?.email || 'Menü');
|
||||
|
||||
// Check if current route is an auth route (no navigation needed)
|
||||
let isAuthRoute = $derived(
|
||||
$page.url.pathname.startsWith('/login') ||
|
||||
$page.url.pathname.startsWith('/register') ||
|
||||
$page.url.pathname.startsWith('/forgot-password')
|
||||
);
|
||||
|
||||
// Navigation items for Todo
|
||||
const navItems: PillNavItem[] = [
|
||||
{ href: '/', label: 'Inbox', icon: 'inbox' },
|
||||
{ href: '/today', label: 'Heute', icon: 'calendar-day' },
|
||||
{ href: '/upcoming', label: 'Demnächst', icon: 'calendar' },
|
||||
{ href: '/completed', label: 'Erledigt', icon: 'check-circle' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
];
|
||||
|
||||
// Navigation shortcuts (Ctrl+1-5)
|
||||
const navRoutes = navItems.map((item) => item.href);
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
|
||||
const num = parseInt(event.key);
|
||||
if (num >= 1 && num <= navRoutes.length) {
|
||||
event.preventDefault();
|
||||
const route = navRoutes[num - 1];
|
||||
if (route) {
|
||||
goto(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleModeChange(isSidebar: boolean) {
|
||||
isSidebarMode = isSidebar;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('todo-nav-sidebar', String(isSidebar));
|
||||
}
|
||||
}
|
||||
|
||||
function handleCollapsedChange(collapsed: boolean) {
|
||||
isCollapsed = collapsed;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('todo-nav-collapsed', String(collapsed));
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleTheme() {
|
||||
theme.toggleMode();
|
||||
}
|
||||
|
||||
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
|
||||
theme.setMode(mode);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.signOut();
|
||||
projectsStore.clear();
|
||||
labelsStore.clear();
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Initialize theme
|
||||
theme.initialize();
|
||||
|
||||
// Initialize auth
|
||||
await authStore.initialize();
|
||||
|
||||
// Load data if authenticated
|
||||
if (authStore.isAuthenticated) {
|
||||
await Promise.all([projectsStore.fetchProjects(), labelsStore.fetchLabels()]);
|
||||
}
|
||||
|
||||
// Initialize sidebar mode from localStorage
|
||||
const savedSidebar = localStorage.getItem('todo-nav-sidebar');
|
||||
if (savedSidebar === 'true') {
|
||||
isSidebarMode = true;
|
||||
}
|
||||
|
||||
// Initialize collapsed state from localStorage
|
||||
const savedCollapsed = localStorage.getItem('todo-nav-collapsed');
|
||||
if (savedCollapsed === 'true') {
|
||||
isCollapsed = true;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if isAuthRoute}
|
||||
<!-- Auth routes: no navigation, just render content -->
|
||||
{@render children()}
|
||||
{:else if loading}
|
||||
<div class="flex min-h-screen items-center justify-center bg-background">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||
></div>
|
||||
<p class="text-muted-foreground">Laden...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="layout-container">
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Todo"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition="bottom"
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#8b5cf6"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
allAppsHref="/apps"
|
||||
/>
|
||||
|
||||
<main
|
||||
class="main-content bg-background"
|
||||
class:sidebar-mode={isSidebarMode && !isCollapsed}
|
||||
class:floating-mode={!isSidebarMode && !isCollapsed}
|
||||
>
|
||||
<div class="content-wrapper">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.layout-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
transition: all 300ms ease;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.main-content.floating-mode {
|
||||
padding-top: 70px;
|
||||
}
|
||||
|
||||
.main-content.sidebar-mode {
|
||||
padding-left: 180px;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 900px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.content-wrapper {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.content-wrapper {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
74
apps/todo/apps/web/src/routes/+page.svelte
Normal file
74
apps/todo/apps/web/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { tasksStore } from '$lib/stores/tasks.svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import TaskList from '$lib/components/TaskList.svelte';
|
||||
import QuickAddTask from '$lib/components/QuickAddTask.svelte';
|
||||
|
||||
let isLoading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
// Redirect to login if not authenticated
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set view to inbox
|
||||
viewStore.setInbox();
|
||||
|
||||
// Fetch inbox tasks
|
||||
await tasksStore.fetchInboxTasks();
|
||||
isLoading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Inbox | Todo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="inbox-view">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-foreground">Inbox</h1>
|
||||
<p class="text-muted-foreground text-sm mt-1">Aufgaben ohne Projekt</p>
|
||||
</header>
|
||||
|
||||
<QuickAddTask />
|
||||
|
||||
{#if isLoading || tasksStore.loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="animate-spin h-8 w-8 border-4 border-primary border-r-transparent rounded-full"
|
||||
></div>
|
||||
</div>
|
||||
{:else if tasksStore.error}
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg">
|
||||
{tasksStore.error}
|
||||
</div>
|
||||
{:else if tasksStore.incompleteTasks.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<div class="text-6xl mb-4">📥</div>
|
||||
<h3 class="text-lg font-medium text-foreground mb-2">Inbox ist leer</h3>
|
||||
<p class="text-muted-foreground">Füge eine neue Aufgabe hinzu, um loszulegen.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<TaskList tasks={tasksStore.incompleteTasks} />
|
||||
{/if}
|
||||
|
||||
{#if viewStore.showCompleted && tasksStore.completedTasks.length > 0}
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-muted-foreground mb-4">
|
||||
Erledigt ({tasksStore.completedTasks.length})
|
||||
</h2>
|
||||
<TaskList tasks={tasksStore.completedTasks} showCompleted />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.inbox-view {
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
</style>
|
||||
58
apps/todo/apps/web/src/routes/completed/+page.svelte
Normal file
58
apps/todo/apps/web/src/routes/completed/+page.svelte
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { tasksStore } from '$lib/stores/tasks.svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import TaskList from '$lib/components/TaskList.svelte';
|
||||
|
||||
let isLoading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
viewStore.setCompleted();
|
||||
await tasksStore.fetchTasks({ isCompleted: true });
|
||||
isLoading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Erledigt | Todo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="completed-view">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-foreground">Erledigt</h1>
|
||||
<p class="text-muted-foreground text-sm mt-1">Erledigte Aufgaben</p>
|
||||
</header>
|
||||
|
||||
{#if isLoading || tasksStore.loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="animate-spin h-8 w-8 border-4 border-primary border-r-transparent rounded-full"
|
||||
></div>
|
||||
</div>
|
||||
{:else if tasksStore.error}
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg">
|
||||
{tasksStore.error}
|
||||
</div>
|
||||
{:else if tasksStore.completedTasks.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<div class="text-6xl mb-4">✅</div>
|
||||
<h3 class="text-lg font-medium text-foreground mb-2">Noch nichts erledigt</h3>
|
||||
<p class="text-muted-foreground">Erledigte Aufgaben werden hier angezeigt.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<TaskList tasks={tasksStore.completedTasks} showCompleted />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.completed-view {
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
</style>
|
||||
22
apps/todo/apps/web/src/routes/feedback/+page.svelte
Normal file
22
apps/todo/apps/web/src/routes/feedback/+page.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { FeedbackPage } from '@manacore/shared-feedback-ui';
|
||||
|
||||
onMount(() => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Feedback | Todo</title>
|
||||
</svelte:head>
|
||||
|
||||
<FeedbackPage
|
||||
appName="Todo"
|
||||
userEmail={authStore.user?.email || undefined}
|
||||
primaryColor="#8b5cf6"
|
||||
/>
|
||||
76
apps/todo/apps/web/src/routes/settings/+page.svelte
Normal file
76
apps/todo/apps/web/src/routes/settings/+page.svelte
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
onMount(() => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Einstellungen | Todo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="settings-view">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-foreground">Einstellungen</h1>
|
||||
</header>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Account Section -->
|
||||
<section class="bg-card rounded-lg border border-border p-6">
|
||||
<h2 class="text-lg font-semibold text-foreground mb-4">Konto</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">E-Mail</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{authStore.user?.email || 'Nicht angemeldet'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-border">
|
||||
<button
|
||||
class="text-red-500 hover:text-red-600 text-sm font-medium"
|
||||
onclick={() => {
|
||||
authStore.signOut();
|
||||
goto('/login');
|
||||
}}
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Appearance Section -->
|
||||
<section class="bg-card rounded-lg border border-border p-6">
|
||||
<h2 class="text-lg font-semibold text-foreground mb-4">Erscheinungsbild</h2>
|
||||
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Theme-Einstellungen sind in der Navigation verfügbar.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- About Section -->
|
||||
<section class="bg-card rounded-lg border border-border p-6">
|
||||
<h2 class="text-lg font-semibold text-foreground mb-4">Über</h2>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-muted-foreground">Todo App v1.0.0</p>
|
||||
<p class="text-sm text-muted-foreground">Teil des ManaCore Ökosystems</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-view {
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
</style>
|
||||
91
apps/todo/apps/web/src/routes/today/+page.svelte
Normal file
91
apps/todo/apps/web/src/routes/today/+page.svelte
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { tasksStore } from '$lib/stores/tasks.svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import TaskList from '$lib/components/TaskList.svelte';
|
||||
import QuickAddTask from '$lib/components/QuickAddTask.svelte';
|
||||
|
||||
let isLoading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
viewStore.setToday();
|
||||
await tasksStore.fetchTodayTasks();
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
// Separate overdue and today tasks
|
||||
let overdueTasks = $derived(tasksStore.overdueTasks);
|
||||
let todayTasks = $derived(tasksStore.todayTasks);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Heute | Todo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="today-view">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-foreground">Heute</h1>
|
||||
<p class="text-muted-foreground text-sm mt-1">Fällige Aufgaben für heute</p>
|
||||
</header>
|
||||
|
||||
<QuickAddTask />
|
||||
|
||||
{#if isLoading || tasksStore.loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="animate-spin h-8 w-8 border-4 border-primary border-r-transparent rounded-full"
|
||||
></div>
|
||||
</div>
|
||||
{:else if tasksStore.error}
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg">
|
||||
{tasksStore.error}
|
||||
</div>
|
||||
{:else}
|
||||
{#if overdueTasks.length > 0}
|
||||
<div class="mb-6">
|
||||
<h2 class="text-sm font-semibold text-red-500 mb-3 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
Überfällig ({overdueTasks.length})
|
||||
</h2>
|
||||
<TaskList tasks={overdueTasks} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if todayTasks.length > 0}
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-muted-foreground mb-3">
|
||||
Heute ({todayTasks.length})
|
||||
</h2>
|
||||
<TaskList tasks={todayTasks} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if overdueTasks.length === 0 && todayTasks.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<div class="text-6xl mb-4">🎉</div>
|
||||
<h3 class="text-lg font-medium text-foreground mb-2">Alles erledigt!</h3>
|
||||
<p class="text-muted-foreground">Keine Aufgaben für heute.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.today-view {
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
</style>
|
||||
100
apps/todo/apps/web/src/routes/upcoming/+page.svelte
Normal file
100
apps/todo/apps/web/src/routes/upcoming/+page.svelte
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { format, addDays, startOfDay } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { tasksStore } from '$lib/stores/tasks.svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import TaskList from '$lib/components/TaskList.svelte';
|
||||
import type { Task } from '@todo/shared';
|
||||
|
||||
let isLoading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
viewStore.setUpcoming();
|
||||
await tasksStore.fetchUpcomingTasks();
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
// Group tasks by day
|
||||
let groupedTasks = $derived(() => {
|
||||
const groups: { date: Date; label: string; tasks: Task[] }[] = [];
|
||||
const today = startOfDay(new Date());
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = addDays(today, i);
|
||||
const dayTasks = tasksStore.tasks.filter((task) => {
|
||||
if (!task.dueDate || task.isCompleted) return false;
|
||||
const taskDate = startOfDay(new Date(task.dueDate));
|
||||
return taskDate.getTime() === date.getTime();
|
||||
});
|
||||
|
||||
if (dayTasks.length > 0) {
|
||||
let label: string;
|
||||
if (i === 0) {
|
||||
label = 'Heute';
|
||||
} else if (i === 1) {
|
||||
label = 'Morgen';
|
||||
} else {
|
||||
label = format(date, 'EEEE, d. MMMM', { locale: de });
|
||||
}
|
||||
|
||||
groups.push({ date, label, tasks: dayTasks });
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Demnächst | Todo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="upcoming-view">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-foreground">Demnächst</h1>
|
||||
<p class="text-muted-foreground text-sm mt-1">Aufgaben der nächsten 7 Tage</p>
|
||||
</header>
|
||||
|
||||
{#if isLoading || tasksStore.loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="animate-spin h-8 w-8 border-4 border-primary border-r-transparent rounded-full"
|
||||
></div>
|
||||
</div>
|
||||
{:else if tasksStore.error}
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg">
|
||||
{tasksStore.error}
|
||||
</div>
|
||||
{:else if groupedTasks().length === 0}
|
||||
<div class="text-center py-12">
|
||||
<div class="text-6xl mb-4">📅</div>
|
||||
<h3 class="text-lg font-medium text-foreground mb-2">Keine anstehenden Aufgaben</h3>
|
||||
<p class="text-muted-foreground">Keine Aufgaben in den nächsten 7 Tagen geplant.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-8">
|
||||
{#each groupedTasks() as group}
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-muted-foreground mb-3">
|
||||
{group.label} ({group.tasks.length})
|
||||
</h2>
|
||||
<TaskList tasks={group.tasks} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.upcoming-view {
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
</style>
|
||||
0
apps/todo/apps/web/static/favicon.png
Normal file
0
apps/todo/apps/web/static/favicon.png
Normal file
12
apps/todo/apps/web/svelte.config.js
Normal file
12
apps/todo/apps/web/svelte.config.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
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;
|
||||
14
apps/todo/apps/web/tsconfig.json
Normal file
14
apps/todo/apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
45
apps/todo/apps/web/vite.config.ts
Normal file
45
apps/todo/apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
port: 5188,
|
||||
strictPort: true,
|
||||
},
|
||||
ssr: {
|
||||
noExternal: [
|
||||
'@todo/shared',
|
||||
'@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: [
|
||||
'@todo/shared',
|
||||
'@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',
|
||||
],
|
||||
},
|
||||
});
|
||||
22
apps/todo/package.json
Normal file
22
apps/todo/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "todo",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Todo App - Task Management for ManaCore Ecosystem",
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"dev:backend": "pnpm --filter @todo/backend dev",
|
||||
"dev:web": "pnpm --filter @todo/web dev",
|
||||
"dev:landing": "pnpm --filter @todo/landing dev",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
"clean": "turbo run clean",
|
||||
"db:push": "pnpm --filter @todo/backend db:push",
|
||||
"db:studio": "pnpm --filter @todo/backend db:studio",
|
||||
"db:seed": "pnpm --filter @todo/backend db:seed"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0"
|
||||
}
|
||||
20
apps/todo/packages/shared/package.json
Normal file
20
apps/todo/packages/shared/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "@todo/shared",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types/index.ts",
|
||||
"./utils": "./src/utils/index.ts",
|
||||
"./constants": "./src/constants/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
88
apps/todo/packages/shared/src/constants/index.ts
Normal file
88
apps/todo/packages/shared/src/constants/index.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// Default project colors
|
||||
export const DEFAULT_PROJECT_COLORS = [
|
||||
'#3B82F6', // Blue
|
||||
'#EF4444', // Red
|
||||
'#10B981', // Green
|
||||
'#F59E0B', // Amber
|
||||
'#8B5CF6', // Violet
|
||||
'#EC4899', // Pink
|
||||
'#06B6D4', // Cyan
|
||||
'#F97316', // Orange
|
||||
] as const;
|
||||
|
||||
// Default label colors
|
||||
export const DEFAULT_LABEL_COLORS = [
|
||||
'#6B7280', // Gray
|
||||
'#EF4444', // Red
|
||||
'#F59E0B', // Amber
|
||||
'#10B981', // Green
|
||||
'#3B82F6', // Blue
|
||||
'#8B5CF6', // Violet
|
||||
'#EC4899', // Pink
|
||||
'#14B8A6', // Teal
|
||||
] as const;
|
||||
|
||||
// Priority configuration
|
||||
export const PRIORITY_CONFIG = {
|
||||
low: {
|
||||
label: 'Low',
|
||||
color: '#6B7280',
|
||||
order: 0,
|
||||
},
|
||||
medium: {
|
||||
label: 'Medium',
|
||||
color: '#3B82F6',
|
||||
order: 1,
|
||||
},
|
||||
high: {
|
||||
label: 'High',
|
||||
color: '#F59E0B',
|
||||
order: 2,
|
||||
},
|
||||
urgent: {
|
||||
label: 'Urgent',
|
||||
color: '#EF4444',
|
||||
order: 3,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Recurrence presets
|
||||
export const RECURRENCE_PRESETS = [
|
||||
{ label: 'Daily', rule: 'FREQ=DAILY' },
|
||||
{ label: 'Weekdays', rule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR' },
|
||||
{ label: 'Weekly', rule: 'FREQ=WEEKLY' },
|
||||
{ label: 'Bi-weekly', rule: 'FREQ=WEEKLY;INTERVAL=2' },
|
||||
{ label: 'Monthly', rule: 'FREQ=MONTHLY' },
|
||||
{ label: 'Yearly', rule: 'FREQ=YEARLY' },
|
||||
] as const;
|
||||
|
||||
// Reminder presets (in minutes)
|
||||
export const REMINDER_PRESETS = [
|
||||
{ label: 'At time of task', minutes: 0 },
|
||||
{ label: '5 minutes before', minutes: 5 },
|
||||
{ label: '15 minutes before', minutes: 15 },
|
||||
{ label: '30 minutes before', minutes: 30 },
|
||||
{ label: '1 hour before', minutes: 60 },
|
||||
{ label: '2 hours before', minutes: 120 },
|
||||
{ label: '1 day before', minutes: 1440 },
|
||||
{ label: '2 days before', minutes: 2880 },
|
||||
{ label: '1 week before', minutes: 10080 },
|
||||
] as const;
|
||||
|
||||
// View types
|
||||
export type ViewType =
|
||||
| 'inbox'
|
||||
| 'today'
|
||||
| 'upcoming'
|
||||
| 'project'
|
||||
| 'label'
|
||||
| 'completed'
|
||||
| 'search';
|
||||
|
||||
// Sort options
|
||||
export const SORT_OPTIONS = [
|
||||
{ value: 'order', label: 'Manual' },
|
||||
{ value: 'dueDate', label: 'Due Date' },
|
||||
{ value: 'priority', label: 'Priority' },
|
||||
{ value: 'createdAt', label: 'Created' },
|
||||
] as const;
|
||||
8
apps/todo/packages/shared/src/index.ts
Normal file
8
apps/todo/packages/shared/src/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Types
|
||||
export * from './types';
|
||||
|
||||
// Constants
|
||||
export * from './constants';
|
||||
|
||||
// Utils
|
||||
export * from './utils';
|
||||
4
apps/todo/packages/shared/src/types/index.ts
Normal file
4
apps/todo/packages/shared/src/types/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './project';
|
||||
export * from './task';
|
||||
export * from './label';
|
||||
export * from './reminder';
|
||||
18
apps/todo/packages/shared/src/types/label.ts
Normal file
18
apps/todo/packages/shared/src/types/label.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export interface Label {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
||||
export interface CreateLabelInput {
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface UpdateLabelInput {
|
||||
name?: string;
|
||||
color?: string;
|
||||
}
|
||||
44
apps/todo/packages/shared/src/types/project.ts
Normal file
44
apps/todo/packages/shared/src/types/project.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
export interface ProjectSettings {
|
||||
defaultView?: 'list' | 'board';
|
||||
showCompletedTasks?: boolean;
|
||||
sortBy?: 'dueDate' | 'priority' | 'createdAt' | 'order';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
color: string;
|
||||
icon?: string | null;
|
||||
order: number;
|
||||
isArchived: boolean;
|
||||
isDefault: boolean;
|
||||
settings?: ProjectSettings | null;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
||||
export interface CreateProjectInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
isDefault?: boolean;
|
||||
settings?: ProjectSettings;
|
||||
}
|
||||
|
||||
export interface UpdateProjectInput {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
color?: string;
|
||||
icon?: string | null;
|
||||
isArchived?: boolean;
|
||||
isDefault?: boolean;
|
||||
settings?: ProjectSettings | null;
|
||||
}
|
||||
|
||||
export interface ReorderProjectsInput {
|
||||
projectIds: string[];
|
||||
}
|
||||
19
apps/todo/packages/shared/src/types/reminder.ts
Normal file
19
apps/todo/packages/shared/src/types/reminder.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export type ReminderType = 'push' | 'email' | 'both';
|
||||
export type ReminderStatus = 'pending' | 'sent' | 'failed' | 'cancelled';
|
||||
|
||||
export interface Reminder {
|
||||
id: string;
|
||||
taskId: string;
|
||||
userId: string;
|
||||
minutesBefore: number;
|
||||
reminderTime: Date | string;
|
||||
type: ReminderType;
|
||||
status: ReminderStatus;
|
||||
sentAt?: Date | string | null;
|
||||
createdAt: Date | string;
|
||||
}
|
||||
|
||||
export interface CreateReminderInput {
|
||||
minutesBefore: number;
|
||||
type?: ReminderType;
|
||||
}
|
||||
128
apps/todo/packages/shared/src/types/task.ts
Normal file
128
apps/todo/packages/shared/src/types/task.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import type { Label } from './label';
|
||||
|
||||
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
|
||||
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
|
||||
|
||||
export interface Subtask {
|
||||
id: string;
|
||||
title: string;
|
||||
isCompleted: boolean;
|
||||
completedAt?: string | null;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface TaskMetadata {
|
||||
notes?: string;
|
||||
attachments?: string[];
|
||||
linkedCalendarEventId?: string | null;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
projectId?: string | null;
|
||||
userId: string;
|
||||
parentTaskId?: string | null;
|
||||
|
||||
// Content
|
||||
title: string;
|
||||
description?: string | null;
|
||||
|
||||
// Scheduling
|
||||
dueDate?: Date | string | null;
|
||||
dueTime?: string | null; // HH:mm format
|
||||
startDate?: Date | string | null;
|
||||
|
||||
// Priority & Status
|
||||
priority: TaskPriority;
|
||||
status: TaskStatus;
|
||||
|
||||
// Completion
|
||||
isCompleted: boolean;
|
||||
completedAt?: Date | string | null;
|
||||
|
||||
// Ordering
|
||||
order: number;
|
||||
|
||||
// Recurrence
|
||||
recurrenceRule?: string | null;
|
||||
recurrenceEndDate?: Date | string | null;
|
||||
lastOccurrence?: Date | string | null;
|
||||
|
||||
// Subtasks
|
||||
subtasks?: Subtask[] | null;
|
||||
|
||||
// Metadata
|
||||
metadata?: TaskMetadata | null;
|
||||
|
||||
// Relations (populated on fetch)
|
||||
labels?: Label[];
|
||||
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
||||
export interface CreateTaskInput {
|
||||
projectId?: string | null;
|
||||
parentTaskId?: string | null;
|
||||
title: string;
|
||||
description?: string;
|
||||
dueDate?: string | null;
|
||||
dueTime?: string | null;
|
||||
startDate?: string | null;
|
||||
priority?: TaskPriority;
|
||||
recurrenceRule?: string | null;
|
||||
recurrenceEndDate?: string | null;
|
||||
subtasks?: Omit<Subtask, 'id'>[];
|
||||
labelIds?: string[];
|
||||
metadata?: TaskMetadata;
|
||||
}
|
||||
|
||||
export interface UpdateTaskInput {
|
||||
projectId?: string | null;
|
||||
parentTaskId?: string | null;
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
dueDate?: string | null;
|
||||
dueTime?: string | null;
|
||||
startDate?: string | null;
|
||||
priority?: TaskPriority;
|
||||
status?: TaskStatus;
|
||||
isCompleted?: boolean;
|
||||
order?: number;
|
||||
recurrenceRule?: string | null;
|
||||
recurrenceEndDate?: string | null;
|
||||
subtasks?: Subtask[] | null;
|
||||
metadata?: TaskMetadata | null;
|
||||
}
|
||||
|
||||
export interface QueryTasksInput {
|
||||
projectId?: string;
|
||||
labelId?: string;
|
||||
priority?: TaskPriority;
|
||||
status?: TaskStatus;
|
||||
isCompleted?: boolean;
|
||||
dueDateFrom?: string;
|
||||
dueDateTo?: string;
|
||||
search?: string;
|
||||
sortBy?: 'dueDate' | 'priority' | 'createdAt' | 'order';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface ReorderTasksInput {
|
||||
taskIds: string[];
|
||||
projectId?: string | null;
|
||||
}
|
||||
|
||||
export interface MoveTaskInput {
|
||||
projectId: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateTaskLabelsInput {
|
||||
labelIds: string[];
|
||||
}
|
||||
|
||||
export interface UpdateSubtasksInput {
|
||||
subtasks: Subtask[];
|
||||
}
|
||||
117
apps/todo/packages/shared/src/utils/index.ts
Normal file
117
apps/todo/packages/shared/src/utils/index.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import type { Task, TaskPriority } from '../types';
|
||||
import { PRIORITY_CONFIG } from '../constants';
|
||||
|
||||
/**
|
||||
* Generate a unique ID for subtasks
|
||||
*/
|
||||
export function generateSubtaskId(): string {
|
||||
return `subtask_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a task is overdue
|
||||
*/
|
||||
export function isTaskOverdue(task: Task): boolean {
|
||||
if (!task.dueDate || task.isCompleted) return false;
|
||||
const dueDate = new Date(task.dueDate);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return dueDate < today;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a task is due today
|
||||
*/
|
||||
export function isTaskDueToday(task: Task): boolean {
|
||||
if (!task.dueDate || task.isCompleted) return false;
|
||||
const dueDate = new Date(task.dueDate);
|
||||
const today = new Date();
|
||||
return (
|
||||
dueDate.getFullYear() === today.getFullYear() &&
|
||||
dueDate.getMonth() === today.getMonth() &&
|
||||
dueDate.getDate() === today.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get priority order for sorting
|
||||
*/
|
||||
export function getPriorityOrder(priority: TaskPriority): number {
|
||||
return PRIORITY_CONFIG[priority]?.order ?? 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get priority color
|
||||
*/
|
||||
export function getPriorityColor(priority: TaskPriority): string {
|
||||
return PRIORITY_CONFIG[priority]?.color ?? '#6B7280';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort tasks by priority (highest first)
|
||||
*/
|
||||
export function sortByPriority(tasks: Task[]): Task[] {
|
||||
return [...tasks].sort((a, b) => getPriorityOrder(b.priority) - getPriorityOrder(a.priority));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort tasks by due date (earliest first, null last)
|
||||
*/
|
||||
export function sortByDueDate(tasks: Task[]): Task[] {
|
||||
return [...tasks].sort((a, b) => {
|
||||
if (!a.dueDate && !b.dueDate) return 0;
|
||||
if (!a.dueDate) return 1;
|
||||
if (!b.dueDate) return -1;
|
||||
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate subtask completion percentage
|
||||
*/
|
||||
export function getSubtaskProgress(task: Task): number {
|
||||
if (!task.subtasks || task.subtasks.length === 0) return 0;
|
||||
const completed = task.subtasks.filter((s) => s.isCompleted).length;
|
||||
return Math.round((completed / task.subtasks.length) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format due date for display
|
||||
*/
|
||||
export function formatDueDate(dueDate: string | Date, includeTime = false): string {
|
||||
const date = new Date(dueDate);
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const isToday =
|
||||
date.getFullYear() === today.getFullYear() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getDate() === today.getDate();
|
||||
|
||||
const isTomorrow =
|
||||
date.getFullYear() === tomorrow.getFullYear() &&
|
||||
date.getMonth() === tomorrow.getMonth() &&
|
||||
date.getDate() === tomorrow.getDate();
|
||||
|
||||
let result: string;
|
||||
if (isToday) {
|
||||
result = 'Today';
|
||||
} else if (isTomorrow) {
|
||||
result = 'Tomorrow';
|
||||
} else if (date.getFullYear() === today.getFullYear()) {
|
||||
result = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
} else {
|
||||
result = date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
if (includeTime) {
|
||||
result += ` at ${date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
16
apps/todo/packages/shared/tsconfig.json
Normal file
16
apps/todo/packages/shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
@ -10,8 +10,12 @@ CREATE DATABASE voxel_lava;
|
|||
-- Create storage database (cloud drive)
|
||||
CREATE DATABASE storage;
|
||||
|
||||
-- Create todo database
|
||||
CREATE DATABASE todo;
|
||||
|
||||
-- Grant all privileges to the default user
|
||||
GRANT ALL PRIVILEGES ON DATABASE chat TO manacore;
|
||||
GRANT ALL PRIVILEGES ON DATABASE voxel_lava TO manacore;
|
||||
GRANT ALL PRIVILEGES ON DATABASE manacore TO manacore;
|
||||
GRANT ALL PRIVILEGES ON DATABASE storage TO manacore;
|
||||
GRANT ALL PRIVILEGES ON DATABASE todo TO manacore;
|
||||
|
|
|
|||
|
|
@ -157,6 +157,56 @@ export const APP_BRANDING: Record<AppId, AppBranding> = {
|
|||
logoStroke: true,
|
||||
logoStrokeWidth: 1.5,
|
||||
},
|
||||
mail: {
|
||||
id: 'mail',
|
||||
name: 'Mail',
|
||||
tagline: 'Smart Email Client',
|
||||
primaryColor: '#6366f1',
|
||||
secondaryColor: '#818cf8',
|
||||
// Envelope icon
|
||||
logoPath:
|
||||
'M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75',
|
||||
logoViewBox: '0 0 24 24',
|
||||
logoStroke: true,
|
||||
logoStrokeWidth: 1.5,
|
||||
},
|
||||
storage: {
|
||||
id: 'storage',
|
||||
name: 'Storage',
|
||||
tagline: 'Cloud Storage',
|
||||
primaryColor: '#3b82f6',
|
||||
secondaryColor: '#60a5fa',
|
||||
// Cloud/storage icon
|
||||
logoPath:
|
||||
'M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z',
|
||||
logoViewBox: '0 0 24 24',
|
||||
logoStroke: true,
|
||||
logoStrokeWidth: 1.5,
|
||||
},
|
||||
clock: {
|
||||
id: 'clock',
|
||||
name: 'Clock',
|
||||
tagline: 'Clocks & Alarms',
|
||||
primaryColor: '#f59e0b',
|
||||
secondaryColor: '#fbbf24',
|
||||
// Clock icon
|
||||
logoPath: 'M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
logoViewBox: '0 0 24 24',
|
||||
logoStroke: true,
|
||||
logoStrokeWidth: 1.5,
|
||||
},
|
||||
todo: {
|
||||
id: 'todo',
|
||||
name: 'Todo',
|
||||
tagline: 'Task Management',
|
||||
primaryColor: '#8b5cf6',
|
||||
secondaryColor: '#a78bfa',
|
||||
// Checklist icon
|
||||
logoPath: 'M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
logoViewBox: '0 0 24 24',
|
||||
logoStroke: true,
|
||||
logoStrokeWidth: 1.5,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ export {
|
|||
ZitareLogo,
|
||||
ContactsLogo,
|
||||
CalendarLogo,
|
||||
StorageLogo,
|
||||
TodoLogo,
|
||||
MailLogo,
|
||||
} from './logos';
|
||||
|
||||
// Configuration
|
||||
|
|
|
|||
13
packages/shared-branding/src/logos/TodoLogo.svelte
Normal file
13
packages/shared-branding/src/logos/TodoLogo.svelte
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts">
|
||||
import AppLogo from '../AppLogo.svelte';
|
||||
|
||||
interface Props {
|
||||
size?: number;
|
||||
color?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { size = 55, color, class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<AppLogo app="todo" {size} {color} class={className} />
|
||||
|
|
@ -14,3 +14,5 @@ export { default as ZitareLogo } from './ZitareLogo.svelte';
|
|||
export { default as ContactsLogo } from './ContactsLogo.svelte';
|
||||
export { default as CalendarLogo } from './CalendarLogo.svelte';
|
||||
export { default as StorageLogo } from './StorageLogo.svelte';
|
||||
export { default as TodoLogo } from './TodoLogo.svelte';
|
||||
export { default as MailLogo } from './MailLogo.svelte';
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ export type AppId =
|
|||
| 'zitare'
|
||||
| 'picture'
|
||||
| 'contacts'
|
||||
| 'calendar';
|
||||
| 'calendar'
|
||||
| 'storage'
|
||||
| 'clock'
|
||||
| 'todo'
|
||||
| 'mail';
|
||||
|
||||
/**
|
||||
* App branding configuration
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue