feat: improve card catalog and add outfit effect filters
Publish GitHub Pages output / publish (push) Successful in 38s

This commit is contained in:
dal4segno committed 2026-10-07 17:36:22 +09:00
1 parent 1b94c781be
commit 89e9976ce9
16 files changed
+971 -1100

No files matched your search

File diff suppressed because it is too large. Load diff
+207
View File
@@ -0,0 +1,207 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { page } from '$app/stores';
import CardCatalogHeader from '$lib/components/CardCatalogHeader.svelte';
import '$lib/styles/card-catalog.css';
import cardSummary from '$lib/data/cards-summary.json';
import { matchesCardFilter, type CardSummary } from '$lib/models/card-list';
import { generationLabel, generationMemberships, outfitEffectOptions, outfitSortValue, parseOutfit, sortOutfits, type OutfitEntry, type OutfitSortKey } from '$lib/models/outfit-list';
const outfits = (cardSummary.cards as CardSummary[]).map(parseOutfit).filter((entry): entry is OutfitEntry => entry !== null);
const storageKey = 'holodori:outfit-list:v1';
const typeOptions = ['큐트', '퓨어', '해피'];
const typeIcons: Record<string, string> = { 큐트: '/icons/types/cute.webp', 퓨어: '/icons/types/pure.webp', 해피: '/icons/types/happy.webp' };
const conditionOptions = ['일반', '조건부', ...(outfits.some(outfit => !outfit.complete) ? ['확인 필요'] : [])];
const rarityOptions = ['★5', '★4'];
const variantOptions = ['일반', ...new Set(outfits.map(outfit => outfit.card.versionName).filter(Boolean))];
const conditionGenerationOptions = [...new Set(outfits.flatMap(outfit => outfit.conditionGenerations))];
const memberGenerationOptions = [...new Set(outfits.flatMap(outfit => generationMemberships(outfit.card.generation)))];
const statIcons: Record<string, string> = { 퍼포먼스: 'performance', 테크닉: 'technique', 센스: 'sense' };
let selectedTypes: string[] = [];
let selectedEffects: string[] = [];
let selectedConditions: string[] = [];
let selectedRarities: string[] = [];
let selectedVariants: string[] = [];
let selectedConditionGenerations: string[] = [];
let selectedMemberGenerations: string[] = [];
let sortKey: OutfitSortKey | null = null;
let sortDirection: 'asc' | 'desc' = 'asc';
let expandedIds: string[] = [];
let ready = false;
function conditionStatus(outfit: OutfitEntry) {
return !outfit.complete ? '확인 필요' : outfit.conditions.length ? '조건부' : '일반';
}
function matchesAny(selected: string[], values: readonly string[]) {
return selected.length === 0 || values.some(value => selected.includes(value));
}
$: filteredOutfits = sortOutfits(outfits.filter(outfit =>
matchesAny(selectedTypes, outfit.conditionTypes)
&& matchesAny(selectedEffects, outfit.effects.map(effect => effect.kind))
&& matchesCardFilter(selectedConditions, conditionStatus(outfit))
&& matchesCardFilter(selectedRarities, `★${outfit.card.rarity}`)
&& matchesCardFilter(selectedVariants, outfit.card.versionName || '일반')
&& matchesAny(selectedConditionGenerations, outfit.conditionGenerations)
&& matchesAny(selectedMemberGenerations, generationMemberships(outfit.card.generation))
), sortKey, sortDirection);
$: hasFilters = Boolean(selectedTypes.length || selectedEffects.length || selectedConditions.length || selectedRarities.length || selectedVariants.length || selectedConditionGenerations.length || selectedMemberGenerations.length);
$: settings = { selectedTypes, selectedEffects, selectedConditions, selectedRarities, selectedVariants, selectedConditionGenerations, selectedMemberGenerations, sortKey, sortDirection };
$: if (ready) {
try { localStorage.setItem(storageKey, JSON.stringify(settings)); } catch {}
}
onMount(() => {
try {
const saved = JSON.parse(localStorage.getItem(storageKey) ?? 'null');
if (saved && typeof saved === 'object' && !Array.isArray(saved)) {
const valid = (value: unknown, options: readonly string[]) => Array.isArray(value) ? options.filter(option => value.includes(option)) : [];
selectedTypes = valid(saved.selectedTypes, typeOptions);
selectedEffects = valid(saved.selectedEffects, outfitEffectOptions);
selectedConditions = valid(saved.selectedConditions, conditionOptions);
selectedRarities = valid(saved.selectedRarities, rarityOptions);
selectedVariants = valid(saved.selectedVariants, variantOptions);
selectedConditionGenerations = valid(saved.selectedConditionGenerations, conditionGenerationOptions);
selectedMemberGenerations = valid(saved.selectedMemberGenerations, memberGenerationOptions);
sortKey = saved.sortKey === 'parameter' || saved.sortKey === 'support' ? saved.sortKey : null;
sortDirection = saved.sortDirection === 'desc' ? 'desc' : 'asc';
}
} catch {}
const requestedId = $page.url.searchParams.get('outfit');
if (requestedId && outfits.some(outfit => outfit.card.id === requestedId)) {
resetFilters();
expandedIds = [requestedId];
void tick().then(() => document.getElementById('outfit-' + requestedId)?.scrollIntoView({ block: 'center' }));
}
ready = true;
});
function resetFilters() {
selectedTypes = []; selectedEffects = []; selectedConditions = []; selectedRarities = [];
selectedVariants = []; selectedConditionGenerations = []; selectedMemberGenerations = [];
}
function toggleOutfit(id: string) {
expandedIds = expandedIds.includes(id) ? expandedIds.filter(value => value !== id) : [...expandedIds, id];
}
function toggleSort(key: OutfitSortKey) {
if (sortKey === key) sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
else { sortKey = key; sortDirection = 'asc'; }
}
function sortLabel(key: OutfitSortKey, label: string, currentKey: OutfitSortKey | null, direction: 'asc' | 'desc') {
return `${label} ${currentKey !== key ? '오름차순 정렬' : direction === 'asc' ? '내림차순으로 변경' : '오름차순으로 변경'}`;
}
function valueLabel(outfit: OutfitEntry, key: OutfitSortKey) {
const value = outfitSortValue(outfit, key);
return value === null ? '—' : `${value}%`;
}
function shortCondition(value: string) {
return generationLabel(value).replace(/\(이\)가/g, '').replace(/이상이면$/, '이상');
}
</script>
<svelte:head>
<title>의상 효과 · 갤산기</title>
<meta name="description" content="홀로드림스 4·5성 의상의 발동 조건과 파라미터·스코어 서포트 효과를 필터링하고 비교합니다." />
</svelte:head>
<main class="card-catalog">
<CardCatalogHeader active="outfits" count={filteredOutfits.length} total={outfits.length} unit="벌" />
<section class="filters" aria-labelledby="outfit-filter-heading">
<div class="filter-header"><div><h2 id="outfit-filter-heading">필터</h2><p class="filter-guide">보고 싶은 항목을 선택하세요. 선택하지 않은 그룹은 전체를 표시합니다.</p></div><button type="button" class="filter-action" onclick={() => resetFilters()} disabled={!hasFilters}>필터 초기화</button></div>
<p class="filter-note">조건 속성·기수는 의상 발동에 필요한 편성 조건입니다.</p>
<div class="filter-groups" style="--filter-columns: 5">
<fieldset><legend>조건 속성 <span class="filter-status">{selectedTypes.length ? `${selectedTypes.length}개 선택` : '전체'}</span></legend><div class="filter-options">{#each typeOptions as value}<label class:checked={selectedTypes.includes(value)}><input type="checkbox" bind:group={selectedTypes} {value} aria-label={value} /><span class="checkmark" aria-hidden="true">✓</span><img class="filter-type-icon" src={typeIcons[value]} alt="" aria-hidden="true" title={value} /><span>{value}</span></label>{/each}</div></fieldset>
<fieldset><legend>강화 효과 <span class="filter-status">{selectedEffects.length ? `${selectedEffects.length}개 선택` : '전체'}</span></legend><div class="filter-options">{#each outfitEffectOptions as value}<label class:checked={selectedEffects.includes(value)}><input type="checkbox" bind:group={selectedEffects} {value} /><span class="checkmark" aria-hidden="true">✓</span>{#if statIcons[value]}<span class={"stat-icon " + statIcons[value]} aria-hidden="true" title={value}></span>{/if}<span>{value}</span></label>{/each}</div></fieldset>
<fieldset><legend>발동 조건 <span class="filter-status">{selectedConditions.length ? `${selectedConditions.length}개 선택` : '전체'}</span></legend><div class="filter-options">{#each conditionOptions as value}<label class:checked={selectedConditions.includes(value)}><input type="checkbox" bind:group={selectedConditions} {value} /><span class="checkmark" aria-hidden="true">✓</span><span>{value}</span></label>{/each}</div></fieldset>
<fieldset><legend>획득 카드 등급 <span class="filter-status">{selectedRarities.length ? `${selectedRarities.length}개 선택` : '전체'}</span></legend><div class="filter-options">{#each rarityOptions as value}<label class:checked={selectedRarities.includes(value)}><input type="checkbox" bind:group={selectedRarities} {value} /><span class="checkmark" aria-hidden="true">✓</span><span>{value}</span></label>{/each}</div></fieldset>
<fieldset><legend>획득 카드 이격 <span class="filter-status">{selectedVariants.length ? `${selectedVariants.length}개 선택` : '전체'}</span></legend><div class="filter-options">{#each variantOptions as value}<label class:checked={selectedVariants.includes(value)}><input type="checkbox" bind:group={selectedVariants} {value} /><span class="checkmark" aria-hidden="true">✓</span><span>{value}</span></label>{/each}</div></fieldset>
<fieldset class="generation-filter"><legend>조건 기수 <span class="filter-status">{selectedConditionGenerations.length ? `${selectedConditionGenerations.length}개 선택` : '전체'}</span></legend><div class="filter-options">{#each conditionGenerationOptions as value}<label class:checked={selectedConditionGenerations.includes(value)}><input type="checkbox" bind:group={selectedConditionGenerations} {value} /><span class="checkmark" aria-hidden="true">✓</span><span>{value}</span></label>{/each}</div></fieldset>
<fieldset class="generation-filter"><legend>멤버 기수 <span class="filter-status">{selectedMemberGenerations.length ? `${selectedMemberGenerations.length}개 선택` : '전체'}</span></legend><div class="filter-options">{#each memberGenerationOptions as value}<label class:checked={selectedMemberGenerations.includes(value)}><input type="checkbox" bind:group={selectedMemberGenerations} {value} /><span class="checkmark" aria-hidden="true">✓</span><span>{value}</span></label>{/each}</div></fieldset>
</div>
</section>
<div class="list-toolbar"><p>수치는 조건 충족 시 기준입니다. 행을 눌러 여러 의상의 전체 설명을 함께 확인할 수 있습니다.</p><button type="button" class="filter-action" disabled={!expandedIds.length} onclick={() => expandedIds = []}>상세 모두 접기</button></div>
<p class="scroll-hint">목록을 좌우로 밀어 모든 항목을 확인할 수 있습니다.</p>
<section class="table-panel" aria-label="의상 효과 목록">
<div class="list-header"><span>멤버</span><span>기수</span><span>의상명</span><span>획득 카드</span><span>발동 조건</span><span>대상</span><span>강화 효과</span><button type="button" class:active={sortKey === 'parameter'} aria-label={sortLabel('parameter', '스탯 증가율', sortKey, sortDirection)} onclick={() => toggleSort('parameter')}><span>스탯<br />증가율</span><span aria-hidden="true">{sortKey === 'parameter' ? sortDirection === 'asc' ? '↑' : '↓' : '↕'}</span></button><button type="button" class:active={sortKey === 'support'} aria-label={sortLabel('support', '스코어 서포트 수치', sortKey, sortDirection)} onclick={() => toggleSort('support')}><span>스코어<br />서포트</span><span aria-hidden="true">{sortKey === 'support' ? sortDirection === 'asc' ? '↑' : '↓' : '↕'}</span></button><span></span></div>
{#if !filteredOutfits.length}<p class="empty" role="status">필터 조건에 맞는 의상이 없습니다.</p>{/if}
{#each filteredOutfits as outfit (outfit.card.id)}
{@const expanded = expandedIds.includes(outfit.card.id)}
<article class="outfit-entry" class:expanded id={'outfit-' + outfit.card.id}>
<div class="outfit-summary">
<button class="expand-button" type="button" aria-expanded={expanded} aria-controls={'outfit-detail-' + outfit.card.id} aria-label={`${outfit.card.name} ${outfit.name} 상세 정보 ${expanded ? '접기' : '펼치기'}`} onclick={() => toggleOutfit(outfit.card.id)}></button>
<span class="cell name">{outfit.card.name}</span>
<span class="cell generation">{generationLabel(outfit.card.generation)}</span>
<span class="cell outfit-name">{outfit.name}</span>
<span class="cell stack"><strong>★{outfit.card.rarity}</strong><span class="variant">{outfit.card.versionName || '일반'}</span></span>
<span class="cell stack condition">{#if !outfit.complete}<span>확인 필요</span>{:else if !outfit.conditions.length}<span class="muted">없음</span>{:else}{#each outfit.conditions as condition}<span>{shortCondition(condition)}</span>{/each}{/if}</span>
<span class="cell">{outfit.complete ? [...new Set(outfit.effects.map(effect => effect.target))].join(' · ') : '—'}</span>
<span class="cell stack">{#each [...new Set(outfit.effects.map(effect => effect.kind))] as kind}{#if statIcons[kind]}<span class="stat-kind"><span class={'stat-icon ' + statIcons[kind]} aria-hidden="true"></span><span>{kind}</span></span>{:else}<span class="effect-kind">{kind === '전체 파라미터' ? '전체' : kind === '스코어 서포트' ? '서포트' : kind}</span>{/if}{/each}</span>
<span class="cell numeric">{valueLabel(outfit, 'parameter')}</span><span class="cell numeric">{valueLabel(outfit, 'support')}</span>
<span class="chevron" aria-hidden="true"></span>
</div>
{#if expanded}
<div class="outfit-detail" id={'outfit-detail-' + outfit.card.id} aria-label={outfit.card.name + ' ' + outfit.name + ' 상세 정보'}>
<p class="full-description">{outfit.description}</p>
{#if outfit.complete}<div class="effect-details">{#each outfit.effects as effect}<dl><div><dt>조건</dt><dd>{effect.condition || '없음'}</dd></div><div><dt>대상</dt><dd>{effect.target}</dd></div><div><dt>효과</dt><dd>{effect.kind} <strong>{effect.value}%</strong></dd></div></dl>{/each}</div>{/if}
</div>
{/if}
</article>
{/each}
</section>
</main>
<style>
:global(*) { box-sizing: border-box; }
.table-panel { --columns: minmax(114px, 1.4fr) minmax(60px, .85fr) minmax(120px, 1.5fr) minmax(58px, .7fr) minmax(120px, 1.5fr) minmax(40px, .5fr) minmax(72px, .95fr) minmax(76px, .9fr) minmax(88px, 1fr) 12px; }
.list-header, .outfit-summary { display: grid; grid-template-columns: var(--columns); column-gap: 6px; align-items: center; padding: 10px 12px; min-height: 56px; text-align: center; }
.list-header, .outfit-entry { min-width: 940px; }
.list-header { color: #8993a5; background: #131923; font-size: 12px; font-weight: 750; }
.list-header > span:first-child, .list-header > span:nth-child(3) { text-align: left; }
.list-header button { display: flex; justify-content: center; align-items: center; gap: 3px; min-width: 0; padding: 5px 0; border: 0; border-radius: 7px; color: inherit; background: transparent; font: inherit; cursor: pointer; }
.list-header button:hover, .list-header button.active { color: #f08db7; background: #2a1b26; }
.outfit-entry { border-top: 1px solid #282f3d; scroll-margin-top: 80px; }
.outfit-summary { position: relative; font-size: 14px; }
.expand-button { position: absolute; inset: 0; z-index: 1; width: 100%; border: 0; background: transparent; cursor: pointer; }
.outfit-summary:hover, .outfit-summary:has(.expand-button:focus-visible) { background: #1d2431; }
.outfit-summary:has(.expand-button:focus-visible) { outline: 2px solid #8ba7d0; outline-offset: -2px; }
.cell, .chevron { position: relative; z-index: 2; min-width: 0; pointer-events: none; }
.name, .outfit-name { text-align: left; overflow-wrap: anywhere; font-weight: 750; line-height: 1.4; }
.generation { color: #aab3c3; word-break: keep-all; line-height: 1.4; font-size: 12px; }
.stack { display: grid; justify-items: center; gap: 3px; }
.condition { word-break: keep-all; line-height: 1.5; font-size: 12px; }
.variant { color: #9fb2ca; font-size: 11px; }
.numeric { color: #dbe1ec; font-weight: 800; font-variant-numeric: tabular-nums; }
.muted { color: #7d879a; }
.effect-kind { padding: 2px 5px; border-radius: 5px; color: #c5d0e0; background: #263044; font-size: 11px; }
.stat-kind { display: inline-flex; align-items: center; gap: 4px; color: #c5d0e0; font-size: 12px; font-weight: 700; white-space: nowrap; }
.stat-kind .stat-icon { flex-shrink: 0; }
.stat-icon { display: block; width: 20px; height: 20px; background: currentcolor; mask: center / contain no-repeat; }
.performance { color: #d95368; mask-image: url('/icons/stats/performance.webp'); }
.technique { color: #416fd1; mask-image: url('/icons/stats/technique.webp'); }
.sense { color: #8057c7; mask-image: url('/icons/stats/sense.webp'); }
.expanded { background: #191f2a; }
.outfit-detail { position: sticky; left: 0; width: 100cqw; padding: 12px; border-top: 1px solid #303746; background: #161e2b; }
.full-description { margin: 0; color: #d0d8e6; font-size: 13px; line-height: 1.7; }
.effect-details { display: grid; gap: 7px; margin-top: 10px; }
dl { display: grid; grid-template-columns: minmax(0, 1.5fr) 70px minmax(0, 1fr); gap: 12px; margin: 0; padding-top: 7px; border-top: 1px solid #303746; }
dl div { display: flex; gap: 7px; font-size: 12px; line-height: 1.6; }
dt { color: #8997ac; flex-shrink: 0; }
dd { margin: 0; color: #c8d2e2; overflow-wrap: anywhere; }
dd strong { color: #f1a1c3; }
.empty { margin: 0; padding: 50px 16px; color: #969fb0; text-align: center; }
button:focus-visible, input:focus-visible { outline: 2px solid #ed8bb5; outline-offset: 3px; }
@media (max-width: 680px) {
.outfit-detail { padding: 10px; }
dl { grid-template-columns: 1fr; gap: 3px; }
}
</style>