From b3ce0b13fed90bb10ad05bd472468e4fdb2ecd23 Mon Sep 17 00:00:00 2001 From: Recionds <56007790+Recionds@users.noreply.github.com> Date: Mon, 2 Feb 2026 20:49:13 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feature=20:=20JWT,=20Cookie,=20Redis=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=97=B0=EB=8F=99=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## #️⃣ 연관된 이슈 > #1 ## 📝 작업 내용 JWT, Cookie, Redis 기반 로그인 서비스와 연동했습니다. > 작업내용 설명 ### ✨ 스크린샷 ### 💬 리뷰 요구사항 --- .gitignore | 2 +- app/layout.tsx | 11 +- app/login/page.tsx | 193 ++++++++++++++++++++++++++++++++--- app/page.tsx | 18 ++++ app/profiles/page.tsx | 110 ++------------------ app/watch/[id]/page.tsx | 5 +- components/browse-header.tsx | 45 +++++--- contexts/AuthContext.tsx | 192 ++++++++++++++++++++++++++++++++++ next.config.mjs | 1 + 9 files changed, 436 insertions(+), 141 deletions(-) create mode 100644 contexts/AuthContext.tsx diff --git a/.gitignore b/.gitignore index f650315..b28ea81 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ yarn-error.log* .pnpm-debug.log* # env files -.env* +.env # vercel .vercel diff --git a/app/layout.tsx b/app/layout.tsx index 17dc715..9c11e97 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,8 @@ import type React from "react" import type { Metadata } from "next" import { Geist, Geist_Mono } from "next/font/google" import { Analytics } from "@vercel/analytics/next" +import { AuthProvider } from "@/contexts/AuthContext" +import { Toaster } from "@/components/ui/toaster" import "./globals.css" const _geist = Geist({ subsets: ["latin"] }) @@ -36,9 +38,12 @@ export default function RootLayout({ children: React.ReactNode }>) { return ( - - - {children} + + + + {children} + + diff --git a/app/login/page.tsx b/app/login/page.tsx index 32198c5..b4a4ff2 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -2,27 +2,129 @@ import type React from "react" -import { useState } from "react" +import { useState, useEffect } from "react" import Link from "next/link" import { useRouter } from "next/navigation" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" -import { Checkbox } from "@/components/ui/checkbox" +import { Separator } from "@/components/ui/separator" +import { useToast } from "@/hooks/use-toast" +import { useAuth } from "@/contexts/AuthContext" +import { apiClient } from "@/lib/api-client" export default function LoginPage() { const router = useRouter() + const { toast } = useToast() + const { login, refreshUser, isAuthenticated } = useAuth() const [isSignUp, setIsSignUp] = useState(false) + const [isLoading, setIsLoading] = useState(false) const [formData, setFormData] = useState({ email: "", password: "", name: "", }) - const handleSubmit = (e: React.FormEvent) => { + // 이미 로그인된 상태라면 /browse로 리다이렉트 + useEffect(() => { + if (isAuthenticated) { + router.push("/browse") + } + }, [isAuthenticated, router]) + + // OAuth 리다이렉트 후 처리 + useEffect(() => { + const checkAuthStatus = async () => { + const urlParams = new URLSearchParams(window.location.search) + const loginSuccess = urlParams.get("login") + const error = urlParams.get("error") + + console.log("[v0] OAuth callback - URL params:", { loginSuccess, error, fullURL: window.location.href }) + + if (error) { + console.log("[v0] OAuth error detected:", error) + toast({ + title: "로그인 실패", + description: decodeURIComponent(error), + variant: "destructive", + }) + return + } + + if (loginSuccess === "success") { + console.log("[v0] Login success detected, refreshing user...") + try { + await refreshUser() + console.log("[v0] User refresh successful") + toast({ + title: "로그인 성공", + description: "환영합니다!", + }) + router.push("/browse") + } catch (err) { + console.error("[v0] User refresh failed:", err) + toast({ + title: "오류", + description: "사용자 정보를 불러오는데 실패했습니다.", + variant: "destructive", + }) + } + } + } + + checkAuthStatus() + }, [refreshUser, router, toast]) + + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - // Backend integration will be added later - router.push("/profiles") + setIsLoading(true) + + try { + if (isSignUp) { + // 회원가입 - apiClient 사용 + await apiClient.post("/api/v1/auth/signup", { + email: formData.email, + password: formData.password, + name: formData.name, + }) + + toast({ + title: "회원가입 완료", + description: "회원가입이 완료되었습니다. 로그인해주세요.", + }) + + // 회원가입 성공 후 로그인 폼으로 전환 + setIsSignUp(false) + setFormData({ email: formData.email, password: "", name: "" }) + } else { + // 로그인 - AuthContext 사용 (내부적으로 apiClient 사용) + await login(formData.email, formData.password) + + toast({ + title: "로그인 성공", + description: "환영합니다!", + }) + + // 로그인 성공 후 browse 페이지로 이동 + router.push("/browse") + } + } catch (error) { + toast({ + title: isSignUp ? "회원가입 실패" : "로그인 실패", + description: error instanceof Error ? error.message : "오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + + const handleOAuthLogin = (provider: "google" | "kakao") => { + // OAuth2 로그인 - 백엔드의 OAuth2 엔드포인트로 리다이렉트 + console.log("[v0] Initiating OAuth login for provider:", provider) + const oauthUrl = `http://localhost:8080/oauth2/authorization/${provider}` + console.log("[v0] Redirecting to:", oauthUrl) + window.location.href = oauthUrl } return ( @@ -62,6 +164,7 @@ export default function LoginPage() { onChange={(e) => setFormData({ ...formData, name: e.target.value })} className="bg-secondary border-border text-foreground" required + disabled={isLoading} /> )} @@ -76,6 +179,7 @@ export default function LoginPage() { onChange={(e) => setFormData({ ...formData, email: e.target.value })} className="bg-secondary border-border text-foreground" required + disabled={isLoading} /> @@ -89,32 +193,89 @@ export default function LoginPage() { onChange={(e) => setFormData({ ...formData, password: e.target.value })} className="bg-secondary border-border text-foreground" required + disabled={isLoading} /> {!isSignUp && ( -
-
- - -
+
- 도움이 필요하신가요? + 비밀번호를 잊으셨나요?
)} - +
+
+ +
+
+ 또는 +
+
+ +
+ + + +
+

