refactor(contacts): consolidate groups into tags feature

Remove groups functionality and use only tags for contact organization.
Tags provide a simpler, more intuitive approach for categorizing contacts.

Frontend changes:
- Remove groups pages (/groups, /groups/new, /groups/[id])
- Remove groups from navigation
- Update FilterBar to use tags instead of groups
- Update ContactList to use selectedTagId
- Remove groupsApi and batch group operations
- Update i18n translations (group → tag)

Backend changes:
- Remove GroupModule and group controller/service
- Remove groups.schema.ts and contact_to_groups relation
- Remove group-related batch operations (addToGroup, removeFromGroup)
- Remove groupId filtering from contacts and export
- Remove preset groups from seed.ts

🤖 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 18:15:32 +01:00
parent 05dd9a0063
commit 99c28242c5
30 changed files with 116 additions and 3024 deletions

View file

@ -83,11 +83,6 @@ pnpm build # Build for production
| `/api/v1/contacts/:id/favorite` | POST | Toggle favorite |
| `/api/v1/contacts/:id/archive` | POST | Toggle archive |
| `/api/v1/contacts/:id/photo` | POST | Upload contact photo |
| `/api/v1/groups` | GET | Get user's groups |
| `/api/v1/groups` | POST | Create new group |
| `/api/v1/groups/:id` | PATCH | Update group |
| `/api/v1/groups/:id` | DELETE | Delete group |
| `/api/v1/groups/:id/contacts` | POST | Add contacts to group |
| `/api/v1/tags` | GET | Get user's tags |
| `/api/v1/tags` | POST | Create new tag |
| `/api/v1/tags/:id` | DELETE | Delete tag |
@ -129,20 +124,6 @@ pnpm build # Build for production
- `shared_with` (JSONB) - Array of user IDs
- `created_at`, `updated_at` (TIMESTAMP)
**contact_groups** - Groups for organizing contacts
- `id` (UUID) - Primary key
- `user_id` (VARCHAR) - User reference
- `name` (VARCHAR) - Group name
- `description` (TEXT) - Optional description
- `color` (VARCHAR) - Group color
- `created_at` (TIMESTAMP)
**contact_to_groups** - Many-to-many relation
- `contact_id` (UUID) - Contact reference
- `group_id` (UUID) - Group reference
**contact_tags** - Tags for contacts
- `id` (UUID) - Primary key

View file

@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './db/database.module';
import { ContactModule } from './contact/contact.module';
import { GroupModule } from './group/group.module';
import { TagModule } from './tag/tag.module';
import { NoteModule } from './note/note.module';
import { ActivityModule } from './activity/activity.module';
@ -22,7 +21,6 @@ import { BatchModule } from './batch/batch.module';
}),
DatabaseModule,
ContactModule,
GroupModule,
TagModule,
NoteModule,
ActivityModule,

View file

