212 lines
6.6 KiB
Svelte
212 lines
6.6 KiB
Svelte
<script lang="ts">
|
|
import { ChevronDown } from 'lucide-svelte';
|
|
import type { Character, Template } from '$lib/types';
|
|
import { roster } from '$lib/state.svelte';
|
|
import { presets } from '$lib/presets';
|
|
import { slugify } from '$lib/utils/slugify';
|
|
import { diffTemplates, hasChanges } from '$lib/utils/template-diff';
|
|
import RecordCard from './RecordCard.svelte';
|
|
import Modal from './Modal.svelte';
|
|
|
|
let { character }: { character: Character } = $props();
|
|
|
|
let dismissed = $state<string | null>(null);
|
|
let showTemplateSwitcher = $state(false);
|
|
let showMigrationModal = $state(false);
|
|
let speciesKeys = $derived(new Set(
|
|
character.template.records.flatMap((r) => r.fields)
|
|
.filter((f) => f.type === 'species')
|
|
.map((f) => slugify(f.label))
|
|
));
|
|
const SPECIES_DEPENDENT_TYPES = new Set(['subspecies', 'citizenship', 'languages']);
|
|
let speciesDependentKeys = $derived(new Set(
|
|
character.template.records.flatMap((r) => r.fields)
|
|
.filter((f) => SPECIES_DEPENDENT_TYPES.has(f.type))
|
|
.map((f) => slugify(f.label))
|
|
));
|
|
let speciesKey = slugify('Species');
|
|
|
|
let pendingMigration = $derived.by(() => {
|
|
if (!character.template.id.startsWith('preset:')) return null;
|
|
const preset = presets.find((p) => p.id === character.template.id);
|
|
if (!preset) return null;
|
|
const diff = diffTemplates(character.template, preset);
|
|
if (!hasChanges(diff)) return null;
|
|
return { preset, diff };
|
|
});
|
|
|
|
let suggestion = $derived.by((): { template: Template; reason: string } | null => {
|
|
const currentSpecies = character.data[speciesKey] as string | undefined;
|
|
if (!currentSpecies) return null;
|
|
const current = character.template;
|
|
if (current.species?.length && !current.species.includes(currentSpecies)) {
|
|
const specific = presets.find((p) =>
|
|
p.species?.includes(currentSpecies) && p.id !== current.id
|
|
);
|
|
const general = presets.find((p) => !p.species && p.id !== current.id);
|
|
const better = specific ?? general;
|
|
if (better && better.id !== dismissed) {
|
|
return {
|
|
template: better,
|
|
reason: `The ${current.name} template isn't designed for this species.`
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (!current.species) {
|
|
const specific = presets.find((p) =>
|
|
p.species?.includes(currentSpecies) && p.id !== current.id
|
|
);
|
|
if (specific && specific.id !== dismissed) {
|
|
return {
|
|
template: specific,
|
|
reason: `A ${specific.name} template is available for this species.`
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
});
|
|
|
|
async function switchTemplate(template: Template) {
|
|
await roster.migrateToPreset(character, template);
|
|
dismissed = null;
|
|
showTemplateSwitcher = false;
|
|
}
|
|
|
|
async function applyMigration() {
|
|
if (!pendingMigration) return;
|
|
await roster.migrateToPreset(character, pendingMigration.preset);
|
|
}
|
|
</script>
|
|
|
|
<div class="flex flex-col gap-4">
|
|
<!-- Template bar -->
|
|
<div class="flex items-center gap-2 text-xs">
|
|
<span class="relative">
|
|
<button
|
|
onclick={(e) => { e.stopPropagation(); showTemplateSwitcher = !showTemplateSwitcher; }}
|
|
class="hover:underline"
|
|
style="color: var(--text-muted);"
|
|
>
|
|
{character.template.name} template
|
|
</button>
|
|
{#if showTemplateSwitcher}
|
|
<nav class="absolute z-10 mt-1 left-0 w-56 rounded border shadow-lg" style="background: var(--bg-card); border-color: var(--border);">
|
|
{#each presets as preset}
|
|
<button
|
|
onclick={(e) => { e.stopPropagation(); switchTemplate(preset); }}
|
|
class="block w-full text-left px-3 py-2 text-sm hover:opacity-80"
|
|
style={preset.id === character.template.id ? 'color: var(--accent);' : 'color: var(--text);'}
|
|
>
|
|
<span class="font-medium">{preset.name}</span>
|
|
{#if preset.description}
|
|
<span class="block text-xs" style="color: var(--text-muted);">{preset.description}</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</nav>
|
|
{/if}
|
|
</span>
|
|
{#if pendingMigration}
|
|
<button
|
|
onclick={() => { showMigrationModal = true; }}
|
|
class="hover:underline"
|
|
style="color: var(--accent);"
|
|
>
|
|
update available
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Species suggestion -->
|
|
{#if suggestion}
|
|
<div class="rounded border px-4 py-3" style="border-color: var(--accent); background: var(--bg-card);">
|
|
<p class="text-sm mb-2">
|
|
{suggestion.reason}
|
|
Switching will keep your existing data.
|
|
</p>
|
|
<div class="flex gap-2">
|
|
<button
|
|
onclick={() => switchTemplate(suggestion!.template)}
|
|
class="px-3 py-1 rounded text-sm border hover:opacity-80"
|
|
style="border-color: var(--accent); color: var(--accent);"
|
|
>
|
|
Switch to {suggestion.template.name}
|
|
</button>
|
|
<button
|
|
onclick={() => { dismissed = suggestion!.template.id; }}
|
|
class="px-3 py-1 rounded text-sm hover:opacity-80"
|
|
style="color: var(--text-muted);"
|
|
>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#each character.template.records as record}
|
|
<RecordCard
|
|
{record}
|
|
data={character.data}
|
|
onFieldChange={(key, value) => {
|
|
character.data[key] = value;
|
|
if (speciesKeys.has(key)) {
|
|
for (const depKey of speciesDependentKeys) {
|
|
character.data[depKey] = '';
|
|
}
|
|
}
|
|
roster.scheduleSave(character);
|
|
}}
|
|
onSave={() => roster.scheduleSave(character)}
|
|
/>
|
|
{/each}
|
|
</div>
|
|
|
|
{#if showMigrationModal && pendingMigration}
|
|
<Modal onClose={() => { showMigrationModal = false; }}>
|
|
<h2 class="font-semibold mb-3">Template Update</h2>
|
|
<p class="text-sm mb-2">The <strong>{pendingMigration.preset.name}</strong> template has been updated:</p>
|
|
<ul class="text-sm flex flex-col gap-0.5 mb-3" style="color: var(--text-muted);">
|
|
{#each pendingMigration.diff.renamedFields as r}
|
|
<li>{r.from} → {r.to}</li>
|
|
{/each}
|
|
{#each pendingMigration.diff.addedRecords as r}
|
|
<li>+ New record: {r}</li>
|
|
{/each}
|
|
{#each pendingMigration.diff.removedRecords as r}
|
|
<li>- Removed record: {r}</li>
|
|
{/each}
|
|
{#each pendingMigration.diff.addedFields as f}
|
|
<li>+ New field: {f}</li>
|
|
{/each}
|
|
{#each pendingMigration.diff.removedFields as f}
|
|
<li>- Removed field: {f}</li>
|
|
{/each}
|
|
</ul>
|
|
<p class="text-xs mb-3" style="color: var(--text-muted);">Your existing data will be preserved.</p>
|
|
<div class="flex gap-2 justify-end">
|
|
<button
|
|
onclick={() => { showMigrationModal = false; }}
|
|
class="px-3 py-1 rounded text-sm hover:opacity-80"
|
|
style="color: var(--text-muted);"
|
|
>
|
|
Skip
|
|
</button>
|
|
<button
|
|
onclick={async () => { await applyMigration(); showMigrationModal = false; }}
|
|
class="px-3 py-1 rounded text-sm border hover:opacity-80"
|
|
style="border-color: var(--accent); color: var(--accent);"
|
|
>
|
|
Update
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
{/if}
|
|
|
|
<svelte:window onclick={() => {
|
|
if (showTemplateSwitcher) {
|
|
showTemplateSwitcher = false;
|
|
}
|
|
}} />
|