add link buttons to import form fields

- add URL builders for wiki/gamewith/kamigame
- add hasLinkButton prop to DetailItem components
- show disabled link button when value is empty
- store page names instead of full URLs
- fix DetailItem to render children when editable
- remove focus background styling
This commit is contained in:
Justin Edmund 2025-12-14 19:35:33 -08:00
parent e1ba34048a
commit 64725bd4e8
6 changed files with 462 additions and 175 deletions

View file

@ -7,6 +7,7 @@
import Checkbox from './checkbox/Checkbox.svelte' import Checkbox from './checkbox/Checkbox.svelte'
import CheckboxGroup from './checkbox/CheckboxGroup.svelte' import CheckboxGroup from './checkbox/CheckboxGroup.svelte'
import DatePicker from './DatePicker.svelte' import DatePicker from './DatePicker.svelte'
import Icon from '../Icon.svelte'
interface SelectOption { interface SelectOption {
value: string | number value: string | number
@ -25,7 +26,9 @@
placeholder, placeholder,
element, element,
onchange, onchange,
width width,
linkUrl,
hasLinkButton = false
}: { }: {
label: string label: string
/** Secondary label displayed below the main label */ /** Secondary label displayed below the main label */
@ -41,17 +44,32 @@
onchange?: (checked: boolean) => void onchange?: (checked: boolean) => void
/** Custom width for the input field (e.g., '320px') */ /** Custom width for the input field (e.g., '320px') */
width?: string width?: string
/** URL to open when link button is clicked */
linkUrl?: string | null
/** Whether to show the link button (disabled when linkUrl is empty) */
hasLinkButton?: boolean
} = $props() } = $props()
// For checkbox type, derive the checked state from value // For checkbox type, derive the checked state from value
// This ensures external changes to value are reflected in the checkbox // This ensures external changes to value are reflected in the checkbox
const checkboxValue = $derived(type === 'checkbox' ? Boolean(value) : false) const checkboxValue = $derived(type === 'checkbox' ? Boolean(value) : false)
// Show link button when hasLinkButton is true or linkUrl is provided
const showLinkButton = $derived(hasLinkButton || !!linkUrl)
const linkDisabled = $derived(!linkUrl)
// Handle checkbox change and call onchange if provided // Handle checkbox change and call onchange if provided
function handleCheckboxChange(checked: boolean) { function handleCheckboxChange(checked: boolean) {
value = checked as any value = checked as any
onchange?.(checked) onchange?.(checked)
} }
// Open URL in new tab
function openLink() {
if (linkUrl) {
window.open(linkUrl, '_blank', 'noopener,noreferrer')
}
}
</script> </script>
<div class="detail-item" class:editable class:hasChildren={!!children}> <div class="detail-item" class:editable class:hasChildren={!!children}>
@ -95,8 +113,21 @@
/> />
{:else if type === 'date'} {:else if type === 'date'}
<DatePicker bind:value={value as string | null} contained={true} {placeholder} /> <DatePicker bind:value={value as string | null} contained={true} {placeholder} />
{:else if children}
{@render children()}
{:else} {:else}
<Input bind:value type="text" contained={true} {placeholder} alignRight={false} /> <Input bind:value type="text" contained={true} {placeholder} alignRight={false} />
{#if showLinkButton}
<button
type="button"
class="link-button"
onclick={openLink}
disabled={linkDisabled}
title={linkDisabled ? 'No link available' : 'Open link'}
>
<Icon name="link" size={16} />
</button>
{/if}
{/if} {/if}
</div> </div>
{:else if children} {:else if children}
@ -133,11 +164,6 @@
background: colors.$grey-90; background: colors.$grey-90;
} }
&.editable:focus-within,
&.hasChildren:focus-within {
background: var(--input-bg-hover);
}
&.editable, &.editable,
&.hasChildren { &.hasChildren {
background: var(--input-bg); background: var(--input-bg);
@ -173,15 +199,48 @@
display: flex; display: flex;
flex-grow: 0; flex-grow: 0;
justify-content: flex-end; justify-content: flex-end;
align-items: center;
gap: spacing.$unit-half;
:global(.input), :global(.input),
:global(.select) { :global(.select),
:global(.multi-select) {
width: var(--custom-width, 240px); width: var(--custom-width, 240px);
} }
:global(.input.number) { :global(.input.number) {
width: var(--custom-width, 120px); width: var(--custom-width, 120px);
} }
.link-button {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
padding: 0;
border: none;
border-radius: layout.$item-corner;
background: transparent;
color: colors.$grey-50;
cursor: pointer;
flex-shrink: 0;
@include effects.smooth-transition(effects.$duration-quick, background-color, color, opacity);
&:hover:not(:disabled) {
background: colors.$grey-90;
color: colors.$grey-30;
}
&:active:not(:disabled) {
background: colors.$grey-80;
}
&:disabled {
cursor: not-allowed;
opacity: 0.4;
}
}
} }
} }
</style> </style>

View file

@ -7,6 +7,7 @@
import Checkbox from './checkbox/Checkbox.svelte' import Checkbox from './checkbox/Checkbox.svelte'
import DatePicker from './DatePicker.svelte' import DatePicker from './DatePicker.svelte'
import SuggestionBadge from './SuggestionBadge.svelte' import SuggestionBadge from './SuggestionBadge.svelte'
import Icon from '../Icon.svelte'
interface SelectOption { interface SelectOption {
value: string | number value: string | number
@ -26,6 +27,8 @@
element, element,
onchange, onchange,
width, width,
linkUrl,
hasLinkButton = false,
// Suggestion props // Suggestion props
suggestion, suggestion,
suggestionLabel, suggestionLabel,
@ -47,6 +50,10 @@
onchange?: (checked: boolean) => void onchange?: (checked: boolean) => void
/** Custom width for the input field (e.g., '320px') */ /** Custom width for the input field (e.g., '320px') */
width?: string width?: string
/** URL to open when link button is clicked */
linkUrl?: string | null
/** Whether to show the link button (disabled when linkUrl is empty) */
hasLinkButton?: boolean
// Suggestion props // Suggestion props
/** The suggested value from wiki */ /** The suggested value from wiki */
suggestion?: string | number | boolean | null | undefined suggestion?: string | number | boolean | null | undefined
@ -63,12 +70,23 @@
// For checkbox type, derive the checked state from value // For checkbox type, derive the checked state from value
const checkboxValue = $derived(type === 'checkbox' ? Boolean(value) : false) const checkboxValue = $derived(type === 'checkbox' ? Boolean(value) : false)
// Show link button when hasLinkButton is true or linkUrl is provided
const showLinkButton = $derived(hasLinkButton || !!linkUrl)
const linkDisabled = $derived(!linkUrl)
// Handle checkbox change and call onchange if provided // Handle checkbox change and call onchange if provided
function handleCheckboxChange(checked: boolean) { function handleCheckboxChange(checked: boolean) {
value = checked as any value = checked as any
onchange?.(checked) onchange?.(checked)
} }
// Open URL in new tab
function openLink() {
if (linkUrl) {
window.open(linkUrl, '_blank', 'noopener,noreferrer')
}
}
// Show suggestion badge when: // Show suggestion badge when:
// 1. We have a suggestion // 1. We have a suggestion
// 2. The suggestion hasn't been dismissed // 2. The suggestion hasn't been dismissed
@ -138,6 +156,17 @@
<DatePicker bind:value={value as string | null} contained={true} {placeholder} /> <DatePicker bind:value={value as string | null} contained={true} {placeholder} />
{:else} {:else}
<Input bind:value type="text" contained={true} {placeholder} alignRight={false} /> <Input bind:value type="text" contained={true} {placeholder} alignRight={false} />
{#if showLinkButton}
<button
type="button"
class="link-button"
onclick={openLink}
disabled={linkDisabled}
title={linkDisabled ? 'No link available' : 'Open link'}
>
<Icon name="link" size={16} />
</button>
{/if}
{/if} {/if}
</div> </div>
{:else if children} {:else if children}
@ -154,6 +183,7 @@
@use '$src/themes/layout' as layout; @use '$src/themes/layout' as layout;
@use '$src/themes/spacing' as spacing; @use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography; @use '$src/themes/typography' as typography;
@use '$src/themes/effects' as effects;
.detail-item { .detail-item {
display: flex; display: flex;
@ -218,6 +248,8 @@
display: flex; display: flex;
flex-grow: 0; flex-grow: 0;
justify-content: flex-end; justify-content: flex-end;
align-items: center;
gap: spacing.$unit-half;
:global(.input), :global(.input),
:global(.select) { :global(.select) {
@ -227,6 +259,36 @@
:global(.input.number) { :global(.input.number) {
width: var(--custom-width, 120px); width: var(--custom-width, 120px);
} }
.link-button {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
padding: 0;
border: none;
border-radius: layout.$item-corner;
background: transparent;
color: colors.$grey-50;
cursor: pointer;
flex-shrink: 0;
@include effects.smooth-transition(effects.$duration-quick, background-color, color, opacity);
&:hover:not(:disabled) {
background: colors.$grey-90;
color: colors.$grey-30;
}
&:active:not(:disabled) {
background: colors.$grey-80;
}
&:disabled {
cursor: not-allowed;
opacity: 0.4;
}
}
} }
} }
</style> </style>

View file

@ -0,0 +1,112 @@
/**
* URL builders for external game resource sites
* These convert stored database values into full URLs
*/
// Base URLs
const WIKI_EN_BASE = 'https://gbf.wiki'
const WIKI_JA_BASE = 'https://gbf-wiki.com'
const GAMEWITH_BASE = 'https://xn--bck3aza1a2if6kra4ee0hf.gamewith.jp/article/show'
const KAMIGAME_BASE = 'https://kamigame.jp'
// Kamigame paths by entity type
const KAMIGAME_PATHS = {
character: '/グラブル/キャラクター',
weapon: '/グラブル/武器',
summon: '/グラブル/召喚石'
} as const
export type EntityType = 'character' | 'weapon' | 'summon'
/**
* Build English wiki URL from page name
* Input: "Florence (Dark)"
* Output: "https://gbf.wiki/Florence_(Dark)"
*/
export function buildWikiEnUrl(pageName: string | undefined | null): string | null {
if (!pageName?.trim()) return null
// Wiki URLs use underscores for spaces
const encoded = encodeURIComponent(pageName.trim().replace(/ /g, '_'))
return `${WIKI_EN_BASE}/${encoded}`
}
/**
* Build Japanese wiki URL from page name
* Input: "フロレンス (SSR)闇属性バージョン"
* Output: "https://gbf-wiki.com/?フロレンス+(SSR)闇属性バージョン"
*/
export function buildWikiJaUrl(pageName: string | undefined | null): string | null {
if (!pageName?.trim()) return null
// Japanese wiki uses query string with + for spaces
const encoded = encodeURIComponent(pageName.trim()).replace(/%20/g, '+')
return `${WIKI_JA_BASE}/?${encoded}`
}
/**
* Build Gamewith URL from article ID
* Input: "519325"
* Output: "https://xn--bck3aza1a2if6kra4ee0hf.gamewith.jp/article/show/519325"
*/
export function buildGamewithUrl(articleId: string | number | undefined | null): string | null {
if (!articleId) return null
const id = String(articleId).trim()
if (!id) return null
return `${GAMEWITH_BASE}/${id}`
}
/**
* Build Kamigame URL from slug and entity type
*
* Character input: "SSR水着リッチ"
* Character output: "https://kamigame.jp/グラブル/キャラクター/SSR水着リッチ.html"
*
* Weapon input: "ブラインド・アンド・ストレイン", rarity: 3 (SSR)
* Weapon output: "https://kamigame.jp/グラブル/武器/SSR/ブラインド・アンド・ストレイン.html"
*
* Summon input: "SSR/アグニス"
* Summon output: "https://kamigame.jp/グラブル/召喚石/SSR/アグニス.html"
*/
export function buildKamigameUrl(
slug: string | undefined | null,
entityType: EntityType,
rarity?: number
): string | null {
if (!slug?.trim()) return null
const basePath = KAMIGAME_PATHS[entityType]
if (entityType === 'weapon' && rarity !== undefined) {
// Weapons: rarity is a separate path segment
const rarityPrefix = getRarityPrefix(rarity)
const encodedPath = encodeURIPath(basePath)
const encodedSlug = encodeURIComponent(slug.trim())
return `${KAMIGAME_BASE}${encodedPath}/${rarityPrefix}/${encodedSlug}.html`
}
// Characters and summons: value includes rarity info
const encodedPath = encodeURIPath(basePath)
const encodedSlug = encodeURIComponent(slug.trim())
return `${KAMIGAME_BASE}${encodedPath}/${encodedSlug}.html`
}
/**
* Map rarity enum to string prefix
*/
function getRarityPrefix(rarity: number): string {
const rarityMap: Record<number, string> = {
3: 'SSR',
2: 'SR',
1: 'R'
}
return rarityMap[rarity] || 'SSR'
}
/**
* Encode a path while preserving slashes
*/
function encodeURIPath(path: string): string {
return path
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/')
}

View file

@ -4,7 +4,13 @@
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { entityAdapter, type CharacterSuggestions } from '$lib/api/adapters/entity.adapter' import { entityAdapter, type CharacterSuggestions } from '$lib/api/adapters/entity.adapter'
import { fetchWikiPages, buildWikiDataMap } from '$lib/api/wiki' import { fetchWikiPages, buildWikiDataMap } from '$lib/api/wiki'
import { getCharacterImage, getPlaceholderImage } from '$lib/utils/images' import { getGameCdnCharacterImage, getPlaceholderImage } from '$lib/utils/images'
import {
buildWikiEnUrl,
buildWikiJaUrl,
buildGamewithUrl,
buildKamigameUrl
} from '$lib/utils/external-links'
// Components // Components
import CharacterUncapSection from '$lib/features/database/characters/sections/CharacterUncapSection.svelte' import CharacterUncapSection from '$lib/features/database/characters/sections/CharacterUncapSection.svelte'
@ -45,9 +51,9 @@
let entities = $state<Map<string, EntityState>>(new Map()) let entities = $state<Map<string, EntityState>>(new Map())
let selectedWikiPage = $state<string | null>(null) let selectedWikiPage = $state<string | null>(null)
// Form data per entity (keyed by wikiPage) // Form data per entity (keyed by wikiPage) - using Record for proper reactivity
let formDataMap = $state<Map<string, any>>(new Map()) let formDataByPage = $state<Record<string, any>>({})
let dismissedSuggestionsMap = $state<Map<string, Set<string>>>(new Map()) let dismissedByPage = $state<Record<string, Set<string>>>({})
let savedEntities = $state<Set<string>>(new Set()) let savedEntities = $state<Set<string>>(new Set())
// Saving state // Saving state
@ -73,10 +79,6 @@
// Get selected entity data // Get selected entity data
const selectedEntity = $derived(selectedWikiPage ? entities.get(selectedWikiPage) : null) const selectedEntity = $derived(selectedWikiPage ? entities.get(selectedWikiPage) : null)
const selectedFormData = $derived(selectedWikiPage ? formDataMap.get(selectedWikiPage) : null)
const selectedDismissed = $derived<Set<string>>(
selectedWikiPage ? dismissedSuggestionsMap.get(selectedWikiPage) ?? new Set<string>() : new Set<string>()
)
// Entity tabs for TabbedEntitySelector // Entity tabs for TabbedEntitySelector
const entityTabs = $derived<EntityTab[]>( const entityTabs = $derived<EntityTab[]>(
@ -85,7 +87,7 @@
granblueId: entity.granblueId, granblueId: entity.granblueId,
status: entity.status, status: entity.status,
imageUrl: entity.granblueId imageUrl: entity.granblueId
? getCharacterImage(entity.granblueId, 'square') ? getGameCdnCharacterImage(entity.granblueId)
: getPlaceholderImage('character', 'square'), : getPlaceholderImage('character', 'square'),
error: entity.error, error: entity.error,
saved: savedEntities.has(wikiPage) saved: savedEntities.has(wikiPage)
@ -107,7 +109,8 @@
proficiency1: suggestions?.proficiency1 ?? 0, proficiency1: suggestions?.proficiency1 ?? 0,
proficiency2: suggestions?.proficiency2 ?? 0, proficiency2: suggestions?.proficiency2 ?? 0,
season: null as number | null, season: null as number | null,
series: [] as number[], series: suggestions?.series ?? ([] as number[]),
promotions: [] as number[],
gacha_available: true, gacha_available: true,
minHp: suggestions?.minHp ?? 0, minHp: suggestions?.minHp ?? 0,
maxHp: suggestions?.maxHp ?? 0, maxHp: suggestions?.maxHp ?? 0,
@ -128,7 +131,7 @@
releaseDate: suggestions?.releaseDate ?? '', releaseDate: suggestions?.releaseDate ?? '',
flbDate: suggestions?.flbDate ?? '', flbDate: suggestions?.flbDate ?? '',
ulbDate: suggestions?.ulbDate ?? '', ulbDate: suggestions?.ulbDate ?? '',
wikiEn: wikiPage ? `https://gbf.wiki/${wikiPage.replace(/ /g, '_')}` : '', wikiEn: wikiPage ? wikiPage.replace(/ /g, '_') : '',
wikiJa: '', wikiJa: '',
gamewith: suggestions?.gamewith ?? '', gamewith: suggestions?.gamewith ?? '',
kamigame: suggestions?.kamigame ?? '', kamigame: suggestions?.kamigame ?? '',
@ -199,16 +202,15 @@
// Create form data for successful results // Create form data for successful results
if (result.status === 'success') { if (result.status === 'success') {
const formData = createEmptyFormData(result.wikiPage, result.suggestions) formDataByPage[result.wikiPage] = createEmptyFormData(result.wikiPage, result.suggestions)
formDataMap.set(result.wikiPage, formData) dismissedByPage[result.wikiPage] = new Set<string>()
dismissedSuggestionsMap.set(result.wikiPage, new Set<string>())
} }
}) })
entities = updatedEntities entities = updatedEntities
// Update formDataMap and dismissedSuggestionsMap to trigger reactivity // Trigger reactivity by reassigning
formDataMap = new Map(formDataMap) formDataByPage = { ...formDataByPage }
dismissedSuggestionsMap = new Map(dismissedSuggestionsMap) dismissedByPage = { ...dismissedByPage }
} catch (error) { } catch (error) {
console.error('Batch preview error:', error) console.error('Batch preview error:', error)
fetchError = 'Failed to fetch wiki data. Please try again.' fetchError = 'Failed to fetch wiki data. Please try again.'
@ -224,28 +226,26 @@
// Accept a suggestion // Accept a suggestion
function handleAcceptSuggestion(field: string, value: any) { function handleAcceptSuggestion(field: string, value: any) {
if (!selectedWikiPage || !selectedFormData) return if (!selectedWikiPage || !formDataByPage[selectedWikiPage]) return
const formData = formDataMap.get(selectedWikiPage) formDataByPage[selectedWikiPage][field] = value
if (formData) { formDataByPage = { ...formDataByPage }
formData[field] = value
formDataMap = new Map(formDataMap)
}
} }
// Dismiss a suggestion // Dismiss a suggestion
function handleDismissSuggestion(field: string) { function handleDismissSuggestion(field: string) {
if (!selectedWikiPage) return if (!selectedWikiPage) return
const dismissed = dismissedSuggestionsMap.get(selectedWikiPage) ?? new Set() const dismissed = dismissedByPage[selectedWikiPage] ?? new Set<string>()
dismissed.add(field) dismissed.add(field)
dismissedSuggestionsMap = new Map(dismissedSuggestionsMap) dismissedByPage[selectedWikiPage] = dismissed
dismissedByPage = { ...dismissedByPage }
} }
// Save current entity // Save current entity
async function saveCurrentEntity() { async function saveCurrentEntity() {
if (!selectedWikiPage) return if (!selectedWikiPage) return
const formData = formDataMap.get(selectedWikiPage) const formData = formDataByPage[selectedWikiPage]
if (!formData) return if (!formData) return
const entity = entities.get(selectedWikiPage) const entity = entities.get(selectedWikiPage)
@ -273,6 +273,7 @@
proficiency2: formData.proficiency2, proficiency2: formData.proficiency2,
season: formData.season === 0 ? null : formData.season, season: formData.season === 0 ? null : formData.season,
series: formData.series, series: formData.series,
promotions: formData.promotions,
gacha_available: formData.gacha_available, gacha_available: formData.gacha_available,
min_hp: formData.minHp, min_hp: formData.minHp,
max_hp: formData.maxHp, max_hp: formData.maxHp,
@ -306,7 +307,9 @@
savedEntities = new Set(savedEntities) savedEntities = new Set(savedEntities)
// Select next unsaved entity // Select next unsaved entity
const unsaved = entityTabs.find((e) => !savedEntities.has(e.wikiPage) && e.status === 'success') const unsaved = entityTabs.find(
(e) => !savedEntities.has(e.wikiPage) && e.status === 'success'
)
if (unsaved) { if (unsaved) {
selectedWikiPage = unsaved.wikiPage selectedWikiPage = unsaved.wikiPage
} }
@ -325,7 +328,7 @@
// Can save current entity // Can save current entity
const canSave = $derived.by(() => { const canSave = $derived.by(() => {
if (!selectedWikiPage) return false if (!selectedWikiPage) return false
const formData = formDataMap.get(selectedWikiPage) const formData = formDataByPage[selectedWikiPage]
if (!formData) return false if (!formData) return false
return ( return (
formData.name.trim() !== '' && formData.name.trim() !== '' &&
@ -368,7 +371,7 @@
<!-- Input phase --> <!-- Input phase -->
{#if entities.size === 0} {#if entities.size === 0}
<div class="input-phase"> <form class="input-phase" onsubmit={(e) => { e.preventDefault(); fetchWikiData(); }}>
<p class="hint">Enter up to 10 wiki page names to import data</p> <p class="hint">Enter up to 10 wiki page names to import data</p>
<div class="wiki-inputs"> <div class="wiki-inputs">
{#each wikiPagesInputs as _, index} {#each wikiPagesInputs as _, index}
@ -391,8 +394,14 @@
{/if} {/if}
</div> </div>
{/each} {/each}
<Button variant="ghost" onclick={addInput}> <Button
<Icon name="plus" size={16} /> variant="ghost"
class="add-input-button"
leftIcon="plus"
size="small"
type="button"
onclick={addInput}
>
Add another Add another
</Button> </Button>
</div> </div>
@ -400,11 +409,11 @@
<p class="error">{fetchError}</p> <p class="error">{fetchError}</p>
{/if} {/if}
<div class="fetch-button"> <div class="fetch-button">
<Button variant="primary" onclick={fetchWikiData} disabled={isFetching}> <Button variant="primary" type="submit" disabled={isFetching}>
{isFetching ? 'Fetching...' : 'Fetch data'} {isFetching ? 'Fetching...' : 'Fetch data'}
</Button> </Button>
</div> </div>
</div> </form>
{:else} {:else}
<!-- Entity selector --> <!-- Entity selector -->
<div class="entity-selector-container"> <div class="entity-selector-container">
@ -427,15 +436,14 @@
<div class="entity-loading"> <div class="entity-loading">
<p>Loading wiki data...</p> <p>Loading wiki data...</p>
</div> </div>
{:else if selectedWikiPage && formDataMap.has(selectedWikiPage)} {:else if selectedWikiPage && formDataByPage[selectedWikiPage]}
{@const formData = formDataMap.get(selectedWikiPage)!}
{@const suggestions = selectedEntity.suggestions} {@const suggestions = selectedEntity.suggestions}
{@const dismissed = dismissedSuggestionsMap.get(selectedWikiPage) ?? new Set<string>()} {@const dismissed = dismissedByPage[selectedWikiPage] ?? new Set<string>()}
<section class="details"> <section class="details">
<DetailsContainer title="Basic Info"> <DetailsContainer title="Basic Info">
<SuggestionDetailItem <SuggestionDetailItem
label="Name (EN)" label="Name (EN)"
bind:value={formData.name} bind:value={formDataByPage[selectedWikiPage].name}
editable={true} editable={true}
type="text" type="text"
placeholder="Character name" placeholder="Character name"
@ -446,7 +454,7 @@
/> />
<SuggestionDetailItem <SuggestionDetailItem
label="Name (JP)" label="Name (JP)"
bind:value={formData.nameJp} bind:value={formDataByPage[selectedWikiPage].nameJp}
editable={true} editable={true}
type="text" type="text"
placeholder="キャラクター名" placeholder="キャラクター名"
@ -458,7 +466,7 @@
<DetailItem <DetailItem
label="Character ID" label="Character ID"
sublabel="Separate multiple IDs with commas" sublabel="Separate multiple IDs with commas"
bind:value={formData.characterId} bind:value={formDataByPage[selectedWikiPage].characterId}
editable={true} editable={true}
type="text" type="text"
placeholder="Character IDs" placeholder="Character IDs"
@ -468,7 +476,7 @@
<CharacterMetadataSection <CharacterMetadataSection
character={emptyCharacter} character={emptyCharacter}
editMode={true} editMode={true}
editData={formData} bind:editData={formDataByPage[selectedWikiPage]}
{suggestions} {suggestions}
dismissedSuggestions={dismissed} dismissedSuggestions={dismissed}
onAcceptSuggestion={handleAcceptSuggestion} onAcceptSuggestion={handleAcceptSuggestion}
@ -478,7 +486,7 @@
<CharacterUncapSection <CharacterUncapSection
character={emptyCharacter} character={emptyCharacter}
editMode={true} editMode={true}
editData={formData} bind:editData={formDataByPage[selectedWikiPage]}
{suggestions} {suggestions}
dismissedSuggestions={dismissed} dismissedSuggestions={dismissed}
onAcceptSuggestion={handleAcceptSuggestion} onAcceptSuggestion={handleAcceptSuggestion}
@ -488,7 +496,7 @@
<CharacterTaxonomySection <CharacterTaxonomySection
character={emptyCharacter} character={emptyCharacter}
editMode={true} editMode={true}
editData={formData} bind:editData={formDataByPage[selectedWikiPage]}
{suggestions} {suggestions}
dismissedSuggestions={dismissed} dismissedSuggestions={dismissed}
onAcceptSuggestion={handleAcceptSuggestion} onAcceptSuggestion={handleAcceptSuggestion}
@ -498,7 +506,7 @@
<CharacterStatsSection <CharacterStatsSection
character={emptyCharacter} character={emptyCharacter}
editMode={true} editMode={true}
editData={formData} bind:editData={formDataByPage[selectedWikiPage]}
{suggestions} {suggestions}
dismissedSuggestions={dismissed} dismissedSuggestions={dismissed}
onAcceptSuggestion={handleAcceptSuggestion} onAcceptSuggestion={handleAcceptSuggestion}
@ -507,29 +515,30 @@
<DetailsContainer title="Nicknames"> <DetailsContainer title="Nicknames">
<DetailItem label="Nicknames (EN)"> <DetailItem label="Nicknames (EN)">
<TagInput bind:value={formData.nicknamesEn} placeholder="Add nickname..." contained /> <TagInput bind:value={formDataByPage[selectedWikiPage].nicknamesEn} placeholder="Add nickname..." contained />
</DetailItem> </DetailItem>
<DetailItem label="Nicknames (JP)"> <DetailItem label="Nicknames (JP)">
<TagInput bind:value={formData.nicknamesJp} placeholder="ニックネーム..." contained /> <TagInput bind:value={formDataByPage[selectedWikiPage].nicknamesJp} placeholder="ニックネーム..." contained />
</DetailItem> </DetailItem>
</DetailsContainer> </DetailsContainer>
<DetailsContainer title="Dates"> <DetailsContainer title="Dates">
<SuggestionDetailItem <SuggestionDetailItem
label="Release Date" label="Release Date"
bind:value={formData.releaseDate} bind:value={formDataByPage[selectedWikiPage].releaseDate}
editable={true} editable={true}
type="text" type="text"
placeholder="YYYY-MM-DD" placeholder="YYYY-MM-DD"
suggestion={suggestions?.releaseDate} suggestion={suggestions?.releaseDate}
dismissedSuggestion={dismissed.has('releaseDate')} dismissedSuggestion={dismissed.has('releaseDate')}
onAcceptSuggestion={() => handleAcceptSuggestion('releaseDate', suggestions?.releaseDate)} onAcceptSuggestion={() =>
handleAcceptSuggestion('releaseDate', suggestions?.releaseDate)}
onDismissSuggestion={() => handleDismissSuggestion('releaseDate')} onDismissSuggestion={() => handleDismissSuggestion('releaseDate')}
/> />
{#if formData.flb} {#if formDataByPage[selectedWikiPage].flb}
<SuggestionDetailItem <SuggestionDetailItem
label="FLB Date" label="FLB Date"
bind:value={formData.flbDate} bind:value={formDataByPage[selectedWikiPage].flbDate}
editable={true} editable={true}
type="text" type="text"
placeholder="YYYY-MM-DD" placeholder="YYYY-MM-DD"
@ -539,10 +548,10 @@
onDismissSuggestion={() => handleDismissSuggestion('flbDate')} onDismissSuggestion={() => handleDismissSuggestion('flbDate')}
/> />
{/if} {/if}
{#if formData.ulb} {#if formDataByPage[selectedWikiPage].ulb}
<SuggestionDetailItem <SuggestionDetailItem
label="ULB Date" label="ULB Date"
bind:value={formData.ulbDate} bind:value={formDataByPage[selectedWikiPage].ulbDate}
editable={true} editable={true}
type="text" type="text"
placeholder="YYYY-MM-DD" placeholder="YYYY-MM-DD"
@ -557,27 +566,33 @@
<DetailsContainer title="Links"> <DetailsContainer title="Links">
<DetailItem <DetailItem
label="Wiki (EN)" label="Wiki (EN)"
bind:value={formData.wikiEn} bind:value={formDataByPage[selectedWikiPage].wikiEn}
editable={true} editable={true}
type="text" type="text"
placeholder="https://gbf.wiki/..." placeholder="Page name (e.g., Narmaya)"
width="480px" width="480px"
hasLinkButton={true}
linkUrl={buildWikiEnUrl(formDataByPage[selectedWikiPage].wikiEn)}
/> />
<DetailItem <DetailItem
label="Wiki (JP)" label="Wiki (JP)"
bind:value={formData.wikiJa} bind:value={formDataByPage[selectedWikiPage].wikiJa}
editable={true} editable={true}
type="text" type="text"
placeholder="https://gbf-wiki.com/..." placeholder="Japanese page name"
width="480px" width="480px"
hasLinkButton={true}
linkUrl={buildWikiJaUrl(formDataByPage[selectedWikiPage].wikiJa)}
/> />
<SuggestionDetailItem <SuggestionDetailItem
label="Gamewith" label="Gamewith"
bind:value={formData.gamewith} bind:value={formDataByPage[selectedWikiPage].gamewith}
editable={true} editable={true}
type="text" type="text"
placeholder="https://..." placeholder="Article ID (e.g., 519325)"
width="480px" width="480px"
hasLinkButton={true}
linkUrl={buildGamewithUrl(formDataByPage[selectedWikiPage].gamewith)}
suggestion={suggestions?.gamewith} suggestion={suggestions?.gamewith}
dismissedSuggestion={dismissed.has('gamewith')} dismissedSuggestion={dismissed.has('gamewith')}
onAcceptSuggestion={() => handleAcceptSuggestion('gamewith', suggestions?.gamewith)} onAcceptSuggestion={() => handleAcceptSuggestion('gamewith', suggestions?.gamewith)}
@ -585,11 +600,13 @@
/> />
<SuggestionDetailItem <SuggestionDetailItem
label="Kamigame" label="Kamigame"
bind:value={formData.kamigame} bind:value={formDataByPage[selectedWikiPage].kamigame}
editable={true} editable={true}
type="text" type="text"
placeholder="https://..." placeholder="Slug (e.g., SSR闇フロレンス)"
width="480px" width="480px"
hasLinkButton={true}
linkUrl={buildKamigameUrl(formDataByPage[selectedWikiPage].kamigame, 'character')}
suggestion={suggestions?.kamigame} suggestion={suggestions?.kamigame}
dismissedSuggestion={dismissed.has('kamigame')} dismissedSuggestion={dismissed.has('kamigame')}
onAcceptSuggestion={() => handleAcceptSuggestion('kamigame', suggestions?.kamigame)} onAcceptSuggestion={() => handleAcceptSuggestion('kamigame', suggestions?.kamigame)}
@ -641,6 +658,10 @@
align-items: center; align-items: center;
} }
:global(.wiki-inputs .add-input-button) {
width: fit-content;
}
.remove-button { .remove-button {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -5,6 +5,12 @@
import { entityAdapter, type SummonSuggestions } from '$lib/api/adapters/entity.adapter' import { entityAdapter, type SummonSuggestions } from '$lib/api/adapters/entity.adapter'
import { fetchWikiPages, buildWikiDataMap } from '$lib/api/wiki' import { fetchWikiPages, buildWikiDataMap } from '$lib/api/wiki'
import { getGameCdnSummonImage, getPlaceholderImage } from '$lib/utils/images' import { getGameCdnSummonImage, getPlaceholderImage } from '$lib/utils/images'
import {
buildWikiEnUrl,
buildWikiJaUrl,
buildGamewithUrl,
buildKamigameUrl
} from '$lib/utils/external-links'
// Components // Components
import SummonUncapSection from '$lib/features/database/summons/sections/SummonUncapSection.svelte' import SummonUncapSection from '$lib/features/database/summons/sections/SummonUncapSection.svelte'
@ -44,9 +50,9 @@
let entities = $state<Map<string, EntityState>>(new Map()) let entities = $state<Map<string, EntityState>>(new Map())
let selectedWikiPage = $state<string | null>(null) let selectedWikiPage = $state<string | null>(null)
// Form data per entity (keyed by wikiPage) // Form data per entity (keyed by wikiPage) - using Record for proper reactivity
let formDataMap = $state<Map<string, any>>(new Map()) let formDataByPage = $state<Record<string, any>>({})
let dismissedSuggestionsMap = $state<Map<string, Set<string>>>(new Map()) let dismissedByPage = $state<Record<string, Set<string>>>({})
let savedEntities = $state<Set<string>>(new Set()) let savedEntities = $state<Set<string>>(new Set())
// Saving state // Saving state
@ -118,7 +124,7 @@
flbDate: suggestions?.flbDate ?? '', flbDate: suggestions?.flbDate ?? '',
ulbDate: suggestions?.ulbDate ?? '', ulbDate: suggestions?.ulbDate ?? '',
transcendenceDate: '', transcendenceDate: '',
wikiEn: wikiPage ? `https://gbf.wiki/${wikiPage.replace(/ /g, '_')}` : '', wikiEn: wikiPage ? wikiPage.replace(/ /g, '_') : '',
wikiJa: '', wikiJa: '',
gamewith: suggestions?.gamewith ?? '', gamewith: suggestions?.gamewith ?? '',
kamigame: suggestions?.kamigame ?? '', kamigame: suggestions?.kamigame ?? '',
@ -188,16 +194,15 @@
// Create form data for successful results // Create form data for successful results
if (result.status === 'success') { if (result.status === 'success') {
const formData = createEmptyFormData(result.wikiPage, result.suggestions) formDataByPage[result.wikiPage] = createEmptyFormData(result.wikiPage, result.suggestions)
formDataMap.set(result.wikiPage, formData) dismissedByPage[result.wikiPage] = new Set<string>()
dismissedSuggestionsMap.set(result.wikiPage, new Set<string>())
} }
}) })
entities = updatedEntities entities = updatedEntities
// Update formDataMap and dismissedSuggestionsMap to trigger reactivity // Trigger reactivity by reassigning
formDataMap = new Map(formDataMap) formDataByPage = { ...formDataByPage }
dismissedSuggestionsMap = new Map(dismissedSuggestionsMap) dismissedByPage = { ...dismissedByPage }
} catch (error) { } catch (error) {
console.error('Batch preview error:', error) console.error('Batch preview error:', error)
fetchError = 'Failed to fetch wiki data. Please try again.' fetchError = 'Failed to fetch wiki data. Please try again.'
@ -213,28 +218,26 @@
// Accept a suggestion // Accept a suggestion
function handleAcceptSuggestion(field: string, value: any) { function handleAcceptSuggestion(field: string, value: any) {
if (!selectedWikiPage) return if (!selectedWikiPage || !formDataByPage[selectedWikiPage]) return
const formData = formDataMap.get(selectedWikiPage) formDataByPage[selectedWikiPage][field] = value
if (formData) { formDataByPage = { ...formDataByPage }
formData[field] = value
formDataMap = new Map(formDataMap)
}
} }
// Dismiss a suggestion // Dismiss a suggestion
function handleDismissSuggestion(field: string) { function handleDismissSuggestion(field: string) {
if (!selectedWikiPage) return if (!selectedWikiPage) return
const dismissed = dismissedSuggestionsMap.get(selectedWikiPage) ?? new Set() const dismissed = dismissedByPage[selectedWikiPage] ?? new Set<string>()
dismissed.add(field) dismissed.add(field)
dismissedSuggestionsMap = new Map(dismissedSuggestionsMap) dismissedByPage[selectedWikiPage] = dismissed
dismissedByPage = { ...dismissedByPage }
} }
// Save current entity // Save current entity
async function saveCurrentEntity() { async function saveCurrentEntity() {
if (!selectedWikiPage) return if (!selectedWikiPage) return
const formData = formDataMap.get(selectedWikiPage) const formData = formDataByPage[selectedWikiPage]
if (!formData) return if (!formData) return
isSaving = true isSaving = true
@ -304,7 +307,7 @@
// Can save current entity // Can save current entity
const canSave = $derived.by(() => { const canSave = $derived.by(() => {
if (!selectedWikiPage) return false if (!selectedWikiPage) return false
const formData = formDataMap.get(selectedWikiPage) const formData = formDataByPage[selectedWikiPage]
if (!formData) return false if (!formData) return false
return ( return (
formData.name.trim() !== '' && formData.name.trim() !== '' &&
@ -318,6 +321,7 @@
entityTabs.length > 0 && entityTabs.length > 0 &&
entityTabs.filter((e) => e.status === 'success').every((e) => savedEntities.has(e.wikiPage)) entityTabs.filter((e) => e.status === 'success').every((e) => savedEntities.has(e.wikiPage))
) )
</script> </script>
<div class="page"> <div class="page">
@ -405,15 +409,14 @@
<div class="entity-loading"> <div class="entity-loading">
<p>Loading wiki data...</p> <p>Loading wiki data...</p>
</div> </div>
{:else if selectedWikiPage && formDataMap.has(selectedWikiPage)} {:else if selectedWikiPage && formDataByPage[selectedWikiPage]}
{@const formData = formDataMap.get(selectedWikiPage)!}
{@const suggestions = selectedEntity.suggestions} {@const suggestions = selectedEntity.suggestions}
{@const dismissed = dismissedSuggestionsMap.get(selectedWikiPage) ?? new Set<string>()} {@const dismissed = dismissedByPage[selectedWikiPage] ?? new Set<string>()}
<section class="details"> <section class="details">
<DetailsContainer title="Basic Info"> <DetailsContainer title="Basic Info">
<SuggestionDetailItem <SuggestionDetailItem
label="Name (EN)" label="Name (EN)"
bind:value={formData.name} bind:value={formDataByPage[selectedWikiPage].name}
editable={true} editable={true}
type="text" type="text"
placeholder="Summon name" placeholder="Summon name"
@ -424,7 +427,7 @@
/> />
<SuggestionDetailItem <SuggestionDetailItem
label="Name (JP)" label="Name (JP)"
bind:value={formData.nameJp} bind:value={formDataByPage[selectedWikiPage].nameJp}
editable={true} editable={true}
type="text" type="text"
placeholder="召喚石名" placeholder="召喚石名"
@ -436,7 +439,7 @@
<DetailItem <DetailItem
label="Summon ID" label="Summon ID"
sublabel="Internal game identifier (if known)" sublabel="Internal game identifier (if known)"
bind:value={formData.summonId} bind:value={formDataByPage[selectedWikiPage].summonId}
editable={true} editable={true}
type="text" type="text"
placeholder="Optional" placeholder="Optional"
@ -446,7 +449,7 @@
<SummonMetadataSection <SummonMetadataSection
summon={emptySummon} summon={emptySummon}
editMode={true} editMode={true}
editData={formData} bind:editData={formDataByPage[selectedWikiPage]}
{suggestions} {suggestions}
dismissedSuggestions={dismissed} dismissedSuggestions={dismissed}
onAcceptSuggestion={handleAcceptSuggestion} onAcceptSuggestion={handleAcceptSuggestion}
@ -456,7 +459,7 @@
<SummonUncapSection <SummonUncapSection
summon={emptySummon} summon={emptySummon}
editMode={true} editMode={true}
editData={formData} bind:editData={formDataByPage[selectedWikiPage]}
{suggestions} {suggestions}
dismissedSuggestions={dismissed} dismissedSuggestions={dismissed}
onAcceptSuggestion={handleAcceptSuggestion} onAcceptSuggestion={handleAcceptSuggestion}
@ -466,7 +469,7 @@
<SummonTaxonomySection <SummonTaxonomySection
summon={emptySummon} summon={emptySummon}
editMode={true} editMode={true}
editData={formData} bind:editData={formDataByPage[selectedWikiPage]}
{suggestions} {suggestions}
dismissedSuggestions={dismissed} dismissedSuggestions={dismissed}
onAcceptSuggestion={handleAcceptSuggestion} onAcceptSuggestion={handleAcceptSuggestion}
@ -476,7 +479,7 @@
<SummonStatsSection <SummonStatsSection
summon={emptySummon} summon={emptySummon}
editMode={true} editMode={true}
editData={formData} bind:editData={formDataByPage[selectedWikiPage]}
{suggestions} {suggestions}
dismissedSuggestions={dismissed} dismissedSuggestions={dismissed}
onAcceptSuggestion={handleAcceptSuggestion} onAcceptSuggestion={handleAcceptSuggestion}
@ -485,17 +488,17 @@
<DetailsContainer title="Nicknames"> <DetailsContainer title="Nicknames">
<DetailItem label="Nicknames (EN)"> <DetailItem label="Nicknames (EN)">
<TagInput bind:value={formData.nicknamesEn} placeholder="Add nickname..." contained /> <TagInput bind:value={formDataByPage[selectedWikiPage].nicknamesEn} placeholder="Add nickname..." contained />
</DetailItem> </DetailItem>
<DetailItem label="Nicknames (JP)"> <DetailItem label="Nicknames (JP)">
<TagInput bind:value={formData.nicknamesJp} placeholder="ニックネーム..." contained /> <TagInput bind:value={formDataByPage[selectedWikiPage].nicknamesJp} placeholder="ニックネーム..." contained />
</DetailItem> </DetailItem>
</DetailsContainer> </DetailsContainer>
<DetailsContainer title="Dates"> <DetailsContainer title="Dates">
<SuggestionDetailItem <SuggestionDetailItem
label="Release Date" label="Release Date"
bind:value={formData.releaseDate} bind:value={formDataByPage[selectedWikiPage].releaseDate}
editable={true} editable={true}
type="text" type="text"
placeholder="YYYY-MM-DD" placeholder="YYYY-MM-DD"
@ -505,10 +508,10 @@
handleAcceptSuggestion('releaseDate', suggestions?.releaseDate)} handleAcceptSuggestion('releaseDate', suggestions?.releaseDate)}
onDismissSuggestion={() => handleDismissSuggestion('releaseDate')} onDismissSuggestion={() => handleDismissSuggestion('releaseDate')}
/> />
{#if formData.flb} {#if formDataByPage[selectedWikiPage].flb}
<SuggestionDetailItem <SuggestionDetailItem
label="FLB Date" label="FLB Date"
bind:value={formData.flbDate} bind:value={formDataByPage[selectedWikiPage].flbDate}
editable={true} editable={true}
type="text" type="text"
placeholder="YYYY-MM-DD" placeholder="YYYY-MM-DD"
@ -518,10 +521,10 @@
onDismissSuggestion={() => handleDismissSuggestion('flbDate')} onDismissSuggestion={() => handleDismissSuggestion('flbDate')}
/> />
{/if} {/if}
{#if formData.ulb} {#if formDataByPage[selectedWikiPage].ulb}
<SuggestionDetailItem <SuggestionDetailItem
label="ULB Date" label="ULB Date"
bind:value={formData.ulbDate} bind:value={formDataByPage[selectedWikiPage].ulbDate}
editable={true} editable={true}
type="text" type="text"
placeholder="YYYY-MM-DD" placeholder="YYYY-MM-DD"
@ -531,10 +534,10 @@
onDismissSuggestion={() => handleDismissSuggestion('ulbDate')} onDismissSuggestion={() => handleDismissSuggestion('ulbDate')}
/> />
{/if} {/if}
{#if formData.transcendence} {#if formDataByPage[selectedWikiPage].transcendence}
<DetailItem <DetailItem
label="Transcendence Date" label="Transcendence Date"
bind:value={formData.transcendenceDate} bind:value={formDataByPage[selectedWikiPage].transcendenceDate}
editable={true} editable={true}
type="text" type="text"
placeholder="YYYY-MM-DD" placeholder="YYYY-MM-DD"
@ -545,27 +548,33 @@
<DetailsContainer title="Links"> <DetailsContainer title="Links">
<DetailItem <DetailItem
label="Wiki (EN)" label="Wiki (EN)"
bind:value={formData.wikiEn} bind:value={formDataByPage[selectedWikiPage].wikiEn}
editable={true} editable={true}
type="text" type="text"
placeholder="https://gbf.wiki/..." placeholder="Page name (e.g., Bahamut)"
width="480px" width="480px"
hasLinkButton={true}
linkUrl={buildWikiEnUrl(formDataByPage[selectedWikiPage].wikiEn)}
/> />
<DetailItem <DetailItem
label="Wiki (JP)" label="Wiki (JP)"
bind:value={formData.wikiJa} bind:value={formDataByPage[selectedWikiPage].wikiJa}
editable={true} editable={true}
type="text" type="text"
placeholder="https://gbf-wiki.com/..." placeholder="Japanese page name"
width="480px" width="480px"
hasLinkButton={true}
linkUrl={buildWikiJaUrl(formDataByPage[selectedWikiPage].wikiJa)}
/> />
<SuggestionDetailItem <SuggestionDetailItem
label="Gamewith" label="Gamewith"
bind:value={formData.gamewith} bind:value={formDataByPage[selectedWikiPage].gamewith}
editable={true} editable={true}
type="text" type="text"
placeholder="https://..." placeholder="Article ID (e.g., 519325)"
width="480px" width="480px"
hasLinkButton={true}
linkUrl={buildGamewithUrl(formDataByPage[selectedWikiPage].gamewith)}
suggestion={suggestions?.gamewith} suggestion={suggestions?.gamewith}
dismissedSuggestion={dismissed.has('gamewith')} dismissedSuggestion={dismissed.has('gamewith')}
onAcceptSuggestion={() => handleAcceptSuggestion('gamewith', suggestions?.gamewith)} onAcceptSuggestion={() => handleAcceptSuggestion('gamewith', suggestions?.gamewith)}
@ -573,11 +582,13 @@
/> />
<SuggestionDetailItem <SuggestionDetailItem
label="Kamigame" label="Kamigame"
bind:value={formData.kamigame} bind:value={formDataByPage[selectedWikiPage].kamigame}
editable={true} editable={true}
type="text" type="text"
placeholder="https://..." placeholder="Slug (e.g., SSR/アグニス)"
width="480px" width="480px"
hasLinkButton={true}
linkUrl={buildKamigameUrl(formDataByPage[selectedWikiPage].kamigame, 'summon')}
suggestion={suggestions?.kamigame} suggestion={suggestions?.kamigame}
dismissedSuggestion={dismissed.has('kamigame')} dismissedSuggestion={dismissed.has('kamigame')}
onAcceptSuggestion={() => handleAcceptSuggestion('kamigame', suggestions?.kamigame)} onAcceptSuggestion={() => handleAcceptSuggestion('kamigame', suggestions?.kamigame)}

View file

@ -4,7 +4,13 @@
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { entityAdapter, type WeaponSuggestions } from '$lib/api/adapters/entity.adapter' import { entityAdapter, type WeaponSuggestions } from '$lib/api/adapters/entity.adapter'
import { fetchWikiPages, buildWikiDataMap } from '$lib/api/wiki' import { fetchWikiPages, buildWikiDataMap } from '$lib/api/wiki'
import { getWeaponImage, getPlaceholderImage } from '$lib/utils/images' import { getGameCdnWeaponImage, getPlaceholderImage } from '$lib/utils/images'
import {
buildWikiEnUrl,
buildWikiJaUrl,
buildGamewithUrl,
buildKamigameUrl
} from '$lib/utils/external-links'
// Components // Components
import WeaponUncapSection from '$lib/features/database/weapons/sections/WeaponUncapSection.svelte' import WeaponUncapSection from '$lib/features/database/weapons/sections/WeaponUncapSection.svelte'
@ -45,9 +51,9 @@
let entities = $state<Map<string, EntityState>>(new Map()) let entities = $state<Map<string, EntityState>>(new Map())
let selectedWikiPage = $state<string | null>(null) let selectedWikiPage = $state<string | null>(null)
// Form data per entity (keyed by wikiPage) // Form data per entity (keyed by wikiPage) - using Record for proper reactivity
let formDataMap = $state<Map<string, any>>(new Map()) let formDataByPage = $state<Record<string, any>>({})
let dismissedSuggestionsMap = $state<Map<string, Set<string>>>(new Map()) let dismissedByPage = $state<Record<string, Set<string>>>({})
let savedEntities = $state<Set<string>>(new Set()) let savedEntities = $state<Set<string>>(new Set())
// Saving state // Saving state
@ -79,7 +85,7 @@
granblueId: entity.granblueId, granblueId: entity.granblueId,
status: entity.status, status: entity.status,
imageUrl: entity.granblueId imageUrl: entity.granblueId
? getWeaponImage(entity.granblueId, 'square') ? getGameCdnWeaponImage(entity.granblueId)
: getPlaceholderImage('weapon', 'square'), : getPlaceholderImage('weapon', 'square'),
error: entity.error, error: entity.error,
saved: savedEntities.has(wikiPage) saved: savedEntities.has(wikiPage)
@ -119,7 +125,7 @@
flbDate: suggestions?.flbDate ?? '', flbDate: suggestions?.flbDate ?? '',
ulbDate: suggestions?.ulbDate ?? '', ulbDate: suggestions?.ulbDate ?? '',
transcendenceDate: '', transcendenceDate: '',
wikiEn: wikiPage ? `https://gbf.wiki/${wikiPage.replace(/ /g, '_')}` : '', wikiEn: wikiPage ? wikiPage.replace(/ /g, '_') : '',
wikiJa: '', wikiJa: '',
gamewith: suggestions?.gamewith ?? '', gamewith: suggestions?.gamewith ?? '',
kamigame: suggestions?.kamigame ?? '', kamigame: suggestions?.kamigame ?? '',
@ -190,16 +196,15 @@
// Create form data for successful results // Create form data for successful results
if (result.status === 'success') { if (result.status === 'success') {
const formData = createEmptyFormData(result.wikiPage, result.suggestions) formDataByPage[result.wikiPage] = createEmptyFormData(result.wikiPage, result.suggestions)
formDataMap.set(result.wikiPage, formData) dismissedByPage[result.wikiPage] = new Set<string>()
dismissedSuggestionsMap.set(result.wikiPage, new Set<string>())
} }
}) })
entities = updatedEntities entities = updatedEntities
// Update formDataMap and dismissedSuggestionsMap to trigger reactivity // Trigger reactivity by reassigning
formDataMap = new Map(formDataMap) formDataByPage = { ...formDataByPage }
dismissedSuggestionsMap = new Map(dismissedSuggestionsMap) dismissedByPage = { ...dismissedByPage }
} catch (error) { } catch (error) {
console.error('Batch preview error:', error) console.error('Batch preview error:', error)
fetchError = 'Failed to fetch wiki data. Please try again.' fetchError = 'Failed to fetch wiki data. Please try again.'
@ -215,28 +220,26 @@
// Accept a suggestion // Accept a suggestion
function handleAcceptSuggestion(field: string, value: any) { function handleAcceptSuggestion(field: string, value: any) {
if (!selectedWikiPage) return if (!selectedWikiPage || !formDataByPage[selectedWikiPage]) return
const formData = formDataMap.get(selectedWikiPage) formDataByPage[selectedWikiPage][field] = value
if (formData) { formDataByPage = { ...formDataByPage }
formData[field] = value
formDataMap = new Map(formDataMap)
}
} }
// Dismiss a suggestion // Dismiss a suggestion
function handleDismissSuggestion(field: string) { function handleDismissSuggestion(field: string) {
if (!selectedWikiPage) return if (!selectedWikiPage) return
const dismissed = dismissedSuggestionsMap.get(selectedWikiPage) ?? new Set() const dismissed = dismissedByPage[selectedWikiPage] ?? new Set<string>()
dismissed.add(field) dismissed.add(field)
dismissedSuggestionsMap = new Map(dismissedSuggestionsMap) dismissedByPage[selectedWikiPage] = dismissed
dismissedByPage = { ...dismissedByPage }
} }
// Save current entity // Save current entity
async function saveCurrentEntity() { async function saveCurrentEntity() {
if (!selectedWikiPage) return if (!selectedWikiPage) return
const formData = formDataMap.get(selectedWikiPage) const formData = formDataByPage[selectedWikiPage]
if (!formData) return if (!formData) return
isSaving = true isSaving = true
@ -288,7 +291,9 @@
savedEntities = new Set(savedEntities) savedEntities = new Set(savedEntities)
// Select next unsaved entity // Select next unsaved entity
const unsaved = entityTabs.find((e) => !savedEntities.has(e.wikiPage) && e.status === 'success') const unsaved = entityTabs.find(
(e) => !savedEntities.has(e.wikiPage) && e.status === 'success'
)
if (unsaved) { if (unsaved) {
selectedWikiPage = unsaved.wikiPage selectedWikiPage = unsaved.wikiPage
} }
@ -307,7 +312,7 @@
// Can save current entity // Can save current entity
const canSave = $derived.by(() => { const canSave = $derived.by(() => {
if (!selectedWikiPage) return false if (!selectedWikiPage) return false
const formData = formDataMap.get(selectedWikiPage) const formData = formDataByPage[selectedWikiPage]
if (!formData) return false if (!formData) return false
return ( return (
formData.name.trim() !== '' && formData.name.trim() !== '' &&
@ -321,6 +326,7 @@
entityTabs.length > 0 && entityTabs.length > 0 &&
entityTabs.filter((e) => e.status === 'success').every((e) => savedEntities.has(e.wikiPage)) entityTabs.filter((e) => e.status === 'success').every((e) => savedEntities.has(e.wikiPage))
) )
</script> </script>
<div class="page"> <div class="page">
@ -350,17 +356,12 @@
<!-- Input phase --> <!-- Input phase -->
{#if entities.size === 0} {#if entities.size === 0}
<div class="input-phase"> <form class="input-phase" onsubmit={(e) => { e.preventDefault(); fetchWikiData(); }}>
<p class="hint">Enter up to 10 wiki page names to import data</p> <p class="hint">Enter up to 10 wiki page names to import data</p>
<div class="wiki-inputs"> <div class="wiki-inputs">
{#each wikiPagesInputs as _, index} {#each wikiPagesInputs as _, index}
<div class="input-row"> <div class="input-row">
<Input <Input bind:value={wikiPagesInputs[index]} placeholder="Ixaba" contained fullWidth />
bind:value={wikiPagesInputs[index]}
placeholder="Ixaba"
contained
fullWidth
/>
{#if wikiPagesInputs.length > 1} {#if wikiPagesInputs.length > 1}
<button <button
type="button" type="button"
@ -373,8 +374,14 @@
{/if} {/if}
</div> </div>
{/each} {/each}
<Button variant="ghost" onclick={addInput}> <Button
<Icon name="plus" size={16} /> variant="ghost"
class="add-input-button"
leftIcon="plus"
size="small"
type="button"
onclick={addInput}
>
Add another Add another
</Button> </Button>
</div> </div>
@ -382,11 +389,11 @@
<p class="error">{fetchError}</p> <p class="error">{fetchError}</p>
{/if} {/if}
<div class="fetch-button"> <div class="fetch-button">
<Button variant="primary" onclick={fetchWikiData} disabled={isFetching}> <Button variant="primary" type="submit" disabled={isFetching}>
{isFetching ? 'Fetching...' : 'Fetch data'} {isFetching ? 'Fetching...' : 'Fetch data'}
</Button> </Button>
</div> </div>
</div> </form>
{:else} {:else}
<!-- Entity selector --> <!-- Entity selector -->
<div class="entity-selector-container"> <div class="entity-selector-container">
@ -409,15 +416,14 @@
<div class="entity-loading"> <div class="entity-loading">
<p>Loading wiki data...</p> <p>Loading wiki data...</p>
</div> </div>
{:else if selectedWikiPage && formDataMap.has(selectedWikiPage)} {:else if selectedWikiPage && formDataByPage[selectedWikiPage]}
{@const formData = formDataMap.get(selectedWikiPage)!}
{@const suggestions = selectedEntity.suggestions} {@const suggestions = selectedEntity.suggestions}
{@const dismissed = dismissedSuggestionsMap.get(selectedWikiPage) ?? new Set<string>()} {@const dismissed = dismissedByPage[selectedWikiPage] ?? new Set<string>()}
<section class="details"> <section class="details">
<DetailsContainer title="Basic Info"> <DetailsContainer title="Basic Info">
<SuggestionDetailItem <SuggestionDetailItem
label="Name (EN)" label="Name (EN)"
bind:value={formData.name} bind:value={formDataByPage[selectedWikiPage].name}
editable={true} editable={true}
type="text" type="text"
placeholder="Weapon name" placeholder="Weapon name"
@ -428,7 +434,7 @@
/> />
<SuggestionDetailItem <SuggestionDetailItem
label="Name (JP)" label="Name (JP)"
bind:value={formData.nameJp} bind:value={formDataByPage[selectedWikiPage].nameJp}
editable={true} editable={true}
type="text" type="text"
placeholder="武器名" placeholder="武器名"
@ -442,7 +448,7 @@
<WeaponMetadataSection <WeaponMetadataSection
weapon={emptyWeapon} weapon={emptyWeapon}
editMode={true} editMode={true}
editData={formData} bind:editData={formDataByPage[selectedWikiPage]}
{suggestions} {suggestions}
dismissedSuggestions={dismissed} dismissedSuggestions={dismissed}
onAcceptSuggestion={handleAcceptSuggestion} onAcceptSuggestion={handleAcceptSuggestion}
@ -452,7 +458,7 @@
<WeaponUncapSection <WeaponUncapSection
weapon={emptyWeapon} weapon={emptyWeapon}
editMode={true} editMode={true}
editData={formData} bind:editData={formDataByPage[selectedWikiPage]}
{suggestions} {suggestions}
dismissedSuggestions={dismissed} dismissedSuggestions={dismissed}
onAcceptSuggestion={handleAcceptSuggestion} onAcceptSuggestion={handleAcceptSuggestion}
@ -462,7 +468,7 @@
<WeaponTaxonomySection <WeaponTaxonomySection
weapon={emptyWeapon} weapon={emptyWeapon}
editMode={true} editMode={true}
editData={formData} bind:editData={formDataByPage[selectedWikiPage]}
{suggestions} {suggestions}
dismissedSuggestions={dismissed} dismissedSuggestions={dismissed}
onAcceptSuggestion={handleAcceptSuggestion} onAcceptSuggestion={handleAcceptSuggestion}
@ -472,7 +478,7 @@
<WeaponStatsSection <WeaponStatsSection
weapon={emptyWeapon} weapon={emptyWeapon}
editMode={true} editMode={true}
editData={formData} bind:editData={formDataByPage[selectedWikiPage]}
{suggestions} {suggestions}
dismissedSuggestions={dismissed} dismissedSuggestions={dismissed}
onAcceptSuggestion={handleAcceptSuggestion} onAcceptSuggestion={handleAcceptSuggestion}
@ -481,35 +487,39 @@
<DetailsContainer title="Nicknames"> <DetailsContainer title="Nicknames">
<DetailItem label="Nicknames (EN)"> <DetailItem label="Nicknames (EN)">
<TagInput bind:value={formData.nicknamesEn} placeholder="Add nickname..." contained /> <TagInput bind:value={formDataByPage[selectedWikiPage].nicknamesEn} placeholder="Add nickname..." contained />
</DetailItem> </DetailItem>
<DetailItem label="Nicknames (JP)"> <DetailItem label="Nicknames (JP)">
<TagInput bind:value={formData.nicknamesJp} placeholder="ニックネーム..." contained /> <TagInput bind:value={formDataByPage[selectedWikiPage].nicknamesJp} placeholder="ニックネーム..." contained />
</DetailItem> </DetailItem>
</DetailsContainer> </DetailsContainer>
<DetailsContainer title="Recruits"> <DetailsContainer title="Recruits">
<DetailItem label="Recruits Character" sublabel="Character recruited by this weapon"> <DetailItem label="Recruits Character" sublabel="Character recruited by this weapon">
<CharacterTypeahead bind:value={formData.recruits} placeholder="Search for character..." /> <CharacterTypeahead
bind:value={formDataByPage[selectedWikiPage].recruits}
placeholder="Search for character..."
/>
</DetailItem> </DetailItem>
</DetailsContainer> </DetailsContainer>
<DetailsContainer title="Dates"> <DetailsContainer title="Dates">
<SuggestionDetailItem <SuggestionDetailItem
label="Release Date" label="Release Date"
bind:value={formData.releaseDate} bind:value={formDataByPage[selectedWikiPage].releaseDate}
editable={true} editable={true}
type="text" type="text"
placeholder="YYYY-MM-DD" placeholder="YYYY-MM-DD"
suggestion={suggestions?.releaseDate} suggestion={suggestions?.releaseDate}
dismissedSuggestion={dismissed.has('releaseDate')} dismissedSuggestion={dismissed.has('releaseDate')}
onAcceptSuggestion={() => handleAcceptSuggestion('releaseDate', suggestions?.releaseDate)} onAcceptSuggestion={() =>
handleAcceptSuggestion('releaseDate', suggestions?.releaseDate)}
onDismissSuggestion={() => handleDismissSuggestion('releaseDate')} onDismissSuggestion={() => handleDismissSuggestion('releaseDate')}
/> />
{#if formData.flb} {#if formDataByPage[selectedWikiPage].flb}
<SuggestionDetailItem <SuggestionDetailItem
label="FLB Date" label="FLB Date"
bind:value={formData.flbDate} bind:value={formDataByPage[selectedWikiPage].flbDate}
editable={true} editable={true}
type="text" type="text"
placeholder="YYYY-MM-DD" placeholder="YYYY-MM-DD"
@ -519,10 +529,10 @@
onDismissSuggestion={() => handleDismissSuggestion('flbDate')} onDismissSuggestion={() => handleDismissSuggestion('flbDate')}
/> />
{/if} {/if}
{#if formData.ulb} {#if formDataByPage[selectedWikiPage].ulb}
<SuggestionDetailItem <SuggestionDetailItem
label="ULB Date" label="ULB Date"
bind:value={formData.ulbDate} bind:value={formDataByPage[selectedWikiPage].ulbDate}
editable={true} editable={true}
type="text" type="text"
placeholder="YYYY-MM-DD" placeholder="YYYY-MM-DD"
@ -532,10 +542,10 @@
onDismissSuggestion={() => handleDismissSuggestion('ulbDate')} onDismissSuggestion={() => handleDismissSuggestion('ulbDate')}
/> />
{/if} {/if}
{#if formData.transcendence} {#if formDataByPage[selectedWikiPage].transcendence}
<DetailItem <DetailItem
label="Transcendence Date" label="Transcendence Date"
bind:value={formData.transcendenceDate} bind:value={formDataByPage[selectedWikiPage].transcendenceDate}
editable={true} editable={true}
type="text" type="text"
placeholder="YYYY-MM-DD" placeholder="YYYY-MM-DD"
@ -546,27 +556,33 @@
<DetailsContainer title="Links"> <DetailsContainer title="Links">
<DetailItem <DetailItem
label="Wiki (EN)" label="Wiki (EN)"
bind:value={formData.wikiEn} bind:value={formDataByPage[selectedWikiPage].wikiEn}
editable={true} editable={true}
type="text" type="text"
placeholder="https://gbf.wiki/..." placeholder="Page name (e.g., Cosmic_Sword)"
width="480px" width="480px"
hasLinkButton={true}
linkUrl={buildWikiEnUrl(formDataByPage[selectedWikiPage].wikiEn)}
/> />
<DetailItem <DetailItem
label="Wiki (JP)" label="Wiki (JP)"
bind:value={formData.wikiJa} bind:value={formDataByPage[selectedWikiPage].wikiJa}
editable={true} editable={true}
type="text" type="text"
placeholder="https://gbf-wiki.com/..." placeholder="Japanese page name"
width="480px" width="480px"
hasLinkButton={true}
linkUrl={buildWikiJaUrl(formDataByPage[selectedWikiPage].wikiJa)}
/> />
<SuggestionDetailItem <SuggestionDetailItem
label="Gamewith" label="Gamewith"
bind:value={formData.gamewith} bind:value={formDataByPage[selectedWikiPage].gamewith}
editable={true} editable={true}
type="text" type="text"
placeholder="https://..." placeholder="Article ID (e.g., 519325)"
width="480px" width="480px"
hasLinkButton={true}
linkUrl={buildGamewithUrl(formDataByPage[selectedWikiPage].gamewith)}
suggestion={suggestions?.gamewith} suggestion={suggestions?.gamewith}
dismissedSuggestion={dismissed.has('gamewith')} dismissedSuggestion={dismissed.has('gamewith')}
onAcceptSuggestion={() => handleAcceptSuggestion('gamewith', suggestions?.gamewith)} onAcceptSuggestion={() => handleAcceptSuggestion('gamewith', suggestions?.gamewith)}
@ -574,11 +590,13 @@
/> />
<SuggestionDetailItem <SuggestionDetailItem
label="Kamigame" label="Kamigame"
bind:value={formData.kamigame} bind:value={formDataByPage[selectedWikiPage].kamigame}
editable={true} editable={true}
type="text" type="text"
placeholder="https://..." placeholder="Japanese name (e.g., 神刃エクス・アシャワン)"
width="480px" width="480px"
hasLinkButton={true}
linkUrl={buildKamigameUrl(formDataByPage[selectedWikiPage].kamigame, 'weapon', formDataByPage[selectedWikiPage].rarity)}
suggestion={suggestions?.kamigame} suggestion={suggestions?.kamigame}
dismissedSuggestion={dismissed.has('kamigame')} dismissedSuggestion={dismissed.has('kamigame')}
onAcceptSuggestion={() => handleAcceptSuggestion('kamigame', suggestions?.kamigame)} onAcceptSuggestion={() => handleAcceptSuggestion('kamigame', suggestions?.kamigame)}
@ -622,6 +640,10 @@
align-items: center; align-items: center;
} }
:global(.wiki-inputs .add-input-button) {
width: fit-content;
}
.remove-button { .remove-button {
display: flex; display: flex;
align-items: center; align-items: center;