.
All checks were successful
Deploy blog-frontend / build-and-deploy (push) Successful in 2m5s

This commit is contained in:
wypark
2026-06-07 11:43:52 +09:00
parent b466fa98c9
commit 8fedc8657e
3 changed files with 93 additions and 20 deletions

5
LOG.md
View File

@@ -2,6 +2,11 @@
## 2026-06-07 ## 2026-06-07
- Fixed the admin dashboard traffic chart visibility by giving bar columns a real height context, so percentage-based bar heights render instead of collapsing.
- Added fallback traffic points from recent/popular post data when the dashboard API or `traffic` array is empty, keeping the graph visible while the backend traffic aggregation is unavailable.
- Validation: `npm run lint` passed and `npm run build` passed. Browser navigation to `/admin` was attempted on `http://localhost:3112/`, but the current browser session was unauthenticated and rendered the login screen, so the admin chart itself was validated by code path and build checks rather than a live authenticated screenshot.
- Recommended next task: verify `/admin` with an authenticated admin session against the production API to confirm the real `traffic` payload replaces the fallback graph.
- Simplified the macOS-inspired layout by removing duplicated navigation surfaces: the desktop shortcut icon layer, the home page's inner Finder sidebar, and app navigation links from the system menu bar. - Simplified the macOS-inspired layout by removing duplicated navigation surfaces: the desktop shortcut icon layer, the home page's inner Finder sidebar, and app navigation links from the system menu bar.
- Reworked the global sidebar into a cleaner library panel focused on profile, search, and categories, with a quieter compact profile row and no duplicated app shortcuts. - Reworked the global sidebar into a cleaner library panel focused on profile, search, and categories, with a quieter compact profile row and no duplicated app shortcuts.
- Kept app-level movement in the Dock, category/search movement in the sidebar, and current-context/status information in the menu bar so each shell area has a clearer role. - Kept app-level movement in the Dock, category/search movement in the sidebar, and current-context/status information in the menu bar so each shell area has a clearer role.

View File