{isSignUp ? "이미 계정이 있으신가요? " : "Streamly 회원이 아니신가요? "} -

diff --git a/app/page.tsx b/app/page.tsx index 78876fd..5671ee4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,8 +1,26 @@ +"use client" + +import { useEffect } from "react" import Link from "next/link" +import { useRouter } from "next/navigation" import { Button } from "@/components/ui/button" import { Play, Info } from "lucide-react" +import { useAuth } from "@/contexts/AuthContext" export default function LandingPage() { + const router = useRouter() + const { isAuthenticated, isLoading } = useAuth() + + useEffect(() => { + if (!isLoading && isAuthenticated) { + router.push("/browse") + } + }, [isAuthenticated, isLoading, router]) + + if (isLoading || isAuthenticated) { + return null + } + return (
{/* Header */} diff --git a/app/profiles/page.tsx b/app/profiles/page.tsx index 2cb424f..17fdfd5 100644 --- a/app/profiles/page.tsx +++ b/app/profiles/page.tsx @@ -1,113 +1,15 @@ "use client" -import { useState } from "react" -import Link from "next/link" +import { useEffect } from "react" import { useRouter } from "next/navigation" -import { Button } from "@/components/ui/button" -import { Edit, Plus } from "lucide-react" - -const MOCK_PROFILES = [ - { - id: 1, - name: "김철수", - avatar: "/profile-avatar-1.jpg", - isKids: false, - }, - { - id: 2, - name: "김영희", - avatar: "/profile-avatar-2.jpg", - isKids: false, - }, - { - id: 3, - name: "어린이", - avatar: "/profile-avatar-kids.jpg", - isKids: true, - }, -] export default function ProfilesPage() { const router = useRouter() - const [isManaging, setIsManaging] = useState(false) - - const handleProfileClick = (profileId: number) => { - if (isManaging) { - router.push(`/profiles/manage/${profileId}`) - } else { - router.push("/browse") - } - } - - return ( -
- {/* Background */} -
- - {/* Header */} -
-
- - STREAMLY - -
-
- - {/* Main Content */} -
-
-

- {isManaging ? "프로필 관리" : "시청할 프로필을 선택하세요."} -

- -
- {MOCK_PROFILES.map((profile) => ( - - ))} - {/* Add Profile Button */} - -
+ useEffect(() => { + // 단일 프로필 시스템으로 변경되었으므로 /browse로 리다이렉트 + router.push("/browse") + }, [router]) - -
-
-
- ) + return null } diff --git a/app/watch/[id]/page.tsx b/app/watch/[id]/page.tsx index 05fb350..f9d602d 100644 --- a/app/watch/[id]/page.tsx +++ b/app/watch/[id]/page.tsx @@ -39,10 +39,11 @@ const REVIEWS = [ }, ] -export default function WatchPage({ params }: { params: { id: string } }) { +export default async function WatchPage({ params }: { params: Promise<{ id: string }> }) { // Mock content data - will be fetched from backend later + const { id } = await params const content = { - id: params.id, + id: id, title: "블랙 호라이즌", year: 2026, rating: "15+", diff --git a/components/browse-header.tsx b/components/browse-header.tsx index d305ba9..ae99dbd 100644 --- a/components/browse-header.tsx +++ b/components/browse-header.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useState, useEffect } from "react" import Link from "next/link" import { useRouter } from "next/navigation" import { Search, Bell, ChevronDown, Upload } from "lucide-react" @@ -13,18 +13,31 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { useAuth } from "@/contexts/AuthContext" export function BrowseHeader() { const router = useRouter() + const { user, logout } = useAuth() const [isScrolled, setIsScrolled] = useState(false) // Handle scroll effect - if (typeof window !== "undefined") { - window.addEventListener("scroll", () => { + useEffect(() => { + const handleScroll = () => { setIsScrolled(window.scrollY > 0) - }) + } + + window.addEventListener("scroll", handleScroll) + return () => window.removeEventListener("scroll", handleScroll) + }, []) + + const handleLogout = async () => { + await logout() } + // 사용자 이름의 첫 글자를 Avatar fallback으로 사용 + const userInitial = user?.nickname?.charAt(0).toUpperCase() || "U" + const userName = user?.nickname || "사용자" + return (
- + - - router.push("/profiles")} className="cursor-pointer"> - 프로필 전환 - - router.push("/profiles/manage")} className="cursor-pointer"> - 프로필 관리 - + + {user && ( + <> +
+

{userName}

+

{user.email}

+
+ + + )} 계정 고객 센터 - router.push("/")} className="cursor-pointer"> + 로그아웃
diff --git a/contexts/AuthContext.tsx b/contexts/AuthContext.tsx new file mode 100644 index 0000000..e09aa3c --- /dev/null +++ b/contexts/AuthContext.tsx @@ -0,0 +1,192 @@ +"use client" + +import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from "react" +import { useRouter } from "next/navigation" +import { apiClient } from "@/lib/api-client" + +type Role = "ROLE_USER" | "ROLE_ADMIN" | "ROLE_UPLOADER" + +interface User { + id: number + email: string + nickname: string + role: Role + provider?: string + createdAt: string +} + +interface AuthContextType { + user: User | null + isLoading: boolean + isAuthenticated: boolean + login: (email: string, password: string) => Promise + logout: () => Promise + refreshUser: () => Promise +} + +const AuthContext = createContext(undefined) + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const router = useRouter() + const refreshIntervalRef = useRef(null) + + // 사용자 정보 가져오기 + const fetchUser = useCallback(async () => { + if (typeof window === 'undefined') { + return null + } + + try { + const userData = await apiClient.get("/api/v1/users/me") + setUser(userData) + return userData + } catch (error) { + // 로그인 전 상태는 정상이므로 에러를 조용히 처리 + // 개발 환경에서도 로그 출력 안 함 + setUser(null) + return null + } + }, []) + + // 자동 토큰 갱신 설정 + const setupTokenRefresh = useCallback(() => { + // 기존 interval 제거 + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current) + } + + // 25분마다 토큰 갱신 (Access Token이 30분이므로 여유 있게) + refreshIntervalRef.current = setInterval(async () => { + try { + await fetch("http://localhost:8080/api/v1/auth/refresh", { + method: "POST", + credentials: "include", + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }) + console.log("[Auth] 토큰 자동 갱신 성공") + } catch (error) { + console.error("[Auth] 자동 토큰 갱신 실패:", error) + // 갱신 실패 시 로그아웃 + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current) + } + setUser(null) + router.push("/login") + } + }, 25 * 60 * 1000) // 25분 + + console.log("[Auth] 자동 토큰 갱신 활성화 (25분마다)") + }, [router]) + + // 초기 로드 시 사용자 정보 확인 + useEffect(() => { + const initAuth = async () => { + setIsLoading(true) + const userData = await fetchUser() + + // 로그인된 경우 자동 갱신 시작 + if (userData) { + setupTokenRefresh() + } + + setIsLoading(false) + } + + initAuth() + + // Cleanup + return () => { + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current) + } + } + }, [fetchUser, setupTokenRefresh]) + + // 다중 탭 동기화 + useEffect(() => { + const handleStorageChange = (e: StorageEvent) => { + if (e.key === "auth_logout") { + // 다른 탭에서 로그아웃 발생 + console.log("[Auth] 다른 탭에서 로그아웃 감지") + setUser(null) + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current) + } + router.push("/login") + } + } + + window.addEventListener("storage", handleStorageChange) + + return () => { + window.removeEventListener("storage", handleStorageChange) + } + }, [router]) + + // 로그인 + const login = async (email: string, password: string) => { + try { + const userData = await apiClient.post("/api/v1/auth/login", { + email, + password, + }) + + setUser(userData) + setupTokenRefresh() // 자동 갱신 시작 + console.log("[Auth] 로그인 성공 및 자동 갱신 시작") + } catch (error) { + throw error + } + } + + // 로그아웃 + const logout = async () => { + try { + // Interval 정리 + if (refreshIntervalRef.current) { + clearInterval(refreshIntervalRef.current) + } + + await apiClient.post("/api/v1/auth/logout") + + // 다른 탭에 로그아웃 신호 전송 + localStorage.setItem("auth_logout", Date.now().toString()) + localStorage.removeItem("auth_logout") + + console.log("[Auth] 로그아웃 완료") + } catch (error) { + console.error("로그아웃 요청 실패:", error) + } finally { + setUser(null) + router.push("/") + } + } + + // 사용자 정보 새로고침 + const refreshUser = async () => { + await fetchUser() + } + + const value: AuthContextType = { + user, + isLoading, + isAuthenticated: !!user, + login, + logout, + refreshUser, + } + + return {children} +} + +export function useAuth() { + const context = useContext(AuthContext) + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider") + } + return context +} diff --git a/next.config.mjs b/next.config.mjs index 4cd9948..9a3d7e2 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -6,6 +6,7 @@ const nextConfig = { images: { unoptimized: true, }, + reactStrictMode: false, } export default nextConfig From c5f1d333e118ccf1699cb5978981fd21d0ef7b94 Mon Sep 17 00:00:00 2001 From: Recionds <56007790+Recionds@users.noreply.github.com> Date: Thu, 12 Feb 2026 10:16:28 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feature=20:=20=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=8F=20=EC=8B=9C=EC=B2=AD=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=97=B0=EB=8F=99.=20HLS=20=ED=94=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=A0=81=EC=9A=A9=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## #️⃣ 연관된 이슈 > #3 ## 📝 작업 내용 백엔드와의 영상 업로드, 수정, 삭제, 시청 기능을 연동하였습니다. 시청 페이지에서 HLS 플레이어를 적용하였습니다. > 작업내용 설명 ### ✨ 스크린샷 ### 💬 리뷰 요구사항 --- .github/ISSUE_TEMPLATE/feature_request.md | 24 ++ .github/pull_request_template.md | 10 + .idea/.gitignore | 10 + .idea/FE.iml | 9 + .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + app/admin/dashboard/page.tsx | 485 +++++++++++++++++++++ app/browse/page.tsx | 339 +++++++++++---- app/category/[slug]/page.tsx | 319 +++++++------- app/my-videos/[id]/page.tsx | 346 +++++++++++++++ app/my-videos/page.tsx | 309 ++++++++++++++ app/search/loading.tsx | 6 +- app/search/page.tsx | 272 +++++++----- app/upload/page.tsx | 246 +++++++++-- app/watch/[id]/page.tsx | 495 +++++++++++----------- components/VideoPlayer.tsx | 317 ++++++++++++++ components/browse-header.tsx | 91 +++- components/edit-video-modal.tsx | 193 +++++++++ contexts/AuthContext.tsx.bak | 120 ++++++ lib/api-client.ts | 162 +++++++ lib/api.ts | 211 +++++++++ lib/cookies.ts | 48 +++ package-lock.json | 199 +++++++++ package.json | 4 +- 25 files changed, 3549 insertions(+), 686 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/pull_request_template.md create mode 100644 .idea/.gitignore create mode 100644 .idea/FE.iml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 app/admin/dashboard/page.tsx create mode 100644 app/my-videos/[id]/page.tsx create mode 100644 app/my-videos/page.tsx create mode 100644 components/VideoPlayer.tsx create mode 100644 components/edit-video-modal.tsx create mode 100644 contexts/AuthContext.tsx.bak create mode 100644 lib/api-client.ts create mode 100644 lib/api.ts create mode 100644 lib/cookies.ts diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..0387261 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,24 @@ +--- +name: ✨ Feature +about: 새로운 기능에 대한 이슈 +title: '' +labels: '' +assignees: '' + +--- + +## 🪄 Description +해당 이슈에 대한 설명 + +## ❤️ Changes + +### 🌳 작업 사항 +- [ ] 세부 사항 1 + + +## 📊 API +| URL | method | Usage | Authorization Needed | +| ------------------ | ------ | -------------------- | -------------------- | + +## 😃 Additional context + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..cef7f21 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,10 @@ +## #️⃣ 연관된 이슈 +> #이슈번호, #이슈번호 + +## 📝 작업 내용 + +> 작업내용 설명 + +### ✨ 스크린샷 + +### 💬 리뷰 요구사항 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..9879198 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# 디폴트 무시된 파일 +/shelf/ +/workspace.xml +# 쿼리 파일을 포함한 무시된 디폴트 폴더 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# 에디터 기반 HTTP 클라이언트 요청 +/httpRequests/ diff --git a/.idea/FE.iml b/.idea/FE.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/FE.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..6f29fee --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..de8600d --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..2d37fc0 --- /dev/null +++ b/app/admin/dashboard/page.tsx @@ -0,0 +1,485 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { BrowseHeader } from '@/components/browse-header' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { apiClient } from '@/lib/api-client' +import { useToast } from '@/hooks/use-toast' +import { + Video, + Users, + Eye, + HardDrive, + CheckCircle, + XCircle, + Clock, + Trash2, + Loader2 +} from 'lucide-react' +import type { Video as VideoType } from '@/lib/api' + +interface DashboardStats { + totalVideos: number + uploadingCount: number + uploadedCount: number + encodingCount: number + completedCount: number + failedCount: number + pendingApprovalCount: number + approvedCount: number + rejectedCount: number + totalViews: number + totalStorageGB: number +} + +export default function AdminDashboard() { + const router = useRouter() + const { toast } = useToast() + const [stats, setStats] = useState(null) + const [videos, setVideos] = useState([]) + const [loading, setLoading] = useState(true) + const [page, setPage] = useState(0) + const [totalPages, setTotalPages] = useState(0) + const [filter, setFilter] = useState<'all' | 'pending' | 'approved' | 'rejected'>('all') + const [deleteVideoId, setDeleteVideoId] = useState(null) + const [approveVideoId, setApproveVideoId] = useState(null) + const [rejectVideoId, setRejectVideoId] = useState(null) + const [rejectReason, setRejectReason] = useState('') + + useEffect(() => { + checkAuth() + loadDashboard() + }, []) + + useEffect(() => { + loadVideos() + }, [page, filter]) + + const checkAuth = () => { + const token = localStorage.getItem('token') + if (!token) { + router.push('/login') + } + } + + const loadDashboard = async () => { + try { + const data = await apiClient.get('/api/v1/admin/videos/dashboard/stats') + setStats(data) + } catch (error) { + console.error('Failed to load dashboard:', error) + + if (error instanceof Error && error.message.includes('인증')) { + toast({ + title: '권한 없음', + description: '관리자 권한이 필요합니다', + variant: 'destructive', + }) + router.push('/browse') + } else { + toast({ + title: '오류', + description: '대시보드 통계를 불러오는데 실패했습니다', + variant: 'destructive', + }) + } + } + } + + const loadVideos = async () => { + try { + setLoading(true) + + let url = `/api/v1/admin/videos?page=${page}&size=20` + if (filter !== 'all') { + url += `&approvalStatus=${filter.toUpperCase()}` + } + + const data = await apiClient.get(url) + setVideos(data.content) + setTotalPages(data.totalPages) + } catch (error) { + console.error('Failed to load videos:', error) + toast({ + title: '오류', + description: error instanceof Error ? error.message : '영상 목록을 불러오는데 실패했습니다', + variant: 'destructive', + }) + } finally { + setLoading(false) + } + } + + const handleApprove = async () => { + if (!approveVideoId) return + + try { + await apiClient.post(`/api/v1/admin/videos/${approveVideoId}/approve`) + + toast({ + title: '성공', + description: '영상이 승인되었습니다', + }) + + loadDashboard() + loadVideos() + } catch (error) { + console.error('Failed to approve video:', error) + toast({ + title: '오류', + description: error instanceof Error ? error.message : '영상 승인 중 오류가 발생했습니다', + variant: 'destructive', + }) + } finally { + setApproveVideoId(null) + } + } + + const handleReject = async () => { + if (!rejectVideoId || !rejectReason.trim()) { + toast({ + title: '입력 오류', + description: '거부 사유를 입력해주세요', + variant: 'destructive', + }) + return + } + + try { + await apiClient.post(`/api/v1/admin/videos/${rejectVideoId}/reject?reason=${encodeURIComponent(rejectReason)}`) + + toast({ + title: '성공', + description: '영상이 거부되었습니다', + }) + + loadDashboard() + loadVideos() + } catch (error) { + console.error('Failed to reject video:', error) + toast({ + title: '오류', + description: error instanceof Error ? error.message : '영상 거부 중 오류가 발생했습니다', + variant: 'destructive', + }) + } finally { + setRejectVideoId(null) + setRejectReason('') + } + } + + const handleDelete = async () => { + if (!deleteVideoId) return + + try { + await apiClient.delete(`/api/v1/admin/videos/${deleteVideoId}`) + + toast({ + title: '성공', + description: '영상이 삭제되었습니다', + }) + + loadDashboard() + loadVideos() + } catch (error) { + console.error('Failed to delete video:', error) + toast({ + title: '오류', + description: error instanceof Error ? error.message : '영상 삭제 중 오류가 발생했습니다', + variant: 'destructive', + }) + } finally { + setDeleteVideoId(null) + } + } + + if (!stats) { + return ( +
+ +
+ +
+
+ ) + } + + return ( +
+ + +
+

관리자 대시보드

+ + {/* Stats Cards */} +
+ + + 전체 영상 + + +
{stats.totalVideos}
+

+ 완료: {stats.completedCount} | 실패: {stats.failedCount} +

+
+
+ + + + 승인 대기 + + + +
{stats.pendingApprovalCount}
+

+ 승인: {stats.approvedCount} | 거부: {stats.rejectedCount} +

+
+
+ + + + 총 조회수 + + + +
{stats.totalViews.toLocaleString()}
+
+
+ + + + 저장 용량 + + + +
{stats.totalStorageGB.toFixed(2)} GB
+
+
+
+ + {/* Filters */} +
+ + + + +
+ + {/* Videos Table */} + + + 영상 목록 + + + {loading ? ( +
+ +
+ ) : ( + <> + + + + 제목 + 업로더 + 상태 + 승인 상태 + 조회수 + 작업 + + + + {videos.map((video) => ( + + {video.title} + {video.uploaderName || '알 수 없음'} + + {video.status} + + + {video.approvalStatus === 'PENDING' && ( + 대기 중 + )} + {video.approvalStatus === 'APPROVED' && ( + 승인됨 + )} + {video.approvalStatus === 'REJECTED' && ( + 거부됨 + )} + + {video.viewCount.toLocaleString()} + +
+ {video.approvalStatus === 'PENDING' && ( + <> + + + + )} + +
+
+
+ ))} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ +
+ + {page + 1} / {totalPages} + +
+ +
+ )} + + )} +
+
+
+ + {/* Approve Dialog */} + setApproveVideoId(null)}> + + + 영상을 승인하시겠습니까? + + 승인된 영상은 사용자에게 공개됩니다. + + + + 취소 + 승인 + + + + + {/* Reject Dialog */} + { + setRejectVideoId(null) + setRejectReason('') + }}> + + + 영상을 거부하시겠습니까? + + 거부 사유를 입력해주세요. + + +
+