mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 00:39:41 +02:00
feat(contacts): integrate spiral-db for visual contact network
Add spiral-db integration to Contacts as the third app using pixel-based spiral visualization. Contacts are encoded with name, company, city, and email/phone flags. Changes: - Add createContactSchema() to spiral-db with bool fields for hasEmail/hasPhone and nullable company/city - Create Svelte 5 spiral store with importContacts from contactsStore - Add SpiralCanvas component and /spiral route - Wire up navigation (Ctrl+5) with auto-import on mount - Favorites show as starred entries with gold border Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
512cf412cc
commit
677a499c93
8 changed files with 959 additions and 1 deletions
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
165
apps/contacts/apps/web/src/lib/components/SpiralCanvas.svelte
Normal file
165
apps/contacts/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 = '#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>
|
||||
202
apps/contacts/apps/web/src/lib/stores/spiral.svelte.ts
Normal file
202
apps/contacts/apps/web/src/lib/stores/spiral.svelte.ts
Normal file
|
|
@ -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<string, unknown> {
|
||||
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<ContactData>;
|
||||
|
||||
image = $state<SpiralImage | null>(null);
|
||||
stats = $state<SpiralStats | null>(null);
|
||||
records = $state<SpiralRecord<ContactData>[]>([]);
|
||||
isLoading = $state(false);
|
||||
error = $state<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
this.db = new SpiralDB<ContactData>({
|
||||
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<ContactData>({
|
||||
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<ContactData>({
|
||||
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<ContactData>(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();
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
526
apps/contacts/apps/web/src/routes/(app)/spiral/+page.svelte
Normal file
526
apps/contacts/apps/web/src/routes/(app)/spiral/+page.svelte
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { COLORS } from '@manacore/spiral-db';
|
||||
import { spiralStore } from '$lib/stores/spiral.svelte';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import SpiralCanvas from '$lib/components/SpiralCanvas.svelte';
|
||||
|
||||
let zoom = $state(10);
|
||||
let showGrid = $state(false);
|
||||
let selectedPixel = $state<number | null>(null);
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
function handleImportContacts() {
|
||||
spiralStore.importContacts(contactsStore.contacts);
|
||||
}
|
||||
|
||||
function handlePixelClick(index: number) {
|
||||
selectedPixel = selectedPixel === index ? null : index;
|
||||
}
|
||||
|
||||
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 = '';
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (contactsStore.contacts.length === 0) {
|
||||
await contactsStore.loadContacts({});
|
||||
}
|
||||
if (contactsStore.contacts.length > 0) {
|
||||
handleImportContacts();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="spiral-page">
|
||||
<h1 class="title">SpiralDB</h1>
|
||||
<p class="subtitle">Dein Kontakt-Netzwerk 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 Kontakte 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">Kontakte</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>
|
||||
|
||||
<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 Kontakte
|
||||
{#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 Kontakte in der Spirale.</p>
|
||||
{:else}
|
||||
<div class="records-list">
|
||||
{#each spiralStore.records as record}
|
||||
<div class="record" class:favorite={record.meta.status === 'completed'}>
|
||||
<div class="record-header">
|
||||
<span class="record-icon">
|
||||
{record.meta.status === 'completed' ? '⭐' : '👤'}
|
||||
</span>
|
||||
<span class="record-name">{record.data.name}</span>
|
||||
<span class="record-id mono">#{record.meta.id}</span>
|
||||
</div>
|
||||
<div class="record-meta">
|
||||
{#if record.data.company}
|
||||
<span class="record-company">{record.data.company}</span>
|
||||
{/if}
|
||||
{#if record.data.city}
|
||||
<span class="record-city">{record.data.city}</span>
|
||||
{/if}
|
||||
<span class="record-badges">
|
||||
{#if record.data.hasEmail}
|
||||
<span class="badge-small" title="Hat E-Mail">@</span>
|
||||
{/if}
|
||||
{#if record.data.hasPhone}
|
||||
<span class="badge-small" title="Hat Telefon">📞</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="record-footer">
|
||||
<button
|
||||
class="btn-small btn-danger"
|
||||
onclick={() => spiralStore.removeContact(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={handleImportContacts}
|
||||
disabled={contactsStore.contacts.length === 0}
|
||||
>
|
||||
Kontakte neu importieren ({contactsStore.contacts.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 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.
|
||||
</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, #3b82f6);
|
||||
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-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(59, 130, 246, 0.2), rgba(99, 102, 241, 0.2));
|
||||
}
|
||||
|
||||
.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-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, #3b82f6);
|
||||
}
|
||||
|
||||
.record.favorite {
|
||||
border-left-color: #eab308;
|
||||
}
|
||||
|
||||
.record-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.record-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.record-name {
|
||||
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-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.record-company {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.record-badges {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.badge-small {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.record-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.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 {
|
||||
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, #3b82f6);
|
||||
border-color: var(--color-primary, #3b82f6);
|
||||
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>
|
||||
|
|
@ -93,6 +93,7 @@ export {
|
|||
export {
|
||||
createTodoSchema,
|
||||
createQuoteSchema,
|
||||
createContactSchema,
|
||||
encodeSchema,
|
||||
decodeSchema,
|
||||
getSchemaPixelCount,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue