feat: add ordered song list tool
Publish GitHub Pages output / publish (push) Canceled after 36s

This commit is contained in:
2026-10-06 11:34:27 +09:00
parent e5b34a03e6
commit 04c71be859
7 changed files with 300 additions and 4 deletions
+7 -1
View File
@@ -1,7 +1,13 @@
---
updatedAt: 2026-10-04T15:00:00.000Z
updatedAt: 2026-10-06T02:32:20.545Z
---
## 2026년 10월 6일
### 선택 순서대로 악곡 목록 만들기
`NEW` 도구에 악곡 목록을 추가했습니다. 악곡을 검색하고 난이도를 골라 선택한 순서대로 목록을 만들 수 있습니다. 같은 악곡·난이도를 여러 번 추가하거나 개별 삭제·목록 초기화를 할 수 있으며, 번호·곡명·난이도·레벨을 목록 순서대로 복사할 수 있습니다. 목록은 브라우저에 자동 저장되어 새로고침하거나 다시 방문해도 선택 순서와 난이도가 유지됩니다.
## 2026년 10월 5일
### KONKON Beats 악곡 추가
+2
View File
@@ -11,6 +11,7 @@
import HoloWorkPage from '../src/routes/tools/holo-work/+page.svelte';
import BoardAwakeningPage from '../src/routes/tools/board-awakening/+page.svelte';
import ToolsPage from '../src/routes/tools/+page.svelte';
import SongListPage from '../src/routes/tools/song-list/+page.svelte';
import ComparePage from '../src/routes/compare/+page.svelte';
import EventPage from '../src/routes/event/+page.svelte';
import DataPage from '../src/routes/data/+page.svelte';
@@ -31,6 +32,7 @@
['/tools/ai', AiPage],
['/compare', ComparePage],
['/tools/random', ToolsPage],
['/tools/song-list', SongListPage],
['/tools/holo-work', HoloWorkPage],
['/tools/board-awakening', BoardAwakeningPage],
['/random', ToolsPage],
+6 -1
View File
@@ -1,9 +1,10 @@
<script lang="ts">
export let active: 'random' | 'holo-work' | 'board-awakening' | 'ai';
export let active: 'random' | 'song-list' | 'holo-work' | 'board-awakening' | 'ai';
</script>
<nav aria-label="도구 메뉴">
<a href="/tools/random" aria-current={active === 'random' ? 'page' : undefined}>악곡 랜덤</a>
<a href="/tools/song-list" aria-current={active === 'song-list' ? 'page' : undefined}>악곡 목록</a>
<a href="/tools/holo-work" aria-current={active === 'holo-work' ? 'page' : undefined}>홀로워크 관리</a>
<a href="/tools/board-awakening" aria-current={active === 'board-awakening' ? 'page' : undefined}>연결노드 개화 추천</a>
<a href="/tools/ai" aria-current={active === 'ai' ? 'page' : undefined}>AI 연결</a>
@@ -15,4 +16,8 @@
a:hover { color: #dfe4eb; background: rgb(255 255 255 / 4%); }
a[aria-current='page'] { color: #f3a5c5; background: rgb(239 139 182 / 12%); }
a:focus-visible { outline: 3px solid #ed8bb5; outline-offset: 3px; }
@media (max-width: 520px) {
nav { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
a:last-child { grid-column: 1 / -1; }
}
</style>
+29
View File
@@ -0,0 +1,29 @@
import { isDifficulty } from '../data/difficulty.ts';
import type { SongChartSummary } from '../data/song-catalog.ts';
export const SONG_LIST_STORAGE_KEY = 'holodori.song-list.v1';
export function serializeSongList(charts: readonly SongChartSummary[]): string {
return JSON.stringify({
schemaVersion: 1,
songs: charts.map(({ songId, difficulty }) => ({ songId, difficulty }))
});
}
export function parseSavedSongList<T extends SongChartSummary>(text: string, catalog: readonly T[]): { charts: T[]; unavailableCount: number } {
const value = JSON.parse(text);
if (!value || value.schemaVersion !== 1 || !Array.isArray(value.songs)) {
throw new Error('올바른 악곡 목록이 아닙니다.');
}
const charts: T[] = [];
let unavailableCount = 0;
for (const song of value.songs) {
if (!song || typeof song.songId !== 'string' || !isDifficulty(song.difficulty)) {
throw new Error('올바른 악곡·난이도가 아닙니다.');
}
const chart = catalog.find((chart) => chart.songId === song.songId && chart.difficulty === song.difficulty);
if (chart) charts.push(chart);
else unavailableCount++;
}
return { charts, unavailableCount };
}
+1 -1
View File
@@ -48,7 +48,7 @@
</a>
<a class="feature-card" href="/tools">
<span class="feature-number">06</span>
<div><h3>도구</h3><p>악곡과 난이도를 랜덤으로 뽑거나, 홀로워크에 보낸 횟수를 멤버별로 기록하고 확인합니다.</p></div>
<div><h3>도구</h3><p>악곡을 랜덤으로 뽑거나 고른 순서대로 목록을 만들고, 홀로워크에 보낸 횟수를 멤버별로 기록합니다.</p></div>
<span class="arrow" aria-hidden="true">→</span>
</a>
</div>
+252
View File
@@ -0,0 +1,252 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { page } from '$app/stores';
import ToolsNav from '$lib/components/ToolsNav.svelte';
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
import { difficulties, difficultyLabel } from '$lib/data/difficulty.ts';
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { songOptions } from '$lib/data/song-catalog.ts';
import { releasedSongCharts } from '$lib/data/song-release.ts';
import { formatRandomSongResults } from '$lib/engine/random-song.ts';
import { SONG_LIST_STORAGE_KEY, parseSavedSongList, serializeSongList } from '$lib/storage/song-list.ts';
type Chart = (typeof exactChartCatalog.charts)[number];
let releaseClock = createInitialReleaseClock($page.data?.serverNow);
let releaseNow = releaseClock.now();
let query = '';
let difficulty: string = 'expert';
let selections: { id: number; chart: Chart }[] = [];
let nextId = 0;
let announcement = '';
let copying = false;
let copyStatus = '';
let showCopyText = false;
let copyTextArea: HTMLTextAreaElement | undefined;
let storageNotice = '';
$: charts = releasedSongCharts(exactChartCatalog.charts, releaseNow);
$: difficultyCharts = charts.filter((chart) => chart.difficulty === difficulty);
$: options = songOptions(difficultyCharts, difficulty, query);
$: resultsText = formatRandomSongResults(selections.map(({ chart }) => chart));
function resetCopyStatus() {
copyStatus = '';
showCopyText = false;
}
function saveSongs() {
try {
if (selections.length) {
localStorage.setItem(SONG_LIST_STORAGE_KEY, serializeSongList(selections.map(({ chart }) => chart)));
} else {
localStorage.removeItem(SONG_LIST_STORAGE_KEY);
}
storageNotice = '';
} catch {
storageNotice = '목록을 브라우저에 저장하지 못했습니다. 페이지를 떠나기 전에 목록을 복사해 주세요.';
}
}
function addSong(songId: string) {
const chart = difficultyCharts.find((chart) => chart.songId === songId);
if (!chart) return;
selections = [...selections, { id: nextId++, chart }];
saveSongs();
resetCopyStatus();
announcement = `${selections.length}번째에 ${chart.title}, ${difficultyLabel(chart.difficulty)}를 추가했습니다.`;
}
function removeSong(id: number) {
const removed = selections.find((entry) => entry.id === id);
selections = selections.filter((entry) => entry.id !== id);
saveSongs();
resetCopyStatus();
announcement = `${removed?.chart.title ?? '악곡'}을 삭제했습니다. ${selections.length}개가 남았습니다.`;
}
function clearSongs() {
selections = [];
saveSongs();
resetCopyStatus();
announcement = '악곡 목록을 초기화했습니다.';
}
async function copySongs() {
if (!selections.length || copying) return;
const snapshot = selections;
const text = resultsText;
copying = true;
resetCopyStatus();
try {
if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable');
await navigator.clipboard.writeText(text);
if (selections === snapshot) copyStatus = `${snapshot.length}개 악곡을 선택 순서대로 복사했습니다.`;
} catch {
if (selections !== snapshot) return;
copyStatus = '자동 복사를 사용할 수 없습니다. 아래 텍스트를 선택해 복사해 주세요.';
showCopyText = true;
await tick();
copyTextArea?.focus();
copyTextArea?.select();
} finally {
copying = false;
}
}
onMount(() => {
try {
const text = localStorage.getItem(SONG_LIST_STORAGE_KEY);
if (text) {
const saved = parseSavedSongList(text, exactChartCatalog.charts);
selections = saved.charts.map((chart) => ({ id: nextId++, chart }));
if (saved.unavailableCount) storageNotice = `현재 악곡 데이터에서 찾을 수 없는 ${saved.unavailableCount}개를 제외하고 목록을 복원했습니다.`;
}
} catch {
storageNotice = '저장된 악곡 목록을 불러오지 못했습니다. 브라우저 저장소 설정을 확인해 주세요.';
}
let mounted = true;
void synchronizeReleaseClock().then((clock) => {
if (!mounted) return;
releaseClock = clock;
releaseNow = clock.now();
});
const timer = window.setInterval(() => releaseNow = releaseClock.now(), 1_000);
return () => { mounted = false; window.clearInterval(timer); };
});
</script>
<svelte:head>
<title>악곡 목록 · 도구 · 홀로도리 계산기</title>
<meta name="description" content="악곡과 난이도를 고른 순서대로 목록을 만들고 복사하세요." />
</svelte:head>
<main>
<header>
<h1>도구</h1>
<p>플레이할 때 유용한 도구를 모았습니다.</p>
</header>
<ToolsNav active="song-list" />
<div class="tool-heading">
<h2>악곡 목록</h2>
<p>플레이한 악곡을 고르면 선택한 순서대로 목록에 추가됩니다.</p>
</div>
<section class="panel" aria-label="악곡 선택">
<div class="filters">
<label>악곡 검색
<input type="search" bind:value={query} placeholder="곡명 검색" autocomplete="off">
</label>
<label>난이도
<select bind:value={difficulty}>
{#each difficulties as item}<option value={item}>{difficultyLabel(item)}</option>{/each}
</select>
</label>
</div>
{#if options.length}
<ul class="song-options" aria-label="추가할 악곡">
{#each options as song (song.id)}
<li>
<button class="song-option" type="button" on:click={() => addSong(song.id)} aria-label={`${song.title}, ${difficultyLabel(difficulty)} 목록에 추가`}>
<strong class="song-title">{song.title}</strong>
<span class="level">{song.difficultyLevel == null ? '레벨 정보 없음' : `Lv. ${song.difficultyLevel}`}</span>
<span class="add-label" aria-hidden="true">+ 추가</span>
</button>
</li>
{/each}
</ul>
{:else}
<p class="placeholder" role="status">검색 결과가 없습니다. 검색어나 난이도를 변경해 주세요.</p>
{/if}
</section>
<p class="sr-only" role="status" aria-atomic="true">{announcement}</p>
<section class="result" aria-labelledby="results-title">
<div class="results-heading">
<h2 id="results-title">선택한 악곡 <span>{selections.length}개</span></h2>
<div class="result-actions">
<button class="secondary-button" type="button" on:click={copySongs} disabled={selections.length === 0 || copying}>{copying ? '복사 중…' : '목록 복사'}</button>
<button class="secondary-button" type="button" on:click={clearSongs} disabled={selections.length === 0}>목록 초기화</button>
</div>
</div>
{#if storageNotice}<p class="storage-notice" role="status">{storageNotice}</p>{/if}
<p class="copy-status" role="status">{copyStatus}</p>
{#if showCopyText}
<label class="copy-text">복사용 텍스트
<textarea bind:this={copyTextArea} value={resultsText} readonly rows={Math.min(selections.length + 1, 8)} on:focus={(event) => event.currentTarget.select()}></textarea>
</label>
{/if}
{#if selections.length}
<ol class="results-list" aria-label="악곡 선택 순서">
{#each selections as entry, index (entry.id)}
<li>
<span class="selection-number" aria-hidden="true">{index + 1}</span>
<strong class="song-title selected-title">{entry.chart.title}</strong>
<span class="selected-difficulty">{difficultyLabel(entry.chart.difficulty)}</span>
<span class="selected-level">{entry.chart.difficultyLevel == null ? '레벨 정보 없음' : `Lv. ${entry.chart.difficultyLevel}`}</span>
<button class="secondary-button remove-button" type="button" on:click={() => removeSong(entry.id)} aria-label={`${index + 1}번째 ${entry.chart.title} 삭제`}>삭제</button>
</li>
{/each}
</ol>
{:else}
<p class="placeholder">위에서 악곡을 선택하면 첫 번째 곡부터 차례대로 쌓입니다.</p>
{/if}
</section>
</main>
<style>
main { width: min(760px, calc(100% - var(--page-gutter))); margin: 0 auto; padding: 42px 0 80px; }
h1 { margin: 0 0 14px; font-size: clamp(30px, 5vw, 42px); letter-spacing: -.04em; }
header > p, .tool-heading p { color: #a4adba; line-height: 1.7; }
.tool-heading p { margin-bottom: 0; }
h2 { margin: 0; font-size: 22px; }
h2 span { margin-left: 6px; color: #a4adba; font-size: 14px; font-weight: 500; }
.panel, .result { margin-top: 26px; padding: 26px; border: 1px solid #343c49; border-radius: 16px; background: #151a22; }
.panel { padding: 20px; }
.filters { display: grid; grid-template-columns: minmax(0, 1fr) 140px; gap: 14px; }
label { display: grid; gap: 8px; min-width: 0; color: #a4adba; font-size: 14px; }
input, select, textarea { width: 100%; min-width: 0; padding: 12px; border: 1px solid #414b5b; border-radius: 9px; background: #101319; color: #e8ebf1; font: inherit; }
input, select { height: 44px; }
.placeholder { color: #a4adba; line-height: 1.7; }
.song-options { max-height: 200px; overflow-y: auto; overscroll-behavior: contain; margin: 12px 0 0; padding: 3px; list-style: none; }
.song-options li + li { margin-top: 4px; }
button { min-height: 44px; border: 1px solid #343c49; border-radius: 9px; background: #101319; color: #e8ebf1; font: inherit; cursor: pointer; }
button:hover:not(:disabled) { border-color: #a76b87; background: #302331; }
button:disabled { color: #87909e; cursor: not-allowed; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 3px solid #ed8bb5; outline-offset: 2px; }
.song-option { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 10px; width: 100%; min-height: 36px; padding: 6px 10px; border-radius: 7px; text-align: left; }
.song-title { min-width: 0; overflow-wrap: anywhere; line-height: 1.6; }
.song-option .song-title { font-size: 14px; line-height: 1.35; }
.level { color: #a4adba; font-size: 13px; }
.add-label { color: #f3b4d0; font-size: 13px; font-weight: 800; white-space: nowrap; }
.result { padding: 20px; }
.results-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.result-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.secondary-button { min-height: 36px; padding: 6px 10px; border-color: #414b5b; background: transparent; color: #bec8d6; font-size: 13px; }
.secondary-button:hover:not(:disabled) { border-color: #637083; background: #202733; }
.secondary-button:disabled { border-color: #343c49; }
.copy-status { margin: 12px 0 0; color: #f3b4d0; font-size: 13px; line-height: 1.6; }
.copy-status:empty { margin: 0; }
.storage-notice { color: #f3b4d0; font-size: 13px; line-height: 1.7; }
.copy-text { margin-top: 12px; }
textarea { min-height: 88px; resize: vertical; line-height: 1.7; }
.results-list { margin: 12px 0 0; padding: 0; list-style: none; }
.results-list li { display: grid; grid-template-columns: 24px minmax(0, 1fr) 52px 46px auto; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid #2c3441; }
.results-list li:last-child { border-bottom: 0; }
.selection-number { color: #a4adba; font-size: 13px; font-variant-numeric: tabular-nums; }
.selected-title { font-size: 14px; line-height: 1.4; }
.selected-difficulty, .selected-level { color: #a4adba; font-size: 12px; }
.selected-level { font-variant-numeric: tabular-nums; }
.remove-button { min-height: 32px; padding: 4px 8px; font-size: 12px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 520px) {
.panel, .result { padding: 20px; }
.result { padding: 16px; }
.filters { grid-template-columns: minmax(0, 1fr) 110px; gap: 10px; }
.song-option { gap: 8px; padding-inline: 8px; }
.song-option .song-title { font-size: 13px; }
.level, .add-label { font-size: 11px; }
.results-list li { grid-template-columns: 18px minmax(0, 1fr) 42px 36px auto; gap: 6px; }
.selected-title { font-size: 13px; }
.selected-difficulty, .selected-level { font-size: 11px; }
}
</style>
+3 -1
View File
@@ -8,10 +8,12 @@ export default defineConfig({
port: 5173,
// SvelteKit excludes build output itself, but must watch generated route modules.
// Ignoring all of .svelte-kit leaves cached page imports pointing at old route IDs.
// Plugin verification output contains complete project copies, including tsconfigs.
// Watching them also triggers unrelated TypeScript cache resets and full reloads.
watch: {
usePolling: process.env.WATCH_USE_POLLING === 'true',
interval: 1000,
ignored: ['**/output/**', '**/pages-dist/**', '**/offline-dist/**', '**/release/**']
ignored: ['**/output/**', '**/pages-dist/**', '**/offline-dist/**', '**/plugin-dist/**', '**/release/**']
}
}
});