feat: copy song lists with jackets and simplify song rows
Publish GitHub Pages output / publish (push) Successful in 38s
Publish GitHub Pages output / publish (push) Successful in 38s
This commit is contained in:
1 parent
89e9976ce9
commit
a9e140fa85
220 files changed
+2070
-50
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user