|
|
|
@@ -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>
|