refactor: only include template in share url if it's custom
This commit is contained in:
parent
bb429d8195
commit
be11c0e57a
6 changed files with 249 additions and 32 deletions
|
|
@ -1,13 +1,26 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Sun, Moon } from 'lucide-svelte';
|
import { Sun, Moon, Share2, Check } from 'lucide-svelte';
|
||||||
import { theme } from '$lib/theme.svelte';
|
import { theme } from '$lib/theme.svelte';
|
||||||
import { roster } from '$lib/state.svelte';
|
import { roster } from '$lib/state.svelte';
|
||||||
import { presets } from '$lib/presets';
|
import { presets } from '$lib/presets';
|
||||||
|
import { encodeCharacterURL } from '$lib/sharing';
|
||||||
import CharacterSwitcher from './CharacterSwitcher.svelte';
|
import CharacterSwitcher from './CharacterSwitcher.svelte';
|
||||||
|
|
||||||
|
let copied = $state(false);
|
||||||
|
|
||||||
async function createCharacter() {
|
async function createCharacter() {
|
||||||
await roster.create(presets[0]);
|
await roster.create(presets[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function share() {
|
||||||
|
const char = roster.active;
|
||||||
|
if (!char) return;
|
||||||
|
const encoded = encodeCharacterURL(char);
|
||||||
|
const url = `${window.location.origin}${window.location.pathname}#${encoded}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => { copied = false; }, 2000);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="sticky top-0 z-20 flex items-center gap-3 px-4 py-3 border-b" style="border-color: var(--border); background: var(--bg-card);">
|
<header class="sticky top-0 z-20 flex items-center gap-3 px-4 py-3 border-b" style="border-color: var(--border); background: var(--bg-card);">
|
||||||
|
|
@ -22,6 +35,16 @@
|
||||||
{#if roster.saveStatus === 'saving'}Saving...{:else if roster.saveStatus === 'saved'}Saved{/if}
|
{#if roster.saveStatus === 'saving'}Saving...{:else if roster.saveStatus === 'saved'}Saved{/if}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{#if roster.active}
|
||||||
|
<button onclick={share} class="p-1 rounded hover:opacity-80" title="Copy share link">
|
||||||
|
{#if copied}
|
||||||
|
<Check size={18} />
|
||||||
|
{:else}
|
||||||
|
<Share2 size={18} />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={createCharacter}
|
onclick={createCharacter}
|
||||||
class="px-3 py-1 rounded text-sm border hover:opacity-80"
|
class="px-3 py-1 rounded text-sm border hover:opacity-80"
|
||||||
|
|
|
||||||
126
src/lib/components/ImportModal.svelte
Normal file
126
src/lib/components/ImportModal.svelte
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
<script lang="ts">
|
||||||
|
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<string, unknown> } | null>(null);
|
||||||
|
let tmplData = $state<any>(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();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen flex flex-col items-center p-4">
|
||||||
|
<div class="w-full max-w-2xl">
|
||||||
|
{#if error}
|
||||||
|
<div class="rounded border p-6 text-center" style="border-color: var(--border); background: var(--bg-card);">
|
||||||
|
<p class="mb-4">{error}</p>
|
||||||
|
<button onclick={onClose} class="px-3 py-1 rounded text-sm border hover:opacity-80" style="border-color: var(--border);">
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if type === 'character' && charData}
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold">{charName()}</h2>
|
||||||
|
<p class="text-sm" style="color: var(--text-muted);">Shared character — {charData.template.name} template</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick={onClose} class="px-3 py-1 rounded text-sm border hover:opacity-80" style="border-color: var(--border);">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onclick={importCharacter} class="px-3 py-1 rounded text-sm border hover:opacity-80" style="border-color: var(--border);">
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded border" style="border-color: var(--border); background: var(--bg-card);">
|
||||||
|
<div class="flex border-b" style="border-color: var(--border);">
|
||||||
|
{#each tabs as tab}
|
||||||
|
<button
|
||||||
|
onclick={() => { 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}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<OutputTab {output} />
|
||||||
|
</div>
|
||||||
|
{:else if type === 'template' && tmplData}
|
||||||
|
<div class="rounded border p-6 text-center" style="border-color: var(--border); background: var(--bg-card);">
|
||||||
|
<h2 class="font-semibold mb-2">Shared Template: {tmplData.name}</h2>
|
||||||
|
<p class="text-sm mb-4" style="color: var(--text-muted);">{tmplData.records.length} records, {tmplData.records.reduce((n: number, r: any) => n + r.fields.length, 0)} fields</p>
|
||||||
|
<div class="flex gap-2 justify-center">
|
||||||
|
<button onclick={onClose} class="px-3 py-1 rounded text-sm border hover:opacity-80" style="border-color: var(--border);">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onclick={importTemplate} class="px-3 py-1 rounded text-sm border hover:opacity-80" style="border-color: var(--border);">
|
||||||
|
Create Character
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
25
src/lib/components/Modal.svelte
Normal file
25
src/lib/components/Modal.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script lang="ts">
|
||||||
|
let { onClose, children }: { onClose: () => void; children: any } = $props();
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
onmousedown={onClose}
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 bg-black/50"></div>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="relative rounded-lg shadow-xl max-w-md w-full mx-4 p-6"
|
||||||
|
style="background: var(--bg-card); color: var(--text);"
|
||||||
|
onmousedown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -5,12 +5,25 @@ import {
|
||||||
encodeTemplateURL,
|
encodeTemplateURL,
|
||||||
decodeTemplateURL
|
decodeTemplateURL
|
||||||
} from './sharing';
|
} from './sharing';
|
||||||
|
import { presets } from './presets';
|
||||||
import type { Character, Template } from './types';
|
import type { Character, Template } from './types';
|
||||||
|
|
||||||
const testTemplate: Template = {
|
const standardPreset = presets.find((p) => p.id === 'preset:standard')!;
|
||||||
id: 'preset:standard',
|
|
||||||
name: 'Standard',
|
const testCharacter: Character = {
|
||||||
description: 'The standard record format.',
|
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,
|
schemaVersion: 1,
|
||||||
records: [
|
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', () => {
|
describe('character URL encoding', () => {
|
||||||
it('round-trips character data', () => {
|
it('round-trips preset character data', () => {
|
||||||
const encoded = encodeCharacterURL(testCharacter);
|
const encoded = encodeCharacterURL(testCharacter);
|
||||||
const decoded = decodeCharacterURL(encoded);
|
const decoded = decodeCharacterURL(encoded);
|
||||||
expect(decoded.data).toEqual(testCharacter.data);
|
expect(decoded.data).toEqual(testCharacter.data);
|
||||||
expect(decoded.template.name).toBe('Standard');
|
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', () => {
|
it('starts with c1. prefix', () => {
|
||||||
const encoded = encodeCharacterURL(testCharacter);
|
const encoded = encodeCharacterURL(testCharacter);
|
||||||
expect(encoded.startsWith('c1.')).toBe(true);
|
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', () => {
|
it('prunes empty values from data', () => {
|
||||||
const char: Character = {
|
const char: Character = {
|
||||||
...testCharacter,
|
...testCharacter,
|
||||||
|
|
@ -65,19 +77,19 @@ describe('character URL encoding', () => {
|
||||||
|
|
||||||
describe('template URL encoding', () => {
|
describe('template URL encoding', () => {
|
||||||
it('round-trips template structure', () => {
|
it('round-trips template structure', () => {
|
||||||
const encoded = encodeTemplateURL(testTemplate);
|
const encoded = encodeTemplateURL(customTemplate);
|
||||||
const decoded = decodeTemplateURL(encoded);
|
const decoded = decodeTemplateURL(encoded);
|
||||||
expect(decoded.name).toBe('Standard');
|
expect(decoded.name).toBe('Custom');
|
||||||
expect(decoded.records).toEqual(testTemplate.records);
|
expect(decoded.records).toEqual(customTemplate.records);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('starts with t1. prefix', () => {
|
it('starts with t1. prefix', () => {
|
||||||
const encoded = encodeTemplateURL(testTemplate);
|
const encoded = encodeTemplateURL(customTemplate);
|
||||||
expect(encoded.startsWith('t1.')).toBe(true);
|
expect(encoded.startsWith('t1.')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips id', () => {
|
it('strips id', () => {
|
||||||
const encoded = encodeTemplateURL(testTemplate);
|
const encoded = encodeTemplateURL(customTemplate);
|
||||||
const decoded = decodeTemplateURL(encoded);
|
const decoded = decodeTemplateURL(encoded);
|
||||||
expect(decoded).not.toHaveProperty('id');
|
expect(decoded).not.toHaveProperty('id');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import pako from 'pako';
|
import pako from 'pako';
|
||||||
import type { Character, Template } from './types';
|
import type { Character, Template } from './types';
|
||||||
|
import { presets } from './presets';
|
||||||
|
|
||||||
function toBase64url(bytes: Uint8Array): string {
|
function toBase64url(bytes: Uint8Array): string {
|
||||||
let binary = '';
|
let binary = '';
|
||||||
|
|
@ -26,20 +27,32 @@ function pruneEmpty(data: Record<string, unknown>): Record<string, unknown> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encodeCharacterURL(char: Character): string {
|
export function encodeCharacterURL(char: Character): string {
|
||||||
const payload = {
|
const isPreset = char.template.id.startsWith('preset:');
|
||||||
template: stripId(char.template),
|
const payload: any = {
|
||||||
data: pruneEmpty(char.data)
|
data: pruneEmpty(char.data)
|
||||||
};
|
};
|
||||||
|
if (isPreset) {
|
||||||
|
payload.templateId = char.template.id;
|
||||||
|
} else {
|
||||||
|
payload.template = stripId(char.template);
|
||||||
|
}
|
||||||
const json = JSON.stringify(payload);
|
const json = JSON.stringify(payload);
|
||||||
const compressed = pako.deflate(new TextEncoder().encode(json));
|
const compressed = pako.deflate(new TextEncoder().encode(json));
|
||||||
return 'c1.' + toBase64url(compressed);
|
return 'c1.' + toBase64url(compressed);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeCharacterURL(encoded: string): { template: Omit<Template, 'id'>; data: Record<string, unknown> } {
|
export function decodeCharacterURL(encoded: string): { template: Template | Omit<Template, 'id'>; data: Record<string, unknown> } {
|
||||||
if (!encoded.startsWith('c1.')) throw new Error('Invalid character URL prefix');
|
if (!encoded.startsWith('c1.')) throw new Error('Invalid character URL prefix');
|
||||||
const bytes = fromBase64url(encoded.slice(3));
|
const bytes = fromBase64url(encoded.slice(3));
|
||||||
const json = new TextDecoder().decode(pako.inflate(bytes));
|
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 {
|
export function encodeTemplateURL(template: Template): string {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,33 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import Header from '$lib/components/Header.svelte';
|
import Header from '$lib/components/Header.svelte';
|
||||||
import SchemaForm from '$lib/components/SchemaForm.svelte';
|
import SchemaForm from '$lib/components/SchemaForm.svelte';
|
||||||
import OutputPanel from '$lib/components/OutputPanel.svelte';
|
import OutputPanel from '$lib/components/OutputPanel.svelte';
|
||||||
|
import ImportModal from '$lib/components/ImportModal.svelte';
|
||||||
import { roster } from '$lib/state.svelte';
|
import { roster } from '$lib/state.svelte';
|
||||||
import { presets } from '$lib/presets';
|
import { presets } from '$lib/presets';
|
||||||
|
|
||||||
|
let importData = $state<string | null>(null);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
if (hash && (hash.startsWith('c1.') || hash.startsWith('t1.'))) {
|
||||||
|
importData = hash;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function closeImport() {
|
||||||
|
importData = null;
|
||||||
|
history.replaceState(null, '', window.location.pathname);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen flex flex-col">
|
<div class="min-h-screen flex flex-col">
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
{#if roster.active}
|
{#if importData}
|
||||||
|
<ImportModal encoded={importData} onClose={closeImport} />
|
||||||
|
{:else if roster.active}
|
||||||
{@const char = roster.active}
|
{@const char = roster.active}
|
||||||
<main class="flex-1 grid grid-cols-1 lg:grid-cols-[1fr_1fr]">
|
<main class="flex-1 grid grid-cols-1 lg:grid-cols-[1fr_1fr]">
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue