feat: release version 1.0.4

This commit is contained in:
dal4segno committed 2026-08-19 18:56:00 +09:00
1 parent bd9d5a6ba6
commit f910db8901
11 files changed
+506 -105

No files matched your search

+249 -73
View File
@@ -1,43 +1,13 @@
<script lang="ts">
import { onMount } from 'svelte';
import cardSummary from '$lib/data/cards-summary.json';
import type { CardListPreferences, UserData } from '$lib/models/user-data';
import { skillAtLevel, sortCardsByActive, type CardSummary, type CardType, type HighestStat, type SkillLevel } from '$lib/models/card-list';
import type { CardListPreferences, CardListSortKey, CardSkillLevel, SortDirection, UserData } from '$lib/models/user-data';
import { loadBrowserData, saveBrowserData } from '$lib/storage/browser-storage';
type CardType = '큐트' | '퓨어' | '해피';
type HighestStat = '퍼포먼스' | '테크닉' | '센스';
type SkillConditionStatus = '일반' | '조건부';
type ActiveProbability = '높음' | '중간';
type SkillLevel = {
level: number;
description: string;
coolTimeSeconds: number | null;
probabilityPercent: number | null;
durationSeconds: number | null;
effect?: string;
condition?: string;
};
type CardDetails = {
title: string;
passive: SkillLevel[];
active: SkillLevel[];
special: SkillLevel[];
costume: { name: string; description: string } | null;
};
type CardSummary = {
id: string;
rarity: number;
type: CardType;
name: string;
versionName: string;
generation: string;
highestStat: HighestStat;
details: CardDetails;
};
const allCards = cardSummary.cards as CardSummary[];
const cards = allCards.filter((card) => card.rarity === 5);
const typeOptions: CardType[] = ['큐트', '퓨어', '해피'];
@@ -59,8 +29,10 @@
let selectedActiveProbabilities = [...activeProbabilityOptions];
let selectedCardVariants = [...cardVariantOptions];
let selectedGenerations = [...generationOptions];
let sortKey: CardListSortKey | null = null;
let sortDirection: SortDirection = 'asc';
let expandedCardId: string | null = null;
let selectedSkillLevel = 1;
let selectedSkillLevel: CardSkillLevel = 1;
let collection: UserData['collection'] = {};
let memberBoards: UserData['memberBoards'] = {};
let boardCards: UserData['boardCards'] = {};
@@ -95,14 +67,19 @@
} catch {}
});
$: filteredCards = cards.filter((card) =>
selectedTypes.includes(card.type)
&& selectedStats.includes(card.highestStat)
&& selectedActiveConditions.includes(skillConditionStatus(card.details.active))
&& selectedSpecialConditions.includes(skillConditionStatus(card.details.special))
&& selectedActiveProbabilities.includes(activeProbabilityStatus(card.details.active))
&& selectedCardVariants.includes(cardVariantStatus(card))
&& generationMemberships(card.generation).some((generation) => selectedGenerations.includes(generation))
$: filteredCards = sortCardsByActive(
cards.filter((card) =>
selectedTypes.includes(card.type)
&& selectedStats.includes(card.highestStat)
&& matchesSkillCondition(selectedActiveConditions, card.details.active ?? [])
&& selectedSpecialConditions.includes(skillConditionStatus(card.details.special))
&& matchesActiveProbability(card.details.active ?? [])
&& selectedCardVariants.includes(cardVariantStatus(card))
&& generationMemberships(card.generation).some((generation) => selectedGenerations.includes(generation))
),
sortKey,
sortDirection,
selectedSkillLevel
);
$: allSelected = selectedTypes.length === typeOptions.length
@@ -113,6 +90,14 @@
&& selectedCardVariants.length === cardVariantOptions.length
&& selectedGenerations.length === generationOptions.length;
$: allDeselected = selectedTypes.length === 0
&& selectedStats.length === 0
&& selectedActiveConditions.length === 0
&& selectedSpecialConditions.length === 0
&& selectedActiveProbabilities.length === 0
&& selectedCardVariants.length === 0
&& selectedGenerations.length === 0;
$: if (expandedCardId && !filteredCards.some((card) => card.id === expandedCardId)) {
expandedCardId = null;
}
@@ -127,6 +112,16 @@
selectedGenerations = [...generationOptions];
}
function selectNone() {
selectedTypes = [];
selectedStats = [];
selectedActiveConditions = [];
selectedSpecialConditions = [];
selectedActiveProbabilities = [];
selectedCardVariants = [];
selectedGenerations = [];
}
function defaultPreferences(): CardListPreferences {
return {
types: [...typeOptions],
@@ -135,7 +130,10 @@
specialConditions: [...skillConditionOptions],
activeProbabilities: [...activeProbabilityOptions],
cardVariants: [...cardVariantOptions],
generations: [...generationOptions]
generations: [...generationOptions],
sortKey: null,
sortDirection: 'asc',
skillLevel: 1
};
}
@@ -147,7 +145,10 @@
specialConditions: [...selectedSpecialConditions],
activeProbabilities: [...selectedActiveProbabilities],
cardVariants: [...selectedCardVariants],
generations: [...selectedGenerations]
generations: [...selectedGenerations],
sortKey,
sortDirection,
skillLevel: selectedSkillLevel
};
}
@@ -160,6 +161,9 @@
selectedCardVariants = data.preferences.cardVariants.filter((value) => cardVariantOptions.includes(value));
selectedGenerations = [...new Set(data.preferences.generations.flatMap(generationMemberships))]
.filter((value) => generationOptions.includes(value));
sortKey = data.preferences.sortKey;
sortDirection = data.preferences.sortDirection;
selectedSkillLevel = data.preferences.skillLevel;
collection = data.collection;
memberBoards = data.memberBoards;
boardCards = data.boardCards;
@@ -189,23 +193,55 @@
return levels.some((level) => Boolean(level.condition)) ? '조건부' : '일반';
}
function activeProbabilityStatus(levels: SkillLevel[]): ActiveProbability {
function matchesSkillCondition(selected: SkillConditionStatus[], levels: SkillLevel[]): boolean {
return levels.length === 0
? selected.length === skillConditionOptions.length
: selected.includes(skillConditionStatus(levels));
}
function activeProbabilityStatus(levels: SkillLevel[]): ActiveProbability | null {
if (levels.length === 0) return null;
return levels.some((level) => level.probabilityPercent === 55) ? '높음' : '중간';
}
function matchesActiveProbability(levels: SkillLevel[]): boolean {
const status = activeProbabilityStatus(levels);
return status === null
? selectedActiveProbabilities.length === activeProbabilityOptions.length
: selectedActiveProbabilities.includes(status);
}
function toggleCard(cardId: string) {
expandedCardId = expandedCardId === cardId ? null : cardId;
selectedSkillLevel = 1;
}
function skillAtLevel(levels: SkillLevel[], selectedLevel: number) {
return levels.find((level) => level.level === selectedLevel) ?? levels.at(-1);
function toggleSort(nextSortKey: CardListSortKey) {
if (sortKey === nextSortKey) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
return;
}
sortKey = nextSortKey;
sortDirection = 'asc';
}
function probabilityLabel(probabilityPercent: number | null) {
function sortButtonLabel(key: CardListSortKey, label: string) {
if (sortKey !== key) return `${label} 오름차순 정렬`;
return `${label} ${sortDirection === 'asc' ? '내림차순' : '오름차순'}으로 변경`;
}
function secondsLabel(seconds: number | null | undefined) {
return seconds == null ? '—' : `${seconds}초`;
}
function probabilityLabel(probabilityPercent: number | null | undefined) {
if (probabilityPercent === 55) return '높음';
if (probabilityPercent === 46) return '중간';
return probabilityPercent === null ? '—' : `${probabilityPercent}%`;
return probabilityPercent == null ? '—' : `${probabilityPercent}%`;
}
function activeEffectLabel(active: SkillLevel | undefined) {
return active?.effect || active?.description || '—';
}
function descriptionSegments(description: string, highlightNumbers = false) {
@@ -287,7 +323,10 @@
<section class="filters" aria-labelledby="filter-heading">
<div class="filter-header">
<h2 id="filter-heading">출력 조건</h2>
<button class="select-all" type="button" onclick={selectAll} disabled={allSelected}>모두 선택</button>
<div class="filter-actions">
<button class="filter-action" type="button" onclick={selectNone} disabled={allDeselected}>모두 취소</button>
<button class="filter-action" type="button" onclick={selectAll} disabled={allSelected}>모두 선택</button>
</div>
</div>
<div class="filter-groups">
@@ -392,12 +431,30 @@
</section>
<section class="table-panel" aria-label="카드 목록">
<div class="list-header" aria-hidden="true">
<div class="list-header">
<span>타입</span>
<span>주스탯</span>
<span>기수</span>
<span>이름</span>
<span></span>
<button class="sort-button" type="button" class:active={sortKey === 'coolTime'} aria-label={sortButtonLabel('coolTime', '주기')} onclick={() => toggleSort('coolTime')}>
<span>주기</span><span class="sort-indicator" aria-hidden="true">{sortKey === 'coolTime' ? (sortDirection === 'asc' ? '↑' : '↓') : '↕'}</span>
</button>
<button class="sort-button" type="button" class:active={sortKey === 'probability'} aria-label={sortButtonLabel('probability', '확률')} onclick={() => toggleSort('probability')}>
<span>확률</span><span class="sort-indicator" aria-hidden="true">{sortKey === 'probability' ? (sortDirection === 'asc' ? '↑' : '↓') : '↕'}</span>
</button>
<button class="sort-button" type="button" class:active={sortKey === 'duration'} aria-label={sortButtonLabel('duration', '지속')} onclick={() => toggleSort('duration')}>
<span>지속</span><span class="sort-indicator" aria-hidden="true">{sortKey === 'duration' ? (sortDirection === 'asc' ? '↑' : '↓') : '↕'}</span>
</button>
<button class="sort-button" type="button" class:active={sortKey === 'effect'} aria-label={sortButtonLabel('effect', '효과')} onclick={() => toggleSort('effect')}>
<span>효과</span><span class="sort-indicator" aria-hidden="true">{sortKey === 'effect' ? (sortDirection === 'asc' ? '↑' : '↓') : '↕'}</span>
</button>
<div class="header-level-control">
<span>개화</span>
<div class="level-toggle header-level-toggle" aria-label="목록에 표시할 스킬 개화 상태">
<button type="button" class:active={selectedSkillLevel === 1} aria-pressed={selectedSkillLevel === 1} onclick={() => selectedSkillLevel = 1}>명함</button>
<button type="button" class:active={selectedSkillLevel === 2} aria-pressed={selectedSkillLevel === 2} onclick={() => selectedSkillLevel = 2}>5 개화</button>
</div>
</div>
<span></span>
</div>
@@ -448,13 +505,24 @@
{#if card.versionName}<span class="version-badge">{card.versionName}</span>{/if}
</span>
</span>
<span class="summary-cell active-cycle-cell active-summary-cell">
<span class="mobile-label">주기</span>
<span class="active-value">{secondsLabel(active?.coolTimeSeconds)}</span>
</span>
<span class="summary-cell active-probability-cell active-summary-cell">
<span class="mobile-label">확률</span>
<span class="active-value">{probabilityLabel(active?.probabilityPercent)}</span>
</span>
<span class="summary-cell active-duration-cell active-summary-cell">
<span class="mobile-label">지속</span>
<span class="active-value">{secondsLabel(active?.durationSeconds)}</span>
</span>
<span class="summary-cell active-effect-cell active-summary-cell">
<span class="mobile-label">효과</span>
<span class="active-effect" title={activeEffectLabel(active)}>{activeEffectLabel(active)}</span>
</span>
<span class="summary-cell level-cell">
{#if expanded}
<div class="level-toggle inline-level-toggle" aria-label="스킬 개화 상태">
<button type="button" class:active={selectedSkillLevel === 1} aria-pressed={selectedSkillLevel === 1} onclick={() => selectedSkillLevel = 1}>명함</button>
<button type="button" class:active={selectedSkillLevel === 2} aria-pressed={selectedSkillLevel === 2} onclick={() => selectedSkillLevel = 2}>5 개화</button>
</div>
{/if}
<span class="skill-level-label">{selectedSkillLevel === 1 ? '명함' : '5 개화'}</span>
</span>
<span class="chevron" aria-hidden="true"></span>
</div>
@@ -704,7 +772,12 @@
letter-spacing: -0.025em;
}
.select-all {
.filter-actions {
display: flex;
gap: 8px;
}
.filter-action {
padding: 7px 12px;
border: 1px solid #dce1ea;
border-radius: 9px;
@@ -717,13 +790,13 @@
transition: border-color 150ms ease, color 150ms ease, background 150ms ease;
}
.select-all:hover:not(:disabled) {
.filter-action:hover:not(:disabled) {
border-color: #ef8bb5;
color: #d83f82;
background: #fff8fb;
}
.select-all:disabled {
.filter-action:disabled {
opacity: 0.42;
cursor: default;
}
@@ -831,7 +904,8 @@
}
.table-panel {
overflow: hidden;
overflow-x: auto;
overflow-y: hidden;
border: 1px solid #e3e7ef;
border-radius: 22px;
background: rgb(255 255 255 / 90%);
@@ -842,11 +916,12 @@
.list-header,
.card-summary {
display: grid;
grid-template-columns: clamp(72px, 5vw, 88px) clamp(84px, 6vw, 104px) clamp(150px, 11vw, 184px) minmax(280px, 1fr) clamp(116px, 9vw, 144px) 40px;
grid-template-columns: 62px 72px 110px minmax(160px, 1fr) 66px 66px 66px minmax(160px, 1.15fr) 190px 32px;
align-items: center;
}
.list-header {
min-width: 1260px;
min-height: 54px;
padding: 0 24px;
border-bottom: 1px solid #e8ebf1;
@@ -856,7 +931,44 @@
font-weight: 700;
}
.list-header .sort-button {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 5px;
width: fit-content;
padding: 6px 7px;
border: 0;
border-radius: 7px;
color: inherit;
background: transparent;
font: inherit;
cursor: pointer;
transition: color 150ms ease, background 150ms ease;
}
.list-header .sort-button:hover,
.list-header .sort-button.active {
color: #d83f82;
background: #fff0f7;
}
.list-header .sort-button:focus-visible {
outline: 3px solid rgb(73 134 231 / 25%);
outline-offset: 1px;
}
.sort-indicator {
color: #a4adbb;
font-size: 12px;
}
.list-header .sort-button.active .sort-indicator {
color: currentcolor;
}
.card-entry {
min-width: 1260px;
border-bottom: 1px solid #edf0f5;
}
@@ -915,11 +1027,49 @@
gap: 10px;
}
.active-summary-cell {
padding-right: 8px;
}
.active-value,
.active-effect {
color: #4d586d;
font-size: 13px;
font-weight: 750;
font-variant-numeric: tabular-nums;
}
.active-effect {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.level-cell {
display: flex;
justify-content: flex-end;
}
.header-level-control {
display: flex;
align-items: center;
gap: 9px;
}
.header-level-toggle {
flex: 0 0 auto;
}
.skill-level-label {
padding: 4px 9px;
border-radius: 999px;
color: #b43d72;
background: #fff0f7;
font-size: 12px;
font-weight: 800;
}
.mobile-label {
display: none;
}
@@ -958,12 +1108,6 @@
background: #fff;
}
.inline-level-toggle {
position: relative;
z-index: 3;
pointer-events: auto;
}
.level-toggle button {
min-width: 50px;
padding: 3px 7px;
@@ -1395,13 +1539,13 @@
box-shadow: 0 14px 42px rgb(0 0 0 / 24%);
}
.select-all {
.filter-action {
border-color: #343c4b;
color: #b5bdcb;
background: #1d2430;
}
.select-all:hover:not(:disabled) {
.filter-action:hover:not(:disabled) {
border-color: #c85b8b;
color: #f08db7;
background: #2a1c27;
@@ -1442,6 +1586,22 @@
background: #131923;
}
.list-header .sort-button:hover,
.list-header .sort-button.active {
color: #f08db7;
background: #2a1b26;
}
.active-value,
.active-effect {
color: #c2cad8;
}
.skill-level-label {
color: #f08db7;
background: #2a1b26;
}
.card-entry {
border-color: #282f3d;
}
@@ -1574,7 +1734,7 @@
@media (max-width: 1100px) {
.list-header,
.card-summary {
grid-template-columns: 72px 84px 150px minmax(0, 1fr) 116px 36px;
grid-template-columns: 62px 72px 110px minmax(160px, 1fr) 66px 66px 66px minmax(160px, 1.15fr) 190px 32px;
}
}
@@ -1625,12 +1785,14 @@
}
.card-entry {
min-width: 0;
padding: 0;
}
.card-summary {
display: grid;
grid-template-columns: 72px 84px minmax(0, 1fr);
grid-template-columns: repeat(3, minmax(0, 1fr));
min-width: 0;
gap: 14px 12px;
padding: 19px 0;
padding-right: 32px;
@@ -1661,6 +1823,20 @@
grid-column: 1 / -1;
}
.active-summary-cell {
padding-right: 0;
}
.active-effect-cell {
grid-column: 1 / -1;
}
.active-effect {
overflow: visible;
text-overflow: clip;
white-space: normal;
}
.level-cell {
display: flex;
justify-content: flex-end;