feat(fend): skeleton implementation of the form from XML
This commit is contained in:
parent
ebc522f568
commit
3c6a31f86b
20 changed files with 563 additions and 2 deletions
30
src/app.css
30
src/app.css
|
|
@ -1 +1,31 @@
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #fafafa;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--bg-input: #ffffff;
|
||||||
|
--border: #d4d4d4;
|
||||||
|
--text: #171717;
|
||||||
|
--text-muted: #737373;
|
||||||
|
--accent: #0066cc;
|
||||||
|
--accent-hover: #0052a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--bg: #0a0a0a;
|
||||||
|
--bg-card: #171717;
|
||||||
|
--bg-input: #0a0a0a;
|
||||||
|
--border: #2e2e2e;
|
||||||
|
--text: #e5e5e5;
|
||||||
|
--text-muted: #a3a3a3;
|
||||||
|
--accent: #4da6ff;
|
||||||
|
--accent-hover: #80bfff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
|
||||||
38
src/lib/components/CharacterSwitcher.svelte
Normal file
38
src/lib/components/CharacterSwitcher.svelte
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Trash2 } from 'lucide-svelte';
|
||||||
|
import { roster } from '$lib/state.svelte';
|
||||||
|
import { slugify } from '$lib/utils/slugify';
|
||||||
|
|
||||||
|
function displayName(char: { data: Record<string, unknown> }): string {
|
||||||
|
const name = char.data[slugify('Name')];
|
||||||
|
return (name as string) || 'Unnamed Character';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<select
|
||||||
|
value={roster.active?.id ?? ''}
|
||||||
|
onchange={(e) => roster.setActive((e.target as HTMLSelectElement).value)}
|
||||||
|
class="rounded px-2 py-1 text-sm"
|
||||||
|
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
|
||||||
|
>
|
||||||
|
{#each roster.characters as char}
|
||||||
|
<option value={char.id}>{displayName(char)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{#if roster.active}
|
||||||
|
<button
|
||||||
|
onclick={async () => {
|
||||||
|
if (roster.active && confirm('Delete this character?')) {
|
||||||
|
await roster.remove(roster.active.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="p-1 rounded hover:opacity-80"
|
||||||
|
style="color: var(--text-muted);"
|
||||||
|
title="Delete character"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
41
src/lib/components/Header.svelte
Normal file
41
src/lib/components/Header.svelte
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Sun, Moon } from 'lucide-svelte';
|
||||||
|
import { theme } from '$lib/theme.svelte';
|
||||||
|
import { roster } from '$lib/state.svelte';
|
||||||
|
import { presets } from '$lib/presets';
|
||||||
|
import CharacterSwitcher from './CharacterSwitcher.svelte';
|
||||||
|
|
||||||
|
async function createCharacter() {
|
||||||
|
await roster.create(presets[0]);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="flex items-center gap-3 px-4 py-3 border-b" style="border-color: var(--border); background: var(--bg-card);">
|
||||||
|
<h1 class="font-bold whitespace-nowrap">Aurora Records</h1>
|
||||||
|
|
||||||
|
{#if roster.characters.length > 0}
|
||||||
|
<CharacterSwitcher />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="ml-auto flex items-center gap-3">
|
||||||
|
<span class="text-sm" style="color: var(--text-muted);">
|
||||||
|
{#if roster.saveStatus === 'saving'}Saving...{:else if roster.saveStatus === 'saved'}Saved{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={createCharacter}
|
||||||
|
class="px-3 py-1 rounded text-sm border hover:opacity-80"
|
||||||
|
style="border-color: var(--border);"
|
||||||
|
>
|
||||||
|
New Character
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onclick={() => theme.toggle()} class="p-1 rounded hover:opacity-80" title="Toggle theme">
|
||||||
|
{#if theme.dark}
|
||||||
|
<Sun size={18} />
|
||||||
|
{:else}
|
||||||
|
<Moon size={18} />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
29
src/lib/components/fields/CheckboxField.svelte
Normal file
29
src/lib/components/fields/CheckboxField.svelte
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { CheckboxField } from '$lib/types';
|
||||||
|
|
||||||
|
let { field, value = [], onChange }: { field: CheckboxField; value: string[]; onChange: (v: string[]) => void } = $props();
|
||||||
|
|
||||||
|
function toggle(optValue: string) {
|
||||||
|
if (value.includes(optValue)) {
|
||||||
|
onChange(value.filter((v) => v !== optValue));
|
||||||
|
} else {
|
||||||
|
onChange([...value, optValue]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend class="text-sm font-medium">{field.label}</legend>
|
||||||
|
<div class="mt-1 flex flex-col gap-1">
|
||||||
|
{#each field.options as opt}
|
||||||
|
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value.includes(opt.value)}
|
||||||
|
onchange={() => toggle(opt.value)}
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
21
src/lib/components/fields/CitizenshipField.svelte
Normal file
21
src/lib/components/fields/CitizenshipField.svelte
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { CitizenshipField } from '$lib/types';
|
||||||
|
import { citizenships } from '$lib/data';
|
||||||
|
|
||||||
|
let { field, value = '', onChange }: { field: CitizenshipField; value: string; onChange: (v: string) => void } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium">{field.label}</span>
|
||||||
|
<select
|
||||||
|
{value}
|
||||||
|
onchange={(e) => onChange((e.target as HTMLSelectElement).value)}
|
||||||
|
class="mt-1 block w-full rounded px-3 py-2 text-sm"
|
||||||
|
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{#each citizenships as c}
|
||||||
|
<option value={c.name}>{c.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
17
src/lib/components/fields/DateField.svelte
Normal file
17
src/lib/components/fields/DateField.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { DateField } from '$lib/types';
|
||||||
|
|
||||||
|
let { field, value = '', onChange }: { field: DateField; value: string; onChange: (v: string) => void } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium">{field.label}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{value}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
oninput={(e) => onChange((e.target as HTMLInputElement).value)}
|
||||||
|
class="mt-1 block w-full rounded px-3 py-2 text-sm"
|
||||||
|
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
53
src/lib/components/fields/DynamicField.svelte
Normal file
53
src/lib/components/fields/DynamicField.svelte
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { FieldDef } from '$lib/types';
|
||||||
|
import TextField from './TextField.svelte';
|
||||||
|
import TextareaField from './TextareaField.svelte';
|
||||||
|
import ListField from './ListField.svelte';
|
||||||
|
import NumberField from './NumberField.svelte';
|
||||||
|
import SelectField from './SelectField.svelte';
|
||||||
|
import CheckboxField from './CheckboxField.svelte';
|
||||||
|
import DateField from './DateField.svelte';
|
||||||
|
import HeightField from './HeightField.svelte';
|
||||||
|
import WeightField from './WeightField.svelte';
|
||||||
|
import SpeciesField from './SpeciesField.svelte';
|
||||||
|
import SubspeciesField from './SubspeciesField.svelte';
|
||||||
|
import CitizenshipField from './CitizenshipField.svelte';
|
||||||
|
import LanguagesField from './LanguagesField.svelte';
|
||||||
|
|
||||||
|
let { field, value, onChange, data }: {
|
||||||
|
field: FieldDef;
|
||||||
|
value: any;
|
||||||
|
onChange: (v: any) => void;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if field.type === 'text'}
|
||||||
|
<TextField {field} {value} {onChange} />
|
||||||
|
{:else if field.type === 'textarea'}
|
||||||
|
<TextareaField {field} {value} {onChange} />
|
||||||
|
{:else if field.type === 'list'}
|
||||||
|
<ListField {field} {value} {onChange} />
|
||||||
|
{:else if field.type === 'number'}
|
||||||
|
<NumberField {field} {value} {onChange} />
|
||||||
|
{:else if field.type === 'select'}
|
||||||
|
<SelectField {field} {value} {onChange} />
|
||||||
|
{:else if field.type === 'multi-select'}
|
||||||
|
<CheckboxField field={{ ...field, type: 'checkbox' }} {value} {onChange} />
|
||||||
|
{:else if field.type === 'checkbox'}
|
||||||
|
<CheckboxField {field} {value} {onChange} />
|
||||||
|
{:else if field.type === 'date'}
|
||||||
|
<DateField {field} {value} {onChange} />
|
||||||
|
{:else if field.type === 'height'}
|
||||||
|
<HeightField {field} {value} {onChange} />
|
||||||
|
{:else if field.type === 'weight'}
|
||||||
|
<WeightField {field} {value} {onChange} />
|
||||||
|
{:else if field.type === 'species'}
|
||||||
|
<SpeciesField {field} {value} {onChange} />
|
||||||
|
{:else if field.type === 'subspecies'}
|
||||||
|
<SubspeciesField {field} {value} {onChange} {data} />
|
||||||
|
{:else if field.type === 'citizenship'}
|
||||||
|
<CitizenshipField {field} {value} {onChange} />
|
||||||
|
{:else if field.type === 'languages'}
|
||||||
|
<LanguagesField {field} {value} {onChange} />
|
||||||
|
{/if}
|
||||||
25
src/lib/components/fields/HeightField.svelte
Normal file
25
src/lib/components/fields/HeightField.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HeightField } from '$lib/types';
|
||||||
|
import { cmToFeetInches } from '$lib/utils/conversions';
|
||||||
|
|
||||||
|
let { field, value, onChange }: { field: HeightField; value: number | undefined; onChange: (v: number) => void } = $props();
|
||||||
|
|
||||||
|
let converted = $derived(value ? cmToFeetInches(value) : '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium">{field.label}</span>
|
||||||
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value ?? ''}
|
||||||
|
min={0}
|
||||||
|
oninput={(e) => onChange(Number((e.target as HTMLInputElement).value))}
|
||||||
|
class="block w-full rounded px-3 py-2 text-sm"
|
||||||
|
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
|
||||||
|
/>
|
||||||
|
<span class="text-sm whitespace-nowrap" style="color: var(--text-muted);">
|
||||||
|
cm {converted ? `(${converted})` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
34
src/lib/components/fields/LanguagesField.svelte
Normal file
34
src/lib/components/fields/LanguagesField.svelte
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { LanguagesField } from '$lib/types';
|
||||||
|
import { languages } from '$lib/data';
|
||||||
|
|
||||||
|
let { field, value = ['Tau Ceti Basic'], onChange }: {
|
||||||
|
field: LanguagesField;
|
||||||
|
value: string[];
|
||||||
|
onChange: (v: string[]) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function toggle(name: string) {
|
||||||
|
if (value.includes(name)) {
|
||||||
|
onChange(value.filter((v) => v !== name));
|
||||||
|
} else {
|
||||||
|
onChange([...value, name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend class="text-sm font-medium">{field.label}</legend>
|
||||||
|
<div class="mt-1 flex flex-col gap-1">
|
||||||
|
{#each languages as lang}
|
||||||
|
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value.includes(lang.name)}
|
||||||
|
onchange={() => toggle(lang.name)}
|
||||||
|
/>
|
||||||
|
{lang.name}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
17
src/lib/components/fields/ListField.svelte
Normal file
17
src/lib/components/fields/ListField.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ListField } from '$lib/types';
|
||||||
|
|
||||||
|
let { field, value = '', onChange }: { field: ListField; value: string; onChange: (v: string) => void } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium">{field.label}</span>
|
||||||
|
<textarea
|
||||||
|
{value}
|
||||||
|
placeholder="One entry per line"
|
||||||
|
oninput={(e) => onChange((e.target as HTMLTextAreaElement).value)}
|
||||||
|
rows={3}
|
||||||
|
class="mt-1 block w-full rounded px-3 py-2 text-sm resize-y"
|
||||||
|
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
23
src/lib/components/fields/NumberField.svelte
Normal file
23
src/lib/components/fields/NumberField.svelte
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { NumberField } from '$lib/types';
|
||||||
|
|
||||||
|
let { field, value, onChange }: { field: NumberField; value: number | undefined; onChange: (v: number) => void } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium">{field.label}</span>
|
||||||
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value ?? ''}
|
||||||
|
min={field.min}
|
||||||
|
max={field.max}
|
||||||
|
oninput={(e) => onChange(Number((e.target as HTMLInputElement).value))}
|
||||||
|
class="block w-full rounded px-3 py-2 text-sm"
|
||||||
|
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
|
||||||
|
/>
|
||||||
|
{#if field.unit}
|
||||||
|
<span class="text-sm" style="color: var(--text-muted);">{field.unit}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
20
src/lib/components/fields/SelectField.svelte
Normal file
20
src/lib/components/fields/SelectField.svelte
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { SelectField } from '$lib/types';
|
||||||
|
|
||||||
|
let { field, value = '', onChange }: { field: SelectField; value: string; onChange: (v: string) => void } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium">{field.label}</span>
|
||||||
|
<select
|
||||||
|
{value}
|
||||||
|
onchange={(e) => onChange((e.target as HTMLSelectElement).value)}
|
||||||
|
class="mt-1 block w-full rounded px-3 py-2 text-sm"
|
||||||
|
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{#each field.options as opt}
|
||||||
|
<option value={opt.value}>{opt.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
28
src/lib/components/fields/SpeciesField.svelte
Normal file
28
src/lib/components/fields/SpeciesField.svelte
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { SpeciesField } from '$lib/types';
|
||||||
|
import { species } from '$lib/data';
|
||||||
|
|
||||||
|
let { field, value = '', onChange }: { field: SpeciesField; value: string; onChange: (v: string) => void } = $props();
|
||||||
|
|
||||||
|
let selected = $derived(species.find((s) => s.id === value));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="block">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium">{field.label}</span>
|
||||||
|
<select
|
||||||
|
{value}
|
||||||
|
onchange={(e) => onChange((e.target as HTMLSelectElement).value)}
|
||||||
|
class="mt-1 block w-full rounded px-3 py-2 text-sm"
|
||||||
|
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{#each species as sp}
|
||||||
|
<option value={sp.id}>{sp.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{#if selected?.description}
|
||||||
|
<p class="mt-1 text-sm italic" style="color: var(--text-muted);">{selected.description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
39
src/lib/components/fields/SubspeciesField.svelte
Normal file
39
src/lib/components/fields/SubspeciesField.svelte
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { SubspeciesField } from '$lib/types';
|
||||||
|
import { species } from '$lib/data';
|
||||||
|
import { slugify } from '$lib/utils/slugify';
|
||||||
|
|
||||||
|
let { field, value = '', onChange, data }: {
|
||||||
|
field: SubspeciesField;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let currentSpecies = $derived(species.find((s) => s.id === data[slugify('Species')]));
|
||||||
|
let subs = $derived(currentSpecies?.subspecies ?? []);
|
||||||
|
let label = $derived(currentSpecies?.subspeciesLabel ?? field.label);
|
||||||
|
let selected = $derived(subs.find((s) => s.id === value));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if subs.length > 0}
|
||||||
|
<div class="block">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium">{label}</span>
|
||||||
|
<select
|
||||||
|
{value}
|
||||||
|
onchange={(e) => onChange((e.target as HTMLSelectElement).value)}
|
||||||
|
class="mt-1 block w-full rounded px-3 py-2 text-sm"
|
||||||
|
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{#each subs as sub}
|
||||||
|
<option value={sub.id}>{sub.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{#if selected?.description}
|
||||||
|
<p class="mt-1 text-sm italic" style="color: var(--text-muted);">{selected.description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
17
src/lib/components/fields/TextField.svelte
Normal file
17
src/lib/components/fields/TextField.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { TextField } from '$lib/types';
|
||||||
|
|
||||||
|
let { field, value = '', onChange }: { field: TextField; value: string; onChange: (v: string) => void } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium">{field.label}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{value}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
oninput={(e) => onChange((e.target as HTMLInputElement).value)}
|
||||||
|
class="mt-1 block w-full rounded px-3 py-2 text-sm"
|
||||||
|
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
17
src/lib/components/fields/TextareaField.svelte
Normal file
17
src/lib/components/fields/TextareaField.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { TextareaField } from '$lib/types';
|
||||||
|
|
||||||
|
let { field, value = '', onChange }: { field: TextareaField; value: string; onChange: (v: string) => void } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium">{field.label}</span>
|
||||||
|
<textarea
|
||||||
|
{value}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
oninput={(e) => onChange((e.target as HTMLTextAreaElement).value)}
|
||||||
|
rows={3}
|
||||||
|
class="mt-1 block w-full rounded px-3 py-2 text-sm resize-y"
|
||||||
|
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
25
src/lib/components/fields/WeightField.svelte
Normal file
25
src/lib/components/fields/WeightField.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { WeightField } from '$lib/types';
|
||||||
|
import { kgToLb } from '$lib/utils/conversions';
|
||||||
|
|
||||||
|
let { field, value, onChange }: { field: WeightField; value: number | undefined; onChange: (v: number) => void } = $props();
|
||||||
|
|
||||||
|
let converted = $derived(value ? Math.round(kgToLb(value)) : '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium">{field.label}</span>
|
||||||
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value ?? ''}
|
||||||
|
min={0}
|
||||||
|
oninput={(e) => onChange(Number((e.target as HTMLInputElement).value))}
|
||||||
|
class="block w-full rounded px-3 py-2 text-sm"
|
||||||
|
style="background: var(--bg-input); border: 1px solid var(--border); color: var(--text);"
|
||||||
|
/>
|
||||||
|
<span class="text-sm whitespace-nowrap" style="color: var(--text-muted);">
|
||||||
|
kg {converted ? `(${converted} lb)` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
25
src/lib/theme.svelte.ts
Normal file
25
src/lib/theme.svelte.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
let dark = $state(false);
|
||||||
|
|
||||||
|
export const theme = {
|
||||||
|
get dark() { return dark; },
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const stored = localStorage.getItem('theme');
|
||||||
|
if (stored) {
|
||||||
|
dark = stored === 'dark';
|
||||||
|
} else {
|
||||||
|
dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
}
|
||||||
|
apply();
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
dark = !dark;
|
||||||
|
localStorage.setItem('theme', dark ? 'dark' : 'light');
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
document.documentElement.classList.toggle('dark', dark);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,19 @@
|
||||||
<script>
|
<script>
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { theme } from '$lib/theme.svelte';
|
||||||
|
import { roster } from '$lib/state.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
let ready = $state(false);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
theme.init();
|
||||||
|
await roster.load();
|
||||||
|
ready = true;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{@render children()}
|
{#if ready}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,50 @@
|
||||||
<h1 class="text-2xl font-bold p-8">Aurora Character Records Generator</h1>
|
<script lang="ts">
|
||||||
|
import Header from '$lib/components/Header.svelte';
|
||||||
|
import DynamicField from '$lib/components/fields/DynamicField.svelte';
|
||||||
|
import { roster } from '$lib/state.svelte';
|
||||||
|
import { presets } from '$lib/presets';
|
||||||
|
import { slugify } from '$lib/utils/slugify';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen flex flex-col">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main class="flex-1 p-4">
|
||||||
|
{#if roster.active}
|
||||||
|
{@const char = roster.active}
|
||||||
|
<div class="max-w-xl mx-auto flex flex-col gap-6">
|
||||||
|
{#each char.template.records as record}
|
||||||
|
<section>
|
||||||
|
<h2 class="text-sm font-semibold uppercase tracking-wide mb-3" style="color: var(--text-muted);">
|
||||||
|
{record.type}
|
||||||
|
</h2>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
{#each record.fields as field}
|
||||||
|
<DynamicField
|
||||||
|
{field}
|
||||||
|
value={char.data[slugify(field.label)]}
|
||||||
|
data={char.data}
|
||||||
|
onChange={(v) => {
|
||||||
|
char.data[slugify(field.label)] = v;
|
||||||
|
roster.scheduleSave(char);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col items-center justify-center gap-4 h-full min-h-[60vh]">
|
||||||
|
<p style="color: var(--text-muted);">No characters yet.</p>
|
||||||
|
<button
|
||||||
|
onclick={() => roster.create(presets[0])}
|
||||||
|
class="px-3 py-1 rounded text-sm border hover:opacity-80"
|
||||||
|
style="border-color: var(--border);"
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue