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
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue