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/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..23650fa --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,86 @@ +name: Deploy Frontend + +on: + push: + branches: + - develop + - main + +env: + AWS_REGION: ap-northeast-2 + ECR_REGISTRY: 774023531956.dkr.ecr.ap-northeast-2.amazonaws.com + ECR_REPOSITORY: streamly/frontend + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build and push image + run: | + docker build -t ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:latest \ + -t ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }} . + docker push ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:latest + docker push ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }} + + - name: Clean up old ECR images + run: | + IMAGE_IDS=$(aws ecr describe-images \ + --repository-name ${{ env.ECR_REPOSITORY }} \ + --query 'sort_by(imageDetails,& imagePushedAt)[:-5].[imageDigest]' \ + --output text) + + if [ ! -z "$IMAGE_IDS" ]; then + echo "Deleting old images..." + echo "$IMAGE_IDS" | while read digest; do + aws ecr batch-delete-image \ + --repository-name ${{ env.ECR_REPOSITORY }} \ + --image-ids imageDigest=$digest + done + fi + + - name: Deploy to EC2 + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.EC2_HOST }} + username: ubuntu + key: ${{ secrets.EC2_SSH_KEY }} + script: | + cd ~/INFRA + + # ECR 로그인 + aws ecr get-login-password --region ap-northeast-2 | \ + docker login --username AWS --password-stdin \ + 774023531956.dkr.ecr.ap-northeast-2.amazonaws.com + + # Frontend 업데이트 + docker-compose pull frontend + docker-compose up -d --no-deps frontend + + # 이전 이미지 정리 + docker image prune -f + + echo "✅ Frontend deployment completed!" + + - name: Notify + if: always() + run: | + if [ ${{ job.status }} == 'success' ]; then + echo "✅ Frontend deployment successful!" + else + echo "❌ Frontend deployment failed!" + fi diff --git a/.gitignore b/.gitignore index f650315..e4f273f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,11 +17,13 @@ yarn-error.log* .pnpm-debug.log* # env files -.env* +.env # vercel .vercel # typescript *.tsbuildinfo -next-env.d.ts \ No newline at end of file +next-env.d.ts + +nul \ No newline at end of file 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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1f59ab3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# Multi-stage build for production +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json* ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build Next.js application +ENV NEXT_TELEMETRY_DISABLED 1 +RUN npm run build + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV production +ENV NEXT_TELEMETRY_DISABLED 1 + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +# Copy built application +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +# Change ownership +RUN chown -R nextjs:nodejs /app + +USER nextjs + +EXPOSE 3000 + +ENV PORT 3000 +ENV HOSTNAME "0.0.0.0" + +CMD ["node", "server.js"] diff --git a/app/admin/dashboard/page.tsx b/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..d46cac3 --- /dev/null +++ b/app/admin/dashboard/page.tsx @@ -0,0 +1,414 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +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, + 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(() => { + loadDashboard() + }, []) + + useEffect(() => { + loadVideos() + }, [page, filter]) + + const loadDashboard = async () => { + try { + const data = await apiClient.get('/api/v1/admin/videos/dashboard/stats') + setStats(data) + } catch (error) { + 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()}` + } + + console.log('[Dashboard] Loading videos with URL:', url) + const data = await apiClient.get(url) + console.log('[Dashboard] Videos loaded:', data) + + setVideos(data.content || []) + setTotalPages(data.totalPages || 0) + } catch (error) { + console.error('[Dashboard] Failed to load videos:', error) + toast({ + title: '오류', + description: '영상 목록을 불러오는데 실패했습니다', + variant: 'destructive', + }) + } finally { + setLoading(false) + } + } + + const handleApprove = async () => { + if (!approveVideoId) return + await apiClient.post(`/api/v1/admin/videos/${approveVideoId}/approve`) + setApproveVideoId(null) + loadDashboard() + loadVideos() + } + + const handleReject = async () => { + if (!rejectVideoId || !rejectReason.trim()) return + await apiClient.post( + `/api/v1/admin/videos/${rejectVideoId}/reject?reason=${encodeURIComponent(rejectReason)}` + ) + setRejectVideoId(null) + setRejectReason('') + loadDashboard() + loadVideos() + } + + const handleDelete = async () => { + if (!deleteVideoId) return + await apiClient.delete(`/api/v1/admin/videos/${deleteVideoId}`) + setDeleteVideoId(null) + loadDashboard() + loadVideos() + } + + 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.length === 0 ? ( +
+

영상이 없습니다

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