Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 567be0e02f | |||
| 9b265c070b |
@@ -1725,7 +1725,7 @@ async function countTierListsUsingTopicItem(itemId) {
|
||||
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT id, is_public, groups_json, pool_json
|
||||
SELECT id, is_public, groups_json
|
||||
FROM tierlists
|
||||
`
|
||||
)
|
||||
@@ -1736,10 +1736,8 @@ async function countTierListsUsingTopicItem(itemId) {
|
||||
|
||||
rows.forEach((row) => {
|
||||
const groups = parseJson(row.groups_json, [])
|
||||
const pool = parseJson(row.pool_json, [])
|
||||
const inGroups = groups.some((group) => (group?.itemIds || []).includes(itemId))
|
||||
const inPool = pool.some((item) => item?.id === itemId)
|
||||
if (!inGroups && !inPool) return
|
||||
if (!inGroups) return
|
||||
totalCount += 1
|
||||
if (row.is_public) publicCount += 1
|
||||
else privateCount += 1
|
||||
@@ -1819,7 +1817,7 @@ async function findCustomItemById(id) {
|
||||
async function getCustomItemUsageMeta() {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT t.topic_id, tp.name AS topic_name, t.groups_json, t.pool_json
|
||||
SELECT t.topic_id, tp.name AS topic_name, t.groups_json
|
||||
FROM tierlists t
|
||||
LEFT JOIN topics tp ON tp.id = t.topic_id
|
||||
`
|
||||
@@ -1829,7 +1827,6 @@ async function getCustomItemUsageMeta() {
|
||||
|
||||
rows.forEach((row) => {
|
||||
const groups = parseJson(row.groups_json, [])
|
||||
const pool = parseJson(row.pool_json, [])
|
||||
const seenItemIds = new Set()
|
||||
|
||||
groups.forEach((group) => {
|
||||
@@ -1839,13 +1836,6 @@ async function getCustomItemUsageMeta() {
|
||||
})
|
||||
})
|
||||
|
||||
pool.forEach((item) => {
|
||||
if (item?.id) {
|
||||
usageMap.set(item.id, (usageMap.get(item.id) || 0) + 1)
|
||||
seenItemIds.add(item.id)
|
||||
}
|
||||
})
|
||||
|
||||
if (!row.topic_id) return
|
||||
|
||||
seenItemIds.forEach((itemId) => {
|
||||
@@ -2013,7 +2003,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
createdAt: Number(row.created_at),
|
||||
ownerName: row.topic_name || row.topic_id,
|
||||
ownerEmail: '',
|
||||
usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size,
|
||||
usageCount: usageMeta.usageMap.get(row.id) || 0,
|
||||
linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
|
||||
assetKind: resolveLibraryAssetKind(row.src),
|
||||
sourceType: 'template',
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-06 v1.0.105
|
||||
- 인증 상태와 템플릿 데이터는 모두 비동기 로딩이라, 응답 전 빈 상태를 먼저 보여주면 “로그아웃된 것처럼 보임” 또는 “템플릿이 없는 사이트처럼 보임”으로 오해될 수 있다. 따라서 앱 셸에서는 인증 확인이 끝날 때까지 공통 로딩을 보여주고, 홈 화면은 템플릿 API 응답 전 빈 상태 대신 로딩 카드를 보여주는 편이 더 자연스럽다고 정리했다.
|
||||
|
||||
## 2026-04-06 v1.0.104
|
||||
- 아이템 사용 횟수는 “템플릿에 포함되어 선택 가능했던 횟수”가 아니라 “사용자가 실제 티어표 보드에 배치한 횟수”가 운영 지표로 더 의미 있다고 정리했다. 따라서 `pool_json`은 미사용 후보로 보고 제외하고, `groups_json`에 들어간 item id만 사용 횟수로 집계한다.
|
||||
- 템플릿 아이템도 같은 이미지가 몇 개 템플릿에 연결됐는지와 실제 저장 티어표에서 사용됐는지는 별도 개념이므로, `usageCount`는 실제 배치 기준으로 바꾸고 템플릿 연결 정보는 별도 `linkedTemplates`로 유지한다.
|
||||
|
||||
## 2026-04-06 v1.0.103
|
||||
- 기존 Git 태그와 커밋 메시지를 직접 재작성하면 원격 히스토리와 배포 참조가 꼬일 수 있으므로, 실제 태그는 그대로 두고 문서 기준 버전만 보정하기로 정리했다.
|
||||
- 다음 작업자와 AI에게 전달할 기준은 다음과 같다. `v0.1`은 개발 시작, `v0.2`는 Figma 기반 리디자인, `v0.3`은 이미지 최적화와 운영 기능 구현, `v0.4`는 게임 티어 중심 구조에서 범용 티어 메이커로 전환한 단계이며, 이 흐름을 공개 기준 `v1.0`으로 승격해 이어간다.
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-06 v1.0.105
|
||||
- 사이트 최초 접속 시 로그인/세션 확인이 끝나기 전 게스트 UI처럼 보였다가 뒤늦게 로그인 상태로 바뀌는 깜빡임을 줄이기 위해, 앱 공통 초기 로딩 화면을 추가했다.
|
||||
- 홈 화면의 주제 템플릿 목록도 API 응답 전에는 `표시할 주제 템플릿이 없어요.`를 바로 보여주지 않고, `주제 템플릿을 불러오고 있어요.` 로딩 카드가 먼저 나오도록 정리했다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-06 v1.0.104
|
||||
- 관리자 아이템 사용 횟수 기준을 실제 티어표 배치 기준으로 정리했다. 이제 `pool_json`에 남아 있는 미사용 후보는 사용 횟수에 포함하지 않고, `groups_json`에 실제 배치된 아이템만 사용된 것으로 계산한다.
|
||||
- 템플릿 아이템 사용 횟수도 같은 이미지가 연결된 템플릿 개수가 아니라, 해당 기본 아이템이 저장된 티어표 보드에 실제로 배치된 횟수를 기준으로 계산한다.
|
||||
- 템플릿 기본 아이템 삭제 영향 안내도 실제 배치된 티어표 기준으로 맞췄다.
|
||||
- 확인: `node --check backend/src/db.js`, `npm run build`
|
||||
|
||||
## 2026-04-06 v1.0.103
|
||||
- 문서상의 버전 표기 기준을 정리했다. 기존에 공개 전 개발 흐름에서 잘못 올라간 `v1.2.x`는 `v0.2.x`, `v1.3.x`는 `v0.3.x`, `v1.4.x`는 `v1.0.x` 흐름으로 해석한다.
|
||||
- 다음 작업자와 AI는 이 문서 기준으로 현재 최신 버전을 `v1.0.103`로 이어가야 한다. 기존 Git 태그/커밋은 히스토리 재작성 위험이 있어 그대로 두고, 문서 기준 버전만 보정했다.
|
||||
|
||||
@@ -155,6 +155,7 @@ const showSettingsThemePanel = computed(() => route.name === 'profile')
|
||||
const showTopicViewToggle = computed(() => route.name === 'topicHub')
|
||||
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||
const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value))
|
||||
const showInitialAppLoading = computed(() => !authReady.value && !showBackendFallback.value)
|
||||
const shouldLockRightRailBodyScroll = computed(() => isRightRailOverlay.value && rightRailOpen.value && !showBackendFallback.value)
|
||||
const leftBottomPrimaryAction = computed(() => {
|
||||
if (!authReady.value) return null
|
||||
@@ -580,6 +581,16 @@ function reloadApp() {
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
<template v-else-if="showInitialAppLoading">
|
||||
<main class="appLoading">
|
||||
<section class="appLoading__card">
|
||||
<div class="appLoading__eyebrow">Loading</div>
|
||||
<h1 class="appLoading__title">접속 정보를 확인하고 있어요.</h1>
|
||||
<p class="appLoading__desc">로그인 상태와 기본 데이터를 확인한 뒤 화면을 보여드릴게요.</p>
|
||||
<div class="appLoading__spinner" aria-hidden="true"></div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
<template v-else>
|
||||
<aside class="leftRail">
|
||||
<div class="leftRail__top railHeader">
|
||||
@@ -837,7 +848,8 @@ function reloadApp() {
|
||||
transition: grid-template-columns 220ms ease;
|
||||
}
|
||||
|
||||
.backendFallback {
|
||||
.backendFallback,
|
||||
.appLoading {
|
||||
min-width: 100dvw;
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
@@ -848,7 +860,8 @@ function reloadApp() {
|
||||
var(--theme-shell-bg);
|
||||
}
|
||||
|
||||
.backendFallback__card {
|
||||
.backendFallback__card,
|
||||
.appLoading__card {
|
||||
width: min(100%, 560px);
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
@@ -859,7 +872,8 @@ function reloadApp() {
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
}
|
||||
|
||||
.backendFallback__eyebrow {
|
||||
.backendFallback__eyebrow,
|
||||
.appLoading__eyebrow {
|
||||
color: var(--theme-accent-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
@@ -867,20 +881,37 @@ function reloadApp() {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.backendFallback__title {
|
||||
.backendFallback__title,
|
||||
.appLoading__title {
|
||||
margin: 0;
|
||||
font-size: clamp(28px, 4vw, 42px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.backendFallback__desc {
|
||||
.backendFallback__desc,
|
||||
.appLoading__desc {
|
||||
margin: 0;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.appLoading__spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
border: 3px solid color-mix(in srgb, var(--theme-text-faint) 34%, transparent);
|
||||
border-top-color: var(--theme-accent-strong);
|
||||
animation: appLoadingSpin 820ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes appLoadingSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.backendFallback__actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
|
||||
@@ -14,6 +14,8 @@ const auth = useAuthStore()
|
||||
|
||||
const templateRecords = ref([])
|
||||
const error = ref('')
|
||||
const isLoadingTemplates = ref(false)
|
||||
const hasLoadedTemplates = ref(false)
|
||||
const loadingFavoriteId = ref('')
|
||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
|
||||
const templates = computed(() => {
|
||||
@@ -39,10 +41,15 @@ const templates = computed(() => {
|
||||
|
||||
async function loadTemplates() {
|
||||
try {
|
||||
isLoadingTemplates.value = true
|
||||
error.value = ''
|
||||
const data = await api.listTopics()
|
||||
templateRecords.value = data.topics || []
|
||||
} catch (e) {
|
||||
error.value = '백엔드에 연결할 수 없어요. backend 서버가 실행 중인지 확인해주세요.'
|
||||
} finally {
|
||||
isLoadingTemplates.value = false
|
||||
hasLoadedTemplates.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +95,14 @@ function templateThumbUrl(template) {
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<TransitionGroup v-if="templates.length" name="libraryCard" tag="section" class="libraryGrid">
|
||||
<section v-else-if="isLoadingTemplates && !hasLoadedTemplates" class="libraryLoading" aria-live="polite">
|
||||
<div class="libraryLoading__spinner" aria-hidden="true"></div>
|
||||
<div>
|
||||
<div class="libraryLoading__title">주제 템플릿을 불러오고 있어요.</div>
|
||||
<div class="libraryLoading__desc">처음 접속 시 서버 응답에 따라 잠시 걸릴 수 있어요.</div>
|
||||
</div>
|
||||
</section>
|
||||
<TransitionGroup v-else-if="templates.length" name="libraryCard" tag="section" class="libraryGrid">
|
||||
<article v-for="template in templates" :key="template.id" class="libraryCard">
|
||||
<button
|
||||
class="libraryCard__favorite"
|
||||
@@ -111,7 +125,7 @@ function templateThumbUrl(template) {
|
||||
</button>
|
||||
</article>
|
||||
</TransitionGroup>
|
||||
<div v-else class="libraryEmpty">{{ query ? '검색어에 맞는 주제 템플릿이 없어요.' : '표시할 주제 템플릿이 없어요.' }}</div>
|
||||
<div v-else-if="hasLoadedTemplates" class="libraryEmpty">{{ query ? '검색어에 맞는 주제 템플릿이 없어요.' : '표시할 주제 템플릿이 없어요.' }}</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -132,6 +146,41 @@ function templateThumbUrl(template) {
|
||||
margin-top: 8px;
|
||||
color: var(--theme-text-muted);
|
||||
}
|
||||
.libraryLoading {
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 28px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
color: var(--theme-text);
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
}
|
||||
.libraryLoading__spinner {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
border: 3px solid color-mix(in srgb, var(--theme-text-faint) 32%, transparent);
|
||||
border-top-color: var(--theme-accent);
|
||||
animation: libraryLoadingSpin 820ms linear infinite;
|
||||
}
|
||||
.libraryLoading__title {
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.libraryLoading__desc {
|
||||
margin-top: 4px;
|
||||
color: var(--theme-text-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
@keyframes libraryLoadingSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.libraryCard {
|
||||
position: relative;
|
||||
text-align: left;
|
||||
|
||||
Reference in New Issue
Block a user