diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte index 4178f74..877af38 100644 --- a/src/lib/components/Header.svelte +++ b/src/lib/components/Header.svelte @@ -1,13 +1,26 @@ @@ -22,6 +35,16 @@ {#if roster.saveStatus === 'saving'}Saving...{:else if roster.saveStatus === 'saved'}Saved{/if} + {#if roster.active} + + {#if copied} + + {:else} + + {/if} + + {/if} + + import { decodeCharacterURL, decodeTemplateURL } from '$lib/sharing'; + import { generateRecord } from '$lib/output'; + import { species } from '$lib/data'; + import { roster } from '$lib/state.svelte'; + import { slugify } from '$lib/utils/slugify'; + import OutputTab from './OutputTab.svelte'; + + let { encoded, onClose }: { encoded: string; onClose: () => void } = $props(); + + let error = $state(''); + let type = $state<'character' | 'template' | null>(null); + let charData = $state<{ template: any; data: Record } | null>(null); + let tmplData = $state(null); + + let activeTab = $state(''); + + $effect(() => { + try { + if (encoded.startsWith('c1.')) { + type = 'character'; + charData = decodeCharacterURL(encoded); + } else if (encoded.startsWith('t1.')) { + type = 'template'; + tmplData = decodeTemplateURL(encoded); + } else { + error = 'Unrecognized share link format.'; + } + } catch { + error = 'Failed to decode share link.'; + } + }); + + let tabs = $derived( + charData?.template.records.filter((r: any) => r.type !== 'public') ?? [] + ); + + $effect(() => { + if (tabs.length && !tabs.some((t: any) => t.type === activeTab)) { + activeTab = tabs[0].type; + } + }); + + let output = $derived( + charData && activeTab + ? generateRecord(charData.template, charData.data, activeTab, species) + : '' + ); + + function charName(): string { + if (!charData) return 'Unknown'; + return (charData.data[slugify('Name')] as string) || 'Unnamed Character'; + } + + async function importCharacter() { + if (!charData) return; + const char = await roster.create(charData.template); + Object.assign(char.data, charData.data); + roster.scheduleSave(char); + onClose(); + } + + async function importTemplate() { + if (!tmplData) return; + await roster.create(tmplData); + onClose(); + } + + + + + {#if error} + + {error} + + Dismiss + + + {:else if type === 'character' && charData} + + + {charName()} + Shared character — {charData.template.name} template + + + + Cancel + + + Import + + + + + + + {#each tabs as tab} + { activeTab = tab.type; }} + class="px-4 py-2 text-sm capitalize" + style={activeTab === tab.type + ? `color: var(--accent); border-bottom: 2px solid var(--accent);` + : `color: var(--text-muted); border-bottom: 2px solid transparent;`} + > + {tab.type} + + {/each} + + + + {:else if type === 'template' && tmplData} + + Shared Template: {tmplData.name} + {tmplData.records.length} records, {tmplData.records.reduce((n: number, r: any) => n + r.fields.length, 0)} fields + + + Cancel + + + Create Character + + + + {/if} + + diff --git a/src/lib/components/Modal.svelte b/src/lib/components/Modal.svelte new file mode 100644 index 0000000..ed11d8f --- /dev/null +++ b/src/lib/components/Modal.svelte @@ -0,0 +1,25 @@ + + + + + + + + + e.stopPropagation()} + > + {@render children()} + + diff --git a/src/lib/sharing.test.ts b/src/lib/sharing.test.ts index 02f151c..d4eae67 100644 --- a/src/lib/sharing.test.ts +++ b/src/lib/sharing.test.ts @@ -5,12 +5,25 @@ import { encodeTemplateURL, decodeTemplateURL } from './sharing'; +import { presets } from './presets'; import type { Character, Template } from './types'; -const testTemplate: Template = { - id: 'preset:standard', - name: 'Standard', - description: 'The standard record format.', +const standardPreset = presets.find((p) => p.id === 'preset:standard')!; + +const testCharacter: Character = { + id: 'abc-123', + template: standardPreset, + data: { + name: 'Yury Zakharov', + species: 'human', + 'employment-history': 'Shaft Miner' + } +}; + +const customTemplate: Template = { + id: 'custom:test', + name: 'Custom', + description: 'A custom template.', schemaVersion: 1, records: [ { @@ -23,35 +36,34 @@ const testTemplate: Template = { ] }; -const testCharacter: Character = { - id: 'abc-123', - template: testTemplate, - data: { - name: 'Yury Zakharov', - species: 'human', - 'employment-history': 'Shaft Miner' - } -}; - describe('character URL encoding', () => { - it('round-trips character data', () => { + it('round-trips preset character data', () => { const encoded = encodeCharacterURL(testCharacter); const decoded = decodeCharacterURL(encoded); expect(decoded.data).toEqual(testCharacter.data); expect(decoded.template.name).toBe('Standard'); }); + it('uses short encoding for preset templates', () => { + const encoded = encodeCharacterURL(testCharacter); + const customChar = { ...testCharacter, template: customTemplate }; + const customEncoded = encodeCharacterURL(customChar); + expect(encoded.length).toBeLessThan(customEncoded.length); + }); + + it('round-trips custom template character', () => { + const char: Character = { ...testCharacter, template: customTemplate }; + const encoded = encodeCharacterURL(char); + const decoded = decodeCharacterURL(encoded); + expect(decoded.data).toEqual(testCharacter.data); + expect(decoded.template.name).toBe('Custom'); + }); + it('starts with c1. prefix', () => { const encoded = encodeCharacterURL(testCharacter); expect(encoded.startsWith('c1.')).toBe(true); }); - it('strips id', () => { - const encoded = encodeCharacterURL(testCharacter); - const decoded = decodeCharacterURL(encoded); - expect(decoded).not.toHaveProperty('id'); - }); - it('prunes empty values from data', () => { const char: Character = { ...testCharacter, @@ -65,19 +77,19 @@ describe('character URL encoding', () => { describe('template URL encoding', () => { it('round-trips template structure', () => { - const encoded = encodeTemplateURL(testTemplate); + const encoded = encodeTemplateURL(customTemplate); const decoded = decodeTemplateURL(encoded); - expect(decoded.name).toBe('Standard'); - expect(decoded.records).toEqual(testTemplate.records); + expect(decoded.name).toBe('Custom'); + expect(decoded.records).toEqual(customTemplate.records); }); it('starts with t1. prefix', () => { - const encoded = encodeTemplateURL(testTemplate); + const encoded = encodeTemplateURL(customTemplate); expect(encoded.startsWith('t1.')).toBe(true); }); it('strips id', () => { - const encoded = encodeTemplateURL(testTemplate); + const encoded = encodeTemplateURL(customTemplate); const decoded = decodeTemplateURL(encoded); expect(decoded).not.toHaveProperty('id'); }); diff --git a/src/lib/sharing.ts b/src/lib/sharing.ts index 0726f10..160f75c 100644 --- a/src/lib/sharing.ts +++ b/src/lib/sharing.ts @@ -1,5 +1,6 @@ import pako from 'pako'; import type { Character, Template } from './types'; +import { presets } from './presets'; function toBase64url(bytes: Uint8Array): string { let binary = ''; @@ -26,20 +27,32 @@ function pruneEmpty(data: Record): Record { } export function encodeCharacterURL(char: Character): string { - const payload = { - template: stripId(char.template), + const isPreset = char.template.id.startsWith('preset:'); + const payload: any = { data: pruneEmpty(char.data) }; + if (isPreset) { + payload.templateId = char.template.id; + } else { + payload.template = stripId(char.template); + } const json = JSON.stringify(payload); const compressed = pako.deflate(new TextEncoder().encode(json)); return 'c1.' + toBase64url(compressed); } -export function decodeCharacterURL(encoded: string): { template: Omit; data: Record } { +export function decodeCharacterURL(encoded: string): { template: Template | Omit; data: Record } { if (!encoded.startsWith('c1.')) throw new Error('Invalid character URL prefix'); const bytes = fromBase64url(encoded.slice(3)); const json = new TextDecoder().decode(pako.inflate(bytes)); - return JSON.parse(json); + const payload = JSON.parse(json); + + if (payload.templateId) { + const preset = presets.find((p) => p.id === payload.templateId); + if (!preset) throw new Error(`Unknown template: ${payload.templateId}`); + return { template: preset, data: payload.data }; + } + return { template: payload.template, data: payload.data }; } export function encodeTemplateURL(template: Template): string { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 58b1204..72c5143 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,15 +1,33 @@ - {#if roster.active} + {#if importData} + + {:else if roster.active} {@const char = roster.active}
{error}
Shared character — {charData.template.name} template
{tmplData.records.length} records, {tmplData.records.reduce((n: number, r: any) => n + r.fields.length, 0)} fields