feat: align random song output with jacket song lists
Publish GitHub Pages output / publish (push) Successful in 48s

This commit is contained in:
2026-10-08 19:07:35 +09:00
parent b28a41f14e
commit e87dd3b1f3
2 changed files with 55 additions and 40 deletions
+5 -1
View File
@@ -1,9 +1,13 @@
---
updatedAt: 2026-10-08T09:15:32.000Z
updatedAt: 2026-10-08T10:07:05.000Z
---
## 2026년 10월 8일
### 악곡 랜덤 결과 표시·복사 통일
`IMPROVED` 악곡 랜덤의 추첨 결과를 악곡 목록과 같은 간결한 번호·곡명 행으로 표시하고, 분홍색 배경과 난이도·레벨 표시를 제거했습니다. ‘목록 복사’는 번호·재킷·제목 순서이며, 재킷은 이미지로, 번호와 제목은 편집 가능한 텍스트로 제공합니다. 자동 복사를 사용할 수 없으면 복사용 목록을 직접 선택해 복사할 수 있습니다. 레벨 범위 조건과 악곡·난이도 조합별 추첨 확률은 유지합니다.
### 카드·의상 목록 정렬 해제
`IMPROVED` 카드와 의상 효과 목록에 ‘기본 정렬’ 버튼을 추가했습니다. 정렬 가능한 열 제목을 반복해서 누르면 오름차순·내림차순·기본 순서로 전환합니다. 정렬을 해제해도 필터·개화 선택과 펼친 상세는 유지하며, 기본 정렬 상태는 다음 방문에도 유지합니다.
+50 -39
View File
@@ -3,10 +3,11 @@
import ToolsNav from '$lib/components/ToolsNav.svelte';
import { page } from '$app/stores';
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
import { difficultyLabel } from '$lib/data/difficulty.ts';
import songJackets from '$lib/data/song-jackets.json';
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { releasedSongCharts } from '$lib/data/song-release.ts';
import { formatRandomSongResults, pickRandomSong, randomSongCandidates } from '$lib/engine/random-song.ts';
import { pickRandomSong, randomSongCandidates } from '$lib/engine/random-song.ts';
import { formatSongListClipboard } from '$lib/engine/song-list-copy.ts';
type Chart = (typeof exactChartCatalog.charts)[number];
let releaseClock = createInitialReleaseClock($page.data?.serverNow);
@@ -18,10 +19,11 @@
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';
$: resultsText = formatRandomSongResults(results);
$: resultsClipboard = formatSongListClipboard(results, songJackets.jackets, copyOrigin);
$: charts = releasedSongCharts(exactChartCatalog.charts, releaseNow);
$: levels = [...new Set(charts.map((chart) => chart.difficultyLevel)
@@ -37,41 +39,54 @@
if (!selected) return;
results = [...results, selected];
copyStatus = '';
showCopyText = false;
announcement = `${results.length}번째 추첨: ${selected.title}, ${difficultyLabel(selected.difficulty)}, ${selected.difficultyLevel == null ? '레벨 정보 없음' : `Lv. ${selected.difficultyLevel}`}`;
showCopyPreview = false;
announcement = `${results.length}번째 추첨: ${selected.title}`;
}
function clearResults() {
results = [];
copyStatus = '';
showCopyText = false;
showCopyPreview = false;
announcement = '추첨 목록을 초기화했습니다.';
}
async function copyResults() {
if (!results.length || copying) return;
const snapshot = results;
const text = resultsText;
const content = resultsClipboard;
copying = true;
copyStatus = '';
showCopyText = false;
showCopyPreview = false;
try {
if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable');
await navigator.clipboard.writeText(text);
if (results === 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 (results === snapshot) {
copyStatus = `${snapshot.length}개 악곡의 번호·재킷·제목을 추첨 순서대로 복사했습니다.`;
if (content.missingJackets) copyStatus += ` 재킷 정보가 없는 ${content.missingJackets}개는 번호·곡명만 포함했습니다.`;
}
} catch {
if (results !== 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;
let mounted = true;
void synchronizeReleaseClock().then((clock) => {
if (!mounted) return;
@@ -137,25 +152,23 @@
<button class="clear-button" type="button" on:click={clearResults} disabled={results.length === 0}>목록 초기화</button>
</div>
</div>
<p class="hint">먼저 뽑은 결과부터 표시합니다. 레벨 범위를 바꿔도 이전 결과는 유지됩니다.</p>
<p class="copy-status" role="status">{copyStatus}</p>
{#if showCopyText}
<label class="copy-text">복사용 텍스트
<textarea bind:this={copyTextArea} value={resultsText} readonly rows={Math.min(results.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 results.length}
<ol class="results-list" aria-label="추첨 순서">
{#each results as result, index}
<li class:latest={index === results.length - 1}>
<li>
<span class="draw-number" aria-hidden="true">{index + 1}</span>
<strong class="song-title">{result.title}</strong>
<div class="badges"><span>{difficultyLabel(result.difficulty)}</span><strong>{result.difficultyLevel == null ? '레벨 정보 없음' : `Lv. ${result.difficultyLevel}`}</strong></div>
</li>
{/each}
</ol>
{:else}
<p class="placeholder">랜덤 선택을 누르면 악곡과 난이도를 함께 뽑습니다.</p>
<p class="placeholder">랜덤 선택을 누르면 추첨한 악곡이 차례대로 쌓입니다.</p>
{/if}
</section>
</main>
@@ -178,31 +191,29 @@
button { width: 100%; min-height: 48px; border: 0; border-radius: 10px; background: #ed8bb5; color: #21121a; font: inherit; font-weight: 850; cursor: pointer; }
button:hover:not(:disabled) { background: #f4a8c9; }
button:disabled { background: #343c49; color: #a4adba; cursor: not-allowed; }
button:focus-visible, select:focus-visible, input:focus-visible, textarea:focus-visible { outline: 3px solid #ed8bb5; outline-offset: 3px; }
.result { border-color: #694254; background: rgb(237 139 181 / 6%); }
button:focus-visible, select:focus-visible, input:focus-visible, .copy-preview:focus-visible { outline: 3px solid #ed8bb5; outline-offset: 3px; }
.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; }
.copy-status { margin: 12px 0 0; color: #f3b4d0; font-size: 13px; line-height: 1.6; }
.copy-status:empty { margin: 0; }
.copy-text { display: grid; gap: 8px; margin-top: 12px; color: #a4adba; font-size: 13px; }
textarea { width: 100%; min-height: 88px; padding: 12px; resize: vertical; border: 1px solid #694254; border-radius: 8px; background: #101319; color: #e8ebf1; font: inherit; 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; }
h2 { margin: 0; font-size: 22px; }
h2 span { margin-left: 6px; color: #a4adba; font-size: 14px; font-weight: 500; }
.clear-button { width: auto; min-height: 38px; padding: 8px 12px; border: 1px solid #694254; background: transparent; color: #f3b4d0; font-size: 13px; }
.clear-button:hover:not(:disabled) { background: #302331; }
.clear-button { width: auto; min-height: 36px; padding: 6px 10px; border: 1px solid #414b5b; background: transparent; color: #bec8d6; font-size: 13px; font-weight: 400; }
.clear-button:hover:not(:disabled) { border-color: #637083; background: #202733; }
.clear-button:disabled { background: transparent; color: #87909e; border-color: #343c49; }
.results-list { margin: 20px 0 0; padding: 0; list-style: none; }
.results-list li { display: grid; grid-template-columns: minmax(28px, auto) minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 16px 12px; border-bottom: 1px solid #343c49; }
.results-list li.latest { border-radius: 9px; border-bottom-color: transparent; background: rgb(237 139 181 / 9%); }
.results-list { margin: 12px 0 0; padding: 0; list-style: none; }
.results-list li { display: grid; grid-template-columns: 24px minmax(0, 1fr); align-items: center; gap: 10px; min-height: 44px; padding: 6px 0; border-bottom: 1px solid #2c3441; }
.results-list li:last-child { border-bottom: 0; }
.draw-number { color: #a4adba; font-size: 13px; font-variant-numeric: tabular-nums; }
.song-title { min-width: 0; overflow-wrap: anywhere; line-height: 1.6; }
.badges { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; font-size: 13px; }
.badges span, .badges strong { padding: 6px 10px; border-radius: 8px; background: #302331; color: #f3b4d0; }
.song-title { min-width: 0; overflow-wrap: anywhere; font-size: 14px; line-height: 1.4; }
.placeholder { color: #a4adba; line-height: 1.7; }
.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; }
.results-list li { grid-template-columns: minmax(24px, auto) minmax(0, 1fr); gap: 8px; }
.badges { grid-column: 2; }
.result { padding: 16px; }
.results-list li { grid-template-columns: 18px minmax(0, 1fr); gap: 6px; }
.song-title { font-size: 13px; }
}
</style>