diff --git a/apps/contacts/apps/web/package.json b/apps/contacts/apps/web/package.json index ffec37fc9..e92d2ab41 100644 --- a/apps/contacts/apps/web/package.json +++ b/apps/contacts/apps/web/package.json @@ -16,9 +16,9 @@ "test:e2e:ui": "playwright test --ui" }, "devDependencies": { - "@playwright/test": "^1.52.0", "@manacore/shared-pwa": "workspace:*", "@manacore/shared-vite-config": "workspace:*", + "@playwright/test": "^1.52.0", "@sveltejs/adapter-node": "^5.0.0", "@sveltejs/kit": "^2.47.1", "@sveltejs/vite-plugin-svelte": "^5.0.0", @@ -58,6 +58,7 @@ "@manacore/shared-theme-ui": "workspace:*", "@manacore/shared-ui": "workspace:*", "@manacore/shared-utils": "workspace:*", + "@manacore/spiral-db": "workspace:^", "date-fns": "^4.1.0", "svelte-i18n": "^4.0.1" }, diff --git a/apps/contacts/apps/web/src/lib/components/SpiralCanvas.svelte b/apps/contacts/apps/web/src/lib/components/SpiralCanvas.svelte new file mode 100644 index 000000000..d06ae1d9f --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/SpiralCanvas.svelte @@ -0,0 +1,165 @@ + + +
+ + + {#if hoveredIndex !== null} +
+ Pixel #{hoveredIndex} +
+ {/if} +
+ + diff --git a/apps/contacts/apps/web/src/lib/stores/spiral.svelte.ts b/apps/contacts/apps/web/src/lib/stores/spiral.svelte.ts new file mode 100644 index 000000000..780270187 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/stores/spiral.svelte.ts @@ -0,0 +1,202 @@ +/** + * Spiral DB Store for Contacts + * Manages SpiralDB state for visual contact storage + */ + +import { + SpiralDB, + createContactSchema, + type SpiralImage, + type SpiralRecord, + exportToPngBytes, + importFromPngBytes, + downloadPng, +} from '@manacore/spiral-db'; + +interface ContactData extends Record { + id: number; + status: number; + hasEmail: boolean; + hasPhone: boolean; + createdAt: Date; + name: string; + company: string | null; + city: string | null; +} + +interface SpiralStats { + imageSize: number; + totalPixels: number; + usedPixels: number; + totalRecords: number; + activeRecords: number; + deletedRecords: number; + currentRing: number; + compressionRatio: number; +} + +class SpiralStore { + private db: SpiralDB; + + image = $state(null); + stats = $state(null); + records = $state[]>([]); + isLoading = $state(false); + error = $state(null); + + constructor() { + this.db = new SpiralDB({ + schema: createContactSchema(), + compression: true, + }); + this.updateState(); + } + + private updateState() { + this.image = this.db.getImage(); + this.records = this.db.getAll(); + + const dbStats = this.db.getStats(); + const jsonSize = JSON.stringify(this.records.map((r) => r.data)).length || 1; + const pixelBytes = Math.ceil((dbStats.usedPixels * 3) / 8); + + this.stats = { + ...dbStats, + compressionRatio: Math.round((1 - pixelBytes / jsonSize) * 100), + }; + } + + /** + * Import contacts from the contacts store + */ + importContacts( + contacts: Array<{ + firstName?: string | null; + lastName?: string | null; + displayName?: string | null; + email?: string | null; + phone?: string | null; + mobile?: string | null; + company?: string | null; + city?: string | null; + isFavorite?: boolean; + isArchived?: boolean; + createdAt?: string | Date; + }> + ) { + this.db = new SpiralDB({ + schema: createContactSchema(), + compression: true, + }); + + for (const contact of contacts) { + const name = + contact.displayName || + [contact.firstName, contact.lastName].filter(Boolean).join(' ') || + 'Unnamed'; + + const status = contact.isFavorite ? 2 : contact.isArchived ? 4 : 0; + + const result = this.db.insert({ + id: 0, + status, + hasEmail: Boolean(contact.email), + hasPhone: Boolean(contact.phone || contact.mobile), + createdAt: contact.createdAt ? new Date(contact.createdAt) : new Date(), + name: name.slice(0, 100), + company: contact.company?.slice(0, 100) ?? null, + city: contact.city?.slice(0, 50) ?? null, + }); + + if (result.success && contact.isFavorite) { + this.db.complete(result.recordId!); + } + } + + this.updateState(); + } + + addContact(contact: { + name: string; + email?: string; + phone?: string; + company?: string; + city?: string; + isFavorite?: boolean; + }) { + const result = this.db.insert({ + id: 0, + status: contact.isFavorite ? 2 : 0, + hasEmail: Boolean(contact.email), + hasPhone: Boolean(contact.phone), + createdAt: new Date(), + name: contact.name.slice(0, 100), + company: contact.company?.slice(0, 100) ?? null, + city: contact.city?.slice(0, 50) ?? null, + }); + + if (result.success) { + this.updateState(); + } + return result; + } + + removeContact(id: number) { + const result = this.db.delete(id); + if (result.success) { + this.updateState(); + } + return result; + } + + favoriteContact(id: number) { + const result = this.db.complete(id); + if (result.success) { + this.updateState(); + } + return result; + } + + downloadPng(filename = 'spiral-contacts.png') { + if (this.image) { + downloadPng(this.image, filename); + } + } + + getPngBytes(): Uint8Array | null { + if (!this.image) return null; + return exportToPngBytes(this.image); + } + + clear() { + this.db = new SpiralDB({ + schema: createContactSchema(), + compression: true, + }); + this.updateState(); + } + + async importFromPng(file: File): Promise<{ success: boolean; error?: string }> { + try { + this.isLoading = true; + this.error = null; + + const buffer = await file.arrayBuffer(); + const bytes = new Uint8Array(buffer); + const image = await importFromPngBytes(bytes); + + this.db = SpiralDB.fromImage(image, createContactSchema()); + this.updateState(); + + return { success: true }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + this.error = errorMessage; + return { success: false, error: errorMessage }; + } finally { + this.isLoading = false; + } + } +} + +export const spiralStore = new SpiralStore(); diff --git a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte index b8c3e189e..703058023 100644 --- a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte @@ -132,6 +132,7 @@ { href: '/tags', label: 'Tags', icon: 'tag' }, { href: '/settings', label: 'Einstellungen', icon: 'settings' }, { href: '/help', label: 'Hilfe', icon: 'help-circle' }, + { href: '/spiral', label: 'Spiral', icon: 'sparkles' }, ]; // Navigation items filtered by visibility settings (with fallback for guest mode) diff --git a/apps/contacts/apps/web/src/routes/(app)/spiral/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/spiral/+page.svelte new file mode 100644 index 000000000..28a34ce71 --- /dev/null +++ b/apps/contacts/apps/web/src/routes/(app)/spiral/+page.svelte @@ -0,0 +1,526 @@ + + +
+

SpiralDB

+

Dein Kontakt-Netzwerk als Pixel-Spirale

+ +
+ +
+ {#if spiralStore.image} +
+ +
+ +
+ + +
+ {:else} +
+

Keine Daten. Importiere deine Kontakte oder lade eine PNG-Datei.

+
+ {/if} +
+ + + {#if spiralStore.stats} +
+

Statistiken

+
+
+ {spiralStore.stats.imageSize}x{spiralStore.stats.imageSize} + Bildgröße +
+
+ {spiralStore.stats.activeRecords} + Kontakte +
+
+ {spiralStore.stats.usedPixels} + Pixel belegt +
+
+ {spiralStore.stats.compressionRatio}% + Kompression vs JSON +
+
+ +
+ {#each Object.entries(COLORS) as [idx, color]} +
+ + {color.name} + {color.bits.join('')} +
+ {/each} +
+
+ {/if} + + +
+

+ Gespeicherte Kontakte + {#if spiralStore.records.length > 0} + {spiralStore.records.length} + {/if} +

+ + {#if spiralStore.records.length === 0} +

Noch keine Kontakte in der Spirale.

+ {:else} +
+ {#each spiralStore.records as record} +
+
+ + {record.meta.status === 'completed' ? '⭐' : '👤'} + + {record.data.name} + #{record.meta.id} +
+
+ {#if record.data.company} + {record.data.company} + {/if} + {#if record.data.city} + {record.data.city} + {/if} + + {#if record.data.hasEmail} + @ + {/if} + {#if record.data.hasPhone} + 📞 + {/if} + +
+ +
+ {/each} +
+ {/if} +
+ + +
+

Aktionen

+
+ + + + +
+ +
+

+ SpiralDB kodiert deine Kontakte als farbige Pixel in einem Spiralmuster. Jedes + Pixel speichert 3 Bit (8 Farben). Das Bild wächst von der Mitte nach außen, je mehr + Kontakte du sammelst. Favoriten werden mit einem Stern markiert. +

+
+
+
+ + +
+ + diff --git a/packages/spiral-db/src/index.ts b/packages/spiral-db/src/index.ts index df7c2f598..51892cc67 100644 --- a/packages/spiral-db/src/index.ts +++ b/packages/spiral-db/src/index.ts @@ -93,6 +93,7 @@ export { export { createTodoSchema, createQuoteSchema, + createContactSchema, encodeSchema, decodeSchema, getSchemaPixelCount, diff --git a/packages/spiral-db/src/schema.test.ts b/packages/spiral-db/src/schema.test.ts index a49bcc819..7eb5825fd 100644 --- a/packages/spiral-db/src/schema.test.ts +++ b/packages/spiral-db/src/schema.test.ts @@ -9,6 +9,7 @@ import { getSchemaPixelCount, createTodoSchema, createQuoteSchema, + createContactSchema, validateRecord, getFieldNames, } from './schema.js'; @@ -240,6 +241,47 @@ describe('Quote Schema', () => { }); }); +describe('Contact Schema', () => { + it('should create contact schema with correct fields', () => { + const schema = createContactSchema(); + expect(schema.name).toBe('contact'); + expect(schema.fields).toHaveLength(8); + expect(schema.fields.map((f) => f.name)).toEqual([ + 'id', + 'status', + 'hasEmail', + 'hasPhone', + 'createdAt', + 'name', + 'company', + 'city', + ]); + }); + + it('should validate a valid contact record', () => { + const schema = createContactSchema(); + const result = validateRecord(schema, { + id: 0, + status: 0, + hasEmail: true, + hasPhone: false, + createdAt: new Date(), + name: 'Max Mustermann', + company: null, + city: null, + }); + expect(result.valid).toBe(true); + }); + + it('should mark company and city as nullable', () => { + const schema = createContactSchema(); + const companyField = schema.fields.find((f) => f.name === 'company'); + const cityField = schema.fields.find((f) => f.name === 'city'); + expect(companyField?.nullable).toBe(true); + expect(cityField?.nullable).toBe(true); + }); +}); + describe('getFieldNames', () => { it('should return field names in order', () => { const schema = createTodoSchema(); diff --git a/packages/spiral-db/src/schema.ts b/packages/spiral-db/src/schema.ts index 49c9bfcd2..75266a93b 100644 --- a/packages/spiral-db/src/schema.ts +++ b/packages/spiral-db/src/schema.ts @@ -110,6 +110,26 @@ export function createTodoSchema(): SchemaDefinition { }; } +/** + * Create a schema for Contact items (Contacts app) + */ +export function createContactSchema(): SchemaDefinition { + return { + version: 1, + name: 'contact', + fields: [ + { name: 'id', type: 'int', maxLength: 12 }, // 0-4095 + { name: 'status', type: 'int', maxLength: 3 }, // 0=active, 2=favorite, 4=archived + { name: 'hasEmail', type: 'bool', maxLength: 1 }, + { name: 'hasPhone', type: 'bool', maxLength: 1 }, + { name: 'createdAt', type: 'timestamp', maxLength: 24 }, + { name: 'name', type: 'string', maxLength: 100 }, + { name: 'company', type: 'string', maxLength: 100, nullable: true }, + { name: 'city', type: 'string', maxLength: 50, nullable: true }, + ], + }; +} + /** * Create a schema for Quote items (Zitare app) */