revert(migrate): removes migrate modal in favour of the button we put in
This commit is contained in:
parent
a4f8c651a4
commit
8525561522
4 changed files with 111 additions and 150 deletions
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
|
|
||||||
|
|
@ -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}
|
|
||||||
|
|
@ -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 {
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue