mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 08:23:37 +02:00
feat: major update with network graphs, themes, todo extensions, and more
## New Features ### Network Graph Visualization (Contacts, Calendar, Todo) - D3.js force simulation for physics-based layout - Zoom & pan with mouse/touchpad - Keyboard shortcuts: +/- zoom, 0 reset, Esc deselect, / search, F focus - Filtering by tags, company/location/project, connection strength - Shared components in @manacore/shared-ui ### Central Tags API (mana-core-auth) - CRUD endpoints for tags - Schema: tags table with userId, name, color, app - Shared tag components in @manacore/shared-ui ### Custom Themes System - Theme editor with live preview and color picker - Community theme gallery - Theme sharing (public, unlisted, private) - Backend API in mana-core-auth ### Todo App Extensions - Glass-pill design for task input and items - Settings page with 20+ preferences - Task edit modal with inline editing - Statistics page with visualizations - PWA support with offline capabilities - Multiple kanban boards ### Contacts App Features - Duplicate detection - Photo upload - Batch operations - Enhanced favorites page with multiple view modes - Alphabet view improvements - Search modal ### Help System - @manacore/shared-help-content - @manacore/shared-help-ui - @manacore/shared-help-types ### Other Features - Themes page for all apps - Referral system frontend - CommandBar (global search) - Skeleton loaders - Settings page improvements ## Bug Fixes - Network graph simulation initialization - Database schema TEXT for user_id columns (Better Auth compatibility) - Various styling fixes ## Documentation - Daily report for 2025-12-10 - CI/CD deployment guide 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e84371aa94
commit
ee42b6cc76
381 changed files with 39284 additions and 6275 deletions
|
|
@ -5,8 +5,10 @@ import { DatabaseModule } from './db/database.module';
|
|||
import { HealthModule } from './health/health.module';
|
||||
import { CalendarModule } from './calendar/calendar.module';
|
||||
import { EventModule } from './event/event.module';
|
||||
import { EventTagModule } from './event-tag/event-tag.module';
|
||||
import { ReminderModule } from './reminder/reminder.module';
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { NetworkModule } from './network/network.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -19,8 +21,10 @@ import { ShareModule } from './share/share.module';
|
|||
HealthModule,
|
||||
CalendarModule,
|
||||
EventModule,
|
||||
EventTagModule,
|
||||
ReminderModule,
|
||||
ShareModule,
|
||||
NetworkModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import { pgTable, uuid, text, timestamp, varchar, primaryKey, index } from 'drizzle-orm/pg-core';
|
||||
import { events } from './events.schema';
|
||||
|
||||
/**
|
||||
* Event tags table - stores user-defined tags with colors
|
||||
*/
|
||||
export const eventTags = pgTable(
|
||||
'event_tags',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
name: varchar('name', { length: 100 }).notNull(),
|
||||
color: varchar('color', { length: 7 }).default('#3B82F6'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdx: index('event_tags_user_idx').on(table.userId),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Event to tags junction table - many-to-many relationship
|
||||
*/
|
||||
export const eventToTags = pgTable(
|
||||
'event_to_tags',
|
||||
{
|
||||
eventId: uuid('event_id')
|
||||
.notNull()
|
||||
.references(() => events.id, { onDelete: 'cascade' }),
|
||||
tagId: uuid('tag_id')
|
||||
.notNull()
|
||||
.references(() => eventTags.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({ columns: [table.eventId, table.tagId] }),
|
||||
eventIdx: index('event_to_tags_event_idx').on(table.eventId),
|
||||
tagIdx: index('event_to_tags_tag_idx').on(table.tagId),
|
||||
})
|
||||
);
|
||||
|
||||
export type EventTag = typeof eventTags.$inferSelect;
|
||||
export type NewEventTag = typeof eventTags.$inferInsert;
|
||||
export type EventToTag = typeof eventToTags.$inferSelect;
|
||||
export type NewEventToTag = typeof eventToTags.$inferInsert;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
// Calendar Database Schemas
|
||||
export * from './calendars.schema';
|
||||
export * from './events.schema';
|
||||
export * from './event-tags.schema';
|
||||
export * from './calendar-shares.schema';
|
||||
export * from './reminders.schema';
|
||||
export * from './external-calendars.schema';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateEventTagDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(7)
|
||||
color?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { IsString, IsOptional, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdateEventTagDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(7)
|
||||
color?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { EventTagService } from './event-tag.service';
|
||||
import { CreateEventTagDto } from './dto/create-event-tag.dto';
|
||||
import { UpdateEventTagDto } from './dto/update-event-tag.dto';
|
||||
|
||||
@Controller('event-tags')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class EventTagController {
|
||||
constructor(private readonly eventTagService: EventTagService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
const tags = await this.eventTagService.findByUserId(user.userId);
|
||||
return { tags };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const tag = await this.eventTagService.findById(id, user.userId);
|
||||
if (!tag) {
|
||||
throw new NotFoundException('Tag not found');
|
||||
}
|
||||
return { tag };
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateEventTagDto) {
|
||||
const tag = await this.eventTagService.create({
|
||||
...dto,
|
||||
userId: user.userId,
|
||||
});
|
||||
return { tag };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateEventTagDto
|
||||
) {
|
||||
const tag = await this.eventTagService.update(id, user.userId, dto);
|
||||
return { tag };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.eventTagService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
apps/calendar/apps/backend/src/event-tag/event-tag.module.ts
Normal file
10
apps/calendar/apps/backend/src/event-tag/event-tag.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { EventTagController } from './event-tag.controller';
|
||||
import { EventTagService } from './event-tag.service';
|
||||
|
||||
@Module({
|
||||
controllers: [EventTagController],
|
||||
providers: [EventTagService],
|
||||
exports: [EventTagService],
|
||||
})
|
||||
export class EventTagModule {}
|
||||
119
apps/calendar/apps/backend/src/event-tag/event-tag.service.ts
Normal file
119
apps/calendar/apps/backend/src/event-tag/event-tag.service.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { eventTags, eventToTags } from '../db/schema';
|
||||
import type { EventTag, NewEventTag } from '../db/schema';
|
||||
|
||||
const DEFAULT_TAGS = [
|
||||
{ name: 'Arbeit', color: '#3b82f6' }, // blue
|
||||
{ name: 'Persönlich', color: '#22c55e' }, // green
|
||||
{ name: 'Familie', color: '#ec4899' }, // pink
|
||||
{ name: 'Wichtig', color: '#ef4444' }, // red
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class EventTagService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findByUserId(userId: string): Promise<EventTag[]> {
|
||||
const tags = await this.db.select().from(eventTags).where(eq(eventTags.userId, userId));
|
||||
|
||||
// Create default tags on first access (when user has no tags yet)
|
||||
if (tags.length === 0) {
|
||||
return this.createDefaultTags(userId);
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
private async createDefaultTags(userId: string): Promise<EventTag[]> {
|
||||
const tagsToCreate = DEFAULT_TAGS.map((tag) => ({
|
||||
userId,
|
||||
name: tag.name,
|
||||
color: tag.color,
|
||||
}));
|
||||
|
||||
return this.db.insert(eventTags).values(tagsToCreate).returning();
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<EventTag | null> {
|
||||
const [tag] = await this.db
|
||||
.select()
|
||||
.from(eventTags)
|
||||
.where(and(eq(eventTags.id, id), eq(eventTags.userId, userId)));
|
||||
return tag || null;
|
||||
}
|
||||
|
||||
async create(data: NewEventTag): Promise<EventTag> {
|
||||
const [tag] = await this.db.insert(eventTags).values(data).returning();
|
||||
return tag;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, data: Partial<NewEventTag>): Promise<EventTag> {
|
||||
const [tag] = await this.db
|
||||
.update(eventTags)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(eq(eventTags.id, id), eq(eventTags.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(eventTags).where(and(eq(eventTags.id, id), eq(eventTags.userId, userId)));
|
||||
}
|
||||
|
||||
async getTagsForEvent(eventId: string): Promise<EventTag[]> {
|
||||
const results = await this.db
|
||||
.select({ tag: eventTags })
|
||||
.from(eventToTags)
|
||||
.innerJoin(eventTags, eq(eventToTags.tagId, eventTags.id))
|
||||
.where(eq(eventToTags.eventId, eventId));
|
||||
|
||||
return results.map((r) => r.tag);
|
||||
}
|
||||
|
||||
async getTagIdsForEvent(eventId: string): Promise<string[]> {
|
||||
const results = await this.db
|
||||
.select({ tagId: eventToTags.tagId })
|
||||
.from(eventToTags)
|
||||
.where(eq(eventToTags.eventId, eventId));
|
||||
|
||||
return results.map((r) => r.tagId);
|
||||
}
|
||||
|
||||
async setEventTags(eventId: string, tagIds: string[]): Promise<void> {
|
||||
// Remove existing tags
|
||||
await this.db.delete(eventToTags).where(eq(eventToTags.eventId, eventId));
|
||||
|
||||
// Add new tags
|
||||
if (tagIds.length > 0) {
|
||||
const values = tagIds.map((tagId) => ({ eventId, tagId }));
|
||||
await this.db.insert(eventToTags).values(values).onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
async addTagToEvent(eventId: string, tagId: string): Promise<void> {
|
||||
await this.db.insert(eventToTags).values({ eventId, tagId }).onConflictDoNothing();
|
||||
}
|
||||
|
||||
async removeTagFromEvent(eventId: string, tagId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(eventToTags)
|
||||
.where(and(eq(eventToTags.eventId, eventId), eq(eventToTags.tagId, tagId)));
|
||||
}
|
||||
|
||||
async getTagsByIds(ids: string[], userId: string): Promise<EventTag[]> {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(eventTags)
|
||||
.where(and(inArray(eventTags.id, ids), eq(eventTags.userId, userId)));
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
IsDateString,
|
||||
IsUUID,
|
||||
IsIn,
|
||||
IsArray,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import type { EventMetadata } from '../../db/schema/events.schema';
|
||||
|
|
@ -63,4 +64,9 @@ export class CreateEventDto {
|
|||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: EventMetadata;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
tagIds?: string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,4 +73,9 @@ export class UpdateEventDto {
|
|||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: EventMetadata;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
tagIds?: string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
|||
import { EventController } from './event.controller';
|
||||
import { EventService } from './event.service';
|
||||
import { CalendarModule } from '../calendar/calendar.module';
|
||||
import { EventTagModule } from '../event-tag/event-tag.module';
|
||||
|
||||
@Module({
|
||||
imports: [CalendarModule],
|
||||
imports: [CalendarModule, EventTagModule],
|
||||
controllers: [EventController],
|
||||
providers: [EventService],
|
||||
exports: [EventService],
|
||||
|
|
|
|||
|
|
@ -5,13 +5,15 @@ import { Database } from '../db/connection';
|
|||
import { events, Event, NewEvent } from '../db/schema/events.schema';
|
||||
import { calendars } from '../db/schema/calendars.schema';
|
||||
import { CalendarService } from '../calendar/calendar.service';
|
||||
import { EventTagService } from '../event-tag/event-tag.service';
|
||||
import { CreateEventDto, UpdateEventDto, QueryEventsDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class EventService {
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private db: Database,
|
||||
private calendarService: CalendarService
|
||||
private calendarService: CalendarService,
|
||||
private eventTagService: EventTagService
|
||||
) {}
|
||||
|
||||
async queryEvents(userId: string, query: QueryEventsDto): Promise<Event[]> {
|
||||
|
|
@ -104,6 +106,12 @@ export class EventService {
|
|||
};
|
||||
|
||||
const [created] = await this.db.insert(events).values(newEvent).returning();
|
||||
|
||||
// Set tags if provided
|
||||
if (dto.tagIds && dto.tagIds.length > 0) {
|
||||
await this.eventTagService.setEventTags(created.id, dto.tagIds);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
|
|
@ -115,8 +123,11 @@ export class EventService {
|
|||
await this.calendarService.findByIdOrThrow(dto.calendarId, userId);
|
||||
}
|
||||
|
||||
// Handle tags separately
|
||||
const { tagIds, ...eventData } = dto;
|
||||
|
||||
const updateData: Partial<NewEvent> = {
|
||||
...dto,
|
||||
...eventData,
|
||||
startTime: dto.startTime ? new Date(dto.startTime) : undefined,
|
||||
endTime: dto.endTime ? new Date(dto.endTime) : undefined,
|
||||
recurrenceEndDate: dto.recurrenceEndDate ? new Date(dto.recurrenceEndDate) : undefined,
|
||||
|
|
@ -136,6 +147,11 @@ export class EventService {
|
|||
.where(and(eq(events.id, id), eq(events.userId, userId)))
|
||||
.returning();
|
||||
|
||||
// Update tags if provided
|
||||
if (tagIds !== undefined) {
|
||||
await this.eventTagService.setEventTags(id, tagIds);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
|
|
@ -173,9 +189,24 @@ export class EventService {
|
|||
.where(and(...conditions))
|
||||
.orderBy(events.startTime);
|
||||
|
||||
return result.map((r) => ({
|
||||
...r.event,
|
||||
calendar: r.calendar,
|
||||
}));
|
||||
// Load tags for all events
|
||||
const eventsWithCalendar = await Promise.all(
|
||||
result.map(async (r) => {
|
||||
const tags = await this.eventTagService.getTagsForEvent(r.event.id);
|
||||
return {
|
||||
...r.event,
|
||||
calendar: r.calendar,
|
||||
tags,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return eventsWithCalendar;
|
||||
}
|
||||
|
||||
async getEventWithTags(id: string, userId: string) {
|
||||
const event = await this.findByIdOrThrow(id, userId);
|
||||
const tags = await this.eventTagService.getTagsForEvent(id);
|
||||
return { ...event, tags };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
apps/calendar/apps/backend/src/network/network.controller.ts
Normal file
18
apps/calendar/apps/backend/src/network/network.controller.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Controller, Get, UseGuards, Headers } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { NetworkService } from './network.service';
|
||||
|
||||
@Controller('api/v1/network')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class NetworkController {
|
||||
constructor(private readonly networkService: NetworkService) {}
|
||||
|
||||
@Get('graph')
|
||||
async getGraph(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Headers('authorization') authorization?: string
|
||||
) {
|
||||
const accessToken = authorization?.replace('Bearer ', '');
|
||||
return this.networkService.getGraph(user.userId, accessToken);
|
||||
}
|
||||
}
|
||||
10
apps/calendar/apps/backend/src/network/network.module.ts
Normal file
10
apps/calendar/apps/backend/src/network/network.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { NetworkController } from './network.controller';
|
||||
import { NetworkService } from './network.service';
|
||||
|
||||
@Module({
|
||||
controllers: [NetworkController],
|
||||
providers: [NetworkService],
|
||||
exports: [NetworkService],
|
||||
})
|
||||
export class NetworkModule {}
|
||||
178
apps/calendar/apps/backend/src/network/network.service.ts
Normal file
178
apps/calendar/apps/backend/src/network/network.service.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { events, eventToTags } from '../db/schema';
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
export interface NetworkNode {
|
||||
id: string;
|
||||
name: string;
|
||||
photoUrl: string | null;
|
||||
company: string | null;
|
||||
isFavorite: boolean;
|
||||
tags: Tag[];
|
||||
connectionCount: number;
|
||||
}
|
||||
|
||||
export interface NetworkLink {
|
||||
source: string;
|
||||
target: string;
|
||||
type: 'tag';
|
||||
strength: number;
|
||||
sharedTags: string[];
|
||||
}
|
||||
|
||||
export interface NetworkGraphResponse {
|
||||
nodes: NetworkNode[];
|
||||
links: NetworkLink[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NetworkService {
|
||||
private authUrl: string;
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private db: Database,
|
||||
private configService: ConfigService
|
||||
) {
|
||||
this.authUrl = this.configService.get<string>('MANA_CORE_AUTH_URL') || 'http://localhost:3001';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch tags from central Tags API
|
||||
*/
|
||||
private async fetchTagsByIds(tagIds: string[], accessToken: string): Promise<Map<string, Tag>> {
|
||||
if (tagIds.length === 0) return new Map();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.authUrl}/api/v1/tags/by-ids?ids=${tagIds.join(',')}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch tags from central API:', response.status);
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const tags: Tag[] = await response.json();
|
||||
return new Map(tags.map((t) => [t.id, t]));
|
||||
} catch (error) {
|
||||
console.error('Error fetching tags from central API:', error);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a network graph of events connected by shared tags
|
||||
*/
|
||||
async getGraph(userId: string, accessToken?: string): Promise<NetworkGraphResponse> {
|
||||
// 1. Get all events for user
|
||||
const eventsData = await this.db
|
||||
.select({
|
||||
event: events,
|
||||
})
|
||||
.from(events)
|
||||
.where(eq(events.userId, userId));
|
||||
|
||||
// 2. Get tag IDs for each event from junction table
|
||||
const eventTagIdsMap = new Map<string, string[]>();
|
||||
const allTagIds = new Set<string>();
|
||||
|
||||
for (const { event } of eventsData) {
|
||||
const tagRelations = await this.db
|
||||
.select({
|
||||
tagId: eventToTags.tagId,
|
||||
})
|
||||
.from(eventToTags)
|
||||
.where(eq(eventToTags.eventId, event.id));
|
||||
|
||||
const tagIds = tagRelations.map((r) => r.tagId);
|
||||
eventTagIdsMap.set(event.id, tagIds);
|
||||
tagIds.forEach((id) => allTagIds.add(id));
|
||||
}
|
||||
|
||||
// 3. Fetch tag details from central Tags API
|
||||
let tagsMap = new Map<string, Tag>();
|
||||
if (accessToken && allTagIds.size > 0) {
|
||||
tagsMap = await this.fetchTagsByIds(Array.from(allTagIds), accessToken);
|
||||
}
|
||||
|
||||
// 4. Build tags for each event
|
||||
const eventTagsMap = new Map<string, Tag[]>();
|
||||
for (const { event } of eventsData) {
|
||||
const tagIds = eventTagIdsMap.get(event.id) || [];
|
||||
const tags = tagIds.map((id) => tagsMap.get(id)).filter((t): t is Tag => t !== undefined);
|
||||
eventTagsMap.set(event.id, tags);
|
||||
}
|
||||
|
||||
// 5. Filter events that have at least one tag
|
||||
const eventsWithTagsList = eventsData.filter((e) => {
|
||||
const tags = eventTagsMap.get(e.event.id) || [];
|
||||
return tags.length > 0;
|
||||
});
|
||||
|
||||
// 6. Build nodes
|
||||
const nodes: NetworkNode[] = eventsWithTagsList.map(({ event }) => {
|
||||
const tags = eventTagsMap.get(event.id) || [];
|
||||
return {
|
||||
id: event.id,
|
||||
name: event.title,
|
||||
photoUrl: null, // Events don't have photos
|
||||
company: event.location || null, // Use location as subtitle
|
||||
isFavorite: false,
|
||||
tags,
|
||||
connectionCount: 0, // Will be calculated below
|
||||
};
|
||||
});
|
||||
|
||||
// 7. Build links based on shared tags
|
||||
const links: NetworkLink[] = [];
|
||||
const connectionCounts = new Map<string, number>();
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const node1 = nodes[i];
|
||||
const node2 = nodes[j];
|
||||
|
||||
// Find shared tags
|
||||
const sharedTags = node1.tags
|
||||
.filter((t1) => node2.tags.some((t2) => t2.id === t1.id))
|
||||
.map((t) => t.name);
|
||||
|
||||
if (sharedTags.length > 0) {
|
||||
// Calculate strength based on number of shared tags
|
||||
const maxTags = Math.max(node1.tags.length, node2.tags.length);
|
||||
const strength = Math.round((sharedTags.length / maxTags) * 100);
|
||||
|
||||
links.push({
|
||||
source: node1.id,
|
||||
target: node2.id,
|
||||
type: 'tag',
|
||||
strength,
|
||||
sharedTags,
|
||||
});
|
||||
|
||||
// Update connection counts
|
||||
connectionCounts.set(node1.id, (connectionCounts.get(node1.id) || 0) + 1);
|
||||
connectionCounts.set(node2.id, (connectionCounts.get(node2.id) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Update connection counts in nodes
|
||||
for (const node of nodes) {
|
||||
node.connectionCount = connectionCounts.get(node.id) || 0;
|
||||
}
|
||||
|
||||
return { nodes, links };
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/d3-force": "^3.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
|
|
@ -30,6 +31,7 @@
|
|||
"dependencies": {
|
||||
"@calendar/shared": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-tags": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-feedback-service": "workspace:*",
|
||||
|
|
@ -43,6 +45,7 @@
|
|||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@neodrag/svelte": "^2.3.3",
|
||||
"d3-force": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"svelte-dnd-action": "^0.9.68",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
@source "../../../packages/shared/src";
|
||||
@source "../../../../../packages/shared-ui/src";
|
||||
@source "../../../../../packages/shared-theme-ui/src";
|
||||
@source "../../../../../packages/shared-theme-ui/src/components";
|
||||
@source "../../../../../packages/shared-theme-ui/src/pages";
|
||||
|
||||
/* Calendar-specific CSS Variables */
|
||||
@layer base {
|
||||
|
|
|
|||
|
|
@ -5,21 +5,21 @@
|
|||
*/
|
||||
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
// Get client-side URLs from environment (Docker runtime)
|
||||
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
|
||||
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
|
||||
const PUBLIC_BACKEND_URL_CLIENT =
|
||||
process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Get client-side URLs from environment at RUNTIME (not build time)
|
||||
// Use $env/dynamic/private to read actual runtime environment variables
|
||||
const authUrlClient = env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || env.PUBLIC_MANA_CORE_AUTH_URL || '';
|
||||
const backendUrlClient = env.PUBLIC_BACKEND_URL_CLIENT || env.PUBLIC_BACKEND_URL || '';
|
||||
|
||||
return resolve(event, {
|
||||
transformPageChunk: ({ html }) => {
|
||||
// Inject runtime environment variables into the HTML
|
||||
// These will be available on window.__PUBLIC_*__ for client-side code
|
||||
const envScript = `<script>
|
||||
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${authUrlClient}";
|
||||
window.__PUBLIC_BACKEND_URL__ = "${backendUrlClient}";
|
||||
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
|
||||
window.__PUBLIC_BACKEND_URL__ = "${PUBLIC_BACKEND_URL_CLIENT}";
|
||||
</script>`;
|
||||
return html.replace('<head>', `<head>${envScript}`);
|
||||
},
|
||||
|
|
|
|||
132
apps/calendar/apps/web/src/lib/api/event-tags.ts
Normal file
132
apps/calendar/apps/web/src/lib/api/event-tags.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* Event Tags API Client - Uses central Tags API from mana-core-auth
|
||||
*
|
||||
* This module wraps the central Tags API to provide backward-compatible
|
||||
* "event tags" interface for the Calendar app. Tags are now unified
|
||||
* across all Manacore apps (Todo, Calendar, Contacts).
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import {
|
||||
createTagsClient,
|
||||
type Tag,
|
||||
type CreateTagInput,
|
||||
type UpdateTagInput,
|
||||
} from '@manacore/shared-tags';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
// Re-export Tag as EventTag for backward compatibility
|
||||
export type EventTag = Tag;
|
||||
export type CreateEventTagInput = CreateTagInput;
|
||||
export type UpdateEventTagInput = UpdateTagInput;
|
||||
|
||||
// Get auth URL dynamically at runtime
|
||||
function getAuthUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
|
||||
.__PUBLIC_MANA_CORE_AUTH_URL__;
|
||||
return injectedUrl || 'http://localhost:3001';
|
||||
}
|
||||
return 'http://localhost:3001';
|
||||
}
|
||||
|
||||
// Lazy-initialized client
|
||||
let _tagsClient: ReturnType<typeof createTagsClient> | null = null;
|
||||
|
||||
function getTagsClient() {
|
||||
if (!browser) return null;
|
||||
if (!_tagsClient) {
|
||||
_tagsClient = createTagsClient({
|
||||
authUrl: getAuthUrl(),
|
||||
getToken: async () => {
|
||||
const token = await authStore.getAccessToken();
|
||||
return token || '';
|
||||
},
|
||||
});
|
||||
}
|
||||
return _tagsClient;
|
||||
}
|
||||
|
||||
export async function getEventTags() {
|
||||
const client = getTagsClient();
|
||||
if (!client) return { data: null, error: null };
|
||||
try {
|
||||
const tags = await client.getAll();
|
||||
return { data: tags, error: null };
|
||||
} catch (e) {
|
||||
return {
|
||||
data: null,
|
||||
error: { message: e instanceof Error ? e.message : 'Failed to fetch tags' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEventTag(id: string) {
|
||||
const client = getTagsClient();
|
||||
if (!client) return { data: null, error: null };
|
||||
try {
|
||||
const tag = await client.getById(id);
|
||||
return { data: tag, error: null };
|
||||
} catch (e) {
|
||||
return {
|
||||
data: null,
|
||||
error: { message: e instanceof Error ? e.message : 'Failed to fetch tag' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function createEventTag(data: CreateEventTagInput) {
|
||||
const client = getTagsClient();
|
||||
if (!client) return { data: null, error: { message: 'Tags client not available' } };
|
||||
try {
|
||||
const tag = await client.create(data);
|
||||
return { data: tag, error: null };
|
||||
} catch (e) {
|
||||
return {
|
||||
data: null,
|
||||
error: { message: e instanceof Error ? e.message : 'Failed to create tag' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateEventTag(id: string, data: UpdateEventTagInput) {
|
||||
const client = getTagsClient();
|
||||
if (!client) return { data: null, error: { message: 'Tags client not available' } };
|
||||
try {
|
||||
const tag = await client.update(id, data);
|
||||
return { data: tag, error: null };
|
||||
} catch (e) {
|
||||
return {
|
||||
data: null,
|
||||
error: { message: e instanceof Error ? e.message : 'Failed to update tag' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteEventTag(id: string) {
|
||||
const client = getTagsClient();
|
||||
if (!client) return { data: null, error: { message: 'Tags client not available' } };
|
||||
try {
|
||||
await client.delete(id);
|
||||
return { data: { success: true }, error: null };
|
||||
} catch (e) {
|
||||
return {
|
||||
data: null,
|
||||
error: { message: e instanceof Error ? e.message : 'Failed to delete tag' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function createDefaultEventTags() {
|
||||
const client = getTagsClient();
|
||||
if (!client) return { data: null, error: null };
|
||||
try {
|
||||
const tags = await client.createDefaults();
|
||||
return { data: tags, error: null };
|
||||
} catch (e) {
|
||||
return {
|
||||
data: null,
|
||||
error: { message: e instanceof Error ? e.message : 'Failed to create default tags' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ export interface QueryEventsParams {
|
|||
startDate: string;
|
||||
endDate: string;
|
||||
calendarIds?: string[];
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export async function getEvents(params: QueryEventsParams) {
|
||||
|
|
@ -19,9 +20,25 @@ export async function getEvents(params: QueryEventsParams) {
|
|||
if (params.calendarIds?.length) {
|
||||
searchParams.set('calendarIds', params.calendarIds.join(','));
|
||||
}
|
||||
if (params.search) {
|
||||
searchParams.set('search', params.search);
|
||||
}
|
||||
return fetchApi<CalendarEvent[]>(`/events?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export async function searchEvents(query: string, limit: number = 10) {
|
||||
// Search events within the next year
|
||||
const now = new Date();
|
||||
const oneYearFromNow = new Date();
|
||||
oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1);
|
||||
|
||||
return getEvents({
|
||||
startDate: now.toISOString(),
|
||||
endDate: oneYearFromNow.toISOString(),
|
||||
search: query,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEvent(id: string) {
|
||||
const result = await fetchApi<{ event: CalendarEvent }>(`/events/${id}`);
|
||||
if (result.error || !result.data) {
|
||||
|
|
|
|||
47
apps/calendar/apps/web/src/lib/api/network.ts
Normal file
47
apps/calendar/apps/web/src/lib/api/network.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* Network Graph API Client
|
||||
*/
|
||||
|
||||
import { fetchApi } from './client';
|
||||
|
||||
export interface NetworkTag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
export interface NetworkNode {
|
||||
id: string;
|
||||
name: string;
|
||||
photoUrl: string | null;
|
||||
company: string | null;
|
||||
isFavorite: boolean;
|
||||
tags: NetworkTag[];
|
||||
connectionCount: number;
|
||||
}
|
||||
|
||||
export interface NetworkLink {
|
||||
source: string;
|
||||
target: string;
|
||||
type: 'tag';
|
||||
strength: number;
|
||||
sharedTags: string[];
|
||||
}
|
||||
|
||||
export interface NetworkGraphResponse {
|
||||
nodes: NetworkNode[];
|
||||
links: NetworkLink[];
|
||||
}
|
||||
|
||||
export const networkApi = {
|
||||
/**
|
||||
* Get the network graph of events connected by shared tags
|
||||
*/
|
||||
async getGraph(): Promise<NetworkGraphResponse> {
|
||||
const result = await fetchApi<NetworkGraphResponse>('/network/graph');
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
return result.data || { nodes: [], links: [] };
|
||||
},
|
||||
};
|
||||
|
|
@ -4,10 +4,12 @@
|
|||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import EventForm from './EventForm.svelte';
|
||||
import { TagBadge } from '@manacore/shared-ui';
|
||||
import type { CalendarEvent, UpdateEventInput } from '@calendar/shared';
|
||||
import * as api from '$lib/api/events';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { EventDetailSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
interface Props {
|
||||
eventId: string;
|
||||
|
|
@ -147,10 +149,7 @@
|
|||
<div class="modal-backdrop" onclick={handleBackdropClick}>
|
||||
<div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
{#if loading}
|
||||
<div class="modal-loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
<EventDetailSkeleton />
|
||||
{:else if event}
|
||||
<div class="modal-header">
|
||||
<h2 id="modal-title" class="modal-title">
|
||||
|
|
@ -384,6 +383,30 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tags -->
|
||||
{#if event.tags && event.tags.length > 0}
|
||||
<div class="detail-row">
|
||||
<span class="detail-icon">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="detail-content">
|
||||
<span class="detail-label">Tags</span>
|
||||
<div class="tags-display">
|
||||
{#each event.tags as tag (tag.id)}
|
||||
<TagBadge tag={{ name: tag.name, color: tag.color }} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Teilnehmer -->
|
||||
{#if event.metadata?.attendees && event.metadata.attendees.length > 0}
|
||||
<div class="detail-row">
|
||||
|
|
@ -476,31 +499,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.modal-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
gap: 1rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid hsl(var(--color-border));
|
||||
border-top-color: hsl(var(--color-primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -691,4 +689,12 @@
|
|||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
/* Tags display */
|
||||
.tags-display {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
|
||||
import { TagSelector, type Tag } from '@manacore/shared-ui';
|
||||
import type {
|
||||
CalendarEvent,
|
||||
CreateEventInput,
|
||||
UpdateEventInput,
|
||||
LocationDetails,
|
||||
EventTag,
|
||||
} from '@calendar/shared';
|
||||
import { format, addMinutes, parseISO } from 'date-fns';
|
||||
|
||||
|
|
@ -36,6 +40,32 @@
|
|||
let locationCity = $state(event?.metadata?.locationDetails?.city || '');
|
||||
let locationCountry = $state(event?.metadata?.locationDetails?.country || '');
|
||||
|
||||
// Tags state - store as Tag[] for compatibility with TagSelector
|
||||
let selectedTags = $state<Tag[]>(
|
||||
event?.tags?.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
color: t.color,
|
||||
})) || []
|
||||
);
|
||||
|
||||
// Convert EventTag to Tag type for shared-ui components
|
||||
function eventTagToTag(tag: EventTag): Tag {
|
||||
return {
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
color: tag.color,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle tag selection changes
|
||||
function handleTagsChange(newTags: Tag[]) {
|
||||
selectedTags = newTags;
|
||||
}
|
||||
|
||||
// Derived available tags for TagSelector
|
||||
let availableTags = $derived(eventTagsStore.tags.map(eventTagToTag));
|
||||
|
||||
// Auto-expand location details if any field is filled
|
||||
$effect(() => {
|
||||
if (event?.metadata?.locationDetails) {
|
||||
|
|
@ -90,6 +120,13 @@
|
|||
|
||||
let submitting = $state(false);
|
||||
|
||||
// Load tags on mount
|
||||
onMount(() => {
|
||||
if (eventTagsStore.tags.length === 0) {
|
||||
eventTagsStore.fetchTags();
|
||||
}
|
||||
});
|
||||
|
||||
function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
|
|
@ -142,6 +179,7 @@
|
|||
endTime: endDateTime.toISOString(),
|
||||
calendarId,
|
||||
metadata: finalMetadata,
|
||||
tagIds: selectedTags.length > 0 ? selectedTags.map((t) => t.id) : undefined,
|
||||
};
|
||||
|
||||
submitting = true;
|
||||
|
|
@ -337,6 +375,20 @@
|
|||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
{#if availableTags.length > 0 || eventTagsStore.loading}
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-foreground">Tags</label>
|
||||
<TagSelector
|
||||
tags={availableTags}
|
||||
{selectedTags}
|
||||
onTagsChange={handleTagsChange}
|
||||
placeholder="Tags auswählen..."
|
||||
addTagLabel="Tag hinzufügen"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* AgendaSkeleton - Skeleton for agenda page event list
|
||||
* Shows date groups with event items
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div class="space-y-6" role="status" aria-label="Termine werden geladen...">
|
||||
<!-- Date Groups -->
|
||||
{#each Array(4) as _, groupIndex}
|
||||
<div class="space-y-2" style="opacity: {Math.max(0.4, 1 - groupIndex * 0.15)}">
|
||||
<!-- Date Header -->
|
||||
<div class="px-2">
|
||||
<SkeletonBox width="140px" height="14px" borderRadius="4px" />
|
||||
</div>
|
||||
|
||||
<!-- Event Items -->
|
||||
{#each Array(groupIndex === 0 ? 3 : 2) as _, eventIndex}
|
||||
<div
|
||||
class="bg-card rounded-xl p-4 flex gap-4"
|
||||
style="opacity: {Math.max(0.5, 1 - eventIndex * 0.15)}"
|
||||
>
|
||||
<!-- Color Bar -->
|
||||
<SkeletonBox width="4px" height="48px" borderRadius="2px" />
|
||||
|
||||
<!-- Event Content -->
|
||||
<div class="flex-1 space-y-2">
|
||||
<!-- Time -->
|
||||
<SkeletonBox width="90px" height="12px" borderRadius="4px" />
|
||||
<!-- Title -->
|
||||
<SkeletonBox width="70%" height="16px" borderRadius="4px" />
|
||||
<!-- Location (occasionally) -->
|
||||
{#if eventIndex === 0}
|
||||
<SkeletonBox width="50%" height="14px" borderRadius="4px" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* AppLoadingSkeleton - Full-page loading skeleton for app initialization
|
||||
* Replaces spinner with calendar-themed skeleton layout
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex min-h-screen items-center justify-center bg-background"
|
||||
role="status"
|
||||
aria-label="Kalender wird geladen..."
|
||||
>
|
||||
<div class="w-full max-w-md px-6 space-y-8">
|
||||
<!-- Logo/Header Area -->
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<SkeletonBox width="64px" height="64px" borderRadius="16px" />
|
||||
<SkeletonBox width="180px" height="24px" borderRadius="8px" />
|
||||
</div>
|
||||
|
||||
<!-- Calendar Preview Skeleton -->
|
||||
<div class="bg-card rounded-xl p-4 space-y-4">
|
||||
<!-- Mini Calendar Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<SkeletonBox width="120px" height="20px" />
|
||||
<div class="flex gap-2">
|
||||
<SkeletonBox width="32px" height="32px" borderRadius="8px" />
|
||||
<SkeletonBox width="32px" height="32px" borderRadius="8px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weekday Headers -->
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
{#each Array(7) as _}
|
||||
<SkeletonBox width="100%" height="24px" borderRadius="4px" />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Calendar Days Grid -->
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
{#each Array(35) as _, i}
|
||||
<div style="opacity: {Math.max(0.3, 1 - (i % 7) * 0.08)}">
|
||||
<SkeletonBox width="100%" height="32px" borderRadius="8px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Indicator -->
|
||||
<div class="flex justify-center">
|
||||
<SkeletonBox width="140px" height="16px" borderRadius="4px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* CalendarViewSkeleton - Skeleton for the main calendar week view
|
||||
* Shows a week grid with time slots and placeholder events
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
|
||||
// 7 days of the week
|
||||
const days = Array(7);
|
||||
// Show hours from 8 to 18 (working hours)
|
||||
const hours = Array(10);
|
||||
</script>
|
||||
|
||||
<div class="calendar-skeleton" role="status" aria-label="Kalender wird geladen...">
|
||||
<!-- Header with day names -->
|
||||
<div class="calendar-header">
|
||||
<!-- Time column spacer -->
|
||||
<div class="time-spacer"></div>
|
||||
|
||||
<!-- Day headers -->
|
||||
{#each days as _, dayIndex}
|
||||
<div class="day-header" style="opacity: {Math.max(0.5, 1 - dayIndex * 0.05)}">
|
||||
<SkeletonBox width="32px" height="14px" borderRadius="4px" />
|
||||
<SkeletonBox width="28px" height="28px" borderRadius="50%" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Calendar grid -->
|
||||
<div class="calendar-grid">
|
||||
<!-- Time column -->
|
||||
<div class="time-column">
|
||||
{#each hours as _, hourIndex}
|
||||
<div class="time-slot" style="opacity: {Math.max(0.4, 1 - hourIndex * 0.05)}">
|
||||
<SkeletonBox width="36px" height="12px" borderRadius="4px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Day columns -->
|
||||
{#each days as _, dayIndex}
|
||||
<div class="day-column" style="opacity: {Math.max(0.6, 1 - dayIndex * 0.04)}">
|
||||
{#each hours as _, hourIndex}
|
||||
<div class="hour-cell"></div>
|
||||
{/each}
|
||||
|
||||
<!-- Placeholder events -->
|
||||
{#if dayIndex === 1}
|
||||
<div class="event-placeholder" style="top: 10%; height: 15%;">
|
||||
<SkeletonBox width="100%" height="100%" borderRadius="6px" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if dayIndex === 2}
|
||||
<div class="event-placeholder" style="top: 30%; height: 10%;">
|
||||
<SkeletonBox width="100%" height="100%" borderRadius="6px" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if dayIndex === 3}
|
||||
<div class="event-placeholder" style="top: 50%; height: 20%;">
|
||||
<SkeletonBox width="100%" height="100%" borderRadius="6px" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if dayIndex === 4}
|
||||
<div class="event-placeholder" style="top: 20%; height: 8%;">
|
||||
<SkeletonBox width="100%" height="100%" borderRadius="6px" />
|
||||
</div>
|
||||
<div class="event-placeholder" style="top: 60%; height: 12%;">
|
||||
<SkeletonBox width="100%" height="100%" borderRadius="6px" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if dayIndex === 5}
|
||||
<div class="event-placeholder" style="top: 40%; height: 15%;">
|
||||
<SkeletonBox width="100%" height="100%" borderRadius="6px" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.calendar-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 600px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.time-spacer {
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.day-header {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.time-column {
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.time-slot {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding-right: 0.5rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.day-column {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
border-left: 1px solid hsl(var(--color-border) / 0.3);
|
||||
}
|
||||
|
||||
.hour-cell {
|
||||
height: 60px;
|
||||
border-bottom: 1px solid hsl(var(--color-border) / 0.2);
|
||||
}
|
||||
|
||||
.event-placeholder {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* EventDetailSkeleton - Skeleton for event detail modal
|
||||
* Matches the layout of EventDetailModal
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div class="p-6 space-y-5" role="status" aria-label="Termin wird geladen...">
|
||||
<!-- Header with Title and Actions -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<SkeletonBox width="60%" height="24px" borderRadius="6px" />
|
||||
<div class="flex gap-2 flex-shrink-0">
|
||||
<SkeletonBox width="32px" height="32px" borderRadius="8px" />
|
||||
<SkeletonBox width="32px" height="32px" borderRadius="8px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Row -->
|
||||
<div class="flex items-start gap-3">
|
||||
<SkeletonBox width="20px" height="20px" borderRadius="50%" />
|
||||
<div class="flex flex-col gap-1.5 flex-1">
|
||||
<SkeletonBox width="60px" height="12px" borderRadius="4px" />
|
||||
<SkeletonBox width="120px" height="16px" borderRadius="4px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time Row -->
|
||||
<div class="flex items-start gap-3">
|
||||
<SkeletonBox width="20px" height="20px" borderRadius="4px" />
|
||||
<div class="flex flex-col gap-1.5 flex-1">
|
||||
<SkeletonBox width="40px" height="12px" borderRadius="4px" />
|
||||
<SkeletonBox width="200px" height="16px" borderRadius="4px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Row -->
|
||||
<div class="flex items-start gap-3">
|
||||
<SkeletonBox width="20px" height="20px" borderRadius="4px" />
|
||||
<div class="flex flex-col gap-1.5 flex-1">
|
||||
<SkeletonBox width="40px" height="12px" borderRadius="4px" />
|
||||
<SkeletonBox width="160px" height="16px" borderRadius="4px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description Row -->
|
||||
<div class="flex items-start gap-3">
|
||||
<SkeletonBox width="20px" height="20px" borderRadius="4px" />
|
||||
<div class="flex flex-col gap-1.5 flex-1">
|
||||
<SkeletonBox width="80px" height="12px" borderRadius="4px" />
|
||||
<div class="space-y-2">
|
||||
<SkeletonBox width="100%" height="14px" borderRadius="4px" />
|
||||
<SkeletonBox width="90%" height="14px" borderRadius="4px" />
|
||||
<SkeletonBox width="70%" height="14px" borderRadius="4px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attendees Row -->
|
||||
<div class="flex items-start gap-3">
|
||||
<SkeletonBox width="20px" height="20px" borderRadius="4px" />
|
||||
<div class="flex flex-col gap-2 flex-1">
|
||||
<SkeletonBox width="100px" height="12px" borderRadius="4px" />
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each Array(3) as _, i}
|
||||
<div class="flex items-center gap-2" style="opacity: {Math.max(0.5, 1 - i * 0.2)}">
|
||||
<SkeletonBox width="140px" height="14px" borderRadius="4px" />
|
||||
<SkeletonBox width="24px" height="18px" borderRadius="4px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* RedirectSkeleton - Simple centered skeleton for redirect pages
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center justify-center min-h-[50vh] gap-4"
|
||||
role="status"
|
||||
aria-label="Weiterleitung..."
|
||||
>
|
||||
<SkeletonBox width="48px" height="48px" borderRadius="50%" />
|
||||
<SkeletonBox width="100px" height="16px" borderRadius="4px" />
|
||||
</div>
|
||||
21
apps/calendar/apps/web/src/lib/components/skeletons/index.ts
Normal file
21
apps/calendar/apps/web/src/lib/components/skeletons/index.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Calendar App Skeleton Components
|
||||
*
|
||||
* App-specific skeleton loaders that match the exact layout of calendar components.
|
||||
* Built on top of @manacore/shared-ui skeleton primitives.
|
||||
*/
|
||||
|
||||
// App Loading Skeleton
|
||||
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';
|
||||
|
||||
// Agenda Skeleton
|
||||
export { default as AgendaSkeleton } from './AgendaSkeleton.svelte';
|
||||
|
||||
// Event Detail Skeleton
|
||||
export { default as EventDetailSkeleton } from './EventDetailSkeleton.svelte';
|
||||
|
||||
// Redirect Skeleton
|
||||
export { default as RedirectSkeleton } from './RedirectSkeleton.svelte';
|
||||
|
||||
// Calendar View Skeleton
|
||||
export { default as CalendarViewSkeleton } from './CalendarViewSkeleton.svelte';
|
||||
114
apps/calendar/apps/web/src/lib/stores/event-tags.svelte.ts
Normal file
114
apps/calendar/apps/web/src/lib/stores/event-tags.svelte.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Event Tags Store - Manages event tags using Svelte 5 runes
|
||||
*
|
||||
* Uses the central Tags API from mana-core-auth. Tags are now unified
|
||||
* across all Manacore apps (Todo, Calendar, Contacts).
|
||||
*/
|
||||
|
||||
import type { EventTag } from '$lib/api/event-tags';
|
||||
import * as api from '$lib/api/event-tags';
|
||||
|
||||
// State
|
||||
let tags = $state<EventTag[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Helper to safely get tags array (Svelte 5 runes safety)
|
||||
function getTagsArray(): EventTag[] {
|
||||
const arr = tags ?? [];
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
}
|
||||
|
||||
export const eventTagsStore = {
|
||||
// Getters
|
||||
get tags() {
|
||||
return tags;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all tags
|
||||
*/
|
||||
async fetchTags() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const result = await api.getEventTags();
|
||||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
tags = [];
|
||||
} else {
|
||||
tags = result.data || [];
|
||||
}
|
||||
|
||||
loading = false;
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new tag
|
||||
*/
|
||||
async createTag(data: api.CreateEventTagInput) {
|
||||
const result = await api.createEventTag(data);
|
||||
|
||||
if (result.data) {
|
||||
tags = [...tags, result.data];
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a tag
|
||||
*/
|
||||
async updateTag(id: string, data: api.UpdateEventTagInput) {
|
||||
const result = await api.updateEventTag(id, data);
|
||||
|
||||
if (result.data) {
|
||||
tags = getTagsArray().map((t) => (t.id === id ? result.data! : t));
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a tag
|
||||
*/
|
||||
async deleteTag(id: string) {
|
||||
const result = await api.deleteEventTag(id);
|
||||
|
||||
if (!result.error) {
|
||||
tags = getTagsArray().filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tag by ID
|
||||
*/
|
||||
getById(id: string) {
|
||||
return getTagsArray().find((t) => t.id === id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tags by IDs
|
||||
*/
|
||||
getByIds(ids: string[]) {
|
||||
return getTagsArray().filter((t) => ids.includes(t.id));
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear store
|
||||
*/
|
||||
clear() {
|
||||
tags = [];
|
||||
error = null;
|
||||
},
|
||||
};
|
||||
370
apps/calendar/apps/web/src/lib/stores/network.svelte.ts
Normal file
370
apps/calendar/apps/web/src/lib/stores/network.svelte.ts
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
/**
|
||||
* Network Store - Manages network graph state with D3-force simulation
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { networkApi } from '$lib/api/network';
|
||||
import type { NetworkNode, NetworkLink } from '$lib/api/network';
|
||||
import {
|
||||
forceSimulation,
|
||||
forceLink,
|
||||
forceManyBody,
|
||||
forceCenter,
|
||||
forceCollide,
|
||||
type Simulation,
|
||||
} from 'd3-force';
|
||||
import type {
|
||||
SimulationNode as SharedSimulationNode,
|
||||
SimulationLink as SharedSimulationLink,
|
||||
} from '@manacore/shared-ui';
|
||||
|
||||
// Re-export types from shared-ui for convenience
|
||||
export type SimulationNode = SharedSimulationNode;
|
||||
export type SimulationLink = SharedSimulationLink;
|
||||
|
||||
// State
|
||||
let nodes = $state<SimulationNode[]>([]);
|
||||
let links = $state<SimulationLink[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let selectedNodeId = $state<string | null>(null);
|
||||
let simulation: Simulation<SimulationNode, SimulationLink> | null = null;
|
||||
let searchQuery = $state('');
|
||||
let filterTagId = $state<string | null>(null);
|
||||
let filterLocation = $state<string | null>(null);
|
||||
let minStrength = $state(0);
|
||||
let tickCounter = $state(0);
|
||||
let simulationInitialized = false;
|
||||
let dataLoaded = false;
|
||||
let lastDimensions = { width: 0, height: 0 };
|
||||
|
||||
// Derived state for filtering
|
||||
const filteredNodes = $derived.by(() => {
|
||||
let result = nodes;
|
||||
|
||||
// Search filter
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(node) =>
|
||||
node.name.toLowerCase().includes(query) ||
|
||||
node.subtitle?.toLowerCase().includes(query) ||
|
||||
node.tags.some((t) => t.name.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
// Tag filter
|
||||
if (filterTagId) {
|
||||
result = result.filter((node) => node.tags.some((t) => t.id === filterTagId));
|
||||
}
|
||||
|
||||
// Location filter (uses subtitle field)
|
||||
if (filterLocation) {
|
||||
result = result.filter((node) => node.subtitle === filterLocation);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const filteredLinks = $derived.by(() => {
|
||||
const filteredNodeIds = new Set(filteredNodes.map((n) => n.id));
|
||||
return links.filter((link) => {
|
||||
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
|
||||
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
|
||||
// Check if both nodes are visible
|
||||
if (!filteredNodeIds.has(sourceId) || !filteredNodeIds.has(targetId)) {
|
||||
return false;
|
||||
}
|
||||
// Filter by minimum strength
|
||||
if (minStrength > 0 && link.strength < minStrength) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
// Get unique locations for filter dropdown
|
||||
const uniqueLocations = $derived.by(() => {
|
||||
const locations = new Set<string>();
|
||||
for (const node of nodes) {
|
||||
if (node.subtitle) {
|
||||
locations.add(node.subtitle);
|
||||
}
|
||||
}
|
||||
return Array.from(locations).sort();
|
||||
});
|
||||
|
||||
// Get unique tags for filter dropdown
|
||||
const uniqueTags = $derived.by(() => {
|
||||
const tagsMap = new Map<string, { id: string; name: string; color: string | null }>();
|
||||
for (const node of nodes) {
|
||||
for (const tag of node.tags) {
|
||||
if (!tagsMap.has(tag.id)) {
|
||||
tagsMap.set(tag.id, tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(tagsMap.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
|
||||
export const networkStore = {
|
||||
// Getters
|
||||
get nodes() {
|
||||
void tickCounter;
|
||||
return filteredNodes;
|
||||
},
|
||||
get allNodes() {
|
||||
void tickCounter;
|
||||
return nodes;
|
||||
},
|
||||
get links() {
|
||||
void tickCounter;
|
||||
return filteredLinks;
|
||||
},
|
||||
get allLinks() {
|
||||
void tickCounter;
|
||||
return links;
|
||||
},
|
||||
get tick() {
|
||||
return tickCounter;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get selectedNodeId() {
|
||||
return selectedNodeId;
|
||||
},
|
||||
get selectedNode() {
|
||||
return nodes.find((n) => n.id === selectedNodeId) || null;
|
||||
},
|
||||
get searchQuery() {
|
||||
return searchQuery;
|
||||
},
|
||||
get filterTagId() {
|
||||
return filterTagId;
|
||||
},
|
||||
get filterLocation() {
|
||||
return filterLocation;
|
||||
},
|
||||
get minStrength() {
|
||||
return minStrength;
|
||||
},
|
||||
get uniqueLocations() {
|
||||
return uniqueLocations;
|
||||
},
|
||||
get uniqueTags() {
|
||||
return uniqueTags;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load network graph data from API
|
||||
*/
|
||||
async loadGraph(force = false) {
|
||||
if (dataLoaded && !force) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
if (simulation) {
|
||||
simulation.stop();
|
||||
simulation = null;
|
||||
}
|
||||
simulationInitialized = false;
|
||||
|
||||
try {
|
||||
const response = await networkApi.getGraph();
|
||||
|
||||
// Convert to simulation nodes with subtitle for location
|
||||
nodes = response.nodes.map((node) => ({
|
||||
...node,
|
||||
subtitle: node.company, // Map company/location to subtitle
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
vx: undefined,
|
||||
vy: undefined,
|
||||
fx: null,
|
||||
fy: null,
|
||||
}));
|
||||
|
||||
// Convert to simulation links
|
||||
links = response.links.map((link) => ({
|
||||
source: link.source,
|
||||
target: link.target,
|
||||
type: link.type,
|
||||
strength: link.strength,
|
||||
sharedTags: link.sharedTags,
|
||||
}));
|
||||
|
||||
dataLoaded = true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load network graph';
|
||||
console.error('Failed to load network graph:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize D3 force simulation
|
||||
*/
|
||||
initSimulation(width: number, height: number) {
|
||||
if (!browser) return;
|
||||
if (nodes.length === 0) return;
|
||||
if (width <= 0 || height <= 0) return;
|
||||
|
||||
if (simulationInitialized && simulation) {
|
||||
if (
|
||||
Math.abs(lastDimensions.width - width) > 50 ||
|
||||
Math.abs(lastDimensions.height - height) > 50
|
||||
) {
|
||||
lastDimensions = { width, height };
|
||||
this.updateSimulationCenter(width, height);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (simulation) {
|
||||
simulation.stop();
|
||||
}
|
||||
|
||||
lastDimensions = { width, height };
|
||||
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const radius = Math.min(width, height) / 3;
|
||||
|
||||
nodes.forEach((node, i) => {
|
||||
if (node.x === undefined || node.y === undefined) {
|
||||
const angle = (i / nodes.length) * 2 * Math.PI;
|
||||
const r = radius * (0.5 + Math.random() * 0.5);
|
||||
node.x = centerX + r * Math.cos(angle);
|
||||
node.y = centerY + r * Math.sin(angle);
|
||||
}
|
||||
});
|
||||
|
||||
simulation = forceSimulation<SimulationNode, SimulationLink>(nodes)
|
||||
.force(
|
||||
'link',
|
||||
forceLink<SimulationNode, SimulationLink>(links)
|
||||
.id((d) => d.id)
|
||||
.distance(100)
|
||||
.strength(0.5)
|
||||
)
|
||||
.force('charge', forceManyBody().strength(-300))
|
||||
.force('center', forceCenter(centerX, centerY))
|
||||
.force('collision', forceCollide().radius(50))
|
||||
.on('tick', () => {
|
||||
tickCounter++;
|
||||
});
|
||||
|
||||
simulationInitialized = true;
|
||||
simulation.alpha(1).restart();
|
||||
},
|
||||
|
||||
updateSimulationCenter(width: number, height: number) {
|
||||
if (simulation) {
|
||||
simulation.force('center', forceCenter(width / 2, height / 2));
|
||||
simulation.alpha(0.3).restart();
|
||||
}
|
||||
},
|
||||
|
||||
stopSimulation() {
|
||||
if (simulation) {
|
||||
simulation.stop();
|
||||
simulation = null;
|
||||
}
|
||||
simulationInitialized = false;
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.stopSimulation();
|
||||
nodes = [];
|
||||
links = [];
|
||||
dataLoaded = false;
|
||||
lastDimensions = { width: 0, height: 0 };
|
||||
tickCounter = 0;
|
||||
},
|
||||
|
||||
reheatSimulation() {
|
||||
if (simulation) {
|
||||
simulation.alpha(0.3).restart();
|
||||
}
|
||||
},
|
||||
|
||||
fixNode(nodeId: string, x: number, y: number) {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
if (node) {
|
||||
node.fx = x;
|
||||
node.fy = y;
|
||||
}
|
||||
},
|
||||
|
||||
releaseNode(nodeId: string) {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
if (node) {
|
||||
node.fx = null;
|
||||
node.fy = null;
|
||||
}
|
||||
},
|
||||
|
||||
selectNode(nodeId: string | null) {
|
||||
selectedNodeId = nodeId;
|
||||
},
|
||||
|
||||
setSearch(query: string) {
|
||||
searchQuery = query;
|
||||
},
|
||||
|
||||
setFilterTag(tagId: string | null) {
|
||||
filterTagId = tagId;
|
||||
},
|
||||
|
||||
setFilterLocation(location: string | null) {
|
||||
filterLocation = location;
|
||||
},
|
||||
|
||||
setMinStrength(strength: number) {
|
||||
minStrength = strength;
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
searchQuery = '';
|
||||
filterTagId = null;
|
||||
filterLocation = null;
|
||||
minStrength = 0;
|
||||
},
|
||||
|
||||
getConnectedNodes(nodeId: string): SimulationNode[] {
|
||||
const connectedIds = new Set<string>();
|
||||
|
||||
for (const link of links) {
|
||||
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
|
||||
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
|
||||
|
||||
if (sourceId === nodeId) {
|
||||
connectedIds.add(targetId);
|
||||
} else if (targetId === nodeId) {
|
||||
connectedIds.add(sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
return nodes.filter((n) => connectedIds.has(n.id));
|
||||
},
|
||||
|
||||
getNodeLinks(nodeId: string): SimulationLink[] {
|
||||
return links.filter((link) => {
|
||||
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
|
||||
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
|
||||
return sourceId === nodeId || targetId === nodeId;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -3,15 +3,25 @@
|
|||
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 { PillNavigation, CommandBar } from '@manacore/shared-ui';
|
||||
import type {
|
||||
PillNavItem,
|
||||
PillDropdownItem,
|
||||
CommandBarItem,
|
||||
QuickAction,
|
||||
} from '@manacore/shared-ui';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
|
||||
import {
|
||||
THEME_DEFINITIONS,
|
||||
DEFAULT_THEME_VARIANTS,
|
||||
EXTENDED_THEME_VARIANTS,
|
||||
} from '@manacore/shared-theme';
|
||||
import type { ThemeVariant } from '@manacore/shared-theme';
|
||||
import {
|
||||
isSidebarMode as sidebarModeStore,
|
||||
isNavCollapsed as collapsedStore,
|
||||
|
|
@ -19,21 +29,68 @@
|
|||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
import { searchEvents } from '$lib/api/events';
|
||||
import { format } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('calendar');
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// CommandBar state
|
||||
let commandBarOpen = $state(false);
|
||||
|
||||
// CommandBar quick actions (no search for calendar yet)
|
||||
const commandBarQuickActions: QuickAction[] = [
|
||||
{ id: 'new', label: 'Neuen Termin erstellen', icon: 'plus', href: '/event/new', shortcut: 'N' },
|
||||
{
|
||||
id: 'today',
|
||||
label: 'Zu Heute springen',
|
||||
icon: 'calendar',
|
||||
onclick: () => viewStore.goToToday(),
|
||||
},
|
||||
{ id: 'agenda', label: 'Agenda anzeigen', icon: 'list', href: '/agenda' },
|
||||
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
|
||||
];
|
||||
|
||||
// CommandBar search - search events
|
||||
async function handleCommandBarSearch(query: string): Promise<CommandBarItem[]> {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
const result = await searchEvents(query);
|
||||
if (result.error || !result.data) return [];
|
||||
|
||||
return result.data.slice(0, 10).map((event) => ({
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
subtitle: format(new Date(event.startTime), 'dd. MMM yyyy, HH:mm', { locale: de }),
|
||||
}));
|
||||
}
|
||||
|
||||
function handleCommandBarSelect(item: CommandBarItem) {
|
||||
goto(`/event/${item.id}`);
|
||||
}
|
||||
|
||||
let isSidebarMode = $state(false);
|
||||
let isCollapsed = $state(false);
|
||||
|
||||
// Use theme store's isDark directly
|
||||
let isDark = $derived(theme.isDark);
|
||||
|
||||
// Get pinned themes from user settings (extended themes only)
|
||||
let pinnedThemes = $derived<ThemeVariant[]>(
|
||||
(userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant =>
|
||||
EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant)
|
||||
)
|
||||
);
|
||||
|
||||
// Visible themes in PillNav: default + pinned extended
|
||||
let visibleThemes = $derived<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
|
||||
|
||||
// Theme variant dropdown items
|
||||
let themeVariantItems = $derived<PillDropdownItem[]>([
|
||||
...theme.variants.map((variant) => ({
|
||||
...visibleThemes.map((variant) => ({
|
||||
id: variant,
|
||||
label: THEME_DEFINITIONS[variant].label,
|
||||
icon: THEME_DEFINITIONS[variant].icon,
|
||||
|
|
@ -69,6 +126,8 @@
|
|||
const navItems: PillNavItem[] = [
|
||||
{ href: '/', label: 'Kalender', icon: 'calendar' },
|
||||
{ href: '/agenda', label: 'Agenda', icon: 'list' },
|
||||
{ href: '/tags', label: 'Tags', icon: 'tag' },
|
||||
{ href: '/network', label: 'Netzwerk', icon: 'share-2' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
];
|
||||
|
|
@ -79,6 +138,13 @@
|
|||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Cmd/Ctrl+K to open command bar (works even in inputs)
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||
event.preventDefault();
|
||||
commandBarOpen = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -209,6 +275,18 @@
|
|||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Global Command Bar (Cmd/K) -->
|
||||
<CommandBar
|
||||
bind:open={commandBarOpen}
|
||||
onClose={() => (commandBarOpen = false)}
|
||||
onSearch={handleCommandBarSearch}
|
||||
onSelect={handleCommandBarSelect}
|
||||
quickActions={commandBarQuickActions}
|
||||
placeholder="Termin suchen..."
|
||||
emptyText="Keine Termine gefunden"
|
||||
searchingText="Suche..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
import CalendarSidebar from '$lib/components/calendar/CalendarSidebar.svelte';
|
||||
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
|
||||
import EventDetailModal from '$lib/components/event/EventDetailModal.svelte';
|
||||
import { CalendarViewSkeleton } from '$lib/components/skeletons';
|
||||
import { format, addMinutes } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
|
|
@ -166,7 +167,9 @@
|
|||
<CalendarHeader />
|
||||
|
||||
<div class="calendar-content">
|
||||
{#if viewStore.viewType === 'day'}
|
||||
{#if !initialized}
|
||||
<CalendarViewSkeleton />
|
||||
{:else if viewStore.viewType === 'day'}
|
||||
<DayView onQuickCreate={handleQuickCreate} />
|
||||
{:else if viewStore.viewType === '5day'}
|
||||
<MultiDayView dayCount={5} onQuickCreate={handleQuickCreate} />
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { format, parseISO, isToday, isTomorrow, addDays, startOfDay, endOfDay } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { AgendaSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
|
|
@ -81,7 +82,7 @@
|
|||
</header>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Laden...</div>
|
||||
<AgendaSkeleton />
|
||||
{:else if groupedEvents.length === 0}
|
||||
<div class="empty-state card">
|
||||
<p>Keine Termine in den nächsten 30 Tagen</p>
|
||||
|
|
@ -153,12 +154,6 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { RedirectSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
// Redirect to main calendar page with event modal
|
||||
onMount(() => {
|
||||
|
|
@ -10,34 +11,4 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<div class="redirect-loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.redirect-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 50vh;
|
||||
gap: 1rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid hsl(var(--color-border));
|
||||
border-top-color: hsl(var(--color-primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<RedirectSkeleton />
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import EventForm from '$lib/components/event/EventForm.svelte';
|
||||
import type { CreateEventInput } from '@calendar/shared';
|
||||
import type { CreateEventInput, UpdateEventInput } from '@calendar/shared';
|
||||
import { addHours, parseISO } from 'date-fns';
|
||||
|
||||
let initialStart = $state<Date | null>(null);
|
||||
|
|
@ -25,8 +25,9 @@
|
|||
}
|
||||
});
|
||||
|
||||
async function handleSave(data: CreateEventInput) {
|
||||
const result = await eventsStore.createEvent(data);
|
||||
async function handleSave(data: CreateEventInput | UpdateEventInput) {
|
||||
// In create mode, data is always CreateEventInput
|
||||
const result = await eventsStore.createEvent(data as CreateEventInput);
|
||||
|
||||
if (result.error) {
|
||||
toast.error(`Fehler beim Erstellen: ${result.error.message}`);
|
||||
|
|
|
|||
415
apps/calendar/apps/web/src/routes/(app)/network/+page.svelte
Normal file
415
apps/calendar/apps/web/src/routes/(app)/network/+page.svelte
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { networkStore, type SimulationNode } from '$lib/stores/network.svelte';
|
||||
import { NetworkGraph, NetworkControls } from '@manacore/shared-ui';
|
||||
import '$lib/i18n';
|
||||
|
||||
let graphComponent: NetworkGraph;
|
||||
let controlsComponent: NetworkControls;
|
||||
let graphContainer: HTMLDivElement;
|
||||
|
||||
function handleNodeClick(node: SimulationNode) {
|
||||
// Select node (highlight connections)
|
||||
networkStore.selectNode(node.id);
|
||||
}
|
||||
|
||||
function handleNodeDoubleClick(node: SimulationNode) {
|
||||
// Navigate to event detail page
|
||||
goto(`/event/${node.id}`);
|
||||
}
|
||||
|
||||
function handleBackgroundClick() {
|
||||
networkStore.selectNode(null);
|
||||
}
|
||||
|
||||
function handleDragStart(node: SimulationNode) {
|
||||
networkStore.fixNode(node.id, node.x ?? 0, node.y ?? 0);
|
||||
networkStore.reheatSimulation();
|
||||
}
|
||||
|
||||
function handleDrag(node: SimulationNode, x: number, y: number) {
|
||||
networkStore.fixNode(node.id, x, y);
|
||||
}
|
||||
|
||||
function handleDragEnd(node: SimulationNode) {
|
||||
networkStore.releaseNode(node.id);
|
||||
}
|
||||
|
||||
function handleZoomIn() {
|
||||
graphComponent?.zoomIn();
|
||||
}
|
||||
|
||||
function handleZoomOut() {
|
||||
graphComponent?.zoomOut();
|
||||
}
|
||||
|
||||
function handleResetZoom() {
|
||||
graphComponent?.resetZoom();
|
||||
}
|
||||
|
||||
function handleFocusSelected() {
|
||||
graphComponent?.focusOnSelectedNode();
|
||||
}
|
||||
|
||||
function handleFocusSearch() {
|
||||
controlsComponent?.focusSearch();
|
||||
}
|
||||
|
||||
function handleSearch(query: string) {
|
||||
networkStore.setSearch(query);
|
||||
}
|
||||
|
||||
function handleTagFilter(tagId: string | null) {
|
||||
networkStore.setFilterTag(tagId);
|
||||
}
|
||||
|
||||
function handleSubtitleFilter(location: string | null) {
|
||||
networkStore.setFilterLocation(location);
|
||||
}
|
||||
|
||||
function handleStrengthFilter(strength: number) {
|
||||
networkStore.setMinStrength(strength);
|
||||
}
|
||||
|
||||
function handleClearFilters() {
|
||||
networkStore.clearFilters();
|
||||
}
|
||||
|
||||
// Initialize simulation when data is loaded and container is ready
|
||||
$effect(() => {
|
||||
if (!networkStore.loading && networkStore.allNodes.length > 0 && graphContainer) {
|
||||
const rect = graphContainer.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
networkStore.initSimulation(rect.width, rect.height);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
networkStore.loadGraph();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
networkStore.stopSimulation();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Netzwerk - Kalender</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="network-page">
|
||||
<!-- Controls (floating) -->
|
||||
<div class="controls-wrapper">
|
||||
<NetworkControls
|
||||
bind:this={controlsComponent}
|
||||
searchQuery={networkStore.searchQuery}
|
||||
tags={networkStore.uniqueTags}
|
||||
selectedTagId={networkStore.filterTagId}
|
||||
subtitles={networkStore.uniqueLocations}
|
||||
selectedSubtitle={networkStore.filterLocation}
|
||||
subtitleLabel="Ort"
|
||||
nodeCount={networkStore.nodes.length}
|
||||
linkCount={networkStore.links.length}
|
||||
nodeLabel="Events"
|
||||
linkLabel="Verbindungen"
|
||||
searchPlaceholder="Event suchen..."
|
||||
minStrength={networkStore.minStrength}
|
||||
onSearch={handleSearch}
|
||||
onTagFilter={handleTagFilter}
|
||||
onSubtitleFilter={handleSubtitleFilter}
|
||||
onStrengthFilter={handleStrengthFilter}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
onResetZoom={handleResetZoom}
|
||||
onFocusSelected={handleFocusSelected}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error Banner -->
|
||||
{#if networkStore.error}
|
||||
<div class="error-banner" role="alert">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{networkStore.error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="graph-container" bind:this={graphContainer}>
|
||||
{#if networkStore.loading}
|
||||
<div class="loading-container">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Lade Netzwerk-Graph...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<NetworkGraph
|
||||
bind:this={graphComponent}
|
||||
nodes={networkStore.nodes}
|
||||
links={networkStore.links}
|
||||
selectedNodeId={networkStore.selectedNodeId}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onBackgroundClick={handleBackgroundClick}
|
||||
onDragStart={handleDragStart}
|
||||
onDrag={handleDrag}
|
||||
onDragEnd={handleDragEnd}
|
||||
onFocusSearch={handleFocusSearch}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Selected Event Info Panel -->
|
||||
{#if networkStore.selectedNode}
|
||||
<div class="info-panel">
|
||||
<div class="info-header">
|
||||
<h3>{networkStore.selectedNode.name}</h3>
|
||||
<button class="close-btn" onclick={() => networkStore.selectNode(null)}>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{#if networkStore.selectedNode.subtitle}
|
||||
<p class="info-subtitle">{networkStore.selectedNode.subtitle}</p>
|
||||
{/if}
|
||||
{#if networkStore.selectedNode.tags.length > 0}
|
||||
<div class="info-tags">
|
||||
{#each networkStore.selectedNode.tags as tag}
|
||||
<span
|
||||
class="tag"
|
||||
style="background-color: {tag.color || 'hsl(var(--muted))'}; color: white;"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="info-stats">
|
||||
<span>{networkStore.selectedNode.connectionCount} Verbindungen</span>
|
||||
</div>
|
||||
<button class="view-btn" onclick={() => goto(`/event/${networkStore.selectedNode?.id}`)}>
|
||||
Event anzeigen
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.network-page {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Floating Controls */
|
||||
.controls-wrapper {
|
||||
position: absolute;
|
||||
top: 5rem; /* Below the nav */
|
||||
left: 1rem;
|
||||
z-index: 10;
|
||||
max-width: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
/* Error Banner */
|
||||
.error-banner {
|
||||
position: absolute;
|
||||
top: 5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
border: 1px solid hsl(var(--destructive) / 0.3);
|
||||
border-radius: 0.875rem;
|
||||
color: hsl(var(--destructive));
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Graph Container - Full screen */
|
||||
.graph-container {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 1rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid hsl(var(--muted));
|
||||
border-top-color: hsl(var(--primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Info Panel */
|
||||
.info-panel {
|
||||
position: fixed;
|
||||
top: 5rem;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
width: 320px;
|
||||
max-width: calc(100vw - 2rem);
|
||||
z-index: 50;
|
||||
background: hsl(var(--card) / 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid hsl(var(--border) / 0.5);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
animation: slideInRight 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info-header h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--muted-foreground));
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.info-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-stats {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
margin-top: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.info-panel {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
top: auto;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: auto;
|
||||
max-height: 50vh;
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
animation: slideInUp 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.controls-wrapper {
|
||||
top: 6rem;
|
||||
width: calc(100% - 1rem);
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
309
apps/calendar/apps/web/src/routes/(app)/tags/+page.svelte
Normal file
309
apps/calendar/apps/web/src/routes/(app)/tags/+page.svelte
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { TagList, TagEditModal, type Tag } from '@manacore/shared-ui';
|
||||
import { MagnifyingGlass, Plus, CaretLeft } from '@manacore/shared-icons';
|
||||
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
|
||||
import type { EventTag } from '@calendar/shared';
|
||||
|
||||
let searchQuery = $state('');
|
||||
let showModal = $state(false);
|
||||
let editingTag = $state<EventTag | null>(null);
|
||||
|
||||
const filteredTags = $derived.by(() => {
|
||||
if (!searchQuery.trim()) return eventTagsStore.tags;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return eventTagsStore.tags.filter((t) => t.name.toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
// Convert EventTag to Tag type for shared-ui components
|
||||
function eventTagToTag(tag: EventTag): Tag {
|
||||
return {
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
color: tag.color,
|
||||
};
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
editingTag = null;
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
function openEditModal(tag: Tag) {
|
||||
const eventTag = eventTagsStore.tags.find((t) => t.id === tag.id);
|
||||
if (eventTag) {
|
||||
editingTag = eventTag;
|
||||
showModal = true;
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal = false;
|
||||
editingTag = null;
|
||||
}
|
||||
|
||||
async function handleSave(name: string, color: string) {
|
||||
try {
|
||||
if (editingTag) {
|
||||
await eventTagsStore.updateTag(editingTag.id, { name, color });
|
||||
} else {
|
||||
await eventTagsStore.createTag({ name, color });
|
||||
}
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
console.error('Failed to save tag:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!editingTag) return;
|
||||
|
||||
try {
|
||||
await eventTagsStore.deleteTag(editingTag.id);
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete tag:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteFromList(tag: Tag) {
|
||||
if (!confirm(`Tag "${tag.name}" wirklich löschen?`)) return;
|
||||
|
||||
try {
|
||||
await eventTagsStore.deleteTag(tag.id);
|
||||
} catch (e) {
|
||||
console.error('Failed to delete tag:', e);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (eventTagsStore.tags.length === 0) {
|
||||
eventTagsStore.fetchTags();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Tags - Kalender</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page-container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<a href="/" class="back-button" aria-label="Zurück">
|
||||
<CaretLeft size={20} weight="bold" />
|
||||
</a>
|
||||
<h1 class="title">Tags</h1>
|
||||
<button onclick={openCreateModal} class="add-button" aria-label="Neues Tag">
|
||||
<Plus size={20} weight="bold" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="search-wrapper">
|
||||
<MagnifyingGlass size={20} class="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tags durchsuchen..."
|
||||
bind:value={searchQuery}
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if eventTagsStore.error}
|
||||
<div class="error-banner" role="alert">
|
||||
<span>{eventTagsStore.error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tag List using shared component -->
|
||||
<TagList
|
||||
tags={filteredTags.map(eventTagToTag)}
|
||||
loading={eventTagsStore.loading}
|
||||
onEdit={openEditModal}
|
||||
onDelete={handleDeleteFromList}
|
||||
emptyMessage={searchQuery ? 'Keine Tags gefunden' : 'Keine Tags vorhanden'}
|
||||
emptyDescription={searchQuery
|
||||
? `Kein Tag für "${searchQuery}" gefunden`
|
||||
: 'Erstelle dein erstes Tag'}
|
||||
/>
|
||||
|
||||
{#if !eventTagsStore.loading && eventTagsStore.tags.length > 0}
|
||||
<p class="tags-count">
|
||||
{eventTagsStore.tags.length}
|
||||
{eventTagsStore.tags.length === 1 ? 'Tag' : 'Tags'}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if !eventTagsStore.loading && eventTagsStore.tags.length === 0 && !searchQuery}
|
||||
<div class="empty-cta">
|
||||
<button onclick={openCreateModal} class="btn btn-primary">
|
||||
<Plus size={16} weight="bold" />
|
||||
Neues Tag
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal using shared component -->
|
||||
<TagEditModal
|
||||
tag={editingTag ? eventTagToTag(editingTag) : null}
|
||||
isOpen={showModal}
|
||||
onClose={closeModal}
|
||||
onSave={handleSave}
|
||||
onDelete={editingTag ? handleDelete : undefined}
|
||||
title={editingTag ? 'Tag bearbeiten' : 'Neues Tag'}
|
||||
saveLabel={editingTag ? 'Speichern' : 'Erstellen'}
|
||||
deleteLabel="Löschen"
|
||||
cancelLabel="Abbrechen"
|
||||
namePlaceholder="Tag Name"
|
||||
colorLabel="Farbe"
|
||||
previewLabel="Vorschau"
|
||||
deleteConfirmMessage={`Tag "${editingTag?.name || ''}" wirklich löschen?`}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.page-container {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem 2rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.2);
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.add-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-button:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.search-wrapper :global(.search-icon) {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: hsl(var(--muted-foreground));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem 0.875rem 3rem;
|
||||
border: 1.5px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.9375rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: hsl(0 84% 60% / 0.1);
|
||||
border: 1px solid hsl(0 84% 60% / 0.3);
|
||||
border-radius: 0.75rem;
|
||||
color: hsl(0 84% 60%);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Count */
|
||||
.tags-count {
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Empty CTA */
|
||||
.empty-cta {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.625rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
|
||||
}
|
||||
</style>
|
||||
19
apps/calendar/apps/web/src/routes/(app)/themes/+page.svelte
Normal file
19
apps/calendar/apps/web/src/routes/(app)/themes/+page.svelte
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ThemePage } from '@manacore/shared-theme-ui';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Themes | Calendar</title>
|
||||
</svelte:head>
|
||||
|
||||
<ThemePage
|
||||
currentVariant={theme.variant}
|
||||
onSelectTheme={(v) => theme.setVariant(v)}
|
||||
showModeSelector={true}
|
||||
currentMode={theme.mode}
|
||||
onModeChange={(m) => theme.setMode(m)}
|
||||
showBackButton={true}
|
||||
onBack={() => goto('/')}
|
||||
/>
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||
import { AppLoadingSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
|
|
@ -23,14 +24,7 @@
|
|||
<ToastContainer />
|
||||
|
||||
{#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>
|
||||
<AppLoadingSkeleton />
|
||||
{:else}
|
||||
<div class="min-h-screen bg-background text-foreground">
|
||||
{@render children()}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,18 @@ export interface EventAttendee {
|
|||
status?: 'accepted' | 'declined' | 'tentative' | 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Event tag with color
|
||||
*/
|
||||
export interface EventTag {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How to display all-day events
|
||||
*/
|
||||
|
|
@ -92,6 +104,9 @@ export interface CalendarEvent {
|
|||
// Metadata
|
||||
metadata?: EventMetadata | null;
|
||||
|
||||
// Tags (populated when fetched)
|
||||
tags?: EventTag[];
|
||||
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
|
@ -124,6 +139,7 @@ export interface CreateEventInput {
|
|||
color?: string;
|
||||
status?: EventStatus;
|
||||
metadata?: EventMetadata;
|
||||
tagIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -144,6 +160,7 @@ export interface UpdateEventInput {
|
|||
color?: string | null;
|
||||
status?: EventStatus;
|
||||
metadata?: EventMetadata;
|
||||
tagIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -27,18 +27,15 @@
|
|||
"@google/generative-ai": "^0.24.1",
|
||||
"@manacore/shared-errors": "workspace:*",
|
||||
"@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",
|
||||
"@types/multer": "^1.4.11",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"openai": "^4.77.0",
|
||||
"postgres": "^3.4.5",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { SpaceModule } from './space/space.module';
|
|||
import { DocumentModule } from './document/document.module';
|
||||
import { ModelModule } from './model/model.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -24,7 +23,6 @@ import { StorageModule } from './storage/storage.module';
|
|||
DocumentModule,
|
||||
ModelModule,
|
||||
HealthModule,
|
||||
StorageModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export * from './storage.module';
|
||||
export * from './storage.service';
|
||||
export * from './storage.controller';
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
interface PresignedUploadRequest {
|
||||
filename: string;
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
@Controller('api/storage')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class StorageController {
|
||||
constructor(private readonly storageService: StorageService) {}
|
||||
|
||||
/**
|
||||
* Upload a file directly
|
||||
*/
|
||||
@Post('upload')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadFile(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('folder') folder?: string
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file provided');
|
||||
}
|
||||
|
||||
const result = await this.storageService.uploadFile(
|
||||
user.userId,
|
||||
file.originalname,
|
||||
file.buffer,
|
||||
{
|
||||
folder,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a presigned URL for client-side upload
|
||||
*/
|
||||
@Post('presigned-upload')
|
||||
async getPresignedUpload(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() body: PresignedUploadRequest
|
||||
) {
|
||||
if (!body.filename) {
|
||||
throw new BadRequestException('Filename is required');
|
||||
}
|
||||
|
||||
const result = await this.storageService.getPresignedUploadUrl(user.userId, body.filename, {
|
||||
folder: body.folder,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a presigned URL for downloading
|
||||
*/
|
||||
@Get('download/:key(*)')
|
||||
async getDownloadUrl(@CurrentUser() user: CurrentUserData, @Param('key') key: string) {
|
||||
// Ensure user can only access their own files
|
||||
if (!key.startsWith(`users/${user.userId}/`)) {
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
|
||||
const exists = await this.storageService.fileExists(key);
|
||||
if (!exists) {
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
|
||||
const url = await this.storageService.getPresignedDownloadUrl(key);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { url },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file
|
||||
*/
|
||||
@Delete(':key(*)')
|
||||
async deleteFile(@CurrentUser() user: CurrentUserData, @Param('key') key: string) {
|
||||
// Ensure user can only delete their own files
|
||||
if (!key.startsWith(`users/${user.userId}/`)) {
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
|
||||
const exists = await this.storageService.fileExists(key);
|
||||
if (!exists) {
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
|
||||
await this.storageService.deleteFile(key);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'File deleted',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List user's files
|
||||
*/
|
||||
@Get('list')
|
||||
async listFiles(@CurrentUser() user: CurrentUserData, @Body('folder') folder?: string) {
|
||||
const files = await this.storageService.listUserFiles(user.userId, folder);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { files },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { StorageService } from './storage.service';
|
||||
import { StorageController } from './storage.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [StorageController],
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
createChatStorage,
|
||||
generateUserFileKey,
|
||||
getContentType,
|
||||
validateFileSize,
|
||||
validateFileExtension,
|
||||
IMAGE_EXTENSIONS,
|
||||
DOCUMENT_EXTENSIONS,
|
||||
AUDIO_EXTENSIONS,
|
||||
} from '@manacore/shared-storage';
|
||||
import type { StorageClient, UploadResult } from '@manacore/shared-storage';
|
||||
|
||||
export interface FileUploadResult {
|
||||
key: string;
|
||||
url?: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface PresignedUploadData {
|
||||
uploadUrl: string;
|
||||
key: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
const ALLOWED_EXTENSIONS = [...IMAGE_EXTENSIONS, ...DOCUMENT_EXTENSIONS, ...AUDIO_EXTENSIONS];
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private readonly logger = new Logger(StorageService.name);
|
||||
private storage: StorageClient | null = null;
|
||||
|
||||
private getStorage(): StorageClient {
|
||||
if (!this.storage) {
|
||||
this.storage = createChatStorage();
|
||||
}
|
||||
return this.storage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to storage
|
||||
*/
|
||||
async uploadFile(
|
||||
userId: string,
|
||||
filename: string,
|
||||
data: Buffer,
|
||||
options?: { folder?: string; public?: boolean }
|
||||
): Promise<FileUploadResult> {
|
||||
// Validate file size (MAX_FILE_SIZE is in bytes)
|
||||
if (!validateFileSize(data.length, MAX_FILE_SIZE / (1024 * 1024))) {
|
||||
throw new Error(`File size exceeds maximum allowed (${MAX_FILE_SIZE / (1024 * 1024)}MB)`);
|
||||
}
|
||||
|
||||
// Validate file extension
|
||||
if (!validateFileExtension(filename, ALLOWED_EXTENSIONS)) {
|
||||
throw new Error(
|
||||
`File type not allowed. Allowed extensions: ${ALLOWED_EXTENSIONS.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = getContentType(filename);
|
||||
const key = generateUserFileKey(userId, filename, options?.folder);
|
||||
|
||||
const storage = this.getStorage();
|
||||
const result: UploadResult = await storage.upload(key, data, {
|
||||
contentType,
|
||||
public: options?.public ?? false,
|
||||
});
|
||||
|
||||
this.logger.log(`File uploaded: ${key} (${data.length} bytes)`);
|
||||
|
||||
return {
|
||||
key: result.key,
|
||||
url: result.url,
|
||||
contentType,
|
||||
size: data.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a presigned URL for uploading (client-side upload)
|
||||
*/
|
||||
async getPresignedUploadUrl(
|
||||
userId: string,
|
||||
filename: string,
|
||||
options?: { folder?: string; expiresIn?: number }
|
||||
): Promise<PresignedUploadData> {
|
||||
// Validate file extension
|
||||
if (!validateFileExtension(filename, ALLOWED_EXTENSIONS)) {
|
||||
throw new Error(
|
||||
`File type not allowed. Allowed extensions: ${ALLOWED_EXTENSIONS.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const key = generateUserFileKey(userId, filename, options?.folder);
|
||||
const expiresIn = options?.expiresIn ?? 3600; // 1 hour default
|
||||
|
||||
const storage = this.getStorage();
|
||||
const uploadUrl = await storage.getUploadUrl(key, { expiresIn });
|
||||
|
||||
return {
|
||||
uploadUrl,
|
||||
key,
|
||||
expiresIn,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a presigned URL for downloading
|
||||
*/
|
||||
async getPresignedDownloadUrl(key: string, expiresIn = 3600): Promise<string> {
|
||||
const storage = this.getStorage();
|
||||
return storage.getDownloadUrl(key, { expiresIn });
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from storage
|
||||
*/
|
||||
async downloadFile(key: string): Promise<Buffer> {
|
||||
const storage = this.getStorage();
|
||||
return storage.download(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from storage
|
||||
*/
|
||||
async deleteFile(key: string): Promise<void> {
|
||||
const storage = this.getStorage();
|
||||
await storage.delete(key);
|
||||
this.logger.log(`File deleted: ${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists
|
||||
*/
|
||||
async fileExists(key: string): Promise<boolean> {
|
||||
const storage = this.getStorage();
|
||||
return storage.exists(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* List files for a user
|
||||
*/
|
||||
async listUserFiles(userId: string, folder?: string): Promise<string[]> {
|
||||
const storage = this.getStorage();
|
||||
const prefix = folder ? `users/${userId}/${folder}/` : `users/${userId}/`;
|
||||
const files = await storage.list(prefix);
|
||||
return files.map((f) => f.key);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,3 +6,5 @@
|
|||
@source "../../../../packages/shared-auth-ui/src";
|
||||
@source "../../../../packages/shared-branding/src";
|
||||
@source "../../../../packages/shared-theme-ui/src";
|
||||
@source "../../../../packages/shared-theme-ui/src/components";
|
||||
@source "../../../../packages/shared-theme-ui/src/pages";
|
||||
|
|
|
|||
|
|
@ -5,21 +5,21 @@
|
|||
*/
|
||||
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
// Get client-side URLs from environment (Docker runtime)
|
||||
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
|
||||
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
|
||||
const PUBLIC_BACKEND_URL_CLIENT =
|
||||
process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Get client-side URLs from environment at RUNTIME (not build time)
|
||||
// Use $env/dynamic/private to read actual runtime environment variables
|
||||
const authUrlClient = env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || env.PUBLIC_MANA_CORE_AUTH_URL || '';
|
||||
const backendUrlClient = env.PUBLIC_BACKEND_URL_CLIENT || env.PUBLIC_BACKEND_URL || '';
|
||||
|
||||
return resolve(event, {
|
||||
transformPageChunk: ({ html }) => {
|
||||
// Inject runtime environment variables into the HTML
|
||||
// These will be available on window.__PUBLIC_*__ for client-side code
|
||||
const envScript = `<script>
|
||||
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${authUrlClient}";
|
||||
window.__PUBLIC_BACKEND_URL__ = "${backendUrlClient}";
|
||||
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
|
||||
window.__PUBLIC_BACKEND_URL__ = "${PUBLIC_BACKEND_URL_CLIENT}";
|
||||
</script>`;
|
||||
return html.replace('<head>', `<head>${envScript}`);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
|
||||
import {
|
||||
THEME_DEFINITIONS,
|
||||
DEFAULT_THEME_VARIANTS,
|
||||
EXTENDED_THEME_VARIANTS,
|
||||
} from '@manacore/shared-theme';
|
||||
import type { ThemeVariant } from '@manacore/shared-theme';
|
||||
import {
|
||||
isSidebarMode as sidebarModeStore,
|
||||
isNavCollapsed as collapsedStore,
|
||||
|
|
@ -30,10 +35,20 @@
|
|||
// Use theme store's isDark directly
|
||||
let isDark = $derived(theme.isDark);
|
||||
|
||||
// Get pinned themes from user settings (extended themes only)
|
||||
let pinnedThemes = $derived<ThemeVariant[]>(
|
||||
(userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant =>
|
||||
EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant)
|
||||
)
|
||||
);
|
||||
|
||||
// Visible themes in PillNav: default + pinned extended
|
||||
let visibleThemes = $derived<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
|
||||
|
||||
// Theme variant dropdown items
|
||||
let themeVariantItems = $derived<PillDropdownItem[]>([
|
||||
// Theme variants
|
||||
...theme.variants.map((variant) => ({
|
||||
// Theme variants (only default + pinned)
|
||||
...visibleThemes.map((variant) => ({
|
||||
id: variant,
|
||||
label: THEME_DEFINITIONS[variant].label,
|
||||
icon: THEME_DEFINITIONS[variant].icon,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
@source "../../../packages/shared/src";
|
||||
@source "../../../../../packages/shared-ui/src";
|
||||
@source "../../../../../packages/shared-theme-ui/src";
|
||||
@source "../../../../../packages/shared-theme-ui/src/components";
|
||||
@source "../../../../../packages/shared-theme-ui/src/pages";
|
||||
|
||||
/* Clock-specific CSS Variables */
|
||||
@layer base {
|
||||
|
|
|
|||
|
|
@ -5,21 +5,21 @@
|
|||
*/
|
||||
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
// Get client-side URLs from environment (Docker runtime)
|
||||
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
|
||||
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
|
||||
const PUBLIC_BACKEND_URL_CLIENT =
|
||||
process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Get client-side URLs from environment at RUNTIME (not build time)
|
||||
// Use $env/dynamic/private to read actual runtime environment variables
|
||||
const authUrlClient = env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || env.PUBLIC_MANA_CORE_AUTH_URL || '';
|
||||
const backendUrlClient = env.PUBLIC_BACKEND_URL_CLIENT || env.PUBLIC_BACKEND_URL || '';
|
||||
|
||||
return resolve(event, {
|
||||
transformPageChunk: ({ html }) => {
|
||||
// Inject runtime environment variables into the HTML
|
||||
// These will be available on window.__PUBLIC_*__ for client-side code
|
||||
const envScript = `<script>
|
||||
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${authUrlClient}";
|
||||
window.__PUBLIC_BACKEND_URL__ = "${backendUrlClient}";
|
||||
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
|
||||
window.__PUBLIC_BACKEND_URL__ = "${PUBLIC_BACKEND_URL_CLIENT}";
|
||||
</script>`;
|
||||
return html.replace('<head>', `<head>${envScript}`);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* AlarmsSkeleton - Skeleton for alarms grid
|
||||
* Shows placeholder tiles for alarm times
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div role="status" aria-label="Wecker werden geladen...">
|
||||
<!-- Alarm Preset Grid (matches DEFAULT_ALARM_PRESETS layout) -->
|
||||
<div class="grid grid-cols-4 sm:grid-cols-6 gap-2">
|
||||
{#each Array(12) as _, i}
|
||||
<div
|
||||
class="bg-card rounded-xl p-3 border border-border flex flex-col items-center justify-center"
|
||||
style="opacity: {Math.max(0.4, 1 - i * 0.05)}"
|
||||
>
|
||||
<!-- Time -->
|
||||
<SkeletonBox width="48px" height="24px" borderRadius="4px" />
|
||||
<!-- Label -->
|
||||
<div class="mt-1">
|
||||
<SkeletonBox width="40px" height="10px" borderRadius="4px" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* AppLoadingSkeleton - Full-page loading skeleton for app initialization
|
||||
* Replaces spinner with clock-themed skeleton layout
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex min-h-screen items-center justify-center bg-background"
|
||||
role="status"
|
||||
aria-label="App wird geladen..."
|
||||
>
|
||||
<div class="w-full max-w-sm px-6 space-y-8">
|
||||
<!-- Clock Icon Area -->
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<SkeletonBox width="80px" height="80px" borderRadius="50%" />
|
||||
<SkeletonBox width="120px" height="24px" borderRadius="8px" />
|
||||
</div>
|
||||
|
||||
<!-- Time Display Skeleton -->
|
||||
<div class="bg-card rounded-2xl p-6 space-y-4">
|
||||
<!-- Large Time -->
|
||||
<div class="flex justify-center">
|
||||
<SkeletonBox width="200px" height="48px" borderRadius="8px" />
|
||||
</div>
|
||||
|
||||
<!-- Date -->
|
||||
<div class="flex justify-center">
|
||||
<SkeletonBox width="140px" height="16px" borderRadius="4px" />
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex justify-center gap-3 pt-2">
|
||||
{#each Array(4) as _, i}
|
||||
<SkeletonBox width="48px" height="48px" borderRadius="12px" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Text -->
|
||||
<div class="flex justify-center">
|
||||
<SkeletonBox width="100px" height="14px" borderRadius="4px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* TimersSkeleton - Skeleton for active timers grid
|
||||
* Shows placeholder tiles for timer displays
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div role="status" aria-label="Timer werden geladen...">
|
||||
<!-- Section Header -->
|
||||
<div class="mb-2">
|
||||
<SkeletonBox width="80px" height="12px" borderRadius="4px" />
|
||||
</div>
|
||||
|
||||
<!-- Timer Grid -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{#each Array(4) as _, i}
|
||||
<div
|
||||
class="bg-card rounded-xl p-3 border border-border"
|
||||
style="opacity: {Math.max(0.5, 1 - i * 0.12)}"
|
||||
>
|
||||
<!-- Time + Delete Button Row -->
|
||||
<div class="flex items-start justify-between mb-1">
|
||||
<SkeletonBox width="70px" height="24px" borderRadius="4px" />
|
||||
<SkeletonBox width="14px" height="14px" borderRadius="4px" />
|
||||
</div>
|
||||
|
||||
<!-- Label -->
|
||||
<div class="mb-2">
|
||||
<SkeletonBox width="50px" height="10px" borderRadius="4px" />
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="mb-2">
|
||||
<SkeletonBox width="100%" height="4px" borderRadius="2px" />
|
||||
</div>
|
||||
|
||||
<!-- Control Buttons -->
|
||||
<div class="flex gap-1">
|
||||
<SkeletonBox width="100%" height="28px" borderRadius="6px" />
|
||||
<SkeletonBox width="48px" height="28px" borderRadius="6px" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* WorldClockSkeleton - Skeleton for world clock grid
|
||||
* Shows placeholder cards for city times
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||
role="status"
|
||||
aria-label="Weltuhren werden geladen..."
|
||||
>
|
||||
{#each Array(6) as _, i}
|
||||
<div
|
||||
class="bg-card rounded-xl p-4 border border-border"
|
||||
style="opacity: {Math.max(0.4, 1 - i * 0.1)}"
|
||||
>
|
||||
<!-- Day/Night + City Name -->
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<SkeletonBox width="32px" height="12px" borderRadius="4px" />
|
||||
<SkeletonBox width="80px" height="14px" borderRadius="4px" />
|
||||
</div>
|
||||
|
||||
<!-- Large Time Display -->
|
||||
<SkeletonBox width="140px" height="40px" borderRadius="6px" />
|
||||
|
||||
<!-- Date and Offset -->
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<SkeletonBox width="90px" height="13px" borderRadius="4px" />
|
||||
<SkeletonBox width="36px" height="14px" borderRadius="4px" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
18
apps/clock/apps/web/src/lib/components/skeletons/index.ts
Normal file
18
apps/clock/apps/web/src/lib/components/skeletons/index.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Clock App Skeleton Components
|
||||
*
|
||||
* App-specific skeleton loaders that match the exact layout of clock components.
|
||||
* Built on top of @manacore/shared-ui skeleton primitives.
|
||||
*/
|
||||
|
||||
// App Loading Skeleton
|
||||
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';
|
||||
|
||||
// World Clock Skeleton
|
||||
export { default as WorldClockSkeleton } from './WorldClockSkeleton.svelte';
|
||||
|
||||
// Alarms Skeleton
|
||||
export { default as AlarmsSkeleton } from './AlarmsSkeleton.svelte';
|
||||
|
||||
// Timers Skeleton
|
||||
export { default as TimersSkeleton } from './TimersSkeleton.svelte';
|
||||
|
|
@ -3,12 +3,22 @@
|
|||
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 { PillNavigation, CommandBar } from '@manacore/shared-ui';
|
||||
import type {
|
||||
PillNavItem,
|
||||
PillDropdownItem,
|
||||
CommandBarItem,
|
||||
QuickAction,
|
||||
} from '@manacore/shared-ui';
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
|
||||
import {
|
||||
THEME_DEFINITIONS,
|
||||
DEFAULT_THEME_VARIANTS,
|
||||
EXTENDED_THEME_VARIANTS,
|
||||
} from '@manacore/shared-theme';
|
||||
import type { ThemeVariant } from '@manacore/shared-theme';
|
||||
import {
|
||||
isSidebarMode as sidebarModeStore,
|
||||
isNavCollapsed as collapsedStore,
|
||||
|
|
@ -16,21 +26,108 @@
|
|||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
import { alarmsApi } from '$lib/api/alarms';
|
||||
import { timersApi } from '$lib/api/timers';
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('clock');
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// CommandBar state
|
||||
let commandBarOpen = $state(false);
|
||||
|
||||
// CommandBar quick actions
|
||||
const commandBarQuickActions: QuickAction[] = [
|
||||
{
|
||||
id: 'alarm',
|
||||
label: 'Neuen Wecker erstellen',
|
||||
icon: 'bell',
|
||||
href: '/alarms?new=true',
|
||||
shortcut: 'A',
|
||||
},
|
||||
{
|
||||
id: 'timer',
|
||||
label: 'Neuen Timer starten',
|
||||
icon: 'timer',
|
||||
href: '/timers?new=true',
|
||||
shortcut: 'T',
|
||||
},
|
||||
{ id: 'stopwatch', label: 'Stoppuhr', icon: 'stopwatch', href: '/stopwatch' },
|
||||
{ id: 'pomodoro', label: 'Pomodoro starten', icon: 'target', href: '/pomodoro' },
|
||||
{ id: 'worldclock', label: 'Weltzeituhr', icon: 'globe', href: '/world-clock' },
|
||||
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
|
||||
];
|
||||
|
||||
// CommandBar search - search alarms and timers
|
||||
async function handleCommandBarSearch(query: string): Promise<CommandBarItem[]> {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
const queryLower = query.toLowerCase();
|
||||
const results: CommandBarItem[] = [];
|
||||
|
||||
try {
|
||||
// Search alarms
|
||||
const alarms = await alarmsApi.getAll();
|
||||
const matchingAlarms = alarms
|
||||
.filter((alarm) => alarm.label?.toLowerCase().includes(queryLower))
|
||||
.slice(0, 5)
|
||||
.map((alarm) => ({
|
||||
id: `alarm-${alarm.id}`,
|
||||
title: alarm.label || 'Wecker',
|
||||
subtitle: `⏰ ${alarm.time} ${alarm.enabled ? '(aktiv)' : '(inaktiv)'}`,
|
||||
}));
|
||||
results.push(...matchingAlarms);
|
||||
|
||||
// Search timers
|
||||
const timers = await timersApi.getAll();
|
||||
const matchingTimers = timers
|
||||
.filter((timer) => timer.label?.toLowerCase().includes(queryLower))
|
||||
.slice(0, 5)
|
||||
.map((timer) => {
|
||||
const mins = Math.floor(timer.durationSeconds / 60);
|
||||
const secs = timer.durationSeconds % 60;
|
||||
return {
|
||||
id: `timer-${timer.id}`,
|
||||
title: timer.label || 'Timer',
|
||||
subtitle: `⏱️ ${mins}:${secs.toString().padStart(2, '0')} ${timer.status === 'running' ? '(läuft)' : ''}`,
|
||||
};
|
||||
});
|
||||
results.push(...matchingTimers);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
return results.slice(0, 10);
|
||||
}
|
||||
|
||||
function handleCommandBarSelect(item: CommandBarItem) {
|
||||
if (item.id.startsWith('alarm-')) {
|
||||
goto('/alarms');
|
||||
} else if (item.id.startsWith('timer-')) {
|
||||
goto('/timers');
|
||||
}
|
||||
}
|
||||
|
||||
let isSidebarMode = $state(false);
|
||||
let isCollapsed = $state(false);
|
||||
|
||||
// Use theme store's isDark directly
|
||||
let isDark = $derived(theme.isDark);
|
||||
|
||||
// Get pinned themes from user settings (extended themes only)
|
||||
let pinnedThemes = $derived<ThemeVariant[]>(
|
||||
(userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant =>
|
||||
EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant)
|
||||
)
|
||||
);
|
||||
|
||||
// Visible themes in PillNav: default + pinned extended
|
||||
let visibleThemes = $derived<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
|
||||
|
||||
// Theme variant dropdown items (with SSR fallback)
|
||||
let themeVariantItems = $derived<PillDropdownItem[]>([
|
||||
...(theme.variants || []).map((variant) => ({
|
||||
...visibleThemes.map((variant) => ({
|
||||
id: variant,
|
||||
label: THEME_DEFINITIONS[variant]?.label || variant,
|
||||
icon: THEME_DEFINITIONS[variant]?.icon || '🎨',
|
||||
|
|
@ -83,6 +180,13 @@
|
|||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Cmd/Ctrl+K to open command bar (works even in inputs)
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||
event.preventDefault();
|
||||
commandBarOpen = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -206,6 +310,18 @@
|
|||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Global Command Bar (Cmd/K) -->
|
||||
<CommandBar
|
||||
bind:open={commandBarOpen}
|
||||
onClose={() => (commandBarOpen = false)}
|
||||
onSearch={handleCommandBarSearch}
|
||||
onSelect={handleCommandBarSelect}
|
||||
quickActions={commandBarQuickActions}
|
||||
placeholder="Schnellzugriff..."
|
||||
emptyText="Keine Ergebnisse"
|
||||
searchingText="Suche..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { toast } from '$lib/stores/toast';
|
||||
import type { CreateAlarmInput, Alarm } from '@clock/shared';
|
||||
import { ALARM_SOUNDS, DEFAULT_ALARM_PRESETS } from '@clock/shared';
|
||||
import { AlarmsSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
// Quick create form (inline)
|
||||
let newTime = $state('07:00');
|
||||
|
|
@ -194,11 +195,7 @@
|
|||
|
||||
<!-- Loading State -->
|
||||
{#if alarmsStore.loading}
|
||||
<div class="flex justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-r-transparent"
|
||||
></div>
|
||||
</div>
|
||||
<AlarmsSkeleton />
|
||||
{:else}
|
||||
<!-- Default Alarm Presets (Grid) -->
|
||||
<div class="alarm-grid">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import { QUICK_TIMER_PRESETS, formatDuration } from '@clock/shared';
|
||||
import { TimersSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
// Form state (inline on page)
|
||||
let formMinutes = $state(5);
|
||||
|
|
@ -219,11 +220,7 @@
|
|||
|
||||
<!-- Loading State -->
|
||||
{#if timersStore.loading}
|
||||
<div class="flex justify-center py-8">
|
||||
<div
|
||||
class="h-6 w-6 animate-spin rounded-full border-2 border-primary border-r-transparent"
|
||||
></div>
|
||||
</div>
|
||||
<TimersSkeleton />
|
||||
{:else if allTimers.length > 0}
|
||||
<!-- Active Timers -->
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { toast } from '$lib/stores/toast';
|
||||
import { POPULAR_TIMEZONES } from '@clock/shared';
|
||||
import WorldMap from '$lib/components/WorldMap.svelte';
|
||||
import { WorldClockSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
// State
|
||||
let showAddModal = $state(false);
|
||||
|
|
@ -205,11 +206,7 @@
|
|||
|
||||
<!-- World Clock List -->
|
||||
{#if worldClocksStore.loading}
|
||||
<div class="flex justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-r-transparent"
|
||||
></div>
|
||||
</div>
|
||||
<WorldClockSkeleton />
|
||||
{:else if worldClocksStore.sortedWorldClocks.length === 0}
|
||||
<div class="card py-12 text-center">
|
||||
<p class="text-lg text-muted-foreground">{$_('worldClock.noClocks')}</p>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { waitLocale } from '$lib/i18n';
|
||||
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||
import { AppLoadingSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
|
|
@ -28,14 +29,7 @@
|
|||
<ToastContainer />
|
||||
|
||||
{#if $isLocaleLoading || 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>
|
||||
<AppLoadingSkeleton />
|
||||
{:else}
|
||||
<div class="min-h-screen bg-background text-foreground">
|
||||
{@render children()}
|
||||
|
|
|
|||
|
|
@ -83,11 +83,6 @@ pnpm build # Build for production
|
|||
| `/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 |
|
||||
|
|
@ -129,20 +124,6 @@ pnpm build # Build for production
|
|||
- `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
|
||||
|
|
@ -208,7 +189,7 @@ S3_BUCKET=contacts-photos
|
|||
# Get credentials from https://console.cloud.google.com/apis/credentials
|
||||
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=your-client-secret
|
||||
GOOGLE_REDIRECT_URI=http://localhost:5184/import?tab=google
|
||||
GOOGLE_REDIRECT_URI=http://localhost:5184/data?tab=import&source=google
|
||||
```
|
||||
|
||||
#### Mobile (.env)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ 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';
|
||||
|
|
@ -10,6 +9,10 @@ import { HealthModule } from './health/health.module';
|
|||
import { ImportModule } from './import/import.module';
|
||||
import { ExportModule } from './export/export.module';
|
||||
import { GoogleModule } from './google/google.module';
|
||||
import { DuplicatesModule } from './duplicates/duplicates.module';
|
||||
import { PhotoModule } from './photo/photo.module';
|
||||
import { BatchModule } from './batch/batch.module';
|
||||
import { NetworkModule } from './network/network.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -19,7 +22,6 @@ import { GoogleModule } from './google/google.module';
|
|||
}),
|
||||
DatabaseModule,
|
||||
ContactModule,
|
||||
GroupModule,
|
||||
TagModule,
|
||||
NoteModule,
|
||||
ActivityModule,
|
||||
|
|
@ -27,6 +29,10 @@ import { GoogleModule } from './google/google.module';
|
|||
ImportModule,
|
||||
ExportModule,
|
||||
GoogleModule,
|
||||
DuplicatesModule,
|
||||
PhotoModule,
|
||||
BatchModule,
|
||||
NetworkModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
68
apps/contacts/apps/backend/src/batch/batch.controller.ts
Normal file
68
apps/contacts/apps/backend/src/batch/batch.controller.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { BatchService } from './batch.service';
|
||||
import { IsArray, IsString, IsBoolean, IsOptional, ArrayMinSize } from 'class-validator';
|
||||
|
||||
class BatchContactIdsDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMinSize(1)
|
||||
contactIds: string[];
|
||||
}
|
||||
|
||||
class BatchArchiveDto extends BatchContactIdsDto {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
archive?: boolean = true;
|
||||
}
|
||||
|
||||
class BatchFavoriteDto extends BatchContactIdsDto {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
favorite?: boolean = true;
|
||||
}
|
||||
|
||||
class BatchTagsDto extends BatchContactIdsDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMinSize(1)
|
||||
tagIds: string[];
|
||||
}
|
||||
|
||||
@Controller('batch')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class BatchController {
|
||||
constructor(private readonly batchService: BatchService) {}
|
||||
|
||||
@Post('delete')
|
||||
async deleteMany(@CurrentUser() user: CurrentUserData, @Body() dto: BatchContactIdsDto) {
|
||||
const result = await this.batchService.deleteMany(dto.contactIds, user.userId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('archive')
|
||||
async archiveMany(@CurrentUser() user: CurrentUserData, @Body() dto: BatchArchiveDto) {
|
||||
const result = await this.batchService.archiveMany(
|
||||
dto.contactIds,
|
||||
user.userId,
|
||||
dto.archive ?? true
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('favorite')
|
||||
async favoriteMany(@CurrentUser() user: CurrentUserData, @Body() dto: BatchFavoriteDto) {
|
||||
const result = await this.batchService.favoriteMany(
|
||||
dto.contactIds,
|
||||
user.userId,
|
||||
dto.favorite ?? true
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('add-tags')
|
||||
async addTags(@CurrentUser() user: CurrentUserData, @Body() dto: BatchTagsDto) {
|
||||
const result = await this.batchService.addTags(dto.contactIds, dto.tagIds, user.userId);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
12
apps/contacts/apps/backend/src/batch/batch.module.ts
Normal file
12
apps/contacts/apps/backend/src/batch/batch.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { BatchController } from './batch.controller';
|
||||
import { BatchService } from './batch.service';
|
||||
import { DatabaseModule } from '../db/database.module';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [BatchController],
|
||||
providers: [BatchService],
|
||||
exports: [BatchService],
|
||||
})
|
||||
export class BatchModule {}
|
||||
155
apps/contacts/apps/backend/src/batch/batch.service.ts
Normal file
155
apps/contacts/apps/backend/src/batch/batch.service.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { Injectable, Inject, BadRequestException } from '@nestjs/common';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { contacts, contactToTags } from '../db/schema';
|
||||
import type { Contact } from '../db/schema';
|
||||
|
||||
export interface BatchResult {
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BatchService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
/**
|
||||
* Delete multiple contacts
|
||||
*/
|
||||
async deleteMany(contactIds: string[], userId: string): Promise<BatchResult> {
|
||||
if (contactIds.length === 0) {
|
||||
throw new BadRequestException('No contacts specified');
|
||||
}
|
||||
|
||||
const result: BatchResult = { success: 0, failed: 0, errors: [] };
|
||||
|
||||
try {
|
||||
// Delete in a single query
|
||||
const deleted = await this.db
|
||||
.delete(contacts)
|
||||
.where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds)))
|
||||
.returning();
|
||||
|
||||
result.success = deleted.length;
|
||||
result.failed = contactIds.length - deleted.length;
|
||||
|
||||
if (result.failed > 0) {
|
||||
result.errors.push(
|
||||
`${result.failed} contacts could not be deleted (not found or no permission)`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
result.failed = contactIds.length;
|
||||
result.errors.push(e instanceof Error ? e.message : 'Delete failed');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive multiple contacts
|
||||
*/
|
||||
async archiveMany(contactIds: string[], userId: string, archive = true): Promise<BatchResult> {
|
||||
if (contactIds.length === 0) {
|
||||
throw new BadRequestException('No contacts specified');
|
||||
}
|
||||
|
||||
const result: BatchResult = { success: 0, failed: 0, errors: [] };
|
||||
|
||||
try {
|
||||
const updated = await this.db
|
||||
.update(contacts)
|
||||
.set({ isArchived: archive, updatedAt: new Date() })
|
||||
.where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds)))
|
||||
.returning();
|
||||
|
||||
result.success = updated.length;
|
||||
result.failed = contactIds.length - updated.length;
|
||||
|
||||
if (result.failed > 0) {
|
||||
result.errors.push(
|
||||
`${result.failed} contacts could not be ${archive ? 'archived' : 'unarchived'}`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
result.failed = contactIds.length;
|
||||
result.errors.push(e instanceof Error ? e.message : 'Archive operation failed');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set favorite status for multiple contacts
|
||||
*/
|
||||
async favoriteMany(contactIds: string[], userId: string, favorite = true): Promise<BatchResult> {
|
||||
if (contactIds.length === 0) {
|
||||
throw new BadRequestException('No contacts specified');
|
||||
}
|
||||
|
||||
const result: BatchResult = { success: 0, failed: 0, errors: [] };
|
||||
|
||||
try {
|
||||
const updated = await this.db
|
||||
.update(contacts)
|
||||
.set({ isFavorite: favorite, updatedAt: new Date() })
|
||||
.where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds)))
|
||||
.returning();
|
||||
|
||||
result.success = updated.length;
|
||||
result.failed = contactIds.length - updated.length;
|
||||
|
||||
if (result.failed > 0) {
|
||||
result.errors.push(`${result.failed} contacts could not be updated`);
|
||||
}
|
||||
} catch (e) {
|
||||
result.failed = contactIds.length;
|
||||
result.errors.push(e instanceof Error ? e.message : 'Favorite operation failed');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add tags to multiple contacts
|
||||
*/
|
||||
async addTags(contactIds: string[], tagIds: string[], userId: string): Promise<BatchResult> {
|
||||
if (contactIds.length === 0) {
|
||||
throw new BadRequestException('No contacts specified');
|
||||
}
|
||||
|
||||
const result: BatchResult = { success: 0, failed: 0, errors: [] };
|
||||
|
||||
// Verify contacts belong to user
|
||||
const validContacts = await this.db
|
||||
.select({ id: contacts.id })
|
||||
.from(contacts)
|
||||
.where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds)));
|
||||
|
||||
const validIds = new Set(validContacts.map((c) => c.id));
|
||||
|
||||
for (const contactId of contactIds) {
|
||||
if (!validIds.has(contactId)) {
|
||||
result.failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const tagId of tagIds) {
|
||||
try {
|
||||
await this.db.insert(contactToTags).values({ contactId, tagId }).onConflictDoNothing();
|
||||
} catch {
|
||||
// Ignore individual tag failures
|
||||
}
|
||||
}
|
||||
result.success++;
|
||||
}
|
||||
|
||||
if (result.failed > 0) {
|
||||
result.errors.push(`${result.failed} contacts could not be tagged`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -143,10 +143,6 @@ class ContactQueryDto {
|
|||
@Transform(({ value }) => value === 'true')
|
||||
isArchived?: boolean;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
groupId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
tagId?: string;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ export interface ContactFilters {
|
|||
search?: string;
|
||||
isFavorite?: boolean;
|
||||
isArchived?: boolean;
|
||||
groupId?: string;
|
||||
tagId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
|
|
@ -22,23 +21,59 @@ export class ContactService {
|
|||
async findByUserId(userId: string, filters: ContactFilters = {}): Promise<Contact[]> {
|
||||
const { search, isFavorite, isArchived = false, limit = 50, offset = 0 } = filters;
|
||||
|
||||
let query = this.db
|
||||
// When searching, use relevance-based sorting (name matches first, then company/email)
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase();
|
||||
const query = this.db
|
||||
.select({
|
||||
contact: contacts,
|
||||
// Relevance score: name matches get higher priority than company/email
|
||||
relevance: sql<number>`
|
||||
CASE
|
||||
WHEN LOWER(${contacts.firstName}) LIKE ${`${searchLower}%`} THEN 100
|
||||
WHEN LOWER(${contacts.lastName}) LIKE ${`${searchLower}%`} THEN 100
|
||||
WHEN LOWER(${contacts.displayName}) LIKE ${`${searchLower}%`} THEN 90
|
||||
WHEN LOWER(${contacts.firstName}) LIKE ${`%${searchLower}%`} THEN 80
|
||||
WHEN LOWER(${contacts.lastName}) LIKE ${`%${searchLower}%`} THEN 80
|
||||
WHEN LOWER(${contacts.displayName}) LIKE ${`%${searchLower}%`} THEN 70
|
||||
WHEN LOWER(${contacts.email}) LIKE ${`%${searchLower}%`} THEN 50
|
||||
WHEN LOWER(${contacts.company}) LIKE ${`%${searchLower}%`} THEN 40
|
||||
ELSE 0
|
||||
END
|
||||
`.as('relevance'),
|
||||
})
|
||||
.from(contacts)
|
||||
.where(
|
||||
and(
|
||||
eq(contacts.userId, userId),
|
||||
eq(contacts.isArchived, isArchived),
|
||||
isFavorite !== undefined ? eq(contacts.isFavorite, isFavorite) : undefined,
|
||||
or(
|
||||
ilike(contacts.firstName, `%${search}%`),
|
||||
ilike(contacts.lastName, `%${search}%`),
|
||||
ilike(contacts.displayName, `%${search}%`),
|
||||
ilike(contacts.email, `%${search}%`),
|
||||
ilike(contacts.company, `%${search}%`)
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(sql`relevance DESC`, desc(contacts.updatedAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const results = await query;
|
||||
return results.map((r) => r.contact);
|
||||
}
|
||||
|
||||
// Without search, just order by updatedAt
|
||||
const 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
|
||||
isFavorite !== undefined ? eq(contacts.isFavorite, isFavorite) : undefined
|
||||
)
|
||||
)
|
||||
.orderBy(desc(contacts.updatedAt))
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
export * from './contacts.schema';
|
||||
export * from './groups.schema';
|
||||
export * from './tags.schema';
|
||||
export * from './notes.schema';
|
||||
export * from './activities.schema';
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ import { contacts } from './schema';
|
|||
const DATABASE_URL =
|
||||
process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/contacts';
|
||||
|
||||
// User ID - can be set via environment variable or defaults to test user
|
||||
const USER_ID = process.env.SEED_USER_ID || 'seed-user-001';
|
||||
// User ID - can be set via environment variable or defaults to dev user
|
||||
const USER_ID =
|
||||
process.env.SEED_USER_ID || process.env.DEV_USER_ID || '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
interface SeedContact {
|
||||
firstName: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { DuplicatesService } from './duplicates.service';
|
||||
import { IsArray, IsString, ArrayMinSize } from 'class-validator';
|
||||
|
||||
class MergeContactsDto {
|
||||
@IsString()
|
||||
primaryId: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMinSize(1)
|
||||
mergeIds: string[];
|
||||
}
|
||||
|
||||
@Controller('duplicates')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DuplicatesController {
|
||||
constructor(private readonly duplicatesService: DuplicatesService) {}
|
||||
|
||||
@Get()
|
||||
async findDuplicates(@CurrentUser() user: CurrentUserData) {
|
||||
const duplicates = await this.duplicatesService.findDuplicates(user.userId);
|
||||
return { duplicates, total: duplicates.length };
|
||||
}
|
||||
|
||||
@Post('merge')
|
||||
async mergeContacts(@CurrentUser() user: CurrentUserData, @Body() dto: MergeContactsDto) {
|
||||
const result = await this.duplicatesService.mergeContacts(
|
||||
dto.primaryId,
|
||||
dto.mergeIds,
|
||||
user.userId
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':groupId/dismiss')
|
||||
async dismissDuplicate(@CurrentUser() user: CurrentUserData, @Param('groupId') groupId: string) {
|
||||
await this.duplicatesService.dismissDuplicate(groupId, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { DuplicatesController } from './duplicates.controller';
|
||||
import { DuplicatesService } from './duplicates.service';
|
||||
import { DatabaseModule } from '../db/database.module';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [DuplicatesController],
|
||||
providers: [DuplicatesService],
|
||||
exports: [DuplicatesService],
|
||||
})
|
||||
export class DuplicatesModule {}
|
||||
255
apps/contacts/apps/backend/src/duplicates/duplicates.service.ts
Normal file
255
apps/contacts/apps/backend/src/duplicates/duplicates.service.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, or, ne, sql } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { contacts } from '../db/schema';
|
||||
import type { Contact } from '../db/schema';
|
||||
|
||||
export interface DuplicateGroup {
|
||||
id: string;
|
||||
contacts: Contact[];
|
||||
matchType: 'email' | 'phone' | 'name';
|
||||
matchValue: string;
|
||||
}
|
||||
|
||||
export interface MergeResult {
|
||||
mergedContact: Contact;
|
||||
deletedIds: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DuplicatesService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
/**
|
||||
* Find all potential duplicate groups for a user
|
||||
*/
|
||||
async findDuplicates(userId: string): Promise<DuplicateGroup[]> {
|
||||
const duplicateGroups: DuplicateGroup[] = [];
|
||||
|
||||
// Get all contacts for this user
|
||||
const allContacts = await this.db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(and(eq(contacts.userId, userId), eq(contacts.isArchived, false)));
|
||||
|
||||
// Build lookup maps
|
||||
const emailMap = new Map<string, Contact[]>();
|
||||
const phoneMap = new Map<string, Contact[]>();
|
||||
const nameMap = new Map<string, Contact[]>();
|
||||
const processedIds = new Set<string>();
|
||||
|
||||
for (const contact of allContacts) {
|
||||
// Group by email
|
||||
if (contact.email) {
|
||||
const normalizedEmail = this.normalizeEmail(contact.email);
|
||||
if (!emailMap.has(normalizedEmail)) {
|
||||
emailMap.set(normalizedEmail, []);
|
||||
}
|
||||
emailMap.get(normalizedEmail)!.push(contact);
|
||||
}
|
||||
|
||||
// Group by phone (check both phone and mobile)
|
||||
for (const phone of [contact.phone, contact.mobile].filter(Boolean) as string[]) {
|
||||
const normalizedPhone = this.normalizePhone(phone);
|
||||
if (normalizedPhone.length >= 6) {
|
||||
if (!phoneMap.has(normalizedPhone)) {
|
||||
phoneMap.set(normalizedPhone, []);
|
||||
}
|
||||
const existing = phoneMap.get(normalizedPhone)!;
|
||||
if (!existing.some((c) => c.id === contact.id)) {
|
||||
existing.push(contact);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group by name (first + last)
|
||||
if (contact.firstName && contact.lastName) {
|
||||
const normalizedName = this.normalizeName(contact.firstName, contact.lastName);
|
||||
if (!nameMap.has(normalizedName)) {
|
||||
nameMap.set(normalizedName, []);
|
||||
}
|
||||
nameMap.get(normalizedName)!.push(contact);
|
||||
}
|
||||
}
|
||||
|
||||
// Create duplicate groups from email matches
|
||||
for (const [email, contactList] of emailMap) {
|
||||
if (contactList.length > 1) {
|
||||
const ids = contactList
|
||||
.map((c) => c.id)
|
||||
.sort()
|
||||
.join('-');
|
||||
if (!processedIds.has(ids)) {
|
||||
processedIds.add(ids);
|
||||
duplicateGroups.push({
|
||||
id: `email-${ids}`,
|
||||
contacts: contactList,
|
||||
matchType: 'email',
|
||||
matchValue: email,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create duplicate groups from phone matches
|
||||
for (const [phone, contactList] of phoneMap) {
|
||||
if (contactList.length > 1) {
|
||||
const ids = contactList
|
||||
.map((c) => c.id)
|
||||
.sort()
|
||||
.join('-');
|
||||
if (!processedIds.has(ids)) {
|
||||
processedIds.add(ids);
|
||||
duplicateGroups.push({
|
||||
id: `phone-${ids}`,
|
||||
contacts: contactList,
|
||||
matchType: 'phone',
|
||||
matchValue: phone,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create duplicate groups from name matches (only if not already matched by email/phone)
|
||||
for (const [name, contactList] of nameMap) {
|
||||
if (contactList.length > 1) {
|
||||
const ids = contactList
|
||||
.map((c) => c.id)
|
||||
.sort()
|
||||
.join('-');
|
||||
if (!processedIds.has(ids)) {
|
||||
processedIds.add(ids);
|
||||
duplicateGroups.push({
|
||||
id: `name-${ids}`,
|
||||
contacts: contactList,
|
||||
matchType: 'name',
|
||||
matchValue: name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return duplicateGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge multiple contacts into one
|
||||
* @param primaryId - The contact to keep (will be updated with merged data)
|
||||
* @param mergeIds - The contacts to merge into primary (will be deleted)
|
||||
* @param userId - User ID for authorization
|
||||
*/
|
||||
async mergeContacts(primaryId: string, mergeIds: string[], userId: string): Promise<MergeResult> {
|
||||
// Get the primary contact
|
||||
const [primaryContact] = await this.db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(and(eq(contacts.id, primaryId), eq(contacts.userId, userId)));
|
||||
|
||||
if (!primaryContact) {
|
||||
throw new NotFoundException('Primary contact not found');
|
||||
}
|
||||
|
||||
// Get contacts to merge
|
||||
const contactsToMerge = await this.db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(and(eq(contacts.userId, userId), or(...mergeIds.map((id) => eq(contacts.id, id)))));
|
||||
|
||||
if (contactsToMerge.length !== mergeIds.length) {
|
||||
throw new NotFoundException('One or more contacts to merge not found');
|
||||
}
|
||||
|
||||
// Merge data - fill empty fields from other contacts
|
||||
const mergedData = this.mergeContactData(primaryContact, contactsToMerge);
|
||||
|
||||
// Update primary contact with merged data
|
||||
const [updatedContact] = await this.db
|
||||
.update(contacts)
|
||||
.set({ ...mergedData, updatedAt: new Date() })
|
||||
.where(eq(contacts.id, primaryId))
|
||||
.returning();
|
||||
|
||||
// Delete merged contacts
|
||||
await this.db
|
||||
.delete(contacts)
|
||||
.where(and(eq(contacts.userId, userId), or(...mergeIds.map((id) => eq(contacts.id, id)))));
|
||||
|
||||
return {
|
||||
mergedContact: updatedContact,
|
||||
deletedIds: mergeIds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a duplicate group (mark as not duplicates)
|
||||
* This could be extended to store dismissals in a separate table
|
||||
*/
|
||||
async dismissDuplicate(groupId: string, userId: string): Promise<void> {
|
||||
// For now, this is a no-op
|
||||
// In a full implementation, you'd store this in a `dismissed_duplicates` table
|
||||
// to avoid showing the same group again
|
||||
}
|
||||
|
||||
private mergeContactData(primary: Contact, others: Contact[]): Partial<Contact> {
|
||||
const updates: Partial<Contact> = {};
|
||||
const allContacts = [primary, ...others];
|
||||
|
||||
// Helper to get first non-empty value
|
||||
const getFirst = <K extends keyof Contact>(field: K): Contact[K] | undefined => {
|
||||
for (const contact of allContacts) {
|
||||
if (contact[field]) return contact[field];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Only update fields that are empty in primary
|
||||
if (!primary.firstName) updates.firstName = getFirst('firstName');
|
||||
if (!primary.lastName) updates.lastName = getFirst('lastName');
|
||||
if (!primary.displayName) updates.displayName = getFirst('displayName');
|
||||
if (!primary.nickname) updates.nickname = getFirst('nickname');
|
||||
if (!primary.email) updates.email = getFirst('email');
|
||||
if (!primary.phone) updates.phone = getFirst('phone');
|
||||
if (!primary.mobile) updates.mobile = getFirst('mobile');
|
||||
if (!primary.street) updates.street = getFirst('street');
|
||||
if (!primary.city) updates.city = getFirst('city');
|
||||
if (!primary.postalCode) updates.postalCode = getFirst('postalCode');
|
||||
if (!primary.country) updates.country = getFirst('country');
|
||||
if (!primary.company) updates.company = getFirst('company');
|
||||
if (!primary.jobTitle) updates.jobTitle = getFirst('jobTitle');
|
||||
if (!primary.department) updates.department = getFirst('department');
|
||||
if (!primary.website) updates.website = getFirst('website');
|
||||
if (!primary.birthday) updates.birthday = getFirst('birthday');
|
||||
if (!primary.photoUrl) updates.photoUrl = getFirst('photoUrl');
|
||||
|
||||
// Merge notes (concatenate if both have notes)
|
||||
const allNotes = allContacts
|
||||
.map((c) => c.notes)
|
||||
.filter(Boolean)
|
||||
.join('\n\n---\n\n');
|
||||
if (allNotes && allNotes !== primary.notes) {
|
||||
updates.notes = allNotes;
|
||||
}
|
||||
|
||||
// Keep favorite if any contact is favorite
|
||||
if (others.some((c) => c.isFavorite)) {
|
||||
updates.isFavorite = true;
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
private normalizeEmail(email: string): string {
|
||||
return email.toLowerCase().trim();
|
||||
}
|
||||
|
||||
private normalizePhone(phone: string): string {
|
||||
const hasPlus = phone.startsWith('+');
|
||||
const digits = phone.replace(/\D/g, '');
|
||||
return hasPlus ? '+' + digits : digits;
|
||||
}
|
||||
|
||||
private normalizeName(firstName: string, lastName: string): string {
|
||||
return `${firstName.toLowerCase().trim()} ${lastName.toLowerCase().trim()}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -11,10 +11,6 @@ export class ExportRequestDto {
|
|||
@IsUUID('4', { each: true })
|
||||
contactIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
groupId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
tagId?: string;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { eq, and, inArray } from 'drizzle-orm';
|
|||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { contacts, type Contact } from '../db/schema';
|
||||
import { contactToGroups, contactToTags } from '../db/schema';
|
||||
import { contactToTags } from '../db/schema';
|
||||
import { ExportRequestDto, ExportFormat } from './dto/export.dto';
|
||||
import { generateVCardFile } from './generators/vcard.generator';
|
||||
import { generateCsvFile } from './generators/csv.generator';
|
||||
|
|
@ -48,7 +48,7 @@ export class ExportService {
|
|||
userId: string,
|
||||
options: ExportRequestDto
|
||||
): Promise<Contact[]> {
|
||||
const { contactIds, groupId, tagId, includeFavorites, includeArchived = false } = options;
|
||||
const { contactIds, tagId, includeFavorites, includeArchived = false } = options;
|
||||
|
||||
// If specific contact IDs are provided, fetch those
|
||||
if (contactIds && contactIds.length > 0) {
|
||||
|
|
@ -58,25 +58,6 @@ export class ExportService {
|
|||
.where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds)));
|
||||
}
|
||||
|
||||
// If a group is specified, get contacts in that group
|
||||
if (groupId) {
|
||||
const groupContacts = await this.db
|
||||
.select({ contactId: contactToGroups.contactId })
|
||||
.from(contactToGroups)
|
||||
.where(eq(contactToGroups.groupId, groupId));
|
||||
|
||||
const contactIdsInGroup = groupContacts.map((gc) => gc.contactId);
|
||||
|
||||
if (contactIdsInGroup.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIdsInGroup)));
|
||||
}
|
||||
|
||||
// If a tag is specified, get contacts with that tag
|
||||
if (tagId) {
|
||||
const taggedContacts = await this.db
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
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 {}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { 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);
|
||||
}
|
||||
}
|
||||
24
apps/contacts/apps/backend/src/network/network.controller.ts
Normal file
24
apps/contacts/apps/backend/src/network/network.controller.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Controller, Get, UseGuards, Query } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { NetworkService } from './network.service';
|
||||
import { IsString, IsOptional, IsIn } from 'class-validator';
|
||||
|
||||
class NetworkQueryDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsIn(['tags'])
|
||||
type?: 'tags';
|
||||
}
|
||||
|
||||
@Controller('network')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class NetworkController {
|
||||
constructor(private readonly networkService: NetworkService) {}
|
||||
|
||||
@Get('graph')
|
||||
async getGraph(@CurrentUser() user: CurrentUserData, @Query() query: NetworkQueryDto) {
|
||||
// Currently only tag-based graph is supported (MVP)
|
||||
const graph = await this.networkService.getTagBasedGraph(user.userId);
|
||||
return graph;
|
||||
}
|
||||
}
|
||||
10
apps/contacts/apps/backend/src/network/network.module.ts
Normal file
10
apps/contacts/apps/backend/src/network/network.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { NetworkController } from './network.controller';
|
||||
import { NetworkService } from './network.service';
|
||||
|
||||
@Module({
|
||||
controllers: [NetworkController],
|
||||
providers: [NetworkService],
|
||||
exports: [NetworkService],
|
||||
})
|
||||
export class NetworkModule {}
|
||||
151
apps/contacts/apps/backend/src/network/network.service.ts
Normal file
151
apps/contacts/apps/backend/src/network/network.service.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { contacts, contactTags, contactToTags } from '../db/schema';
|
||||
|
||||
export interface NetworkNode {
|
||||
id: string;
|
||||
name: string;
|
||||
photoUrl: string | null;
|
||||
company: string | null;
|
||||
isFavorite: boolean;
|
||||
tags: { id: string; name: string; color: string | null }[];
|
||||
connectionCount: number;
|
||||
}
|
||||
|
||||
export interface NetworkLink {
|
||||
source: string;
|
||||
target: string;
|
||||
type: 'tag';
|
||||
strength: number;
|
||||
sharedTags: string[];
|
||||
}
|
||||
|
||||
export interface NetworkGraphResponse {
|
||||
nodes: NetworkNode[];
|
||||
links: NetworkLink[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NetworkService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async getTagBasedGraph(userId: string): Promise<NetworkGraphResponse> {
|
||||
// 1. Get all contacts for the user (excluding archived)
|
||||
const userContacts = await this.db
|
||||
.select({
|
||||
id: contacts.id,
|
||||
firstName: contacts.firstName,
|
||||
lastName: contacts.lastName,
|
||||
displayName: contacts.displayName,
|
||||
photoUrl: contacts.photoUrl,
|
||||
company: contacts.company,
|
||||
isFavorite: contacts.isFavorite,
|
||||
})
|
||||
.from(contacts)
|
||||
.where(eq(contacts.userId, userId));
|
||||
|
||||
if (userContacts.length === 0) {
|
||||
return { nodes: [], links: [] };
|
||||
}
|
||||
|
||||
// 2. Get all tags for the user
|
||||
const userTags = await this.db.select().from(contactTags).where(eq(contactTags.userId, userId));
|
||||
|
||||
const tagMap = new Map(userTags.map((t) => [t.id, t]));
|
||||
|
||||
// 3. Get all contact-tag associations
|
||||
const contactTagAssociations = await this.db
|
||||
.select({
|
||||
contactId: contactToTags.contactId,
|
||||
tagId: contactToTags.tagId,
|
||||
})
|
||||
.from(contactToTags)
|
||||
.innerJoin(contacts, eq(contactToTags.contactId, contacts.id))
|
||||
.where(eq(contacts.userId, userId));
|
||||
|
||||
// 4. Build contact -> tags mapping
|
||||
const contactTagsMap = new Map<string, string[]>();
|
||||
for (const assoc of contactTagAssociations) {
|
||||
const existing = contactTagsMap.get(assoc.contactId) || [];
|
||||
existing.push(assoc.tagId);
|
||||
contactTagsMap.set(assoc.contactId, existing);
|
||||
}
|
||||
|
||||
// 5. Build nodes
|
||||
const nodes: NetworkNode[] = userContacts.map((contact) => {
|
||||
const tagIds = contactTagsMap.get(contact.id) || [];
|
||||
const tags = tagIds
|
||||
.map((tagId) => {
|
||||
const tag = tagMap.get(tagId);
|
||||
return tag ? { id: tag.id, name: tag.name, color: tag.color } : null;
|
||||
})
|
||||
.filter((t): t is { id: string; name: string; color: string | null } => t !== null);
|
||||
|
||||
return {
|
||||
id: contact.id,
|
||||
name: this.getDisplayName(contact),
|
||||
photoUrl: contact.photoUrl,
|
||||
company: contact.company,
|
||||
isFavorite: contact.isFavorite ?? false,
|
||||
tags,
|
||||
connectionCount: 0, // Will be calculated after links
|
||||
};
|
||||
});
|
||||
|
||||
// 6. Build links based on shared tags
|
||||
const links: NetworkLink[] = [];
|
||||
const connectionCounts = new Map<string, number>();
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const nodeA = nodes[i];
|
||||
const nodeB = nodes[j];
|
||||
|
||||
const tagsA = new Set(nodeA.tags.map((t) => t.id));
|
||||
const tagsB = new Set(nodeB.tags.map((t) => t.id));
|
||||
|
||||
const sharedTagIds = [...tagsA].filter((tagId) => tagsB.has(tagId));
|
||||
|
||||
if (sharedTagIds.length > 0) {
|
||||
const sharedTagNames = sharedTagIds
|
||||
.map((tagId) => tagMap.get(tagId)?.name)
|
||||
.filter((name): name is string => !!name);
|
||||
|
||||
// Strength based on number of shared tags (max 100)
|
||||
const strength = Math.min(sharedTagIds.length * 25, 100);
|
||||
|
||||
links.push({
|
||||
source: nodeA.id,
|
||||
target: nodeB.id,
|
||||
type: 'tag',
|
||||
strength,
|
||||
sharedTags: sharedTagNames,
|
||||
});
|
||||
|
||||
// Count connections
|
||||
connectionCounts.set(nodeA.id, (connectionCounts.get(nodeA.id) || 0) + 1);
|
||||
connectionCounts.set(nodeB.id, (connectionCounts.get(nodeB.id) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Update connection counts on nodes
|
||||
for (const node of nodes) {
|
||||
node.connectionCount = connectionCounts.get(node.id) || 0;
|
||||
}
|
||||
|
||||
return { nodes, links };
|
||||
}
|
||||
|
||||
private getDisplayName(contact: {
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
displayName: string | null;
|
||||
}): string {
|
||||
if (contact.displayName) return contact.displayName;
|
||||
const parts = [contact.firstName, contact.lastName].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(' ') : 'Unbekannt';
|
||||
}
|
||||
}
|
||||
42
apps/contacts/apps/backend/src/photo/photo.controller.ts
Normal file
42
apps/contacts/apps/backend/src/photo/photo.controller.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Delete,
|
||||
Param,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { PhotoService } from './photo.service';
|
||||
|
||||
@Controller('contacts')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PhotoController {
|
||||
constructor(private readonly photoService: PhotoService) {}
|
||||
|
||||
@Post(':id/photo')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('photo', {
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024, // 5MB
|
||||
},
|
||||
})
|
||||
)
|
||||
async uploadPhoto(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File
|
||||
) {
|
||||
const result = await this.photoService.uploadPhoto(id, user.userId, file);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id/photo')
|
||||
async deletePhoto(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.photoService.deletePhoto(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
19
apps/contacts/apps/backend/src/photo/photo.module.ts
Normal file
19
apps/contacts/apps/backend/src/photo/photo.module.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { PhotoController } from './photo.controller';
|
||||
import { PhotoService } from './photo.service';
|
||||
import { DatabaseModule } from '../db/database.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
DatabaseModule,
|
||||
MulterModule.register({
|
||||
storage: memoryStorage(),
|
||||
}),
|
||||
],
|
||||
controllers: [PhotoController],
|
||||
providers: [PhotoService],
|
||||
exports: [PhotoService],
|
||||
})
|
||||
export class PhotoModule {}
|
||||
132
apps/contacts/apps/backend/src/photo/photo.service.ts
Normal file
132
apps/contacts/apps/backend/src/photo/photo.service.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { Injectable, Inject, BadRequestException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { contacts } from '../db/schema';
|
||||
import {
|
||||
createContactsStorage,
|
||||
generateUserFileKey,
|
||||
getContentType,
|
||||
validateFileSize,
|
||||
validateFileExtension,
|
||||
IMAGE_EXTENSIONS,
|
||||
} from '@manacore/shared-storage';
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
@Injectable()
|
||||
export class PhotoService {
|
||||
private storage = createContactsStorage();
|
||||
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
/**
|
||||
* Upload a photo for a contact
|
||||
*/
|
||||
async uploadPhoto(
|
||||
contactId: string,
|
||||
userId: string,
|
||||
file: Express.Multer.File
|
||||
): Promise<{ photoUrl: string }> {
|
||||
// Validate file
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file provided');
|
||||
}
|
||||
|
||||
if (!validateFileSize(file.size, MAX_FILE_SIZE)) {
|
||||
throw new BadRequestException(`File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit`);
|
||||
}
|
||||
|
||||
// validateFileExtension expects a filename, not just the extension
|
||||
if (!validateFileExtension(file.originalname, IMAGE_EXTENSIONS)) {
|
||||
throw new BadRequestException(`Invalid file type. Allowed: ${IMAGE_EXTENSIONS.join(', ')}`);
|
||||
}
|
||||
|
||||
const extension = file.originalname.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
// Verify contact belongs to user
|
||||
const [contact] = await this.db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(and(eq(contacts.id, contactId), eq(contacts.userId, userId)));
|
||||
|
||||
if (!contact) {
|
||||
throw new BadRequestException('Contact not found');
|
||||
}
|
||||
|
||||
// Delete old photo if exists
|
||||
if (contact.photoUrl) {
|
||||
try {
|
||||
const oldKey = this.extractKeyFromUrl(contact.photoUrl);
|
||||
if (oldKey) {
|
||||
await this.storage.delete(oldKey);
|
||||
}
|
||||
} catch {
|
||||
// Ignore deletion errors
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique key for the new photo
|
||||
const filename = `${contactId}.${extension}`;
|
||||
const key = generateUserFileKey(userId, filename);
|
||||
|
||||
// Upload to S3
|
||||
const contentType = getContentType(filename);
|
||||
await this.storage.upload(key, file.buffer, {
|
||||
contentType,
|
||||
public: true,
|
||||
});
|
||||
|
||||
// Generate the URL (for MinIO, construct it manually)
|
||||
const photoUrl = `http://localhost:9000/contacts-storage/${key}`;
|
||||
|
||||
// Update contact with photo URL
|
||||
await this.db
|
||||
.update(contacts)
|
||||
.set({ photoUrl, updatedAt: new Date() })
|
||||
.where(eq(contacts.id, contactId));
|
||||
|
||||
return { photoUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete photo for a contact
|
||||
*/
|
||||
async deletePhoto(contactId: string, userId: string): Promise<void> {
|
||||
// Get contact
|
||||
const [contact] = await this.db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(and(eq(contacts.id, contactId), eq(contacts.userId, userId)));
|
||||
|
||||
if (!contact) {
|
||||
throw new BadRequestException('Contact not found');
|
||||
}
|
||||
|
||||
if (!contact.photoUrl) {
|
||||
return; // No photo to delete
|
||||
}
|
||||
|
||||
// Delete from S3
|
||||
try {
|
||||
const key = this.extractKeyFromUrl(contact.photoUrl);
|
||||
if (key) {
|
||||
await this.storage.delete(key);
|
||||
}
|
||||
} catch {
|
||||
// Ignore deletion errors
|
||||
}
|
||||
|
||||
// Update contact to remove photo URL
|
||||
await this.db
|
||||
.update(contacts)
|
||||
.set({ photoUrl: null, updatedAt: new Date() })
|
||||
.where(eq(contacts.id, contactId));
|
||||
}
|
||||
|
||||
private extractKeyFromUrl(url: string): string | null {
|
||||
// Extract key from URLs like http://localhost:9000/contacts-storage/users/xxx/file.jpg
|
||||
const match = url.match(/contacts-storage\/(.+)$/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -71,4 +71,43 @@ export class TagController {
|
|||
await this.tagService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/contacts/:contactId')
|
||||
async addToContact(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) tagId: string,
|
||||
@Param('contactId', ParseUUIDPipe) contactId: string
|
||||
) {
|
||||
// Verify tag belongs to user
|
||||
const tag = await this.tagService.findById(tagId, user.userId);
|
||||
if (!tag) {
|
||||
throw new Error('Tag not found');
|
||||
}
|
||||
await this.tagService.addTagToContact(contactId, tagId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete(':id/contacts/:contactId')
|
||||
async removeFromContact(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) tagId: string,
|
||||
@Param('contactId', ParseUUIDPipe) contactId: string
|
||||
) {
|
||||
// Verify tag belongs to user
|
||||
const tag = await this.tagService.findById(tagId, user.userId);
|
||||
if (!tag) {
|
||||
throw new Error('Tag not found');
|
||||
}
|
||||
await this.tagService.removeTagFromContact(contactId, tagId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Get('contact/:contactId')
|
||||
async getTagsForContact(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('contactId', ParseUUIDPipe) contactId: string
|
||||
) {
|
||||
const tagIds = await this.tagService.getTagsForContact(contactId);
|
||||
return { tagIds };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,36 @@ import { Database } from '../db/connection';
|
|||
import { contactTags, contactToTags } from '../db/schema';
|
||||
import type { ContactTag, NewContactTag } from '../db/schema';
|
||||
|
||||
const DEFAULT_TAGS = [
|
||||
{ name: 'Familie', color: '#ec4899' }, // pink
|
||||
{ name: 'Freunde', color: '#22c55e' }, // green
|
||||
{ name: 'Arbeit', color: '#3b82f6' }, // blue
|
||||
{ name: 'Wichtig', color: '#ef4444' }, // red
|
||||
] as const;
|
||||
|
||||
@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));
|
||||
const tags = await this.db.select().from(contactTags).where(eq(contactTags.userId, userId));
|
||||
|
||||
// Create default tags on first access (when user has no tags yet)
|
||||
if (tags.length === 0) {
|
||||
return this.createDefaultTags(userId);
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
private async createDefaultTags(userId: string): Promise<ContactTag[]> {
|
||||
const tagsToCreate = DEFAULT_TAGS.map((tag) => ({
|
||||
userId,
|
||||
name: tag.name,
|
||||
color: tag.color,
|
||||
}));
|
||||
|
||||
return this.db.insert(contactTags).values(tagsToCreate).returning();
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<ContactTag | null> {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@
|
|||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/d3-selection": "^3.0.11",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"@types/node": "^20.0.0",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
|
|
@ -28,10 +31,14 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-tags": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-feedback-service": "workspace:*",
|
||||
"@manacore/shared-feedback-ui": "workspace:*",
|
||||
"@manacore/shared-help-content": "workspace:*",
|
||||
"@manacore/shared-help-types": "workspace:*",
|
||||
"@manacore/shared-help-ui": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-profile-ui": "workspace:*",
|
||||
|
|
@ -41,6 +48,10 @@
|
|||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"d3-force": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"lucide-svelte": "^0.556.0",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
},
|
||||
"type": "module"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
@import "tailwindcss";
|
||||
@import "@manacore/shared-tailwind/themes.css";
|
||||
|
||||
/* Scan shared packages for Tailwind classes */
|
||||
@source "../../../packages/shared/src";
|
||||
@source "../../../../../packages/shared-ui/src";
|
||||
@source "../../../../../packages/shared-theme-ui/src";
|
||||
@source "../../../../../packages/shared-theme-ui/src/components";
|
||||
@source "../../../../../packages/shared-theme-ui/src/pages";
|
||||
|
||||
/* Contacts-specific CSS Variables */
|
||||
@layer base {
|
||||
:root {
|
||||
|
|
@ -61,8 +68,9 @@
|
|||
|
||||
/* Avatar styles */
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
min-width: 56px;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
|
|
@ -70,13 +78,13 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.avatar-lg {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
font-size: 2rem;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
|
|
|
|||
63
apps/contacts/apps/web/src/lib/api/batch.ts
Normal file
63
apps/contacts/apps/web/src/lib/api/batch.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { API_BASE } from './config';
|
||||
|
||||
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 BatchResult {
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export const batchApi = {
|
||||
async deleteMany(contactIds: string[]): Promise<BatchResult> {
|
||||
return fetchWithAuth('/batch/delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ contactIds }),
|
||||
});
|
||||
},
|
||||
|
||||
async archiveMany(contactIds: string[], archive = true): Promise<BatchResult> {
|
||||
return fetchWithAuth('/batch/archive', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ contactIds, archive }),
|
||||
});
|
||||
},
|
||||
|
||||
async favoriteMany(contactIds: string[], favorite = true): Promise<BatchResult> {
|
||||
return fetchWithAuth('/batch/favorite', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ contactIds, favorite }),
|
||||
});
|
||||
},
|
||||
|
||||
async addTags(contactIds: string[], tagIds: string[]): Promise<BatchResult> {
|
||||
return fetchWithAuth('/batch/add-tags', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ contactIds, tagIds }),
|
||||
});
|
||||
},
|
||||
};
|
||||
7
apps/contacts/apps/web/src/lib/api/config.ts
Normal file
7
apps/contacts/apps/web/src/lib/api/config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { PUBLIC_BACKEND_URL } from '$env/static/public';
|
||||
|
||||
/**
|
||||
* API Configuration
|
||||
* Uses environment variable PUBLIC_BACKEND_URL with fallback for development
|
||||
*/
|
||||
export const API_BASE = `${PUBLIC_BACKEND_URL || 'http://localhost:3015'}/api/v1`;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const API_BASE = 'http://localhost:3015/api/v1';
|
||||
import { API_BASE } from './config';
|
||||
import { createTagsClient, type Tag } from '@manacore/shared-tags';
|
||||
|
||||
async function fetchWithAuth(url: string, options: RequestInit = {}) {
|
||||
const token = await authStore.getAccessToken();
|
||||
|
|
@ -57,22 +58,8 @@ export interface Contact {
|
|||
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;
|
||||
}
|
||||
// Re-export Tag as ContactTag for backward compatibility
|
||||
export type ContactTag = Tag;
|
||||
|
||||
export interface ContactNote {
|
||||
id: string;
|
||||
|
|
@ -98,7 +85,6 @@ export interface ContactFilters {
|
|||
search?: string;
|
||||
isFavorite?: boolean;
|
||||
isArchived?: boolean;
|
||||
groupId?: string;
|
||||
tagId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
|
|
@ -111,7 +97,6 @@ export const contactsApi = {
|
|||
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));
|
||||
|
|
@ -162,75 +147,93 @@ export const contactsApi = {
|
|||
},
|
||||
};
|
||||
|
||||
// Groups API
|
||||
export const groupsApi = {
|
||||
async list() {
|
||||
return fetchWithAuth('/groups');
|
||||
},
|
||||
// Tags API - Uses central Tags API from mana-core-auth
|
||||
// Contact-tag associations still use the Contacts backend
|
||||
|
||||
async get(id: string) {
|
||||
return fetchWithAuth(`/groups/${id}`);
|
||||
},
|
||||
// Get auth URL dynamically at runtime
|
||||
function getAuthUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
|
||||
.__PUBLIC_MANA_CORE_AUTH_URL__;
|
||||
return injectedUrl || 'http://localhost:3001';
|
||||
}
|
||||
return 'http://localhost:3001';
|
||||
}
|
||||
|
||||
async create(data: { name: string; description?: string; color?: string }) {
|
||||
return fetchWithAuth('/groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
// Lazy-initialized tags client
|
||||
let _tagsClient: ReturnType<typeof createTagsClient> | null = null;
|
||||
|
||||
function getTagsClient() {
|
||||
if (!browser) return null;
|
||||
if (!_tagsClient) {
|
||||
_tagsClient = createTagsClient({
|
||||
authUrl: getAuthUrl(),
|
||||
getToken: async () => {
|
||||
const token = await authStore.getAccessToken();
|
||||
return token || '';
|
||||
},
|
||||
});
|
||||
},
|
||||
}
|
||||
return _tagsClient;
|
||||
}
|
||||
|
||||
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');
|
||||
// Get all tags from central Tags API
|
||||
async list(): Promise<{ tags: ContactTag[] }> {
|
||||
const client = getTagsClient();
|
||||
if (!client) return { tags: [] };
|
||||
const tags = await client.getAll();
|
||||
return { tags };
|
||||
},
|
||||
|
||||
async create(data: { name: string; color?: string }) {
|
||||
return fetchWithAuth('/tags', {
|
||||
// Create tag via central Tags API
|
||||
async create(data: { name: string; color?: string }): Promise<{ tag: ContactTag }> {
|
||||
const client = getTagsClient();
|
||||
if (!client) throw new Error('Tags client not available');
|
||||
const tag = await client.create(data);
|
||||
return { tag };
|
||||
},
|
||||
|
||||
// Update tag via central Tags API
|
||||
async update(id: string, data: { name?: string; color?: string }): Promise<{ tag: ContactTag }> {
|
||||
const client = getTagsClient();
|
||||
if (!client) throw new Error('Tags client not available');
|
||||
const tag = await client.update(id, data);
|
||||
return { tag };
|
||||
},
|
||||
|
||||
// Delete tag via central Tags API
|
||||
async delete(id: string): Promise<{ success: boolean }> {
|
||||
const client = getTagsClient();
|
||||
if (!client) throw new Error('Tags client not available');
|
||||
await client.delete(id);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
// Contact-tag associations still use Contacts backend
|
||||
async addToContact(tagId: string, contactId: string): Promise<{ success: boolean }> {
|
||||
return fetchWithAuth(`/tags/${tagId}/contacts/${contactId}`, {
|
||||
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}`, {
|
||||
async removeFromContact(tagId: string, contactId: string): Promise<{ success: boolean }> {
|
||||
return fetchWithAuth(`/tags/${tagId}/contacts/${contactId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
async getForContact(contactId: string): Promise<{ tagIds: string[] }> {
|
||||
return fetchWithAuth(`/tags/contact/${contactId}`);
|
||||
},
|
||||
|
||||
// Create default tags via central Tags API
|
||||
async createDefaults(): Promise<{ tags: ContactTag[] }> {
|
||||
const client = getTagsClient();
|
||||
if (!client) return { tags: [] };
|
||||
const tags = await client.createDefaults();
|
||||
return { tags };
|
||||
},
|
||||
};
|
||||
|
||||
// Notes API
|
||||
|
|
@ -287,3 +290,34 @@ export const activitiesApi = {
|
|||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Photo API
|
||||
export const photoApi = {
|
||||
async upload(contactId: string, file: File): Promise<{ photoUrl: string }> {
|
||||
const token = await authStore.getAccessToken();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('photo', file);
|
||||
|
||||
const response = await fetch(`${API_BASE}/contacts/${contactId}/photo`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Upload failed' }));
|
||||
throw new Error(error.message || 'Upload failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async delete(contactId: string): Promise<void> {
|
||||
await fetchWithAuth(`/contacts/${contactId}/photo`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
59
apps/contacts/apps/web/src/lib/api/duplicates.ts
Normal file
59
apps/contacts/apps/web/src/lib/api/duplicates.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { API_BASE } from './config';
|
||||
import type { Contact } from './contacts';
|
||||
|
||||
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 DuplicateGroup {
|
||||
id: string;
|
||||
contacts: Contact[];
|
||||
matchType: 'email' | 'phone' | 'name';
|
||||
matchValue: string;
|
||||
}
|
||||
|
||||
export interface MergeResult {
|
||||
mergedContact: Contact;
|
||||
deletedIds: string[];
|
||||
}
|
||||
|
||||
export const duplicatesApi = {
|
||||
async findDuplicates(): Promise<{ duplicates: DuplicateGroup[]; total: number }> {
|
||||
return fetchWithAuth('/duplicates');
|
||||
},
|
||||
|
||||
async mergeContacts(primaryId: string, mergeIds: string[]): Promise<MergeResult> {
|
||||
return fetchWithAuth('/duplicates/merge', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ primaryId, mergeIds }),
|
||||
});
|
||||
},
|
||||
|
||||
async dismissDuplicate(groupId: string): Promise<void> {
|
||||
await fetchWithAuth(`/duplicates/${groupId}/dismiss`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const API_BASE = 'http://localhost:3015/api/v1';
|
||||
import { API_BASE } from './config';
|
||||
|
||||
export type ExportFormat = 'vcard' | 'csv';
|
||||
|
||||
export interface ExportOptions {
|
||||
format: ExportFormat;
|
||||
contactIds?: string[];
|
||||
groupId?: string;
|
||||
tagId?: string;
|
||||
includeFavorites?: boolean;
|
||||
includeArchived?: boolean;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const API_BASE = 'http://localhost:3015/api/v1';
|
||||
import { API_BASE } from './config';
|
||||
|
||||
export interface GoogleContact {
|
||||
resourceName: string;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const API_BASE = 'http://localhost:3015/api/v1';
|
||||
import { API_BASE } from './config';
|
||||
|
||||
export interface ParsedContact {
|
||||
firstName?: string;
|
||||
|
|
|
|||
74
apps/contacts/apps/web/src/lib/api/network.ts
Normal file
74
apps/contacts/apps/web/src/lib/api/network.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { API_BASE } from './config';
|
||||
|
||||
async function fetchWithAuth(url: string, options: RequestInit = {}) {
|
||||
let token: string | null = null;
|
||||
try {
|
||||
token = await authStore.getAccessToken();
|
||||
console.log('[Network API] Got token:', token ? 'present' : 'missing');
|
||||
} catch (e) {
|
||||
console.error('[Network API] Error getting token:', e);
|
||||
}
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const fullUrl = `${API_BASE}${url}`;
|
||||
console.log('[Network API] Fetching:', fullUrl);
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
console.log('[Network API] Response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('[Network API] Error response:', errorText);
|
||||
let error: { message?: string } = { message: 'Request failed' };
|
||||
try {
|
||||
error = JSON.parse(errorText);
|
||||
} catch {
|
||||
error = { message: errorText || 'Request failed' };
|
||||
}
|
||||
throw new Error(error.message || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export interface NetworkNode {
|
||||
id: string;
|
||||
name: string;
|
||||
photoUrl: string | null;
|
||||
company: string | null;
|
||||
isFavorite: boolean;
|
||||
tags: { id: string; name: string; color: string | null }[];
|
||||
connectionCount: number;
|
||||
}
|
||||
|
||||
export interface NetworkLink {
|
||||
source: string;
|
||||
target: string;
|
||||
type: 'tag';
|
||||
strength: number;
|
||||
sharedTags: string[];
|
||||
}
|
||||
|
||||
export interface NetworkGraphResponse {
|
||||
nodes: NetworkNode[];
|
||||
links: NetworkLink[];
|
||||
}
|
||||
|
||||
export const networkApi = {
|
||||
async getGraph(): Promise<NetworkGraphResponse> {
|
||||
return fetchWithAuth('/network/graph');
|
||||
},
|
||||
};
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { contactsApi, type Contact } from '$lib/api/contacts';
|
||||
import { contactsApi, photoApi, type Contact } from '$lib/api/contacts';
|
||||
import ContactNotes from './ContactNotes.svelte';
|
||||
import { ContactDetailSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
interface Props {
|
||||
contactId: string;
|
||||
|
|
@ -16,6 +18,8 @@
|
|||
let editing = $state(false);
|
||||
let saving = $state(false);
|
||||
let deleting = $state(false);
|
||||
let uploadingPhoto = $state(false);
|
||||
let photoInput: HTMLInputElement;
|
||||
|
||||
// Edit form state
|
||||
let firstName = $state('');
|
||||
|
|
@ -136,6 +140,59 @@
|
|||
}
|
||||
}
|
||||
|
||||
function handlePhotoClick() {
|
||||
photoInput?.click();
|
||||
}
|
||||
|
||||
async function handlePhotoChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file || !contact) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
error = 'Bitte wähle eine Bilddatei aus';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
error = 'Das Bild darf maximal 5MB groß sein';
|
||||
return;
|
||||
}
|
||||
|
||||
uploadingPhoto = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await photoApi.upload(contactId, file);
|
||||
contact = { ...contact, photoUrl: result.photoUrl };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Hochladen';
|
||||
} finally {
|
||||
uploadingPhoto = false;
|
||||
// Reset input to allow re-selecting same file
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePhoto() {
|
||||
if (!contact?.photoUrl) return;
|
||||
if (!confirm('Foto wirklich entfernen?')) return;
|
||||
|
||||
uploadingPhoto = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
await photoApi.delete(contactId);
|
||||
contact = { ...contact, photoUrl: null };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Löschen';
|
||||
} finally {
|
||||
uploadingPhoto = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
|
|
@ -148,8 +205,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Reload contact when contactId changes
|
||||
$effect(() => {
|
||||
if (contactId) {
|
||||
loadContact();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
loadContact();
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
|
|
@ -220,25 +283,7 @@
|
|||
<!-- Modal Body -->
|
||||
<div class="modal-body">
|
||||
{#if loading}
|
||||
<div class="loading-container">
|
||||
<svg class="spinner-lg" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-opacity="0.25"
|
||||
/>
|
||||
<path
|
||||
d="M12 2a10 10 0 0 1 10 10"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<p class="loading-text">Lade Kontakt...</p>
|
||||
</div>
|
||||
<ContactDetailSkeleton />
|
||||
{:else if error && !contact}
|
||||
<div class="error-container">
|
||||
<div class="error-icon-wrapper">
|
||||
|
|
@ -477,12 +522,105 @@
|
|||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<!-- Hidden file input for photo upload -->
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
bind:this={photoInput}
|
||||
onchange={handlePhotoChange}
|
||||
class="hidden-input"
|
||||
/>
|
||||
|
||||
<!-- View Mode -->
|
||||
<div class="profile-header">
|
||||
<div class="avatar-wrapper">
|
||||
<div class="avatar-circle avatar-large">
|
||||
{initials()}
|
||||
</div>
|
||||
{#if contact.photoUrl}
|
||||
<img
|
||||
src={contact.photoUrl}
|
||||
alt={getDisplayName()}
|
||||
class="avatar-image avatar-large"
|
||||
/>
|
||||
<button
|
||||
onclick={handleDeletePhoto}
|
||||
disabled={uploadingPhoto}
|
||||
class="photo-delete-btn"
|
||||
aria-label="Foto entfernen"
|
||||
title="Foto entfernen"
|
||||
>
|
||||
{#if uploadingPhoto}
|
||||
<svg class="spinner-sm" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-opacity="0.25"
|
||||
/>
|
||||
<path
|
||||
d="M12 2a10 10 0 0 1 10 10"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={handlePhotoClick}
|
||||
disabled={uploadingPhoto}
|
||||
class="avatar-circle avatar-large avatar-upload-btn"
|
||||
aria-label="Foto hochladen"
|
||||
title="Foto hochladen"
|
||||
>
|
||||
{#if uploadingPhoto}
|
||||
<svg class="spinner-lg" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-opacity="0.25"
|
||||
/>
|
||||
<path
|
||||
d="M12 2a10 10 0 0 1 10 10"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="avatar-initials">{initials()}</span>
|
||||
<span class="avatar-upload-overlay">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={handleToggleFavorite}
|
||||
class="favorite-btn"
|
||||
|
|
@ -700,6 +838,9 @@
|
|||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Contact Notes (separate from contact.notes field) -->
|
||||
<ContactNotes {contactId} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
@ -917,6 +1058,102 @@
|
|||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 8px 24px hsl(var(--color-primary) / 0.3);
|
||||
}
|
||||
|
||||
.avatar-upload-btn {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar-upload-btn:hover .avatar-upload-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.avatar-upload-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.avatar-initials {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.avatar-upload-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: hsl(var(--color-foreground) / 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.avatar-upload-overlay svg {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.photo-delete-btn {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-error));
|
||||
border: 2px solid hsl(var(--color-background));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px hsl(var(--color-foreground) / 0.1);
|
||||
}
|
||||
|
||||
.photo-delete-btn:hover:not(:disabled) {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.photo-delete-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.photo-delete-btn svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.spinner-sm {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.favorite-btn {
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,133 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import { viewModeStore } from '$lib/stores/view-mode.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import ExportModal from '$lib/components/export/ExportModal.svelte';
|
||||
import ViewModeToggle from '$lib/components/ViewModeToggle.svelte';
|
||||
import SortToggle, { type SortField } from '$lib/components/SortToggle.svelte';
|
||||
import FilterBar, {
|
||||
type ContactFilter,
|
||||
type BirthdayFilter,
|
||||
} from '$lib/components/FilterBar.svelte';
|
||||
import ContactListView from '$lib/components/views/ContactListView.svelte';
|
||||
import ContactGridView from '$lib/components/views/ContactGridView.svelte';
|
||||
import ContactAlphabetView from '$lib/components/views/ContactAlphabetView.svelte';
|
||||
import { ContactListSkeleton, ContactGridSkeleton } from '$lib/components/skeletons';
|
||||
import { batchApi } from '$lib/api/batch';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
|
||||
let searchQuery = $state('');
|
||||
let sortField = $state<SortField>('lastName');
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
let showExportModal = $state(false);
|
||||
|
||||
// Infinite scroll
|
||||
let scrollContainer: HTMLDivElement;
|
||||
let intersectionObserver: IntersectionObserver | null = null;
|
||||
let loadMoreTrigger: HTMLDivElement;
|
||||
|
||||
// Filter state
|
||||
let selectedTagId = $state<string | null>(null);
|
||||
let contactFilter = $state<ContactFilter>('all');
|
||||
let birthdayFilter = $state<BirthdayFilter>('all');
|
||||
let selectedCompany = $state<string | null>(null);
|
||||
|
||||
// Count favorites for quick filter button
|
||||
let favoritesCount = $derived(contactsStore.contacts.filter((c) => c.isFavorite).length);
|
||||
|
||||
// Batch selection state
|
||||
let selectionMode = $state(false);
|
||||
let selectedIds = $state<Set<string>>(new Set());
|
||||
let batchLoading = $state(false);
|
||||
|
||||
// Derived state for selection
|
||||
let allSelected = $derived(
|
||||
contactsStore.contacts.length > 0 && contactsStore.contacts.every((c) => selectedIds.has(c.id))
|
||||
);
|
||||
|
||||
// Helper functions for birthday filtering
|
||||
function isBirthdayToday(birthday: string | null | undefined): boolean {
|
||||
if (!birthday) return false;
|
||||
const today = new Date();
|
||||
const bday = new Date(birthday);
|
||||
return bday.getDate() === today.getDate() && bday.getMonth() === today.getMonth();
|
||||
}
|
||||
|
||||
function isBirthdayThisWeek(birthday: string | null | undefined): boolean {
|
||||
if (!birthday) return false;
|
||||
const today = new Date();
|
||||
const bday = new Date(birthday);
|
||||
// Set birthday to current year
|
||||
bday.setFullYear(today.getFullYear());
|
||||
// Get start and end of current week
|
||||
const startOfWeek = new Date(today);
|
||||
startOfWeek.setDate(today.getDate() - today.getDay());
|
||||
startOfWeek.setHours(0, 0, 0, 0);
|
||||
const endOfWeek = new Date(startOfWeek);
|
||||
endOfWeek.setDate(startOfWeek.getDate() + 6);
|
||||
endOfWeek.setHours(23, 59, 59, 999);
|
||||
return bday >= startOfWeek && bday <= endOfWeek;
|
||||
}
|
||||
|
||||
function isBirthdayThisMonth(birthday: string | null | undefined): boolean {
|
||||
if (!birthday) return false;
|
||||
const today = new Date();
|
||||
const bday = new Date(birthday);
|
||||
return bday.getMonth() === today.getMonth();
|
||||
}
|
||||
|
||||
function isContactIncomplete(contact: (typeof contactsStore.contacts)[0]): boolean {
|
||||
return !contact.phone && !contact.mobile && !contact.email;
|
||||
}
|
||||
|
||||
// Filtered and sorted contacts
|
||||
let filteredContacts = $derived.by(() => {
|
||||
let result = [...contactsStore.contacts];
|
||||
|
||||
// Apply contact filter
|
||||
if (contactFilter === 'favorites') {
|
||||
result = result.filter((c) => c.isFavorite);
|
||||
} else if (contactFilter === 'hasPhone') {
|
||||
result = result.filter((c) => c.phone || c.mobile);
|
||||
} else if (contactFilter === 'hasEmail') {
|
||||
result = result.filter((c) => c.email);
|
||||
} else if (contactFilter === 'incomplete') {
|
||||
result = result.filter((c) => isContactIncomplete(c));
|
||||
}
|
||||
|
||||
// Apply birthday filter
|
||||
if (birthdayFilter === 'today') {
|
||||
result = result.filter((c) => isBirthdayToday(c.birthday));
|
||||
} else if (birthdayFilter === 'thisWeek') {
|
||||
result = result.filter((c) => isBirthdayThisWeek(c.birthday));
|
||||
} else if (birthdayFilter === 'thisMonth') {
|
||||
result = result.filter((c) => isBirthdayThisMonth(c.birthday));
|
||||
}
|
||||
|
||||
// Apply company filter
|
||||
if (selectedCompany) {
|
||||
result = result.filter((c) => c.company === selectedCompany);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// Sorted contacts based on selected sort field
|
||||
let sortedContacts = $derived.by(() => {
|
||||
return [...filteredContacts].sort((a, b) => {
|
||||
const aValue =
|
||||
(sortField === 'firstName'
|
||||
? a.firstName || a.lastName || a.displayName || a.email
|
||||
: a.lastName || a.firstName || a.displayName || a.email
|
||||
)?.toLowerCase() || '';
|
||||
const bValue =
|
||||
(sortField === 'firstName'
|
||||
? b.firstName || b.lastName || b.displayName || b.email
|
||||
: b.lastName || b.firstName || b.displayName || b.email
|
||||
)?.toLowerCase() || '';
|
||||
return aValue.localeCompare(bValue, 'de');
|
||||
});
|
||||
});
|
||||
|
||||
function handleSearch() {
|
||||
clearTimeout(searchTimeout);
|
||||
|
|
@ -17,27 +137,117 @@
|
|||
}, 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}`);
|
||||
if (selectionMode) {
|
||||
toggleSelection(id);
|
||||
} else {
|
||||
goto(`/contacts/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectionMode() {
|
||||
selectionMode = !selectionMode;
|
||||
if (!selectionMode) {
|
||||
selectedIds = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelection(id: string) {
|
||||
const newSet = new Set(selectedIds);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
selectedIds = newSet;
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allSelected) {
|
||||
selectedIds = new Set();
|
||||
} else {
|
||||
selectedIds = new Set(contactsStore.contacts.map((c) => c.id));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (selectedIds.size === 0) return;
|
||||
if (!confirm(`${selectedIds.size} Kontakte wirklich löschen?`)) return;
|
||||
|
||||
batchLoading = true;
|
||||
try {
|
||||
const result = await batchApi.deleteMany([...selectedIds]);
|
||||
toasts.success(`${result.success} Kontakte gelöscht`);
|
||||
selectedIds = new Set();
|
||||
selectionMode = false;
|
||||
await contactsStore.loadContacts();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : 'Fehler beim Löschen');
|
||||
} finally {
|
||||
batchLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchArchive() {
|
||||
if (selectedIds.size === 0) return;
|
||||
|
||||
batchLoading = true;
|
||||
try {
|
||||
const result = await batchApi.archiveMany([...selectedIds], true);
|
||||
toasts.success(`${result.success} Kontakte archiviert`);
|
||||
selectedIds = new Set();
|
||||
selectionMode = false;
|
||||
await contactsStore.loadContacts();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : 'Fehler beim Archivieren');
|
||||
} finally {
|
||||
batchLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchFavorite() {
|
||||
if (selectedIds.size === 0) return;
|
||||
|
||||
batchLoading = true;
|
||||
try {
|
||||
const result = await batchApi.favoriteMany([...selectedIds], true);
|
||||
toasts.success(`${result.success} Kontakte zu Favoriten hinzugefügt`);
|
||||
selectedIds = new Set();
|
||||
selectionMode = false;
|
||||
await contactsStore.loadContacts();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : 'Fehler');
|
||||
} finally {
|
||||
batchLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setupInfiniteScroll() {
|
||||
if (intersectionObserver) {
|
||||
intersectionObserver.disconnect();
|
||||
}
|
||||
|
||||
intersectionObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry?.isIntersecting && contactsStore.hasMore && !contactsStore.loadingMore) {
|
||||
contactsStore.loadMore();
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: '200px',
|
||||
threshold: 0.1,
|
||||
}
|
||||
);
|
||||
|
||||
if (loadMoreTrigger) {
|
||||
intersectionObserver.observe(loadMoreTrigger);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
|
|
@ -45,29 +255,47 @@
|
|||
if (contactsStore.contacts.length === 0) {
|
||||
await contactsStore.loadContacts();
|
||||
}
|
||||
|
||||
// Setup infinite scroll after DOM is ready
|
||||
setupInfiniteScroll();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (intersectionObserver) {
|
||||
intersectionObserver.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// Re-setup observer when trigger element changes
|
||||
$effect(() => {
|
||||
if (loadMoreTrigger && intersectionObserver) {
|
||||
intersectionObserver.disconnect();
|
||||
intersectionObserver.observe(loadMoreTrigger);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('contacts.title')}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Selection Mode Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showExportModal = true)}
|
||||
class="btn btn-secondary flex items-center gap-2"
|
||||
title={$_('export.title')}
|
||||
onclick={toggleSelectionMode}
|
||||
class="btn {selectionMode ? 'btn-primary' : 'btn-secondary'} flex items-center gap-2"
|
||||
title={selectionMode ? 'Auswahl beenden' : 'Mehrere auswählen'}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{$_('export.button')}</span>
|
||||
<span class="hidden sm:inline">{selectionMode ? 'Fertig' : 'Auswählen'}</span>
|
||||
</button>
|
||||
<a href="/contacts/new" class="btn btn-primary flex items-center gap-2">
|
||||
<span>+</span>
|
||||
|
|
@ -76,37 +304,163 @@
|
|||
</div>
|
||||
</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"
|
||||
<!-- Batch Actions Bar (shown when in selection mode) -->
|
||||
{#if selectionMode}
|
||||
<div class="batch-actions-bar">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onchange={toggleSelectAll}
|
||||
class="w-5 h-5 rounded border-2 border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{#if selectedIds.size === 0}
|
||||
Alle auswählen
|
||||
{:else}
|
||||
{selectedIds.size} ausgewählt
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleBatchFavorite}
|
||||
disabled={batchLoading || selectedIds.size === 0}
|
||||
class="batch-btn"
|
||||
title="Zu Favoriten hinzufügen"
|
||||
>
|
||||
<svg class="w-5 h-5" 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>
|
||||
<span class="hidden sm:inline">Favoriten</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleBatchArchive}
|
||||
disabled={batchLoading || selectedIds.size === 0}
|
||||
class="batch-btn"
|
||||
title="Archivieren"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Archivieren</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleBatchDelete}
|
||||
disabled={batchLoading || selectedIds.size === 0}
|
||||
class="batch-btn batch-btn-danger"
|
||||
title="Löschen"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Löschen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search, Filters and View Toggle -->
|
||||
<div class="flex items-center gap-4 flex-wrap">
|
||||
<div class="relative flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$_('contacts.search')}
|
||||
bind:value={searchQuery}
|
||||
oninput={handleSearch}
|
||||
class="input w-full pl-10"
|
||||
/>
|
||||
</svg>
|
||||
<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>
|
||||
<!-- Quick Favorites Filter -->
|
||||
<button
|
||||
type="button"
|
||||
class="favorites-quick-btn"
|
||||
class:active={contactFilter === 'favorites'}
|
||||
onclick={() => (contactFilter = contactFilter === 'favorites' ? 'all' : 'favorites')}
|
||||
title={contactFilter === 'favorites' ? 'Alle Kontakte anzeigen' : 'Nur Favoriten anzeigen'}
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
class:filled={contactFilter === 'favorites'}
|
||||
fill={contactFilter === 'favorites' ? 'currentColor' : '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 favoritesCount > 0}
|
||||
<span class="favorites-count">{favoritesCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<FilterBar
|
||||
contacts={contactsStore.contacts}
|
||||
{selectedTagId}
|
||||
onTagChange={(id) => {
|
||||
selectedTagId = id;
|
||||
if (id) {
|
||||
contactsStore.setTagId(id);
|
||||
} else {
|
||||
contactsStore.setTagId(undefined);
|
||||
}
|
||||
contactsStore.loadContacts();
|
||||
}}
|
||||
{contactFilter}
|
||||
onContactFilterChange={(f) => (contactFilter = f)}
|
||||
{birthdayFilter}
|
||||
onBirthdayFilterChange={(f) => (birthdayFilter = f)}
|
||||
{selectedCompany}
|
||||
onCompanyChange={(c) => (selectedCompany = c)}
|
||||
/>
|
||||
<SortToggle value={sortField} onchange={(v) => (sortField = v)} />
|
||||
<ViewModeToggle />
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<!-- Loading state with skeleton -->
|
||||
{#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>
|
||||
{#if viewModeStore.mode === 'grid'}
|
||||
<ContactGridSkeleton count={8} />
|
||||
{:else}
|
||||
<ContactListSkeleton count={10} />
|
||||
{/if}
|
||||
{:else if contactsStore.contacts.length === 0}
|
||||
<!-- Empty state -->
|
||||
<div class="text-center py-12">
|
||||
|
|
@ -118,83 +472,178 @@
|
|||
</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>
|
||||
<!-- Contacts View -->
|
||||
{#if viewModeStore.mode === 'grid'}
|
||||
<ContactGridView
|
||||
contacts={sortedContacts}
|
||||
onContactClick={handleContactClick}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
{selectionMode}
|
||||
{selectedIds}
|
||||
onToggleSelection={toggleSelection}
|
||||
/>
|
||||
{:else if viewModeStore.mode === 'alphabet'}
|
||||
<ContactAlphabetView
|
||||
contacts={sortedContacts}
|
||||
onContactClick={handleContactClick}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
{selectionMode}
|
||||
{selectedIds}
|
||||
onToggleSelection={toggleSelection}
|
||||
{sortField}
|
||||
/>
|
||||
{:else}
|
||||
<ContactListView
|
||||
contacts={sortedContacts}
|
||||
onContactClick={handleContactClick}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
{selectionMode}
|
||||
{selectedIds}
|
||||
onToggleSelection={toggleSelection}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- 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}
|
||||
<!-- Infinite scroll trigger & loading more indicator -->
|
||||
{#if contactsStore.hasMore}
|
||||
<div bind:this={loadMoreTrigger} class="load-more-trigger">
|
||||
{#if contactsStore.loadingMore}
|
||||
<div class="loading-more">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>{$_('common.loadingMore')}</span>
|
||||
</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>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Total count -->
|
||||
<p class="text-sm text-muted-foreground text-center">
|
||||
{contactsStore.total} Kontakte
|
||||
{contactsStore.contacts.length} / {contactsStore.total}
|
||||
{contactsStore.total === 1 ? $_('contacts.contact') : $_('contacts.contactsPlural')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Export Modal -->
|
||||
<ExportModal isOpen={showExportModal} onClose={() => (showExportModal = false)} />
|
||||
<style>
|
||||
.batch-actions-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.75rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.batch-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-foreground));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.batch-btn:hover:not(:disabled) {
|
||||
background: hsl(var(--color-surface-hover));
|
||||
}
|
||||
|
||||
.batch-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.batch-btn-danger:hover:not(:disabled) {
|
||||
background: hsl(var(--color-error) / 0.15);
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
/* Favorites Quick Filter Button */
|
||||
.favorites-quick-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: hsl(var(--background) / 0.75);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid hsl(var(--border) / 0.5);
|
||||
border-radius: 9999px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.favorites-quick-btn:hover {
|
||||
color: hsl(var(--foreground));
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.favorites-quick-btn.active {
|
||||
color: #ef4444;
|
||||
border-color: #ef4444 / 0.5;
|
||||
background: hsl(0 84% 60% / 0.1);
|
||||
}
|
||||
|
||||
.favorites-quick-btn.active:hover {
|
||||
background: hsl(0 84% 60% / 0.15);
|
||||
}
|
||||
|
||||
.favorites-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.favorites-quick-btn.active .favorites-count {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Infinite scroll */
|
||||
.load-more-trigger {
|
||||
height: 1px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 2px solid hsl(var(--muted));
|
||||
border-top-color: hsl(var(--primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue