mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(contacts): add duplicate detection, photo upload, and batch operations
- Add duplicate detection service with merge functionality - Find duplicates by email, phone, or name - Merge contacts with field selection UI - Dismiss false positives - Add contact photo upload - Upload photos to MinIO/S3 storage - Display photos in all contact views - Delete photo functionality - Add batch operations for multiple contacts - Selection mode with checkboxes in all views - Batch delete, archive, and favorite actions - Fixed-height action bar to prevent layout shifts - Add multiple contact view modes - List view, Grid view, Alphabet view - View mode toggle component - Sort by first/last name - Increase avatar sizes across all views 🤖 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
23c2d85f6e
commit
dd40bb40e7
41 changed files with 5174 additions and 619 deletions
|
|
@ -8,9 +8,9 @@ pnpm docker:down
|
|||
|
||||
pnpm dev:calendar:app
|
||||
pnpm dev:todo:full
|
||||
pnpm dev:contacts:full
|
||||
pnpm dev:chat:app
|
||||
pnpm dev:clock:app
|
||||
pnpm dev:contacts:app
|
||||
pnpm dev:context:app
|
||||
pnpm dev:manacore:app # Nur ManaCore Web
|
||||
pnpm dev:manacore:backends # Alle 9 Backends für Dashboard-Widgets
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ 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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -27,6 +30,9 @@ import { GoogleModule } from './google/google.module';
|
|||
ImportModule,
|
||||
ExportModule,
|
||||
GoogleModule,
|
||||
DuplicatesModule,
|
||||
PhotoModule,
|
||||
BatchModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
89
apps/contacts/apps/backend/src/batch/batch.controller.ts
Normal file
89
apps/contacts/apps/backend/src/batch/batch.controller.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
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 BatchGroupDto extends BatchContactIdsDto {
|
||||
@IsString()
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
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-to-group')
|
||||
async addToGroup(@CurrentUser() user: CurrentUserData, @Body() dto: BatchGroupDto) {
|
||||
const result = await this.batchService.addToGroup(dto.contactIds, dto.groupId, user.userId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('remove-from-group')
|
||||
async removeFromGroup(@CurrentUser() user: CurrentUserData, @Body() dto: BatchGroupDto) {
|
||||
const result = await this.batchService.removeFromGroup(
|
||||
dto.contactIds,
|
||||
dto.groupId,
|
||||
user.userId
|
||||
);
|
||||
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 {}
|
||||
239
apps/contacts/apps/backend/src/batch/batch.service.ts
Normal file
239
apps/contacts/apps/backend/src/batch/batch.service.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
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, contactToGroups, 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 multiple contacts to a group
|
||||
*/
|
||||
async addToGroup(contactIds: string[], groupId: 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;
|
||||
}
|
||||
|
||||
try {
|
||||
// Insert if not exists (ignore duplicates)
|
||||
await this.db.insert(contactToGroups).values({ contactId, groupId }).onConflictDoNothing();
|
||||
result.success++;
|
||||
} catch {
|
||||
result.failed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.failed > 0) {
|
||||
result.errors.push(`${result.failed} contacts could not be added to group`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove multiple contacts from a group
|
||||
*/
|
||||
async removeFromGroup(
|
||||
contactIds: string[],
|
||||
groupId: 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 first
|
||||
const validContacts = await this.db
|
||||
.select({ id: contacts.id })
|
||||
.from(contacts)
|
||||
.where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds)));
|
||||
|
||||
const validIds = validContacts.map((c) => c.id);
|
||||
|
||||
if (validIds.length === 0) {
|
||||
result.failed = contactIds.length;
|
||||
result.errors.push('No valid contacts found');
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.db
|
||||
.delete(contactToGroups)
|
||||
.where(
|
||||
and(eq(contactToGroups.groupId, groupId), inArray(contactToGroups.contactId, validIds))
|
||||
);
|
||||
result.success = validIds.length;
|
||||
result.failed = contactIds.length - validIds.length;
|
||||
} catch (e) {
|
||||
result.failed = contactIds.length;
|
||||
result.errors.push(e instanceof Error ? e.message : 'Remove from group 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { pgTable, uuid, timestamp, varchar, text, primaryKey } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, uuid, timestamp, varchar, text, primaryKey, boolean } from 'drizzle-orm/pg-core';
|
||||
import { contacts } from './contacts.schema';
|
||||
|
||||
export const contactGroups = pgTable('contact_groups', {
|
||||
|
|
@ -7,6 +7,8 @@ export const contactGroups = pgTable('contact_groups', {
|
|||
name: varchar('name', { length: 100 }).notNull(),
|
||||
description: text('description'),
|
||||
color: varchar('color', { length: 20 }),
|
||||
icon: varchar('icon', { length: 50 }),
|
||||
isPreset: boolean('is_preset').default(false).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,75 @@
|
|||
import 'dotenv/config';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import { contacts } from './schema';
|
||||
import { contacts, contactGroups } 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';
|
||||
|
||||
// System user ID for preset groups (visible to all users)
|
||||
const SYSTEM_USER_ID = 'system';
|
||||
|
||||
// Preset groups available to all users
|
||||
interface PresetGroup {
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const presetGroups: PresetGroup[] = [
|
||||
{
|
||||
name: 'Familie',
|
||||
description: 'Familienmitglieder und Verwandte',
|
||||
color: '#ef4444', // Red
|
||||
icon: 'home',
|
||||
},
|
||||
{
|
||||
name: 'Freunde',
|
||||
description: 'Freunde und Bekannte',
|
||||
color: '#f97316', // Orange
|
||||
icon: 'users',
|
||||
},
|
||||
{
|
||||
name: 'Arbeit',
|
||||
description: 'Kollegen und Geschäftskontakte',
|
||||
color: '#3b82f6', // Blue
|
||||
icon: 'briefcase',
|
||||
},
|
||||
{
|
||||
name: 'Kunden',
|
||||
description: 'Kunden und Auftraggeber',
|
||||
color: '#22c55e', // Green
|
||||
icon: 'building',
|
||||
},
|
||||
{
|
||||
name: 'Partner',
|
||||
description: 'Geschäftspartner und Lieferanten',
|
||||
color: '#8b5cf6', // Purple
|
||||
icon: 'handshake',
|
||||
},
|
||||
{
|
||||
name: 'VIP',
|
||||
description: 'Wichtige Kontakte',
|
||||
color: '#eab308', // Yellow/Gold
|
||||
icon: 'star',
|
||||
},
|
||||
{
|
||||
name: 'Nachbarn',
|
||||
description: 'Nachbarn und Anwohner',
|
||||
color: '#14b8a6', // Teal
|
||||
icon: 'map-pin',
|
||||
},
|
||||
{
|
||||
name: 'Vereine',
|
||||
description: 'Vereinsmitglieder und Clubs',
|
||||
color: '#ec4899', // Pink
|
||||
icon: 'flag',
|
||||
},
|
||||
];
|
||||
|
||||
interface SeedContact {
|
||||
firstName: string;
|
||||
|
|
@ -472,6 +534,46 @@ const seedContacts: SeedContact[] = [
|
|||
},
|
||||
];
|
||||
|
||||
async function seedPresetGroups() {
|
||||
console.log('🏷️ Seeding preset groups...');
|
||||
|
||||
const connection = postgres(DATABASE_URL);
|
||||
const db = drizzle(connection);
|
||||
|
||||
try {
|
||||
const { sql, eq, and } = await import('drizzle-orm');
|
||||
|
||||
// Check if preset groups already exist
|
||||
const existingPresets = await db
|
||||
.select()
|
||||
.from(contactGroups)
|
||||
.where(and(eq(contactGroups.userId, SYSTEM_USER_ID), eq(contactGroups.isPreset, true)));
|
||||
|
||||
if (existingPresets.length > 0) {
|
||||
console.log(` ℹ️ ${existingPresets.length} preset groups already exist, skipping...`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert preset groups
|
||||
const groupsToInsert = presetGroups.map((group) => ({
|
||||
userId: SYSTEM_USER_ID,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
color: group.color,
|
||||
icon: group.icon,
|
||||
isPreset: true,
|
||||
}));
|
||||
|
||||
await db.insert(contactGroups).values(groupsToInsert);
|
||||
console.log(` ✅ Inserted ${presetGroups.length} preset groups`);
|
||||
} catch (error) {
|
||||
console.error('❌ Preset groups seed failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function seed() {
|
||||
console.log('🌱 Starting seed...');
|
||||
console.log(`📊 Preparing to insert ${seedContacts.length} contacts`);
|
||||
|
|
@ -531,7 +633,15 @@ async function seed() {
|
|||
}
|
||||
}
|
||||
|
||||
seed()
|
||||
async function main() {
|
||||
// First seed preset groups (system-wide)
|
||||
await seedPresetGroups();
|
||||
|
||||
// Then seed contacts for test user
|
||||
await seed();
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
console.log('🎉 Seed completed!');
|
||||
process.exit(0);
|
||||
|
|
|
|||
|
|
@ -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()}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { eq, and, or } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import {
|
||||
|
|
@ -9,19 +9,45 @@ import {
|
|||
type NewContactGroup,
|
||||
} from '../db/schema';
|
||||
|
||||
// System user ID for preset groups (visible to all users)
|
||||
const SYSTEM_USER_ID = 'system';
|
||||
|
||||
@Injectable()
|
||||
export class GroupService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
/**
|
||||
* Get all groups for a user, including preset groups (system groups)
|
||||
*/
|
||||
async findByUserId(userId: string): Promise<ContactGroup[]> {
|
||||
return this.db.select().from(contactGroups).where(eq(contactGroups.userId, userId));
|
||||
// Get user's own groups + preset groups (system)
|
||||
return this.db
|
||||
.select()
|
||||
.from(contactGroups)
|
||||
.where(
|
||||
or(
|
||||
eq(contactGroups.userId, userId),
|
||||
and(eq(contactGroups.userId, SYSTEM_USER_ID), eq(contactGroups.isPreset, true))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a group by ID (user's own or preset)
|
||||
*/
|
||||
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)));
|
||||
.where(
|
||||
and(
|
||||
eq(contactGroups.id, id),
|
||||
or(
|
||||
eq(contactGroups.userId, userId),
|
||||
and(eq(contactGroups.userId, SYSTEM_USER_ID), eq(contactGroups.isPreset, true))
|
||||
)
|
||||
)
|
||||
);
|
||||
return group || null;
|
||||
}
|
||||
|
||||
|
|
@ -30,7 +56,19 @@ export class GroupService {
|
|||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a group - preset groups cannot be modified
|
||||
*/
|
||||
async update(id: string, userId: string, data: Partial<NewContactGroup>): Promise<ContactGroup> {
|
||||
// First check if this is a preset group
|
||||
const existingGroup = await this.findById(id, userId);
|
||||
if (!existingGroup) {
|
||||
throw new NotFoundException('Group not found');
|
||||
}
|
||||
if (existingGroup.isPreset) {
|
||||
throw new ForbiddenException('Preset groups cannot be modified');
|
||||
}
|
||||
|
||||
const [group] = await this.db
|
||||
.update(contactGroups)
|
||||
.set(data)
|
||||
|
|
@ -44,7 +82,19 @@ export class GroupService {
|
|||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a group - preset groups cannot be deleted
|
||||
*/
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
// First check if this is a preset group
|
||||
const existingGroup = await this.findById(id, userId);
|
||||
if (!existingGroup) {
|
||||
throw new NotFoundException('Group not found');
|
||||
}
|
||||
if (existingGroup.isPreset) {
|
||||
throw new ForbiddenException('Preset groups cannot be deleted');
|
||||
}
|
||||
|
||||
await this.db
|
||||
.delete(contactGroups)
|
||||
.where(and(eq(contactGroups.id, id), eq(contactGroups.userId, userId)));
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -61,8 +61,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 +71,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 */
|
||||
|
|
|
|||
78
apps/contacts/apps/web/src/lib/api/batch.ts
Normal file
78
apps/contacts/apps/web/src/lib/api/batch.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const API_BASE = 'http://localhost:3015/api/v1';
|
||||
|
||||
async function fetchWithAuth(url: string, options: RequestInit = {}) {
|
||||
const token = await authStore.getAccessToken();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
||||
throw new Error(error.message || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export interface 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 addToGroup(contactIds: string[], groupId: string): Promise<BatchResult> {
|
||||
return fetchWithAuth('/batch/add-to-group', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ contactIds, groupId }),
|
||||
});
|
||||
},
|
||||
|
||||
async removeFromGroup(contactIds: string[], groupId: string): Promise<BatchResult> {
|
||||
return fetchWithAuth('/batch/remove-from-group', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ contactIds, groupId }),
|
||||
});
|
||||
},
|
||||
|
||||
async addTags(contactIds: string[], tagIds: string[]): Promise<BatchResult> {
|
||||
return fetchWithAuth('/batch/add-tags', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ contactIds, tagIds }),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -63,6 +63,8 @@ export interface ContactGroup {
|
|||
name: string;
|
||||
description?: string | null;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
isPreset: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
|
@ -287,3 +289,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',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
60
apps/contacts/apps/web/src/lib/api/duplicates.ts
Normal file
60
apps/contacts/apps/web/src/lib/api/duplicates.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import type { Contact } from './contacts';
|
||||
|
||||
const API_BASE = 'http://localhost:3015/api/v1';
|
||||
|
||||
async function fetchWithAuth(url: string, options: RequestInit = {}) {
|
||||
const token = await authStore.getAccessToken();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
||||
throw new Error(error.message || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export interface 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,7 +1,7 @@
|
|||
<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';
|
||||
|
||||
interface Props {
|
||||
contactId: string;
|
||||
|
|
@ -16,6 +16,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 +138,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();
|
||||
|
|
@ -477,12 +532,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"
|
||||
|
|
@ -917,6 +1065,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;
|
||||
|
|
|
|||
|
|
@ -2,13 +2,124 @@
|
|||
import { onMount } 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 { 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);
|
||||
|
||||
// Filter state
|
||||
let selectedGroupId = $state<string | null>(null);
|
||||
let contactFilter = $state<ContactFilter>('all');
|
||||
let birthdayFilter = $state<BirthdayFilter>('all');
|
||||
let selectedCompany = $state<string | null>(null);
|
||||
|
||||
// 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 === '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);
|
||||
searchTimeout = setTimeout(() => {
|
||||
|
|
@ -17,27 +128,94 @@
|
|||
}, 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;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
|
|
@ -50,9 +228,26 @@
|
|||
|
||||
<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={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="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">{selectionMode ? 'Fertig' : 'Auswählen'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showExportModal = true)}
|
||||
|
|
@ -76,28 +271,128 @@
|
|||
</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>
|
||||
<FilterBar
|
||||
contacts={contactsStore.contacts}
|
||||
{selectedGroupId}
|
||||
onGroupChange={(id) => {
|
||||
selectedGroupId = id;
|
||||
if (id) {
|
||||
contactsStore.setGroupId(id);
|
||||
} else {
|
||||
contactsStore.setGroupId(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 -->
|
||||
|
|
@ -118,83 +413,87 @@
|
|||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Contacts List -->
|
||||
<div class="space-y-2">
|
||||
{#each contactsStore.contacts as contact (contact.id)}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => handleContactClick(contact.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleContactClick(contact.id)}
|
||||
class="contact-card w-full text-left cursor-pointer"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="avatar">
|
||||
{#if contact.photoUrl}
|
||||
<img
|
||||
src={contact.photoUrl}
|
||||
alt={getDisplayName(contact)}
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
{getInitials(contact)}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Contact Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-foreground truncate">
|
||||
{getDisplayName(contact)}
|
||||
</div>
|
||||
{#if contact.company || contact.jobTitle}
|
||||
<div class="text-sm text-muted-foreground truncate">
|
||||
{[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')}
|
||||
</div>
|
||||
{/if}
|
||||
{#if contact.email}
|
||||
<div class="text-sm text-muted-foreground truncate">
|
||||
{contact.email}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Favorite button -->
|
||||
<button
|
||||
onclick={(e) => handleToggleFavorite(e, contact.id)}
|
||||
class="p-2 rounded-full hover:bg-accent transition-colors"
|
||||
>
|
||||
{#if contact.isFavorite}
|
||||
<svg class="h-5 w-5 text-red-500 fill-current" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="h-5 w-5 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- 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}
|
||||
|
||||
<!-- Total count -->
|
||||
<p class="text-sm text-muted-foreground text-center">
|
||||
{contactsStore.total} Kontakte
|
||||
{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));
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
397
apps/contacts/apps/web/src/lib/components/FilterBar.svelte
Normal file
397
apps/contacts/apps/web/src/lib/components/FilterBar.svelte
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { onMount } from 'svelte';
|
||||
import { groupsApi, type ContactGroup, type Contact } from '$lib/api/contacts';
|
||||
|
||||
export type ContactFilter = 'all' | 'hasPhone' | 'hasEmail' | 'incomplete';
|
||||
export type BirthdayFilter = 'all' | 'today' | 'thisWeek' | 'thisMonth';
|
||||
|
||||
interface Props {
|
||||
contacts: Contact[];
|
||||
selectedGroupId: string | null;
|
||||
onGroupChange: (groupId: string | null) => void;
|
||||
contactFilter: ContactFilter;
|
||||
onContactFilterChange: (filter: ContactFilter) => void;
|
||||
birthdayFilter: BirthdayFilter;
|
||||
onBirthdayFilterChange: (filter: BirthdayFilter) => void;
|
||||
selectedCompany: string | null;
|
||||
onCompanyChange: (company: string | null) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
contacts,
|
||||
selectedGroupId,
|
||||
onGroupChange,
|
||||
contactFilter,
|
||||
onContactFilterChange,
|
||||
birthdayFilter,
|
||||
onBirthdayFilterChange,
|
||||
selectedCompany,
|
||||
onCompanyChange,
|
||||
}: Props = $props();
|
||||
|
||||
let groups = $state<ContactGroup[]>([]);
|
||||
let showFilters = $state(false);
|
||||
let loadingGroups = $state(true);
|
||||
|
||||
// Extract unique companies from contacts
|
||||
let companies = $derived.by(() => {
|
||||
const companySet = new Set<string>();
|
||||
for (const contact of contacts) {
|
||||
if (contact.company) {
|
||||
companySet.add(contact.company);
|
||||
}
|
||||
}
|
||||
return Array.from(companySet).sort((a, b) => a.localeCompare(b, 'de'));
|
||||
});
|
||||
|
||||
// Count active filters
|
||||
let activeFilterCount = $derived.by(() => {
|
||||
let count = 0;
|
||||
if (selectedGroupId) count++;
|
||||
if (contactFilter !== 'all') count++;
|
||||
if (birthdayFilter !== 'all') count++;
|
||||
if (selectedCompany) count++;
|
||||
return count;
|
||||
});
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
const response = await groupsApi.list();
|
||||
groups = response.groups || [];
|
||||
} catch (e) {
|
||||
console.error('Failed to load groups:', e);
|
||||
} finally {
|
||||
loadingGroups = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllFilters() {
|
||||
onGroupChange(null);
|
||||
onContactFilterChange('all');
|
||||
onBirthdayFilterChange('all');
|
||||
onCompanyChange(null);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadGroups();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="filter-bar">
|
||||
<!-- Filter Toggle Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="filter-toggle"
|
||||
class:active={showFilters || activeFilterCount > 0}
|
||||
onclick={() => (showFilters = !showFilters)}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{$_('filters.title')}</span>
|
||||
{#if activeFilterCount > 0}
|
||||
<span class="filter-badge">{activeFilterCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Filter Pills (shown when filters are active) -->
|
||||
{#if activeFilterCount > 0 && !showFilters}
|
||||
<div class="active-filters">
|
||||
{#if selectedGroupId}
|
||||
{@const group = groups.find((g) => g.id === selectedGroupId)}
|
||||
{#if group}
|
||||
<button type="button" class="filter-pill" onclick={() => onGroupChange(null)}>
|
||||
<span class="pill-color" style="background: {group.color || '#6366f1'}"></span>
|
||||
{group.name}
|
||||
<svg class="w-3 h-3" 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>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if contactFilter !== 'all'}
|
||||
<button type="button" class="filter-pill" onclick={() => onContactFilterChange('all')}>
|
||||
{$_(`filters.contact.${contactFilter}`)}
|
||||
<svg class="w-3 h-3" 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>
|
||||
{/if}
|
||||
{#if birthdayFilter !== 'all'}
|
||||
<button type="button" class="filter-pill" onclick={() => onBirthdayFilterChange('all')}>
|
||||
{$_(`filters.birthday.${birthdayFilter}`)}
|
||||
<svg class="w-3 h-3" 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>
|
||||
{/if}
|
||||
{#if selectedCompany}
|
||||
<button type="button" class="filter-pill" onclick={() => onCompanyChange(null)}>
|
||||
{selectedCompany}
|
||||
<svg class="w-3 h-3" 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>
|
||||
{/if}
|
||||
<button type="button" class="clear-all-btn" onclick={clearAllFilters}>
|
||||
{$_('filters.clearAll')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Expanded Filter Panel -->
|
||||
{#if showFilters}
|
||||
<div class="filter-panel">
|
||||
<!-- Groups Filter -->
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">{$_('filters.group')}</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={selectedGroupId || ''}
|
||||
onchange={(e) => onGroupChange(e.currentTarget.value || null)}
|
||||
>
|
||||
<option value="">{$_('filters.allGroups')}</option>
|
||||
{#each groups as group}
|
||||
<option value={group.id}>{group.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Contact Info Filter -->
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">{$_('filters.contactInfo')}</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={contactFilter}
|
||||
onchange={(e) => onContactFilterChange(e.currentTarget.value as ContactFilter)}
|
||||
>
|
||||
<option value="all">{$_('filters.contact.all')}</option>
|
||||
<option value="hasPhone">{$_('filters.contact.hasPhone')}</option>
|
||||
<option value="hasEmail">{$_('filters.contact.hasEmail')}</option>
|
||||
<option value="incomplete">{$_('filters.contact.incomplete')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Birthday Filter -->
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">{$_('filters.birthdayLabel')}</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={birthdayFilter}
|
||||
onchange={(e) => onBirthdayFilterChange(e.currentTarget.value as BirthdayFilter)}
|
||||
>
|
||||
<option value="all">{$_('filters.birthday.all')}</option>
|
||||
<option value="today">{$_('filters.birthday.today')}</option>
|
||||
<option value="thisWeek">{$_('filters.birthday.thisWeek')}</option>
|
||||
<option value="thisMonth">{$_('filters.birthday.thisMonth')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Company Filter -->
|
||||
{#if companies.length > 0}
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">{$_('filters.company')}</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
value={selectedCompany || ''}
|
||||
onchange={(e) => onCompanyChange(e.currentTarget.value || null)}
|
||||
>
|
||||
<option value="">{$_('filters.allCompanies')}</option>
|
||||
{#each companies as company}
|
||||
<option value={company}>{company}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Clear Filters -->
|
||||
{#if activeFilterCount > 0}
|
||||
<button type="button" class="clear-filters-btn" onclick={clearAllFilters}>
|
||||
{$_('filters.clearAll')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
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;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.filter-toggle:hover {
|
||||
color: hsl(var(--foreground));
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.filter-toggle.active {
|
||||
color: hsl(var(--primary));
|
||||
border-color: hsl(var(--primary) / 0.5);
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.filter-badge {
|
||||
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;
|
||||
color: hsl(var(--primary-foreground));
|
||||
background: hsl(var(--primary));
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.active-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--muted));
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.filter-pill:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.2);
|
||||
}
|
||||
|
||||
.pill-color {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.clear-all-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.clear-all-btn:hover {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
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: var(--radius-lg);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
min-width: 150px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.filter-select:hover {
|
||||
border-color: hsl(var(--primary) / 0.5);
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.clear-filters-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--muted));
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.clear-filters-btn:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--muted-foreground) / 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter-panel {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
66
apps/contacts/apps/web/src/lib/components/SortToggle.svelte
Normal file
66
apps/contacts/apps/web/src/lib/components/SortToggle.svelte
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export type SortField = 'firstName' | 'lastName';
|
||||
|
||||
interface Props {
|
||||
value: SortField;
|
||||
onchange: (value: SortField) => void;
|
||||
}
|
||||
|
||||
let { value, onchange }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="sort-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="sort-btn"
|
||||
class:active={value === 'firstName'}
|
||||
onclick={() => onchange('firstName')}
|
||||
>
|
||||
{$_('sort.firstName')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="sort-btn"
|
||||
class:active={value === 'lastName'}
|
||||
onclick={() => onchange('lastName')}
|
||||
>
|
||||
{$_('sort.lastName')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sort-toggle {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
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;
|
||||
}
|
||||
|
||||
.sort-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sort-btn:hover {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.sort-btn.active {
|
||||
color: hsl(var(--primary-foreground));
|
||||
background: hsl(var(--primary));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { viewModeStore, type ViewMode } from '$lib/stores/view-mode.svelte';
|
||||
|
||||
const modes: { id: ViewMode; icon: string; label: string }[] = [
|
||||
{ id: 'list', icon: 'list', label: 'views.list' },
|
||||
{ id: 'grid', icon: 'grid', label: 'views.grid' },
|
||||
{ id: 'alphabet', icon: 'alphabet', label: 'views.alphabet' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="view-mode-toggle">
|
||||
{#each modes as mode}
|
||||
<button
|
||||
type="button"
|
||||
class="view-mode-btn"
|
||||
class:active={viewModeStore.mode === mode.id}
|
||||
onclick={() => viewModeStore.setMode(mode.id)}
|
||||
title={$_(mode.label)}
|
||||
>
|
||||
{#if mode.icon === 'list'}
|
||||
<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 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
{:else if mode.icon === 'grid'}
|
||||
<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 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if mode.icon === 'alphabet'}
|
||||
<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="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.view-mode-toggle {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
background-color: hsl(var(--muted));
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.view-mode-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
color: hsl(var(--muted-foreground));
|
||||
transition: all var(--transition-fast);
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.view-mode-btn:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background-color: hsl(var(--background) / 0.5);
|
||||
}
|
||||
|
||||
.view-mode-btn.active {
|
||||
color: hsl(var(--primary));
|
||||
background-color: hsl(var(--background));
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { Contact } from '$lib/api/contacts';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
contacts: Contact[];
|
||||
matchType: 'email' | 'phone' | 'name';
|
||||
matchValue: string;
|
||||
onMerge: (primaryId: string, mergeIds: string[]) => void;
|
||||
onDismiss: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { isOpen, contacts, matchType, matchValue, onMerge, onDismiss, onClose }: Props = $props();
|
||||
|
||||
let selectedPrimaryId = $state<string | null>(null);
|
||||
let merging = $state(false);
|
||||
|
||||
// Reset selection when modal opens
|
||||
$effect(() => {
|
||||
if (isOpen && contacts.length > 0) {
|
||||
selectedPrimaryId = contacts[0].id;
|
||||
}
|
||||
});
|
||||
|
||||
function getInitials(contact: Contact) {
|
||||
const first = contact.firstName?.[0] || '';
|
||||
const last = contact.lastName?.[0] || '';
|
||||
return (first + last).toUpperCase() || contact.email?.[0]?.toUpperCase() || '?';
|
||||
}
|
||||
|
||||
function getDisplayName(contact: Contact) {
|
||||
if (contact.displayName) return contact.displayName;
|
||||
if (contact.firstName || contact.lastName) {
|
||||
return [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
return contact.email || 'Unbekannt';
|
||||
}
|
||||
|
||||
function getMatchTypeLabel(type: 'email' | 'phone' | 'name') {
|
||||
switch (type) {
|
||||
case 'email':
|
||||
return 'E-Mail';
|
||||
case 'phone':
|
||||
return 'Telefon';
|
||||
case 'name':
|
||||
return 'Name';
|
||||
}
|
||||
}
|
||||
|
||||
function getFieldValue(contact: Contact, field: keyof Contact): string {
|
||||
const value = contact[field];
|
||||
if (value === null || value === undefined) return '-';
|
||||
if (typeof value === 'boolean') return value ? 'Ja' : 'Nein';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
async function handleMerge() {
|
||||
if (!selectedPrimaryId) return;
|
||||
merging = true;
|
||||
const mergeIds = contacts.filter((c) => c.id !== selectedPrimaryId).map((c) => c.id);
|
||||
onMerge(selectedPrimaryId, mergeIds);
|
||||
merging = false;
|
||||
}
|
||||
|
||||
// Fields to display in comparison
|
||||
const comparisonFields: { key: keyof Contact; label: string }[] = [
|
||||
{ key: 'firstName', label: 'Vorname' },
|
||||
{ key: 'lastName', label: 'Nachname' },
|
||||
{ key: 'email', label: 'E-Mail' },
|
||||
{ key: 'phone', label: 'Telefon' },
|
||||
{ key: 'mobile', label: 'Mobil' },
|
||||
{ key: 'company', label: 'Firma' },
|
||||
{ key: 'jobTitle', label: 'Position' },
|
||||
{ key: 'city', label: 'Stadt' },
|
||||
];
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<!-- Backdrop -->
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 bg-black/50"
|
||||
onclick={onClose}
|
||||
aria-label="Close modal"
|
||||
></button>
|
||||
|
||||
<!-- Modal -->
|
||||
<div
|
||||
class="relative bg-card rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="p-6 border-b border-border">
|
||||
<h2 class="text-xl font-semibold text-foreground">Duplikate zusammenführen</h2>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
{contacts.length} Kontakte gefunden mit gleicher {getMatchTypeLabel(matchType)}:
|
||||
<span class="font-medium">{matchValue}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-auto p-6">
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Wähle den Hauptkontakt aus. Die Daten der anderen Kontakte werden ergänzt (leere Felder
|
||||
werden gefüllt).
|
||||
</p>
|
||||
|
||||
<!-- Contact comparison table -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-border">
|
||||
<th class="text-left py-2 px-3 font-medium text-muted-foreground">Feld</th>
|
||||
{#each contacts as contact (contact.id)}
|
||||
<th class="text-left py-2 px-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="primary"
|
||||
value={contact.id}
|
||||
bind:group={selectedPrimaryId}
|
||||
class="w-4 h-4 text-primary"
|
||||
/>
|
||||
<span class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-8 h-8 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium"
|
||||
>
|
||||
{getInitials(contact)}
|
||||
</span>
|
||||
<span class="font-medium text-foreground">
|
||||
{getDisplayName(contact)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each comparisonFields as field (field.key)}
|
||||
<tr class="border-b border-border/50">
|
||||
<td class="py-2 px-3 text-muted-foreground">{field.label}</td>
|
||||
{#each contacts as contact (contact.id)}
|
||||
<td
|
||||
class="py-2 px-3 {selectedPrimaryId === contact.id
|
||||
? 'bg-primary-highlight'
|
||||
: ''}"
|
||||
>
|
||||
{getFieldValue(contact, field.key)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
<tr class="border-b border-border/50">
|
||||
<td class="py-2 px-3 text-muted-foreground">Erstellt am</td>
|
||||
{#each contacts as contact (contact.id)}
|
||||
<td
|
||||
class="py-2 px-3 {selectedPrimaryId === contact.id
|
||||
? 'bg-primary-highlight'
|
||||
: ''}"
|
||||
>
|
||||
{new Date(contact.createdAt).toLocaleDateString('de-DE')}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 px-3 text-muted-foreground">Zuletzt geändert</td>
|
||||
{#each contacts as contact (contact.id)}
|
||||
<td
|
||||
class="py-2 px-3 {selectedPrimaryId === contact.id
|
||||
? 'bg-primary-highlight'
|
||||
: ''}"
|
||||
>
|
||||
{new Date(contact.updatedAt).toLocaleDateString('de-DE')}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Info box -->
|
||||
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-950/30 rounded-lg text-sm">
|
||||
<p class="text-blue-800 dark:text-blue-200">
|
||||
<strong>Hinweis:</strong> Der ausgewählte Hauptkontakt wird beibehalten. Leere Felder werden
|
||||
mit Daten aus den anderen Kontakten gefüllt. Die anderen Kontakte werden gelöscht.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="p-6 border-t border-border flex justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onDismiss}
|
||||
class="btn btn-ghost text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Kein Duplikat
|
||||
</button>
|
||||
<div class="flex gap-3">
|
||||
<button type="button" onclick={onClose} class="btn btn-secondary"> Abbrechen </button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleMerge}
|
||||
disabled={!selectedPrimaryId || merging}
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{#if merging}
|
||||
<span class="animate-spin mr-2">⏳</span>
|
||||
{/if}
|
||||
Zusammenführen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.bg-primary-highlight {
|
||||
background-color: hsl(var(--primary) / 0.05);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,494 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { Contact } from '$lib/api/contacts';
|
||||
import type { SortField } from '$lib/components/SortToggle.svelte';
|
||||
|
||||
interface Props {
|
||||
contacts: Contact[];
|
||||
onContactClick: (id: string) => void;
|
||||
onToggleFavorite: (e: MouseEvent, id: string) => void;
|
||||
selectionMode?: boolean;
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelection?: (id: string) => void;
|
||||
sortField?: SortField;
|
||||
}
|
||||
|
||||
let {
|
||||
contacts,
|
||||
onContactClick,
|
||||
onToggleFavorite,
|
||||
selectionMode = false,
|
||||
selectedIds = new Set(),
|
||||
onToggleSelection,
|
||||
sortField = 'lastName',
|
||||
}: Props = $props();
|
||||
|
||||
function handleCheckboxClick(e: MouseEvent, id: string) {
|
||||
e.stopPropagation();
|
||||
onToggleSelection?.(id);
|
||||
}
|
||||
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||
|
||||
function getInitials(contact: Contact) {
|
||||
const first = contact.firstName?.[0] || '';
|
||||
const last = contact.lastName?.[0] || '';
|
||||
return (first + last).toUpperCase() || contact.email?.[0]?.toUpperCase() || '?';
|
||||
}
|
||||
|
||||
function getDisplayName(contact: Contact) {
|
||||
if (contact.displayName) return contact.displayName;
|
||||
if (contact.firstName || contact.lastName) {
|
||||
return [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
return contact.email || 'Unbekannt';
|
||||
}
|
||||
|
||||
function getFirstLetter(contact: Contact): string {
|
||||
const name =
|
||||
sortField === 'firstName'
|
||||
? contact.firstName || contact.lastName || contact.displayName || contact.email || ''
|
||||
: contact.lastName || contact.firstName || contact.displayName || contact.email || '';
|
||||
const letter = name[0]?.toUpperCase() || '#';
|
||||
return /[A-Z]/.test(letter) ? letter : '#';
|
||||
}
|
||||
|
||||
// Group contacts by first letter (contacts are already sorted from parent)
|
||||
let groupedContacts = $derived.by(() => {
|
||||
const groups: Record<string, Contact[]> = {};
|
||||
|
||||
for (const contact of contacts) {
|
||||
const letter = getFirstLetter(contact);
|
||||
if (!groups[letter]) {
|
||||
groups[letter] = [];
|
||||
}
|
||||
groups[letter].push(contact);
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
// Available letters (letters that have contacts)
|
||||
let availableLetters = $derived(Object.keys(groupedContacts).sort());
|
||||
|
||||
function scrollToLetter(letter: string) {
|
||||
const element = document.getElementById(`section-${letter}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="alphabet-view">
|
||||
<!-- Contacts grouped by letter -->
|
||||
<div class="alphabet-sections">
|
||||
{#each availableLetters as letter}
|
||||
<div id="section-{letter}" class="alphabet-section">
|
||||
<!-- Section Header -->
|
||||
<div class="section-header">
|
||||
<span class="section-letter">{letter}</span>
|
||||
<span class="section-count">{groupedContacts[letter].length}</span>
|
||||
</div>
|
||||
|
||||
<!-- Contacts in this section -->
|
||||
<div class="section-contacts">
|
||||
{#each groupedContacts[letter] as contact (contact.id)}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => onContactClick(contact.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)}
|
||||
class="alphabet-contact-card {selectionMode && selectedIds.has(contact.id)
|
||||
? 'selected'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Selection Checkbox -->
|
||||
{#if selectionMode}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => handleCheckboxClick(e, contact.id)}
|
||||
class="selection-checkbox"
|
||||
aria-label={selectedIds.has(contact.id) ? 'Auswahl aufheben' : 'Auswählen'}
|
||||
>
|
||||
{#if selectedIds.has(contact.id)}
|
||||
<svg class="w-5 h-5 text-primary" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<div class="w-5 h-5 rounded border-2 border-border"></div>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="avatar-sm">
|
||||
{#if contact.photoUrl}
|
||||
<img
|
||||
src={contact.photoUrl}
|
||||
alt={getDisplayName(contact)}
|
||||
class="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
{getInitials(contact)}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Contact Info -->
|
||||
<div class="contact-info">
|
||||
<div class="contact-name">
|
||||
{getDisplayName(contact)}
|
||||
</div>
|
||||
<div class="contact-details">
|
||||
{#if contact.jobTitle && contact.company}
|
||||
<span>{contact.jobTitle} @ {contact.company}</span>
|
||||
{:else if contact.company}
|
||||
<span>{contact.company}</span>
|
||||
{:else if contact.email}
|
||||
<span>{contact.email}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="quick-actions">
|
||||
{#if contact.phone || contact.mobile}
|
||||
<a
|
||||
href="tel:{contact.mobile || contact.phone}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="quick-action-btn"
|
||||
title={$_('contacts.call')}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
{#if contact.email}
|
||||
<a
|
||||
href="mailto:{contact.email}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="quick-action-btn"
|
||||
title={$_('contacts.email')}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
onclick={(e) => onToggleFavorite(e, contact.id)}
|
||||
class="quick-action-btn"
|
||||
title={contact.isFavorite ? $_('contacts.unfavorite') : $_('contacts.favorite')}
|
||||
>
|
||||
{#if contact.isFavorite}
|
||||
<svg class="w-4 h-4 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="w-4 h-4" 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>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Alphabet Quick Jump -->
|
||||
<div class="alphabet-nav">
|
||||
{#each alphabet as letter}
|
||||
<button
|
||||
type="button"
|
||||
class="alphabet-nav-btn"
|
||||
class:active={availableLetters.includes(letter)}
|
||||
class:disabled={!availableLetters.includes(letter)}
|
||||
onclick={() => availableLetters.includes(letter) && scrollToLetter(letter)}
|
||||
disabled={!availableLetters.includes(letter)}
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
{/each}
|
||||
{#if availableLetters.includes('#')}
|
||||
<button type="button" class="alphabet-nav-btn active" onclick={() => scrollToLetter('#')}>
|
||||
#
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.alphabet-view {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.alphabet-sections {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.alphabet-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.875rem;
|
||||
margin-bottom: 0.75rem;
|
||||
position: sticky;
|
||||
top: 80px;
|
||||
z-index: 10;
|
||||
/* Glass pill effect */
|
||||
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;
|
||||
box-shadow: 0 2px 8px hsl(var(--foreground) / 0.05);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.section-header {
|
||||
top: 90px;
|
||||
}
|
||||
}
|
||||
|
||||
.section-letter {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--primary));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.section-count {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.section-contacts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.alphabet-contact-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.alphabet-contact-card:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.avatar-sm {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
min-width: 52px;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.contact-name {
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.contact-details {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.alphabet-contact-card:hover .quick-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.quick-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground));
|
||||
transition: all var(--transition-fast);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quick-action-btn:hover {
|
||||
background-color: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.selection-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.selection-checkbox:hover {
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.alphabet-contact-card.selected {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
/* Alphabet Navigation */
|
||||
.alphabet-nav {
|
||||
position: sticky;
|
||||
top: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
padding: 0.5rem 0.25rem;
|
||||
height: fit-content;
|
||||
/* Glass effect */
|
||||
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: var(--radius-lg);
|
||||
box-shadow: 0 2px 8px hsl(var(--foreground) / 0.05);
|
||||
}
|
||||
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.alphabet-nav {
|
||||
top: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.alphabet-nav-btn {
|
||||
width: 1.75rem;
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.alphabet-nav-btn.active {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.alphabet-nav-btn.active:hover {
|
||||
background-color: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.alphabet-nav-btn.disabled {
|
||||
color: hsl(var(--muted-foreground) / 0.3);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Mobile: Hide alphabet nav, show horizontal version at bottom */
|
||||
@media (max-width: 768px) {
|
||||
.alphabet-view {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.alphabet-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.alphabet-nav-btn {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.alphabet-sections {
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,338 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { Contact } from '$lib/api/contacts';
|
||||
|
||||
interface Props {
|
||||
contacts: Contact[];
|
||||
onContactClick: (id: string) => void;
|
||||
onToggleFavorite: (e: MouseEvent, id: string) => void;
|
||||
selectionMode?: boolean;
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelection?: (id: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
contacts,
|
||||
onContactClick,
|
||||
onToggleFavorite,
|
||||
selectionMode = false,
|
||||
selectedIds = new Set(),
|
||||
onToggleSelection,
|
||||
}: Props = $props();
|
||||
|
||||
function handleCheckboxClick(e: MouseEvent, id: string) {
|
||||
e.stopPropagation();
|
||||
onToggleSelection?.(id);
|
||||
}
|
||||
|
||||
function getInitials(contact: Contact) {
|
||||
const first = contact.firstName?.[0] || '';
|
||||
const last = contact.lastName?.[0] || '';
|
||||
return (first + last).toUpperCase() || contact.email?.[0]?.toUpperCase() || '?';
|
||||
}
|
||||
|
||||
function getDisplayName(contact: Contact) {
|
||||
if (contact.displayName) return contact.displayName;
|
||||
if (contact.firstName || contact.lastName) {
|
||||
return [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
return contact.email || 'Unbekannt';
|
||||
}
|
||||
|
||||
// Generate a consistent gradient based on contact name
|
||||
function getGradient(contact: Contact) {
|
||||
const name = getDisplayName(contact);
|
||||
const hash = name.split('').reduce((acc, char) => char.charCodeAt(0) + acc, 0);
|
||||
const gradients = [
|
||||
'from-blue-500 to-purple-600',
|
||||
'from-green-500 to-teal-600',
|
||||
'from-orange-500 to-red-600',
|
||||
'from-pink-500 to-rose-600',
|
||||
'from-indigo-500 to-blue-600',
|
||||
'from-cyan-500 to-blue-600',
|
||||
'from-violet-500 to-purple-600',
|
||||
'from-amber-500 to-orange-600',
|
||||
];
|
||||
return gradients[hash % gradients.length];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="contact-grid">
|
||||
{#each contacts as contact (contact.id)}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => onContactClick(contact.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)}
|
||||
class="grid-card {selectionMode && selectedIds.has(contact.id) ? 'selected' : ''}"
|
||||
>
|
||||
<!-- Selection Checkbox -->
|
||||
{#if selectionMode}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => handleCheckboxClick(e, contact.id)}
|
||||
class="selection-checkbox"
|
||||
aria-label={selectedIds.has(contact.id) ? 'Auswahl aufheben' : 'Auswählen'}
|
||||
>
|
||||
{#if selectedIds.has(contact.id)}
|
||||
<svg class="w-5 h-5 text-primary" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<div class="w-5 h-5 rounded border-2 border-border"></div>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Favorite Badge -->
|
||||
<button
|
||||
onclick={(e) => onToggleFavorite(e, contact.id)}
|
||||
class="favorite-btn"
|
||||
title={contact.isFavorite ? $_('contacts.unfavorite') : $_('contacts.favorite')}
|
||||
>
|
||||
{#if contact.isFavorite}
|
||||
<svg class="w-5 h-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="w-5 h-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>
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="grid-avatar bg-gradient-to-br {getGradient(contact)}">
|
||||
{#if contact.photoUrl}
|
||||
<img
|
||||
src={contact.photoUrl}
|
||||
alt={getDisplayName(contact)}
|
||||
class="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<span class="text-white font-bold text-2xl">{getInitials(contact)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Contact Info -->
|
||||
<div class="grid-info">
|
||||
<h3 class="grid-name">{getDisplayName(contact)}</h3>
|
||||
{#if contact.jobTitle}
|
||||
<p class="grid-job">{contact.jobTitle}</p>
|
||||
{/if}
|
||||
{#if contact.company}
|
||||
<p class="grid-company">{contact.company}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid-actions">
|
||||
{#if contact.phone || contact.mobile}
|
||||
<a
|
||||
href="tel:{contact.mobile || contact.phone}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="action-btn"
|
||||
title={$_('contacts.call')}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
{#if contact.email}
|
||||
<a
|
||||
href="mailto:{contact.email}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="action-btn"
|
||||
title={$_('contacts.email')}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.contact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.contact-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.contact-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.contact-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.grid-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1.5rem 1rem;
|
||||
background-color: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.grid-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
.favorite-btn {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
padding: 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-full);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.favorite-btn:hover {
|
||||
background-color: hsl(var(--muted));
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.grid-avatar {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: var(--radius-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.grid-info {
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid-name {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.grid-job {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.grid-company {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground) / 0.8);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.grid-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground));
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background-color: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.selection-checkbox {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
left: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--background));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.selection-checkbox:hover {
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.grid-card.selected {
|
||||
background: hsl(var(--primary) / 0.1) !important;
|
||||
border-color: hsl(var(--primary) / 0.3) !important;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { Contact } from '$lib/api/contacts';
|
||||
|
||||
interface Props {
|
||||
contacts: Contact[];
|
||||
onContactClick: (id: string) => void;
|
||||
onToggleFavorite: (e: MouseEvent, id: string) => void;
|
||||
selectionMode?: boolean;
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelection?: (id: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
contacts,
|
||||
onContactClick,
|
||||
onToggleFavorite,
|
||||
selectionMode = false,
|
||||
selectedIds = new Set(),
|
||||
onToggleSelection,
|
||||
}: Props = $props();
|
||||
|
||||
function getInitials(contact: Contact) {
|
||||
const first = contact.firstName?.[0] || '';
|
||||
const last = contact.lastName?.[0] || '';
|
||||
return (first + last).toUpperCase() || contact.email?.[0]?.toUpperCase() || '?';
|
||||
}
|
||||
|
||||
function getDisplayName(contact: Contact) {
|
||||
if (contact.displayName) return contact.displayName;
|
||||
if (contact.firstName || contact.lastName) {
|
||||
return [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
return contact.email || 'Unbekannt';
|
||||
}
|
||||
|
||||
function handleCheckboxClick(e: MouseEvent, id: string) {
|
||||
e.stopPropagation();
|
||||
onToggleSelection?.(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each contacts as contact (contact.id)}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => onContactClick(contact.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)}
|
||||
class="contact-card w-full text-left cursor-pointer {selectionMode &&
|
||||
selectedIds.has(contact.id)
|
||||
? 'selected'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Selection Checkbox -->
|
||||
{#if selectionMode}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => handleCheckboxClick(e, contact.id)}
|
||||
class="selection-checkbox"
|
||||
aria-label={selectedIds.has(contact.id) ? 'Auswahl aufheben' : 'Auswählen'}
|
||||
>
|
||||
{#if selectedIds.has(contact.id)}
|
||||
<svg class="w-5 h-5 text-primary" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<div class="w-5 h-5 rounded border-2 border-border"></div>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="avatar">
|
||||
{#if contact.photoUrl}
|
||||
<img
|
||||
src={contact.photoUrl}
|
||||
alt={getDisplayName(contact)}
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
{getInitials(contact)}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Contact Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-foreground truncate">
|
||||
{getDisplayName(contact)}
|
||||
</div>
|
||||
{#if contact.company || contact.jobTitle}
|
||||
<div class="text-sm text-muted-foreground truncate">
|
||||
{[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')}
|
||||
</div>
|
||||
{/if}
|
||||
{#if contact.email}
|
||||
<div class="text-sm text-muted-foreground truncate">
|
||||
{contact.email}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Favorite button -->
|
||||
<button
|
||||
onclick={(e) => onToggleFavorite(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>
|
||||
|
||||
<style>
|
||||
.selection-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.selection-checkbox:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
|
||||
:global(.contact-card.selected) {
|
||||
background: hsl(var(--color-primary) / 0.1) !important;
|
||||
border-color: hsl(var(--color-primary) / 0.3) !important;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -67,7 +67,22 @@
|
|||
"noContacts": "Keine Kontakte gefunden",
|
||||
"addFirst": "Füge deinen ersten Kontakt hinzu",
|
||||
"favorites": "Favoriten",
|
||||
"archive": "Archiv"
|
||||
"archive": "Archiv",
|
||||
"contact": "Kontakt",
|
||||
"contactsPlural": "Kontakte",
|
||||
"call": "Anrufen",
|
||||
"email": "E-Mail senden",
|
||||
"favorite": "Als Favorit markieren",
|
||||
"unfavorite": "Favorit entfernen"
|
||||
},
|
||||
"views": {
|
||||
"list": "Listenansicht",
|
||||
"grid": "Kachelansicht",
|
||||
"alphabet": "Alphabetisch"
|
||||
},
|
||||
"sort": {
|
||||
"firstName": "Vorname",
|
||||
"lastName": "Nachname"
|
||||
},
|
||||
"contact": {
|
||||
"firstName": "Vorname",
|
||||
|
|
@ -120,6 +135,28 @@
|
|||
"contacts": "Kontakte",
|
||||
"loadMore": "Mehr laden"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filter",
|
||||
"clearAll": "Alle löschen",
|
||||
"group": "Gruppe",
|
||||
"allGroups": "Alle Gruppen",
|
||||
"contactInfo": "Kontaktinfo",
|
||||
"contact": {
|
||||
"all": "Alle Kontakte",
|
||||
"hasPhone": "Mit Telefon",
|
||||
"hasEmail": "Mit E-Mail",
|
||||
"incomplete": "Unvollständig"
|
||||
},
|
||||
"birthdayLabel": "Geburtstag",
|
||||
"birthday": {
|
||||
"all": "Alle",
|
||||
"today": "Heute",
|
||||
"thisWeek": "Diese Woche",
|
||||
"thisMonth": "Diesen Monat"
|
||||
},
|
||||
"company": "Firma",
|
||||
"allCompanies": "Alle Firmen"
|
||||
},
|
||||
"export": {
|
||||
"title": "Kontakte exportieren",
|
||||
"button": "Exportieren",
|
||||
|
|
|
|||
|
|
@ -67,7 +67,22 @@
|
|||
"noContacts": "No contacts found",
|
||||
"addFirst": "Add your first contact",
|
||||
"favorites": "Favorites",
|
||||
"archive": "Archive"
|
||||
"archive": "Archive",
|
||||
"contact": "Contact",
|
||||
"contactsPlural": "Contacts",
|
||||
"call": "Call",
|
||||
"email": "Send email",
|
||||
"favorite": "Mark as favorite",
|
||||
"unfavorite": "Remove favorite"
|
||||
},
|
||||
"views": {
|
||||
"list": "List view",
|
||||
"grid": "Grid view",
|
||||
"alphabet": "Alphabetical"
|
||||
},
|
||||
"sort": {
|
||||
"firstName": "First Name",
|
||||
"lastName": "Last Name"
|
||||
},
|
||||
"contact": {
|
||||
"firstName": "First Name",
|
||||
|
|
@ -120,6 +135,28 @@
|
|||
"contacts": "Contacts",
|
||||
"loadMore": "Load more"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filters",
|
||||
"clearAll": "Clear all",
|
||||
"group": "Group",
|
||||
"allGroups": "All groups",
|
||||
"contactInfo": "Contact info",
|
||||
"contact": {
|
||||
"all": "All contacts",
|
||||
"hasPhone": "With phone",
|
||||
"hasEmail": "With email",
|
||||
"incomplete": "Incomplete"
|
||||
},
|
||||
"birthdayLabel": "Birthday",
|
||||
"birthday": {
|
||||
"all": "All",
|
||||
"today": "Today",
|
||||
"thisWeek": "This week",
|
||||
"thisMonth": "This month"
|
||||
},
|
||||
"company": "Company",
|
||||
"allCompanies": "All companies"
|
||||
},
|
||||
"export": {
|
||||
"title": "Export Contacts",
|
||||
"button": "Export",
|
||||
|
|
|
|||
|
|
@ -199,6 +199,13 @@ export const contactsStore = {
|
|||
filters = { ...filters, search };
|
||||
},
|
||||
|
||||
/**
|
||||
* Set group filter
|
||||
*/
|
||||
setGroupId(groupId: string | undefined) {
|
||||
filters = { ...filters, groupId };
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear selected contact
|
||||
*/
|
||||
|
|
|
|||
228
apps/contacts/apps/web/src/lib/stores/settings.svelte.ts
Normal file
228
apps/contacts/apps/web/src/lib/stores/settings.svelte.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* Settings Store - Manages user preferences for the Contacts app
|
||||
* Uses Svelte 5 runes and localStorage for persistence
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// Settings types
|
||||
export type ContactSortBy = 'name' | 'company' | 'created' | 'updated';
|
||||
export type ContactSortOrder = 'asc' | 'desc';
|
||||
export type ContactView = 'list' | 'grid' | 'alphabet';
|
||||
export type DateFormat = 'dd.MM.yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd';
|
||||
|
||||
export interface ContactsAppSettings {
|
||||
// Display Settings
|
||||
/** Default view mode for contacts list */
|
||||
defaultView: ContactView;
|
||||
/** Default sort field */
|
||||
sortBy: ContactSortBy;
|
||||
/** Default sort order */
|
||||
sortOrder: ContactSortOrder;
|
||||
/** Show contact photos in list */
|
||||
showPhotos: boolean;
|
||||
/** Show company name in list */
|
||||
showCompany: boolean;
|
||||
/** Contacts per page in list view */
|
||||
contactsPerPage: number;
|
||||
|
||||
// Contact Display
|
||||
/** Display name format: 'first-last' or 'last-first' */
|
||||
nameFormat: 'first-last' | 'last-first';
|
||||
/** Date format for birthdays etc. */
|
||||
dateFormat: DateFormat;
|
||||
/** Show birthday reminders */
|
||||
showBirthdayReminders: boolean;
|
||||
/** Days before birthday to remind */
|
||||
birthdayReminderDays: number;
|
||||
|
||||
// Import/Export
|
||||
/** Default export format */
|
||||
defaultExportFormat: 'vcf' | 'csv' | 'json';
|
||||
/** Include notes in export */
|
||||
includeNotesInExport: boolean;
|
||||
/** Include photos in export */
|
||||
includePhotosInExport: boolean;
|
||||
|
||||
// Duplicates
|
||||
/** Auto-detect duplicates on import */
|
||||
autoDetectDuplicates: boolean;
|
||||
/** Duplicate detection sensitivity: 'strict' | 'normal' | 'loose' */
|
||||
duplicateSensitivity: 'strict' | 'normal' | 'loose';
|
||||
|
||||
// Privacy
|
||||
/** Blur contact photos by default (privacy mode) */
|
||||
privacyMode: boolean;
|
||||
/** Require confirmation before sharing contact */
|
||||
confirmBeforeSharing: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: ContactsAppSettings = {
|
||||
// Display Settings
|
||||
defaultView: 'list',
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
showPhotos: true,
|
||||
showCompany: true,
|
||||
contactsPerPage: 50,
|
||||
|
||||
// Contact Display
|
||||
nameFormat: 'first-last',
|
||||
dateFormat: 'dd.MM.yyyy',
|
||||
showBirthdayReminders: true,
|
||||
birthdayReminderDays: 7,
|
||||
|
||||
// Import/Export
|
||||
defaultExportFormat: 'vcf',
|
||||
includeNotesInExport: true,
|
||||
includePhotosInExport: true,
|
||||
|
||||
// Duplicates
|
||||
autoDetectDuplicates: true,
|
||||
duplicateSensitivity: 'normal',
|
||||
|
||||
// Privacy
|
||||
privacyMode: false,
|
||||
confirmBeforeSharing: true,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'contacts-settings';
|
||||
|
||||
// Load settings from localStorage
|
||||
function loadSettings(): ContactsAppSettings {
|
||||
if (!browser) return DEFAULT_SETTINGS;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
// Merge with defaults to handle new settings added in updates
|
||||
return { ...DEFAULT_SETTINGS, ...parsed };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load contacts settings:', e);
|
||||
}
|
||||
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
|
||||
// Save settings to localStorage
|
||||
function saveSettings(settings: ContactsAppSettings) {
|
||||
if (!browser) return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch (e) {
|
||||
console.error('Failed to save contacts settings:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// State
|
||||
let settings = $state<ContactsAppSettings>(loadSettings());
|
||||
|
||||
export const contactsSettings = {
|
||||
// Full settings object
|
||||
get settings() {
|
||||
return settings;
|
||||
},
|
||||
|
||||
// Display Settings
|
||||
get defaultView() {
|
||||
return settings.defaultView;
|
||||
},
|
||||
get sortBy() {
|
||||
return settings.sortBy;
|
||||
},
|
||||
get sortOrder() {
|
||||
return settings.sortOrder;
|
||||
},
|
||||
get showPhotos() {
|
||||
return settings.showPhotos;
|
||||
},
|
||||
get showCompany() {
|
||||
return settings.showCompany;
|
||||
},
|
||||
get contactsPerPage() {
|
||||
return settings.contactsPerPage;
|
||||
},
|
||||
|
||||
// Contact Display
|
||||
get nameFormat() {
|
||||
return settings.nameFormat;
|
||||
},
|
||||
get dateFormat() {
|
||||
return settings.dateFormat;
|
||||
},
|
||||
get showBirthdayReminders() {
|
||||
return settings.showBirthdayReminders;
|
||||
},
|
||||
get birthdayReminderDays() {
|
||||
return settings.birthdayReminderDays;
|
||||
},
|
||||
|
||||
// Import/Export
|
||||
get defaultExportFormat() {
|
||||
return settings.defaultExportFormat;
|
||||
},
|
||||
get includeNotesInExport() {
|
||||
return settings.includeNotesInExport;
|
||||
},
|
||||
get includePhotosInExport() {
|
||||
return settings.includePhotosInExport;
|
||||
},
|
||||
|
||||
// Duplicates
|
||||
get autoDetectDuplicates() {
|
||||
return settings.autoDetectDuplicates;
|
||||
},
|
||||
get duplicateSensitivity() {
|
||||
return settings.duplicateSensitivity;
|
||||
},
|
||||
|
||||
// Privacy
|
||||
get privacyMode() {
|
||||
return settings.privacyMode;
|
||||
},
|
||||
get confirmBeforeSharing() {
|
||||
return settings.confirmBeforeSharing;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize settings from localStorage
|
||||
*/
|
||||
initialize() {
|
||||
if (!browser) return;
|
||||
settings = loadSettings();
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a single setting
|
||||
*/
|
||||
set<K extends keyof ContactsAppSettings>(key: K, value: ContactsAppSettings[K]) {
|
||||
settings = { ...settings, [key]: value };
|
||||
saveSettings(settings);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update multiple settings at once
|
||||
*/
|
||||
update(updates: Partial<ContactsAppSettings>) {
|
||||
settings = { ...settings, ...updates };
|
||||
saveSettings(settings);
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset all settings to defaults
|
||||
*/
|
||||
reset() {
|
||||
settings = { ...DEFAULT_SETTINGS };
|
||||
saveSettings(settings);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get default settings (for reference)
|
||||
*/
|
||||
getDefaults() {
|
||||
return DEFAULT_SETTINGS;
|
||||
},
|
||||
};
|
||||
67
apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts
Normal file
67
apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* View Mode Store - Manages contact list view mode
|
||||
* Syncs with contactsSettings for the default view preference
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { contactsSettings, type ContactView } from './settings.svelte';
|
||||
|
||||
export type ViewMode = ContactView;
|
||||
|
||||
const STORAGE_KEY = 'contacts-view-mode';
|
||||
|
||||
// Get initial mode: current session preference > settings default > 'list'
|
||||
function getInitialMode(): ViewMode {
|
||||
if (!browser) return 'list';
|
||||
|
||||
// First check if there's a session-specific preference
|
||||
const sessionMode = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (sessionMode === 'list' || sessionMode === 'grid' || sessionMode === 'alphabet') {
|
||||
return sessionMode;
|
||||
}
|
||||
|
||||
// Otherwise use the default from settings
|
||||
return contactsSettings.defaultView || 'list';
|
||||
}
|
||||
|
||||
let mode = $state<ViewMode>(getInitialMode());
|
||||
|
||||
export const viewModeStore = {
|
||||
get mode() {
|
||||
return mode;
|
||||
},
|
||||
|
||||
setMode(newMode: ViewMode) {
|
||||
mode = newMode;
|
||||
// Save to sessionStorage for current session
|
||||
if (browser) {
|
||||
sessionStorage.setItem(STORAGE_KEY, newMode);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset to default view from settings
|
||||
*/
|
||||
resetToDefault() {
|
||||
mode = contactsSettings.defaultView || 'list';
|
||||
if (browser) {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize mode from settings (call on app load)
|
||||
*/
|
||||
initialize() {
|
||||
if (!browser) return;
|
||||
|
||||
// Check if there's a session preference
|
||||
const sessionMode = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (sessionMode === 'list' || sessionMode === 'grid' || sessionMode === 'alphabet') {
|
||||
mode = sessionMode;
|
||||
} else {
|
||||
// Use default from settings
|
||||
mode = contactsSettings.defaultView || 'list';
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -18,6 +18,8 @@
|
|||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
import ContactDetailModal from '$lib/components/ContactDetailModal.svelte';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import { viewModeStore } from '$lib/stores/view-mode.svelte';
|
||||
import { contactsSettings } from '$lib/stores/settings.svelte';
|
||||
|
||||
// Check if we're on a contact detail route
|
||||
const contactDetailMatch = $derived($page.url.pathname.match(/^\/contacts\/([0-9a-f-]{36})$/i));
|
||||
|
|
@ -77,6 +79,7 @@
|
|||
{ href: '/groups', label: 'Gruppen', icon: 'folder' },
|
||||
{ href: '/favorites', label: 'Favoriten', icon: 'heart' },
|
||||
{ href: '/archive', label: 'Archiv', icon: 'archive' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
];
|
||||
|
||||
|
|
@ -154,6 +157,10 @@
|
|||
// Load user settings
|
||||
await userSettings.load();
|
||||
|
||||
// Initialize contacts settings and view mode
|
||||
contactsSettings.initialize();
|
||||
viewModeStore.initialize();
|
||||
|
||||
// Initialize sidebar mode from localStorage
|
||||
const savedSidebar = localStorage.getItem('contacts-nav-sidebar');
|
||||
if (savedSidebar === 'true') {
|
||||
|
|
@ -213,9 +220,9 @@
|
|||
<main
|
||||
class="main-content bg-background"
|
||||
class:sidebar-mode={isSidebarMode && !isCollapsed}
|
||||
class:floating-mode={!isSidebarMode && !isCollapsed}
|
||||
class:floating-mode={!isSidebarMode}
|
||||
>
|
||||
<div class="content-wrapper">
|
||||
<div class="content-wrapper" class:settings-page={$page.url.pathname === '/settings'}>
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
|
|
@ -240,7 +247,14 @@
|
|||
|
||||
/* Floating nav mode - add top padding for fixed nav */
|
||||
.main-content.floating-mode {
|
||||
padding-top: 100px;
|
||||
padding-top: 80px;
|
||||
}
|
||||
|
||||
/* Extra padding on mobile for larger nav */
|
||||
@media (max-width: 768px) {
|
||||
.main-content.floating-mode {
|
||||
padding-top: 90px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar mode - add left padding for sidebar nav */
|
||||
|
|
@ -255,11 +269,20 @@
|
|||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
/* Settings page has its own padding and max-width */
|
||||
.content-wrapper.settings-page {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.content-wrapper {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
.content-wrapper.settings-page {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
|
|
@ -267,5 +290,8 @@
|
|||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
.content-wrapper.settings-page {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
255
apps/contacts/apps/web/src/routes/(app)/duplicates/+page.svelte
Normal file
255
apps/contacts/apps/web/src/routes/(app)/duplicates/+page.svelte
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { duplicatesApi, type DuplicateGroup } from '$lib/api/duplicates';
|
||||
import MergeModal from '$lib/components/duplicates/MergeModal.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
|
||||
let duplicates = $state<DuplicateGroup[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let selectedGroup = $state<DuplicateGroup | null>(null);
|
||||
let showMergeModal = $state(false);
|
||||
|
||||
async function loadDuplicates() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await duplicatesApi.findDuplicates();
|
||||
duplicates = result.duplicates;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Laden der Duplikate';
|
||||
console.error('Failed to load duplicates:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getMatchTypeLabel(type: 'email' | 'phone' | 'name') {
|
||||
switch (type) {
|
||||
case 'email':
|
||||
return 'E-Mail';
|
||||
case 'phone':
|
||||
return 'Telefon';
|
||||
case 'name':
|
||||
return 'Name';
|
||||
}
|
||||
}
|
||||
|
||||
function getMatchTypeIcon(type: 'email' | 'phone' | 'name') {
|
||||
switch (type) {
|
||||
case 'email':
|
||||
return '✉️';
|
||||
case 'phone':
|
||||
return '📞';
|
||||
case 'name':
|
||||
return '👤';
|
||||
}
|
||||
}
|
||||
|
||||
function getInitials(contact: DuplicateGroup['contacts'][0]) {
|
||||
const first = contact.firstName?.[0] || '';
|
||||
const last = contact.lastName?.[0] || '';
|
||||
return (first + last).toUpperCase() || contact.email?.[0]?.toUpperCase() || '?';
|
||||
}
|
||||
|
||||
function getDisplayName(contact: DuplicateGroup['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';
|
||||
}
|
||||
|
||||
function handleOpenMerge(group: DuplicateGroup) {
|
||||
selectedGroup = group;
|
||||
showMergeModal = true;
|
||||
}
|
||||
|
||||
function handleCloseMergeModal() {
|
||||
showMergeModal = false;
|
||||
selectedGroup = null;
|
||||
}
|
||||
|
||||
async function handleMerge(primaryId: string, mergeIds: string[]) {
|
||||
try {
|
||||
await duplicatesApi.mergeContacts(primaryId, mergeIds);
|
||||
toasts.success(`${mergeIds.length + 1} Kontakte wurden zusammengeführt`);
|
||||
// Remove the merged group from the list
|
||||
if (selectedGroup) {
|
||||
duplicates = duplicates.filter((d) => d.id !== selectedGroup!.id);
|
||||
}
|
||||
handleCloseMergeModal();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : 'Fehler beim Zusammenführen');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDismiss() {
|
||||
if (!selectedGroup) return;
|
||||
try {
|
||||
await duplicatesApi.dismissDuplicate(selectedGroup.id);
|
||||
duplicates = duplicates.filter((d) => d.id !== selectedGroup!.id);
|
||||
toasts.info('Duplikat-Gruppe wurde ignoriert');
|
||||
handleCloseMergeModal();
|
||||
} catch (e) {
|
||||
toasts.error('Fehler beim Ignorieren');
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadDuplicates();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Duplikate - Contacts</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-foreground">Duplikate finden</h1>
|
||||
<p class="text-muted-foreground mt-1">Finde und führe doppelte Kontakte zusammen</p>
|
||||
</div>
|
||||
<button type="button" onclick={loadDuplicates} class="btn btn-secondary" disabled={loading}>
|
||||
{#if loading}
|
||||
<span class="animate-spin mr-2">⏳</span>
|
||||
{:else}
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
Erneut suchen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<!-- Error state -->
|
||||
<div class="text-center py-12">
|
||||
<div class="text-6xl mb-4">❌</div>
|
||||
<h2 class="text-xl font-semibold text-foreground mb-2">Fehler beim Laden</h2>
|
||||
<p class="text-muted-foreground mb-4">{error}</p>
|
||||
<button type="button" onclick={loadDuplicates} class="btn btn-primary">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
{:else if duplicates.length === 0}
|
||||
<!-- Empty state -->
|
||||
<div class="text-center py-12">
|
||||
<div class="text-6xl mb-4">✨</div>
|
||||
<h2 class="text-xl font-semibold text-foreground mb-2">Keine Duplikate gefunden</h2>
|
||||
<p class="text-muted-foreground">
|
||||
Deine Kontakte sehen sauber aus! Es wurden keine potenziellen Duplikate erkannt.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div class="bg-card border border-border rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-foreground">{duplicates.length}</div>
|
||||
<div class="text-sm text-muted-foreground">Duplikat-Gruppen</div>
|
||||
</div>
|
||||
<div class="bg-card border border-border rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-foreground">
|
||||
{duplicates.reduce((sum, d) => sum + d.contacts.length, 0)}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">Betroffene Kontakte</div>
|
||||
</div>
|
||||
<div class="bg-card border border-border rounded-lg p-4">
|
||||
<div class="text-2xl font-bold text-green-600">
|
||||
{duplicates.reduce((sum, d) => sum + d.contacts.length - 1, 0)}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">Mögliche Einsparung</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Duplicates list -->
|
||||
<div class="space-y-4">
|
||||
{#each duplicates as group (group.id)}
|
||||
<div class="bg-card border border-border rounded-lg overflow-hidden">
|
||||
<!-- Group header -->
|
||||
<div class="p-4 border-b border-border bg-muted/30 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl">{getMatchTypeIcon(group.matchType)}</span>
|
||||
<div>
|
||||
<div class="font-medium text-foreground">
|
||||
{group.contacts.length} Kontakte mit gleicher {getMatchTypeLabel(group.matchType)}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{group.matchValue}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleOpenMerge(group)}
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
Zusammenführen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Contacts preview -->
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
{#each group.contacts as contact (contact.id)}
|
||||
<div class="flex items-center gap-3 bg-muted/20 rounded-lg p-3 min-w-[200px]">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-primary/10 text-primary flex items-center justify-center text-sm font-medium"
|
||||
>
|
||||
{#if contact.photoUrl}
|
||||
<img
|
||||
src={contact.photoUrl}
|
||||
alt={getDisplayName(contact)}
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
{getInitials(contact)}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-foreground truncate">
|
||||
{getDisplayName(contact)}
|
||||
</div>
|
||||
{#if contact.company}
|
||||
<div class="text-xs text-muted-foreground truncate">
|
||||
{contact.company}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Merge Modal -->
|
||||
{#if selectedGroup}
|
||||
<MergeModal
|
||||
isOpen={showMergeModal}
|
||||
contacts={selectedGroup.contacts}
|
||||
matchType={selectedGroup.matchType}
|
||||
matchValue={selectedGroup.matchValue}
|
||||
onMerge={handleMerge}
|
||||
onDismiss={handleDismiss}
|
||||
onClose={handleCloseMergeModal}
|
||||
/>
|
||||
{/if}
|
||||
|
|
@ -10,14 +10,38 @@
|
|||
let error = $state<string | null>(null);
|
||||
let searchQuery = $state('');
|
||||
|
||||
// Sort groups: preset groups first, then user groups
|
||||
const sortedGroups = $derived(() => {
|
||||
return [...groups].sort((a, b) => {
|
||||
// Preset groups first
|
||||
if (a.isPreset && !b.isPreset) return -1;
|
||||
if (!a.isPreset && b.isPreset) return 1;
|
||||
// Then alphabetically
|
||||
return a.name.localeCompare(b.name, 'de');
|
||||
});
|
||||
});
|
||||
|
||||
const filteredGroups = $derived(() => {
|
||||
if (!searchQuery.trim()) return groups;
|
||||
const sorted = sortedGroups();
|
||||
if (!searchQuery.trim()) return sorted;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return groups.filter(
|
||||
return sorted.filter(
|
||||
(g) => g.name.toLowerCase().includes(query) || g.description?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
// Icon mapping for preset groups
|
||||
const iconMap: Record<string, string> = {
|
||||
home: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6',
|
||||
users: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z',
|
||||
briefcase: 'M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
|
||||
building: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
|
||||
handshake: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
|
||||
star: 'M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z',
|
||||
'map-pin': 'M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z',
|
||||
flag: 'M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9',
|
||||
};
|
||||
|
||||
async function loadGroups() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
|
@ -34,18 +58,27 @@
|
|||
goto(`/groups/${id}`);
|
||||
}
|
||||
|
||||
async function handleDeleteGroup(e: MouseEvent, id: string) {
|
||||
async function handleDeleteGroup(e: MouseEvent, group: ContactGroup) {
|
||||
e.stopPropagation();
|
||||
if (group.isPreset) {
|
||||
error = 'Voreingestellte Gruppen können nicht gelöscht werden';
|
||||
return;
|
||||
}
|
||||
if (!confirm('Gruppe wirklich löschen?')) return;
|
||||
|
||||
try {
|
||||
await groupsApi.delete(id);
|
||||
groups = groups.filter((g) => g.id !== id);
|
||||
await groupsApi.delete(group.id);
|
||||
groups = groups.filter((g) => g.id !== group.id);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Löschen';
|
||||
}
|
||||
}
|
||||
|
||||
function getIconPath(icon: string | null | undefined): string | null {
|
||||
if (!icon) return null;
|
||||
return iconMap[icon] || null;
|
||||
}
|
||||
|
||||
function getGroupColor(color: string | null | undefined): string {
|
||||
return color || '#6366f1';
|
||||
}
|
||||
|
|
@ -151,49 +184,105 @@
|
|||
<p class="empty-description">Keine Gruppen gefunden für "{searchQuery}"</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="groups-list">
|
||||
{#each filteredGroups() as group (group.id)}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => handleGroupClick(group.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleGroupClick(group.id)}
|
||||
class="group-card"
|
||||
>
|
||||
<div class="group-color" style="background-color: {getGroupColor(group.color)}"></div>
|
||||
<div class="group-info">
|
||||
<h3 class="group-name">{group.name}</h3>
|
||||
{#if group.description}
|
||||
<p class="group-description">{group.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="group-actions">
|
||||
<button
|
||||
onclick={(e) => handleDeleteGroup(e, group.id)}
|
||||
class="delete-button"
|
||||
aria-label="Gruppe löschen"
|
||||
>
|
||||
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<!-- Preset Groups Section -->
|
||||
{@const presetGroups = filteredGroups().filter((g) => g.isPreset)}
|
||||
{@const userGroups = filteredGroups().filter((g) => !g.isPreset)}
|
||||
|
||||
{#if presetGroups.length > 0}
|
||||
<div class="section-header">
|
||||
<span class="section-title">Voreingestellte Gruppen</span>
|
||||
</div>
|
||||
<div class="groups-list">
|
||||
{#each presetGroups as group (group.id)}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => handleGroupClick(group.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleGroupClick(group.id)}
|
||||
class="group-card"
|
||||
>
|
||||
<div class="group-color" style="background-color: {getGroupColor(group.color)}">
|
||||
{#if getIconPath(group.icon)}
|
||||
<svg class="group-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d={getIconPath(group.icon)}
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="group-info">
|
||||
<h3 class="group-name">{group.name}</h3>
|
||||
{#if group.description}
|
||||
<p class="group-description">{group.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="group-actions">
|
||||
<svg class="chevron" 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"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<svg class="chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- User Groups Section -->
|
||||
{#if userGroups.length > 0}
|
||||
<div class="section-header" class:has-margin={presetGroups.length > 0}>
|
||||
<span class="section-title">Meine Gruppen</span>
|
||||
</div>
|
||||
<div class="groups-list">
|
||||
{#each userGroups as group (group.id)}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => handleGroupClick(group.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleGroupClick(group.id)}
|
||||
class="group-card"
|
||||
>
|
||||
<div class="group-color" style="background-color: {getGroupColor(group.color)}"></div>
|
||||
<div class="group-info">
|
||||
<h3 class="group-name">{group.name}</h3>
|
||||
{#if group.description}
|
||||
<p class="group-description">{group.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="group-actions">
|
||||
<button
|
||||
onclick={(e) => handleDeleteGroup(e, group)}
|
||||
class="delete-button"
|
||||
aria-label="Gruppe löschen"
|
||||
>
|
||||
<svg class="icon-sm" 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>
|
||||
</button>
|
||||
<svg class="chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="groups-count">{groups.length} Gruppe{groups.length !== 1 ? 'n' : ''}</p>
|
||||
{/if}
|
||||
|
|
@ -397,11 +486,37 @@
|
|||
box-shadow: 0 4px 12px hsl(var(--color-foreground) / 0.05);
|
||||
}
|
||||
|
||||
/* Section Headers */
|
||||
.section-header {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section-header.has-margin {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.group-color {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.group-icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.group-info {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,104 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import {
|
||||
contactsSettings,
|
||||
type ContactView,
|
||||
type ContactSortBy,
|
||||
type ContactSortOrder,
|
||||
type DateFormat,
|
||||
} from '$lib/stores/settings.svelte';
|
||||
import {
|
||||
SettingsPage,
|
||||
SettingsSection,
|
||||
SettingsCard,
|
||||
SettingsRow,
|
||||
SettingsToggle,
|
||||
SettingsSelect,
|
||||
SettingsNumberInput,
|
||||
SettingsDangerZone,
|
||||
SettingsDangerButton,
|
||||
GlobalSettingsSection,
|
||||
} from '@manacore/shared-ui';
|
||||
|
||||
// Options for selects
|
||||
const viewOptions = [
|
||||
{ value: 'list', label: 'Liste' },
|
||||
{ value: 'grid', label: 'Kacheln' },
|
||||
{ value: 'alphabet', label: 'Alphabetisch' },
|
||||
];
|
||||
|
||||
const sortByOptions = [
|
||||
{ value: 'name', label: 'Name' },
|
||||
{ value: 'company', label: 'Firma' },
|
||||
{ value: 'created', label: 'Erstellt' },
|
||||
{ value: 'updated', label: 'Aktualisiert' },
|
||||
];
|
||||
|
||||
const sortOrderOptions = [
|
||||
{ value: 'asc', label: 'Aufsteigend (A-Z)' },
|
||||
{ value: 'desc', label: 'Absteigend (Z-A)' },
|
||||
];
|
||||
|
||||
const nameFormatOptions = [
|
||||
{ value: 'first-last', label: 'Vorname Nachname' },
|
||||
{ value: 'last-first', label: 'Nachname, Vorname' },
|
||||
];
|
||||
|
||||
const dateFormatOptions = [
|
||||
{ value: 'dd.MM.yyyy', label: 'TT.MM.JJJJ (deutsch)' },
|
||||
{ value: 'MM/dd/yyyy', label: 'MM/TT/JJJJ (US)' },
|
||||
{ value: 'yyyy-MM-dd', label: 'JJJJ-MM-TT (ISO)' },
|
||||
];
|
||||
|
||||
const exportFormatOptions = [
|
||||
{ value: 'vcf', label: 'vCard (.vcf)' },
|
||||
{ value: 'csv', label: 'CSV (.csv)' },
|
||||
{ value: 'json', label: 'JSON (.json)' },
|
||||
];
|
||||
|
||||
const duplicateSensitivityOptions = [
|
||||
{ value: 'strict', label: 'Streng' },
|
||||
{ value: 'normal', label: 'Normal' },
|
||||
{ value: 'loose', label: 'Locker' },
|
||||
];
|
||||
|
||||
// Translation function for start page labels
|
||||
const startPageLabels: Record<string, string> = {
|
||||
'nav.contacts': 'Kontakte',
|
||||
'nav.groups': 'Gruppen',
|
||||
'nav.favorites': 'Favoriten',
|
||||
};
|
||||
|
||||
function translateLabel(key: string): string {
|
||||
return startPageLabels[key] || key;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load user settings from server
|
||||
await userSettings.load();
|
||||
|
||||
// Initialize contacts settings from localStorage
|
||||
contactsSettings.initialize();
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.signOut();
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
function handleResetSettings() {
|
||||
if (confirm('Alle Einstellungen auf Standardwerte zurücksetzen?')) {
|
||||
contactsSettings.reset();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -19,8 +106,482 @@
|
|||
</svelte:head>
|
||||
|
||||
<SettingsPage title="Einstellungen" subtitle="Passe die App an deine Vorlieben an.">
|
||||
<!-- Account Section -->
|
||||
<SettingsSection title="Konto">
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<SettingsCard>
|
||||
<SettingsRow label="E-Mail" description={authStore.user?.email || 'Nicht angemeldet'}>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow label="Konto-Status" description="Dein aktueller Kontostatus" border={false}>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
<span
|
||||
class="rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-800 dark:bg-green-900/20 dark:text-green-400"
|
||||
>
|
||||
Aktiv
|
||||
</span>
|
||||
</SettingsRow>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Global Settings Section (synced across all apps) -->
|
||||
<GlobalSettingsSection {userSettings} appId="contacts" />
|
||||
<GlobalSettingsSection {userSettings} appId="contacts" t={translateLabel} />
|
||||
|
||||
<!-- Display Settings Section -->
|
||||
<SettingsSection title="Anzeige">
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<SettingsCard>
|
||||
<SettingsSelect
|
||||
label="Standard-Ansicht"
|
||||
description="Ansicht beim Öffnen der App"
|
||||
options={viewOptions}
|
||||
value={contactsSettings.defaultView}
|
||||
onchange={(v: string | number | null) =>
|
||||
contactsSettings.set('defaultView', v as ContactView)}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 10h16M4 14h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsSelect>
|
||||
|
||||
<SettingsSelect
|
||||
label="Sortierung"
|
||||
description="Standard-Sortierung der Kontakte"
|
||||
options={sortByOptions}
|
||||
value={contactsSettings.sortBy}
|
||||
onchange={(v: string | number | null) =>
|
||||
contactsSettings.set('sortBy', v as ContactSortBy)}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsSelect>
|
||||
|
||||
<SettingsSelect
|
||||
label="Sortierreihenfolge"
|
||||
description="Aufsteigende oder absteigende Sortierung"
|
||||
options={sortOrderOptions}
|
||||
value={contactsSettings.sortOrder}
|
||||
onchange={(v: string | number | null) =>
|
||||
contactsSettings.set('sortOrder', v as ContactSortOrder)}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsSelect>
|
||||
|
||||
<SettingsToggle
|
||||
label="Fotos anzeigen"
|
||||
description="Kontaktfotos in der Liste anzeigen"
|
||||
isOn={contactsSettings.showPhotos}
|
||||
onToggle={(v) => contactsSettings.set('showPhotos', v)}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsToggle>
|
||||
|
||||
<SettingsToggle
|
||||
label="Firma anzeigen"
|
||||
description="Firmenname in der Kontaktliste anzeigen"
|
||||
isOn={contactsSettings.showCompany}
|
||||
onToggle={(v) => contactsSettings.set('showCompany', v)}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsToggle>
|
||||
|
||||
<SettingsNumberInput
|
||||
label="Kontakte pro Seite"
|
||||
description="Anzahl der Kontakte pro Seite"
|
||||
value={contactsSettings.contactsPerPage}
|
||||
onchange={(v: number | null) => contactsSettings.set('contactsPerPage', v ?? 50)}
|
||||
min={10}
|
||||
max={200}
|
||||
border={false}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsNumberInput>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Contact Format Section -->
|
||||
<SettingsSection title="Kontakt-Darstellung">
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<SettingsCard>
|
||||
<SettingsSelect
|
||||
label="Namensformat"
|
||||
description="Reihenfolge von Vor- und Nachname"
|
||||
options={nameFormatOptions}
|
||||
value={contactsSettings.nameFormat}
|
||||
onchange={(v: string | number | null) =>
|
||||
contactsSettings.set('nameFormat', v as 'first-last' | 'last-first')}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsSelect>
|
||||
|
||||
<SettingsSelect
|
||||
label="Datumsformat"
|
||||
description="Format für Geburtstage und andere Daten"
|
||||
options={dateFormatOptions}
|
||||
value={contactsSettings.dateFormat}
|
||||
onchange={(v: string | number | null) =>
|
||||
contactsSettings.set('dateFormat', v as DateFormat)}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsSelect>
|
||||
|
||||
<SettingsToggle
|
||||
label="Geburtstags-Erinnerungen"
|
||||
description="Benachrichtigungen bei bevorstehenden Geburtstagen"
|
||||
isOn={contactsSettings.showBirthdayReminders}
|
||||
onToggle={(v) => contactsSettings.set('showBirthdayReminders', v)}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 15.546c-.523 0-1.046.151-1.5.454a2.704 2.704 0 01-3 0 2.704 2.704 0 00-3 0 2.704 2.704 0 01-3 0 2.704 2.704 0 00-3 0 2.704 2.704 0 01-3 0 2.701 2.701 0 00-1.5-.454M9 6v2m3-2v2m3-2v2M9 3h.01M12 3h.01M15 3h.01M21 21v-7a2 2 0 00-2-2H5a2 2 0 00-2 2v7h18zm-3-9v-2a2 2 0 00-2-2H8a2 2 0 00-2 2v2h12z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsToggle>
|
||||
|
||||
<SettingsNumberInput
|
||||
label="Erinnerung X Tage vorher"
|
||||
description="Tage vor dem Geburtstag erinnern"
|
||||
value={contactsSettings.birthdayReminderDays}
|
||||
onchange={(v: number | null) => contactsSettings.set('birthdayReminderDays', v ?? 7)}
|
||||
min={1}
|
||||
max={30}
|
||||
border={false}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsNumberInput>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Import/Export Section -->
|
||||
<SettingsSection title="Import & Export">
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<SettingsCard>
|
||||
<SettingsSelect
|
||||
label="Standard-Exportformat"
|
||||
description="Bevorzugtes Format für Kontakt-Export"
|
||||
options={exportFormatOptions}
|
||||
value={contactsSettings.defaultExportFormat}
|
||||
onchange={(v: string | number | null) =>
|
||||
contactsSettings.set('defaultExportFormat', v as 'vcf' | 'csv' | 'json')}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsSelect>
|
||||
|
||||
<SettingsToggle
|
||||
label="Notizen exportieren"
|
||||
description="Notizen beim Export mit einschließen"
|
||||
isOn={contactsSettings.includeNotesInExport}
|
||||
onToggle={(v) => contactsSettings.set('includeNotesInExport', v)}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsToggle>
|
||||
|
||||
<SettingsToggle
|
||||
label="Fotos exportieren"
|
||||
description="Kontaktfotos beim Export mit einschließen"
|
||||
isOn={contactsSettings.includePhotosInExport}
|
||||
onToggle={(v) => contactsSettings.set('includePhotosInExport', v)}
|
||||
border={false}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsToggle>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Duplicates Section -->
|
||||
<SettingsSection title="Duplikate">
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<SettingsCard>
|
||||
<SettingsToggle
|
||||
label="Automatische Erkennung"
|
||||
description="Duplikate automatisch beim Import erkennen"
|
||||
isOn={contactsSettings.autoDetectDuplicates}
|
||||
onToggle={(v) => contactsSettings.set('autoDetectDuplicates', v)}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsToggle>
|
||||
|
||||
<SettingsSelect
|
||||
label="Erkennungs-Empfindlichkeit"
|
||||
description="Wie streng sollen Duplikate erkannt werden?"
|
||||
options={duplicateSensitivityOptions}
|
||||
value={contactsSettings.duplicateSensitivity}
|
||||
onchange={(v: string | number | null) =>
|
||||
contactsSettings.set('duplicateSensitivity', v as 'strict' | 'normal' | 'loose')}
|
||||
border={false}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsSelect>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Privacy Section -->
|
||||
<SettingsSection title="Datenschutz">
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<SettingsCard>
|
||||
<SettingsToggle
|
||||
label="Datenschutz-Modus"
|
||||
description="Kontaktfotos und sensible Daten unkenntlich machen"
|
||||
isOn={contactsSettings.privacyMode}
|
||||
onToggle={(v) => contactsSettings.set('privacyMode', v)}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsToggle>
|
||||
|
||||
<SettingsToggle
|
||||
label="Teilen bestätigen"
|
||||
description="Bestätigung vor dem Teilen von Kontakten"
|
||||
isOn={contactsSettings.confirmBeforeSharing}
|
||||
onToggle={(v) => contactsSettings.set('confirmBeforeSharing', v)}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsToggle>
|
||||
|
||||
<SettingsToggle
|
||||
label="Löschen bestätigen"
|
||||
description="Bestätigung vor dem Löschen von Kontakten"
|
||||
isOn={userSettings.general?.confirmOnDelete ?? true}
|
||||
onToggle={(v) => userSettings.updateGeneral({ confirmOnDelete: v })}
|
||||
border={false}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg 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>
|
||||
{/snippet}
|
||||
</SettingsToggle>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- About Section -->
|
||||
<SettingsSection title="Über">
|
||||
|
|
@ -37,8 +598,58 @@
|
|||
|
||||
<SettingsCard>
|
||||
<SettingsRow label="Version" border={false}>
|
||||
{#snippet icon()}
|
||||
<svg 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>
|
||||
{/snippet}
|
||||
<span class="text-[hsl(var(--muted-foreground))]">1.0.0</span>
|
||||
</SettingsRow>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<SettingsDangerZone title="Gefahrenzone">
|
||||
<SettingsDangerButton
|
||||
label="Einstellungen zurücksetzen"
|
||||
description="Alle App-Einstellungen auf Standardwerte zurücksetzen"
|
||||
buttonText="Zurücksetzen"
|
||||
onclick={handleResetSettings}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsDangerButton>
|
||||
|
||||
<SettingsDangerButton
|
||||
label="Abmelden"
|
||||
description="Von deinem Konto abmelden"
|
||||
buttonText="Abmelden"
|
||||
onclick={handleLogout}
|
||||
border={false}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
</SettingsDangerButton>
|
||||
</SettingsDangerZone>
|
||||
</SettingsPage>
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@
|
|||
"dev:contacts:landing": "pnpm --filter @contacts/landing dev",
|
||||
"dev:contacts:backend": "pnpm --filter @contacts/backend dev",
|
||||
"dev:contacts:app": "turbo run dev --filter=@contacts/web --filter=@contacts/backend",
|
||||
"dev:contacts:full": "./scripts/setup-databases.sh contacts && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:contacts:backend\" \"pnpm dev:contacts:web\"",
|
||||
"dev:contacts:full": "./scripts/setup-databases.sh contacts && ./scripts/setup-databases.sh auth && pnpm contacts:db:seed && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:contacts:backend\" \"pnpm dev:contacts:web\"",
|
||||
"contacts:db:push": "pnpm --filter @contacts/backend db:push",
|
||||
"contacts:db:studio": "pnpm --filter @contacts/backend db:studio",
|
||||
"contacts:db:seed": "pnpm --filter @contacts/backend db:seed",
|
||||
|
|
|
|||
|
|
@ -21,87 +21,39 @@
|
|||
class: className = '',
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
// Base card classes using Tailwind
|
||||
const baseCardClasses =
|
||||
'rounded-2xl overflow-hidden shadow-md border backdrop-blur-xl ' +
|
||||
'bg-white/85 border-black/10 ' +
|
||||
'dark:bg-white/[0.06] dark:border-white/10 dark:shadow-lg';
|
||||
|
||||
const dangerCardClasses =
|
||||
'rounded-2xl overflow-hidden shadow-md border backdrop-blur-xl ' +
|
||||
'bg-red-500/[0.08] border-red-500/30 ' +
|
||||
'dark:bg-red-500/[0.12] dark:border-red-500/25 dark:shadow-lg';
|
||||
|
||||
const headerClasses =
|
||||
'px-5 py-4 border-b border-black/[0.08] dark:border-white/10';
|
||||
|
||||
const dangerHeaderClasses =
|
||||
'px-5 py-4 border-b border-red-500/20 bg-red-500/10';
|
||||
</script>
|
||||
|
||||
<div class="settings-card settings-card--{variant} {className}">
|
||||
<div class="{variant === 'danger' ? dangerCardClasses : baseCardClasses} {className}">
|
||||
{#if title || description}
|
||||
<header class="settings-card__header">
|
||||
<header class="{variant === 'danger' ? dangerHeaderClasses : headerClasses}">
|
||||
{#if title}
|
||||
<h3 class="settings-card__title">{title}</h3>
|
||||
<h3 class="text-base font-semibold text-foreground {variant === 'danger' ? 'text-red-500 dark:text-red-400' : ''}">{title}</h3>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p class="settings-card__description">{description}</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">{description}</p>
|
||||
{/if}
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<div class="settings-card__content">
|
||||
<div class="flex flex-col">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-card {
|
||||
/* Glass effect */
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
:global(.dark) .settings-card {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.settings-card--danger {
|
||||
border-color: hsl(var(--destructive) / 0.3);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
:global(.dark) .settings-card--danger {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-color: rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
.settings-card__header {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
:global(.dark) .settings-card__header {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.settings-card--danger .settings-card__header {
|
||||
border-bottom-color: hsl(var(--destructive) / 0.2);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.settings-card__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-card--danger .settings-card__title {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.settings-card__description {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0.25rem 0 0 0;
|
||||
}
|
||||
|
||||
.settings-card__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -35,91 +35,112 @@
|
|||
}: Props = $props();
|
||||
|
||||
const isClickable = $derived(!!href || !!onclick);
|
||||
|
||||
// Tailwind classes
|
||||
const baseRowClasses =
|
||||
'flex items-center justify-between gap-4 px-5 py-4 bg-transparent w-full text-left no-underline transition-all duration-200';
|
||||
|
||||
const borderClasses = 'border-b border-black/[0.08] dark:border-white/10 last:border-b-0';
|
||||
|
||||
const clickableClasses = 'cursor-pointer hover:bg-black/[0.04] dark:hover:bg-white/[0.06]';
|
||||
|
||||
const disabledClasses = 'opacity-50 cursor-not-allowed pointer-events-none';
|
||||
|
||||
const iconClasses =
|
||||
'flex items-center justify-center flex-shrink-0 w-9 h-9 rounded-[0.625rem] bg-black/[0.04] dark:bg-white/[0.08] text-primary';
|
||||
|
||||
function getRowClasses(isBordered: boolean, isClick: boolean, isDisabled: boolean): string {
|
||||
let classes = baseRowClasses;
|
||||
if (isBordered) classes += ' ' + borderClasses;
|
||||
if (isClick) classes += ' ' + clickableClasses;
|
||||
if (isDisabled) classes += ' ' + disabledClasses;
|
||||
return classes;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
{href}
|
||||
class="settings-row {border ? 'settings-row--border' : ''} settings-row--clickable {disabled
|
||||
? 'settings-row--disabled'
|
||||
: ''} {className}"
|
||||
>
|
||||
<div class="settings-row__content">
|
||||
<a {href} class="{getRowClasses(border, true, disabled)} {className}">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
{#if icon}
|
||||
<span class="settings-row__icon">
|
||||
<span class={iconClasses}>
|
||||
{@render icon()}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="settings-row__text">
|
||||
<span class="settings-row__label">{label}</span>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-[0.9375rem] font-medium text-gray-700 dark:text-gray-100">{label}</span>
|
||||
{#if description}
|
||||
<span class="settings-row__description">{description}</span>
|
||||
<span class="text-[0.8125rem] text-gray-500 dark:text-gray-400 leading-snug"
|
||||
>{description}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row__control">
|
||||
<div class="flex items-center flex-shrink-0">
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<svg class="settings-row__chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
class="w-5 h-5 text-gray-400 dark:text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{:else if onclick}
|
||||
<button
|
||||
type="button"
|
||||
{onclick}
|
||||
class="settings-row {border ? 'settings-row--border' : ''} settings-row--clickable {disabled
|
||||
? 'settings-row--disabled'
|
||||
: ''} {className}"
|
||||
{disabled}
|
||||
>
|
||||
<div class="settings-row__content">
|
||||
<button type="button" {onclick} class="{getRowClasses(border, true, disabled)} {className}" {disabled}>
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
{#if icon}
|
||||
<span class="settings-row__icon">
|
||||
<span class={iconClasses}>
|
||||
{@render icon()}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="settings-row__text">
|
||||
<span class="settings-row__label">{label}</span>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-[0.9375rem] font-medium text-gray-700 dark:text-gray-100">{label}</span>
|
||||
{#if description}
|
||||
<span class="settings-row__description">{description}</span>
|
||||
<span class="text-[0.8125rem] text-gray-500 dark:text-gray-400 leading-snug"
|
||||
>{description}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row__control">
|
||||
<div class="flex items-center flex-shrink-0">
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<svg class="settings-row__chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
class="w-5 h-5 text-gray-400 dark:text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{:else}
|
||||
<div
|
||||
class="settings-row {border ? 'settings-row--border' : ''} {disabled
|
||||
? 'settings-row--disabled'
|
||||
: ''} {className}"
|
||||
>
|
||||
<div class="settings-row__content">
|
||||
<div class="{getRowClasses(border, false, disabled)} {className}">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
{#if icon}
|
||||
<span class="settings-row__icon">
|
||||
<span class={iconClasses}>
|
||||
{@render icon()}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="settings-row__text">
|
||||
<span class="settings-row__label">{label}</span>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-[0.9375rem] font-medium text-gray-700 dark:text-gray-100">{label}</span>
|
||||
{#if description}
|
||||
<span class="settings-row__description">{description}</span>
|
||||
<span class="text-[0.8125rem] text-gray-500 dark:text-gray-400 leading-snug"
|
||||
>{description}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if children}
|
||||
<div class="settings-row__control">
|
||||
<div class="flex items-center flex-shrink-0">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -127,120 +148,9 @@
|
|||
{/if}
|
||||
|
||||
<style>
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.settings-row--border {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
:global(.dark) .settings-row--border {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.settings-row--border:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.settings-row--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-row--clickable:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
:global(.dark) .settings-row--clickable:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.settings-row--disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.settings-row__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-row__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.625rem;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
:global(.dark) .settings-row__icon {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.settings-row__icon :global(svg) {
|
||||
/* Keep SVG sizing for icons passed via snippet */
|
||||
:global(svg) {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
|
||||
.settings-row__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-row__label {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .settings-row__label {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.settings-row__description {
|
||||
font-size: 0.8125rem;
|
||||
color: #6b7280;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
:global(.dark) .settings-row__description {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.settings-row__control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-row__chevron {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
:global(.dark) .settings-row__chevron {
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -15,72 +15,23 @@
|
|||
let { title, icon, class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section class="settings-section {className}">
|
||||
<section class="flex flex-col gap-3 {className}">
|
||||
{#if title}
|
||||
<header class="settings-section__header">
|
||||
<header class="flex items-center gap-2 pl-1">
|
||||
{#if icon}
|
||||
<span class="settings-section__icon">
|
||||
<span
|
||||
class="flex items-center justify-center w-7 h-7 rounded-lg bg-black/[0.04] dark:bg-white/[0.08] text-primary [&>svg]:w-4 [&>svg]:h-4"
|
||||
>
|
||||
{@render icon()}
|
||||
</span>
|
||||
{/if}
|
||||
<h2 class="settings-section__title">{title}</h2>
|
||||
<h2 class="text-[0.9375rem] font-semibold text-gray-700 dark:text-gray-100 m-0 tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<div class="settings-section__content">
|
||||
<div class="flex flex-col gap-2">
|
||||
{@render children()}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-section__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
.settings-section__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
:global(.dark) .settings-section__icon {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.settings-section__icon :global(svg) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.settings-section__title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
:global(.dark) .settings-section__title {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.settings-section__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -36,23 +36,31 @@
|
|||
onToggle(!isOn);
|
||||
}
|
||||
}
|
||||
|
||||
// Tailwind classes
|
||||
const baseClasses = 'flex items-center justify-between gap-4 px-5 py-4';
|
||||
const borderClasses = 'border-b border-black/[0.08] dark:border-white/10 last:border-b-0';
|
||||
const disabledClasses = 'opacity-50 cursor-not-allowed';
|
||||
|
||||
const iconClasses =
|
||||
'flex items-center justify-center flex-shrink-0 w-9 h-9 rounded-[0.625rem] bg-black/[0.04] dark:bg-white/[0.08] text-primary [&>svg]:w-[1.125rem] [&>svg]:h-[1.125rem]';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="settings-toggle {border ? 'settings-toggle--border' : ''} {disabled
|
||||
? 'settings-toggle--disabled'
|
||||
: ''} {className}"
|
||||
class="{baseClasses} {border ? borderClasses : ''} {disabled ? disabledClasses : ''} {className}"
|
||||
>
|
||||
<div class="settings-toggle__content">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
{#if icon}
|
||||
<span class="settings-toggle__icon">
|
||||
<span class={iconClasses}>
|
||||
{@render icon()}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="settings-toggle__text">
|
||||
<span class="settings-toggle__label">{label}</span>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-[0.9375rem] font-medium text-gray-700 dark:text-gray-100">{label}</span>
|
||||
{#if description}
|
||||
<span class="settings-toggle__description">{description}</span>
|
||||
<span class="text-[0.8125rem] text-gray-500 dark:text-gray-400 leading-snug"
|
||||
>{description}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -60,165 +68,20 @@
|
|||
<button
|
||||
type="button"
|
||||
onclick={handleToggle}
|
||||
class="settings-toggle__switch {isOn ? 'settings-toggle__switch--on' : ''}"
|
||||
class="relative w-12 h-7 rounded-full border flex-shrink-0 transition-all duration-200
|
||||
{isOn
|
||||
? 'bg-primary border-primary shadow-[0_0_0_2px_hsl(var(--primary)/0.2)] dark:shadow-[0_0_0_2px_hsl(var(--primary)/0.3)]'
|
||||
: 'bg-black/[0.08] border-black/10 dark:bg-white/[0.12] dark:border-white/[0.15]'}
|
||||
{!disabled ? 'cursor-pointer hover:border-black/20 dark:hover:border-white/25' : 'cursor-not-allowed'}
|
||||
focus-visible:outline-2 focus-visible:outline-primary/40 focus-visible:outline-offset-2"
|
||||
role="switch"
|
||||
aria-checked={isOn}
|
||||
aria-label={label}
|
||||
{disabled}
|
||||
>
|
||||
<span class="settings-toggle__thumb"></span>
|
||||
<span
|
||||
class="absolute top-[1px] left-[1px] w-6 h-6 rounded-full bg-white shadow-md transition-transform duration-200
|
||||
{isOn ? 'translate-x-5' : ''}"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.settings-toggle--border {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
:global(.dark) .settings-toggle--border {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.settings-toggle--border:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.settings-toggle--disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.settings-toggle__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-toggle__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.625rem;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
:global(.dark) .settings-toggle__icon {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.settings-toggle__icon :global(svg) {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
|
||||
.settings-toggle__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-toggle__label {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .settings-toggle__label {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.settings-toggle__description {
|
||||
font-size: 0.8125rem;
|
||||
color: #6b7280;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
:global(.dark) .settings-toggle__description {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Toggle Switch - Glass style */
|
||||
.settings-toggle__switch {
|
||||
position: relative;
|
||||
width: 3rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:global(.dark) .settings-toggle__switch {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.settings-toggle__switch:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.settings-toggle__switch--on {
|
||||
background: hsl(var(--primary));
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.2);
|
||||
}
|
||||
|
||||
:global(.dark) .settings-toggle__switch--on {
|
||||
background: hsl(var(--primary));
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
.settings-toggle__thumb {
|
||||
position: absolute;
|
||||
top: 0.0625rem;
|
||||
left: 0.0625rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 9999px;
|
||||
background-color: white;
|
||||
box-shadow:
|
||||
0 2px 4px rgba(0, 0, 0, 0.15),
|
||||
0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.settings-toggle__switch--on .settings-toggle__thumb {
|
||||
transform: translateX(1.25rem);
|
||||
}
|
||||
|
||||
.settings-toggle__switch:hover:not(:disabled) {
|
||||
border-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
:global(.dark) .settings-toggle__switch:hover:not(:disabled) {
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.settings-toggle__switch--on:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.settings-toggle__switch:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 0.4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue