Files
uniare.sori.studio/preset.html
zenn a281d731a1 v4.0.0: UNIARE 리브랜딩·레이아웃·덱 UX 개선
제품명·헤더 로고 이미지 통일, 덱 이름 변경·조회 수 제거, 썸네일 홀드 미리보기를 누르는 동안만 표시하도록 수정하고 Tailwind CSS를 재빌드했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 11:19:07 +09:00

283 lines
14 KiB
HTML

<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-4516420168710424" crossorigin="anonymous"></script>
<link rel="stylesheet" href="./style/output.css" />
<link rel="stylesheet" href="./style/app-shell.css" />
<link rel="stylesheet" href="./style/auth-header.css" />
<link rel="stylesheet" href="./style/deck-history.css" />
<title>공개 덱 - UNIARE</title>
</head>
<body class="app-page deck-history-page">
<nav class="app-nav">
<div class="app-nav-inner">
<a href="/" class="app-brand">
<img src="/images/assets/uniare-h48.png" alt="UNIARE" class="app-brand-logo" />
</a>
<div id="auth-header-slot"></div>
</div>
</nav>
<div class="app-ad-top">
<div class="app-ad-slot app-ad-slot--leaderboard" id="ad-leaderboard" aria-label="광고"></div>
</div>
<div class="app-shell">
<div class="app-body-layout">
<main class="app-main deck-history-main">
<header class="deck-history-header">
<div class="page-title-row">
<h1 data-i18n="publicDecksTitle">공개 덱</h1>
<button type="button" class="page-help-btn" data-i18n-tooltip="publicDecksDesc" aria-expanded="false">?</button>
</div>
</header>
<div id="deck-toolbar" class="deck-history-toolbar hidden">
<div class="deck-toolbar-fav-row">
<button type="button" id="deck-favorite-filter" class="deck-favorite-filter" aria-pressed="false">
<span class="deck-favorite-filter-icon deck-star-icon" aria-hidden="true"></span>
<span data-i18n="deckFavoriteFilterOnly">즐겨찾기만 보기</span>
<span id="deck-fav-count" class="deck-fav-count"></span>
</button>
</div>
<div class="deck-toolbar-main-row">
<input type="search" id="deck-search" class="deck-search-input" data-i18n-placeholder="publicDeckSearchPlaceholder" placeholder="덱 이름·시리즈·작성자 검색..." autocomplete="off" />
<select id="deck-series-filter" class="deck-series-select" aria-label="시리즈 필터">
<option value="">모든 시리즈</option>
</select>
<span id="deck-count" class="deck-count-label"></span>
</div>
</div>
<div id="preset-list">
<div class="deck-status-message" data-i18n="deckLoading">불러오는 중...</div>
</div>
</main>
<aside class="app-ad-rail" aria-label="광고">
<div class="app-ad-slot app-ad-slot--skyscraper" id="ad-sidebar">
<ins
class="adsbygoogle"
style="display: block"
data-ad-client="ca-pub-4516420168710424"
data-ad-slot="4260332296"
data-ad-format="auto"
data-full-width-responsive="true"
></ins>
</div>
</aside>
</div>
</div>
<script src="/script/api.js"></script>
<script src="/script/ads.js"></script>
<script src="/script/deck-favorites.js"></script>
<script src="/script/deck-preview-hold.js"></script>
<script src="/script/page-i18n.js"></script>
<script src="/script/page-help.js"></script>
<script src="/script/auth-header.js"></script>
<script>
/** @type {Array<object>} */
let allDecks = [];
/**
* @param {string} key
* @param {string} fallback
* @returns {string}
*/
function t(key, fallback) {
return window.UaPageI18n?.t(key, fallback) || fallback;
}
/**
* @param {number} filtered
* @param {number} total
* @returns {string}
*/
function formatDeckCount(filtered, total) {
return t('deckCountFormat', '{filtered} / {total}개')
.replace('{filtered}', String(filtered))
.replace('{total}', String(total));
}
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function populateSeriesFilter(decks) {
const select = document.getElementById('deck-series-filter');
const current = select.value;
const seriesMap = new Map();
decks.forEach((d) => {
if (!seriesMap.has(d.series)) seriesMap.set(d.series, d.series_name || d.series);
});
const sorted = [...seriesMap.entries()].sort((a, b) => String(a[1]).localeCompare(String(b[1]), 'ko'));
select.innerHTML =
`<option value="">${escapeHtml(t('deckAllSeries', '모든 시리즈'))}</option>` +
sorted.map(([key, label]) => `<option value="${escapeHtml(key)}">${escapeHtml(label)}</option>`).join('');
if (current && seriesMap.has(current)) select.value = current;
}
function getFilteredDecks() {
const query = document.getElementById('deck-search').value.trim().toLowerCase();
const series = document.getElementById('deck-series-filter').value;
const favOnly =
document.getElementById('deck-favorite-filter')?.getAttribute('aria-pressed') === 'true';
return allDecks.filter((d) => {
if (favOnly && !UaDeckFavorites.isFavorite(d.share_code)) return false;
if (series && d.series !== series) return false;
if (!query) return true;
const haystack = [d.name, d.series_name, d.series, d.author, d.share_code].filter(Boolean).join(' ').toLowerCase();
return haystack.includes(query);
});
}
/**
* @param {object} d
* @returns {string|null}
*/
function getPreviewSrc(d) {
if (!d.preview_url) return null;
const src = UaDeckApi.resolveMediaUrl(d.preview_url);
const version = d.updated_at ? `?v=${encodeURIComponent(d.updated_at)}` : '';
return src + version;
}
/**
* @param {string} shareCode
* @returns {string}
*/
function countFavorites() {
return allDecks.filter((d) => UaDeckFavorites.isFavorite(d.share_code)).length;
}
function updateFavCount() {
const el = document.getElementById('deck-fav-count');
if (!el) return;
const n = countFavorites();
el.textContent = n > 0 ? String(n) : '';
}
function favoriteButtonHtml(shareCode) {
const on = UaDeckFavorites.isFavorite(shareCode);
const cls = on ? 'deck-card-btn-favorite is-on' : 'deck-card-btn-favorite';
const starCls = on ? 'deck-star-icon is-on' : 'deck-star-icon';
return `<button type="button" class="deck-card-btn ${cls}" data-favorite="${escapeHtml(shareCode)}" aria-pressed="${on ? 'true' : 'false'}"><span class="${starCls}" aria-hidden="true"></span>${escapeHtml(t('deckFavorite', '즐겨찾기'))}</button>`;
}
function renderPreviewHtml(d, title) {
const previewSrc = getPreviewSrc(d);
if (!previewSrc) {
return `<div class="deck-card-preview"><div class="deck-card-preview-placeholder">${escapeHtml(t('deckNoPreview', '미리보기 없음'))}</div></div>`;
}
const holdAttrs = `data-hold-preview data-preview-src="${escapeHtml(previewSrc)}"`;
return `<div class="deck-card-preview deck-card-preview--holdable" ${holdAttrs}><img src="${escapeHtml(previewSrc)}" alt="${title}" loading="lazy" class="deck-card-preview-img" draggable="false" /></div>`;
}
function renderDeckCard(d) {
const title = escapeHtml(d.name || d.series_name || d.series);
const seriesLabel = escapeHtml(d.series_name || d.series);
const date = new Date(d.updated_at).toLocaleDateString('ko-KR');
const favClass = UaDeckFavorites.isFavorite(d.share_code) ? 'deck-card is-favorite' : 'deck-card';
return `
<article class="${favClass}" data-code="${escapeHtml(d.share_code)}">
${renderPreviewHtml(d, title)}
<div class="deck-card-body">
<span class="deck-card-series">${seriesLabel}</span>
<h2 class="deck-card-title">${title}</h2>
<div class="deck-card-meta">${escapeHtml(d.author)} · ${date}</div>
<div class="deck-card-actions">
${favoriteButtonHtml(d.share_code)}
<button type="button" class="deck-card-btn deck-card-btn-load" data-load="${escapeHtml(d.share_code)}" data-series="${escapeHtml(d.series)}" data-name="${escapeHtml(d.series_name || '')}">${escapeHtml(t('deckLoad', '불러오기'))}</button>
</div>
</div>
</article>`;
}
function renderList() {
const list = document.getElementById('preset-list');
const countEl = document.getElementById('deck-count');
const filtered = getFilteredDecks();
countEl.textContent = formatDeckCount(filtered.length, allDecks.length);
updateFavCount();
if (!filtered.length) {
const favOnly =
document.getElementById('deck-favorite-filter')?.getAttribute('aria-pressed') === 'true';
const emptyMsg = favOnly
? t('deckNoFavorites', '즐겨찾기한 덱이 없습니다.')
: allDecks.length
? t('deckNoMatch', '조건에 맞는 덱이 없습니다.')
: t('publicDeckEmpty', '공개된 덱이 없습니다.');
list.innerHTML = `<div class="deck-grid"><div class="deck-grid-empty">${escapeHtml(emptyMsg)}</div></div>`;
return;
}
list.innerHTML = `<div class="deck-grid">${filtered.map(renderDeckCard).join('')}</div>`;
UaDeckPreviewHold.bindHoldPreview(list);
}
async function loadPublicDecks() {
const list = document.getElementById('preset-list');
const toolbar = document.getElementById('deck-toolbar');
try {
const { presets } = await UaDeckApi.getPresets();
allDecks = presets;
if (!presets.length) {
toolbar.classList.add('hidden');
list.innerHTML = `<div class="deck-status-message">${escapeHtml(t('publicDeckEmpty', '공개된 덱이 없습니다.'))}</div>`;
return;
}
toolbar.classList.remove('hidden');
populateSeriesFilter(presets);
renderList();
} catch (err) {
toolbar.classList.add('hidden');
list.innerHTML = `<div class="deck-status-message error">${escapeHtml(err.message)}</div>`;
}
}
document.addEventListener('DOMContentLoaded', async () => {
if (window.UaPageI18n?.applyPageI18n) {
await window.UaPageI18n.applyPageI18n();
}
loadPublicDecks();
document.getElementById('deck-search').addEventListener('input', renderList);
document.getElementById('deck-series-filter').addEventListener('change', renderList);
const favFilterBtn = document.getElementById('deck-favorite-filter');
const favFilterIcon = favFilterBtn.querySelector('.deck-star-icon');
favFilterBtn.addEventListener('click', () => {
const on = favFilterBtn.getAttribute('aria-pressed') !== 'true';
favFilterBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
favFilterIcon.classList.toggle('is-on', on);
renderList();
});
document.getElementById('preset-list').addEventListener('click', (e) => {
const favBtn = e.target.closest('[data-favorite]');
if (favBtn) {
UaDeckFavorites.toggle(favBtn.dataset.favorite);
renderList();
return;
}
const loadBtn = e.target.closest('[data-load]');
if (loadBtn) {
const code = loadBtn.dataset.load;
const series = loadBtn.dataset.series;
const name = loadBtn.dataset.name;
location.href = `/cardList.html?series=${series}&seriesName=${encodeURIComponent(name)}&d=${code}`;
}
});
});
</script>
</body>
</html>