feat: copy song lists with jackets and simplify song rows
Publish GitHub Pages output / publish (push) Successful in 38s

This commit is contained in:
dal4segno committed 2026-10-07 21:58:31 +09:00
1 parent 89e9976ce9
commit a9e140fa85
220 files changed
+2070 -50

No files matched your search

+46 -49
View File
@@ -3,35 +3,34 @@
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 songJackets from '$lib/data/song-jackets.json';
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 { formatSongListClipboard } from '$lib/engine/song-list-copy.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 showCopyPreview = false;
let copyPreview: HTMLDivElement | undefined;
let copyOrigin = 'https://doricalc.cc';
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));
$: options = songOptions(charts, '', query);
$: resultsClipboard = formatSongListClipboard(selections.map(({ chart }) => chart), songJackets.jackets, copyOrigin);
function resetCopyStatus() {
copyStatus = '';
showCopyText = false;
showCopyPreview = false;
}
function saveSongs() {
@@ -48,12 +47,12 @@
}
function addSong(songId: string) {
const chart = difficultyCharts.find((chart) => chart.songId === songId);
const chart = charts.find((chart) => chart.songId === songId);
if (!chart) return;
selections = [...selections, { id: nextId++, chart }];
saveSongs();
resetCopyStatus();
announcement = `${selections.length}번째에 ${chart.title}, ${difficultyLabel(chart.difficulty)}를 추가했습니다.`;
announcement = `${selections.length}번째에 ${chart.title}를 추가했습니다.`;
}
function removeSong(id: number) {
@@ -74,26 +73,39 @@
async function copySongs() {
if (!selections.length || copying) return;
const snapshot = selections;
const text = resultsText;
const content = resultsClipboard;
copying = true;
resetCopyStatus();
try {
if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable');
await navigator.clipboard.writeText(text);
if (selections === snapshot) copyStatus = `${snapshot.length}개 악곡을 선택 순서대로 복사했습니다.`;
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') throw new Error('Rich clipboard unavailable');
await navigator.clipboard.write([new ClipboardItem({
'text/html': new Blob([content.html], { type: 'text/html' }),
'text/plain': new Blob([content.text], { type: 'text/plain' })
})]);
if (selections === snapshot) {
copyStatus = `${snapshot.length}개 악곡의 번호·재킷·제목을 선택 순서대로 복사했습니다.`;
if (content.missingJackets) copyStatus += ` 재킷 정보가 없는 ${content.missingJackets}개는 번호·곡명만 포함했습니다.`;
}
} catch {
if (selections !== snapshot) return;
copyStatus = '자동 복사를 사용할 수 없습니다. 아래 텍스트를 선택해 복사해 주세요.';
showCopyText = true;
copyStatus = '자동 복사를 사용할 수 없습니다. 아래 선택된 목록을 직접 복사해 주세요.';
showCopyPreview = true;
await tick();
copyTextArea?.focus();
copyTextArea?.select();
if (copyPreview) {
copyPreview.focus();
const range = document.createRange();
range.selectNodeContents(copyPreview);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
} finally {
copying = false;
}
}
onMount(() => {
copyOrigin = window.location.origin;
try {
const text = localStorage.getItem(SONG_LIST_STORAGE_KEY);
if (text) {
@@ -117,7 +129,7 @@
<svelte:head>
<title>악곡 목록 · 도구 · 갤산기</title>
<meta name="description" content="악곡과 난이도를 고른 순서대로 목록을 만들고 복사하세요." />
<meta name="description" content="악곡을 고른 순서대로 목록을 만들고 복사하세요." />
</svelte:head>
<main>
@@ -136,26 +148,20 @@
<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)} 목록에 추가`}>
<button class="song-option" type="button" on:click={() => addSong(song.id)} aria-label={`${song.title} 목록에 추가`}>
<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>
<p class="placeholder" role="status">검색 결과가 없습니다. 검색어를 변경해 주세요.</p>
{/if}
</section>
@@ -170,10 +176,10 @@
</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 showCopyPreview}
<div class="copy-preview" bind:this={copyPreview} tabindex="-1" aria-label="복사용 악곡 목록">
{@html resultsClipboard.html}
</div>
{/if}
{#if selections.length}
<ol class="results-list" aria-label="악곡 선택 순서">
@@ -181,8 +187,6 @@
<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}
@@ -202,21 +206,19 @@
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; }
.filters { display: grid; grid-template-columns: minmax(0, 1fr); }
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; }
input { width: 100%; min-width: 0; height: 44px; padding: 12px; border: 1px solid #414b5b; border-radius: 9px; background: #101319; color: #e8ebf1; font: inherit; }
.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; }
button:focus-visible, input:focus-visible, .copy-preview:focus-visible { outline: 3px solid #ed8bb5; outline-offset: 2px; }
.song-option { display: grid; grid-template-columns: minmax(0, 1fr) 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; }
@@ -227,26 +229,21 @@
.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; }
.copy-preview { margin-top: 12px; max-height: 320px; overflow: auto; padding: 12px; border-radius: 9px; background: #fff; color: #222; font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
.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 { display: grid; grid-template-columns: 24px minmax(0, 1fr) 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; }
.add-label { font-size: 11px; }
.results-list li { grid-template-columns: 18px minmax(0, 1fr) auto; gap: 6px; }
.selected-title { font-size: 13px; }
.selected-difficulty, .selected-level { font-size: 11px; }
}
</style>