@ -22,11 +22,6 @@ class BatchFavoriteDto extends BatchContactIdsDto {
favorite?: boolean = true;
}
class BatchGroupDto extends BatchContactIdsDto {
@IsString()
groupId: string;
}
class BatchTagsDto extends BatchContactIdsDto {
@IsArray()
@IsString({ each: true })
@ -65,22 +60,6 @@ export class BatchController {
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);

View file

@ -2,7 +2,7 @@ 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 { contacts, contactToTags } from '../db/schema';
import type { Contact } from '../db/schema';
export interface BatchResult {
@ -112,90 +112,6 @@ export class BatchService {
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
*/

View file

@ -143,10 +143,6 @@ class ContactQueryDto {
@Transform(({ value }) => value === 'true')
isArchived?: boolean;
@IsUUID()
@IsOptional()
groupId?: string;
@IsUUID()
@IsOptional()
tagId?: string;

View file

@ -9,7 +9,6 @@ export interface ContactFilters {
search?: string;
isFavorite?: boolean;
isArchived?: boolean;
groupId?: string;
tagId?: string;
limit?: number;
offset?: number;

View file

@ -1,33 +0,0 @@
import { pgTable, uuid, timestamp, varchar, text, primaryKey, boolean } from 'drizzle-orm/pg-core';
import { contacts } from './contacts.schema';
export const contactGroups = pgTable('contact_groups', {
id: uuid('id').primaryKey().defaultRandom(),
userId: varchar('user_id', { length: 255 }).notNull(),
name: varchar('name', { length: 100 }).notNull(),
description: text('description'),
color: varchar('color', { length: 20 }),
icon: varchar('icon', { length: 50 }),
isPreset: boolean('is_preset').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export const contactToGroups = pgTable(
'contact_to_groups',
{
contactId: uuid('contact_id')
.references(() => contacts.id, { onDelete: 'cascade' })
.notNull(),
groupId: uuid('group_id')
.references(() => contactGroups.id, { onDelete: 'cascade' })
.notNull(),
},
(table) => ({
pk: primaryKey({ columns: [table.contactId, table.groupId] }),
})
);
export type ContactGroup = typeof contactGroups.$inferSelect;
export type NewContactGroup = typeof contactGroups.$inferInsert;
export type ContactToGroup = typeof contactToGroups.$inferSelect;
export type NewContactToGroup = typeof contactToGroups.$inferInsert;

View file

@ -1,5 +1,4 @@
export * from './contacts.schema';
export * from './groups.schema';
export * from './tags.schema';
export * from './notes.schema';
export * from './activities.schema';

View file

@ -1,75 +1,14 @@
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { contacts, contactGroups } from './schema';
import { contacts } from './schema';
const DATABASE_URL =
process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/contacts';
// User ID - can be set via environment variable or defaults to 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',
},
];
const USER_ID =
process.env.SEED_USER_ID || process.env.DEV_USER_ID || '00000000-0000-0000-0000-000000000000';
interface SeedContact {
firstName: string;
@ -534,46 +473,6 @@ 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`);
@ -633,15 +532,7 @@ async function seed() {
}
}
async function main() {
// First seed preset groups (system-wide)
await seedPresetGroups();
// Then seed contacts for test user
await seed();
}
main()
seed()
.then(() => {
console.log('🎉 Seed completed!');
process.exit(0);

View file

@ -11,10 +11,6 @@ export class ExportRequestDto {
@IsUUID('4', { each: true })
contactIds?: string[];
@IsOptional()
@IsUUID('4')
groupId?: string;
@IsOptional()
@IsUUID('4')
tagId?: string;

View file

@ -3,7 +3,7 @@ import { eq, and, inArray } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { contacts, type Contact } from '../db/schema';
import { contactToGroups, contactToTags } from '../db/schema';
import { contactToTags } from '../db/schema';
import { ExportRequestDto, ExportFormat } from './dto/export.dto';
import { generateVCardFile } from './generators/vcard.generator';
import { generateCsvFile } from './generators/csv.generator';
@ -48,7 +48,7 @@ export class ExportService {
userId: string,
options: ExportRequestDto
): Promise<Contact[]> {
const { contactIds, groupId, tagId, includeFavorites, includeArchived = false } = options;
const { contactIds, tagId, includeFavorites, includeArchived = false } = options;
// If specific contact IDs are provided, fetch those
if (contactIds && contactIds.length > 0) {
@ -58,25 +58,6 @@ export class ExportService {
.where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds)));
}
// If a group is specified, get contacts in that group
if (groupId) {
const groupContacts = await this.db
.select({ contactId: contactToGroups.contactId })
.from(contactToGroups)
.where(eq(contactToGroups.groupId, groupId));
const contactIdsInGroup = groupContacts.map((gc) => gc.contactId);
if (contactIdsInGroup.length === 0) {
return [];
}
return this.db
.select()
.from(contacts)
.where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIdsInGroup)));
}
// If a tag is specified, get contacts with that tag
if (tagId) {
const taggedContacts = await this.db

View file

@ -1,130 +0,0 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { GroupService } from './group.service';
import { IsString, IsOptional, MaxLength, IsArray, IsUUID } from 'class-validator';
class CreateGroupDto {
@IsString()
@MaxLength(100)
name!: string;
@IsString()
@IsOptional()
description?: string;
@IsString()
@IsOptional()
@MaxLength(20)
color?: string;
}
class UpdateGroupDto {
@IsString()
@IsOptional()
@MaxLength(100)
name?: string;
@IsString()
@IsOptional()
description?: string;
@IsString()
@IsOptional()
@MaxLength(20)
color?: string;
}
class AddContactsDto {
@IsArray()
@IsUUID('4', { each: true })
contactIds!: string[];
}
@Controller('groups')
@UseGuards(JwtAuthGuard)
export class GroupController {
constructor(private readonly groupService: GroupService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
const groups = await this.groupService.findByUserId(user.userId);
return { groups };
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const group = await this.groupService.findById(id, user.userId);
const contactIds = group ? await this.groupService.getContactsInGroup(id) : [];
return { group, contactIds };
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateGroupDto) {
const group = await this.groupService.create({
...dto,
userId: user.userId,
});
return { group };
}
@Patch(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateGroupDto
) {
const group = await this.groupService.update(id, user.userId, dto);
return { group };
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
await this.groupService.delete(id, user.userId);
return { success: true };
}
@Post(':id/contacts')
async addContacts(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AddContactsDto
) {
// Verify group belongs to user
const group = await this.groupService.findById(id, user.userId);
if (!group) {
return { success: false, error: 'Group not found' };
}
for (const contactId of dto.contactIds) {
await this.groupService.addContactToGroup(contactId, id);
}
return { success: true };
}
@Delete(':id/contacts/:contactId')
async removeContact(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Param('contactId', ParseUUIDPipe) contactId: string
) {
// Verify group belongs to user
const group = await this.groupService.findById(id, user.userId);
if (!group) {
return { success: false, error: 'Group not found' };
}
await this.groupService.removeContactFromGroup(contactId, id);
return { success: true };
}
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { GroupController } from './group.controller';
import { GroupService } from './group.service';
@Module({
controllers: [GroupController],
providers: [GroupService],
exports: [GroupService],
})
export class GroupModule {}

View file

@ -1,121 +0,0 @@
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 {
contactGroups,
contactToGroups,
type ContactGroup,
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[]> {
// 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),
or(
eq(contactGroups.userId, userId),
and(eq(contactGroups.userId, SYSTEM_USER_ID), eq(contactGroups.isPreset, true))
)
)
);
return group || null;
}
async create(data: NewContactGroup): Promise<ContactGroup> {
const [group] = await this.db.insert(contactGroups).values(data).returning();
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)
.where(and(eq(contactGroups.id, id), eq(contactGroups.userId, userId)))
.returning();
if (!group) {
throw new NotFoundException('Group not found');
}
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)));
}
async addContactToGroup(contactId: string, groupId: string): Promise<void> {
await this.db.insert(contactToGroups).values({ contactId, groupId }).onConflictDoNothing();
}
async removeContactFromGroup(contactId: string, groupId: string): Promise<void> {
await this.db
.delete(contactToGroups)
.where(and(eq(contactToGroups.contactId, contactId), eq(contactToGroups.groupId, groupId)));
}
async getContactsInGroup(groupId: string): Promise<string[]> {
const results = await this.db
.select({ contactId: contactToGroups.contactId })
.from(contactToGroups)
.where(eq(contactToGroups.groupId, groupId));
return results.map((r) => r.contactId);
}
}

View file

@ -1,6 +1,5 @@
import { authStore } from '$lib/stores/auth.svelte';
const API_BASE = 'http://localhost:3015/api/v1';
import { API_BASE } from './config';
async function fetchWithAuth(url: string, options: RequestInit = {}) {
const token = await authStore.getAccessToken();
@ -55,20 +54,6 @@ export const batchApi = {
});
},
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',

View file

@ -0,0 +1,7 @@
import { PUBLIC_BACKEND_URL } from '$env/static/public';
/**
* API Configuration
* Uses environment variable PUBLIC_BACKEND_URL with fallback for development
*/
export const API_BASE = `${PUBLIC_BACKEND_URL || 'http://localhost:3015'}/api/v1`;

View file

@ -1,6 +1,5 @@
import { authStore } from '$lib/stores/auth.svelte';
const API_BASE = 'http://localhost:3015/api/v1';
import { API_BASE } from './config';
async function fetchWithAuth(url: string, options: RequestInit = {}) {
const token = await authStore.getAccessToken();
@ -57,17 +56,6 @@ export interface Contact {
updatedAt: string;
}
export interface ContactGroup {
id: string;
userId: string;
name: string;
description?: string | null;
color?: string | null;
icon?: string | null;
isPreset: boolean;
createdAt: string;
}
export interface ContactTag {
id: string;
userId: string;
@ -100,7 +88,6 @@ export interface ContactFilters {
search?: string;
isFavorite?: boolean;
isArchived?: boolean;
groupId?: string;
tagId?: string;
limit?: number;
offset?: number;
@ -113,7 +100,6 @@ export const contactsApi = {
if (filters.search) params.set('search', filters.search);
if (filters.isFavorite !== undefined) params.set('isFavorite', String(filters.isFavorite));
if (filters.isArchived !== undefined) params.set('isArchived', String(filters.isArchived));
if (filters.groupId) params.set('groupId', filters.groupId);
if (filters.tagId) params.set('tagId', filters.tagId);
if (filters.limit) params.set('limit', String(filters.limit));
if (filters.offset) params.set('offset', String(filters.offset));
@ -164,50 +150,6 @@ export const contactsApi = {
},
};
// Groups API
export const groupsApi = {
async list() {
return fetchWithAuth('/groups');
},
async get(id: string) {
return fetchWithAuth(`/groups/${id}`);
},
async create(data: { name: string; description?: string; color?: string }) {
return fetchWithAuth('/groups', {
method: 'POST',
body: JSON.stringify(data),
});
},
async update(id: string, data: { name?: string; description?: string; color?: string }) {
return fetchWithAuth(`/groups/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
},
async delete(id: string) {
return fetchWithAuth(`/groups/${id}`, {
method: 'DELETE',
});
},
async addContacts(groupId: string, contactIds: string[]) {
return fetchWithAuth(`/groups/${groupId}/contacts`, {
method: 'POST',
body: JSON.stringify({ contactIds }),
});
},
async removeContact(groupId: string, contactId: string) {
return fetchWithAuth(`/groups/${groupId}/contacts/${contactId}`, {
method: 'DELETE',
});
},
};
// Tags API
export const tagsApi = {
async list(): Promise<{ tags: ContactTag[] }> {

View file

@ -1,8 +1,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { API_BASE } from './config';
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();

View file

@ -1,13 +1,11 @@
import { authStore } from '$lib/stores/auth.svelte';
const API_BASE = 'http://localhost:3015/api/v1';
import { API_BASE } from './config';
export type ExportFormat = 'vcard' | 'csv';
export interface ExportOptions {
format: ExportFormat;
contactIds?: string[];
groupId?: string;
tagId?: string;
includeFavorites?: boolean;
includeArchived?: boolean;

View file

@ -1,6 +1,5 @@
import { authStore } from '$lib/stores/auth.svelte';
const API_BASE = 'http://localhost:3015/api/v1';
import { API_BASE } from './config';
export interface GoogleContact {
resourceName: string;

View file

@ -1,6 +1,5 @@
import { authStore } from '$lib/stores/auth.svelte';
const API_BASE = 'http://localhost:3015/api/v1';
import { API_BASE } from './config';
export interface ParsedContact {
firstName?: string;

View file

@ -1,15 +1,15 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { onMount } from 'svelte';
import { groupsApi, type ContactGroup, type Contact } from '$lib/api/contacts';
import { tagsApi, type ContactTag, type Contact } from '$lib/api/contacts';
export type ContactFilter = 'all' | 'favorites' | 'hasPhone' | 'hasEmail' | 'incomplete';
export type BirthdayFilter = 'all' | 'today' | 'thisWeek' | 'thisMonth';
interface Props {
contacts: Contact[];
selectedGroupId: string | null;
onGroupChange: (groupId: string | null) => void;
selectedTagId: string | null;
onTagChange: (tagId: string | null) => void;
contactFilter: ContactFilter;
onContactFilterChange: (filter: ContactFilter) => void;
birthdayFilter: BirthdayFilter;
@ -20,8 +20,8 @@
let {
contacts,
selectedGroupId,
onGroupChange,
selectedTagId,
onTagChange,
contactFilter,
onContactFilterChange,
birthdayFilter,
@ -30,9 +30,9 @@
onCompanyChange,
}: Props = $props();
let groups = $state<ContactGroup[]>([]);
let tags = $state<ContactTag[]>([]);
let showFilters = $state(false);
let loadingGroups = $state(true);
let loadingTags = $state(true);
// Extract unique companies from contacts
let companies = $derived.by(() => {
@ -48,26 +48,26 @@
// Count active filters (excluding favorites since it has its own quick button)
let activeFilterCount = $derived.by(() => {
let count = 0;
if (selectedGroupId) count++;
if (selectedTagId) count++;
if (contactFilter !== 'all' && contactFilter !== 'favorites') count++;
if (birthdayFilter !== 'all') count++;
if (selectedCompany) count++;
return count;
});
async function loadGroups() {
async function loadTags() {
try {
const response = await groupsApi.list();
groups = response.groups || [];
const response = await tagsApi.list();
tags = response.tags || [];
} catch (e) {
console.error('Failed to load groups:', e);
console.error('Failed to load tags:', e);
} finally {
loadingGroups = false;
loadingTags = false;
}
}
function clearAllFilters() {
onGroupChange(null);
onTagChange(null);
// Keep favorites filter if active (controlled by separate quick button)
if (contactFilter !== 'favorites') {
onContactFilterChange('all');
@ -77,7 +77,7 @@
}
onMount(() => {
loadGroups();
loadTags();
});
</script>
@ -106,12 +106,12 @@
<!-- 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}
{#if selectedTagId}
{@const tag = tags.find((t) => t.id === selectedTagId)}
{#if tag}
<button type="button" class="filter-pill" onclick={() => onTagChange(null)}>
<span class="pill-color" style="background: {tag.color || '#6366f1'}"></span>
{tag.name}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
@ -171,17 +171,17 @@
<!-- Expanded Filter Panel -->
{#if showFilters}
<div class="filter-panel">
<!-- Groups Filter -->
<!-- Tags Filter -->
<div class="filter-section">
<label class="filter-label">{$_('filters.group')}</label>
<label class="filter-label">{$_('filters.tag')}</label>
<select
class="filter-select"
value={selectedGroupId || ''}
onchange={(e) => onGroupChange(e.currentTarget.value || null)}
value={selectedTagId || ''}
onchange={(e) => onTagChange(e.currentTarget.value || null)}
>
<option value="">{$_('filters.allGroups')}</option>
{#each groups as group}
<option value={group.id}>{group.name}</option>
<option value="">{$_('filters.allTags')}</option>
{#each tags as tag}
<option value={tag.id}>{tag.name}</option>
{/each}
</select>
</div>

View file

@ -140,8 +140,8 @@
"filters": {
"title": "Filter",
"clearAll": "Alle löschen",
"group": "Gruppe",
"allGroups": "Alle Gruppen",
"tag": "Tag",
"allTags": "Alle Tags",
"contactInfo": "Kontaktinfo",
"contact": {
"all": "Alle Kontakte",

View file

@ -140,8 +140,8 @@
"filters": {
"title": "Filters",
"clearAll": "Clear all",
"group": "Group",
"allGroups": "All groups",
"tag": "Tag",
"allTags": "All tags",
"contactInfo": "Contact info",
"contact": {
"all": "All contacts",

View file

@ -248,10 +248,10 @@ export const contactsStore = {
},
/**
* Set group filter
* Set tag filter
*/
setGroupId(groupId: string | undefined) {
filters = { ...filters, groupId };
setTagId(tagId: string | undefined) {
filters = { ...filters, tagId };
},
/**

View file

@ -76,7 +76,6 @@
// Navigation items for Contacts
const navItems: PillNavItem[] = [
{ href: '/', label: 'Kontakte', icon: 'users' },
{ href: '/groups', label: 'Gruppen', icon: 'folder' },
{ href: '/tags', label: 'Tags', icon: 'tag' },
{ href: '/favorites', label: 'Favoriten', icon: 'heart' },
{ href: '/archive', label: 'Archiv', icon: 'archive' },

View file

@ -1,618 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { groupsApi } from '$lib/api/contacts';
import type { ContactGroup } from '$lib/api/contacts';
import '$lib/i18n';
let loading = $state(true);
let groups = $state<ContactGroup[]>([]);
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(() => {
const sorted = sortedGroups();
if (!searchQuery.trim()) return sorted;
const query = searchQuery.toLowerCase();
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;
try {
groups = await groupsApi.list();
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Laden der Gruppen';
} finally {
loading = false;
}
}
function handleGroupClick(id: string) {
goto(`/groups/${id}`);
}
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(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';
}
onMount(loadGroups);
</script>
<svelte:head>
<title>Gruppen - Contacts</title>
</svelte:head>
<div class="page-container">
<!-- Header -->
<header class="header">
<a href="/" class="back-button" aria-label="Zurück">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</a>
<h1 class="title">Gruppen</h1>
<a href="/groups/new" class="add-button" aria-label="Neue Gruppe">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</a>
</header>
<!-- Search -->
<div class="search-wrapper">
<svg class="search-icon" 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>
<input
type="text"
placeholder="Gruppen durchsuchen..."
bind:value={searchQuery}
class="search-input"
/>
</div>
{#if error}
<div class="error-banner" role="alert">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>{error}</span>
</div>
{/if}
{#if loading}
<div class="loading-container">
<div class="spinner"></div>
</div>
{:else if groups.length === 0}
<div class="empty-state">
<div class="empty-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="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"
/>
</svg>
</div>
<h2 class="empty-title">Keine Gruppen</h2>
<p class="empty-description">Erstelle deine erste Gruppe um Kontakte zu organisieren.</p>
<a href="/groups/new" class="btn btn-primary">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Neue Gruppe
</a>
</div>
{:else if filteredGroups().length === 0}
<div class="empty-state">
<div class="empty-icon">
<svg 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>
<h2 class="empty-title">Keine Ergebnisse</h2>
<p class="empty-description">Keine Gruppen gefunden für "{searchQuery}"</p>
</div>
{:else}
<!-- 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="M9 5l7 7-7 7"
/>
</svg>
</div>
</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}
</div>
<style>
.page-container {
max-width: 640px;
margin: 0 auto;
padding: 0 1rem 2rem;
}
/* Header */
.header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 0;
position: sticky;
top: 0;
background: hsl(var(--color-background));
z-index: 10;
margin-bottom: 0.5rem;
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
transition: all 0.2s ease;
}
.back-button:hover {
background: hsl(var(--color-surface-hover));
transform: translateX(-2px);
}
.title {
flex: 1;
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--color-foreground));
}
.add-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
transition: all 0.2s ease;
}
.add-button:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px hsl(var(--color-primary) / 0.3);
}
/* Search */
.search-wrapper {
position: relative;
margin-bottom: 1.5rem;
}
.search-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
width: 1.25rem;
height: 1.25rem;
color: hsl(var(--color-muted-foreground));
pointer-events: none;
}
.search-input {
width: 100%;
padding: 0.875rem 1rem 0.875rem 3rem;
border: 1.5px solid hsl(var(--color-border));
border-radius: 0.75rem;
background: hsl(var(--color-input));
color: hsl(var(--color-foreground));
font-size: 0.9375rem;
transition: all 0.2s ease;
}
.search-input:focus {
outline: none;
border-color: hsl(var(--color-primary));
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
}
.search-input::placeholder {
color: hsl(var(--color-muted-foreground) / 0.6);
}
/* Error */
.error-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: hsl(var(--color-error) / 0.1);
border: 1px solid hsl(var(--color-error) / 0.3);
border-radius: 0.75rem;
color: hsl(var(--color-error));
margin-bottom: 1.5rem;
}
/* Loading */
.loading-container {
display: flex;
justify-content: center;
padding: 4rem 0;
}
.spinner {
width: 2.5rem;
height: 2.5rem;
border: 3px solid hsl(var(--color-muted));
border-top-color: hsl(var(--color-primary));
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem 1rem;
text-align: center;
}
.empty-icon {
width: 5rem;
height: 5rem;
border-radius: 50%;
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
}
.empty-icon svg {
width: 2.5rem;
height: 2.5rem;
}
.empty-title {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin-bottom: 0.5rem;
}
.empty-description {
color: hsl(var(--color-muted-foreground));
margin-bottom: 1.5rem;
max-width: 280px;
}
/* Groups List */
.groups-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.group-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
}
.group-card:hover {
border-color: hsl(var(--color-primary) / 0.3);
transform: translateY(-1px);
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 {
flex: 1;
min-width: 0;
}
.group-name {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin-bottom: 0.25rem;
}
.group-description {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.group-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.delete-button {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: transparent;
color: hsl(var(--color-muted-foreground));
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.delete-button:hover {
background: hsl(var(--color-error) / 0.1);
color: hsl(var(--color-error));
}
.chevron {
width: 1.25rem;
height: 1.25rem;
color: hsl(var(--color-muted-foreground));
}
/* Count */
.groups-count {
text-align: center;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin-top: 1.5rem;
}
/* Button */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border-radius: 0.75rem;
font-weight: 600;
font-size: 0.9375rem;
cursor: pointer;
transition: all 0.2s ease;
border: none;
text-decoration: none;
}
.btn-primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
box-shadow: 0 4px 12px hsl(var(--color-primary) / 0.3);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px hsl(var(--color-primary) / 0.4);
}
/* Icons */
.icon {
width: 1.25rem;
height: 1.25rem;
}
.icon-sm {
width: 1rem;
height: 1rem;
}
</style>

View file

@ -1,581 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { groupsApi } from '$lib/api/contacts';
import '$lib/i18n';
let loading = $state(false);
let error = $state<string | null>(null);
let name = $state('');
let description = $state('');
let color = $state('#6366f1');
const presetColors = [
'#ef4444', // red
'#f97316', // orange
'#f59e0b', // amber
'#84cc16', // lime
'#22c55e', // green
'#14b8a6', // teal
'#06b6d4', // cyan
'#3b82f6', // blue
'#6366f1', // indigo
'#8b5cf6', // violet
'#a855f7', // purple
'#ec4899', // pink
];
async function handleSubmit() {
if (!name.trim()) {
error = 'Bitte einen Namen eingeben';
return;
}
loading = true;
error = null;
try {
await groupsApi.create({
name: name.trim(),
description: description.trim() || undefined,
color,
});
goto('/groups');
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Erstellen der Gruppe';
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Neue Gruppe - Contacts</title>
</svelte:head>
<div class="page-container">
<!-- Header -->
<header class="header">
<a href="/groups" class="back-button" aria-label="Zurück">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</a>
<h1 class="title">Neue Gruppe</h1>
<div class="header-spacer"></div>
</header>
<!-- Preview -->
<div class="preview-section">
<div class="preview-color" style="background-color: {color}">
<svg class="preview-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="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"
/>
</svg>
</div>
<p class="preview-name">{name || 'Neue Gruppe'}</p>
{#if description}
<p class="preview-description">{description}</p>
{/if}
</div>
{#if error}
<div class="error-banner" role="alert">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>{error}</span>
</div>
{/if}
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="form"
>
<!-- Name Section -->
<section class="form-section">
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="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"
/>
</svg>
</div>
<h2 class="section-title">Gruppenname</h2>
</div>
<div class="form-field">
<label for="name" class="label">Name</label>
<input
id="name"
type="text"
bind:value={name}
class="input"
placeholder="z.B. Familie, Arbeit, Freunde"
required
/>
</div>
<div class="form-field">
<label for="description" class="label">Beschreibung (optional)</label>
<textarea
id="description"
bind:value={description}
rows="3"
class="input textarea"
placeholder="Kurze Beschreibung der Gruppe..."
></textarea>
</div>
</section>
<!-- Color Section -->
<section class="form-section">
<div class="section-header">
<div class="section-icon" style="background-color: {color}20; color: {color}">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"
/>
</svg>
</div>
<h2 class="section-title">Farbe</h2>
</div>
<div class="color-picker">
{#each presetColors as presetColor}
<button
type="button"
class="color-option"
class:selected={color === presetColor}
style="background-color: {presetColor}"
onclick={() => (color = presetColor)}
aria-label="Farbe {presetColor}"
>
{#if color === presetColor}
<svg class="check-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M5 13l4 4L19 7"
/>
</svg>
{/if}
</button>
{/each}
</div>
<div class="custom-color">
<label for="customColor" class="label">Oder eigene Farbe wählen:</label>
<div class="color-input-wrapper">
<input id="customColor" type="color" bind:value={color} class="color-input" />
<input
type="text"
bind:value={color}
class="input color-text"
pattern="^#[0-9A-Fa-f]{6}$"
placeholder="#6366f1"
/>
</div>
</div>
</section>
<!-- Action Buttons -->
<div class="actions">
<a href="/groups" class="btn btn-secondary"> Abbrechen </a>
<button type="submit" disabled={loading} class="btn btn-primary">
{#if loading}
<svg class="spinner" 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>
<span>Erstellen...</span>
{:else}
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span>Gruppe erstellen</span>
{/if}
</button>
</div>
</form>
</div>
<style>
.page-container {
max-width: 640px;
margin: 0 auto;
padding: 0 1rem 2rem;
}
/* Header */
.header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 0;
position: sticky;
top: 0;
background: hsl(var(--color-background));
z-index: 10;
margin-bottom: 0.5rem;
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
transition: all 0.2s ease;
}
.back-button:hover {
background: hsl(var(--color-surface-hover));
transform: translateX(-2px);
}
.title {
flex: 1;
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--color-foreground));
}
.header-spacer {
width: 2.5rem;
}
/* Preview Section */
.preview-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem 0 2rem;
}
.preview-color {
width: 80px;
height: 80px;
border-radius: 1rem;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
box-shadow: 0 8px 24px currentColor;
transition: all 0.3s ease;
}
.preview-icon {
width: 2.5rem;
height: 2.5rem;
color: white;
}
.preview-name {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--color-foreground));
text-align: center;
}
.preview-description {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
margin-top: 0.25rem;
}
/* Error Banner */
.error-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: hsl(var(--color-error) / 0.1);
border: 1px solid hsl(var(--color-error) / 0.3);
border-radius: 0.75rem;
color: hsl(var(--color-error));
margin-bottom: 1.5rem;
}
/* Form */
.form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Form Section */
.form-section {
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 1rem;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.section-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
margin-bottom: 0.25rem;
}
.section-icon {
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.section-icon svg {
width: 1.125rem;
height: 1.125rem;
}
.section-title {
font-size: 0.9375rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
/* Form Fields */
.form-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.label {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.input {
width: 100%;
padding: 0.75rem 1rem;
border: 1.5px solid hsl(var(--color-border));
border-radius: 0.625rem;
background: hsl(var(--color-input));
color: hsl(var(--color-foreground));
font-size: 0.9375rem;
transition: all 0.2s ease;
}
.input:focus {
outline: none;
border-color: hsl(var(--color-primary));
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
}
.input::placeholder {
color: hsl(var(--color-muted-foreground) / 0.6);
}
.textarea {
resize: none;
min-height: 80px;
}
/* Color Picker */
.color-picker {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.75rem;
}
.color-option {
aspect-ratio: 1;
border-radius: 0.625rem;
border: 3px solid transparent;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.color-option:hover {
transform: scale(1.1);
}
.color-option.selected {
border-color: hsl(var(--color-foreground));
box-shadow: 0 0 0 2px hsl(var(--color-background));
}
.check-icon {
width: 1.25rem;
height: 1.25rem;
color: white;
}
.custom-color {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 1rem;
border-top: 1px solid hsl(var(--color-border) / 0.5);
}
.color-input-wrapper {
display: flex;
gap: 0.75rem;
align-items: center;
}
.color-input {
width: 3rem;
height: 3rem;
border: none;
border-radius: 0.5rem;
cursor: pointer;
padding: 0;
overflow: hidden;
}
.color-input::-webkit-color-swatch-wrapper {
padding: 0;
}
.color-input::-webkit-color-swatch {
border: none;
border-radius: 0.5rem;
}
.color-text {
flex: 1;
text-transform: uppercase;
font-family: monospace;
}
/* Action Buttons */
.actions {
display: flex;
gap: 1rem;
padding-top: 0.5rem;
}
.btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.875rem 1.5rem;
border-radius: 0.75rem;
font-weight: 600;
font-size: 0.9375rem;
cursor: pointer;
transition: all 0.2s ease;
border: none;
text-decoration: none;
}
.btn-primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
box-shadow: 0 4px 12px hsl(var(--color-primary) / 0.3);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 16px hsl(var(--color-primary) / 0.4);
}
.btn-primary:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.btn-secondary {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.btn-secondary:hover {
background: hsl(var(--color-surface-hover));
}
/* Icons */
.icon {
width: 1.25rem;
height: 1.25rem;
}
.icon-sm {
width: 1rem;
height: 1rem;
}
/* Spinner */
.spinner {
width: 1.25rem;
height: 1.25rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Responsive */
@media (max-width: 480px) {
.color-picker {
grid-template-columns: repeat(4, 1fr);
}
.actions {
flex-direction: column-reverse;
}
}
</style>

View file

@ -1,15 +1,76 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import { toasts } from '$lib/stores/toast';
import ToastContainer from '$lib/components/ToastContainer.svelte';
let { children } = $props();
let loading = $state(true);
/**
* Global error handler for unhandled promise rejections and API errors
*/
function setupGlobalErrorHandling() {
if (!browser) return;
// Handle unhandled promise rejections (e.g., failed API calls)
window.addEventListener('unhandledrejection', (event) => {
const error = event.reason;
// Extract error message
let message = 'Ein unerwarteter Fehler ist aufgetreten';
if (error instanceof Error) {
// Network errors
if (error.message === 'Failed to fetch' || error.name === 'TypeError') {
message = 'Netzwerkfehler: Server nicht erreichbar';
}
// Auth errors
else if (
error.message.includes('401') ||
error.message.toLowerCase().includes('unauthorized')
) {
message = 'Sitzung abgelaufen. Bitte erneut anmelden.';
}
// Other API errors
else if (error.message) {
message = error.message;
}
}
// Show toast notification
toasts.error(message);
// Prevent default browser error handling
event.preventDefault();
});
// Handle general JavaScript errors
window.addEventListener('error', (event) => {
// Only handle non-script errors (network failures for resources, etc.)
if (event.message && !event.filename) {
toasts.error('Ein Fehler ist aufgetreten');
}
});
// Handle offline/online status
window.addEventListener('offline', () => {
toasts.warning('Keine Internetverbindung', 10000);
});
window.addEventListener('online', () => {
toasts.success('Verbindung wiederhergestellt');
});
}
onMount(async () => {
// Setup global error handling
setupGlobalErrorHandling();
// Initialize theme
theme.initialize();