mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
05dd9a0063
commit
99c28242c5
30 changed files with 116 additions and 3024 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -143,10 +143,6 @@ class ContactQueryDto {
|
|||
@Transform(({ value }) => value === 'true')
|
||||
isArchived?: boolean;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
groupId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
tagId?: string;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ export interface ContactFilters {
|
|||
search?: string;
|
||||
isFavorite?: boolean;
|
||||
isArchived?: boolean;
|
||||
groupId?: string;
|
||||
tagId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
export * from './contacts.schema';
|
||||
export * from './groups.schema';
|
||||
export * from './tags.schema';
|
||||
export * from './notes.schema';
|
||||
export * from './activities.schema';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -11,10 +11,6 @@ export class ExportRequestDto {
|
|||
@IsUUID('4', { each: true })
|
||||
contactIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
groupId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
tagId?: string;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { eq, and, inArray } from 'drizzle-orm';
|
|||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { contacts, type Contact } from '../db/schema';
|
||||
import { contactToGroups, contactToTags } from '../db/schema';
|
||||
import { contactToTags } from '../db/schema';
|
||||
import { ExportRequestDto, ExportFormat } from './dto/export.dto';
|
||||
import { generateVCardFile } from './generators/vcard.generator';
|
||||
import { generateCsvFile } from './generators/csv.generator';
|
||||
|
|
@ -48,7 +48,7 @@ export class ExportService {
|
|||
userId: string,
|
||||
options: ExportRequestDto
|
||||
): Promise<Contact[]> {
|
||||
const { contactIds, groupId, tagId, includeFavorites, includeArchived = false } = options;
|
||||
const { contactIds, tagId, includeFavorites, includeArchived = false } = options;
|
||||
|
||||
// If specific contact IDs are provided, fetch those
|
||||
if (contactIds && contactIds.length > 0) {
|
||||
|
|
@ -58,25 +58,6 @@ export class ExportService {
|
|||
.where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds)));
|
||||
}
|
||||
|
||||
// If a group is specified, get contacts in that group
|
||||
if (groupId) {
|
||||
const groupContacts = await this.db
|
||||
.select({ contactId: contactToGroups.contactId })
|
||||
.from(contactToGroups)
|
||||
.where(eq(contactToGroups.groupId, groupId));
|
||||
|
||||
const contactIdsInGroup = groupContacts.map((gc) => gc.contactId);
|
||||
|
||||
if (contactIdsInGroup.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIdsInGroup)));
|
||||
}
|
||||
|
||||
// If a tag is specified, get contacts with that tag
|
||||
if (tagId) {
|
||||
const taggedContacts = await this.db
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { GroupService } from './group.service';
|
||||
import { IsString, IsOptional, MaxLength, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
class CreateGroupDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
}
|
||||
|
||||
class UpdateGroupDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
}
|
||||
|
||||
class AddContactsDto {
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
contactIds!: string[];
|
||||
}
|
||||
|
||||
@Controller('groups')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class GroupController {
|
||||
constructor(private readonly groupService: GroupService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
const groups = await this.groupService.findByUserId(user.userId);
|
||||
return { groups };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
const group = await this.groupService.findById(id, user.userId);
|
||||
const contactIds = group ? await this.groupService.getContactsInGroup(id) : [];
|
||||
return { group, contactIds };
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateGroupDto) {
|
||||
const group = await this.groupService.create({
|
||||
...dto,
|
||||
userId: user.userId,
|
||||
});
|
||||
return { group };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateGroupDto
|
||||
) {
|
||||
const group = await this.groupService.update(id, user.userId, dto);
|
||||
return { group };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.groupService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/contacts')
|
||||
async addContacts(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AddContactsDto
|
||||
) {
|
||||
// Verify group belongs to user
|
||||
const group = await this.groupService.findById(id, user.userId);
|
||||
if (!group) {
|
||||
return { success: false, error: 'Group not found' };
|
||||
}
|
||||
|
||||
for (const contactId of dto.contactIds) {
|
||||
await this.groupService.addContactToGroup(contactId, id);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete(':id/contacts/:contactId')
|
||||
async removeContact(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('contactId', ParseUUIDPipe) contactId: string
|
||||
) {
|
||||
// Verify group belongs to user
|
||||
const group = await this.groupService.findById(id, user.userId);
|
||||
if (!group) {
|
||||
return { success: false, error: 'Group not found' };
|
||||
}
|
||||
|
||||
await this.groupService.removeContactFromGroup(contactId, id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { GroupController } from './group.controller';
|
||||
import { GroupService } from './group.service';
|
||||
|
||||
@Module({
|
||||
controllers: [GroupController],
|
||||
providers: [GroupService],
|
||||
exports: [GroupService],
|
||||
})
|
||||
export class GroupModule {}
|
||||
|
|
@ -1,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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
7
apps/contacts/apps/web/src/lib/api/config.ts
Normal file
7
apps/contacts/apps/web/src/lib/api/config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { PUBLIC_BACKEND_URL } from '$env/static/public';
|
||||
|
||||
/**
|
||||
* API Configuration
|
||||
* Uses environment variable PUBLIC_BACKEND_URL with fallback for development
|
||||
*/
|
||||
export const API_BASE = `${PUBLIC_BACKEND_URL || 'http://localhost:3015'}/api/v1`;
|
||||
|
|
@ -1,6 +1,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[] }> {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const API_BASE = 'http://localhost:3015/api/v1';
|
||||
import { API_BASE } from './config';
|
||||
|
||||
export type ExportFormat = 'vcard' | 'csv';
|
||||
|
||||
export interface ExportOptions {
|
||||
format: ExportFormat;
|
||||
contactIds?: string[];
|
||||
groupId?: string;
|
||||
tagId?: string;
|
||||
includeFavorites?: boolean;
|
||||
includeArchived?: boolean;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const API_BASE = 'http://localhost:3015/api/v1';
|
||||
import { API_BASE } from './config';
|
||||
|
||||
export interface GoogleContact {
|
||||
resourceName: string;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const API_BASE = 'http://localhost:3015/api/v1';
|
||||
import { API_BASE } from './config';
|
||||
|
||||
export interface ParsedContact {
|
||||
firstName?: string;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue