mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +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
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"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue