From 5bcbb4b71d1e9260b11620a2dda9bd8e30e0261f Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 23 Mar 2026 10:44:39 +0100 Subject: [PATCH] feat(zitare): integrate spiral-db for visual quote storage Add spiral-db integration to Zitare as the second app (after Todo) to use pixel-based spiral data visualization. Favorites are encoded as colored pixels in a spiral pattern and can be exported/imported as PNG. Changes: - Add createQuoteSchema() to spiral-db with fields for category, language, author, text, and quoteId - Create Svelte 5 spiral store with importFavorites, CRUD, PNG export - Add SpiralCanvas component for interactive visualization - Add /spiral route with stats, records list, and actions - Wire up navigation (Ctrl+6) and auto-import favorites on mount Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/zitare/apps/web/package.json | 1 + .../src/lib/components/SpiralCanvas.svelte | 165 ++++++ .../apps/web/src/lib/stores/spiral.svelte.ts | 233 ++++++++ .../apps/web/src/routes/(app)/+layout.svelte | 3 +- .../web/src/routes/(app)/spiral/+page.svelte | 549 ++++++++++++++++++ packages/spiral-db/src/index.ts | 1 + packages/spiral-db/src/schema.test.ts | 47 ++ packages/spiral-db/src/schema.ts | 20 + 8 files changed, 1018 insertions(+), 1 deletion(-) create mode 100644 apps/zitare/apps/web/src/lib/components/SpiralCanvas.svelte create mode 100644 apps/zitare/apps/web/src/lib/stores/spiral.svelte.ts create mode 100644 apps/zitare/apps/web/src/routes/(app)/spiral/+page.svelte diff --git a/apps/zitare/apps/web/package.json b/apps/zitare/apps/web/package.json index d869e5ec2..2a599ad8e 100644 --- a/apps/zitare/apps/web/package.json +++ b/apps/zitare/apps/web/package.json @@ -48,6 +48,7 @@ "@manacore/shared-theme": "workspace:*", "@manacore/shared-theme-ui": "workspace:*", "@manacore/shared-ui": "workspace:*", + "@manacore/spiral-db": "workspace:^", "@zitare/content": "workspace:*", "svelte-i18n": "^4.0.1" }, diff --git a/apps/zitare/apps/web/src/lib/components/SpiralCanvas.svelte b/apps/zitare/apps/web/src/lib/components/SpiralCanvas.svelte new file mode 100644 index 000000000..74dc513dc --- /dev/null +++ b/apps/zitare/apps/web/src/lib/components/SpiralCanvas.svelte @@ -0,0 +1,165 @@ + + +
+ + + {#if hoveredIndex !== null} +
+ Pixel #{hoveredIndex} +
+ {/if} +
+ + diff --git a/apps/zitare/apps/web/src/lib/stores/spiral.svelte.ts b/apps/zitare/apps/web/src/lib/stores/spiral.svelte.ts new file mode 100644 index 000000000..19422a79c --- /dev/null +++ b/apps/zitare/apps/web/src/lib/stores/spiral.svelte.ts @@ -0,0 +1,233 @@ +/** + * Spiral DB Store for Zitare + * Manages SpiralDB state for visual quote storage + */ + +import { + SpiralDB, + createQuoteSchema, + type SpiralImage, + type SpiralRecord, + exportToPngBytes, + importFromPngBytes, + downloadPng, +} from '@manacore/spiral-db'; + +interface QuoteData extends Record { + id: number; + status: number; + category: number; + language: number; + createdAt: Date; + quoteId: string; + author: string; + text: string; +} + +interface SpiralStats { + imageSize: number; + totalPixels: number; + usedPixels: number; + totalRecords: number; + activeRecords: number; + deletedRecords: number; + currentRing: number; + compressionRatio: number; +} + +const CATEGORY_MAP: Record = { + motivation: 0, + weisheit: 1, + liebe: 2, + leben: 3, + erfolg: 4, + glueck: 5, + freundschaft: 6, + mut: 7, + hoffnung: 8, + natur: 9, +}; + +const CATEGORY_NAMES: Record = Object.fromEntries( + Object.entries(CATEGORY_MAP).map(([k, v]) => [v, k]) +); + +const LANGUAGE_MAP: Record = { + original: 0, + de: 1, + en: 2, + it: 3, + fr: 4, + es: 5, +}; + +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: createQuoteSchema(), + 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 favorites from the favorites store, merged with quote data + */ + importFavorites( + favorites: Array<{ + quoteId: string; + createdAt?: string | Date; + }>, + getQuote: (quoteId: string) => { + author: string; + text: string; + category: string; + language?: string; + } | null + ) { + this.db = new SpiralDB({ + schema: createQuoteSchema(), + compression: true, + }); + + for (const fav of favorites) { + const quote = getQuote(fav.quoteId); + if (!quote) continue; + + const result = this.db.insert({ + id: 0, + status: 2, // favorited + category: CATEGORY_MAP[quote.category] ?? 0, + language: LANGUAGE_MAP[quote.language ?? 'de'] ?? 1, + createdAt: fav.createdAt ? new Date(fav.createdAt) : new Date(), + quoteId: fav.quoteId.slice(0, 100), + author: quote.author.slice(0, 100), + text: quote.text.slice(0, 255), + }); + + if (result.success) { + this.db.complete(result.recordId!); + } + } + + this.updateState(); + } + + /** + * Add a single quote to the spiral + */ + addQuote(quote: { + quoteId: string; + author: string; + text: string; + category: string; + language?: string; + }) { + const result = this.db.insert({ + id: 0, + status: 0, + category: CATEGORY_MAP[quote.category] ?? 0, + language: LANGUAGE_MAP[quote.language ?? 'de'] ?? 1, + createdAt: new Date(), + quoteId: quote.quoteId.slice(0, 100), + author: quote.author.slice(0, 100), + text: quote.text.slice(0, 255), + }); + + if (result.success) { + this.updateState(); + } + return result; + } + + /** + * Remove a quote (soft delete) + */ + removeQuote(id: number) { + const result = this.db.delete(id); + if (result.success) { + this.updateState(); + } + return result; + } + + /** + * Mark a quote as favorited + */ + favoriteQuote(id: number) { + const result = this.db.complete(id); + if (result.success) { + this.updateState(); + } + return result; + } + + downloadPng(filename = 'spiral-quotes.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: createQuoteSchema(), + 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, createQuoteSchema()); + 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; + } + } + + getCategoryName(index: number): string { + return CATEGORY_NAMES[index] ?? 'unknown'; + } +} + +export const spiralStore = new SpiralStore(); diff --git a/apps/zitare/apps/web/src/routes/(app)/+layout.svelte b/apps/zitare/apps/web/src/routes/(app)/+layout.svelte index 2cfbab9c3..1cd4f6f19 100644 --- a/apps/zitare/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/zitare/apps/web/src/routes/(app)/+layout.svelte @@ -95,6 +95,7 @@ { href: '/favorites', label: $_('nav.favorites'), icon: 'heart' }, { href: '/lists', label: $_('nav.lists'), icon: 'list' }, { href: '/settings', label: $_('nav.settings'), icon: 'settings' }, + { href: '/spiral', label: 'Spiral', icon: 'sparkles' }, ]); // Filter hidden nav items @@ -103,7 +104,7 @@ ); // Navigation routes for keyboard shortcuts - const navRoutes = ['/', '/categories', '/favorites', '/lists', '/settings']; + const navRoutes = ['/', '/categories', '/favorites', '/lists', '/settings', '/spiral']; function handleToggleTheme() { theme.toggleMode(); diff --git a/apps/zitare/apps/web/src/routes/(app)/spiral/+page.svelte b/apps/zitare/apps/web/src/routes/(app)/spiral/+page.svelte new file mode 100644 index 000000000..b2c7101cb --- /dev/null +++ b/apps/zitare/apps/web/src/routes/(app)/spiral/+page.svelte @@ -0,0 +1,549 @@ + + +
+

SpiralDB

+

Deine Zitate als Pixel-Spirale

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

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

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

Statistiken

+
+
+ {spiralStore.stats.imageSize}x{spiralStore.stats.imageSize} + Bildgröße +
+
+ {spiralStore.stats.activeRecords} + Zitate +
+
+ {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 Zitate + {#if spiralStore.records.length > 0} + {spiralStore.records.length} + {/if} +

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

Noch keine Zitate in der Spirale.

+ {:else} +
+ {#each spiralStore.records as record} + {@const cat = spiralStore.getCategoryName(record.data.category)} +
+
+ {categoryIcons[cat] ?? '💬'} + {record.data.author} + #{record.meta.id} +
+

{record.data.text}

+ +
+ {/each} +
+ {/if} +
+ + +
+

Aktionen

+
+ + + + +
+ +
+

+ SpiralDB kodiert deine Zitate 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 + Zitate du sammelst. +

+
+
+
+ + +
+ + diff --git a/packages/spiral-db/src/index.ts b/packages/spiral-db/src/index.ts index 0146f8769..df7c2f598 100644 --- a/packages/spiral-db/src/index.ts +++ b/packages/spiral-db/src/index.ts @@ -92,6 +92,7 @@ export { // Schema utilities export { createTodoSchema, + createQuoteSchema, encodeSchema, decodeSchema, getSchemaPixelCount, diff --git a/packages/spiral-db/src/schema.test.ts b/packages/spiral-db/src/schema.test.ts index 18c1288fc..a49bcc819 100644 --- a/packages/spiral-db/src/schema.test.ts +++ b/packages/spiral-db/src/schema.test.ts @@ -8,6 +8,7 @@ import { decodeSchema, getSchemaPixelCount, createTodoSchema, + createQuoteSchema, validateRecord, getFieldNames, } from './schema.js'; @@ -193,6 +194,52 @@ describe('validateRecord', () => { }); }); +describe('Quote Schema', () => { + it('should create quote schema with correct fields', () => { + const schema = createQuoteSchema(); + expect(schema.name).toBe('quote'); + expect(schema.version).toBe(1); + expect(schema.fields).toHaveLength(8); + expect(schema.fields.map((f) => f.name)).toEqual([ + 'id', + 'status', + 'category', + 'language', + 'createdAt', + 'quoteId', + 'author', + 'text', + ]); + }); + + it('should round-trip quote schema encode/decode', () => { + const schema = createQuoteSchema(); + const pixels = encodeSchema(schema); + const names = getFieldNames(schema); + const decoded = decodeSchema(pixels, names); + expect(decoded.fields.length).toBe(schema.fields.length); + for (let i = 0; i < schema.fields.length; i++) { + expect(decoded.fields[i].type).toBe(schema.fields[i].type); + expect(decoded.fields[i].maxLength).toBe(schema.fields[i].maxLength); + } + }); + + it('should validate a valid quote record', () => { + const schema = createQuoteSchema(); + const result = validateRecord(schema, { + id: 0, + status: 0, + category: 3, + language: 1, + createdAt: new Date(), + quoteId: 'q-123', + author: 'Goethe', + text: 'Ein kluges Wort', + }); + expect(result.valid).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 f59a99bb2..49c9bfcd2 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 Quote items (Zitare app) + */ +export function createQuoteSchema(): SchemaDefinition { + return { + version: 1, + name: 'quote', + fields: [ + { name: 'id', type: 'int', maxLength: 12 }, // 0-4095 + { name: 'status', type: 'int', maxLength: 3 }, // 0=active, 2=favorited, 4=removed + { name: 'category', type: 'int', maxLength: 4 }, // 10 categories (0-15) + { name: 'language', type: 'int', maxLength: 3 }, // 6 languages (0-7) + { name: 'createdAt', type: 'timestamp', maxLength: 24 }, + { name: 'quoteId', type: 'string', maxLength: 100 }, // Reference to content package + { name: 'author', type: 'string', maxLength: 100 }, + { name: 'text', type: 'string', maxLength: 255 }, + ], + }; +} + /** * Validate that a record matches a schema */