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:
Till-JS 2025-12-09 17:45:29 +01:00
parent 23c2d85f6e
commit dd40bb40e7
41 changed files with 5174 additions and 619 deletions

View file

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

View file

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

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

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

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

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

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

View file

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

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

View file

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

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -199,6 +199,13 @@ export const contactsStore = {
filters = { ...filters, search };
},
/**
* Set group filter
*/
setGroupId(groupId: string | undefined) {
filters = { ...filters, groupId };
},
/**
* Clear selected contact
*/

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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