v1.4.3: 관리자 UI·홈·미디어 개선

- 관리자 라이트 테마 격리, 대시보드 활성 링크, 로그인 우측 정렬
- 대시보드 통계 추이 차트·툴팁, 홈 Latest/Featured 보정
- 미디어 종류·미사용 필터, 비디오 프레임 썸네일
- NAS 운영 업데이트 절차 문서 추가
This commit is contained in:
2026-05-21 18:30:50 +09:00
parent 6919669330
commit 10c5a099fc
15 changed files with 523 additions and 84 deletions

View File

@@ -111,7 +111,22 @@ const formatTrendValue = (key, value) => {
return formatEngagedDuration(value)
}
return `${Number(value || 0)}`
if (key === 'visitors') {
return `${Number(value || 0)}`
}
return `${Number(value || 0)}`
}
/**
* 추세 막대 툴팁 문구를 반환한다.
* @param {{ key: 'visitors' | 'avgEngagedSeconds' | 'scroll50Reach', title: string }} metric - 지표 정보
* @param {Object} row - 추세 행
* @returns {string} 툴팁 문구
*/
const getTrendTooltipLabel = (metric, row) => {
const dayLabel = row.label || formatTrendDayLabel(row.day)
return `${dayLabel} · ${metric.title} ${formatTrendValue(metric.key, row[metric.key])}`
}
const chartMetrics = computed(() => [
@@ -139,7 +154,7 @@ const chartMetrics = computed(() => [
* @returns {number} 높이 %
*/
const getTrendBarHeight = (key, row) => {
const rows = trendRows.value
const rows = chartTrendRows.value
const maxValue = Math.max(...rows.map((item) => Number(item[key] || 0)), 0)
if (maxValue <= 0) {
@@ -164,6 +179,67 @@ const formatTrendDayLabel = (day) => {
return `${month}.${date}`
}
/**
* 선택 기간에 맞는 차트 표시 집계 단위를 반환한다.
* @param {number} days - 선택 기간
* @returns {number} 묶을 일수
*/
const getTrendChartBucketSize = (days) => {
if (days <= 14) {
return 1
}
if (days <= 60) {
return 7
}
if (days <= 180) {
return 14
}
return 30
}
/**
* 차트 표시용 추세 묶음 행을 만든다.
* @param {Array<Object>} rows - 일자별 추세 행
* @param {number} bucketSize - 묶을 일수
* @returns {Array<Object>} 차트 표시 행
*/
const buildTrendChartRows = (rows, bucketSize) => {
if (bucketSize <= 1) {
return rows.map((row) => ({
...row,
label: formatTrendDayLabel(row.day)
}))
}
const buckets = []
for (let index = 0; index < rows.length; index += bucketSize) {
const bucketRows = rows.slice(index, index + bucketSize)
const firstRow = bucketRows[0]
const lastRow = bucketRows[bucketRows.length - 1]
const engagedRows = bucketRows.filter((row) => Number(row.avgEngagedSeconds || 0) > 0)
const totalEngagedSeconds = engagedRows.reduce((sum, row) => sum + Number(row.avgEngagedSeconds || 0), 0)
const startLabel = formatTrendDayLabel(firstRow?.day)
const endLabel = formatTrendDayLabel(lastRow?.day)
buckets.push({
day: firstRow?.day || '',
label: startLabel === endLabel ? startLabel : `${startLabel}-${endLabel}`,
visitors: bucketRows.reduce((sum, row) => sum + Number(row.visitors || 0), 0),
avgEngagedSeconds: engagedRows.length ? Math.round(totalEngagedSeconds / engagedRows.length) : 0,
scroll50Reach: bucketRows.reduce((sum, row) => sum + Number(row.scroll50Reach || 0), 0)
})
}
return buckets
}
const chartTrendRows = computed(() => {
return buildTrendChartRows(trendRows.value, getTrendChartBucketSize(selectedAnalyticsDays.value))
})
/**
* 현재 접속자가 보고 있는 화면명을 반환한다.
* @param {Object} session - 접속 세션
@@ -268,19 +344,35 @@ watch(selectedAnalyticsDays, () => {
{{ metric.label }}
</strong>
</div>
<div class="admin-dashboard__chart-bars mt-4 flex h-32 items-end gap-1 border-b border-line">
<div
class="admin-dashboard__chart-bars mt-4 flex h-32 items-end gap-1 border-b border-line"
role="img"
:aria-label="`${metric.title} ${analyticsRangeLabel} 추이`"
>
<div
v-for="row in trendRows"
v-for="row in chartTrendRows"
:key="`${metric.key}-${row.day}`"
class="admin-dashboard__chart-bar-wrap flex min-w-[3px] flex-1 items-end"
:title="`${row.day} · ${formatTrendValue(metric.key, row[metric.key])}`"
class="admin-dashboard__chart-bar-wrap group relative flex h-full min-w-[3px] flex-1 items-end"
tabindex="0"
:aria-label="getTrendTooltipLabel(metric, row)"
>
<div
class="admin-dashboard__chart-bar w-full bg-[#15171a]/80"
class="admin-dashboard__chart-tooltip pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden -translate-x-1/2 whitespace-nowrap rounded bg-[#15171a] px-2 py-1 text-[11px] font-medium text-white shadow-lg group-hover:block group-focus-visible:block"
>
{{ getTrendTooltipLabel(metric, row) }}
</div>
<div
class="admin-dashboard__chart-bar w-full rounded-t-sm bg-[#15171a] transition-colors group-hover:bg-[#2f6feb] group-focus-visible:bg-[#2f6feb]"
:style="{ height: `${getTrendBarHeight(metric.key, row)}%` }"
/>
</div>
</div>
<p
v-if="!chartTrendRows.length"
class="admin-dashboard__chart-empty mt-4 text-center text-xs text-muted"
>
선택한 기간에 표시할 추이 데이터가 없습니다.
</p>
<div class="mt-2 flex justify-between text-[11px] text-muted">
<span>{{ formatTrendDayLabel(trendStartDay) }}</span>
<span>{{ analyticsRangeLabel }}</span>

View File

@@ -10,6 +10,7 @@ const form = reactive({
const pending = ref(false)
const errorMessage = ref('')
const showPassword = ref(false)
const emailInput = ref(null)
const passwordInput = ref(null)
@@ -20,6 +21,12 @@ const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
})
})
/**
* 관리자 로그인 제출 가능 여부
* @returns {boolean} 제출 가능 여부
*/
const canSubmitAdminLogin = computed(() => Boolean(form.email.trim()) && Boolean(form.password))
/**
* 브라우저/비밀번호 관리자 자동완성 값을 로그인 폼 상태에 반영한다.
* @returns {void}
@@ -85,64 +92,76 @@ onMounted(() => {
</script>
<template>
<main class="admin-login flex min-h-screen items-center justify-center bg-[#f5f5f2] px-5 text-ink">
<section class="admin-login__panel w-full max-w-sm border border-line bg-paper p-8">
<p class="admin-login__eyebrow text-xs font-semibold uppercase text-muted">
Admin
</p>
<h1 class="admin-login__title mt-2 text-3xl font-semibold">
로그인
</h1>
<p
v-if="bootstrapStatus?.needsAdminSetup"
class="mt-3 rounded border border-[#ff4f2e]/30 bg-[#ff4f2e]/10 px-3 py-2 text-xs text-[#b63a23]"
>
등록된 관리자가 없습니다.
<NuxtLink class="font-semibold underline-offset-2 hover:underline" to="/signup">
관리자 등록으로 이동
</NuxtLink>
</p>
<form class="admin-login__form mt-8 grid gap-4" @submit.prevent="submitLogin">
<label class="admin-login__field grid gap-2 text-sm">
<span class="admin-login__label font-medium">이메일</span>
<input
ref="emailInput"
v-model="form.email"
class="admin-login__input rounded border border-line bg-white px-3 py-2"
type="email"
autocomplete="username"
required
@change="syncAdminLoginAutofill"
@focus="syncAdminLoginAutofill"
@input="syncAdminLoginAutofill"
>
</label>
<label class="admin-login__field grid gap-2 text-sm">
<span class="admin-login__label font-medium">비밀번호</span>
<input
ref="passwordInput"
v-model="form.password"
class="admin-login__input rounded border border-line bg-white px-3 py-2"
type="password"
autocomplete="current-password"
required
@change="syncAdminLoginAutofill"
@focus="syncAdminLoginAutofill"
@input="syncAdminLoginAutofill"
>
</label>
<p v-if="errorMessage" class="admin-login__error text-sm text-red-600">
{{ errorMessage }}
<main class="admin-login min-h-screen bg-[#0a0b0d] text-[#f5f7fa] [color-scheme:dark]">
<div class="mx-auto flex min-h-screen w-full max-w-[1280px] items-center justify-end px-5 py-12 sm:px-10 lg:px-16">
<section class="admin-login__panel w-full max-w-[430px] p-5 sm:p-8">
<p class="admin-login__eyebrow text-right text-xs font-semibold uppercase text-[#9ba3af]">
Admin
</p>
<button
class="admin-login__button rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
type="submit"
:disabled="pending"
<h1 class="admin-login__title text-right mt-2 text-2xl font-semibold leading-tight">
관리자 로그인
</h1>
<p class="mt-2 text-right text-sm text-[#9ba3af]">
관리자 계정으로 로그인해 콘텐츠와 사이트 설정을 관리하세요.
</p>
<p
v-if="bootstrapStatus?.needsAdminSetup"
class="mt-5 rounded-[10px] border border-[#b03b43]/50 bg-[#b03b43]/10 px-3 py-2 text-right text-xs text-[#e5acb1]"
>
{{ pending ? '확인 중' : '로그인' }}
</button>
</form>
</section>
등록된 관리자가 없습니다.
<NuxtLink class="font-semibold text-[#7eb8ff] underline-offset-2 hover:underline" to="/signup">
관리자 등록으로 이동
</NuxtLink>
</p>
<form class="admin-login__form mt-8 space-y-5" @submit.prevent="submitLogin">
<label class="admin-login__field block space-y-1.5">
<span class="admin-login__label text-xs text-[#d8dee6]">이메일</span>
<input
ref="emailInput"
v-model="form.email"
class="auth-form-input admin-login__input h-10 w-full rounded-[8px] border border-[#1a212a] bg-transparent px-3 text-sm outline-none transition-colors focus:border-[#2f6feb]"
type="email"
autocomplete="username"
required
@change="syncAdminLoginAutofill"
@focus="syncAdminLoginAutofill"
@input="syncAdminLoginAutofill"
>
</label>
<label class="admin-login__field block space-y-1.5">
<span class="admin-login__label text-xs text-[#d8dee6]">비밀번호</span>
<span class="flex items-center rounded-[8px] border border-[#1a212a] transition-colors focus-within:border-[#2f6feb]">
<input
ref="passwordInput"
v-model="form.password"
class="auth-form-input admin-login__input h-10 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
required
@change="syncAdminLoginAutofill"
@focus="syncAdminLoginAutofill"
@input="syncAdminLoginAutofill"
>
<AuthPasswordVisibilityToggle v-model="showPassword" />
</span>
</label>
<p v-if="errorMessage" class="admin-login__error text-right text-xs text-[#e5acb1]" aria-live="polite">
{{ errorMessage }}
</p>
<button
class="admin-login__button ml-auto block h-9 rounded-[8px] bg-[#2f6feb] px-8 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
type="submit"
:disabled="pending || !canSubmitAdminLogin"
>
{{ pending ? '확인 중' : '로그인' }}
</button>
</form>
<NuxtLink class="mt-6 flex justify-end text-xs text-[#9ba3af] hover:opacity-80" to="/">
사이트로 돌아가기
</NuxtLink>
</section>
</div>
</main>
</template>

View File

@@ -16,6 +16,8 @@ const isThumbnailFolderPath = (folder) => folder === MEDIA_THUMBNAIL_ROOT || Str
const activeTab = ref('library')
const searchText = ref('')
const activeFolder = ref('')
const activeMediaKind = ref('all')
const showUnusedOnly = ref(false)
const isCreateFolderModalOpen = ref(false)
const createFolderModalName = ref('')
const deletingFolder = ref('')
@@ -70,6 +72,38 @@ const thumbnailMediaItems = computed(() => (mediaItems.value || []).filter((item
const scopeItems = computed(() => (activeTab.value === 'thumbnails' ? thumbnailMediaItems.value : libraryMediaItems.value))
const mediaKindFilterOptions = computed(() => {
const baseItems = scopeItems.value.filter((item) => {
const folder = activeFolder.value
return activeTab.value === 'thumbnails'
? true
: (!folder || item.category === folder || item.category?.startsWith(`${folder}/`))
})
const countByKind = baseItems.reduce((counts, item) => {
const kind = getMediaItemKind(item)
counts[kind] = (counts[kind] || 0) + 1
return counts
}, {})
return [
{ id: 'all', label: '전체', count: baseItems.length },
{ id: 'image', label: '이미지', count: countByKind.image || 0 },
{ id: 'video', label: '영상', count: countByKind.video || 0 },
{ id: 'audio', label: '음악', count: countByKind.audio || 0 },
{ id: 'file', label: '파일', count: countByKind.file || 0 }
]
})
const unusedMediaCount = computed(() => scopeItems.value.filter((item) => {
const folder = activeFolder.value
const matchesFolder = activeTab.value === 'thumbnails'
? true
: (!folder || item.category === folder || item.category?.startsWith(`${folder}/`))
return matchesFolder && !isMediaItemLocked(item)
}).length)
/**
* 상단 탭 전환 시 목록 상태를 초기화한다.
* @param {'library' | 'thumbnails'} tab - 선택 탭
@@ -83,10 +117,31 @@ const setActiveTab = (tab) => {
activeTab.value = tab
activeFolder.value = ''
searchText.value = ''
activeMediaKind.value = 'all'
showUnusedOnly.value = false
clearMediaSelection()
closeMediaDetail()
}
/**
* 미디어 종류 필터를 선택한다.
* @param {'all'|'image'|'video'|'audio'|'file'} kind - 선택할 미디어 종류
* @returns {void}
*/
const setMediaKindFilter = (kind) => {
activeMediaKind.value = kind
clearMediaSelection()
}
/**
* 미사용 미디어만 보기 필터를 토글한다.
* @returns {void}
*/
const toggleUnusedMediaFilter = () => {
showUnusedOnly.value = !showUnusedOnly.value
clearMediaSelection()
}
/**
* ISO 시각을 짧은 로캘 문자열로 표시한다.
* @param {string | null} iso - ISO 시각
@@ -164,6 +219,8 @@ const folderMediaCounts = computed(() => normalizedFolders.value.reduce((counts,
const filteredMediaItems = computed(() => {
const query = searchText.value.trim().toLowerCase()
const folder = activeFolder.value
const mediaKind = activeMediaKind.value
const unusedOnly = showUnusedOnly.value
const base = scopeItems.value
return base.filter((item) => {
@@ -175,8 +232,10 @@ const filteredMediaItems = computed(() => {
item.name,
...usageTitles
].some((value) => String(value || '').toLowerCase().includes(query))
const matchesKind = mediaKind === 'all' || getMediaItemKind(item) === mediaKind
const matchesUsage = !unusedOnly || !isMediaItemLocked(item)
return matchesFolder && matchesQuery
return matchesFolder && matchesQuery && matchesKind && matchesUsage
})
})
@@ -225,6 +284,8 @@ const isMediaSelected = (item) => selectedMediaUrls.value.includes(item.url)
*/
const selectFolder = (folder) => {
activeFolder.value = folder
activeMediaKind.value = 'all'
showUnusedOnly.value = false
selectedMediaUrls.value = []
lastSelectedIndex.value = -1
}
@@ -583,6 +644,32 @@ const deleteMedia = async (item) => {
:placeholder="activeTab === 'thumbnails' ? '파일명, 게시물 제목(사용처) 검색' : '파일명, 게시물 제목(사용처) 검색'"
>
</div>
<div
v-if="activeTab === 'library'"
class="admin-media__filters flex flex-wrap gap-1.5"
>
<button
v-for="option in mediaKindFilterOptions"
:key="option.id"
class="admin-media__kind-filter rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="activeMediaKind === option.id ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-line bg-white text-muted hover:text-ink'"
type="button"
@click="setMediaKindFilter(option.id)"
>
{{ option.label }}
<span class="ml-1 opacity-70">{{ option.count }}</span>
</button>
<button
class="admin-media__unused-filter rounded-full border px-3 py-1.5 text-xs font-semibold transition"
:class="showUnusedOnly ? 'border-[#15171a] bg-[#15171a] text-white' : 'border-line bg-white text-muted hover:text-ink'"
type="button"
:aria-pressed="showUnusedOnly"
@click="toggleUnusedMediaFilter"
>
미사용
<span class="ml-1 opacity-70">{{ unusedMediaCount }}</span>
</button>
</div>
</div>
<div class="admin-media__layout mt-8 grid gap-5 lg:grid-cols-[16rem_minmax(0,1fr)]">
@@ -733,6 +820,11 @@ const deleteMedia = async (item) => {
:src="item.url"
:alt="item.title"
>
<AdminMediaVideoThumbnail
v-else-if="getMediaItemKind(item) === 'video'"
:src="item.url"
:alt="item.title"
/>
<span
v-else
class="admin-media__image flex aspect-square w-full items-center justify-center bg-surface text-xs font-bold uppercase tracking-[0.18em] text-muted"

View File

@@ -49,7 +49,7 @@ const setPostFeedStyle = (value) => {
const isPostFeedCards = computed(() => postFeedStyle.value === 'cards')
/** @type {import('vue').ComputedRef<boolean>} */
const showPostFeedMedia = computed(() => postFeedStyle.value === 'list' || postFeedStyle.value === 'cards')
const showPostFeedMedia = computed(() => postFeedStyle.value === 'compact' || postFeedStyle.value === 'cards')
/**
* Latest 피드 컨테이너 클래스
@@ -335,9 +335,12 @@ const scrollFeatured = (direction) => {
>
<div
v-else
class="h-full w-full bg-[linear-gradient(135deg,#071b22,#5f6f85)]"
/>
<h3 class="absolute right-0 bottom-2.5 left-0 px-3 text-sm font-medium leading-tight text-white line-clamp-2">
class="post-card-media__placeholder flex h-full w-full items-center justify-center bg-[#F7F4EF] p-5 text-center text-sm font-medium leading-snug text-[var(--site-muted)] transition-all duration-200 group-hover:opacity-90"
:aria-label="post.title"
>
<span class="post-card-media__placeholder-text line-clamp-4">{{ post.title }}</span>
</div>
<h3 v-if="post.featuredImage" class="absolute right-0 bottom-2.5 left-0 px-3 text-sm font-medium leading-tight text-white line-clamp-2">
{{ post.title }}
</h3>
</NuxtLink>
@@ -345,7 +348,7 @@ const scrollFeatured = (direction) => {
</div>
</section>
<section class="py-4 px-6">
<section class="latest-posts-section min-h-[360px] py-4 px-6">
<div class="mx-auto max-w-[720px]">
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2">
<h2 class="text-sm font-medium uppercase site-muted">Latest</h2>

View File

@@ -247,7 +247,7 @@ const goPreviousStep = () => {
<template>
<section class="auth-signup min-h-screen bg-[#0a0b0d] text-[#f5f7fa] [color-scheme:dark]">
<div class="mx-auto flex min-h-screen w-full max-w-[1280px] items-start px-5 py-12 sm:px-10 sm:py-16 lg:px-16 lg:py-24">
<div class="auth-signup__panel flex min-h-[calc(100vh-6rem)] w-full max-w-[430px] flex-col rounded-2xl border border-[#1a212a] bg-[#0d1116] p-5 sm:min-h-[calc(100vh-8rem)] sm:p-8 lg:min-h-[calc(100vh-12rem)]">
<div class="auth-signup__panel flex min-h-[calc(100vh-6rem)] w-full max-w-[430px] flex-col rounded-2xl p-5 sm:min-h-[calc(100vh-8rem)] sm:p-8 lg:min-h-[calc(100vh-12rem)]">
<div>
<template v-if="currentStep === 1">
<p class="text-[32px] font-semibold leading-tight sm:text-[40px]">