mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
✨ feat(todo): integrate spiral-db visualization
Add interactive spiral database visualization to todo app: - SpiralCanvas component for pixel-based image rendering - Reactive Svelte 5 store for SpiralDB state management - Full /spiral page with stats, zoom, grid toggle, emoji view - Import existing todos into spiral format - PNG export/download functionality - Navigation link in app layout
This commit is contained in:
parent
c5c8907758
commit
4c3ca3bdf6
6 changed files with 1598 additions and 584 deletions
|
|
@ -33,6 +33,7 @@
|
|||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/spiral-db": "workspace:*",
|
||||
"@manacore/shared-api-client": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-splitscreen": "workspace:*",
|
||||
|
|
|
|||
172
apps/todo/apps/web/src/lib/components/SpiralCanvas.svelte
Normal file
172
apps/todo/apps/web/src/lib/components/SpiralCanvas.svelte
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<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);
|
||||
|
||||
// Render the spiral image to canvas
|
||||
$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;
|
||||
|
||||
// Clear canvas
|
||||
ctx.fillStyle = '#1a1a1a';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw pixels
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw grid if enabled
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight center pixel (index 0)
|
||||
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);
|
||||
|
||||
// Highlight specific index if provided
|
||||
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);
|
||||
}
|
||||
|
||||
// Highlight hovered pixel
|
||||
if (hoveredIndex !== null) {
|
||||
const point = spiralToXY(hoveredIndex, width);
|
||||
ctx.strokeStyle = '#3b82f6';
|
||||
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>
|
||||
205
apps/todo/apps/web/src/lib/stores/spiral.svelte.ts
Normal file
205
apps/todo/apps/web/src/lib/stores/spiral.svelte.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
/**
|
||||
* Spiral DB Store
|
||||
* Manages SpiralDB state for visual todo storage
|
||||
*/
|
||||
|
||||
import {
|
||||
SpiralDB,
|
||||
createTodoSchema,
|
||||
type SpiralImage,
|
||||
type SpiralRecord,
|
||||
exportToPngBytes,
|
||||
downloadPng,
|
||||
} from '@manacore/spiral-db';
|
||||
|
||||
interface TodoData extends Record<string, unknown> {
|
||||
id: number;
|
||||
status: number;
|
||||
priority: number;
|
||||
createdAt: Date;
|
||||
dueDate: Date | null;
|
||||
completedAt: Date | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
interface SpiralStats {
|
||||
imageSize: number;
|
||||
totalPixels: number;
|
||||
usedPixels: number;
|
||||
totalRecords: number;
|
||||
activeRecords: number;
|
||||
deletedRecords: number;
|
||||
currentRing: number;
|
||||
compressionRatio: number;
|
||||
}
|
||||
|
||||
class SpiralStore {
|
||||
private db: SpiralDB<TodoData>;
|
||||
|
||||
// Reactive state
|
||||
image = $state<SpiralImage | null>(null);
|
||||
stats = $state<SpiralStats | null>(null);
|
||||
records = $state<SpiralRecord<TodoData>[]>([]);
|
||||
isLoading = $state(false);
|
||||
error = $state<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
this.db = new SpiralDB<TodoData>({
|
||||
schema: createTodoSchema(),
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a todo to the spiral database
|
||||
*/
|
||||
addTodo(todo: {
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: number;
|
||||
dueDate?: Date | null;
|
||||
tags?: number[];
|
||||
}) {
|
||||
const result = this.db.insert({
|
||||
id: 0, // Will be assigned by DB
|
||||
status: 0, // active
|
||||
priority: todo.priority ?? 1,
|
||||
createdAt: new Date(),
|
||||
dueDate: todo.dueDate ?? null,
|
||||
completedAt: null,
|
||||
title: todo.title,
|
||||
description: todo.description ?? null,
|
||||
tags: todo.tags ?? [],
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
this.updateState();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a todo
|
||||
*/
|
||||
completeTodo(id: number) {
|
||||
const result = this.db.complete(id);
|
||||
if (result.success) {
|
||||
this.updateState();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a todo
|
||||
*/
|
||||
deleteTodo(id: number) {
|
||||
const result = this.db.delete(id);
|
||||
if (result.success) {
|
||||
this.updateState();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific todo by ID
|
||||
*/
|
||||
getTodo(id: number) {
|
||||
return this.db.read(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import todos from the main task store
|
||||
*/
|
||||
importTodos(
|
||||
todos: Array<{
|
||||
title: string;
|
||||
description?: string | null;
|
||||
priority?: string;
|
||||
dueDate?: string | Date | null;
|
||||
isCompleted?: boolean;
|
||||
createdAt?: string | Date;
|
||||
}>
|
||||
) {
|
||||
// Reset database
|
||||
this.db = new SpiralDB<TodoData>({
|
||||
schema: createTodoSchema(),
|
||||
compression: true,
|
||||
});
|
||||
|
||||
// Convert priority string to number
|
||||
const priorityMap: Record<string, number> = {
|
||||
low: 0,
|
||||
medium: 1,
|
||||
high: 2,
|
||||
urgent: 3,
|
||||
};
|
||||
|
||||
for (const todo of todos) {
|
||||
const result = this.db.insert({
|
||||
id: 0,
|
||||
status: todo.isCompleted ? 1 : 0,
|
||||
priority: priorityMap[todo.priority || 'medium'] ?? 1,
|
||||
createdAt: todo.createdAt ? new Date(todo.createdAt) : new Date(),
|
||||
dueDate: todo.dueDate ? new Date(todo.dueDate) : null,
|
||||
completedAt: todo.isCompleted ? new Date() : null,
|
||||
title: todo.title.slice(0, 50), // Max length from schema
|
||||
description: todo.description?.slice(0, 200) ?? null,
|
||||
tags: [],
|
||||
});
|
||||
|
||||
if (result.success && todo.isCompleted) {
|
||||
this.db.complete(result.recordId!);
|
||||
}
|
||||
}
|
||||
|
||||
this.updateState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export to PNG and download
|
||||
*/
|
||||
downloadPng(filename = 'spiral-todos.png') {
|
||||
if (this.image) {
|
||||
downloadPng(this.image, filename);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PNG bytes for export
|
||||
*/
|
||||
getPngBytes(): Uint8Array | null {
|
||||
if (!this.image) return null;
|
||||
return exportToPngBytes(this.image);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data
|
||||
*/
|
||||
clear() {
|
||||
this.db = new SpiralDB<TodoData>({
|
||||
schema: createTodoSchema(),
|
||||
compression: true,
|
||||
});
|
||||
this.updateState();
|
||||
}
|
||||
}
|
||||
|
||||
export const spiralStore = new SpiralStore();
|
||||
|
|
@ -171,6 +171,7 @@
|
|||
onClick: handleFilterToggle,
|
||||
active: isFilterStripVisible,
|
||||
},
|
||||
{ href: '/spiral', label: 'Spiral', icon: 'sparkles' },
|
||||
{ href: '/tags', label: 'Tags', icon: 'tag' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
|
|
|
|||
712
apps/todo/apps/web/src/routes/(app)/spiral/+page.svelte
Normal file
712
apps/todo/apps/web/src/routes/(app)/spiral/+page.svelte
Normal file
|
|
@ -0,0 +1,712 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { spiralStore } from '$lib/stores/spiral.svelte';
|
||||
import { tasksStore } from '$lib/stores/tasks.svelte';
|
||||
import SpiralCanvas from '$lib/components/SpiralCanvas.svelte';
|
||||
import { visualizeImageEmoji, COLORS, type ColorDefinition } from '@manacore/spiral-db';
|
||||
|
||||
// Get colors as array for iteration
|
||||
const colorsArray: ColorDefinition[] = Object.values(COLORS);
|
||||
|
||||
// UI State
|
||||
let scale = $state(8);
|
||||
let showGrid = $state(false);
|
||||
let showEmoji = $state(false);
|
||||
let selectedPixel = $state<number | null>(null);
|
||||
let newTodoTitle = $state('');
|
||||
|
||||
// Derived state
|
||||
let emojiView = $derived(spiralStore.image ? visualizeImageEmoji(spiralStore.image) : '');
|
||||
|
||||
// Import todos from main store on mount
|
||||
onMount(async () => {
|
||||
// Fetch tasks if not already loaded
|
||||
if (tasksStore.tasks.length === 0) {
|
||||
await tasksStore.fetchTasks({});
|
||||
}
|
||||
|
||||
// Import existing todos into spiral DB
|
||||
if (tasksStore.tasks.length > 0) {
|
||||
spiralStore.importTodos(
|
||||
tasksStore.tasks.map((t) => ({
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
priority: t.priority,
|
||||
dueDate: t.dueDate,
|
||||
isCompleted: t.isCompleted,
|
||||
createdAt: t.createdAt,
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function handleAddTodo() {
|
||||
if (!newTodoTitle.trim()) return;
|
||||
|
||||
spiralStore.addTodo({
|
||||
title: newTodoTitle.trim(),
|
||||
priority: 1,
|
||||
});
|
||||
|
||||
newTodoTitle = '';
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
handleAddTodo();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePixelClick(index: number, x: number, y: number) {
|
||||
selectedPixel = index;
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
spiralStore.downloadPng();
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
if (confirm('Alle Spiral-Daten löschen?')) {
|
||||
spiralStore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function handleReimport() {
|
||||
spiralStore.importTodos(
|
||||
tasksStore.tasks.map((t) => ({
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
priority: t.priority,
|
||||
dueDate: t.dueDate,
|
||||
isCompleted: t.isCompleted,
|
||||
createdAt: t.createdAt,
|
||||
}))
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Spiral DB - Todo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="spiral-page">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">Spiral Database</h1>
|
||||
<p class="page-subtitle">Deine Todos als Pixel-Bild gespeichert</p>
|
||||
</header>
|
||||
|
||||
<div class="content-grid">
|
||||
<!-- Visualization Section -->
|
||||
<section class="viz-section">
|
||||
<div class="viz-header">
|
||||
<h2>Visualisierung</h2>
|
||||
<div class="viz-controls">
|
||||
<label class="control-label">
|
||||
<span>Zoom:</span>
|
||||
<input type="range" min="4" max="20" bind:value={scale} class="zoom-slider" />
|
||||
<span class="zoom-value">{scale}x</span>
|
||||
</label>
|
||||
|
||||
<label class="control-checkbox">
|
||||
<input type="checkbox" bind:checked={showGrid} />
|
||||
<span>Raster</span>
|
||||
</label>
|
||||
|
||||
<label class="control-checkbox">
|
||||
<input type="checkbox" bind:checked={showEmoji} />
|
||||
<span>Emoji</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="viz-container">
|
||||
{#if spiralStore.image}
|
||||
{#if showEmoji}
|
||||
<pre class="emoji-view">{emojiView}</pre>
|
||||
{:else}
|
||||
<SpiralCanvas
|
||||
image={spiralStore.image}
|
||||
{scale}
|
||||
{showGrid}
|
||||
highlightIndex={selectedPixel}
|
||||
onPixelClick={handlePixelClick}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="empty-state">Keine Daten</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedPixel !== null}
|
||||
<div class="pixel-detail">
|
||||
Ausgewählter Pixel: <code>#{selectedPixel}</code>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<section class="stats-section">
|
||||
<h2>Statistiken</h2>
|
||||
|
||||
{#if spiralStore.stats}
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">
|
||||
{spiralStore.stats.imageSize}x{spiralStore.stats.imageSize}
|
||||
</div>
|
||||
<div class="stat-label">Bildgröße</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{spiralStore.stats.activeRecords}</div>
|
||||
<div class="stat-label">Aktive Todos</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{spiralStore.stats.usedPixels}</div>
|
||||
<div class="stat-label">Genutzte Pixel</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card highlight">
|
||||
<div class="stat-value">{spiralStore.stats.compressionRatio}%</div>
|
||||
<div class="stat-label">Kompression vs JSON</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">Ring {spiralStore.stats.currentRing}</div>
|
||||
<div class="stat-label">Aktueller Ring</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{spiralStore.stats.totalRecords}</div>
|
||||
<div class="stat-label">Gesamt Records</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Color Legend -->
|
||||
<div class="legend">
|
||||
<h3>Farbpalette (3-Bit)</h3>
|
||||
<div class="legend-grid">
|
||||
{#each colorsArray as color}
|
||||
<div class="legend-item">
|
||||
<div
|
||||
class="color-swatch"
|
||||
style="background: rgb({color.rgb.r}, {color.rgb.g}, {color.rgb.b})"
|
||||
></div>
|
||||
<span class="color-name">{color.name}</span>
|
||||
<span class="color-bits">{color.index.toString(2).padStart(3, '0')}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Records Section -->
|
||||
<section class="records-section">
|
||||
<h2>Gespeicherte Todos ({spiralStore.records.length})</h2>
|
||||
|
||||
<!-- Quick Add -->
|
||||
<div class="quick-add">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newTodoTitle}
|
||||
placeholder="Neues Todo hinzufügen..."
|
||||
onkeydown={handleKeydown}
|
||||
class="quick-add-input"
|
||||
/>
|
||||
<button onclick={handleAddTodo} class="btn-add" disabled={!newTodoTitle.trim()}>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Records List -->
|
||||
<div class="records-list">
|
||||
{#each spiralStore.records as record}
|
||||
<div class="record-item" class:completed={record.meta.status === 'completed'}>
|
||||
<div class="record-status">
|
||||
{#if record.meta.status === 'completed'}
|
||||
<span class="status-icon completed">✓</span>
|
||||
{:else if record.meta.status === 'deleted'}
|
||||
<span class="status-icon deleted">✗</span>
|
||||
{:else}
|
||||
<span class="status-icon active">○</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="record-content">
|
||||
<div class="record-title">{record.data.title}</div>
|
||||
<div class="record-meta">
|
||||
ID: {record.meta.id} | Priority: {record.data.priority}
|
||||
{#if record.data.dueDate}
|
||||
| Due: {new Date(record.data.dueDate).toLocaleDateString('de-DE')}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="record-actions">
|
||||
{#if record.meta.status === 'active'}
|
||||
<button
|
||||
class="btn-small"
|
||||
onclick={() => spiralStore.completeTodo(record.meta.id)}
|
||||
title="Erledigen"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
{/if}
|
||||
{#if record.meta.status !== 'deleted'}
|
||||
<button
|
||||
class="btn-small danger"
|
||||
onclick={() => spiralStore.deleteTodo(record.meta.id)}
|
||||
title="Löschen"
|
||||
>
|
||||
✗
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-records">Keine Todos in der Spiral-Datenbank</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Actions Section -->
|
||||
<section class="actions-section">
|
||||
<h2>Aktionen</h2>
|
||||
<div class="action-buttons">
|
||||
<button onclick={handleDownload} class="btn-action" disabled={!spiralStore.image}>
|
||||
<span class="btn-icon">📥</span>
|
||||
PNG herunterladen
|
||||
</button>
|
||||
|
||||
<button onclick={handleReimport} class="btn-action">
|
||||
<span class="btn-icon">🔄</span>
|
||||
Todos neu importieren
|
||||
</button>
|
||||
|
||||
<button onclick={handleClear} class="btn-action danger">
|
||||
<span class="btn-icon">🗑️</span>
|
||||
Alles löschen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h4>Wie funktioniert SpiralDB?</h4>
|
||||
<p>
|
||||
SpiralDB speichert strukturierte Daten in Pixel-Bildern. Jeder Pixel nutzt 3 Bit (8
|
||||
Farben) und die Daten wachsen spiralförmig vom Zentrum nach außen. Das Bild kann als PNG
|
||||
exportiert und später wieder importiert werden.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.spiral-page {
|
||||
padding: 1rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.viz-section {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
background: var(--color-surface);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Visualization */
|
||||
.viz-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.viz-controls {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.zoom-slider {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.zoom-value {
|
||||
font-family: monospace;
|
||||
min-width: 3ch;
|
||||
}
|
||||
|
||||
.control-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.viz-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
background: #1a1a2e;
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.emoji-view {
|
||||
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--color-text-secondary);
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.pixel-detail {
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.pixel-detail code {
|
||||
background: var(--color-background);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--color-background);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card.highlight {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-card.highlight .stat-label {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* Legend */
|
||||
.legend h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.legend-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.color-name {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.color-bits {
|
||||
color: var(--color-text-secondary);
|
||||
font-family: monospace;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
/* Records */
|
||||
.quick-add {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.quick-add-input {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.quick-add-input:focus {
|
||||
outline: none;
|
||||
border-color: #8b5cf6;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: #8b5cf6;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-add:hover:not(:disabled) {
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
.btn-add:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.records-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.record-item.completed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.record-status {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.status-icon.completed {
|
||||
color: #22c55e;
|
||||
}
|
||||
.status-icon.deleted {
|
||||
color: #ef4444;
|
||||
}
|
||||
.status-icon.active {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.record-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.record-title {
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.record-item.completed .record-title {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.record-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.record-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-small:hover {
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-small.danger:hover {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.empty-records {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-action:hover:not(:disabled) {
|
||||
background: var(--color-surface);
|
||||
border-color: #8b5cf6;
|
||||
}
|
||||
|
||||
.btn-action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-action.danger:hover:not(:disabled) {
|
||||
background: #fef2f2;
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
:global(.dark) .btn-action.danger:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--color-background);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #8b5cf6;
|
||||
}
|
||||
|
||||
.info-box h4 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
1091
pnpm-lock.yaml
generated
1091
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue