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:
Till JS 2026-03-23 11:02:19 +01:00
parent 512cf412cc
commit 677a499c93
8 changed files with 959 additions and 1 deletions

View file

@ -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"
},

View 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>

View 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();

View file

@ -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)

View 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&ouml;&szlig;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)}
>
&times;
</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&ouml;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&auml;chst von der Mitte nach au&szlig;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>

View file

@ -93,6 +93,7 @@ export {
export {
createTodoSchema,
createQuoteSchema,
createContactSchema,
encodeSchema,
decodeSchema,
getSchemaPixelCount,

View file

@ -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();

View file

@ -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)
*/