mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
✨ feat(contacts): add complete contacts app with backend, web, and landing
- Add NestJS backend with CRUD endpoints for contacts, groups, tags, notes, and activities - Add SvelteKit web app with auth pages (login, register, forgot-password) - Add Astro landing page - Add ContactsLogo to shared-branding package - Add contacts to MANA_APPS configuration - Update shared-storage with contacts bucket support - Update environment scripts and Docker configuration for contacts database - Integrate mana-core-auth for JWT authentication - Follow existing app architecture patterns (route groups, PillNavigation) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
00176a25e0
commit
45d70150f4
76 changed files with 3812 additions and 1 deletions
|
|
@ -192,6 +192,16 @@ CONTACTS_S3_PUBLIC_URL=http://localhost:9000/contacts-photos
|
|||
CALENDAR_BACKEND_PORT=3014
|
||||
CALENDAR_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/calendar
|
||||
|
||||
# ============================================
|
||||
# STORAGE PROJECT (Cloud Drive)
|
||||
# ============================================
|
||||
|
||||
STORAGE_BACKEND_PORT=3016
|
||||
STORAGE_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/storage
|
||||
STORAGE_S3_PUBLIC_URL=http://localhost:9000/storage-storage
|
||||
STORAGE_MAX_FILE_SIZE=104857600
|
||||
STORAGE_MAX_FILES_PER_UPLOAD=10
|
||||
|
||||
# ============================================
|
||||
# MANA-GAMES PROJECT
|
||||
# ============================================
|
||||
|
|
|
|||
|
|
@ -400,6 +400,7 @@ pnpm docker:up
|
|||
| `presi-storage` | Presi | Presentation slides |
|
||||
| `calendar-storage` | Calendar | Calendar attachments |
|
||||
| `contacts-storage` | Contacts | Contact avatars/files |
|
||||
| `storage-storage` | Storage | Cloud drive files |
|
||||
|
||||
### Usage in Backend
|
||||
|
||||
|
|
|
|||
25
COMMANDS.md
25
COMMANDS.md
|
|
@ -3,14 +3,39 @@
|
|||
# Alles starten (PostgreSQL, Redis, Auth, Chat)
|
||||
|
||||
pnpm docker:up:all
|
||||
|
||||
|
||||
pnpm docker:down
|
||||
|
||||
pnpm dev:chat:app
|
||||
pnpm dev:contacts:app
|
||||
pnpm dev:picture:app
|
||||
pnpm dev:manacore:app
|
||||
pnpm dev:zitare:app
|
||||
pnpm dev:presi:app
|
||||
|
||||
# Deployment Landingpages:
|
||||
## Einzelne Landing Page
|
||||
pnpm deploy:landing:chat
|
||||
pnpm deploy:landing:picture
|
||||
pnpm deploy:landing:manacore
|
||||
pnpm deploy:landing:manadeck
|
||||
pnpm deploy:landing:zitare
|
||||
|
||||
Hier sind alle Landing Page URLs:
|
||||
|
||||
| Projekt | URL |
|
||||
|----------|------------------------------------|
|
||||
| Chat | https://chat-landing-90m.pages.dev |
|
||||
| Picture | https://picture-landing.pages.dev |
|
||||
| ManaCore | https://manacore-landing.pages.dev |
|
||||
| ManaDeck | https://manadeck-landing.pages.dev |
|
||||
| Zitare | https://zitare-landing.pages.dev |
|
||||
| Presi | https://presi-landing.pages.dev |
|
||||
|
||||
## Alle auf einmal
|
||||
pnpm deploy:landing:all
|
||||
|
||||
Übersicht aller wichtigen Befehle zum Starten, Stoppen und Verwalten der Apps.
|
||||
|
||||
## Inhaltsverzeichnis
|
||||
|
|
|
|||
221
apps/contacts/CLAUDE.md
Normal file
221
apps/contacts/CLAUDE.md
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
# Contacts Project Guide
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/contacts/
|
||||
├── apps/
|
||||
│ ├── backend/ # NestJS API server (@contacts/backend) - Port 3015
|
||||
│ ├── landing/ # Astro marketing landing page (@contacts/landing)
|
||||
│ ├── web/ # SvelteKit web application (@contacts/web) - Port 5184
|
||||
│ └── mobile/ # Expo/React Native mobile app (@contacts/mobile)
|
||||
├── packages/
|
||||
│ └── shared/ # Shared types, utils, configs (@contacts/shared)
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Root Level (from monorepo root)
|
||||
|
||||
```bash
|
||||
pnpm contacts:dev # Run all contacts apps
|
||||
pnpm dev:contacts:mobile # Start mobile app
|
||||
pnpm dev:contacts:web # Start web app
|
||||
pnpm dev:contacts:landing # Start landing page
|
||||
pnpm dev:contacts:backend # Start backend server
|
||||
pnpm dev:contacts:app # Start web + backend together
|
||||
```
|
||||
|
||||
### Mobile App (apps/contacts/apps/mobile)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start Expo dev server
|
||||
pnpm ios # Run on iOS simulator
|
||||
pnpm android # Run on Android emulator
|
||||
```
|
||||
|
||||
### Backend (apps/contacts/apps/backend)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start with hot reload
|
||||
pnpm build # Build for production
|
||||
pnpm start:prod # Start production server
|
||||
pnpm db:push # Push schema to database
|
||||
pnpm db:studio # Open Drizzle Studio
|
||||
```
|
||||
|
||||
### Web App (apps/contacts/apps/web)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server
|
||||
pnpm build # Build for production
|
||||
pnpm preview # Preview production build
|
||||
```
|
||||
|
||||
### Landing Page (apps/contacts/apps/landing)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server
|
||||
pnpm build # Build for production
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Mobile**: React Native 0.81 + Expo SDK 54, NativeWind, Expo Router, Zustand
|
||||
- **Web**: SvelteKit 2.x, Svelte 5 (runes mode), Tailwind CSS
|
||||
- **Landing**: Astro 5.x, Tailwind CSS
|
||||
- **Backend**: NestJS 10, Drizzle ORM, PostgreSQL
|
||||
- **Types**: TypeScript 5.x
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| ------------------------------------- | ------ | -------------------------- |
|
||||
| `/api/v1/health` | GET | Health check |
|
||||
| `/api/v1/contacts` | GET | Get user's contacts |
|
||||
| `/api/v1/contacts` | POST | Create new contact |
|
||||
| `/api/v1/contacts/:id` | GET | Get contact details |
|
||||
| `/api/v1/contacts/:id` | PATCH | Update contact |
|
||||
| `/api/v1/contacts/:id` | DELETE | Delete contact |
|
||||
| `/api/v1/contacts/:id/favorite` | POST | Toggle favorite |
|
||||
| `/api/v1/contacts/:id/archive` | POST | Toggle archive |
|
||||
| `/api/v1/contacts/:id/photo` | POST | Upload contact photo |
|
||||
| `/api/v1/groups` | GET | Get user's groups |
|
||||
| `/api/v1/groups` | POST | Create new group |
|
||||
| `/api/v1/groups/:id` | PATCH | Update group |
|
||||
| `/api/v1/groups/:id` | DELETE | Delete group |
|
||||
| `/api/v1/groups/:id/contacts` | POST | Add contacts to group |
|
||||
| `/api/v1/tags` | GET | Get user's tags |
|
||||
| `/api/v1/tags` | POST | Create new tag |
|
||||
| `/api/v1/tags/:id` | DELETE | Delete tag |
|
||||
| `/api/v1/contacts/:id/notes` | GET | Get contact notes |
|
||||
| `/api/v1/contacts/:id/notes` | POST | Add note to contact |
|
||||
| `/api/v1/notes/:id` | PATCH | Update note |
|
||||
| `/api/v1/notes/:id` | DELETE | Delete note |
|
||||
| `/api/v1/contacts/:id/activities` | GET | Get contact activities |
|
||||
| `/api/v1/contacts/:id/activities` | POST | Log activity |
|
||||
| `/api/v1/contacts/import` | POST | Import contacts (vCard/CSV)|
|
||||
| `/api/v1/contacts/export` | GET | Export contacts |
|
||||
| `/api/v1/organizations/:orgId/contacts` | GET | Get organization contacts |
|
||||
| `/api/v1/teams/:teamId/contacts` | GET | Get team contacts |
|
||||
| `/api/v1/contacts/:id/share` | POST | Share contact |
|
||||
|
||||
### Database Schema
|
||||
|
||||
**contacts** - Contact information
|
||||
|
||||
- `id` (UUID) - Primary key
|
||||
- `user_id` (VARCHAR) - User reference
|
||||
- `first_name`, `last_name`, `display_name`, `nickname` (VARCHAR)
|
||||
- `email`, `phone`, `mobile` (VARCHAR)
|
||||
- `street`, `city`, `postal_code`, `country` (VARCHAR)
|
||||
- `company`, `job_title`, `department` (VARCHAR)
|
||||
- `website`, `birthday`, `notes`, `photo_url` (VARCHAR/TEXT/DATE)
|
||||
- `is_favorite`, `is_archived` (BOOLEAN)
|
||||
- `organization_id`, `team_id` (UUID) - Manacore integration
|
||||
- `visibility` (VARCHAR) - private/team/organization/public
|
||||
- `shared_with` (JSONB) - Array of user IDs
|
||||
- `created_at`, `updated_at` (TIMESTAMP)
|
||||
|
||||
**contact_groups** - Groups for organizing contacts
|
||||
|
||||
- `id` (UUID) - Primary key
|
||||
- `user_id` (VARCHAR) - User reference
|
||||
- `name` (VARCHAR) - Group name
|
||||
- `description` (TEXT) - Optional description
|
||||
- `color` (VARCHAR) - Group color
|
||||
- `created_at` (TIMESTAMP)
|
||||
|
||||
**contact_to_groups** - Many-to-many relation
|
||||
|
||||
- `contact_id` (UUID) - Contact reference
|
||||
- `group_id` (UUID) - Group reference
|
||||
|
||||
**contact_tags** - Tags for contacts
|
||||
|
||||
- `id` (UUID) - Primary key
|
||||
- `user_id` (VARCHAR) - User reference
|
||||
- `name` (VARCHAR) - Tag name
|
||||
- `color` (VARCHAR) - Tag color
|
||||
|
||||
**contact_to_tags** - Many-to-many relation
|
||||
|
||||
- `contact_id` (UUID) - Contact reference
|
||||
- `tag_id` (UUID) - Tag reference
|
||||
|
||||
**contact_notes** - Notes for contacts
|
||||
|
||||
- `id` (UUID) - Primary key
|
||||
- `contact_id` (UUID) - Contact reference
|
||||
- `user_id` (VARCHAR) - User reference
|
||||
- `content` (TEXT) - Note content
|
||||
- `is_pinned` (BOOLEAN)
|
||||
- `created_at`, `updated_at` (TIMESTAMP)
|
||||
|
||||
**contact_activities** - Activity log
|
||||
|
||||
- `id` (UUID) - Primary key
|
||||
- `contact_id` (UUID) - Contact reference
|
||||
- `user_id` (VARCHAR) - User reference
|
||||
- `activity_type` (VARCHAR) - created/updated/called/emailed/met/note_added
|
||||
- `description` (TEXT)
|
||||
- `metadata` (JSONB)
|
||||
- `created_at` (TIMESTAMP)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Backend (.env)
|
||||
|
||||
```
|
||||
NODE_ENV=development
|
||||
PORT=3015
|
||||
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/contacts
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:5184,http://localhost:8081
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_ACCESS_KEY=minioadmin
|
||||
S3_SECRET_KEY=minioadmin
|
||||
S3_BUCKET=contacts-photos
|
||||
```
|
||||
|
||||
#### Mobile (.env)
|
||||
|
||||
```
|
||||
EXPO_PUBLIC_BACKEND_URL=http://localhost:3015
|
||||
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
#### Web (.env)
|
||||
|
||||
```
|
||||
PUBLIC_BACKEND_URL=http://localhost:3015
|
||||
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
## Shared Packages
|
||||
|
||||
### @contacts/shared
|
||||
|
||||
- Types: `Contact`, `ContactGroup`, `ContactTag`, `ContactNote`, `ContactActivity`
|
||||
- Utils: Search, filter, import/export functions
|
||||
- Configs: App configuration
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
- **TypeScript**: Strict typing with interfaces
|
||||
- **Mobile**: Functional components with hooks, Zustand for state
|
||||
- **Web**: Svelte 5 runes mode (`$state`, `$derived`, `$effect`)
|
||||
- **Styling**: Tailwind CSS / NativeWind
|
||||
- **Formatting**: Prettier with project config
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Authentication**: Uses Mana Core Auth (JWT in Authorization header)
|
||||
2. **Database**: PostgreSQL with Drizzle ORM
|
||||
3. **Port**: Backend runs on port 3015, Web on port 5184 by default
|
||||
4. **Storage**: Uses MinIO/S3 for contact photos via @manacore/shared-storage
|
||||
5. **Manacore Integration**: Contacts can be linked to Organizations and Teams
|
||||
12
apps/contacts/apps/backend/drizzle.config.ts
Normal file
12
apps/contacts/apps/backend/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
schema: './src/db/schema/index.ts',
|
||||
out: './src/db/migrations',
|
||||
dbCredentials: {
|
||||
url: process.env.CONTACTS_DATABASE_URL || process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/contacts',
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
||||
10
apps/contacts/apps/backend/nest-cli.json
Normal file
10
apps/contacts/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": false,
|
||||
"assets": [],
|
||||
"watchAssets": false
|
||||
}
|
||||
}
|
||||
54
apps/contacts/apps/backend/package.json
Normal file
54
apps/contacts/apps/backend/package.json
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"name": "@contacts/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"dev": "nest start --watch",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit",
|
||||
"migration:generate": "drizzle-kit generate",
|
||||
"migration:run": "tsx src/db/migrate.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "tsx src/db/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-nestjs-auth": "workspace:*",
|
||||
"@manacore/shared-storage": "workspace:*",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"postgres": "^3.4.5",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
||||
"@typescript-eslint/parser": "^8.18.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { ActivityService, ActivityType } from './activity.service';
|
||||
import { IsString, IsOptional, IsIn, IsObject } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
class CreateActivityDto {
|
||||
@IsString()
|
||||
@IsIn(['created', 'updated', 'called', 'emailed', 'met', 'note_added'])
|
||||
activityType!: ActivityType;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
class ActivityQueryDto {
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@Controller('contacts/:contactId/activities')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ActivityController {
|
||||
constructor(private readonly activityService: ActivityService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('contactId', ParseUUIDPipe) contactId: string,
|
||||
@Query() query: ActivityQueryDto
|
||||
) {
|
||||
const activities = await this.activityService.findByContactId(
|
||||
contactId,
|
||||
user.userId,
|
||||
query.limit
|
||||
);
|
||||
return { activities };
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('contactId', ParseUUIDPipe) contactId: string,
|
||||
@Body() dto: CreateActivityDto
|
||||
) {
|
||||
const activity = await this.activityService.logActivity(
|
||||
contactId,
|
||||
user.userId,
|
||||
dto.activityType,
|
||||
dto.description,
|
||||
dto.metadata
|
||||
);
|
||||
return { activity };
|
||||
}
|
||||
}
|
||||
10
apps/contacts/apps/backend/src/activity/activity.module.ts
Normal file
10
apps/contacts/apps/backend/src/activity/activity.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ActivityController } from './activity.controller';
|
||||
import { ActivityService } from './activity.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ActivityController],
|
||||
providers: [ActivityService],
|
||||
exports: [ActivityService],
|
||||
})
|
||||
export class ActivityModule {}
|
||||
46
apps/contacts/apps/backend/src/activity/activity.service.ts
Normal file
46
apps/contacts/apps/backend/src/activity/activity.service.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { contactActivities, type ContactActivity, type NewContactActivity } from '../db/schema';
|
||||
|
||||
export type ActivityType = 'created' | 'updated' | 'called' | 'emailed' | 'met' | 'note_added';
|
||||
|
||||
@Injectable()
|
||||
export class ActivityService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findByContactId(
|
||||
contactId: string,
|
||||
userId: string,
|
||||
limit = 50
|
||||
): Promise<ContactActivity[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(contactActivities)
|
||||
.where(and(eq(contactActivities.contactId, contactId), eq(contactActivities.userId, userId)))
|
||||
.orderBy(desc(contactActivities.createdAt))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async create(data: NewContactActivity): Promise<ContactActivity> {
|
||||
const [activity] = await this.db.insert(contactActivities).values(data).returning();
|
||||
return activity;
|
||||
}
|
||||
|
||||
async logActivity(
|
||||
contactId: string,
|
||||
userId: string,
|
||||
activityType: ActivityType,
|
||||
description?: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): Promise<ContactActivity> {
|
||||
return this.create({
|
||||
contactId,
|
||||
userId,
|
||||
activityType,
|
||||
description,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
26
apps/contacts/apps/backend/src/app.module.ts
Normal file
26
apps/contacts/apps/backend/src/app.module.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { DatabaseModule } from './db/database.module';
|
||||
import { ContactModule } from './contact/contact.module';
|
||||
import { GroupModule } from './group/group.module';
|
||||
import { TagModule } from './tag/tag.module';
|
||||
import { NoteModule } from './note/note.module';
|
||||
import { ActivityModule } from './activity/activity.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
DatabaseModule,
|
||||
ContactModule,
|
||||
GroupModule,
|
||||
TagModule,
|
||||
NoteModule,
|
||||
ActivityModule,
|
||||
HealthModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
240
apps/contacts/apps/backend/src/contact/contact.controller.ts
Normal file
240
apps/contacts/apps/backend/src/contact/contact.controller.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { ContactService } from './contact.service';
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsEmail,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
class CreateContactDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
firstName?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
lastName?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(200)
|
||||
displayName?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
nickname?: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
@MaxLength(255)
|
||||
email?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(50)
|
||||
phone?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(50)
|
||||
mobile?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(255)
|
||||
street?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
city?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(20)
|
||||
postalCode?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
country?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(200)
|
||||
company?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(200)
|
||||
jobTitle?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(200)
|
||||
department?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(500)
|
||||
website?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
birthday?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
organizationId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
teamId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
visibility?: string;
|
||||
}
|
||||
|
||||
class UpdateContactDto extends CreateContactDto {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isFavorite?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isArchived?: boolean;
|
||||
}
|
||||
|
||||
class ContactQueryDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true')
|
||||
isFavorite?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true')
|
||||
isArchived?: boolean;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
groupId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
tagId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
@Controller('contacts')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ContactController {
|
||||
constructor(private readonly contactService: ContactService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData, @Query() query: ContactQueryDto) {
|
||||
const contacts = await this.contactService.findByUserId(user.userId, query);
|
||||
const total = await this.contactService.count(user.userId, query.isArchived);
|
||||
return { contacts, total };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string
|
||||
) {
|
||||
const contact = await this.contactService.findById(id, user.userId);
|
||||
if (!contact) {
|
||||
return { contact: null };
|
||||
}
|
||||
return { contact };
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateContactDto) {
|
||||
// Generate display name if not provided
|
||||
let displayName = dto.displayName;
|
||||
if (!displayName && (dto.firstName || dto.lastName)) {
|
||||
displayName = [dto.firstName, dto.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
const contact = await this.contactService.create({
|
||||
...dto,
|
||||
displayName,
|
||||
userId: user.userId,
|
||||
createdBy: user.userId,
|
||||
});
|
||||
return { contact };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateContactDto
|
||||
) {
|
||||
const contact = await this.contactService.update(id, user.userId, dto);
|
||||
return { contact };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string
|
||||
) {
|
||||
await this.contactService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/favorite')
|
||||
async toggleFavorite(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string
|
||||
) {
|
||||
const contact = await this.contactService.toggleFavorite(id, user.userId);
|
||||
return { contact };
|
||||
}
|
||||
|
||||
@Post(':id/archive')
|
||||
async toggleArchive(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string
|
||||
) {
|
||||
const contact = await this.contactService.toggleArchive(id, user.userId);
|
||||
return { contact };
|
||||
}
|
||||
}
|
||||
10
apps/contacts/apps/backend/src/contact/contact.module.ts
Normal file
10
apps/contacts/apps/backend/src/contact/contact.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ContactController } from './contact.controller';
|
||||
import { ContactService } from './contact.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ContactController],
|
||||
providers: [ContactService],
|
||||
exports: [ContactService],
|
||||
})
|
||||
export class ContactModule {}
|
||||
115
apps/contacts/apps/backend/src/contact/contact.service.ts
Normal file
115
apps/contacts/apps/backend/src/contact/contact.service.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, or, ilike, desc, sql } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { contacts, type Contact, type NewContact } from '../db/schema';
|
||||
|
||||
export interface ContactFilters {
|
||||
search?: string;
|
||||
isFavorite?: boolean;
|
||||
isArchived?: boolean;
|
||||
groupId?: string;
|
||||
tagId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContactService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findByUserId(userId: string, filters: ContactFilters = {}): Promise<Contact[]> {
|
||||
const { search, isFavorite, isArchived = false, limit = 50, offset = 0 } = filters;
|
||||
|
||||
let query = this.db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(
|
||||
and(
|
||||
eq(contacts.userId, userId),
|
||||
eq(contacts.isArchived, isArchived),
|
||||
isFavorite !== undefined ? eq(contacts.isFavorite, isFavorite) : undefined,
|
||||
search
|
||||
? or(
|
||||
ilike(contacts.firstName, `%${search}%`),
|
||||
ilike(contacts.lastName, `%${search}%`),
|
||||
ilike(contacts.displayName, `%${search}%`),
|
||||
ilike(contacts.email, `%${search}%`),
|
||||
ilike(contacts.company, `%${search}%`)
|
||||
)
|
||||
: undefined
|
||||
)
|
||||
)
|
||||
.orderBy(desc(contacts.updatedAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Contact | null> {
|
||||
const [contact] = await this.db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(and(eq(contacts.id, id), eq(contacts.userId, userId)));
|
||||
return contact || null;
|
||||
}
|
||||
|
||||
async create(data: NewContact): Promise<Contact> {
|
||||
const [contact] = await this.db.insert(contacts).values(data).returning();
|
||||
return contact;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, data: Partial<NewContact>): Promise<Contact> {
|
||||
const [contact] = await this.db
|
||||
.update(contacts)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(eq(contacts.id, id), eq(contacts.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (!contact) {
|
||||
throw new NotFoundException('Contact not found');
|
||||
}
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
const result = await this.db
|
||||
.delete(contacts)
|
||||
.where(and(eq(contacts.id, id), eq(contacts.userId, userId)));
|
||||
|
||||
// Drizzle doesn't return affected rows easily, so we check manually
|
||||
const existing = await this.findById(id, userId);
|
||||
if (existing) {
|
||||
throw new NotFoundException('Contact not found');
|
||||
}
|
||||
}
|
||||
|
||||
async toggleFavorite(id: string, userId: string): Promise<Contact> {
|
||||
const contact = await this.findById(id, userId);
|
||||
if (!contact) {
|
||||
throw new NotFoundException('Contact not found');
|
||||
}
|
||||
|
||||
return this.update(id, userId, { isFavorite: !contact.isFavorite });
|
||||
}
|
||||
|
||||
async toggleArchive(id: string, userId: string): Promise<Contact> {
|
||||
const contact = await this.findById(id, userId);
|
||||
if (!contact) {
|
||||
throw new NotFoundException('Contact not found');
|
||||
}
|
||||
|
||||
return this.update(id, userId, { isArchived: !contact.isArchived });
|
||||
}
|
||||
|
||||
async count(userId: string, isArchived = false): Promise<number> {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(contacts)
|
||||
.where(and(eq(contacts.userId, userId), eq(contacts.isArchived, isArchived)));
|
||||
|
||||
return Number(result[0]?.count || 0);
|
||||
}
|
||||
}
|
||||
38
apps/contacts/apps/backend/src/db/connection.ts
Normal file
38
apps/contacts/apps/backend/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import * as schema from './schema';
|
||||
|
||||
// Use require for postgres to avoid ESM/CommonJS interop issues
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const postgres = require('postgres');
|
||||
|
||||
let connection: ReturnType<typeof postgres> | null = null;
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
export function getConnection(databaseUrl: string) {
|
||||
if (!connection) {
|
||||
connection = postgres(databaseUrl, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export function getDb(databaseUrl: string) {
|
||||
if (!db) {
|
||||
const conn = getConnection(databaseUrl);
|
||||
db = drizzle(conn, { schema });
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function closeConnection() {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
28
apps/contacts/apps/backend/src/db/database.module.ts
Normal file
28
apps/contacts/apps/backend/src/db/database.module.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb, closeConnection, type Database } from './connection';
|
||||
|
||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: (configService: ConfigService): Database => {
|
||||
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
return getDb(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
async onModuleDestroy() {
|
||||
await closeConnection();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { pgTable, uuid, timestamp, varchar, text, jsonb } from 'drizzle-orm/pg-core';
|
||||
import { contacts } from './contacts.schema';
|
||||
|
||||
export const contactActivities = pgTable('contact_activities', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
contactId: uuid('contact_id')
|
||||
.references(() => contacts.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(),
|
||||
activityType: varchar('activity_type', { length: 50 }).notNull(),
|
||||
// Types: 'created' | 'updated' | 'called' | 'emailed' | 'met' | 'note_added'
|
||||
description: text('description'),
|
||||
metadata: jsonb('metadata').$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type ContactActivity = typeof contactActivities.$inferSelect;
|
||||
export type NewContactActivity = typeof contactActivities.$inferInsert;
|
||||
52
apps/contacts/apps/backend/src/db/schema/contacts.schema.ts
Normal file
52
apps/contacts/apps/backend/src/db/schema/contacts.schema.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { pgTable, uuid, timestamp, varchar, text, boolean, date, jsonb } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const contacts = pgTable('contacts', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(),
|
||||
|
||||
// Basic Info
|
||||
firstName: varchar('first_name', { length: 100 }),
|
||||
lastName: varchar('last_name', { length: 100 }),
|
||||
displayName: varchar('display_name', { length: 200 }),
|
||||
nickname: varchar('nickname', { length: 100 }),
|
||||
|
||||
// Contact Details
|
||||
email: varchar('email', { length: 255 }),
|
||||
phone: varchar('phone', { length: 50 }),
|
||||
mobile: varchar('mobile', { length: 50 }),
|
||||
|
||||
// Address
|
||||
street: varchar('street', { length: 255 }),
|
||||
city: varchar('city', { length: 100 }),
|
||||
postalCode: varchar('postal_code', { length: 20 }),
|
||||
country: varchar('country', { length: 100 }),
|
||||
|
||||
// Organization
|
||||
company: varchar('company', { length: 200 }),
|
||||
jobTitle: varchar('job_title', { length: 200 }),
|
||||
department: varchar('department', { length: 200 }),
|
||||
|
||||
// Additional
|
||||
website: varchar('website', { length: 500 }),
|
||||
birthday: date('birthday'),
|
||||
notes: text('notes'),
|
||||
photoUrl: varchar('photo_url', { length: 500 }),
|
||||
|
||||
// Flags
|
||||
isFavorite: boolean('is_favorite').default(false),
|
||||
isArchived: boolean('is_archived').default(false),
|
||||
|
||||
// Manacore Integration
|
||||
organizationId: uuid('organization_id'),
|
||||
teamId: uuid('team_id'),
|
||||
visibility: varchar('visibility', { length: 20 }).default('private'),
|
||||
createdBy: varchar('created_by', { length: 255 }).notNull(),
|
||||
sharedWith: jsonb('shared_with').$type<string[]>().default([]),
|
||||
|
||||
// Metadata
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Contact = typeof contacts.$inferSelect;
|
||||
export type NewContact = typeof contacts.$inferInsert;
|
||||
31
apps/contacts/apps/backend/src/db/schema/groups.schema.ts
Normal file
31
apps/contacts/apps/backend/src/db/schema/groups.schema.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { pgTable, uuid, timestamp, varchar, text, primaryKey } from 'drizzle-orm/pg-core';
|
||||
import { contacts } from './contacts.schema';
|
||||
|
||||
export const contactGroups = pgTable('contact_groups', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(),
|
||||
name: varchar('name', { length: 100 }).notNull(),
|
||||
description: text('description'),
|
||||
color: varchar('color', { length: 20 }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const contactToGroups = pgTable(
|
||||
'contact_to_groups',
|
||||
{
|
||||
contactId: uuid('contact_id')
|
||||
.references(() => contacts.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
groupId: uuid('group_id')
|
||||
.references(() => contactGroups.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({ columns: [table.contactId, table.groupId] }),
|
||||
})
|
||||
);
|
||||
|
||||
export type ContactGroup = typeof contactGroups.$inferSelect;
|
||||
export type NewContactGroup = typeof contactGroups.$inferInsert;
|
||||
export type ContactToGroup = typeof contactToGroups.$inferSelect;
|
||||
export type NewContactToGroup = typeof contactToGroups.$inferInsert;
|
||||
5
apps/contacts/apps/backend/src/db/schema/index.ts
Normal file
5
apps/contacts/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from './contacts.schema';
|
||||
export * from './groups.schema';
|
||||
export * from './tags.schema';
|
||||
export * from './notes.schema';
|
||||
export * from './activities.schema';
|
||||
17
apps/contacts/apps/backend/src/db/schema/notes.schema.ts
Normal file
17
apps/contacts/apps/backend/src/db/schema/notes.schema.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { pgTable, uuid, timestamp, varchar, text, boolean } from 'drizzle-orm/pg-core';
|
||||
import { contacts } from './contacts.schema';
|
||||
|
||||
export const contactNotes = pgTable('contact_notes', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
contactId: uuid('contact_id')
|
||||
.references(() => contacts.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(),
|
||||
content: text('content').notNull(),
|
||||
isPinned: boolean('is_pinned').default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type ContactNote = typeof contactNotes.$inferSelect;
|
||||
export type NewContactNote = typeof contactNotes.$inferInsert;
|
||||
30
apps/contacts/apps/backend/src/db/schema/tags.schema.ts
Normal file
30
apps/contacts/apps/backend/src/db/schema/tags.schema.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { pgTable, uuid, timestamp, varchar, primaryKey } from 'drizzle-orm/pg-core';
|
||||
import { contacts } from './contacts.schema';
|
||||
|
||||
export const contactTags = pgTable('contact_tags', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(),
|
||||
name: varchar('name', { length: 50 }).notNull(),
|
||||
color: varchar('color', { length: 20 }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const contactToTags = pgTable(
|
||||
'contact_to_tags',
|
||||
{
|
||||
contactId: uuid('contact_id')
|
||||
.references(() => contacts.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
tagId: uuid('tag_id')
|
||||
.references(() => contactTags.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({ columns: [table.contactId, table.tagId] }),
|
||||
})
|
||||
);
|
||||
|
||||
export type ContactTag = typeof contactTags.$inferSelect;
|
||||
export type NewContactTag = typeof contactTags.$inferInsert;
|
||||
export type ContactToTag = typeof contactToTags.$inferSelect;
|
||||
export type NewContactToTag = typeof contactToTags.$inferInsert;
|
||||
136
apps/contacts/apps/backend/src/group/group.controller.ts
Normal file
136
apps/contacts/apps/backend/src/group/group.controller.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { GroupService } from './group.service';
|
||||
import { IsString, IsOptional, MaxLength, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
class CreateGroupDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
}
|
||||
|
||||
class UpdateGroupDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
}
|
||||
|
||||
class AddContactsDto {
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
contactIds!: string[];
|
||||
}
|
||||
|
||||
@Controller('groups')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class GroupController {
|
||||
constructor(private readonly groupService: GroupService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
const groups = await this.groupService.findByUserId(user.userId);
|
||||
return { groups };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string
|
||||
) {
|
||||
const group = await this.groupService.findById(id, user.userId);
|
||||
const contactIds = group ? await this.groupService.getContactsInGroup(id) : [];
|
||||
return { group, contactIds };
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateGroupDto) {
|
||||
const group = await this.groupService.create({
|
||||
...dto,
|
||||
userId: user.userId,
|
||||
});
|
||||
return { group };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateGroupDto
|
||||
) {
|
||||
const group = await this.groupService.update(id, user.userId, dto);
|
||||
return { group };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string
|
||||
) {
|
||||
await this.groupService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/contacts')
|
||||
async addContacts(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AddContactsDto
|
||||
) {
|
||||
// Verify group belongs to user
|
||||
const group = await this.groupService.findById(id, user.userId);
|
||||
if (!group) {
|
||||
return { success: false, error: 'Group not found' };
|
||||
}
|
||||
|
||||
for (const contactId of dto.contactIds) {
|
||||
await this.groupService.addContactToGroup(contactId, id);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete(':id/contacts/:contactId')
|
||||
async removeContact(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('contactId', ParseUUIDPipe) contactId: string
|
||||
) {
|
||||
// Verify group belongs to user
|
||||
const group = await this.groupService.findById(id, user.userId);
|
||||
if (!group) {
|
||||
return { success: false, error: 'Group not found' };
|
||||
}
|
||||
|
||||
await this.groupService.removeContactFromGroup(contactId, id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
apps/contacts/apps/backend/src/group/group.module.ts
Normal file
10
apps/contacts/apps/backend/src/group/group.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { GroupController } from './group.controller';
|
||||
import { GroupService } from './group.service';
|
||||
|
||||
@Module({
|
||||
controllers: [GroupController],
|
||||
providers: [GroupService],
|
||||
exports: [GroupService],
|
||||
})
|
||||
export class GroupModule {}
|
||||
74
apps/contacts/apps/backend/src/group/group.service.ts
Normal file
74
apps/contacts/apps/backend/src/group/group.service.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import {
|
||||
contactGroups,
|
||||
contactToGroups,
|
||||
type ContactGroup,
|
||||
type NewContactGroup,
|
||||
} from '../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class GroupService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findByUserId(userId: string): Promise<ContactGroup[]> {
|
||||
return this.db.select().from(contactGroups).where(eq(contactGroups.userId, userId));
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<ContactGroup | null> {
|
||||
const [group] = await this.db
|
||||
.select()
|
||||
.from(contactGroups)
|
||||
.where(and(eq(contactGroups.id, id), eq(contactGroups.userId, userId)));
|
||||
return group || null;
|
||||
}
|
||||
|
||||
async create(data: NewContactGroup): Promise<ContactGroup> {
|
||||
const [group] = await this.db.insert(contactGroups).values(data).returning();
|
||||
return group;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, data: Partial<NewContactGroup>): Promise<ContactGroup> {
|
||||
const [group] = await this.db
|
||||
.update(contactGroups)
|
||||
.set(data)
|
||||
.where(and(eq(contactGroups.id, id), eq(contactGroups.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (!group) {
|
||||
throw new NotFoundException('Group not found');
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(contactGroups)
|
||||
.where(and(eq(contactGroups.id, id), eq(contactGroups.userId, userId)));
|
||||
}
|
||||
|
||||
async addContactToGroup(contactId: string, groupId: string): Promise<void> {
|
||||
await this.db
|
||||
.insert(contactToGroups)
|
||||
.values({ contactId, groupId })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
async removeContactFromGroup(contactId: string, groupId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(contactToGroups)
|
||||
.where(and(eq(contactToGroups.contactId, contactId), eq(contactToGroups.groupId, groupId)));
|
||||
}
|
||||
|
||||
async getContactsInGroup(groupId: string): Promise<string[]> {
|
||||
const results = await this.db
|
||||
.select({ contactId: contactToGroups.contactId })
|
||||
.from(contactToGroups)
|
||||
.where(eq(contactToGroups.groupId, groupId));
|
||||
|
||||
return results.map((r) => r.contactId);
|
||||
}
|
||||
}
|
||||
13
apps/contacts/apps/backend/src/health/health.controller.ts
Normal file
13
apps/contacts/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: 'contacts-backend',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/contacts/apps/backend/src/health/health.module.ts
Normal file
7
apps/contacts/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
40
apps/contacts/apps/backend/src/main.ts
Normal file
40
apps/contacts/apps/backend/src/main.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Enable CORS for mobile and web apps
|
||||
const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((origin) => origin.trim()) || [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5184',
|
||||
'http://localhost:8081',
|
||||
'exp://localhost:8081',
|
||||
'http://localhost:3001',
|
||||
];
|
||||
|
||||
app.enableCors({
|
||||
origin: corsOrigins,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Enable validation
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Set global prefix for API routes
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
const port = process.env.PORT || 3015;
|
||||
await app.listen(port);
|
||||
console.log(`Contacts backend running on http://localhost:${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
96
apps/contacts/apps/backend/src/note/note.controller.ts
Normal file
96
apps/contacts/apps/backend/src/note/note.controller.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { NoteService } from './note.service';
|
||||
import { IsString, IsOptional, IsBoolean } from 'class-validator';
|
||||
|
||||
class CreateNoteDto {
|
||||
@IsString()
|
||||
content!: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
class UpdateNoteDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
content?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
@Controller('contacts/:contactId/notes')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ContactNoteController {
|
||||
constructor(private readonly noteService: NoteService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('contactId', ParseUUIDPipe) contactId: string
|
||||
) {
|
||||
const notes = await this.noteService.findByContactId(contactId, user.userId);
|
||||
return { notes };
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('contactId', ParseUUIDPipe) contactId: string,
|
||||
@Body() dto: CreateNoteDto
|
||||
) {
|
||||
const note = await this.noteService.create({
|
||||
...dto,
|
||||
contactId,
|
||||
userId: user.userId,
|
||||
});
|
||||
return { note };
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('notes')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class NoteController {
|
||||
constructor(private readonly noteService: NoteService) {}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateNoteDto
|
||||
) {
|
||||
const note = await this.noteService.update(id, user.userId, dto);
|
||||
return { note };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string
|
||||
) {
|
||||
await this.noteService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/pin')
|
||||
async togglePin(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string
|
||||
) {
|
||||
const note = await this.noteService.togglePin(id, user.userId);
|
||||
return { note };
|
||||
}
|
||||
}
|
||||
10
apps/contacts/apps/backend/src/note/note.module.ts
Normal file
10
apps/contacts/apps/backend/src/note/note.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ContactNoteController, NoteController } from './note.controller';
|
||||
import { NoteService } from './note.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ContactNoteController, NoteController],
|
||||
providers: [NoteService],
|
||||
exports: [NoteService],
|
||||
})
|
||||
export class NoteModule {}
|
||||
60
apps/contacts/apps/backend/src/note/note.service.ts
Normal file
60
apps/contacts/apps/backend/src/note/note.service.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { contactNotes, type ContactNote, type NewContactNote } from '../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class NoteService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findByContactId(contactId: string, userId: string): Promise<ContactNote[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(contactNotes)
|
||||
.where(and(eq(contactNotes.contactId, contactId), eq(contactNotes.userId, userId)))
|
||||
.orderBy(desc(contactNotes.isPinned), desc(contactNotes.createdAt));
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<ContactNote | null> {
|
||||
const [note] = await this.db
|
||||
.select()
|
||||
.from(contactNotes)
|
||||
.where(and(eq(contactNotes.id, id), eq(contactNotes.userId, userId)));
|
||||
return note || null;
|
||||
}
|
||||
|
||||
async create(data: NewContactNote): Promise<ContactNote> {
|
||||
const [note] = await this.db.insert(contactNotes).values(data).returning();
|
||||
return note;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, data: Partial<NewContactNote>): Promise<ContactNote> {
|
||||
const [note] = await this.db
|
||||
.update(contactNotes)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(eq(contactNotes.id, id), eq(contactNotes.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (!note) {
|
||||
throw new NotFoundException('Note not found');
|
||||
}
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(contactNotes)
|
||||
.where(and(eq(contactNotes.id, id), eq(contactNotes.userId, userId)));
|
||||
}
|
||||
|
||||
async togglePin(id: string, userId: string): Promise<ContactNote> {
|
||||
const note = await this.findById(id, userId);
|
||||
if (!note) {
|
||||
throw new NotFoundException('Note not found');
|
||||
}
|
||||
|
||||
return this.update(id, userId, { isPinned: !note.isPinned });
|
||||
}
|
||||
}
|
||||
77
apps/contacts/apps/backend/src/tag/tag.controller.ts
Normal file
77
apps/contacts/apps/backend/src/tag/tag.controller.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { TagService } from './tag.service';
|
||||
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||
|
||||
class CreateTagDto {
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
}
|
||||
|
||||
class UpdateTagDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(50)
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
}
|
||||
|
||||
@Controller('tags')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TagController {
|
||||
constructor(private readonly tagService: TagService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
const tags = await this.tagService.findByUserId(user.userId);
|
||||
return { tags };
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateTagDto) {
|
||||
const tag = await this.tagService.create({
|
||||
...dto,
|
||||
userId: user.userId,
|
||||
});
|
||||
return { tag };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateTagDto
|
||||
) {
|
||||
const tag = await this.tagService.update(id, user.userId, dto);
|
||||
return { tag };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string
|
||||
) {
|
||||
await this.tagService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
apps/contacts/apps/backend/src/tag/tag.module.ts
Normal file
10
apps/contacts/apps/backend/src/tag/tag.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TagController } from './tag.controller';
|
||||
import { TagService } from './tag.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TagController],
|
||||
providers: [TagService],
|
||||
exports: [TagService],
|
||||
})
|
||||
export class TagModule {}
|
||||
66
apps/contacts/apps/backend/src/tag/tag.service.ts
Normal file
66
apps/contacts/apps/backend/src/tag/tag.service.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { contactTags, contactToTags, type ContactTag, type NewContactTag } from '../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class TagService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findByUserId(userId: string): Promise<ContactTag[]> {
|
||||
return this.db.select().from(contactTags).where(eq(contactTags.userId, userId));
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<ContactTag | null> {
|
||||
const [tag] = await this.db
|
||||
.select()
|
||||
.from(contactTags)
|
||||
.where(and(eq(contactTags.id, id), eq(contactTags.userId, userId)));
|
||||
return tag || null;
|
||||
}
|
||||
|
||||
async create(data: NewContactTag): Promise<ContactTag> {
|
||||
const [tag] = await this.db.insert(contactTags).values(data).returning();
|
||||
return tag;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, data: Partial<NewContactTag>): Promise<ContactTag> {
|
||||
const [tag] = await this.db
|
||||
.update(contactTags)
|
||||
.set(data)
|
||||
.where(and(eq(contactTags.id, id), eq(contactTags.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (!tag) {
|
||||
throw new NotFoundException('Tag not found');
|
||||
}
|
||||
|
||||
return tag;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(contactTags)
|
||||
.where(and(eq(contactTags.id, id), eq(contactTags.userId, userId)));
|
||||
}
|
||||
|
||||
async addTagToContact(contactId: string, tagId: string): Promise<void> {
|
||||
await this.db.insert(contactToTags).values({ contactId, tagId }).onConflictDoNothing();
|
||||
}
|
||||
|
||||
async removeTagFromContact(contactId: string, tagId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(contactToTags)
|
||||
.where(and(eq(contactToTags.contactId, contactId), eq(contactToTags.tagId, tagId)));
|
||||
}
|
||||
|
||||
async getTagsForContact(contactId: string): Promise<string[]> {
|
||||
const results = await this.db
|
||||
.select({ tagId: contactToTags.tagId })
|
||||
.from(contactToTags)
|
||||
.where(eq(contactToTags.contactId, contactId));
|
||||
|
||||
return results.map((r) => r.tagId);
|
||||
}
|
||||
}
|
||||
25
apps/contacts/apps/backend/tsconfig.json
Normal file
25
apps/contacts/apps/backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"rootDir": "./src",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
47
apps/contacts/apps/web/package.json
Normal file
47
apps/contacts/apps/web/package.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "@contacts/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 ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/node": "^20.0.0",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-feedback-service": "workspace:*",
|
||||
"@manacore/shared-feedback-ui": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-profile-ui": "workspace:*",
|
||||
"@manacore/shared-subscription-ui": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
157
apps/contacts/apps/web/src/app.css
Normal file
157
apps/contacts/apps/web/src/app.css
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
@import "tailwindcss";
|
||||
@import "@manacore/shared-tailwind/themes.css";
|
||||
|
||||
/* Contacts-specific CSS Variables */
|
||||
@layer base {
|
||||
:root {
|
||||
/* Spacing */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-2xl: 3rem;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
--transition-slow: 300ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
@layer components {
|
||||
/* Card styles */
|
||||
.card {
|
||||
background-color: hsl(var(--card));
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
border: 1px solid hsl(var(--border));
|
||||
transition: transform var(--transition-base), box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Contact card specific */
|
||||
.contact-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid hsl(var(--border));
|
||||
background-color: hsl(var(--card));
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.contact-card:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
/* Avatar styles */
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.avatar-lg {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn {
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-base);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: hsl(var(--primary) / 0.9);
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
.input {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 2px solid hsl(var(--border));
|
||||
border-radius: var(--radius-md);
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* Tag styles */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
/* Legacy color variable compatibility */
|
||||
@layer base {
|
||||
:root {
|
||||
--color-primary: var(--primary);
|
||||
--color-background: var(--background);
|
||||
--color-surface: var(--card);
|
||||
--color-surface-elevated: var(--card);
|
||||
--color-text-primary: var(--foreground);
|
||||
--color-text-secondary: var(--muted-foreground);
|
||||
--color-text-tertiary: var(--muted-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-border-hover: var(--border);
|
||||
--color-success: 142 76% 36%;
|
||||
--color-warning: 38 92% 50%;
|
||||
--color-error: 0 84% 60%;
|
||||
--color-info: 217 91% 60%;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
12
apps/contacts/apps/web/src/app.html
Normal file
12
apps/contacts/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>
|
||||
284
apps/contacts/apps/web/src/lib/api/contacts.ts
Normal file
284
apps/contacts/apps/web/src/lib/api/contacts.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const API_BASE = 'http://localhost:3015/api/v1';
|
||||
|
||||
async function fetchWithAuth(url: string, options: RequestInit = {}) {
|
||||
const token = await authStore.getAccessToken();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
||||
throw new Error(error.message || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
id: string;
|
||||
userId: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
displayName?: string | null;
|
||||
nickname?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
mobile?: string | null;
|
||||
street?: string | null;
|
||||
city?: string | null;
|
||||
postalCode?: string | null;
|
||||
country?: string | null;
|
||||
company?: string | null;
|
||||
jobTitle?: string | null;
|
||||
department?: string | null;
|
||||
website?: string | null;
|
||||
birthday?: string | null;
|
||||
notes?: string | null;
|
||||
photoUrl?: string | null;
|
||||
isFavorite: boolean;
|
||||
isArchived: boolean;
|
||||
organizationId?: string | null;
|
||||
teamId?: string | null;
|
||||
visibility: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ContactGroup {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
color?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ContactTag {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ContactNote {
|
||||
id: string;
|
||||
contactId: string;
|
||||
userId: string;
|
||||
content: string;
|
||||
isPinned: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ContactActivity {
|
||||
id: string;
|
||||
contactId: string;
|
||||
userId: string;
|
||||
activityType: string;
|
||||
description?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ContactFilters {
|
||||
search?: string;
|
||||
isFavorite?: boolean;
|
||||
isArchived?: boolean;
|
||||
groupId?: string;
|
||||
tagId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// Contacts API
|
||||
export const contactsApi = {
|
||||
async list(filters: ContactFilters = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.search) params.set('search', filters.search);
|
||||
if (filters.isFavorite !== undefined) params.set('isFavorite', String(filters.isFavorite));
|
||||
if (filters.isArchived !== undefined) params.set('isArchived', String(filters.isArchived));
|
||||
if (filters.groupId) params.set('groupId', filters.groupId);
|
||||
if (filters.tagId) params.set('tagId', filters.tagId);
|
||||
if (filters.limit) params.set('limit', String(filters.limit));
|
||||
if (filters.offset) params.set('offset', String(filters.offset));
|
||||
|
||||
const query = params.toString();
|
||||
return fetchWithAuth(`/contacts${query ? `?${query}` : ''}`);
|
||||
},
|
||||
|
||||
async get(id: string) {
|
||||
return fetchWithAuth(`/contacts/${id}`);
|
||||
},
|
||||
|
||||
async create(data: Partial<Contact>) {
|
||||
return fetchWithAuth('/contacts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<Contact>) {
|
||||
return fetchWithAuth(`/contacts/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
return fetchWithAuth(`/contacts/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
async toggleFavorite(id: string) {
|
||||
return fetchWithAuth(`/contacts/${id}/favorite`, {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
async toggleArchive(id: string) {
|
||||
return fetchWithAuth(`/contacts/${id}/archive`, {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Groups API
|
||||
export const groupsApi = {
|
||||
async list() {
|
||||
return fetchWithAuth('/groups');
|
||||
},
|
||||
|
||||
async get(id: string) {
|
||||
return fetchWithAuth(`/groups/${id}`);
|
||||
},
|
||||
|
||||
async create(data: { name: string; description?: string; color?: string }) {
|
||||
return fetchWithAuth('/groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async update(id: string, data: { name?: string; description?: string; color?: string }) {
|
||||
return fetchWithAuth(`/groups/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
return fetchWithAuth(`/groups/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
async addContacts(groupId: string, contactIds: string[]) {
|
||||
return fetchWithAuth(`/groups/${groupId}/contacts`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ contactIds }),
|
||||
});
|
||||
},
|
||||
|
||||
async removeContact(groupId: string, contactId: string) {
|
||||
return fetchWithAuth(`/groups/${groupId}/contacts/${contactId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Tags API
|
||||
export const tagsApi = {
|
||||
async list() {
|
||||
return fetchWithAuth('/tags');
|
||||
},
|
||||
|
||||
async create(data: { name: string; color?: string }) {
|
||||
return fetchWithAuth('/tags', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async update(id: string, data: { name?: string; color?: string }) {
|
||||
return fetchWithAuth(`/tags/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
return fetchWithAuth(`/tags/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Notes API
|
||||
export const notesApi = {
|
||||
async list(contactId: string) {
|
||||
return fetchWithAuth(`/contacts/${contactId}/notes`);
|
||||
},
|
||||
|
||||
async create(contactId: string, data: { content: string; isPinned?: boolean }) {
|
||||
return fetchWithAuth(`/contacts/${contactId}/notes`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async update(noteId: string, data: { content?: string; isPinned?: boolean }) {
|
||||
return fetchWithAuth(`/notes/${noteId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async delete(noteId: string) {
|
||||
return fetchWithAuth(`/notes/${noteId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
async togglePin(noteId: string) {
|
||||
return fetchWithAuth(`/notes/${noteId}/pin`, {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Activities API
|
||||
export const activitiesApi = {
|
||||
async list(contactId: string, limit?: number) {
|
||||
const params = limit ? `?limit=${limit}` : '';
|
||||
return fetchWithAuth(`/contacts/${contactId}/activities${params}`);
|
||||
},
|
||||
|
||||
async create(
|
||||
contactId: string,
|
||||
data: {
|
||||
activityType: 'created' | 'updated' | 'called' | 'emailed' | 'met' | 'note_added';
|
||||
description?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
) {
|
||||
return fetchWithAuth(`/contacts/${contactId}/activities`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
};
|
||||
32
apps/contacts/apps/web/src/lib/components/AppSlider.svelte
Normal file
32
apps/contacts/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,47 @@
|
|||
<script lang="ts">
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { setLocale, supportedLocales, type SupportedLocale } from '$lib/i18n';
|
||||
|
||||
const languageLabels: Record<SupportedLocale, string> = {
|
||||
de: 'Deutsch',
|
||||
en: 'English',
|
||||
it: 'Italiano',
|
||||
fr: 'Français',
|
||||
es: 'Español',
|
||||
};
|
||||
|
||||
let isOpen = $state(false);
|
||||
|
||||
function handleSelect(lang: SupportedLocale) {
|
||||
setLocale(lang);
|
||||
isOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => (isOpen = !isOpen)}
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{languageLabels[$locale as SupportedLocale] || 'Language'}
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="absolute right-0 mt-2 w-40 rounded-md border border-border bg-card shadow-lg z-50"
|
||||
>
|
||||
{#each supportedLocales as lang}
|
||||
<button
|
||||
onclick={() => handleSelect(lang)}
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-accent transition-colors first:rounded-t-md last:rounded-b-md"
|
||||
class:bg-accent={$locale === lang}
|
||||
>
|
||||
{languageLabels[lang]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
<script lang="ts">
|
||||
import { toasts, type Toast } from '$lib/stores/toast';
|
||||
|
||||
function getIcon(type: Toast['type']) {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return '✓';
|
||||
case 'error':
|
||||
return '✕';
|
||||
case 'warning':
|
||||
return '⚠';
|
||||
case 'info':
|
||||
default:
|
||||
return 'ℹ';
|
||||
}
|
||||
}
|
||||
|
||||
function getColorClass(type: Toast['type']) {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'bg-green-500';
|
||||
case 'error':
|
||||
return 'bg-red-500';
|
||||
case 'warning':
|
||||
return 'bg-yellow-500';
|
||||
case 'info':
|
||||
default:
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{#each $toasts as toast (toast.id)}
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg bg-card px-4 py-3 shadow-lg border border-border animate-in slide-in-from-right duration-200"
|
||||
>
|
||||
<span
|
||||
class="{getColorClass(toast.type)} flex h-6 w-6 items-center justify-center rounded-full text-white text-sm"
|
||||
>
|
||||
{getIcon(toast.type)}
|
||||
</span>
|
||||
<span class="text-foreground">{toast.message}</span>
|
||||
<button
|
||||
onclick={() => toasts.remove(toast.id)}
|
||||
class="ml-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes slide-in-from-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: slide-in-from-right 0.2s ease-out;
|
||||
}
|
||||
</style>
|
||||
49
apps/contacts/apps/web/src/lib/i18n/index.ts
Normal file
49
apps/contacts/apps/web/src/lib/i18n/index.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { init, register, locale, waitLocale } from 'svelte-i18n';
|
||||
|
||||
// List of supported locales
|
||||
export const supportedLocales = ['de', 'en', 'it', 'fr', 'es'] as const;
|
||||
export type SupportedLocale = (typeof supportedLocales)[number];
|
||||
|
||||
// Default locale
|
||||
const defaultLocale = 'de';
|
||||
|
||||
// Register all available locales
|
||||
register('de', () => import('./locales/de.json'));
|
||||
register('en', () => import('./locales/en.json'));
|
||||
|
||||
// Get initial locale from browser or localStorage
|
||||
function getInitialLocale(): SupportedLocale {
|
||||
if (browser) {
|
||||
// Check localStorage first
|
||||
const stored = localStorage.getItem('contacts_locale');
|
||||
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
|
||||
return stored as SupportedLocale;
|
||||
}
|
||||
|
||||
// Fall back to browser language
|
||||
const browserLang = navigator.language.split('-')[0];
|
||||
if (supportedLocales.includes(browserLang as SupportedLocale)) {
|
||||
return browserLang as SupportedLocale;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
// Initialize i18n at module scope (required for SSR)
|
||||
init({
|
||||
fallbackLocale: defaultLocale,
|
||||
initialLocale: getInitialLocale(),
|
||||
});
|
||||
|
||||
// Set locale and persist to localStorage
|
||||
export function setLocale(newLocale: SupportedLocale) {
|
||||
locale.set(newLocale);
|
||||
if (browser) {
|
||||
localStorage.setItem('contacts_locale', newLocale);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for locale to be loaded (useful for SSR)
|
||||
export { waitLocale };
|
||||
64
apps/contacts/apps/web/src/lib/i18n/locales/de.json
Normal file
64
apps/contacts/apps/web/src/lib/i18n/locales/de.json
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Contacts"
|
||||
},
|
||||
"nav": {
|
||||
"contacts": "Kontakte",
|
||||
"groups": "Gruppen",
|
||||
"favorites": "Favoriten",
|
||||
"archive": "Archiv",
|
||||
"search": "Suche",
|
||||
"settings": "Einstellungen",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Kontakte",
|
||||
"new": "Neuer Kontakt",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"search": "Kontakte durchsuchen...",
|
||||
"noContacts": "Keine Kontakte gefunden",
|
||||
"addFirst": "Füge deinen ersten Kontakt hinzu",
|
||||
"favorites": "Favoriten",
|
||||
"archive": "Archiv"
|
||||
},
|
||||
"contact": {
|
||||
"firstName": "Vorname",
|
||||
"lastName": "Nachname",
|
||||
"displayName": "Anzeigename",
|
||||
"email": "E-Mail",
|
||||
"phone": "Telefon",
|
||||
"mobile": "Mobil",
|
||||
"company": "Firma",
|
||||
"jobTitle": "Position",
|
||||
"department": "Abteilung",
|
||||
"street": "Straße",
|
||||
"city": "Stadt",
|
||||
"postalCode": "PLZ",
|
||||
"country": "Land",
|
||||
"website": "Website",
|
||||
"birthday": "Geburtstag",
|
||||
"notes": "Notizen"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Gruppen",
|
||||
"new": "Neue Gruppe",
|
||||
"noGroups": "Keine Gruppen vorhanden"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"create": "Erstellen",
|
||||
"favorite": "Favorit",
|
||||
"unfavorite": "Kein Favorit",
|
||||
"archive": "Archivieren",
|
||||
"unarchive": "Wiederherstellen"
|
||||
},
|
||||
"messages": {
|
||||
"saved": "Gespeichert",
|
||||
"deleted": "Gelöscht",
|
||||
"error": "Ein Fehler ist aufgetreten"
|
||||
}
|
||||
}
|
||||
64
apps/contacts/apps/web/src/lib/i18n/locales/en.json
Normal file
64
apps/contacts/apps/web/src/lib/i18n/locales/en.json
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Contacts"
|
||||
},
|
||||
"nav": {
|
||||
"contacts": "Contacts",
|
||||
"groups": "Groups",
|
||||
"favorites": "Favorites",
|
||||
"archive": "Archive",
|
||||
"search": "Search",
|
||||
"settings": "Settings",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Contacts",
|
||||
"new": "New Contact",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"search": "Search contacts...",
|
||||
"noContacts": "No contacts found",
|
||||
"addFirst": "Add your first contact",
|
||||
"favorites": "Favorites",
|
||||
"archive": "Archive"
|
||||
},
|
||||
"contact": {
|
||||
"firstName": "First Name",
|
||||
"lastName": "Last Name",
|
||||
"displayName": "Display Name",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"mobile": "Mobile",
|
||||
"company": "Company",
|
||||
"jobTitle": "Job Title",
|
||||
"department": "Department",
|
||||
"street": "Street",
|
||||
"city": "City",
|
||||
"postalCode": "Postal Code",
|
||||
"country": "Country",
|
||||
"website": "Website",
|
||||
"birthday": "Birthday",
|
||||
"notes": "Notes"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Groups",
|
||||
"new": "New Group",
|
||||
"noGroups": "No groups available"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"create": "Create",
|
||||
"favorite": "Favorite",
|
||||
"unfavorite": "Unfavorite",
|
||||
"archive": "Archive",
|
||||
"unarchive": "Restore"
|
||||
},
|
||||
"messages": {
|
||||
"saved": "Saved",
|
||||
"deleted": "Deleted",
|
||||
"error": "An error occurred"
|
||||
}
|
||||
}
|
||||
186
apps/contacts/apps/web/src/lib/stores/auth.svelte.ts
Normal file
186
apps/contacts/apps/web/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/**
|
||||
* 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';
|
||||
|
||||
// Initialize Mana Core Auth only on the client side
|
||||
// TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env when available
|
||||
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;
|
||||
}
|
||||
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;
|
||||
|
||||
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;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authService.signOut();
|
||||
user = null;
|
||||
} catch (error) {
|
||||
console.error('Sign out error:', error);
|
||||
// Clear user even if sign out fails
|
||||
user = 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();
|
||||
},
|
||||
};
|
||||
207
apps/contacts/apps/web/src/lib/stores/contacts.svelte.ts
Normal file
207
apps/contacts/apps/web/src/lib/stores/contacts.svelte.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
/**
|
||||
* Contacts Store - Manages contacts state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import { contactsApi, type Contact, type ContactFilters } from '$lib/api/contacts';
|
||||
|
||||
// State
|
||||
let contacts = $state<Contact[]>([]);
|
||||
let selectedContact = $state<Contact | null>(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let total = $state(0);
|
||||
let filters = $state<ContactFilters>({});
|
||||
|
||||
export const contactsStore = {
|
||||
// Getters
|
||||
get contacts() {
|
||||
return contacts;
|
||||
},
|
||||
get selectedContact() {
|
||||
return selectedContact;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get total() {
|
||||
return total;
|
||||
},
|
||||
get filters() {
|
||||
return filters;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load contacts with optional filters
|
||||
*/
|
||||
async loadContacts(newFilters?: ContactFilters) {
|
||||
if (newFilters) {
|
||||
filters = { ...filters, ...newFilters };
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await contactsApi.list(filters);
|
||||
contacts = result.contacts;
|
||||
total = result.total;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load contacts';
|
||||
console.error('Failed to load contacts:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load a single contact by ID
|
||||
*/
|
||||
async loadContact(id: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await contactsApi.get(id);
|
||||
selectedContact = result.contact;
|
||||
return result.contact;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load contact';
|
||||
console.error('Failed to load contact:', e);
|
||||
return null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new contact
|
||||
*/
|
||||
async createContact(data: Partial<Contact>) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await contactsApi.create(data);
|
||||
// Add to local state
|
||||
contacts = [result.contact, ...contacts];
|
||||
total += 1;
|
||||
return result.contact;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create contact';
|
||||
console.error('Failed to create contact:', e);
|
||||
throw e;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a contact
|
||||
*/
|
||||
async updateContact(id: string, data: Partial<Contact>) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await contactsApi.update(id, data);
|
||||
// Update in local state
|
||||
contacts = contacts.map((c) => (c.id === id ? result.contact : c));
|
||||
if (selectedContact?.id === id) {
|
||||
selectedContact = result.contact;
|
||||
}
|
||||
return result.contact;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update contact';
|
||||
console.error('Failed to update contact:', e);
|
||||
throw e;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a contact
|
||||
*/
|
||||
async deleteContact(id: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
await contactsApi.delete(id);
|
||||
// Remove from local state
|
||||
contacts = contacts.filter((c) => c.id !== id);
|
||||
total -= 1;
|
||||
if (selectedContact?.id === id) {
|
||||
selectedContact = null;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete contact';
|
||||
console.error('Failed to delete contact:', e);
|
||||
throw e;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle favorite status
|
||||
*/
|
||||
async toggleFavorite(id: string) {
|
||||
try {
|
||||
const result = await contactsApi.toggleFavorite(id);
|
||||
// Update in local state
|
||||
contacts = contacts.map((c) => (c.id === id ? result.contact : c));
|
||||
if (selectedContact?.id === id) {
|
||||
selectedContact = result.contact;
|
||||
}
|
||||
return result.contact;
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle favorite:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle archive status
|
||||
*/
|
||||
async toggleArchive(id: string) {
|
||||
try {
|
||||
const result = await contactsApi.toggleArchive(id);
|
||||
// Remove from current view if archived/unarchived
|
||||
contacts = contacts.filter((c) => c.id !== id);
|
||||
total -= 1;
|
||||
if (selectedContact?.id === id) {
|
||||
selectedContact = null;
|
||||
}
|
||||
return result.contact;
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle archive:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear filters and reload
|
||||
*/
|
||||
async clearFilters() {
|
||||
filters = {};
|
||||
await this.loadContacts();
|
||||
},
|
||||
|
||||
/**
|
||||
* Set search query
|
||||
*/
|
||||
setSearch(search: string) {
|
||||
filters = { ...filters, search };
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear selected contact
|
||||
*/
|
||||
clearSelected() {
|
||||
selectedContact = null;
|
||||
},
|
||||
};
|
||||
4
apps/contacts/apps/web/src/lib/stores/navigation.ts
Normal file
4
apps/contacts/apps/web/src/lib/stores/navigation.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
export const isSidebarMode = writable(false);
|
||||
export const isNavCollapsed = writable(false);
|
||||
7
apps/contacts/apps/web/src/lib/stores/theme.ts
Normal file
7
apps/contacts/apps/web/src/lib/stores/theme.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { createThemeStore } from '@manacore/shared-theme';
|
||||
|
||||
// Create theme store with Contacts' primary color (blue)
|
||||
export const theme = createThemeStore({
|
||||
appId: 'contacts',
|
||||
defaultVariant: 'lume',
|
||||
});
|
||||
44
apps/contacts/apps/web/src/lib/stores/toast.ts
Normal file
44
apps/contacts/apps/web/src/lib/stores/toast.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'info' | 'warning';
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
function createToastStore() {
|
||||
const { subscribe, update } = writable<Toast[]>([]);
|
||||
|
||||
function addToast(toast: Omit<Toast, 'id'>) {
|
||||
const id = crypto.randomUUID();
|
||||
const newToast = { ...toast, id };
|
||||
|
||||
update((toasts) => [...toasts, newToast]);
|
||||
|
||||
// Auto-remove after duration
|
||||
const duration = toast.duration || 5000;
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, duration);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function removeToast(id: string) {
|
||||
update((toasts) => toasts.filter((t) => t.id !== id));
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
success: (message: string, duration?: number) =>
|
||||
addToast({ type: 'success', message, duration }),
|
||||
error: (message: string, duration?: number) => addToast({ type: 'error', message, duration }),
|
||||
info: (message: string, duration?: number) => addToast({ type: 'info', message, duration }),
|
||||
warning: (message: string, duration?: number) =>
|
||||
addToast({ type: 'warning', message, duration }),
|
||||
remove: removeToast,
|
||||
};
|
||||
}
|
||||
|
||||
export const toasts = createToastStore();
|
||||
248
apps/contacts/apps/web/src/routes/(app)/+layout.svelte
Normal file
248
apps/contacts/apps/web/src/routes/(app)/+layout.svelte
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
<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 { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
|
||||
import {
|
||||
isSidebarMode as sidebarModeStore,
|
||||
isNavCollapsed as collapsedStore,
|
||||
} from '$lib/stores/navigation';
|
||||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('contacts');
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
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
|
||||
...theme.variants.map((variant) => ({
|
||||
id: variant,
|
||||
label: THEME_DEFINITIONS[variant].label,
|
||||
icon: THEME_DEFINITIONS[variant].icon,
|
||||
onClick: () => theme.setVariant(variant),
|
||||
active: theme.variant === variant,
|
||||
})),
|
||||
// Separator and link to full themes page
|
||||
{
|
||||
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) {
|
||||
setLocale(newLocale as any);
|
||||
}
|
||||
let languageItems = $derived(
|
||||
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
|
||||
);
|
||||
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
|
||||
|
||||
// User email for user dropdown (fallback to 'Menü' when not logged in)
|
||||
let userEmail = $derived(authStore.user?.email || 'Menü');
|
||||
|
||||
// Navigation items for Contacts
|
||||
const navItems: PillNavItem[] = [
|
||||
{ href: '/', label: 'Kontakte', icon: 'users' },
|
||||
{ href: '/groups', label: 'Gruppen', icon: 'folder' },
|
||||
{ href: '/favorites', label: 'Favoriten', icon: 'heart' },
|
||||
{ href: '/archive', label: 'Archiv', icon: 'archive' },
|
||||
{ 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;
|
||||
|
||||
// Cmd/Ctrl+K to open search (works even in inputs)
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||
event.preventDefault();
|
||||
// TODO: Open search modal
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
sidebarModeStore.set(isSidebar);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('contacts-nav-sidebar', String(isSidebar));
|
||||
}
|
||||
}
|
||||
|
||||
function handleCollapsedChange(collapsed: boolean) {
|
||||
isCollapsed = collapsed;
|
||||
collapsedStore.set(collapsed);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('contacts-nav-collapsed', String(collapsed));
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleTheme() {
|
||||
theme.toggleMode();
|
||||
}
|
||||
|
||||
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
|
||||
theme.setMode(mode);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.signOut();
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Redirect to login if not authenticated
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize sidebar mode from localStorage
|
||||
const savedSidebar = localStorage.getItem('contacts-nav-sidebar');
|
||||
if (savedSidebar === 'true') {
|
||||
isSidebarMode = true;
|
||||
sidebarModeStore.set(true);
|
||||
}
|
||||
|
||||
// Initialize collapsed state from localStorage
|
||||
const savedCollapsed = localStorage.getItem('contacts-nav-collapsed');
|
||||
if (savedCollapsed === 'true') {
|
||||
isCollapsed = true;
|
||||
collapsedStore.set(true);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- Navigation Layout -->
|
||||
<div class="layout-container">
|
||||
<!-- Floating/Sidebar Pill Navigation -->
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Contacts"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#3b82f6"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
allAppsHref="/apps"
|
||||
/>
|
||||
|
||||
<!-- Main Content with dynamic padding based on nav mode -->
|
||||
<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>
|
||||
|
||||
<style>
|
||||
.layout-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
transition: all 300ms ease;
|
||||
}
|
||||
|
||||
/* Floating nav mode - add top padding for fixed nav */
|
||||
.main-content.floating-mode {
|
||||
padding-top: 100px;
|
||||
}
|
||||
|
||||
/* Sidebar mode - add left padding for sidebar nav */
|
||||
.main-content.sidebar-mode {
|
||||
padding-left: 180px;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 80rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.content-wrapper {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.content-wrapper {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
182
apps/contacts/apps/web/src/routes/(app)/+page.svelte
Normal file
182
apps/contacts/apps/web/src/routes/(app)/+page.svelte
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import '$lib/i18n';
|
||||
|
||||
let searchQuery = $state('');
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
function handleSearch() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
contactsStore.setSearch(searchQuery);
|
||||
contactsStore.loadContacts();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function getInitials(contact: (typeof contactsStore.contacts)[0]) {
|
||||
const first = contact.firstName?.[0] || '';
|
||||
const last = contact.lastName?.[0] || '';
|
||||
return (first + last).toUpperCase() || contact.email?.[0]?.toUpperCase() || '?';
|
||||
}
|
||||
|
||||
function getDisplayName(contact: (typeof contactsStore.contacts)[0]) {
|
||||
if (contact.displayName) return contact.displayName;
|
||||
if (contact.firstName || contact.lastName) {
|
||||
return [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
return contact.email || 'Unbekannt';
|
||||
}
|
||||
|
||||
async function handleToggleFavorite(e: MouseEvent, id: string) {
|
||||
e.stopPropagation();
|
||||
await contactsStore.toggleFavorite(id);
|
||||
}
|
||||
|
||||
function handleContactClick(id: string) {
|
||||
goto(`/contacts/${id}`);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await contactsStore.loadContacts();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('contacts.title')} - Contacts</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('contacts.title')}</h1>
|
||||
<a
|
||||
href="/contacts/new"
|
||||
class="btn btn-primary flex items-center gap-2"
|
||||
>
|
||||
<span>+</span>
|
||||
<span>{$_('contacts.new')}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$_('contacts.search')}
|
||||
bind:value={searchQuery}
|
||||
oninput={handleSearch}
|
||||
class="input w-full pl-10"
|
||||
/>
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
{#if contactsStore.loading}
|
||||
<div class="flex justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else if contactsStore.contacts.length === 0}
|
||||
<!-- Empty state -->
|
||||
<div class="text-center py-12">
|
||||
<div class="text-6xl mb-4">👤</div>
|
||||
<h2 class="text-xl font-semibold text-foreground mb-2">{$_('contacts.noContacts')}</h2>
|
||||
<p class="text-muted-foreground mb-4">{$_('contacts.addFirst')}</p>
|
||||
<a href="/contacts/new" class="btn btn-primary">
|
||||
{$_('contacts.new')}
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Contacts List -->
|
||||
<div class="space-y-2">
|
||||
{#each contactsStore.contacts as contact (contact.id)}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => handleContactClick(contact.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleContactClick(contact.id)}
|
||||
class="contact-card w-full text-left cursor-pointer"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="avatar">
|
||||
{#if contact.photoUrl}
|
||||
<img
|
||||
src={contact.photoUrl}
|
||||
alt={getDisplayName(contact)}
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
{getInitials(contact)}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Contact Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-foreground truncate">
|
||||
{getDisplayName(contact)}
|
||||
</div>
|
||||
{#if contact.company || contact.jobTitle}
|
||||
<div class="text-sm text-muted-foreground truncate">
|
||||
{[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')}
|
||||
</div>
|
||||
{/if}
|
||||
{#if contact.email}
|
||||
<div class="text-sm text-muted-foreground truncate">
|
||||
{contact.email}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Favorite button -->
|
||||
<button
|
||||
onclick={(e) => handleToggleFavorite(e, contact.id)}
|
||||
class="p-2 rounded-full hover:bg-accent transition-colors"
|
||||
>
|
||||
{#if contact.isFavorite}
|
||||
<svg class="h-5 w-5 text-red-500 fill-current" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="h-5 w-5 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Total count -->
|
||||
<p class="text-sm text-muted-foreground text-center">
|
||||
{contactsStore.total} Kontakte
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
5
apps/contacts/apps/web/src/routes/(auth)/+layout.svelte
Normal file
5
apps/contacts/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 { ContactsLogo } 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';
|
||||
import '$lib/i18n';
|
||||
|
||||
const translations = $derived(getForgotPasswordTranslations($locale || 'de'));
|
||||
|
||||
async function handleResetPassword(email: string) {
|
||||
return authStore.resetPassword(email);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{translations.title} | Contacts</title>
|
||||
</svelte:head>
|
||||
|
||||
<ForgotPasswordPage
|
||||
appName="Contacts"
|
||||
logo={ContactsLogo}
|
||||
primaryColor="#3b82f6"
|
||||
onResetPassword={handleResetPassword}
|
||||
{goto}
|
||||
loginPath="/login"
|
||||
lightBackground="#eff6ff"
|
||||
darkBackground="#1e293b"
|
||||
{translations}
|
||||
>
|
||||
{#snippet headerControls()}
|
||||
<LanguageSelector />
|
||||
{/snippet}
|
||||
{#snippet appSlider()}
|
||||
<AppSlider />
|
||||
{/snippet}
|
||||
</ForgotPasswordPage>
|
||||
49
apps/contacts/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
49
apps/contacts/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<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 { ContactsLogo } 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';
|
||||
import '$lib/i18n';
|
||||
|
||||
// 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} | Contacts</title>
|
||||
</svelte:head>
|
||||
|
||||
<LoginPage
|
||||
appName="Contacts"
|
||||
logo={ContactsLogo}
|
||||
primaryColor="#3b82f6"
|
||||
onSignIn={handleSignIn}
|
||||
{goto}
|
||||
enableGoogle={false}
|
||||
enableApple={false}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
forgotPasswordPath="/forgot-password"
|
||||
lightBackground="#eff6ff"
|
||||
darkBackground="#1e293b"
|
||||
{translations}
|
||||
>
|
||||
{#snippet headerControls()}
|
||||
<LanguageSelector />
|
||||
{/snippet}
|
||||
{#snippet appSlider()}
|
||||
<AppSlider />
|
||||
{/snippet}
|
||||
</LoginPage>
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<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 { ContactsLogo } 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';
|
||||
import '$lib/i18n';
|
||||
|
||||
const translations = $derived(getRegisterTranslations($locale || 'de'));
|
||||
|
||||
async function handleSignUp(email: string, password: string) {
|
||||
return authStore.signUp(email, password);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{translations.title} | Contacts</title>
|
||||
</svelte:head>
|
||||
|
||||
<RegisterPage
|
||||
appName="Contacts"
|
||||
logo={ContactsLogo}
|
||||
primaryColor="#3b82f6"
|
||||
onSignUp={handleSignUp}
|
||||
{goto}
|
||||
successRedirect="/"
|
||||
loginPath="/login"
|
||||
lightBackground="#eff6ff"
|
||||
darkBackground="#1e293b"
|
||||
{translations}
|
||||
>
|
||||
{#snippet headerControls()}
|
||||
<LanguageSelector />
|
||||
{/snippet}
|
||||
{#snippet appSlider()}
|
||||
<AppSlider />
|
||||
{/snippet}
|
||||
</RegisterPage>
|
||||
9
apps/contacts/apps/web/src/routes/+error.svelte
Normal file
9
apps/contacts/apps/web/src/routes/+error.svelte
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-[60vh] flex-col items-center justify-center text-center">
|
||||
<h1 class="text-6xl font-bold text-primary mb-4">{$page.status}</h1>
|
||||
<p class="text-xl text-muted-foreground mb-8">{$page.error?.message || 'Seite nicht gefunden'}</p>
|
||||
<a href="/" class="btn btn-primary">Zurück zur Startseite</a>
|
||||
</div>
|
||||
39
apps/contacts/apps/web/src/routes/+layout.svelte
Normal file
39
apps/contacts/apps/web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
// Initialize theme
|
||||
theme.initialize();
|
||||
|
||||
// Initialize auth
|
||||
await authStore.initialize();
|
||||
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#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="min-h-screen bg-background text-foreground">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Global Toast notifications -->
|
||||
<ToastContainer />
|
||||
12
apps/contacts/apps/web/svelte.config.js
Normal file
12
apps/contacts/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/contacts/apps/web/tsconfig.json
Normal file
14
apps/contacts/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/contacts/apps/web/vite.config.ts
Normal file
45
apps/contacts/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: 5184,
|
||||
strictPort: true,
|
||||
},
|
||||
ssr: {
|
||||
noExternal: [
|
||||
'@manacore/shared-icons',
|
||||
'@manacore/shared-ui',
|
||||
'@manacore/shared-tailwind',
|
||||
'@manacore/shared-theme',
|
||||
'@manacore/shared-theme-ui',
|
||||
'@manacore/shared-feedback-ui',
|
||||
'@manacore/shared-feedback-service',
|
||||
'@manacore/shared-feedback-types',
|
||||
'@manacore/shared-auth',
|
||||
'@manacore/shared-auth-ui',
|
||||
'@manacore/shared-branding',
|
||||
'@manacore/shared-subscription-ui',
|
||||
'@manacore/shared-utils',
|
||||
],
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: [
|
||||
'@manacore/shared-icons',
|
||||
'@manacore/shared-ui',
|
||||
'@manacore/shared-tailwind',
|
||||
'@manacore/shared-theme',
|
||||
'@manacore/shared-theme-ui',
|
||||
'@manacore/shared-feedback-ui',
|
||||
'@manacore/shared-feedback-service',
|
||||
'@manacore/shared-feedback-types',
|
||||
'@manacore/shared-auth',
|
||||
'@manacore/shared-auth-ui',
|
||||
'@manacore/shared-branding',
|
||||
'@manacore/shared-subscription-ui',
|
||||
'@manacore/shared-utils',
|
||||
],
|
||||
},
|
||||
});
|
||||
23
apps/contacts/package.json
Normal file
23
apps/contacts/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "contacts",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Contacts App - Contact Management with Manacore Integration",
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"dev:backend": "pnpm --filter @contacts/backend dev",
|
||||
"dev:web": "pnpm --filter @contacts/web dev",
|
||||
"dev:landing": "pnpm --filter @contacts/landing dev",
|
||||
"dev:mobile": "pnpm --filter @contacts/mobile dev",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
"clean": "turbo run clean",
|
||||
"db:push": "pnpm --filter @contacts/backend db:push",
|
||||
"db:studio": "pnpm --filter @contacts/backend db:studio",
|
||||
"db:seed": "pnpm --filter @contacts/backend db:seed"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0"
|
||||
}
|
||||
|
|
@ -83,6 +83,7 @@ services:
|
|||
mc mb --ignore-existing myminio/presi-storage;
|
||||
mc mb --ignore-existing myminio/calendar-storage;
|
||||
mc mb --ignore-existing myminio/contacts-storage;
|
||||
mc mb --ignore-existing myminio/storage-storage;
|
||||
mc anonymous set download myminio/picture-storage;
|
||||
echo 'Buckets created successfully';
|
||||
exit 0;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@ CREATE DATABASE chat;
|
|||
-- Create voxel_lava database
|
||||
CREATE DATABASE voxel_lava;
|
||||
|
||||
-- Create storage database (cloud drive)
|
||||
CREATE DATABASE storage;
|
||||
|
||||
-- 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;
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ const moodlitSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill
|
|||
// Nutriphi icon (nutrition/heart with gradient)
|
||||
const nutriphiSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#nutriGrad)"/><path d="M512 760C512 760 280 600 280 420C280 340 344 280 424 280C472 280 512 308 512 308C512 308 552 280 600 280C680 280 744 340 744 420C744 600 512 760 512 760Z" fill="white"/><path d="M512 280V200" stroke="white" stroke-width="24" stroke-linecap="round"/><path d="M512 200C512 200 560 160 600 180" stroke="white" stroke-width="24" stroke-linecap="round"/><defs><linearGradient id="nutriGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#10b981"/><stop offset="1" stop-color="#059669"/></linearGradient></defs></svg>`;
|
||||
|
||||
// Contacts icon (address book/person with gradient)
|
||||
const contactsSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#contactsGrad)"/><circle cx="512" cy="380" r="100" fill="white"/><path d="M320 620C320 540 408 480 512 480C616 480 704 540 704 620V680C704 702.091 685.091 720 663 720H361C338.909 720 320 702.091 320 680V620Z" fill="white"/><rect x="240" y="300" width="24" height="80" rx="12" fill="white" fill-opacity="0.6"/><rect x="240" y="420" width="24" height="80" rx="12" fill="white" fill-opacity="0.6"/><rect x="240" y="540" width="24" height="80" rx="12" fill="white" fill-opacity="0.6"/><defs><linearGradient id="contactsGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#3b82f6"/><stop offset="1" stop-color="#2563eb"/></linearGradient></defs></svg>`;
|
||||
|
||||
/**
|
||||
* App icons as data URLs
|
||||
* Use these directly in <img src={APP_ICONS.memoro}> or CSS background-image
|
||||
|
|
@ -62,6 +65,7 @@ export const APP_ICONS = {
|
|||
wisekeep: svgToDataUrl(wisekeepSvg),
|
||||
moodlit: svgToDataUrl(moodlitSvg),
|
||||
nutriphi: svgToDataUrl(nutriphiSvg),
|
||||
contacts: svgToDataUrl(contactsSvg),
|
||||
} as const;
|
||||
|
||||
export type AppIconId = keyof typeof APP_ICONS;
|
||||
|
|
|
|||
|
|
@ -131,6 +131,19 @@ export const APP_BRANDING: Record<AppId, AppBranding> = {
|
|||
logoStroke: true,
|
||||
logoStrokeWidth: 1.5,
|
||||
},
|
||||
contacts: {
|
||||
id: 'contacts',
|
||||
name: 'Contacts',
|
||||
tagline: 'Contact Management',
|
||||
primaryColor: '#3b82f6',
|
||||
secondaryColor: '#60a5fa',
|
||||
// Users/contacts icon
|
||||
logoPath:
|
||||
'M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z',
|
||||
logoViewBox: '0 0 24 24',
|
||||
logoStroke: true,
|
||||
logoStrokeWidth: 1.5,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export {
|
|||
PresiLogo,
|
||||
NutriPhiLogo,
|
||||
ZitareLogo,
|
||||
ContactsLogo,
|
||||
} from './logos';
|
||||
|
||||
// Configuration
|
||||
|
|
|
|||
13
packages/shared-branding/src/logos/ContactsLogo.svelte
Normal file
13
packages/shared-branding/src/logos/ContactsLogo.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="contacts" {size} {color} class={className} />
|
||||
|
|
@ -11,3 +11,4 @@ export { default as ChatLogo } from './ChatLogo.svelte';
|
|||
export { default as PresiLogo } from './PresiLogo.svelte';
|
||||
export { default as NutriPhiLogo } from './NutriPhiLogo.svelte';
|
||||
export { default as ZitareLogo } from './ZitareLogo.svelte';
|
||||
export { default as ContactsLogo } from './ContactsLogo.svelte';
|
||||
|
|
|
|||
|
|
@ -180,6 +180,22 @@ export const MANA_APPS: ManaApp[] = [
|
|||
status: 'development',
|
||||
archived: true,
|
||||
},
|
||||
{
|
||||
id: 'contacts',
|
||||
name: 'ManaContacts',
|
||||
description: {
|
||||
de: 'Kontaktverwaltung',
|
||||
en: 'Contact Management',
|
||||
},
|
||||
longDescription: {
|
||||
de: 'Verwalte deine Kontakte übersichtlich mit Gruppen, Tags und Notizen.',
|
||||
en: 'Manage your contacts clearly with groups, tags, and notes.',
|
||||
},
|
||||
icon: APP_ICONS.contacts,
|
||||
color: '#3b82f6',
|
||||
comingSoon: false,
|
||||
status: 'development',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -260,6 +276,7 @@ export const APP_URLS: Record<AppIconId, { dev: string; prod: string }> = {
|
|||
manacore: { dev: 'http://localhost:5173', prod: 'https://manacore.app' },
|
||||
mana: { dev: 'http://localhost:5173', prod: 'https://manacore.app' },
|
||||
moodlit: { dev: 'http://localhost:5183', prod: 'https://moodlit.manacore.app' },
|
||||
contacts: { dev: 'http://localhost:5184', prod: 'https://contacts.manacore.app' },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ export type AppId =
|
|||
| 'presi'
|
||||
| 'nutriphi'
|
||||
| 'zitare'
|
||||
| 'picture';
|
||||
| 'picture'
|
||||
| 'contacts';
|
||||
|
||||
/**
|
||||
* App branding configuration
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ The following buckets are automatically created:
|
|||
| `presi-storage` | Presi | Presentation slides |
|
||||
| `calendar-storage` | Calendar | Calendar attachments |
|
||||
| `contacts-storage` | Contacts | Contact avatars/files |
|
||||
| `storage-storage` | Storage | Cloud drive files |
|
||||
|
||||
## Usage
|
||||
|
||||
|
|
@ -89,6 +90,7 @@ import {
|
|||
createPresiStorage,
|
||||
createCalendarStorage,
|
||||
createContactsStorage,
|
||||
createStorageStorage,
|
||||
} from '@manacore/shared-storage';
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -119,3 +119,13 @@ export function createCalendarStorage(): StorageClient {
|
|||
export function createContactsStorage(): StorageClient {
|
||||
return createStorageClient({ name: BUCKETS.CONTACTS });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a storage client for the Storage project (cloud drive)
|
||||
*/
|
||||
export function createStorageStorage(publicUrl?: string): StorageClient {
|
||||
return createStorageClient({
|
||||
name: BUCKETS.STORAGE,
|
||||
publicUrl: publicUrl ?? process.env.STORAGE_S3_PUBLIC_URL,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export {
|
|||
createPresiStorage,
|
||||
createCalendarStorage,
|
||||
createContactsStorage,
|
||||
createStorageStorage,
|
||||
} from './factory.js';
|
||||
|
||||
// Utilities
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export const BUCKETS = {
|
|||
PRESI: 'presi-storage',
|
||||
CALENDAR: 'calendar-storage',
|
||||
CONTACTS: 'contacts-storage',
|
||||
STORAGE: 'storage-storage',
|
||||
} as const;
|
||||
|
||||
export type BucketName = (typeof BUCKETS)[keyof typeof BUCKETS];
|
||||
|
|
|
|||
|
|
@ -387,6 +387,75 @@ const APP_CONFIGS = [
|
|||
PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.MANA_GAMES_BACKEND_PORT || '3011'}`,
|
||||
},
|
||||
},
|
||||
|
||||
// Calendar Backend (NestJS)
|
||||
{
|
||||
path: 'apps/calendar/apps/backend/.env',
|
||||
vars: {
|
||||
NODE_ENV: () => 'development',
|
||||
PORT: (env) => env.CALENDAR_BACKEND_PORT || '3014',
|
||||
DATABASE_URL: (env) => env.CALENDAR_DATABASE_URL,
|
||||
MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
|
||||
DEV_BYPASS_AUTH: () => 'true',
|
||||
DEV_USER_ID: (env) => env.DEV_USER_ID || '00000000-0000-0000-0000-000000000000',
|
||||
CORS_ORIGINS: (env) => env.CORS_ORIGINS,
|
||||
},
|
||||
},
|
||||
|
||||
// Calendar Mobile (Expo)
|
||||
{
|
||||
path: 'apps/calendar/apps/mobile/.env',
|
||||
vars: {
|
||||
EXPO_PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.CALENDAR_BACKEND_PORT || '3014'}`,
|
||||
EXPO_PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
|
||||
},
|
||||
},
|
||||
|
||||
// Calendar Web (SvelteKit)
|
||||
{
|
||||
path: 'apps/calendar/apps/web/.env',
|
||||
vars: {
|
||||
PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.CALENDAR_BACKEND_PORT || '3014'}`,
|
||||
PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
|
||||
},
|
||||
},
|
||||
|
||||
// Contacts Backend (NestJS)
|
||||
{
|
||||
path: 'apps/contacts/apps/backend/.env',
|
||||
vars: {
|
||||
NODE_ENV: () => 'development',
|
||||
PORT: (env) => env.CONTACTS_BACKEND_PORT || '3015',
|
||||
DATABASE_URL: (env) => env.CONTACTS_DATABASE_URL,
|
||||
MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
|
||||
DEV_BYPASS_AUTH: () => 'true',
|
||||
DEV_USER_ID: (env) => env.DEV_USER_ID || '00000000-0000-0000-0000-000000000000',
|
||||
S3_ENDPOINT: (env) => env.S3_ENDPOINT,
|
||||
S3_REGION: (env) => env.S3_REGION,
|
||||
S3_ACCESS_KEY: (env) => env.S3_ACCESS_KEY,
|
||||
S3_SECRET_KEY: (env) => env.S3_SECRET_KEY,
|
||||
S3_BUCKET: (env) => env.CONTACTS_S3_BUCKET || 'contacts-photos',
|
||||
CORS_ORIGINS: (env) => env.CORS_ORIGINS,
|
||||
},
|
||||
},
|
||||
|
||||
// Contacts Mobile (Expo)
|
||||
{
|
||||
path: 'apps/contacts/apps/mobile/.env',
|
||||
vars: {
|
||||
EXPO_PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.CONTACTS_BACKEND_PORT || '3015'}`,
|
||||
EXPO_PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
|
||||
},
|
||||
},
|
||||
|
||||
// Contacts Web (SvelteKit)
|
||||
{
|
||||
path: 'apps/contacts/apps/web/.env',
|
||||
vars: {
|
||||
PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.CONTACTS_BACKEND_PORT || '3015'}`,
|
||||
PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function main() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue