revert(migrate): removes migrate modal in favour of the button we put in

This commit is contained in:
Lewis Wynne 2026-03-23 21:02:22 +00:00
parent a4f8c651a4
commit 8525561522
4 changed files with 111 additions and 150 deletions

View file

@ -1,25 +1,35 @@
<script lang="ts"> <script lang="ts">
import { ChevronDown } from 'lucide-svelte';
import type { Character, Template } 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 { presets } from '$lib/presets';
import { slugify } from '$lib/utils/slugify'; import { slugify } from '$lib/utils/slugify';
import { diffTemplates, hasChanges } from '$lib/utils/template-diff';
import RecordCard from './RecordCard.svelte'; import RecordCard from './RecordCard.svelte';
import Modal from './Modal.svelte';
let { character }: { character: Character } = $props(); let { character }: { character: Character } = $props();
let dismissed = $state<string | null>(null); let dismissed = $state<string | null>(null);
let showTemplateSwitcher = $state(false);
let showMigrationModal = $state(false);
let speciesKey = slugify('Species'); 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 => { let suggestion = $derived.by((): { template: Template; reason: string } | null => {
const currentSpecies = character.data[speciesKey] as string | undefined; const currentSpecies = character.data[speciesKey] as string | undefined;
if (!currentSpecies) return null; if (!currentSpecies) return null;
const current = character.template; const current = character.template;
// Current template is species-specific but doesn't match selected species
if (current.species?.length && !current.species.includes(currentSpecies)) { 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) => const specific = presets.find((p) =>
p.species?.includes(currentSpecies) && p.id !== current.id p.species?.includes(currentSpecies) && p.id !== current.id
); );
@ -34,7 +44,6 @@
return null; return null;
} }
// Current template is general, but a species-specific one exists
if (!current.species) { if (!current.species) {
const specific = presets.find((p) => const specific = presets.find((p) =>
p.species?.includes(currentSpecies) && p.id !== current.id p.species?.includes(currentSpecies) && p.id !== current.id
@ -54,10 +63,55 @@
character.template = $state.snapshot(template); character.template = $state.snapshot(template);
roster.scheduleSave(character); roster.scheduleSave(character);
dismissed = null; dismissed = null;
showTemplateSwitcher = false;
}
async function applyMigration() {
if (!pendingMigration) return;
await roster.migrateToPreset(character, pendingMigration.preset);
} }
</script> </script>
<div class="flex flex-col gap-4"> <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} {#if suggestion}
<div class="rounded border px-4 py-3" style="border-color: var(--accent); background: var(--bg-card);"> <div class="rounded border px-4 py-3" style="border-color: var(--accent); background: var(--bg-card);">
<p class="text-sm mb-2"> <p class="text-sm mb-2">
@ -94,3 +148,50 @@
/> />
{/each} {/each}
</div> </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;
}
}} />

View file