@@ -29,10 +29,17 @@ import {
Category, Category,
DashboardPostStat, DashboardPostStat,
DashboardRange, DashboardRange,
DashboardTrafficPoint,
PageMeta, PageMeta,
Post, Post,
} from '@/types'; } from '@/types';
const dashboardRangeDays: Record<DashboardRange, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
};
const countCategories = (categories: Category[] = []): number => { const countCategories = (categories: Category[] = []): number => {
return categories.reduce((count, category) => { return categories.reduce((count, category) => {
return count + 1 + countCategories(category.children || []); return count + 1 + countCategories(category.children || []);
@@ -89,6 +96,46 @@ const toPostStat = (post: Post): DashboardPostStat => ({
createdAt: post.createdAt, createdAt: post.createdAt,
}); });
const formatKstDateKey = (date: Date) => {
return new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Asia/Seoul',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
};
const getFallbackTraffic = (
posts: Post[],
range: DashboardRange,
): DashboardTrafficPoint[] => {
const days = dashboardRangeDays[range];
const today = new Date();
const points = Array.from({ length: days }, (_, index) => {
const date = new Date(today);
date.setDate(today.getDate() - (days - 1 - index));
return {
date: formatKstDateKey(date),
views: 0,
};
});
const pointMap = new Map(points.map((point) => [point.date, point]));
posts.forEach((post) => {
const createdAt = new Date(post.createdAt);
if (Number.isNaN(createdAt.getTime())) return;
const dateKey = formatKstDateKey(createdAt);
const point = pointMap.get(dateKey);
if (!point) return;
point.views += post.viewCount;
});
return points;
};
function RecentPostRow({ post }: { post: Post }) { function RecentPostRow({ post }: { post: Post }) {
return ( return (
<Link <Link
@@ -249,6 +296,11 @@ export default function AdminPage() {
() => dashboard?.topPosts ?? (popularData?.content ?? []).map(toPostStat), () => dashboard?.topPosts ?? (popularData?.content ?? []).map(toPostStat),
[dashboard?.topPosts, popularData?.content], [dashboard?.topPosts, popularData?.content],
); );
const fallbackTraffic = useMemo(
() => getFallbackTraffic([...(latestData?.content ?? []), ...(popularData?.content ?? [])], range),
[latestData?.content, popularData?.content, range],
);
const trafficPoints = dashboard?.traffic?.length ? dashboard.traffic : fallbackTraffic;
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@@ -301,10 +353,10 @@ export default function AdminPage() {
/> />
<AdminDashboardTrafficChart <AdminDashboardTrafficChart
points={dashboard?.traffic ?? []} points={trafficPoints}
range={range} range={range}
onRangeChange={setRange} onRangeChange={setRange}
isFallback={isFallback} isFallback={isFallback || !dashboard?.traffic?.length}
/> />
<div className="grid gap-6 xl:grid-cols-[1fr_0.8fr]"> <div className="grid gap-6 xl:grid-cols-[1fr_0.8fr]">

View File

@@ -34,6 +34,7 @@ export default function AdminDashboardTrafficChart({
isFallback, isFallback,
}: AdminDashboardTrafficChartProps) { }: AdminDashboardTrafficChartProps) {
const maxViews = Math.max(...points.map((point) => point.views), 1); const maxViews = Math.max(...points.map((point) => point.views), 1);
const hasTraffic = points.some((point) => point.views > 0);
return ( return (
<Surface as="section" className="p-5"> <Surface as="section" className="p-5">
@@ -44,7 +45,9 @@ export default function AdminDashboardTrafficChart({
<h2 className="text-lg font-bold text-[var(--color-text)]"> </h2> <h2 className="text-lg font-bold text-[var(--color-text)]"> </h2>
</div> </div>
<p className="mt-1 text-sm text-[var(--color-text-muted)]"> <p className="mt-1 text-sm text-[var(--color-text-muted)]">
. {isFallback
? '대시보드 집계 전에는 게시글 누적 조회수로 임시 흐름을 표시합니다.'
: '최근 기간별 조회수 흐름을 확인합니다.'}
</p> </p>
</div> </div>
<SegmentedControl <SegmentedControl
@@ -56,24 +59,37 @@ export default function AdminDashboardTrafficChart({
</div> </div>
{points.length > 0 ? ( {points.length > 0 ? (
<div className="flex h-64 items-end gap-1.5 overflow-hidden rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 py-4"> <>
<div className="h-64 overflow-x-auto rounded-lg border border-[var(--color-line)] bg-[var(--color-control)] px-3 py-4">
<div className="flex h-full min-w-full items-stretch gap-1.5">
{points.map((point) => { {points.map((point) => {
const height = Math.max((point.views / maxViews) * 100, 4); const height = Math.max((point.views / maxViews) * 100, 4);
return ( return (
<div key={point.date} className="flex min-w-2 flex-1 flex-col items-center gap-2"> <div key={point.date} className="flex min-w-2 flex-1 flex-col items-center gap-2">
<div className="flex min-h-0 w-full flex-1 items-end">
<div <div
className="w-full rounded-t bg-[var(--color-accent)]/70 transition hover:bg-[var(--color-accent)]" className="w-full rounded-t bg-[var(--color-accent)]/70 transition hover:bg-[var(--color-accent)]"
style={{ height: `${height}%` }} style={{ height: `${height}%` }}
title={`${formatDay(point.date)} 조회 ${point.views.toLocaleString()}`} title={`${formatDay(point.date)} 조회 ${point.views.toLocaleString()}`}
/> />
<span className="hidden text-[10px] font-medium text-[var(--color-text-subtle)] sm:block"> </div>
<span className="hidden h-4 text-[10px] font-medium text-[var(--color-text-subtle)] sm:block">
{formatDay(point.date)} {formatDay(point.date)}
</span> </span>
</div> </div>
); );
})} })}
</div> </div>
</div>
{isFallback && (
<p className="mt-3 text-xs font-medium text-[var(--color-text-subtle)]">
{hasTraffic
? '임시 그래프는 글 발행일과 누적 조회수를 기준으로 계산됩니다.'
: '집계 API가 연결되면 실제 일별 조회수로 대체됩니다.'}
</p>
)}
</>
) : ( ) : (
<EmptyState <EmptyState
title="통계 API 연결 전" title="통계 API 연결 전"