feat(templates): templates attached to species (e.g. ipc template), with prompts when switching to a matching species
This commit is contained in:
parent
5488352514
commit
abe0755abc
6 changed files with 132 additions and 4 deletions
|
|
@ -6,13 +6,19 @@
|
||||||
import { encodeCharacterURL } from '$lib/sharing';
|
import { encodeCharacterURL } from '$lib/sharing';
|
||||||
import { slugify } from '$lib/utils/slugify';
|
import { slugify } from '$lib/utils/slugify';
|
||||||
import CharacterSwitcher from './CharacterSwitcher.svelte';
|
import CharacterSwitcher from './CharacterSwitcher.svelte';
|
||||||
|
import TemplatePicker from './TemplatePicker.svelte';
|
||||||
import Modal from './Modal.svelte';
|
import Modal from './Modal.svelte';
|
||||||
|
|
||||||
let shared = $state(false);
|
let shared = $state(false);
|
||||||
let confirmDelete = $state(false);
|
let confirmDelete = $state(false);
|
||||||
|
let showPicker = $state(false);
|
||||||
|
|
||||||
async function createCharacter() {
|
function createCharacter() {
|
||||||
await roster.create(presets[0]);
|
if (presets.length === 1) {
|
||||||
|
roster.create(presets[0]);
|
||||||
|
} else {
|
||||||
|
showPicker = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function share() {
|
async function share() {
|
||||||
|
|
@ -82,6 +88,10 @@
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{#if showPicker}
|
||||||
|
<TemplatePicker onClose={() => { showPicker = false; }} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if confirmDelete && roster.active}
|
{#if confirmDelete && roster.active}
|
||||||
<Modal onClose={() => { confirmDelete = false; }}>
|
<Modal onClose={() => { confirmDelete = false; }}>
|
||||||
<h2 class="font-semibold mb-2">Delete Character</h2>
|
<h2 class="font-semibold mb-2">Delete Character</h2>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,88 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Character } from '$lib/types';
|
import type { Character, Template } from '$lib/types';
|
||||||
import { roster } from '$lib/state.svelte';
|
import { roster } from '$lib/state.svelte';
|
||||||
|
import { presets } from '$lib/presets';
|
||||||
|
import { slugify } from '$lib/utils/slugify';
|
||||||
import RecordCard from './RecordCard.svelte';
|
import RecordCard from './RecordCard.svelte';
|
||||||
|
|
||||||
let { character }: { character: Character } = $props();
|
let { character }: { character: Character } = $props();
|
||||||
|
|
||||||
|
let dismissed = $state<string | null>(null);
|
||||||
|
|
||||||
|
let speciesKey = slugify('Species');
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Current template is species-specific but doesn't match selected species
|
||||||
|
if (current.species?.length && !current.species.includes(currentSpecies)) {
|
||||||
|
// Find a template that matches, or fall back to a general one
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current template is general, but a species-specific one exists
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
function switchTemplate(template: Template) {
|
||||||
|
character.template = $state.snapshot(template);
|
||||||
|
roster.scheduleSave(character);
|
||||||
|
dismissed = null;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
|
{#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}
|
{#each character.template.records as record}
|
||||||
<RecordCard
|
<RecordCard
|
||||||
{record}
|
{record}
|
||||||
|
|
|
||||||
31
src/lib/components/TemplatePicker.svelte
Normal file
31
src/lib/components/TemplatePicker.svelte
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Template } from '$lib/types';
|
||||||
|
import { presets } from '$lib/presets';
|
||||||
|
import { roster } from '$lib/state.svelte';
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
|
||||||
|
let { onClose }: { onClose: () => void } = $props();
|
||||||
|
|
||||||
|
async function pick(template: Template) {
|
||||||
|
await roster.create(template);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal {onClose}>
|
||||||
|
<h2 class="font-semibold mb-3">New Character</h2>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#each presets as preset}
|
||||||
|
<button
|
||||||
|
onclick={() => pick(preset)}
|
||||||
|
class="text-left px-3 py-2 rounded border hover:opacity-80"
|
||||||
|
style="border-color: var(--border);"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-sm">{preset.name}</span>
|
||||||
|
{#if preset.description}
|
||||||
|
<span class="block text-xs" style="color: var(--text-muted);">{preset.description}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
@ -107,11 +107,13 @@ export function parseTemplate(xml: string, id: string): Template {
|
||||||
fields: r.field.map(parseField)
|
fields: r.field.map(parseField)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const speciesAttr = root['@_species'];
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: root['@_name'],
|
name: root['@_name'],
|
||||||
description: root.description ?? '',
|
description: root.description ?? '',
|
||||||
schemaVersion: Number(root['@_schemaVersion'] ?? 1),
|
schemaVersion: Number(root['@_schemaVersion'] ?? 1),
|
||||||
|
...(speciesAttr && { species: speciesAttr.split(',').map((s: string) => s.trim()) }),
|
||||||
records
|
records
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ export interface Template {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
schemaVersion: number;
|
schemaVersion: number;
|
||||||
|
species?: string[];
|
||||||
records: RecordDef[];
|
records: RecordDef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,11 @@
|
||||||
import ImportModal from '$lib/components/ImportModal.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';
|
||||||
|
import TemplatePicker from '$lib/components/TemplatePicker.svelte';
|
||||||
|
|
||||||
let importData = $state<string | null>(null);
|
let importData = $state<string | null>(null);
|
||||||
let mobileView = $state<'edit' | 'preview' | 'split'>('split');
|
let mobileView = $state<'edit' | 'preview' | 'split'>('split');
|
||||||
|
let showPicker = $state(false);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const hash = window.location.hash.slice(1);
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
@ -86,12 +88,18 @@
|
||||||
<main class="flex-1 flex flex-col items-center justify-center gap-4">
|
<main class="flex-1 flex flex-col items-center justify-center gap-4">
|
||||||
<p style="color: var(--text-muted);">No characters yet.</p>
|
<p style="color: var(--text-muted);">No characters yet.</p>
|
||||||
<button
|
<button
|
||||||
onclick={() => roster.create(presets[0])}
|
onclick={() => {
|
||||||
|
if (presets.length === 1) roster.create(presets[0]);
|
||||||
|
else showPicker = true;
|
||||||
|
}}
|
||||||
class="px-3 py-1 rounded text-sm border hover:opacity-80"
|
class="px-3 py-1 rounded text-sm border hover:opacity-80"
|
||||||
style="border-color: var(--border);"
|
style="border-color: var(--border);"
|
||||||
>
|
>
|
||||||
Get Started
|
Get Started
|
||||||
</button>
|
</button>
|
||||||
</main>
|
</main>
|
||||||
|
{#if showPicker}
|
||||||
|
<TemplatePicker onClose={() => { showPicker = false; }} />
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue