mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
67a181bb04
commit
5bcbb4b71d
8 changed files with 1018 additions and 1 deletions
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
165
apps/zitare/apps/web/src/lib/components/SpiralCanvas.svelte
Normal file
165
apps/zitare/apps/web/src/lib/components/SpiralCanvas.svelte
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<script lang="ts">
|
||||
import type { SpiralImage } from '@manacore/spiral-db';
|
||||
import { spiralToXY, xyToSpiral } from '@manacore/spiral-db';
|
||||
|
||||
interface Props {
|
||||
image: SpiralImage;
|
||||
scale?: number;
|
||||
showGrid?: boolean;
|
||||
highlightIndex?: number | null;
|
||||
onPixelClick?: (index: number, x: number, y: number) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
image,
|
||||
scale = 10,
|
||||
showGrid = false,
|
||||
highlightIndex = null,
|
||||
onPixelClick,
|
||||
}: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let hoveredIndex = $state<number | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!canvas || !image) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const { width, height, pixels } = image;
|
||||
canvas.width = width * scale;
|
||||
canvas.height = height * scale;
|
||||
|
||||
ctx.fillStyle = '#1a1a1a';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const offset = (y * width + x) * 3;
|
||||
const r = pixels[offset];
|
||||
const g = pixels[offset + 1];
|
||||
const b = pixels[offset + 2];
|
||||
|
||||
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
|
||||
ctx.fillRect(x * scale, y * scale, scale, scale);
|
||||
}
|
||||
}
|
||||
|
||||
if (showGrid && scale >= 8) {
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
for (let x = 0; x <= width; x++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x * scale, 0);
|
||||
ctx.lineTo(x * scale, height * scale);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
for (let y = 0; y <= height; y++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y * scale);
|
||||
ctx.lineTo(width * scale, y * scale);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
const center = Math.floor(width / 2);
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(center * scale, center * scale, scale, scale);
|
||||
|
||||
if (highlightIndex !== null && highlightIndex >= 0) {
|
||||
const point = spiralToXY(highlightIndex, width);
|
||||
ctx.strokeStyle = '#fbbf24';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(point.x * scale, point.y * scale, scale, scale);
|
||||
}
|
||||
|
||||
if (hoveredIndex !== null) {
|
||||
const point = spiralToXY(hoveredIndex, width);
|
||||
ctx.strokeStyle = '#8b5cf6';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(point.x * scale, point.y * scale, scale, scale);
|
||||
}
|
||||
});
|
||||
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
if (!canvas || !image) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = Math.floor((e.clientX - rect.left) / scale);
|
||||
const y = Math.floor((e.clientY - rect.top) / scale);
|
||||
|
||||
if (x >= 0 && x < image.width && y >= 0 && y < image.height) {
|
||||
hoveredIndex = xyToSpiral(x, y, image.width);
|
||||
} else {
|
||||
hoveredIndex = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
hoveredIndex = null;
|
||||
}
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (!canvas || !image || !onPixelClick) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = Math.floor((e.clientX - rect.left) / scale);
|
||||
const y = Math.floor((e.clientY - rect.top) / scale);
|
||||
|
||||
if (x >= 0 && x < image.width && y >= 0 && y < image.height) {
|
||||
const index = xyToSpiral(x, y, image.width);
|
||||
onPixelClick(index, x, y);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="spiral-canvas-container">
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
onmousemove={handleMouseMove}
|
||||
onmouseleave={handleMouseLeave}
|
||||
onclick={handleClick}
|
||||
class="spiral-canvas"
|
||||
class:clickable={!!onPixelClick}
|
||||
></canvas>
|
||||
|
||||
{#if hoveredIndex !== null}
|
||||
<div class="pixel-info">
|
||||
Pixel #{hoveredIndex}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.spiral-canvas-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.spiral-canvas {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.spiral-canvas.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pixel-info {
|
||||
position: absolute;
|
||||
bottom: -30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
233
apps/zitare/apps/web/src/lib/stores/spiral.svelte.ts
Normal file
233
apps/zitare/apps/web/src/lib/stores/spiral.svelte.ts
Normal file
|
|
@ -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<string, unknown> {
|
||||
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<string, number> = {
|
||||
motivation: 0,
|
||||
weisheit: 1,
|
||||
liebe: 2,
|
||||
leben: 3,
|
||||
erfolg: 4,
|
||||
glueck: 5,
|
||||
freundschaft: 6,
|
||||
mut: 7,
|
||||
hoffnung: 8,
|
||||
natur: 9,
|
||||
};
|
||||
|
||||
const CATEGORY_NAMES: Record<number, string> = Object.fromEntries(
|
||||
Object.entries(CATEGORY_MAP).map(([k, v]) => [v, k])
|
||||
);
|
||||
|
||||
const LANGUAGE_MAP: Record<string, number> = {
|
||||
original: 0,
|
||||
de: 1,
|
||||
en: 2,
|
||||
it: 3,
|
||||
fr: 4,
|
||||
es: 5,
|
||||
};
|
||||
|
||||
class SpiralStore {
|
||||
private db: SpiralDB<QuoteData>;
|
||||
|
||||
image = $state<SpiralImage | null>(null);
|
||||
stats = $state<SpiralStats | null>(null);
|
||||
records = $state<SpiralRecord<QuoteData>[]>([]);
|
||||
isLoading = $state(false);
|
||||
error = $state<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
this.db = new SpiralDB<QuoteData>({
|
||||
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<QuoteData>({
|
||||
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<QuoteData>({
|
||||
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<QuoteData>(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();
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
549
apps/zitare/apps/web/src/routes/(app)/spiral/+page.svelte
Normal file
549
apps/zitare/apps/web/src/routes/(app)/spiral/+page.svelte
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { COLORS } from '@manacore/spiral-db';
|
||||
import type { ColorIndex } from '@manacore/spiral-db';
|
||||
import { spiralStore } from '$lib/stores/spiral.svelte';
|
||||
import { favoritesStore } from '$lib/stores/favorites.svelte';
|
||||
import { quotesStore } from '$lib/stores/quotes.svelte';
|
||||
import SpiralCanvas from '$lib/components/SpiralCanvas.svelte';
|
||||
import { QUOTES, type Quote } from '@zitare/content';
|
||||
|
||||
let zoom = $state(10);
|
||||
let showGrid = $state(false);
|
||||
let selectedPixel = $state<number | null>(null);
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
// Build a lookup map for quotes
|
||||
const quoteMap = new Map<string, Quote>();
|
||||
for (const q of QUOTES) {
|
||||
quoteMap.set(q.id, q);
|
||||
}
|
||||
|
||||
function getQuoteData(quoteId: string) {
|
||||
const quote = quoteMap.get(quoteId);
|
||||
if (!quote) return null;
|
||||
return {
|
||||
author: quote.author,
|
||||
text: quotesStore.getText(quote),
|
||||
category: quote.category,
|
||||
language: quotesStore.language,
|
||||
};
|
||||
}
|
||||
|
||||
function handleImportFavorites() {
|
||||
spiralStore.importFavorites(
|
||||
favoritesStore.favorites.map((f) => ({
|
||||
quoteId: f.quoteId,
|
||||
createdAt: f.createdAt,
|
||||
})),
|
||||
getQuoteData
|
||||
);
|
||||
}
|
||||
|
||||
function handlePixelClick(index: number) {
|
||||
selectedPixel = selectedPixel === index ? null : index;
|
||||
}
|
||||
|
||||
async function handleImportPng() {
|
||||
fileInput?.click();
|
||||
}
|
||||
|
||||
async function handleFileSelected(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
await spiralStore.importFromPng(file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
// Category display helpers
|
||||
const categoryIcons: Record<string, string> = {
|
||||
motivation: '🔥',
|
||||
weisheit: '🦉',
|
||||
liebe: '❤️',
|
||||
leben: '🌱',
|
||||
erfolg: '🏆',
|
||||
glueck: '🍀',
|
||||
freundschaft: '🤝',
|
||||
mut: '🦁',
|
||||
hoffnung: '🌅',
|
||||
natur: '🌿',
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (favoritesStore.favorites.length > 0) {
|
||||
handleImportFavorites();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="spiral-page">
|
||||
<h1 class="title">SpiralDB</h1>
|
||||
<p class="subtitle">Deine Zitate als Pixel-Spirale</p>
|
||||
|
||||
<div class="layout">
|
||||
<!-- Visualization -->
|
||||
<section class="section viz-section">
|
||||
{#if spiralStore.image}
|
||||
<div class="canvas-wrapper">
|
||||
<SpiralCanvas
|
||||
image={spiralStore.image}
|
||||
scale={zoom}
|
||||
{showGrid}
|
||||
highlightIndex={selectedPixel}
|
||||
onPixelClick={handlePixelClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<label class="control">
|
||||
<span>Zoom</span>
|
||||
<input type="range" min="4" max="20" bind:value={zoom} />
|
||||
<span class="mono">{zoom}x</span>
|
||||
</label>
|
||||
<label class="control">
|
||||
<input type="checkbox" bind:checked={showGrid} />
|
||||
<span>Grid</span>
|
||||
</label>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<p>Keine Daten. Importiere deine Favoriten oder lade eine PNG-Datei.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Stats -->
|
||||
{#if spiralStore.stats}
|
||||
<section class="section">
|
||||
<h2 class="section-title">Statistiken</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat">
|
||||
<span class="stat-value"
|
||||
>{spiralStore.stats.imageSize}x{spiralStore.stats.imageSize}</span
|
||||
>
|
||||
<span class="stat-label">Bildgröße</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{spiralStore.stats.activeRecords}</span>
|
||||
<span class="stat-label">Zitate</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{spiralStore.stats.usedPixels}</span>
|
||||
<span class="stat-label">Pixel belegt</span>
|
||||
</div>
|
||||
<div class="stat highlight">
|
||||
<span class="stat-value">{spiralStore.stats.compressionRatio}%</span>
|
||||
<span class="stat-label">Kompression vs JSON</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color Legend -->
|
||||
<div class="color-legend">
|
||||
{#each Object.entries(COLORS) as [idx, color]}
|
||||
<div class="color-item">
|
||||
<span
|
||||
class="color-swatch"
|
||||
style="background: rgb({color.rgb.r}, {color.rgb.g}, {color.rgb.b})"
|
||||
></span>
|
||||
<span class="color-name">{color.name}</span>
|
||||
<span class="color-bits mono">{color.bits.join('')}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Records -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">
|
||||
Gespeicherte Zitate
|
||||
{#if spiralStore.records.length > 0}
|
||||
<span class="badge">{spiralStore.records.length}</span>
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
{#if spiralStore.records.length === 0}
|
||||
<p class="empty-hint">Noch keine Zitate in der Spirale.</p>
|
||||
{:else}
|
||||
<div class="records-list">
|
||||
{#each spiralStore.records as record}
|
||||
{@const cat = spiralStore.getCategoryName(record.data.category)}
|
||||
<div class="record" class:completed={record.meta.status === 'completed'}>
|
||||
<div class="record-header">
|
||||
<span class="record-icon">{categoryIcons[cat] ?? '💬'}</span>
|
||||
<span class="record-author">{record.data.author}</span>
|
||||
<span class="record-id mono">#{record.meta.id}</span>
|
||||
</div>
|
||||
<p class="record-text">{record.data.text}</p>
|
||||
<div class="record-footer">
|
||||
<span class="record-category">{cat}</span>
|
||||
<button
|
||||
class="btn-small btn-danger"
|
||||
onclick={() => spiralStore.removeQuote(record.meta.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">Aktionen</h2>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={() => spiralStore.downloadPng()}
|
||||
disabled={!spiralStore.stats || spiralStore.stats.totalRecords === 0}
|
||||
>
|
||||
PNG herunterladen
|
||||
</button>
|
||||
<button class="btn" onclick={handleImportPng}> PNG importieren </button>
|
||||
<button
|
||||
class="btn"
|
||||
onclick={handleImportFavorites}
|
||||
disabled={favoritesStore.favorites.length === 0}
|
||||
>
|
||||
Favoriten neu importieren ({favoritesStore.favorites.length})
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
onclick={() => spiralStore.clear()}
|
||||
disabled={!spiralStore.stats || spiralStore.stats.totalRecords === 0}
|
||||
>
|
||||
Alles löschen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
<strong>SpiralDB</strong> 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.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept=".png"
|
||||
class="hidden"
|
||||
onchange={handleFileSelected}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.spiral-page {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-foreground);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--color-card, rgba(255, 255, 255, 0.05));
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-foreground);
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: var(--color-primary, #8b5cf6);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.viz-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.canvas-wrapper {
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
max-height: 500px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.control input[type='range'] {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-background, rgba(0, 0, 0, 0.2));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-foreground);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.stat.highlight {
|
||||
background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(99, 102, 241, 0.2));
|
||||
}
|
||||
|
||||
/* Color Legend */
|
||||
.color-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.color-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.color-bits {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Records */
|
||||
.records-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.record {
|
||||
padding: 0.75rem;
|
||||
background: var(--color-background, rgba(0, 0, 0, 0.2));
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--color-primary, #8b5cf6);
|
||||
}
|
||||
|
||||
.record.completed {
|
||||
border-left-color: #22c55e;
|
||||
}
|
||||
|
||||
.record-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.record-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.record-author {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-foreground);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.record-id {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.record-text {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted-foreground);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 0.375rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.record-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.record-category {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
|
||||
background: var(--color-background, rgba(0, 0, 0, 0.2));
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--color-accent, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary, #8b5cf6);
|
||||
border-color: var(--color-primary, #8b5cf6);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.btn-small:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 0.75rem;
|
||||
background: var(--color-background, rgba(0, 0, 0, 0.2));
|
||||
border-radius: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted-foreground);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -92,6 +92,7 @@ export {
|
|||
// Schema utilities
|
||||
export {
|
||||
createTodoSchema,
|
||||
createQuoteSchema,
|
||||
encodeSchema,
|
||||
decodeSchema,
|
||||
getSchemaPixelCount,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue