Merge branch 'till-dev' into till-dev-backup

This commit is contained in:
Till-JS 2025-12-10 16:02:35 +01:00
commit f04300d5e9
269 changed files with 27419 additions and 2009 deletions

View file

@ -5,12 +5,36 @@ import { Database } from '../db/connection';
import { contactTags, contactToTags } from '../db/schema';
import type { ContactTag, NewContactTag } from '../db/schema';
const DEFAULT_TAGS = [
{ name: 'Familie', color: '#ec4899' }, // pink
{ name: 'Freunde', color: '#22c55e' }, // green
{ name: 'Arbeit', color: '#3b82f6' }, // blue
{ name: 'Wichtig', color: '#ef4444' }, // red
] as const;
@Injectable()
export class TagService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findByUserId(userId: string): Promise<ContactTag[]> {
return this.db.select().from(contactTags).where(eq(contactTags.userId, userId));
const tags = await this.db.select().from(contactTags).where(eq(contactTags.userId, userId));
// Create default tags on first access (when user has no tags yet)
if (tags.length === 0) {
return this.createDefaultTags(userId);
}
return tags;
}
private async createDefaultTags(userId: string): Promise<ContactTag[]> {
const tagsToCreate = DEFAULT_TAGS.map((tag) => ({
userId,
name: tag.name,
color: tag.color,
}));
return this.db.insert(contactTags).values(tagsToCreate).returning();
}
async findById(id: string, userId: string): Promise<ContactTag | null> {

View file

@ -31,6 +31,7 @@
},
"dependencies": {
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-tags": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*",

View file

@ -1,6 +1,13 @@
@import "tailwindcss";
@import "@manacore/shared-tailwind/themes.css";
/* Scan shared packages for Tailwind classes */
@source "../../../packages/shared/src";
@source "../../../../../packages/shared-ui/src";
@source "../../../../../packages/shared-theme-ui/src";
@source "../../../../../packages/shared-theme-ui/src/components";
@source "../../../../../packages/shared-theme-ui/src/pages";
/* Contacts-specific CSS Variables */
@layer base {
:root {

View file

@ -1,5 +1,7 @@
import { browser } from '$app/environment';
import { authStore } from '$lib/stores/auth.svelte';
import { API_BASE } from './config';
import { createTagsClient, type Tag } from '@manacore/shared-tags';
async function fetchWithAuth(url: string, options: RequestInit = {}) {
const token = await authStore.getAccessToken();
@ -56,13 +58,8 @@ export interface Contact {
updatedAt: string;
}
export interface ContactTag {
id: string;
userId: string;
name: string;
color?: string | null;
createdAt: string;
}
// Re-export Tag as ContactTag for backward compatibility
export type ContactTag = Tag;
export interface ContactNote {
id: string;
@ -150,32 +147,70 @@ export const contactsApi = {
},
};
// Tags API
// Tags API - Uses central Tags API from mana-core-auth
// Contact-tag associations still use the Contacts backend
// Get auth URL dynamically at runtime
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
return 'http://localhost:3001';
}
// Lazy-initialized tags client
let _tagsClient: ReturnType<typeof createTagsClient> | null = null;
function getTagsClient() {
if (!browser) return null;
if (!_tagsClient) {
_tagsClient = createTagsClient({
authUrl: getAuthUrl(),
getToken: async () => {
const token = await authStore.getAccessToken();
return token || '';
},
});
}
return _tagsClient;
}
export const tagsApi = {
// Get all tags from central Tags API
async list(): Promise<{ tags: ContactTag[] }> {
return fetchWithAuth('/tags');
const client = getTagsClient();
if (!client) return { tags: [] };
const tags = await client.getAll();
return { tags };
},
// Create tag via central Tags API
async create(data: { name: string; color?: string }): Promise<{ tag: ContactTag }> {
return fetchWithAuth('/tags', {
method: 'POST',
body: JSON.stringify(data),
});
const client = getTagsClient();
if (!client) throw new Error('Tags client not available');
const tag = await client.create(data);
return { tag };
},
// Update tag via central Tags API
async update(id: string, data: { name?: string; color?: string }): Promise<{ tag: ContactTag }> {
return fetchWithAuth(`/tags/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
const client = getTagsClient();
if (!client) throw new Error('Tags client not available');
const tag = await client.update(id, data);
return { tag };
},
// Delete tag via central Tags API
async delete(id: string): Promise<{ success: boolean }> {
return fetchWithAuth(`/tags/${id}`, {
method: 'DELETE',
});
const client = getTagsClient();
if (!client) throw new Error('Tags client not available');
await client.delete(id);
return { success: true };
},
// Contact-tag associations still use Contacts backend
async addToContact(tagId: string, contactId: string): Promise<{ success: boolean }> {
return fetchWithAuth(`/tags/${tagId}/contacts/${contactId}`, {
method: 'POST',
@ -191,6 +226,14 @@ export const tagsApi = {
async getForContact(contactId: string): Promise<{ tagIds: string[] }> {
return fetchWithAuth(`/tags/contact/${contactId}`);
},
// Create default tags via central Tags API
async createDefaults(): Promise<{ tags: ContactTag[] }> {
const client = getTagsClient();
if (!client) return { tags: [] };
const tags = await client.createDefaults();
return { tags };
},
};
// Notes API

View file

@ -3,9 +3,9 @@
import { viewModeStore, type ViewMode } from '$lib/stores/view-mode.svelte';
const modes: { id: ViewMode; icon: string; label: string }[] = [
{ id: 'alphabet', icon: 'alphabet', label: 'views.alphabet' },
{ id: 'list', icon: 'list', label: 'views.list' },
{ id: 'grid', icon: 'grid', label: 'views.grid' },
{ id: 'alphabet', icon: 'alphabet', label: 'views.alphabet' },
];
</script>

View file

@ -74,7 +74,14 @@
function scrollToLetter(letter: string) {
const element = document.getElementById(`section-${letter}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
const headerOffset = 100; // Account for sticky header
const elementPosition = element.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.scrollY - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth',
});
}
}
</script>
@ -296,6 +303,10 @@
gap: 0.5rem;
}
.section-contacts .alphabet-contact-card:last-child {
margin-bottom: 1rem;
}
.alphabet-contact-card {
display: flex;
align-items: center;

View file

@ -0,0 +1,15 @@
/**
* Custom Themes Store - Manages user's custom themes and community themes
*/
import { createCustomThemesStore } from '@manacore/shared-theme';
import { authStore } from './auth.svelte';
// Auth URL for theme API calls
const MANA_AUTH_URL = 'http://localhost:3001';
// Create the custom themes store
export const customThemesStore = createCustomThemesStore({
authUrl: MANA_AUTH_URL,
getAccessToken: () => authStore.getAccessToken(),
});

View file

@ -12,25 +12,15 @@ import {
forceCenter,
forceCollide,
type Simulation,
type SimulationNodeDatum,
type SimulationLinkDatum,
} from 'd3-force';
import type {
SimulationNode as SharedSimulationNode,
SimulationLink as SharedSimulationLink,
} from '@manacore/shared-ui';
// Extended types for D3 simulation
export interface SimulationNode extends NetworkNode, SimulationNodeDatum {
x?: number;
y?: number;
vx?: number;
vy?: number;
fx?: number | null;
fy?: number | null;
}
export interface SimulationLink extends SimulationLinkDatum<SimulationNode> {
type: 'tag';
strength: number;
sharedTags: string[];
}
// Re-export types from shared-ui for convenience
export type SimulationNode = SharedSimulationNode;
export type SimulationLink = SharedSimulationLink;
// State
let nodes = $state<SimulationNode[]>([]);
@ -42,6 +32,7 @@ let simulation: Simulation<SimulationNode, SimulationLink> | null = null;
let searchQuery = $state('');
let filterTagId = $state<string | null>(null);
let filterCompany = $state<string | null>(null);
let minStrength = $state(0);
let tickCounter = $state(0); // Used to trigger reactivity on simulation tick
let simulationInitialized = false;
let dataLoaded = false; // Prevent double loading
@ -57,7 +48,7 @@ const filteredNodes = $derived.by(() => {
result = result.filter(
(node) =>
node.name.toLowerCase().includes(query) ||
node.company?.toLowerCase().includes(query) ||
node.subtitle?.toLowerCase().includes(query) ||
node.tags.some((t) => t.name.toLowerCase().includes(query))
);
}
@ -67,9 +58,9 @@ const filteredNodes = $derived.by(() => {
result = result.filter((node) => node.tags.some((t) => t.id === filterTagId));
}
// Company filter
// Company filter (uses subtitle field)
if (filterCompany) {
result = result.filter((node) => node.company === filterCompany);
result = result.filter((node) => node.subtitle === filterCompany);
}
return result;
@ -80,16 +71,24 @@ const filteredLinks = $derived.by(() => {
return links.filter((link) => {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
return filteredNodeIds.has(sourceId) && filteredNodeIds.has(targetId);
// Check if both nodes are visible and strength meets minimum
if (!filteredNodeIds.has(sourceId) || !filteredNodeIds.has(targetId)) {
return false;
}
// Filter by minimum strength
if (minStrength > 0 && link.strength < minStrength) {
return false;
}
return true;
});
});
// Get unique companies for filter dropdown
// Get unique companies for filter dropdown (uses subtitle field)
const uniqueCompanies = $derived.by(() => {
const companies = new Set<string>();
for (const node of nodes) {
if (node.company) {
companies.add(node.company);
if (node.subtitle) {
companies.add(node.subtitle);
}
}
return Array.from(companies).sort();
@ -151,6 +150,9 @@ export const networkStore = {
get filterCompany() {
return filterCompany;
},
get minStrength() {
return minStrength;
},
get uniqueCompanies() {
return uniqueCompanies;
},
@ -194,9 +196,10 @@ export const networkStore = {
'links'
);
// Convert to simulation nodes
// Convert to simulation nodes with subtitle for company
nodes = response.nodes.map((node) => ({
...node,
subtitle: node.company, // Map company to subtitle for shared component
x: undefined,
y: undefined,
vx: undefined,
@ -392,6 +395,13 @@ export const networkStore = {
filterCompany = company;
},
/**
* Set minimum strength filter
*/
setMinStrength(strength: number) {
minStrength = strength;
},
/**
* Clear all filters
*/
@ -399,6 +409,7 @@ export const networkStore = {
searchQuery = '';
filterTagId = null;
filterCompany = null;
minStrength = 0;
},
/**

View file

@ -59,7 +59,7 @@ export interface ContactsAppSettings {
const DEFAULT_SETTINGS: ContactsAppSettings = {
// Display Settings
defaultView: 'list',
defaultView: 'alphabet',
sortBy: 'name',
sortOrder: 'asc',
showPhotos: true,

View file

@ -0,0 +1,275 @@
/**
* Contacts Statistics Store - Calculates contact statistics using Svelte 5 runes
*/
import type { Contact } from '$lib/api/contacts';
import { subDays, format, parseISO, isWithinInterval, getMonth, eachDayOfInterval } from 'date-fns';
import { de } from 'date-fns/locale';
import type {
HeatmapDataPoint,
TrendDataPoint,
DonutSegment,
ProgressItem,
} from '@manacore/shared-ui';
// Types
export interface ContactTag {
id: string;
name: string;
color: string;
}
// State
let contacts = $state<Contact[]>([]);
let tags = $state<ContactTag[]>([]);
export const contactsStatisticsStore = {
// Setters
setContacts(newContacts: Contact[]) {
contacts = newContacts;
},
setTags(newTags: ContactTag[]) {
tags = newTags;
},
// Quick Stats
get totalContacts() {
return contacts.length;
},
get favoriteContacts() {
return contacts.filter((c) => c.isFavorite).length;
},
get archivedContacts() {
return contacts.filter((c) => c.isArchived).length;
},
get activeContacts() {
return contacts.filter((c) => !c.isArchived).length;
},
get recentlyAdded() {
const weekAgo = subDays(new Date(), 7);
return contacts.filter((c) => {
const createdAt =
typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt);
return createdAt >= weekAgo;
}).length;
},
get birthdaysThisMonth() {
const currentMonth = getMonth(new Date());
return contacts.filter((c) => {
if (!c.birthday) return false;
const birthday = typeof c.birthday === 'string' ? parseISO(c.birthday) : new Date(c.birthday);
return getMonth(birthday) === currentMonth;
}).length;
},
get contactsWithEmail() {
return contacts.filter((c) => c.email).length;
},
get contactsWithPhone() {
return contacts.filter((c) => c.phone || c.mobile).length;
},
// Completeness rate (contacts with email AND phone)
get completenessRate() {
if (contacts.length === 0) return 0;
const complete = contacts.filter((c) => c.email && (c.phone || c.mobile)).length;
return Math.round((complete / contacts.length) * 100);
},
// Activity Heatmap (last 6 months) - based on contact creation
get activityHeatmap(): HeatmapDataPoint[] {
const endDate = new Date();
const startDate = subDays(endDate, 180);
// Count contacts created per day
const creationMap = new Map<string, number>();
contacts.forEach((c) => {
const createdAt =
typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt);
if (createdAt >= startDate && createdAt <= endDate) {
const dateKey = format(createdAt, 'yyyy-MM-dd');
creationMap.set(dateKey, (creationMap.get(dateKey) || 0) + 1);
}
});
// Generate all days
const days = eachDayOfInterval({ start: startDate, end: endDate });
return days.map((day) => {
const dateKey = format(day, 'yyyy-MM-dd');
return {
date: dateKey,
count: creationMap.get(dateKey) || 0,
dayOfWeek: day.getDay(),
};
});
},
// Weekly Trend (last 4 weeks)
get weeklyTrend(): TrendDataPoint[] {
const endDate = new Date();
const startDate = subDays(endDate, 27);
const creationMap = new Map<string, number>();
contacts.forEach((c) => {
const createdAt =
typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt);
if (createdAt >= startDate && createdAt <= endDate) {
const dateKey = format(createdAt, 'yyyy-MM-dd');
creationMap.set(dateKey, (creationMap.get(dateKey) || 0) + 1);
}
});
const days = eachDayOfInterval({ start: startDate, end: endDate });
return days.map((day) => {
const dateKey = format(day, 'yyyy-MM-dd');
return {
date: dateKey,
count: creationMap.get(dateKey) || 0,
label: format(day, 'EEE', { locale: de }),
};
});
},
// Contact Status Breakdown (Donut Chart) - Favorites / Active / Archived
get statusBreakdown(): DonutSegment[] {
const total = contacts.length;
if (total === 0) return [];
const favorites = contacts.filter((c) => c.isFavorite && !c.isArchived).length;
const archived = contacts.filter((c) => c.isArchived).length;
const regular = contacts.filter((c) => !c.isFavorite && !c.isArchived).length;
return [
{
id: 'favorites',
label: 'Favoriten',
count: favorites,
percentage: Math.round((favorites / total) * 100),
color: '#F59E0B', // amber
},
{
id: 'regular',
label: 'Aktiv',
count: regular,
percentage: Math.round((regular / total) * 100),
color: '#10B981', // green
},
{
id: 'archived',
label: 'Archiviert',
count: archived,
percentage: Math.round((archived / total) * 100),
color: '#6B7280', // gray
},
];
},
// Tags Progress (Progress Bars)
get tagProgress(): ProgressItem[] {
// Count contacts per tag
const tagCountMap = new Map<string, number>();
// This requires contacts to have a tags array - we'll estimate from the tag data
// For now, we'll show tags with placeholder counts
// In a real implementation, we'd need contactTags relation data
const result: ProgressItem[] = tags.map((tag) => ({
id: tag.id,
name: tag.name,
color: tag.color || '#6B7280',
total: contacts.length, // Total contacts as reference
completed: 0, // Would need contact-tag relation to calculate
percentage: 0,
}));
return result.sort((a, b) => b.completed - a.completed);
},
// Info completeness breakdown
get infoBreakdown(): DonutSegment[] {
const total = contacts.length;
if (total === 0) return [];
const withEmail = contacts.filter((c) => c.email).length;
const withPhone = contacts.filter((c) => c.phone || c.mobile).length;
const withCompany = contacts.filter((c) => c.company).length;
const withBirthday = contacts.filter((c) => c.birthday).length;
return [
{
id: 'email',
label: 'Mit E-Mail',
count: withEmail,
percentage: Math.round((withEmail / total) * 100),
color: '#3B82F6', // blue
},
{
id: 'phone',
label: 'Mit Telefon',
count: withPhone,
percentage: Math.round((withPhone / total) * 100),
color: '#10B981', // green
},
{
id: 'company',
label: 'Mit Firma',
count: withCompany,
percentage: Math.round((withCompany / total) * 100),
color: '#8B5CF6', // violet
},
{
id: 'birthday',
label: 'Mit Geburtstag',
count: withBirthday,
percentage: Math.round((withBirthday / total) * 100),
color: '#EC4899', // pink
},
];
},
// Country breakdown
get countryBreakdown(): ProgressItem[] {
const countryMap = new Map<string, number>();
contacts.forEach((c) => {
const country = c.country || 'Unbekannt';
countryMap.set(country, (countryMap.get(country) || 0) + 1);
});
const result: ProgressItem[] = [];
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#6B7280'];
let colorIndex = 0;
countryMap.forEach((count, country) => {
if (country !== 'Unbekannt' || count > 0) {
result.push({
id: country,
name: country,
color: colors[colorIndex % colors.length],
total: contacts.length,
completed: count,
percentage: Math.round((count / contacts.length) * 100),
});
colorIndex++;
}
});
return result.sort((a, b) => b.completed - a.completed).slice(0, 8);
},
// Total tags count
get totalTags() {
return tags.length;
},
};

View file

@ -10,9 +10,9 @@ export type ViewMode = ContactView;
const STORAGE_KEY = 'contacts-view-mode';
// Get initial mode: current session preference > settings default > 'list'
// Get initial mode: current session preference > settings default > 'alphabet'
function getInitialMode(): ViewMode {
if (!browser) return 'list';
if (!browser) return 'alphabet';
// First check if there's a session-specific preference
const sessionMode = sessionStorage.getItem(STORAGE_KEY);
@ -21,7 +21,7 @@ function getInitialMode(): ViewMode {
}
// Otherwise use the default from settings
return contactsSettings.defaultView || 'list';
return contactsSettings.defaultView || 'alphabet';
}
let mode = $state<ViewMode>(getInitialMode());
@ -43,7 +43,7 @@ export const viewModeStore = {
* Reset to default view from settings
*/
resetToDefault() {
mode = contactsSettings.defaultView || 'list';
mode = contactsSettings.defaultView || 'alphabet';
if (browser) {
sessionStorage.removeItem(STORAGE_KEY);
}
@ -61,7 +61,7 @@ export const viewModeStore = {
mode = sessionMode;
} else {
// Use default from settings
mode = contactsSettings.defaultView || 'list';
mode = contactsSettings.defaultView || 'alphabet';
}
},
};

View file

@ -0,0 +1,227 @@
/**
* Contact Parser for Contacts App
*
* Extends the base parser with contact-specific patterns:
* - Company: @CompanyName or bei CompanyName
* - Email: Recognizes email addresses
* - Phone: Recognizes phone numbers
* - Name: First and last name extraction
*/
import { extractTags, extractAtReference } from '@manacore/shared-utils';
export interface ParsedContact {
displayName: string;
firstName?: string;
lastName?: string;
company?: string;
email?: string;
phone?: string;
tagNames: string[];
}
interface Tag {
id: string;
name: string;
}
export interface ParsedContactWithIds {
displayName: string;
firstName?: string;
lastName?: string;
company?: string;
email?: string;
phone?: string;
tagIds: string[];
}
// Email pattern
const EMAIL_PATTERN = /\b([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})\b/;
// Phone patterns (various formats)
const PHONE_PATTERNS: RegExp[] = [
// International format: +49 123 456789, +49-123-456789
/\+\d{1,3}[-\s]?\d{2,4}[-\s]?\d{3,}[-\s]?\d*/,
// German format: 0123 456789, 0123/456789
/\b0\d{2,4}[-\s/]?\d{3,}[-\s]?\d*/,
// Simple format: 123456789 (at least 6 digits)
/\b\d{6,}\b/,
];
// Company patterns (alternative to @company)
const COMPANY_PATTERNS: RegExp[] = [
/\bbei\s+([^@#]+?)(?=\s+(?:@|#|\+|[a-zA-Z0-9._%+-]+@)|$)/i,
/\bvon\s+([^@#]+?)(?=\s+(?:@|#|\+|[a-zA-Z0-9._%+-]+@)|$)/i,
];
/**
* Extract email from text
*/
function extractEmail(text: string): { email?: string; remaining: string } {
const match = text.match(EMAIL_PATTERN);
if (match) {
return {
email: match[1],
remaining: text.replace(EMAIL_PATTERN, '').trim(),
};
}
return { email: undefined, remaining: text };
}
/**
* Extract phone number from text
*/
function extractPhone(text: string): { phone?: string; remaining: string } {
for (const pattern of PHONE_PATTERNS) {
const match = text.match(pattern);
if (match) {
return {
phone: match[0].trim(),
remaining: text.replace(pattern, '').trim(),
};
}
}
return { phone: undefined, remaining: text };
}
/**
* Extract company from text (bei/von patterns)
*/
function extractCompanyPattern(text: string): { company?: string; remaining: string } {
for (const pattern of COMPANY_PATTERNS) {
const match = text.match(pattern);
if (match) {
return {
company: match[1].trim(),
remaining: text.replace(pattern, '').trim(),
};
}
}
return { company: undefined, remaining: text };
}
/**
* Extract first and last name from display name
*/
function parseNames(displayName: string): { firstName?: string; lastName?: string } {
const parts = displayName.trim().split(/\s+/);
if (parts.length === 0) {
return {};
}
if (parts.length === 1) {
return { firstName: parts[0] };
}
// First part is first name, rest is last name
return {
firstName: parts[0],
lastName: parts.slice(1).join(' '),
};
}
/**
* Parse natural language contact input
*
* Examples:
* - "Max Mustermann @ACME Corp max@example.com #kunde #wichtig"
* - "Anna Schmidt bei Google +49 123 456789"
* - "Peter Müller peter@mail.de #privat"
*/
export function parseContactInput(input: string): ParsedContact {
let text = input.trim();
// Extract tags first (#tag1 #tag2)
const tagsResult = extractTags(text);
text = tagsResult.remaining;
const tagNames = tagsResult.value || [];
// Extract company via @CompanyName
const atRefResult = extractAtReference(text);
text = atRefResult.remaining;
let company = atRefResult.value;
// If no @company, try bei/von patterns
if (!company) {
const companyPatternResult = extractCompanyPattern(text);
text = companyPatternResult.remaining;
company = companyPatternResult.company;
}
// Extract email
const emailResult = extractEmail(text);
text = emailResult.remaining;
const email = emailResult.email;
// Extract phone
const phoneResult = extractPhone(text);
text = phoneResult.remaining;
const phone = phoneResult.phone;
// Clean up multiple spaces and get display name
const displayName = text.replace(/\s+/g, ' ').trim();
// Parse first and last name
const { firstName, lastName } = parseNames(displayName);
return {
displayName,
firstName,
lastName,
company,
email,
phone,
tagNames,
};
}
/**
* Resolve tag names to IDs
*/
export function resolveContactIds(parsed: ParsedContact, tags: Tag[]): ParsedContactWithIds {
const tagIds: string[] = [];
// Find tags by name (case-insensitive)
for (const tagName of parsed.tagNames) {
const tag = tags.find((t) => t.name.toLowerCase() === tagName.toLowerCase());
if (tag) {
tagIds.push(tag.id);
}
}
return {
displayName: parsed.displayName,
firstName: parsed.firstName,
lastName: parsed.lastName,
company: parsed.company,
email: parsed.email,
phone: parsed.phone,
tagIds,
};
}
/**
* Format parsed contact for preview display
*/
export function formatParsedContactPreview(parsed: ParsedContact): string {
const parts: string[] = [];
if (parsed.company) {
parts.push(`🏢 ${parsed.company}`);
}
if (parsed.email) {
parts.push(`📧 ${parsed.email}`);
}
if (parsed.phone) {
parts.push(`📞 ${parsed.phone}`);
}
if (parsed.tagNames.length > 0) {
parts.push(`🏷️ ${parsed.tagNames.join(', ')}`);
}
return parts.join(' · ');
}

View file

@ -9,11 +9,18 @@
PillDropdownItem,
CommandBarItem,
QuickAction,
CreatePreview,
} from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
@ -23,13 +30,21 @@
import { setLocale, supportedLocales } from '$lib/i18n';
import ContactDetailModal from '$lib/components/ContactDetailModal.svelte';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { contactsApi } from '$lib/api/contacts';
import { contactsApi, tagsApi } from '$lib/api/contacts';
import { viewModeStore } from '$lib/stores/view-mode.svelte';
import { contactsSettings } from '$lib/stores/settings.svelte';
import {
parseContactInput,
resolveContactIds,
formatParsedContactPreview,
} from '$lib/utils/contact-parser';
// Search modal state
let searchModalOpen = $state(false);
// Tags state for Quick-Create
let availableTags = $state<{ id: string; name: string }[]>([]);
// Check if we're on a contact detail route
const contactDetailMatch = $derived($page.url.pathname.match(/^\/contacts\/([0-9a-f-]{36})$/i));
const showContactModal = $derived(!!contactDetailMatch);
@ -46,10 +61,20 @@
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
// Get pinned themes from user settings (extended themes only)
let pinnedThemes = $derived<ThemeVariant[]>(
(userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant =>
EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant)
)
);
// Visible themes in PillNav: default + pinned extended
let visibleThemes = $derived<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
// Theme variants
...theme.variants.map((variant) => ({
// Theme variants (only default + pinned)
...visibleThemes.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant].label,
icon: THEME_DEFINITIONS[variant].icon,
@ -82,19 +107,25 @@
// User email for user dropdown (fallback to 'Menü' when not logged in)
let userEmail = $derived(authStore.user?.email || 'Menü');
// Navigation items for Contacts
const navItems: PillNavItem[] = [
// Base navigation items for Contacts
const baseNavItems: PillNavItem[] = [
{ href: '/', label: 'Kontakte', icon: 'users' },
{ href: '/tags', label: 'Tags', icon: 'tag' },
{ href: '/favorites', label: 'Favoriten', icon: 'heart' },
{ href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' },
{ href: '/network', label: 'Netzwerk', icon: 'share-2' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
{ href: '/help', label: 'Hilfe', icon: 'help-circle' },
];
// Navigation shortcuts (Ctrl+1-5)
const navRoutes = navItems.map((item) => item.href);
// Navigation items filtered by visibility settings
const navItems = $derived(
filterHiddenNavItems('contacts', baseNavItems, userSettings.nav.hiddenNavItems)
);
// Navigation shortcuts (Ctrl+1-5) - use base items for consistent shortcuts
const navRoutes = baseNavItems.map((item) => item.href);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
@ -178,6 +209,47 @@
goto(`/contacts/${item.id}`);
}
// CommandBar Quick-Create handlers
function handleCommandBarParseCreate(query: string): CreatePreview | null {
if (!query.trim()) return null;
const parsed = parseContactInput(query);
if (!parsed.displayName) return null;
return {
title: parsed.displayName,
subtitle: formatParsedContactPreview(parsed),
};
}
async function handleCommandBarCreate(query: string): Promise<void> {
const parsed = parseContactInput(query);
if (!parsed.displayName) return;
// Resolve tag names to IDs
const resolved = resolveContactIds(parsed, availableTags);
try {
const contact = await contactsStore.createContact({
displayName: resolved.displayName,
firstName: resolved.firstName,
lastName: resolved.lastName,
company: resolved.company,
email: resolved.email,
phone: resolved.phone,
});
// Add tags to the created contact
if (resolved.tagIds.length > 0 && contact) {
for (const tagId of resolved.tagIds) {
await tagsApi.addToContact(tagId, contact.id);
}
}
} catch (e) {
console.error('Failed to create contact:', e);
}
}
// CommandBar quick actions
const commandBarQuickActions: QuickAction[] = [
{
@ -199,9 +271,17 @@
return;
}
// Load user settings
// Load user settings and tags
await userSettings.load();
// Load tags for Quick-Create
try {
const tagsResult = await tagsApi.list();
availableTags = (tagsResult.tags || []).map((t) => ({ id: t.id, name: t.name }));
} catch (e) {
console.error('Failed to load tags:', e);
}
// Initialize contacts settings and view mode
contactsSettings.initialize();
viewModeStore.initialize();
@ -226,6 +306,9 @@
<!-- Navigation Layout -->
<div class="layout-container">
<!-- Shadow gradient above navigation -->
<div class="nav-shadow-gradient"></div>
<!-- Floating/Sidebar Pill Navigation -->
<PillNavigation
items={navItems}
@ -267,7 +350,7 @@
class:sidebar-mode={isSidebarMode && !isCollapsed}
class:floating-mode={!isSidebarMode}
>
<div class="content-wrapper" class:settings-page={$page.url.pathname === '/settings'}>
<div class="content-wrapper">
{@render children()}
</div>
</main>
@ -284,9 +367,13 @@
onSearch={handleCommandBarSearch}
onSelect={handleCommandBarSelect}
quickActions={commandBarQuickActions}
placeholder="Kontakt suchen..."
placeholder="Kontakt suchen oder erstellen..."
emptyText="Keine Kontakte gefunden"
searchingText="Suche..."
onCreate={handleCommandBarCreate}
onParseCreate={handleCommandBarParseCreate}
createText="Als Kontakt erstellen"
createShortcut="⌘↵"
/>
</div>
@ -320,35 +407,44 @@
}
.content-wrapper {
max-width: 80rem;
max-width: 900px;
margin-left: auto;
margin-right: auto;
padding: 2rem 1rem;
}
/* Settings page has its own padding and max-width */
.content-wrapper.settings-page {
max-width: none;
padding: 0;
padding: 1rem;
}
@media (min-width: 640px) {
.content-wrapper {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.content-wrapper.settings-page {
padding: 0;
padding: 1.5rem;
}
}
@media (min-width: 1024px) {
.content-wrapper {
padding-left: 2rem;
padding-right: 2rem;
padding: 2rem;
}
.content-wrapper.settings-page {
padding: 0;
}
/* Shadow gradient above pill navigation */
.nav-shadow-gradient {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 80px;
background: linear-gradient(
to bottom,
hsl(var(--background)) 0%,
hsl(var(--background)) 50%,
hsl(var(--background) / 0) 100%
);
pointer-events: none;
z-index: 40;
}
@media (max-width: 768px) {
.nav-shadow-gradient {
height: 90px;
}
}
</style>

View file

@ -1,23 +1,47 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { networkStore, type SimulationNode } from '$lib/stores/network.svelte';
import NetworkGraph from '$lib/components/network/NetworkGraph.svelte';
import NetworkControls from '$lib/components/network/NetworkControls.svelte';
import { NetworkGraph, NetworkControls } from '@manacore/shared-ui';
import ContactDetailModal from '$lib/components/ContactDetailModal.svelte';
import { NetworkGraphSkeleton } from '$lib/components/skeletons';
import '$lib/i18n';
let graphComponent: NetworkGraph;
let controlsComponent: NetworkControls;
let graphContainer: HTMLDivElement;
function handleNodeClick(node: SimulationNode) {
// Select node (highlight connections and show detail sidebar)
networkStore.selectNode(node.id);
}
function handleNodeDoubleClick(node: SimulationNode) {
// Navigate to contact detail page
goto(`/contacts/${node.id}`);
}
function handleBackgroundClick() {
networkStore.selectNode(null);
}
function handleCloseSidebar() {
networkStore.selectNode(null);
}
function handleDragStart(node: SimulationNode) {
networkStore.fixNode(node.id, node.x ?? 0, node.y ?? 0);
networkStore.reheatSimulation();
}
function handleDrag(node: SimulationNode, x: number, y: number) {
networkStore.fixNode(node.id, x, y);
}
function handleDragEnd(node: SimulationNode) {
networkStore.releaseNode(node.id);
}
function handleZoomIn() {
graphComponent?.zoomIn();
}
@ -30,9 +54,51 @@
graphComponent?.resetZoom();
}
function handleFocusSelected() {
graphComponent?.focusOnSelectedNode();
}
function handleFocusSearch() {
controlsComponent?.focusSearch();
}
function handleSearch(query: string) {
networkStore.setSearch(query);
}
function handleTagFilter(tagId: string | null) {
networkStore.setFilterTag(tagId);
}
function handleSubtitleFilter(company: string | null) {
networkStore.setFilterCompany(company);
}
function handleStrengthFilter(strength: number) {
networkStore.setMinStrength(strength);
}
function handleClearFilters() {
networkStore.clearFilters();
}
// Initialize simulation when data is loaded and container is ready
$effect(() => {
if (!networkStore.loading && networkStore.allNodes.length > 0 && graphContainer) {
const rect = graphContainer.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
networkStore.initSimulation(rect.width, rect.height);
}
}
});
onMount(() => {
networkStore.loadGraph();
});
onDestroy(() => {
networkStore.stopSimulation();
});
</script>
<svelte:head>
@ -43,9 +109,28 @@
<!-- Controls (floating) -->
<div class="controls-wrapper">
<NetworkControls
bind:this={controlsComponent}
searchQuery={networkStore.searchQuery}
tags={networkStore.uniqueTags}
selectedTagId={networkStore.filterTagId}
subtitles={networkStore.uniqueCompanies}
selectedSubtitle={networkStore.filterCompany}
subtitleLabel="Firma"
nodeCount={networkStore.nodes.length}
linkCount={networkStore.links.length}
nodeLabel="Kontakte"
linkLabel="Verbindungen"
searchPlaceholder="Kontakt suchen..."
minStrength={networkStore.minStrength}
onSearch={handleSearch}
onTagFilter={handleTagFilter}
onSubtitleFilter={handleSubtitleFilter}
onStrengthFilter={handleStrengthFilter}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onResetZoom={handleResetZoom}
onFocusSelected={handleFocusSelected}
onClearFilters={handleClearFilters}
/>
</div>
@ -65,11 +150,23 @@
{/if}
<!-- Main Content -->
<div class="graph-container">
<div class="graph-container" bind:this={graphContainer}>
{#if networkStore.loading}
<NetworkGraphSkeleton />
{:else}
<NetworkGraph bind:this={graphComponent} onNodeClick={handleNodeClick} />
<NetworkGraph
bind:this={graphComponent}
nodes={networkStore.nodes}
links={networkStore.links}
selectedNodeId={networkStore.selectedNodeId}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
onBackgroundClick={handleBackgroundClick}
onDragStart={handleDragStart}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
onFocusSearch={handleFocusSearch}
/>
{/if}
</div>

View file

@ -0,0 +1,280 @@
<script lang="ts">
import { onMount } from 'svelte';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { contactsStatisticsStore } from '$lib/stores/statistics.svelte';
import { tagsApi } from '$lib/api/tags';
import {
StatsGrid,
ActivityHeatmap,
TrendLineChart,
DonutChart,
ProgressBars,
StatisticsSkeleton,
type StatItem,
} from '@manacore/shared-ui';
import { BarChart3, Users, Star, UserPlus, Cake, Mail, CheckCircle } from 'lucide-svelte';
let loading = $state(true);
// Update statistics when contacts change
$effect(() => {
contactsStatisticsStore.setContacts(contactsStore.contacts);
});
// Build stats items for StatsGrid
let statsItems = $derived<StatItem[]>([
{
id: 'total',
label: 'Gesamt',
value: contactsStatisticsStore.totalContacts,
icon: Users,
variant: 'primary',
},
{
id: 'favorites',
label: 'Favoriten',
value: contactsStatisticsStore.favoriteContacts,
icon: Star,
variant: 'accent',
},
{
id: 'recentlyAdded',
label: 'Neu (7 Tage)',
value: contactsStatisticsStore.recentlyAdded,
icon: UserPlus,
variant: 'success',
},
{
id: 'birthdays',
label: 'Geburtstage',
value: contactsStatisticsStore.birthdaysThisMonth,
icon: Cake,
variant: 'info',
},
{
id: 'withEmail',
label: 'Mit E-Mail',
value: contactsStatisticsStore.contactsWithEmail,
icon: Mail,
variant: 'neutral',
},
{
id: 'completeness',
label: 'Vollständigkeit',
value: `${contactsStatisticsStore.completenessRate}%`,
icon: CheckCircle,
variant: contactsStatisticsStore.completenessRate >= 70 ? 'success' : 'danger',
},
]);
onMount(async () => {
// Fetch all contacts (without filters for statistics)
await contactsStore.loadContacts({ isArchived: false });
// Also load archived for complete statistics
const allContacts = [...contactsStore.contacts];
// Fetch tags
try {
const tagsResult = await tagsApi.list();
contactsStatisticsStore.setTags(tagsResult);
} catch (e) {
console.error('Failed to load tags:', e);
}
loading = false;
});
</script>
<svelte:head>
<title>Statistiken - Kontakte</title>
</svelte:head>
<div class="statistics-page">
<header class="page-header">
<div class="header-icon">
<BarChart3 size={28} />
</div>
<div class="header-content">
<h1>Statistiken</h1>
<p class="header-subtitle">Deine Kontakte im Überblick</p>
</div>
</header>
{#if loading}
<StatisticsSkeleton statCards={6} legendItems={4} />
{:else}
<!-- Quick Stats -->
<section class="stats-section">
<StatsGrid items={statsItems} columns={6} />
</section>
<!-- Charts Grid -->
<div class="charts-grid">
<!-- Activity Heatmap -->
<section class="chart-section heatmap-section">
<ActivityHeatmap
data={contactsStatisticsStore.activityHeatmap}
itemName="Kontakt"
itemNamePlural="Kontakte"
/>
</section>
<!-- Weekly Trend + Status Donut -->
<div class="charts-row">
<section class="chart-section trend-section">
<TrendLineChart
data={contactsStatisticsStore.weeklyTrend}
itemName="Kontakt"
itemNamePlural="Kontakte"
/>
</section>
<section class="chart-section donut-section">
<DonutChart
data={contactsStatisticsStore.statusBreakdown}
title="Status"
centerLabel="Kontakte"
centerValue={contactsStatisticsStore.totalContacts}
/>
</section>
</div>
<!-- Info Completeness -->
<div class="charts-row">
<section class="chart-section info-section">
<DonutChart
data={contactsStatisticsStore.infoBreakdown}
title="Informationen"
centerLabel="Kontakte"
centerValue={contactsStatisticsStore.totalContacts}
/>
</section>
<section class="chart-section country-section">
<ProgressBars
data={contactsStatisticsStore.countryBreakdown}
title="Nach Land"
emptyMessage="Keine Länder angegeben"
/>
</section>
</div>
</div>
<!-- Additional Stats -->
<div class="additional-stats">
<div class="stat-card-small">
<span class="stat-label">Aktive Kontakte</span>
<span class="stat-value">{contactsStatisticsStore.activeContacts}</span>
</div>
<div class="stat-card-small">
<span class="stat-label">Archivierte Kontakte</span>
<span class="stat-value">{contactsStatisticsStore.archivedContacts}</span>
</div>
<div class="stat-card-small">
<span class="stat-label">Tags</span>
<span class="stat-value">{contactsStatisticsStore.totalTags}</span>
</div>
</div>
{/if}
</div>
<style>
.statistics-page {
padding-bottom: 6rem;
}
.page-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.header-icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
background: hsl(var(--primary) / 0.15);
color: hsl(var(--primary));
border-radius: 1rem;
}
.header-content h1 {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
margin: 0;
}
.header-subtitle {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0.25rem 0 0 0;
}
.stats-section {
margin-bottom: 1.5rem;
}
.charts-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.charts-row {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
@media (min-width: 768px) {
.charts-row {
grid-template-columns: 1fr 1fr;
}
}
.chart-section {
min-width: 0;
}
.additional-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.stat-card-small {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1rem;
}
:global(.dark) .stat-card-small {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.stat-card-small .stat-label {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.stat-card-small .stat-value {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
}
</style>

View file

@ -3,7 +3,14 @@
import { _ } from 'svelte-i18n';
import { tagsApi } from '$lib/api/contacts';
import type { ContactTag } from '$lib/api/contacts';
import { TagGridSkeleton } from '$lib/components/skeletons';
import {
TagList,
TagEditModal,
TagColorPicker,
DEFAULT_TAG_COLOR,
type Tag,
} from '@manacore/shared-ui';
import { MagnifyingGlass, Plus, CaretLeft } from '@manacore/shared-icons';
let loading = $state(true);
let tags = $state<ContactTag[]>([]);
@ -13,9 +20,6 @@
// Modal state
let showModal = $state(false);
let editingTag = $state<ContactTag | null>(null);
let tagName = $state('');
let tagColor = $state('#6366f1');
let saving = $state(false);
const filteredTags = $derived.by(() => {
if (!searchQuery.trim()) return tags;
@ -23,22 +27,6 @@
return tags.filter((t) => t.name.toLowerCase().includes(query));
});
const colorOptions = [
'#ef4444', // red
'#f97316', // orange
'#f59e0b', // amber
'#84cc16', // lime
'#22c55e', // green
'#14b8a6', // teal
'#06b6d4', // cyan
'#3b82f6', // blue
'#6366f1', // indigo
'#8b5cf6', // violet
'#a855f7', // purple
'#ec4899', // pink
'#64748b', // slate
];
async function loadTags() {
loading = true;
error = null;
@ -54,53 +42,48 @@
function openCreateModal() {
editingTag = null;
tagName = '';
tagColor = '#6366f1';
showModal = true;
}
function openEditModal(tag: ContactTag) {
editingTag = tag;
tagName = tag.name;
tagColor = tag.color || '#6366f1';
showModal = true;
}
function closeModal() {
showModal = false;
editingTag = null;
tagName = '';
tagColor = '#6366f1';
}
async function handleSave() {
if (!tagName.trim()) return;
saving = true;
async function handleSave(name: string, color: string) {
error = null;
try {
if (editingTag) {
const response = await tagsApi.update(editingTag.id, {
name: tagName.trim(),
color: tagColor,
});
const response = await tagsApi.update(editingTag.id, { name, color });
tags = tags.map((t) => (t.id === editingTag!.id ? response.tag : t));
} else {
const response = await tagsApi.create({
name: tagName.trim(),
color: tagColor,
});
const response = await tagsApi.create({ name, color });
tags = [...tags, response.tag];
}
closeModal();
} catch (e) {
error = e instanceof Error ? e.message : $_('messages.error');
} finally {
saving = false;
}
}
async function handleDelete(tag: ContactTag) {
async function handleDelete() {
if (!editingTag) return;
try {
await tagsApi.delete(editingTag.id);
tags = tags.filter((t) => t.id !== editingTag!.id);
closeModal();
} catch (e) {
error = e instanceof Error ? e.message : $_('messages.error');
}
}
async function handleDeleteFromList(tag: Tag) {
if (!confirm($_('tags.confirmDelete', { values: { name: tag.name } }))) return;
try {
@ -122,28 +105,17 @@
<!-- Header -->
<header class="header">
<a href="/" class="back-button" aria-label={$_('common.back')}>
<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>
<CaretLeft size={20} weight="bold" />
</a>
<h1 class="title">{$_('tags.title')}</h1>
<button onclick={openCreateModal} class="add-button" aria-label={$_('tags.new')}>
<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>
<Plus size={20} weight="bold" />
</button>
</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>
<MagnifyingGlass size={20} class="search-icon" />
<input
type="text"
placeholder={$_('tags.search')}
@ -154,209 +126,55 @@
{#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}
<TagGridSkeleton count={6} />
{:else if tags.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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
</div>
<h2 class="empty-title">{$_('tags.noTags')}</h2>
<p class="empty-description">{$_('tags.createFirst')}</p>
<button onclick={openCreateModal} 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>
{$_('tags.new')}
</button>
</div>
{:else if filteredTags.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">{$_('tags.noResults')}</h2>
<p class="empty-description">{$_('tags.noResultsFor', { values: { query: searchQuery } })}</p>
</div>
{:else}
<div class="tags-grid">
{#each filteredTags as tag (tag.id)}
<div class="tag-card">
<div class="tag-color" style="background-color: {tag.color || '#6366f1'}">
<svg class="tag-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
</div>
<div class="tag-info">
<h3 class="tag-name">{tag.name}</h3>
</div>
<div class="tag-actions">
<button
onclick={() => openEditModal(tag)}
class="action-button"
aria-label={$_('actions.edit')}
>
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
onclick={() => handleDelete(tag)}
class="action-button delete"
aria-label={$_('actions.delete')}
>
<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>
</div>
</div>
{/each}
</div>
<!-- Tag List using shared component -->
<TagList
tags={filteredTags}
{loading}
onEdit={(tag) => openEditModal(tag as ContactTag)}
onDelete={handleDeleteFromList}
emptyMessage={searchQuery ? $_('tags.noResults') : $_('tags.noTags')}
emptyDescription={searchQuery
? $_('tags.noResultsFor', { values: { query: searchQuery } })
: $_('tags.createFirst')}
/>
{#if !loading && tags.length > 0}
<p class="tags-count">
{tags.length}
{tags.length === 1 ? $_('tags.tagSingular') : $_('tags.tagPlural')}
</p>
{/if}
{#if !loading && tags.length === 0 && !searchQuery}
<div class="empty-cta">
<button onclick={openCreateModal} class="btn btn-primary">
<Plus size={16} weight="bold" />
{$_('tags.new')}
</button>
</div>
{/if}
</div>
<!-- Create/Edit Modal -->
{#if showModal}
<div class="modal-backdrop" onclick={closeModal} role="presentation">
<div
class="modal"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<header class="modal-header">
<h2 id="modal-title" class="modal-title">
{editingTag ? $_('tags.edit') : $_('tags.new')}
</h2>
<button onclick={closeModal} class="modal-close" aria-label={$_('common.cancel')}>
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</header>
<div class="modal-body">
<div class="form-group">
<label for="tag-name" class="form-label">{$_('tags.name')}</label>
<input
id="tag-name"
type="text"
bind:value={tagName}
placeholder={$_('tags.namePlaceholder')}
class="form-input"
/>
</div>
<div class="form-group">
<label class="form-label">{$_('tags.color')}</label>
<div class="color-picker">
{#each colorOptions as color}
<button
type="button"
class="color-option"
class:selected={tagColor === color}
style="background-color: {color}"
onclick={() => (tagColor = color)}
aria-label={color}
>
{#if tagColor === color}
<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>
<!-- Preview -->
<div class="form-group">
<label class="form-label">{$_('tags.preview')}</label>
<div class="tag-preview">
<span class="preview-tag" style="background-color: {tagColor}">
{tagName || $_('tags.namePlaceholder')}
</span>
</div>
</div>
</div>
<footer class="modal-footer">
<button onclick={closeModal} class="btn btn-secondary" disabled={saving}>
{$_('common.cancel')}
</button>
<button onclick={handleSave} class="btn btn-primary" disabled={saving || !tagName.trim()}>
{#if saving}
<span class="btn-spinner"></span>
{/if}
{editingTag ? $_('actions.save') : $_('actions.create')}
</button>
</footer>
</div>
</div>
{/if}
<!-- Create/Edit Modal using shared component -->
<TagEditModal
tag={editingTag}
isOpen={showModal}
onClose={closeModal}
onSave={handleSave}
onDelete={editingTag ? handleDelete : undefined}
title={editingTag ? $_('tags.edit') : $_('tags.new')}
saveLabel={editingTag ? $_('actions.save') : $_('actions.create')}
deleteLabel={$_('actions.delete')}
cancelLabel={$_('common.cancel')}
namePlaceholder={$_('tags.namePlaceholder')}
colorLabel={$_('tags.color')}
previewLabel={$_('tags.preview')}
deleteConfirmMessage={$_('tags.confirmDelete', { values: { name: editingTag?.name || '' } })}
/>
<style>
.page-container {
@ -433,13 +251,11 @@
margin-bottom: 1.5rem;
}
.search-icon {
.search-wrapper :global(.search-icon) {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
width: 1.25rem;
height: 1.25rem;
color: hsl(var(--muted-foreground));
pointer-events: none;
}
@ -474,156 +290,6 @@
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(--muted));
border-top-color: hsl(var(--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(--muted));
color: hsl(var(--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(--foreground));
margin-bottom: 0.5rem;
}
.empty-description {
color: hsl(var(--muted-foreground));
margin-bottom: 1.5rem;
max-width: 280px;
}
/* Tags Grid */
.tags-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.tag-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
transition: all 0.2s ease;
}
.tag-card:hover {
border-color: hsl(var(--primary) / 0.3);
box-shadow: 0 4px 12px hsl(var(--foreground) / 0.05);
}
.tag-color {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.625rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.tag-icon {
width: 1.25rem;
height: 1.25rem;
color: white;
}
.tag-info {
flex: 1;
min-width: 0;
}
.tag-name {
font-size: 0.9375rem;
font-weight: 600;
color: hsl(var(--foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tag-actions {
display: flex;
align-items: center;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s ease;
}
.tag-card:hover .tag-actions {
opacity: 1;
}
.action-button {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: transparent;
color: hsl(var(--muted-foreground));
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.action-button:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.action-button.delete:hover {
background: hsl(0 84% 60% / 0.1);
color: hsl(0 84% 60%);
}
/* Count */
.tags-count {
text-align: center;
@ -632,157 +298,11 @@
margin-top: 1.5rem;
}
/* Modal */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
/* Empty CTA */
.empty-cta {
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 1rem;
}
.modal {
background: hsl(var(--background));
border-radius: 1rem;
width: 100%;
max-width: 400px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid hsl(var(--border));
}
.modal-title {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: transparent;
color: hsl(var(--muted-foreground));
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.modal-close:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid hsl(var(--border));
}
/* Form */
.form-group {
margin-bottom: 1.25rem;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--foreground));
margin-bottom: 0.5rem;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
border: 1.5px solid hsl(var(--border));
border-radius: 0.625rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.9375rem;
transition: all 0.2s ease;
}
.form-input:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
}
/* Color Picker */
.color-picker {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.color-option {
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
border: 2px 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(--foreground));
box-shadow: 0 0 0 2px hsl(var(--background));
}
.check-icon {
width: 1rem;
height: 1rem;
color: white;
}
/* Tag Preview */
.tag-preview {
display: flex;
align-items: center;
gap: 0.5rem;
}
.preview-tag {
display: inline-flex;
align-items: center;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
font-size: 0.8125rem;
font-weight: 500;
color: white;
margin-top: 1rem;
}
/* Buttons */
@ -801,46 +321,12 @@
text-decoration: none;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.btn-primary:hover:not(:disabled) {
.btn-primary:hover {
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
}
.btn-secondary {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.btn-secondary:hover:not(:disabled) {
background: hsl(var(--muted-foreground) / 0.2);
}
.btn-spinner {
width: 1rem;
height: 1rem;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
/* Icons */
.icon {
width: 1.25rem;
height: 1.25rem;
}
.icon-sm {
width: 1rem;
height: 1rem;
}
</style>

View file

@ -0,0 +1,25 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ThemePage } from '@manacore/shared-theme-ui';
import { theme } from '$lib/stores/theme';
import { customThemesStore } from '$lib/stores/custom-themes.svelte';
</script>
<svelte:head>
<title>Themes | Contacts</title>
</svelte:head>
<ThemePage
currentVariant={theme.variant}
onSelectTheme={(v) => theme.setVariant(v)}
showModeSelector={true}
currentMode={theme.mode}
onModeChange={(m) => theme.setMode(m)}
showBackButton={true}
onBack={() => goto('/')}
showCustomThemes={true}
{customThemesStore}
onCreateTheme={() => goto('/themes/editor')}
onEditTheme={(t) => goto(`/themes/editor?id=${t.id}`)}
onCommunityThemes={() => goto('/themes/community')}
/>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { CommunityThemesPage } from '@manacore/shared-theme-ui';
import { customThemesStore } from '$lib/stores/custom-themes.svelte';
import { theme } from '$lib/stores/theme';
// Get effective mode from theme store
let effectiveMode = $derived(
theme.mode === 'system'
? typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: theme.mode
) as 'light' | 'dark';
</script>
<svelte:head>
<title>Community Themes | Contacts</title>
</svelte:head>
<CommunityThemesPage
store={customThemesStore}
{effectiveMode}
onBack={() => goto('/themes')}
onSelectTheme={(t) => {
// Could open a detail modal here
console.log('Selected theme:', t);
}}
/>

View file

@ -0,0 +1,75 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { ThemeEditorPage } from '@manacore/shared-theme-ui';
import { customThemesStore } from '$lib/stores/custom-themes.svelte';
import { theme } from '$lib/stores/theme';
import { onMount } from 'svelte';
import type { CustomTheme } from '@manacore/shared-theme';
// Get theme ID from URL if editing
let themeId = $derived($page.url.searchParams.get('id'));
let editingTheme = $state<CustomTheme | undefined>(undefined);
// Load theme data if editing
onMount(async () => {
if (themeId) {
await customThemesStore.loadCustomThemes();
editingTheme = customThemesStore.customThemes.find((t) => t.id === themeId);
}
});
// Get effective mode from theme store
let effectiveMode = $derived(
theme.mode === 'system'
? typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: theme.mode
) as 'light' | 'dark';
async function handleSave(themeData: {
name: string;
description?: string;
emoji: string;
lightColors: any;
darkColors: any;
}) {
if (themeId && editingTheme) {
await customThemesStore.updateTheme(themeId, themeData);
} else {
await customThemesStore.createTheme(themeData);
}
goto('/themes');
}
async function handlePublish(themeData: {
name: string;
description?: string;
emoji: string;
lightColors: any;
darkColors: any;
tags?: string[];
}) {
let theme: CustomTheme;
if (themeId && editingTheme) {
theme = await customThemesStore.updateTheme(themeId, themeData);
} else {
theme = await customThemesStore.createTheme(themeData);
}
await customThemesStore.publishTheme(theme.id, { tags: themeData.tags });
goto('/themes');
}
</script>
<svelte:head>
<title>{themeId ? 'Theme bearbeiten' : 'Neues Theme'} | Contacts</title>
</svelte:head>
<ThemeEditorPage
{effectiveMode}
existingTheme={editingTheme}
onBack={() => goto('/themes')}
onSave={handleSave}
onPublish={handlePublish}
/>