Compare commits

...

2 Commits

Author SHA1 Message Date
47a804c764 ui: add branded splash loading 2026-04-06 16:47:01 +09:00
567be0e02f ui: add initial loading states 2026-04-06 16:42:48 +09:00
4 changed files with 274 additions and 7 deletions

View File

@@ -1,5 +1,12 @@
# 의사결정 이력
## 2026-04-06 v1.1.0
- `v1.0.105`까지 공개 기준 1.0 라인에서 초기 안정화 작업을 이어왔고, 이번부터는 사용자 경험 polish 성격의 개선을 `v1.1.x` 라인으로 올려 진행하기로 정리했다.
- 최초 접속 로딩은 인증 상태를 설명하는 문구보다, 사용자가 사이트에 들어왔다는 느낌을 주는 짧은 브랜드 스플래시가 더 자연스럽다. 다만 실제 서버 연결 실패나 점검 상황은 여전히 명확한 안내가 필요하므로, 백엔드 fallback 화면은 기존 기능 안내형 UI를 유지한다.
## 2026-04-06 v1.0.105
- 인증 상태와 템플릿 데이터는 모두 비동기 로딩이라, 응답 전 빈 상태를 먼저 보여주면 “로그아웃된 것처럼 보임” 또는 “템플릿이 없는 사이트처럼 보임”으로 오해될 수 있다. 따라서 앱 셸에서는 인증 확인이 끝날 때까지 공통 로딩을 보여주고, 홈 화면은 템플릿 API 응답 전 빈 상태 대신 로딩 카드를 보여주는 편이 더 자연스럽다고 정리했다.
## 2026-04-06 v1.0.104
- 아이템 사용 횟수는 “템플릿에 포함되어 선택 가능했던 횟수”가 아니라 “사용자가 실제 티어표 보드에 배치한 횟수”가 운영 지표로 더 의미 있다고 정리했다. 따라서 `pool_json`은 미사용 후보로 보고 제외하고, `groups_json`에 들어간 item id만 사용 횟수로 집계한다.
- 템플릿 아이템도 같은 이미지가 몇 개 템플릿에 연결됐는지와 실제 저장 티어표에서 사용됐는지는 별도 개념이므로, `usageCount`는 실제 배치 기준으로 바꾸고 템플릿 연결 정보는 별도 `linkedTemplates`로 유지한다.

View File

@@ -1,5 +1,15 @@
# 업데이트 로그
## 2026-04-06 v1.1.0
- 이번 작업부터 공개 후 개선 흐름을 `v1.1.x` 라인으로 올려 이어간다. 이전 최신 문서 기준은 `v1.0.105`다.
- 사이트 최초 접속 로딩 화면을 `접속 정보를 확인하고 있어요.` 같은 기능 안내 중심 문구에서, Tier Maker 브랜드 마크와 진행 애니메이션이 있는 스플래시 스타일로 변경했다.
- 확인: `npm run build`
## 2026-04-06 v1.0.105
- 사이트 최초 접속 시 로그인/세션 확인이 끝나기 전 게스트 UI처럼 보였다가 뒤늦게 로그인 상태로 바뀌는 깜빡임을 줄이기 위해, 앱 공통 초기 로딩 화면을 추가했다.
- 홈 화면의 주제 템플릿 목록도 API 응답 전에는 `표시할 주제 템플릿이 없어요.`를 바로 보여주지 않고, `주제 템플릿을 불러오고 있어요.` 로딩 카드가 먼저 나오도록 정리했다.
- 확인: `npm run build`
## 2026-04-06 v1.0.104
- 관리자 아이템 사용 횟수 기준을 실제 티어표 배치 기준으로 정리했다. 이제 `pool_json`에 남아 있는 미사용 후보는 사용 횟수에 포함하지 않고, `groups_json`에 실제 배치된 아이템만 사용된 것으로 계산한다.
- 템플릿 아이템 사용 횟수도 같은 이미지가 연결된 템플릿 개수가 아니라, 해당 기본 아이템이 저장된 티어표 보드에 실제로 배치된 횟수를 기준으로 계산한다.

View File

@@ -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,24 @@ function reloadApp() {
</section>
</main>
</template>
<template v-else-if="showInitialAppLoading">
<main class="appLoading">
<section class="appLoading__card">
<div class="appLoading__brand" aria-hidden="true">
<div class="appLoading__logo">
<span class="appLoading__logoMain">T</span>
<span class="appLoading__logoBar"></span>
</div>
<span class="appLoading__orb appLoading__orb--mint"></span>
<span class="appLoading__orb appLoading__orb--blue"></span>
</div>
<div class="appLoading__eyebrow">Tier Maker</div>
<h1 class="appLoading__title">티어표를 준비하고 있어요</h1>
<p class="appLoading__desc">잠시만 기다려 주세요.</p>
<div class="appLoading__progress" aria-hidden="true"><span></span></div>
</section>
</main>
</template>
<template v-else>
<aside class="leftRail">
<div class="leftRail__top railHeader">
@@ -837,7 +856,8 @@ function reloadApp() {
transition: grid-template-columns 220ms ease;
}
.backendFallback {
.backendFallback,
.appLoading {
min-width: 100dvw;
min-height: 100dvh;
display: grid;
@@ -848,7 +868,17 @@ function reloadApp() {
var(--theme-shell-bg);
}
.backendFallback__card {
.appLoading {
overflow: hidden;
background:
radial-gradient(circle at 50% 18%, rgba(127, 231, 214, 0.22), transparent 28%),
radial-gradient(circle at 26% 76%, rgba(95, 202, 255, 0.18), transparent 30%),
radial-gradient(circle at 78% 68%, rgba(167, 139, 250, 0.14), transparent 28%),
var(--theme-shell-bg);
}
.backendFallback__card,
.appLoading__card {
width: min(100%, 560px);
display: grid;
gap: 18px;
@@ -859,7 +889,104 @@ function reloadApp() {
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
}
.backendFallback__eyebrow {
.appLoading__card {
position: relative;
width: min(100%, 420px);
justify-items: center;
gap: 14px;
padding: 42px 32px;
overflow: hidden;
text-align: center;
background:
linear-gradient(145deg, color-mix(in srgb, var(--theme-card-bg) 92%, white), var(--theme-card-bg)),
var(--theme-card-bg);
}
.appLoading__card::before {
content: '';
position: absolute;
inset: -42%;
background: conic-gradient(from 90deg, transparent, rgba(127, 231, 214, 0.18), transparent 34%, rgba(95, 202, 255, 0.16), transparent 68%);
animation: appLoadingAura 4.8s linear infinite;
}
.appLoading__card > * {
position: relative;
z-index: 1;
}
.appLoading__brand {
position: relative;
width: 112px;
height: 112px;
display: grid;
place-items: center;
margin-bottom: 6px;
}
.appLoading__logo {
position: relative;
width: 76px;
height: 76px;
display: grid;
place-items: center;
border-radius: 24px;
border: 1px solid color-mix(in srgb, var(--theme-accent-strong) 34%, transparent);
background:
linear-gradient(135deg, rgba(127, 231, 214, 0.2), rgba(95, 202, 255, 0.12)),
color-mix(in srgb, var(--theme-card-bg) 86%, #090d16);
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.28),
0 0 34px rgba(127, 231, 214, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.12);
animation: appLoadingFloat 2.6s ease-in-out infinite;
}
.appLoading__logoMain {
color: var(--theme-accent-strong);
font-size: 44px;
font-weight: 950;
line-height: 1;
letter-spacing: -0.12em;
}
.appLoading__logoBar {
position: absolute;
right: 18px;
bottom: 16px;
width: 10px;
height: 24px;
border-radius: 999px;
background: #5fcaff;
box-shadow: 0 0 18px rgba(95, 202, 255, 0.54);
}
.appLoading__orb {
position: absolute;
width: 14px;
height: 14px;
border-radius: 999px;
filter: drop-shadow(0 0 12px currentColor);
animation: appLoadingOrbit 2.4s ease-in-out infinite;
}
.appLoading__orb--mint {
top: 10px;
right: 18px;
color: #7fe7d6;
background: currentColor;
}
.appLoading__orb--blue {
left: 14px;
bottom: 18px;
color: #5fcaff;
background: currentColor;
animation-delay: -1.1s;
}
.backendFallback__eyebrow,
.appLoading__eyebrow {
color: var(--theme-accent-strong);
font-size: 12px;
font-weight: 800;
@@ -867,20 +994,94 @@ 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 {
.appLoading__title {
font-size: clamp(27px, 4vw, 38px);
}
.backendFallback__desc,
.appLoading__desc {
margin: 0;
color: var(--theme-text-muted);
font-size: 15px;
line-height: 1.7;
}
.appLoading__progress {
width: min(100%, 220px);
height: 6px;
margin-top: 12px;
overflow: hidden;
border-radius: 999px;
background: color-mix(in srgb, var(--theme-text-faint) 18%, transparent);
}
.appLoading__progress span {
display: block;
width: 42%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #7fe7d6, #5fcaff);
box-shadow: 0 0 20px rgba(127, 231, 214, 0.46);
animation: appLoadingProgress 1.35s ease-in-out infinite;
}
@keyframes appLoadingAura {
to {
transform: rotate(360deg);
}
}
@keyframes appLoadingFloat {
0%,
100% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-6px) scale(1.02);
}
}
@keyframes appLoadingOrbit {
0%,
100% {
transform: translate(0, 0);
opacity: 0.75;
}
50% {
transform: translate(8px, -10px);
opacity: 1;
}
}
@keyframes appLoadingProgress {
0% {
transform: translateX(-110%);
}
100% {
transform: translateX(250%);
}
}
@media (prefers-reduced-motion: reduce) {
.appLoading__card::before,
.appLoading__logo,
.appLoading__orb,
.appLoading__progress span {
animation: none;
}
}
.backendFallback__actions {
display: flex;
justify-content: flex-start;

View File

@@ -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;