feat: intro and some context

This commit is contained in:
lew 2026-03-24 03:18:16 +00:00
parent 38768ca963
commit a87ee38839
5 changed files with 114 additions and 28 deletions

View file

@ -17,6 +17,7 @@
--text-muted: #737373; --text-muted: #737373;
--accent: #0066cc; --accent: #0066cc;
--accent-hover: #0052a3; --accent-hover: #0052a3;
--error: #dc2626;
} }
.dark { .dark {
@ -28,6 +29,7 @@
--text-muted: #a3a3a3; --text-muted: #a3a3a3;
--accent: #4da6ff; --accent: #4da6ff;
--accent-hover: #80bfff; --accent-hover: #80bfff;
--error: #f87171;
} }
body { body {
@ -35,3 +37,9 @@ body {
color: var(--text); color: var(--text);
font-family: system-ui, -apple-system, sans-serif; font-family: system-ui, -apple-system, sans-serif;
} }
.field-error input,
.field-error select,
.field-error textarea {
border-color: var(--error) !important;
}

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Aurora Records</title> <title>Character Records</title>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

View file

@ -62,7 +62,7 @@
<header class="border-b shrink-0" style="border-color: var(--border); background: var(--bg-card);"> <header class="border-b shrink-0" style="border-color: var(--border); background: var(--bg-card);">
<div class="flex items-center gap-2 px-4 py-3 max-w-7xl mx-auto w-full"> <div class="flex items-center gap-2 px-4 py-3 max-w-7xl mx-auto w-full">
<h1 class="font-bold whitespace-nowrap">Aurora Records</h1> <h1 class="font-bold whitespace-nowrap">Character Records</h1>
{#if roster.characters.length > 0} {#if roster.characters.length > 0}
<CharacterSwitcher /> <CharacterSwitcher />

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { ChevronDown } from 'lucide-svelte'; import { ChevronDown } from 'lucide-svelte';
import type { RecordDef } from '$lib/types'; import type { FieldDef, RecordDef } from '$lib/types';
import DynamicField from './fields/DynamicField.svelte'; import DynamicField from './fields/DynamicField.svelte';
import { slugify } from '$lib/utils/slugify'; import { slugify } from '$lib/utils/slugify';
@ -12,15 +12,22 @@
} = $props(); } = $props();
let expanded = $state(false); let expanded = $state(false);
let touched: Record<string, boolean> = $state({});
function isFieldEmpty(v: unknown): boolean {
if (v === undefined || v === null || v === '' || v === 0) return true;
if (Array.isArray(v) && v.length === 0) return true;
return false;
}
function isRequired(field: FieldDef): boolean {
if (field.type === 'separator') return false;
return !!field.required;
}
let dataFields = $derived(record.fields.filter((f) => f.type !== 'separator')); let dataFields = $derived(record.fields.filter((f) => f.type !== 'separator'));
let filled = $derived( let filled = $derived(
dataFields.filter((f) => { dataFields.filter((f) => !isFieldEmpty(data[slugify(f.label)])).length
const v = data[slugify(f.label)];
if (v === undefined || v === null || v === '' || v === 0) return false;
if (Array.isArray(v) && v.length === 0) return false;
return true;
}).length
); );
</script> </script>
@ -55,12 +62,26 @@
{#if expanded} {#if expanded}
<div transition:slide={{ duration: 150 }} class="px-4 pb-4 flex flex-col gap-4"> <div transition:slide={{ duration: 150 }} class="px-4 pb-4 flex flex-col gap-4">
{#each record.fields as field} {#each record.fields as field}
{@const key = slugify(field.label)}
{@const hasError = isRequired(field) && touched[key] && isFieldEmpty(data[key])}
<div
class={hasError ? 'field-error' : ''}
onfocusout={(e) => {
if (isRequired(field) && !(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) {
touched[key] = true;
}
}}
>
<DynamicField <DynamicField
{field} {field}
value={data[slugify(field.label)]} value={data[key]}
{data} {data}
onChange={(v) => onFieldChange(slugify(field.label), v)} onChange={(v) => onFieldChange(key, v)}
/> />
{#if hasError}
<p class="text-xs mt-1" style="color: var(--error);">This field is required</p>
{/if}
</div>
{/each} {/each}
</div> </div>
{/if} {/if}

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { FileText, Link2, Users } from 'lucide-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';
@ -11,8 +12,10 @@
let importData = $state<string | null>(null); let importData = $state<string | null>(null);
let fileImportData = $state<{ template: any; data: Record<string, unknown> } | null>(null); let fileImportData = $state<{ template: any; data: Record<string, unknown> } | null>(null);
let importError = $state<string | null>(null);
let mobileView = $state<'edit' | 'preview' | 'split'>('split'); let mobileView = $state<'edit' | 'preview' | 'split'>('split');
let showPicker = $state(false); let showPicker = $state(false);
let emptyFileInput: HTMLInputElement;
function checkHash() { function checkHash() {
const hash = window.location.hash.slice(1); const hash = window.location.hash.slice(1);
@ -35,8 +38,9 @@
function handleFileImport(json: string) { function handleFileImport(json: string) {
try { try {
fileImportData = parseCharacterFile(json); fileImportData = parseCharacterFile(json);
importError = null;
} catch { } catch {
// TODO: show error to user importError = 'Could not read that file. Check that it\u2019s a valid character export.';
} }
} }
@ -44,12 +48,28 @@
fileImportData = null; fileImportData = null;
} }
async function handleEmptyFileImport(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const text = await file.text();
handleFileImport(text);
input.value = '';
}
const modes = ['edit', 'preview', 'split'] as const; const modes = ['edit', 'preview', 'split'] as const;
</script> </script>
<div class="h-dvh flex flex-col overflow-hidden"> <div class="h-dvh flex flex-col overflow-hidden">
<Header onImport={handleFileImport} /> <Header onImport={handleFileImport} />
{#if importError}
<div class="px-4 py-2 text-sm flex items-center justify-center gap-2 border-b" style="background: color-mix(in srgb, var(--error) 8%, var(--bg)); color: var(--error); border-color: var(--border);">
<span>{importError}</span>
<button onclick={() => { importError = null; }} class="underline hover:opacity-80">Dismiss</button>
</div>
{/if}
{#if fileImportData} {#if fileImportData}
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
<ImportModal fileData={fileImportData} onClose={closeFileImport} /> <ImportModal fileData={fileImportData} onClose={closeFileImport} />
@ -109,19 +129,56 @@
{/if} {/if}
</main> </main>
{:else} {:else}
<main class="flex-1 flex flex-col items-center justify-center gap-4"> <main class="flex-1 flex items-center justify-center p-6">
<p style="color: var(--text-muted);">No characters yet.</p> <div class="max-w-md w-full flex flex-col gap-6" style="color: var(--text-muted);">
<div class="text-sm flex flex-col gap-4">
<p>
Pick a template and fill in the form. Each section covers a different record. Blank fields are omitted from the output automatically, so no rush to finish everything.
</p>
<p>
Characters save to your browser. You can also export to a file or generate a share link: the link itself encodes the full set of records, so functionally it's a save file.
</p>
<p>
Share links let the recipient see a preview of your records, with the option to import the character into their own roster.
</p>
<p>
This tool is entirely data-driven in XML, and it's already set up for template sharing. A visual template editor is coming soon, so anybody can create their own templates and share them between one another.
</p>
<p>
Cheers.
</p>
</div>
<div class="flex gap-3">
<button <button
onclick={() => { onclick={() => {
if (presets.length === 1) roster.create(presets[0]); if (presets.length === 1) roster.create(presets[0]);
else showPicker = true; else showPicker = true;
}} }}
class="px-3 py-1 rounded text-sm border hover:opacity-80" class="flex-1 px-4 py-2 rounded text-sm font-medium hover:opacity-90"
style="border-color: var(--border);" style="background: var(--accent); color: white;"
> >
Get Started New Character
</button> </button>
<button
onclick={() => emptyFileInput.click()}
class="px-4 py-2 rounded text-sm border hover:opacity-80"
style="border-color: var(--border); color: var(--text-muted);"
>
Import
</button>
</div>
</div>
</main> </main>
<input
bind:this={emptyFileInput}
type="file"
accept=".json"
class="hidden"
onchange={handleEmptyFileImport}
/>
{#if showPicker} {#if showPicker}
<TemplatePicker onClose={() => { showPicker = false; }} /> <TemplatePicker onClose={() => { showPicker = false; }} />
{/if} {/if}