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:
Till-JS 2025-12-02 13:17:42 +01:00
parent 00176a25e0
commit 45d70150f4
76 changed files with 3812 additions and 1 deletions

View file

@ -192,6 +192,16 @@ CONTACTS_S3_PUBLIC_URL=http://localhost:9000/contacts-photos
CALENDAR_BACKEND_PORT=3014
CALENDAR_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/calendar
# ============================================
# STORAGE PROJECT (Cloud Drive)
# ============================================
STORAGE_BACKEND_PORT=3016
STORAGE_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/storage
STORAGE_S3_PUBLIC_URL=http://localhost:9000/storage-storage
STORAGE_MAX_FILE_SIZE=104857600
STORAGE_MAX_FILES_PER_UPLOAD=10
# ============================================
# MANA-GAMES PROJECT
# ============================================

View file

@ -400,6 +400,7 @@ pnpm docker:up
| `presi-storage` | Presi | Presentation slides |
| `calendar-storage` | Calendar | Calendar attachments |
| `contacts-storage` | Contacts | Contact avatars/files |
| `storage-storage` | Storage | Cloud drive files |
### Usage in Backend

View file

@ -3,14 +3,39 @@
# Alles starten (PostgreSQL, Redis, Auth, Chat)
pnpm docker:up:all
pnpm docker:down
pnpm dev:chat:app
pnpm dev:contacts:app
pnpm dev:picture:app
pnpm dev:manacore:app
pnpm dev:zitare:app
pnpm dev:presi:app
# Deployment Landingpages:
## Einzelne Landing Page
pnpm deploy:landing:chat
pnpm deploy:landing:picture
pnpm deploy:landing:manacore
pnpm deploy:landing:manadeck
pnpm deploy:landing:zitare
Hier sind alle Landing Page URLs:
| Projekt | URL |
|----------|------------------------------------|
| Chat | https://chat-landing-90m.pages.dev |
| Picture | https://picture-landing.pages.dev |
| ManaCore | https://manacore-landing.pages.dev |
| ManaDeck | https://manadeck-landing.pages.dev |
| Zitare | https://zitare-landing.pages.dev |
| Presi | https://presi-landing.pages.dev |
## Alle auf einmal
pnpm deploy:landing:all
Übersicht aller wichtigen Befehle zum Starten, Stoppen und Verwalten der Apps.
## Inhaltsverzeichnis

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

View file

@ -0,0 +1,12 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
dbCredentials: {
url: process.env.CONTACTS_DATABASE_URL || process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/contacts',
},
verbose: true,
strict: true,
});

View file

@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": false,
"assets": [],
"watchAssets": false
}
}

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

View file

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

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

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

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

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

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

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

View file

@ -0,0 +1,38 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
// Use require for postgres to avoid ESM/CommonJS interop issues
// eslint-disable-next-line @typescript-eslint/no-var-requires
const postgres = require('postgres');
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,28 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection, type Database } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}

View file

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

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

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

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

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

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

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

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

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

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

View file

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

View file

@ -0,0 +1,40 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS for mobile and web apps
const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((origin) => origin.trim()) || [
'http://localhost:3000',
'http://localhost:5173',
'http://localhost: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();

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

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

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

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

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

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

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

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

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

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

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

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

View file

@ -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>

View file

@ -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>

View file

@ -0,0 +1,49 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
// List of supported locales
export const supportedLocales = ['de', 'en', '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 };

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

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

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

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

View file

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
export const isSidebarMode = writable(false);
export const isNavCollapsed = writable(false);

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

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

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

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

View file

@ -0,0 +1,5 @@
<script lang="ts">
let { children } = $props();
</script>
{@render children()}

View file

@ -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>

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

View file

@ -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>

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

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

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

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

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

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

View file

@ -83,6 +83,7 @@ services:
mc mb --ignore-existing myminio/presi-storage;
mc mb --ignore-existing myminio/calendar-storage;
mc mb --ignore-existing myminio/contacts-storage;
mc mb --ignore-existing myminio/storage-storage;
mc anonymous set download myminio/picture-storage;
echo 'Buckets created successfully';
exit 0;

View file

@ -7,7 +7,11 @@ CREATE DATABASE chat;
-- Create voxel_lava database
CREATE DATABASE voxel_lava;
-- Create storage database (cloud drive)
CREATE DATABASE storage;
-- Grant all privileges to the default user
GRANT ALL PRIVILEGES ON DATABASE chat TO manacore;
GRANT ALL PRIVILEGES ON DATABASE voxel_lava TO manacore;
GRANT ALL PRIVILEGES ON DATABASE manacore TO manacore;
GRANT ALL PRIVILEGES ON DATABASE storage TO manacore;

View file

@ -45,6 +45,9 @@ const moodlitSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill
// Nutriphi icon (nutrition/heart with gradient)
const nutriphiSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#nutriGrad)"/><path d="M512 760C512 760 280 600 280 420C280 340 344 280 424 280C472 280 512 308 512 308C512 308 552 280 600 280C680 280 744 340 744 420C744 600 512 760 512 760Z" fill="white"/><path d="M512 280V200" stroke="white" stroke-width="24" stroke-linecap="round"/><path d="M512 200C512 200 560 160 600 180" stroke="white" stroke-width="24" stroke-linecap="round"/><defs><linearGradient id="nutriGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#10b981"/><stop offset="1" stop-color="#059669"/></linearGradient></defs></svg>`;
// Contacts icon (address book/person with gradient)
const contactsSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#contactsGrad)"/><circle cx="512" cy="380" r="100" fill="white"/><path d="M320 620C320 540 408 480 512 480C616 480 704 540 704 620V680C704 702.091 685.091 720 663 720H361C338.909 720 320 702.091 320 680V620Z" fill="white"/><rect x="240" y="300" width="24" height="80" rx="12" fill="white" fill-opacity="0.6"/><rect x="240" y="420" width="24" height="80" rx="12" fill="white" fill-opacity="0.6"/><rect x="240" y="540" width="24" height="80" rx="12" fill="white" fill-opacity="0.6"/><defs><linearGradient id="contactsGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#3b82f6"/><stop offset="1" stop-color="#2563eb"/></linearGradient></defs></svg>`;
/**
* App icons as data URLs
* Use these directly in <img src={APP_ICONS.memoro}> or CSS background-image
@ -62,6 +65,7 @@ export const APP_ICONS = {
wisekeep: svgToDataUrl(wisekeepSvg),
moodlit: svgToDataUrl(moodlitSvg),
nutriphi: svgToDataUrl(nutriphiSvg),
contacts: svgToDataUrl(contactsSvg),
} as const;
export type AppIconId = keyof typeof APP_ICONS;

View file

@ -131,6 +131,19 @@ export const APP_BRANDING: Record<AppId, AppBranding> = {
logoStroke: true,
logoStrokeWidth: 1.5,
},
contacts: {
id: 'contacts',
name: 'Contacts',
tagline: 'Contact Management',
primaryColor: '#3b82f6',
secondaryColor: '#60a5fa',
// Users/contacts icon
logoPath:
'M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z',
logoViewBox: '0 0 24 24',
logoStroke: true,
logoStrokeWidth: 1.5,
},
};
/**

View file

@ -24,6 +24,7 @@ export {
PresiLogo,
NutriPhiLogo,
ZitareLogo,
ContactsLogo,
} from './logos';
// Configuration

View file

@ -0,0 +1,13 @@
<script lang="ts">
import AppLogo from '../AppLogo.svelte';
interface Props {
size?: number;
color?: string;
class?: string;
}
let { size = 55, color, class: className = '' }: Props = $props();
</script>
<AppLogo app="contacts" {size} {color} class={className} />

View file

@ -11,3 +11,4 @@ export { default as ChatLogo } from './ChatLogo.svelte';
export { default as PresiLogo } from './PresiLogo.svelte';
export { default as NutriPhiLogo } from './NutriPhiLogo.svelte';
export { default as ZitareLogo } from './ZitareLogo.svelte';
export { default as ContactsLogo } from './ContactsLogo.svelte';

View file

@ -180,6 +180,22 @@ export const MANA_APPS: ManaApp[] = [
status: 'development',
archived: true,
},
{
id: 'contacts',
name: 'ManaContacts',
description: {
de: 'Kontaktverwaltung',
en: 'Contact Management',
},
longDescription: {
de: 'Verwalte deine Kontakte übersichtlich mit Gruppen, Tags und Notizen.',
en: 'Manage your contacts clearly with groups, tags, and notes.',
},
icon: APP_ICONS.contacts,
color: '#3b82f6',
comingSoon: false,
status: 'development',
},
];
/**
@ -260,6 +276,7 @@ export const APP_URLS: Record<AppIconId, { dev: string; prod: string }> = {
manacore: { dev: 'http://localhost:5173', prod: 'https://manacore.app' },
mana: { dev: 'http://localhost:5173', prod: 'https://manacore.app' },
moodlit: { dev: 'http://localhost:5183', prod: 'https://moodlit.manacore.app' },
contacts: { dev: 'http://localhost:5184', prod: 'https://contacts.manacore.app' },
};
/**

View file

@ -11,7 +11,8 @@ export type AppId =
| 'presi'
| 'nutriphi'
| 'zitare'
| 'picture';
| 'picture'
| 'contacts';
/**
* App branding configuration

View file

@ -29,6 +29,7 @@ The following buckets are automatically created:
| `presi-storage` | Presi | Presentation slides |
| `calendar-storage` | Calendar | Calendar attachments |
| `contacts-storage` | Contacts | Contact avatars/files |
| `storage-storage` | Storage | Cloud drive files |
## Usage
@ -89,6 +90,7 @@ import {
createPresiStorage,
createCalendarStorage,
createContactsStorage,
createStorageStorage,
} from '@manacore/shared-storage';
```

View file

@ -119,3 +119,13 @@ export function createCalendarStorage(): StorageClient {
export function createContactsStorage(): StorageClient {
return createStorageClient({ name: BUCKETS.CONTACTS });
}
/**
* Create a storage client for the Storage project (cloud drive)
*/
export function createStorageStorage(publicUrl?: string): StorageClient {
return createStorageClient({
name: BUCKETS.STORAGE,
publicUrl: publicUrl ?? process.env.STORAGE_S3_PUBLIC_URL,
});
}

View file

@ -12,6 +12,7 @@ export {
createPresiStorage,
createCalendarStorage,
createContactsStorage,
createStorageStorage,
} from './factory.js';
// Utilities

View file

@ -83,6 +83,7 @@ export const BUCKETS = {
PRESI: 'presi-storage',
CALENDAR: 'calendar-storage',
CONTACTS: 'contacts-storage',
STORAGE: 'storage-storage',
} as const;
export type BucketName = (typeof BUCKETS)[keyof typeof BUCKETS];

View file

@ -387,6 +387,75 @@ const APP_CONFIGS = [
PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.MANA_GAMES_BACKEND_PORT || '3011'}`,
},
},
// Calendar Backend (NestJS)
{
path: 'apps/calendar/apps/backend/.env',
vars: {
NODE_ENV: () => 'development',
PORT: (env) => env.CALENDAR_BACKEND_PORT || '3014',
DATABASE_URL: (env) => env.CALENDAR_DATABASE_URL,
MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
DEV_BYPASS_AUTH: () => 'true',
DEV_USER_ID: (env) => env.DEV_USER_ID || '00000000-0000-0000-0000-000000000000',
CORS_ORIGINS: (env) => env.CORS_ORIGINS,
},
},
// Calendar Mobile (Expo)
{
path: 'apps/calendar/apps/mobile/.env',
vars: {
EXPO_PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.CALENDAR_BACKEND_PORT || '3014'}`,
EXPO_PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
},
},
// Calendar Web (SvelteKit)
{
path: 'apps/calendar/apps/web/.env',
vars: {
PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.CALENDAR_BACKEND_PORT || '3014'}`,
PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
},
},
// Contacts Backend (NestJS)
{
path: 'apps/contacts/apps/backend/.env',
vars: {
NODE_ENV: () => 'development',
PORT: (env) => env.CONTACTS_BACKEND_PORT || '3015',
DATABASE_URL: (env) => env.CONTACTS_DATABASE_URL,
MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
DEV_BYPASS_AUTH: () => 'true',
DEV_USER_ID: (env) => env.DEV_USER_ID || '00000000-0000-0000-0000-000000000000',
S3_ENDPOINT: (env) => env.S3_ENDPOINT,
S3_REGION: (env) => env.S3_REGION,
S3_ACCESS_KEY: (env) => env.S3_ACCESS_KEY,
S3_SECRET_KEY: (env) => env.S3_SECRET_KEY,
S3_BUCKET: (env) => env.CONTACTS_S3_BUCKET || 'contacts-photos',
CORS_ORIGINS: (env) => env.CORS_ORIGINS,
},
},
// Contacts Mobile (Expo)
{
path: 'apps/contacts/apps/mobile/.env',
vars: {
EXPO_PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.CONTACTS_BACKEND_PORT || '3015'}`,
EXPO_PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
},
},
// Contacts Web (SvelteKit)
{
path: 'apps/contacts/apps/web/.env',
vars: {
PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.CONTACTS_BACKEND_PORT || '3015'}`,
PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
},
},
];
function main() {