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