@ -1,58 +0,0 @@
<script lang="ts">
import { roster } from '$lib/state.svelte';
import Modal from './Modal.svelte';
let upgrades = $derived(roster.pendingUpgrades);
function dismiss() {
roster.clearUpgrades();
}
</script>
{#if upgrades.length > 0}
<Modal onClose={dismiss}>
<h2 class="font-semibold mb-3">Templates Updated</h2>
<div class="flex flex-col gap-4">
{#each upgrades as upgrade}
<div class="rounded border px-3 py-2" style="border-color: var(--border);">
<p class="text-sm font-medium">{upgrade.characterName}</p>
<p class="text-xs mb-2" style="color: var(--text-muted);">{upgrade.templateName} template</p>
<ul class="text-xs flex flex-col gap-0.5 mb-2" style="color: var(--text-muted);">
{#each upgrade.diff.renamedFields as r}
<li>{r.from}{r.to}</li>
{/each}
{#each upgrade.diff.addedRecords as r}
<li>+ New record: {r}</li>
{/each}
{#each upgrade.diff.removedRecords as r}
<li>- Removed record: {r}</li>
{/each}
{#each upgrade.diff.addedFields as f}
<li>+ New field: {f}</li>
{/each}
{#each upgrade.diff.removedFields as f}
<li>- Removed field: {f}</li>
{/each}
</ul>
<div class="flex gap-2">
<button
onclick={() => roster.applyUpgrade(upgrade.characterId)}
class="px-2 py-1 rounded text-xs border hover:opacity-80"
style="border-color: var(--accent); color: var(--accent);"
>
Update
</button>
<button
onclick={() => roster.skipUpgrade(upgrade.characterId)}
class="px-2 py-1 rounded text-xs hover:opacity-80"
style="color: var(--text-muted);"
>
Skip
</button>
</div>
</div>
{/each}
</div>
<p class="text-xs mt-3" style="color: var(--text-muted);">Your existing data will be preserved. Skipped updates will be offered again next time.</p>
</Modal>
{/if}

View file

@ -1,38 +1,14 @@
import { getAllCharacters, saveCharacter, deleteCharacter } from './storage'; import { getAllCharacters, saveCharacter, deleteCharacter } from './storage';
import { isBlankCharacter } from './utils/blank'; import { isBlankCharacter } from './utils/blank';
import { presets } from './presets';
import { diffTemplates, hasChanges, type TemplateDiff } from './utils/template-diff';
import { slugify } from './utils/slugify'; import { slugify } from './utils/slugify';
import type { Character, Template } from './types'; import type { Character, Template } from './types';
export interface PendingUpgrade {
characterId: string;
characterName: string;
templateName: string;
preset: Template;
diff: TemplateDiff;
}
let characters = $state<Character[]>([]); let characters = $state<Character[]>([]);
let activeId = $state<string | null>(null); let activeId = $state<string | null>(null);
let saveStatus = $state<'idle' | 'saving' | 'saved'>('idle'); let saveStatus = $state<'idle' | 'saving' | 'saved'>('idle');
let pendingUpgrades = $state<PendingUpgrade[]>([]);
let saveTimer: ReturnType<typeof setTimeout> | null = null; let saveTimer: ReturnType<typeof setTimeout> | null = null;
let statusTimer: ReturnType<typeof setTimeout> | null = null; let statusTimer: ReturnType<typeof setTimeout> | null = null;
function getSkippedUpgrades(): Record<string, string> {
try {
return JSON.parse(localStorage.getItem('skippedUpgrades') || '{}');
} catch {
return {};
}
}
function upgradeFingerprint(preset: Template): string {
const fields = preset.records.flatMap((r) => r.fields.map((f) => f.label)).sort();
return `${preset.id}:${fields.join(',')}`;
}
function migrateData(char: Character, preset: Template) { function migrateData(char: Character, preset: Template) {
for (const record of preset.records) { for (const record of preset.records) {
for (const field of record.fields) { for (const field of record.fields) {
@ -52,84 +28,30 @@ function migrateData(char: Character, preset: Template) {
} }
} }
function charDisplayName(char: Character): string {
return (char.data['name'] as string)
|| (char.data['designation'] as string)
|| 'Unnamed Character';
}
export const roster = { export const roster = {
get characters() { return characters; }, get characters() { return characters; },
get active() { return characters.find((c) => c.id === activeId) ?? null; }, get active() { return characters.find((c) => c.id === activeId) ?? null; },
get saveStatus() { return saveStatus; }, get saveStatus() { return saveStatus; },
get pendingUpgrades() { return pendingUpgrades; },
clearUpgrades() { async migrateToPreset(char: Character, preset: Template) {
pendingUpgrades = []; migrateData(char, preset);
}, char.template = $state.snapshot(preset);
async applyUpgrade(characterId: string) {
const upgrade = pendingUpgrades.find((u) => u.characterId === characterId);
const char = characters.find((c) => c.id === characterId);
if (!upgrade || !char) return;
migrateData(char, upgrade.preset);
char.template = upgrade.preset;
await saveCharacter($state.snapshot(char)); await saveCharacter($state.snapshot(char));
const skipped = getSkippedUpgrades();
delete skipped[characterId];
localStorage.setItem('skippedUpgrades', JSON.stringify(skipped));
pendingUpgrades = pendingUpgrades.filter((u) => u.characterId !== characterId);
},
skipUpgrade(characterId: string) {
const upgrade = pendingUpgrades.find((u) => u.characterId === characterId);
if (upgrade) {
const skipped = getSkippedUpgrades();
skipped[characterId] = upgradeFingerprint(upgrade.preset);
localStorage.setItem('skippedUpgrades', JSON.stringify(skipped));
}
pendingUpgrades = pendingUpgrades.filter((u) => u.characterId !== characterId);
}, },
async load() { async load() {
const all = await getAllCharacters(); const all = await getAllCharacters();
const kept: Character[] = []; const kept: Character[] = [];
const upgrades: PendingUpgrade[] = [];
const skipped = getSkippedUpgrades();
for (const char of all) { for (const char of all) {
if (isBlankCharacter(char)) { if (isBlankCharacter(char)) {
await deleteCharacter(char.id); await deleteCharacter(char.id);
continue; } else {
kept.push(char);
} }
if (char.template.id.startsWith('preset:')) {
const preset = presets.find((p) => p.id === char.template.id);
if (preset) {
const diff = diffTemplates(char.template, preset);
if (hasChanges(diff)) {
const fp = upgradeFingerprint(preset);
if (skipped[char.id] === fp) {
kept.push(char);
continue;
}
upgrades.push({
characterId: char.id,
characterName: charDisplayName(char),
templateName: preset.name,
preset,
diff
});
}
}
}
kept.push(char);
} }
characters = kept; characters = kept;
pendingUpgrades = upgrades;
const stored = localStorage.getItem('activeCharacterId'); const stored = localStorage.getItem('activeCharacterId');
if (stored && characters.some((c) => c.id === stored)) { if (stored && characters.some((c) => c.id === stored)) {
@ -155,7 +77,6 @@ export const roster = {
async remove(id: string) { async remove(id: string) {
characters = characters.filter((c) => c.id !== id); characters = characters.filter((c) => c.id !== id);
await deleteCharacter(id); await deleteCharacter(id);
pendingUpgrades = pendingUpgrades.filter((u) => u.characterId !== id);
if (activeId === id) { if (activeId === id) {
activeId = characters[0]?.id ?? null; activeId = characters[0]?.id ?? null;
if (activeId) localStorage.setItem('activeCharacterId', activeId); if (activeId) localStorage.setItem('activeCharacterId', activeId);

View file

@ -7,7 +7,6 @@
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'; import TemplatePicker from '$lib/components/TemplatePicker.svelte';
import UpgradeModal from '$lib/components/UpgradeModal.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');
@ -104,5 +103,3 @@
{/if} {/if}
{/if} {/if}
</div> </div>
<UpgradeModal />