mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-26 13:34:38 +02:00
Merge branch 'till-dev' into till-dev-backup
This commit is contained in:
commit
f04300d5e9
269 changed files with 27419 additions and 2009 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
@ -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;
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export interface ContactsAppSettings {
|
|||
|
||||
const DEFAULT_SETTINGS: ContactsAppSettings = {
|
||||
// Display Settings
|
||||
defaultView: 'list',
|
||||
defaultView: 'alphabet',
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
showPhotos: true,
|
||||
|
|
|
|||
275
apps/contacts/apps/web/src/lib/stores/statistics.svelte.ts
Normal file
275
apps/contacts/apps/web/src/lib/stores/statistics.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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';
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
227
apps/contacts/apps/web/src/lib/utils/contact-parser.ts
Normal file
227
apps/contacts/apps/web/src/lib/utils/contact-parser.ts
Normal 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(' · ');
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
280
apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte
Normal file
280
apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
25
apps/contacts/apps/web/src/routes/(app)/themes/+page.svelte
Normal file
25
apps/contacts/apps/web/src/routes/(app)/themes/+page.svelte
Normal 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')}
|
||||
/>
|
||||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
Loading…
Add table
Add a link
Reference in a new issue