Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion public/file.svg

This file was deleted.

1 change: 0 additions & 1 deletion public/globe.svg

This file was deleted.

Binary file removed public/icons/FireFlyLogo.png
Binary file not shown.
Binary file added public/icons/firefly-adminpage-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/icons/kakaologin-icon.png
Binary file not shown.
1 change: 0 additions & 1 deletion public/next.svg

This file was deleted.

Binary file removed public/poster.webp
Binary file not shown.
Binary file removed public/snapshot.webp
Binary file not shown.
1 change: 0 additions & 1 deletion public/window.svg

This file was deleted.

Binary file modified src/app/favicon.ico
Binary file not shown.
7 changes: 4 additions & 3 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import type { Metadata, Viewport } from 'next';
import Providers from './providers';
import './globals.css';
import { Toaster } from 'sonner';
import { AuthToastHandler } from '@/components/common/AuthToastHandler';
import { AuthToastHandler } from '@components/common/AuthToastHandler';
import { GoogleAnalytics } from '@next/third-parties/google'; // 구글 애널리틱스 추가

export const metadata: Metadata = {
title: '반딧불',
description: '30초만에 수많은 OTT 콘텐츠 숲을 밝히는 작은 빛',
title: '반딧불 - 관리자',
description:
'30초만에 수많은 OTT 콘텐츠 숲을 밝히는 작은 빛 - 반딧불의 관리자 페이지',
};

export const viewport: Viewport = {
Expand Down
61 changes: 42 additions & 19 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,75 @@
'use client';

import * as React from 'react';
import { useState } from 'react';
import { LogOut } from 'lucide-react';
import { Button } from '@components/ui/button';
import AdminDashboard from '@components/admin/AdminDashboard';
import { RequestQueueDashboard } from '@components/admin/RequestQueueDashboard';
import { BatchRequestQueueDashboard } from '@components/admin/batch/BatchRequestQueueDashboard';
import { SmoothExpandableSidebar } from '@components/admin/SmoothExpandableSidebar';
import UserManagement from '@components/admin/userManagement/UserManagement';
import { BatchResultDashboard } from '@components/admin/batch/BatchResultDashboard';
import Image from 'next/image';
import { useLogoutHandler } from '@hooks/useLogoutHandler';

type TabType = 'request-queue' | 'content-management' | 'member-management';
// 탭 종류 정의
type TabType =
| 'request-queue'
| 'content-management'
| 'member-management'
| 'batch-result';

// 각 탭에 대한 정보를 포함하는 객체
const tabConfig = {
'request-queue': {
title: '요청 대기열',
description: '사용자 요청을 확인하고 처리할 수 있습니다',
component: RequestQueueDashboard,
},
'content-management': {
title: '콘텐츠 관리',
description: '사이트의 콘텐츠를 생성, 수정, 삭제할 수 있습니다',
component: AdminDashboard,
},
'request-queue': {
title: '배치 대기열',
description: '서버 내에서 대기 중인 배치 요청들을 확인할 수 있습니다',
component: BatchRequestQueueDashboard,
},
'batch-result': {
title: '배치 결과',
description: '서버 내에서 수행된 배치 요청들의 결과를 확인할 수 있습니다',
component: BatchResultDashboard,
},
'member-management': {
title: '회원 정보 관리',
description: '회원 정보를 조회하고 관리할 수 있습니다',
component: AdminDashboard,
component: UserManagement,
},
};

// 관리 페이지의 최상단 컴포넌트
export default function AdminPage() {
const [activeTab, setActiveTab] = React.useState<TabType>('request-queue');
const [activeTab, setActiveTab] = useState<TabType>('content-management'); // 현재 활성화된 탭
const { handleLogout } = useLogoutHandler();

const currentTab = tabConfig[activeTab]; // 현재 탭에 관한 정보 포함하는 객체
const CurrentComponent = currentTab.component; // 현재 탭에 관한 컴포넌트
const { handleLogout } = useLogoutHandler();

return (
<div className="min-h-screen flex flex-col bg-background transition-all duration-300 ease-out">
<header className="sticky top-0 z-30 flex h-16 items-center justify-between gap-4 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-6">
<div className="flex flex-col">
<h1 className="text-xl font-semibold text-foreground leading-tight">
{currentTab.title}
</h1>
<p className="text-sm text-muted-foreground leading-tight">
{currentTab.description}
</p>
{/* 동적 헤더 */}
<header className="sticky top-0 z-30 flex h-16 items-center justify-between gap-4 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-4">
<div className="flex flex-row justify-start items-center">
<Image
src="/icons/firefly-adminpage-logo.png"
alt="logo"
width={40}
height={40}
className="h-full w-auto object-contain mr-4"
/>
<div className="flex flex-col">
<h1 className="text-xl font-semibold text-foreground leading-tight">
{currentTab.title}
</h1>
<p className="text-sm text-muted-foreground leading-tight">
{currentTab.description}
</p>
</div>
</div>

{/* 로그아웃 버튼 */}
Expand Down
66 changes: 51 additions & 15 deletions src/components/admin/AdminDashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useCallback, useEffect, useRef } from 'react';
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import {
Card,
CardContent,
Expand Down Expand Up @@ -28,14 +28,33 @@ import { useGetContentDetail } from '@hooks/admin/useGetContentDetail';
import { useMutationErrorToast } from '@hooks/useMutationErrorToast';
import ContentForm from '@components/admin/ContentForm';
import ContentCard from '@components/admin/ContentCard';
import ContentChart from '@components/admin/ContentChart';
import CategoryChart from '@components/admin/CategoryChart';
import ContentDetail from '@components/admin/ContentDetail';
import SearchFilter from '@components/admin/SearchFilter';
import { useGetCategoryMetrics } from '@hooks/admin/useGetCategoryMetrics';

export default function AdminDashboard() {
// 카테고리 지표 조회
const {
data: categoryMetricsData,
isLoading: isMetricsLoading,
error: metricsError,
} = useGetCategoryMetrics();

const [categoryType, setCategoryType] = useState<string>('');

const filteredCategoryCount =
categoryType && categoryType !== 'all'
? (categoryMetricsData?.categoryMetrics.find(
(metric) => metric.categoryType === categoryType,
)?.count ?? 0)
: (categoryMetricsData?.categoryMetrics.reduce(
(sum, metric) => sum + metric.count,
0,
) ?? 0);

// 무한 스크롤용 필터 상태
const size = 20;
const [categoryType, setCategoryType] = useState<string>('');

// 무한 스크롤 쿼리
const { data, isError, fetchNextPage, hasNextPage, isFetchingNextPage } =
Expand Down Expand Up @@ -166,6 +185,10 @@ export default function AdminDashboard() {

// 모든 페이지의 콘텐츠 합치기
const allContents = data?.pages.flatMap((page) => page.item) || [];
const triggerIndex = useMemo(
() => allContents.length - 8,
[allContents.length],
);

return (
<div className="h-screen overflow-y-auto bg-gray-50 p-6">
Expand All @@ -181,7 +204,17 @@ export default function AdminDashboard() {
{/* 콘텐츠 분포 차트 */}
<div className="w-full flex justify-center">
<div className="w-full max-w-5xl">
<ContentChart contents={allContents} />
{isMetricsLoading ? (
<div className="text-center py-4">차트를 불러오는 중...</div>
) : metricsError ? (
<div className="text-center py-4">카테고리 지표 로드 실패</div>
) : !categoryMetricsData ? (
<div className="text-center py-4">카테고리 데이터가 없습니다</div>
) : (
<CategoryChart
categoryMetrics={categoryMetricsData.categoryMetrics}
/>
)}
</div>
</div>

Expand All @@ -194,7 +227,7 @@ export default function AdminDashboard() {
등록된 콘텐츠 목록
</CardTitle>
<CardDescription>
전체 {allContents.length}개의 콘텐츠
전체 {filteredCategoryCount}개의 콘텐츠
</CardDescription>
</div>
<Button
Expand All @@ -206,7 +239,7 @@ export default function AdminDashboard() {
</div>

{/* 검색 및 필터 */}
<div className="mt-4">
<div className="mt-1">
<SearchFilter
filterType={categoryType}
onFilterChange={handleFilterChange}
Expand All @@ -215,18 +248,21 @@ export default function AdminDashboard() {
</CardHeader>

<CardContent>
<ScrollArea className="h-96">
<ScrollArea className="h-[500px]">
<div className="space-y-3 mb-3">
{allContents.map((content, idx) => (
<ContentCard
key={`${content.contentId}-${idx}`}
content={content}
onView={openDetailDialog}
onEdit={openEditDialog}
onDelete={handleDeleteContent}
/>
<div key={content.contentId}>
<ContentCard
content={content}
onView={openDetailDialog}
onEdit={openEditDialog}
onDelete={handleDeleteContent}
/>
{idx === triggerIndex && (
<div ref={loadMoreRef} style={{ height: 1 }} />
)}
</div>
))}
<div ref={loadMoreRef} style={{ height: 1 }} />
</div>
</ScrollArea>
{isFetchingNextPage && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,37 +13,34 @@ import {
ChartTooltipContent,
} from '@components/ui/chart';
import { PieChart, Pie, Cell } from 'recharts';
import { ContentSummary } from '@type/admin/Content';
import { generateChartData } from '@utils/getContentUtils';
import { CHART_COLORS } from '@/constants';
import { CHART_COLORS } from '@constants/index';
import { useMemo } from 'react';
import { CategoryMetric } from '@type/admin/CategoryMetric';

interface ContenChartProps {
contents: ContentSummary[];
interface CategoryChartProps {
categoryMetrics: CategoryMetric[];
}

export default function ContentChart({ contents }: ContenChartProps) {
const chartData = useMemo(() => generateChartData(contents), [contents]); // [{ name: '영화', value: 20 }, { name: '드라마', value: 10 }]
export default function CategoryChart({ categoryMetrics }: CategoryChartProps) {
const formattedData = useMemo(() => {
return categoryMetrics.map((item) => ({
name: item.categoryType,
count: item.count,
fill: CHART_COLORS[item.categoryId % CHART_COLORS.length],
}));
}, [categoryMetrics]);

const chartConfig = useMemo(() => {
return chartData.reduce(
(acc, item, index) => {
acc[item.name] = {
label: item.name,
color: CHART_COLORS[index % CHART_COLORS.length],
return categoryMetrics.reduce(
(acc, item) => {
acc[item.categoryType] = {
label: item.categoryType,
color: CHART_COLORS[item.categoryId % CHART_COLORS.length],
};
return acc;
},
{} as Record<string, { label: string; color: string }>,
);
}, [chartData]);

const formattedData = useMemo(() => {
return chartData.map((item, index) => ({
...item,
fill: CHART_COLORS[index % CHART_COLORS.length],
}));
}, [chartData]);
}, [categoryMetrics]);

return (
<Card className="bg-white">
Expand All @@ -69,8 +66,8 @@ export default function ContentChart({ contents }: ContenChartProps) {
`${name} ${(percent * 100).toFixed(0)}%`
}
>
{formattedData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
{formattedData.map((entry) => (
<Cell key={entry.name} fill={entry.fill} />
))}
</Pie>
</PieChart>
Expand Down
2 changes: 1 addition & 1 deletion src/components/admin/ContentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function ContentCard({ content, onView, onEdit, onDelete }: ContentCardProps) {
message: '정말로 이 콘텐츠를 삭제하시겠습니까?',
confirmText: '삭제',
cancelText: '취소',
className: 'border',
className: 'border bg-white',
onConfirm: async () => {
try {
await onDelete(content.contentId);
Expand Down
Loading