diff --git a/package-lock.json b/package-lock.json index d7f1f7d..2c3d2be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,6 @@ "clsx": "^2.1.1", "lucide-react": "^0.462.0", "next": "15.3.4", - "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "sonner": "^1.5.0", diff --git a/package.json b/package.json index 98aa1c1..d7a471f 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "clsx": "^2.1.1", "lucide-react": "^0.462.0", "next": "15.3.4", - "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "sonner": "^1.5.0", diff --git a/public/qr.jpeg b/public/qr.jpeg new file mode 100644 index 0000000..2e76b83 Binary files /dev/null and b/public/qr.jpeg differ diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx deleted file mode 100644 index 5abc48d..0000000 --- a/src/app/admin/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Toaster } from "@/components/landing/ui/sonner"; - -export default function AdminLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( -
- {children} - -
- ); -} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx deleted file mode 100644 index 3e18c75..0000000 --- a/src/app/admin/page.tsx +++ /dev/null @@ -1,1946 +0,0 @@ -"use client"; - -import { useState, useEffect, useCallback } from "react"; -import svg2vectordrawable from "svg2vectordrawable"; -import { - getGuides, - deleteGuides, - uploadGuidePair, -} from "@/app/services/apis/guide"; -import { - Guide, - FoodCategory, - FOOD_CATEGORY_OPTIONS, -} from "@/app/services/types/guide"; -import { toast } from "sonner"; -import { Button } from "@/components/landing/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/landing/ui/card"; -import { Input } from "@/components/landing/ui/input"; -import { Label } from "@/components/landing/ui/label"; -import { Checkbox } from "@/components/landing/ui/checkbox"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/landing/ui/dialog"; -import Image from "next/image"; - -// SVG-이미지 나란히 보기 컴포넌트 -const SvgImagePreview = ({ - guide, - className, -}: { - guide: Guide; - className?: string; -}) => { - const [svgError, setSvgError] = useState(false); - const [svgLoading, setSvgLoading] = useState(true); - - const handleSvgLoad = () => { - console.log("SVG object 로드 성공"); - setSvgLoading(false); - }; - - const handleSvgError = () => { - console.log("SVG object 로드 실패"); - setSvgError(true); - setSvgLoading(false); - }; - - return ( -
- {/* SVG 미리보기 */} -
-
- SVG -
- {!svgError ? ( - <> - - {svgLoading && ( -
-
SVG 로딩 중...
-
- )} - - ) : ( -
-
SVG 로드 실패
-
- )} - - - {/* 이미지 미리보기 */} -
-
- IMG -
- {guide.fileName} -
- - ); -}; - -export default function AdminPage() { - const [mounted, setMounted] = useState(false); - const [guides, setGuides] = useState([]); - const [loading, setLoading] = useState(true); - - // 토큰 관리 상태 - const [authToken, setAuthToken] = useState(""); - const [tokenInput, setTokenInput] = useState(""); - const [tokenStatus, setTokenStatus] = useState<"none" | "valid" | "invalid">( - "none" - ); - - // 뷰 모드 상태 (그리드/리스트) - const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); - - // 선택된 가이드 관리 - const [selectedGuides, setSelectedGuides] = useState([]); - - // 일괄삭제 모달 관리 - const [showBatchDeleteModal, setShowBatchDeleteModal] = useState(false); - const [batchDeleteIds, setBatchDeleteIds] = useState(""); - - // 정렬 상태 - const [sortBy, setSortBy] = useState<"id" | "name" | "category">("id"); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); - - // 파일 트리플 업로드 관련 상태 (SVG, XML, 이미지) - interface FileUploadTriple { - id: string; - svgFile: File | null; // SVG 파일 (UI 미리보기용) - xmlFile: File | null; // XML 파일 (안드로이드용, SVG에서 자동 변환) - imageFile: File | null; // 이미지 파일 - fileName: string; - uploading: boolean; - progress: number; - error: string | null; - completed: boolean; - nameMatchError: boolean; - category: FoodCategory; // 선택된 카테고리 - content?: string; // 가이드 설명 - tags: string[]; // 태그 목록 - tagInput: string; // 태그 입력 필드 - isComposing: boolean; // 한글 입력 상태 관리 - } - - const [uploadTriples, setUploadTriples] = useState([ - { - id: "triple-1", - svgFile: null, - xmlFile: null, - imageFile: null, - fileName: "", - uploading: false, - progress: 0, - error: null, - completed: false, - nameMatchError: false, - category: FoodCategory.COFFEE, - content: "", - tags: [], - tagInput: "", - isComposing: false, - }, - ]); - - // 클라이언트에서만 렌더링하도록 설정 - useEffect(() => { - setMounted(true); - - // 로컬 스토리지에서 저장된 토큰 불러오기 - const savedToken = localStorage.getItem("admin_auth_token"); - if (savedToken) { - setAuthToken(savedToken); - setTokenInput(savedToken); - setTokenStatus("valid"); - } - }, []); - - // 토큰 저장 함수 - const saveToken = () => { - if (!tokenInput.trim()) { - toast.error("토큰을 입력해주세요."); - return; - } - - try { - localStorage.setItem("admin_auth_token", tokenInput.trim()); - setAuthToken(tokenInput.trim()); - setTokenStatus("valid"); - toast.success("토큰이 저장되었습니다."); - - // 토큰 저장 후 가이드 목록 다시 로드 - loadGuides(); - } catch (error) { - console.error("Failed to save token:", error); - toast.error("토큰 저장에 실패했습니다."); - } - }; - - // 토큰 제거 함수 - const removeToken = () => { - localStorage.removeItem("admin_auth_token"); - setAuthToken(""); - setTokenInput(""); - setTokenStatus("none"); - setGuides([]); - toast.success("토큰이 제거되었습니다."); - }; - - // 토큰 테스트 함수 - const testToken = async () => { - if (!authToken) { - toast.error("저장된 토큰이 없습니다."); - return; - } - - try { - // 간단한 API 호출로 토큰 유효성 테스트 - await getGuides({ page: 0, size: 1 }); - setTokenStatus("valid"); - toast.success("토큰이 유효합니다."); - } catch (error) { - setTokenStatus("invalid"); - toast.error("토큰이 유효하지 않습니다."); - } - }; - - // 가이드 목록 로드 - const loadGuides = useCallback(async () => { - if (!authToken) { - setLoading(false); - return; - } - - try { - setLoading(true); - const response = await getGuides({ page: 0, size: 50 }); - setGuides(response.content); - setSelectedGuides([]); // 가이드 목록이 변경될 때 선택 상태 초기화 - setTokenStatus("valid"); - } catch (error) { - console.error("Failed to load guides:", error); - setTokenStatus("invalid"); - toast.error( - "가이드 목록을 불러오는데 실패했습니다. 토큰을 확인해주세요." - ); - } finally { - setLoading(false); - } - }, [authToken]); - - useEffect(() => { - if (mounted && authToken) { - loadGuides(); - } - }, [mounted, authToken, loadGuides]); - - // 새 파일 트리플 추가 - const addNewTriple = () => { - const newTriple: FileUploadTriple = { - id: `triple-${Date.now()}`, - svgFile: null, - xmlFile: null, - imageFile: null, - fileName: "", - uploading: false, - progress: 0, - error: null, - completed: false, - nameMatchError: false, - category: FoodCategory.COFFEE, - content: "", - tags: [], - tagInput: "", - isComposing: false, - }; - setUploadTriples((prev) => [...prev, newTriple]); - }; - - // 파일 트리플 제거 - const removeTriple = (id: string) => { - setUploadTriples((prev) => prev.filter((triple) => triple.id !== id)); - }; - - // SVG를 안드로이드 Vector Drawable XML로 변환하는 함수 - const convertSvgToAndroidXml = async (svgFile: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = async (e) => { - try { - const svgContent = e.target?.result as string; - const xmlContent = await svg2vectordrawable(svgContent); - - // 새로운 XML 파일명 생성 - const originalName = svgFile.name.replace(/\.svg$/i, ""); - const xmlFileName = `${originalName}.xml`; - - // Blob을 File로 변환 - const blob = new Blob([xmlContent], { type: "application/xml" }); - const xmlFile = new File([blob], xmlFileName, { - type: "application/xml", - lastModified: Date.now(), - }); - - resolve(xmlFile); - } catch (error) { - console.error("SVG 파싱 오류:", error); - reject( - new Error( - "SVG를 안드로이드 Vector Drawable로 변환 중 오류가 발생했습니다." - ) - ); - } - }; - - reader.onerror = () => { - reject(new Error("파일 읽기 중 오류가 발생했습니다.")); - }; - - reader.readAsText(svgFile); - }); - }; - - // 파일명 매칭 검증 함수 (3개 파일) - const validateTripleFileNameMatch = ( - svgFile: File | null, - imageFile: File | null - ) => { - if (!svgFile || !imageFile) return { isValid: true, fileName: "" }; - - const svgName = svgFile.name.replace(/\.svg$/i, ""); - const imageName = imageFile.name.replace(/\.(png|jpg|jpeg)$/i, ""); - - return { - isValid: svgName === imageName, - fileName: svgName === imageName ? svgName : "", - }; - }; - - // SVG 파일 선택 (XML은 자동 생성) - const handleSvgSelect = async (tripleId: string, file: File) => { - try { - // SVG에서 XML 자동 생성 - const xmlFile = await convertSvgToAndroidXml(file); - - setUploadTriples((prev) => - prev.map((triple) => { - if (triple.id === tripleId) { - const validation = validateTripleFileNameMatch( - file, - triple.imageFile - ); - return { - ...triple, - svgFile: file, - xmlFile: xmlFile, - fileName: validation.fileName, - nameMatchError: !validation.isValid, - }; - } - return triple; - }) - ); - - toast.success(`${file.name}을 선택하고 XML을 자동 생성했습니다.`); - } catch (error) { - console.error("SVG 처리 중 오류:", error); - toast.error( - error instanceof Error - ? error.message - : "SVG 처리 중 오류가 발생했습니다." - ); - } - }; - - // 이미지 파일 선택 - const handleImageSelect = (tripleId: string, file: File) => { - setUploadTriples((prev) => - prev.map((triple) => { - if (triple.id === tripleId) { - const validation = validateTripleFileNameMatch(triple.svgFile, file); - return { - ...triple, - imageFile: file, - fileName: validation.fileName, - nameMatchError: !validation.isValid, - }; - } - return triple; - }) - ); - }; - - // SVG 파일 제거 (XML도 함께 제거) - const removeSvgFile = (tripleId: string) => { - setUploadTriples((prev) => - prev.map((triple) => { - if (triple.id === tripleId) { - // 이미지 파일이 있으면 이미지 파일명으로, 없으면 빈 문자열 - const fileName = triple.imageFile - ? triple.imageFile.name.replace(/\.(png|jpg|jpeg)$/i, "") - : ""; - return { - ...triple, - svgFile: null, - xmlFile: null, // SVG가 제거되면 XML도 함께 제거 - fileName, - nameMatchError: false, - }; - } - return triple; - }) - ); - // 파일 input 초기화 - const input = document.getElementById( - `svg-${tripleId}` - ) as HTMLInputElement; - if (input) input.value = ""; - }; - - // 이미지 파일 제거 - const removeImageFile = (tripleId: string) => { - setUploadTriples((prev) => - prev.map((triple) => { - if (triple.id === tripleId) { - // SVG 파일이 있으면 SVG 파일명으로, 없으면 빈 문자열 - const fileName = triple.svgFile - ? triple.svgFile.name.replace(/\.svg$/i, "") - : ""; - return { - ...triple, - imageFile: null, - fileName, - nameMatchError: false, - }; - } - return triple; - }) - ); - // 파일 input 초기화 - const input = document.getElementById( - `image-${tripleId}` - ) as HTMLInputElement; - if (input) input.value = ""; - }; - - // 개별 파일 트리플 업로드 - const uploadTriple = async (triple: FileUploadTriple) => { - if (!triple.imageFile || !triple.xmlFile || !triple.svgFile || !authToken) - return; - - setUploadTriples((prev) => - prev.map((t) => - t.id === triple.id - ? { ...t, uploading: true, progress: 0, error: null } - : t - ) - ); - - try { - const guide = await uploadGuidePair( - triple.imageFile, - triple.xmlFile!, - triple.svgFile!, - triple.fileName, - triple.category, - triple.content, - triple.tags, - (progress) => { - setUploadTriples((prev) => - prev.map((t) => (t.id === triple.id ? { ...t, progress } : t)) - ); - } - ); - - setUploadTriples((prev) => - prev.map((t) => - t.id === triple.id - ? { ...t, progress: 100, completed: true, uploading: false } - : t - ) - ); - - // 개별 업로드 완료 후 가이드 목록 새로고침 - toast.success(`${triple.fileName} 업로드 완료`); - loadGuides(); - } catch (error) { - console.error("업로드 실패:", error); - setUploadTriples((prev) => - prev.map((t) => - t.id === triple.id - ? { - ...t, - uploading: false, - error: error instanceof Error ? error.message : "업로드 실패", - } - : t - ) - ); - toast.error(`${triple.fileName} 업로드 실패`); - } - window.location.reload(); - }; - - // 전체 업로드 - const uploadAll = async () => { - const validTriples = uploadTriples.filter( - (triple) => - triple.imageFile && - triple.xmlFile && - triple.svgFile && - !triple.completed && - !triple.nameMatchError - ); - - let successCount = 0; - for (const triple of validTriples) { - try { - await uploadTriple(triple); - successCount++; - } catch (error) { - console.error(`Failed to upload triple ${triple.fileName}:`, error); - } - } - - // 업로드 완료 후 가이드 목록 새로고침 (성공한 업로드가 있을 때만) - if (successCount > 0) { - toast.success( - `${successCount}개의 파일 트리플이 성공적으로 업로드되었습니다.` - ); - loadGuides(); - } - window.location.reload(); - }; - - // 단일 가이드 삭제 처리 - const handleDeleteGuide = async (guide: Guide) => { - if (!authToken) { - toast.error("먼저 인증 토큰을 설정해주세요."); - return; - } - - if (!confirm(`${guide.fileName}을(를) 삭제하시겠습니까?`)) { - return; - } - - try { - await deleteGuides([guide.guideId]); - setGuides((prev) => prev.filter((g) => g.guideId !== guide.guideId)); - setSelectedGuides((prev) => prev.filter((id) => id !== guide.guideId)); - toast.success("가이드가 삭제되었습니다."); - } catch (error) { - console.error("Failed to delete guide:", error); - toast.error("가이드 삭제에 실패했습니다."); - } - }; - - // 선택된 가이드들 다중 삭제 - const handleDeleteSelectedGuides = async () => { - if (!authToken) { - toast.error("먼저 인증 토큰을 설정해주세요."); - return; - } - - if (selectedGuides.length === 0) { - toast.error("삭제할 가이드를 선택해주세요."); - return; - } - - if ( - !confirm(`선택된 ${selectedGuides.length}개의 가이드를 삭제하시겠습니까?`) - ) { - return; - } - - try { - await deleteGuides(selectedGuides); - setGuides((prev) => - prev.filter((g) => !selectedGuides.includes(g.guideId)) - ); - setSelectedGuides([]); - toast.success(`${selectedGuides.length}개의 가이드가 삭제되었습니다.`); - } catch (error) { - console.error("Failed to delete guides:", error); - toast.error("가이드 삭제에 실패했습니다."); - } - }; - - // 가이드 선택/해제 - const handleSelectGuide = (guideId: number, checked: boolean) => { - if (checked) { - setSelectedGuides((prev) => [...prev, guideId]); - } else { - setSelectedGuides((prev) => prev.filter((id) => id !== guideId)); - } - }; - - // 전체 선택/해제 - const handleSelectAll = (checked: boolean) => { - if (checked) { - setSelectedGuides(guides.map((g) => g.guideId)); - } else { - setSelectedGuides([]); - } - }; - - // ID 목록으로 일괄삭제 - const handleBatchDeleteByIds = async () => { - if (!authToken) { - toast.error("먼저 인증 토큰을 설정해주세요."); - return; - } - - if (!batchDeleteIds.trim()) { - toast.error("삭제할 가이드 ID를 입력해주세요."); - return; - } - - try { - // 쉼표로 구분된 ID들을 파싱 - const idsArray = batchDeleteIds - .split(",") - .map((id) => parseInt(id.trim())) - .filter((id) => !isNaN(id) && id > 0); - - if (idsArray.length === 0) { - toast.error("유효한 가이드 ID를 입력해주세요."); - return; - } - - // 존재하지 않는 ID 확인 - const existingIds = guides.map((g) => g.guideId); - const invalidIds = idsArray.filter((id) => !existingIds.includes(id)); - - if (invalidIds.length > 0) { - toast.error(`존재하지 않는 ID: ${invalidIds.join(", ")}`); - return; - } - - if ( - !confirm( - `입력된 ${ - idsArray.length - }개의 가이드를 삭제하시겠습니까?\nID: ${idsArray.join(", ")}` - ) - ) { - return; - } - - await deleteGuides(idsArray); - setGuides((prev) => prev.filter((g) => !idsArray.includes(g.guideId))); - setSelectedGuides((prev) => prev.filter((id) => !idsArray.includes(id))); - setBatchDeleteIds(""); - setShowBatchDeleteModal(false); - toast.success(`${idsArray.length}개의 가이드가 삭제되었습니다.`); - } catch (error) { - console.error("Failed to delete guides by IDs:", error); - toast.error("가이드 삭제에 실패했습니다."); - } - }; - - // 전체 가이드 삭제 - const handleDeleteAllGuides = async () => { - if (!authToken) { - toast.error("먼저 인증 토큰을 설정해주세요."); - return; - } - - if (guides.length === 0) { - toast.error("삭제할 가이드가 없습니다."); - return; - } - - if ( - !confirm( - `정말로 모든 가이드 ${guides.length}개를 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.` - ) - ) { - return; - } - - try { - const allIds = guides.map((g) => g.guideId); - await deleteGuides(allIds); - setGuides([]); - setSelectedGuides([]); - toast.success(`모든 가이드 ${allIds.length}개가 삭제되었습니다.`); - } catch (error) { - console.error("Failed to delete all guides:", error); - toast.error("전체 가이드 삭제에 실패했습니다."); - } - }; - - // 카테고리별 삭제 - const handleDeleteByCategory = async (categoryName: string) => { - if (!authToken) { - toast.error("먼저 인증 토큰을 설정해주세요."); - return; - } - - const categoryGuides = guides.filter( - (g) => g.categoryName === categoryName - ); - - if (categoryGuides.length === 0) { - toast.error(`'${categoryName}' 카테고리에 삭제할 가이드가 없습니다.`); - return; - } - - if ( - !confirm( - `'${categoryName}' 카테고리의 가이드 ${categoryGuides.length}개를 모두 삭제하시겠습니까?` - ) - ) { - return; - } - - try { - const categoryIds = categoryGuides.map((g) => g.guideId); - await deleteGuides(categoryIds); - setGuides((prev) => prev.filter((g) => !categoryIds.includes(g.guideId))); - setSelectedGuides((prev) => - prev.filter((id) => !categoryIds.includes(id)) - ); - toast.success( - `'${categoryName}' 카테고리의 가이드 ${categoryIds.length}개가 삭제되었습니다.` - ); - } catch (error) { - console.error("Failed to delete guides by category:", error); - toast.error("카테고리별 가이드 삭제에 실패했습니다."); - } - }; - - // 카테고리별 선택 - const handleSelectByCategory = (categoryName: string) => { - const categoryIds = guides - .filter((g) => g.categoryName === categoryName) - .map((g) => g.guideId); - - setSelectedGuides((prev) => { - const newSelection = [...prev]; - categoryIds.forEach((id) => { - if (!newSelection.includes(id)) { - newSelection.push(id); - } - }); - return newSelection; - }); - toast.success( - `'${categoryName}' 카테고리의 가이드 ${categoryIds.length}개를 선택했습니다.` - ); - }; - - // 선택된 가이드 ID 복사 - const copySelectedIds = async () => { - if (selectedGuides.length === 0) { - toast.error("선택된 가이드가 없습니다."); - return; - } - - try { - const idsText = selectedGuides.join(", "); - await navigator.clipboard.writeText(idsText); - toast.success(`선택된 가이드 ID가 복사되었습니다: ${idsText}`); - } catch (error) { - toast.error("클립보드 복사에 실패했습니다."); - } - }; - - // 가이드 정렬 함수 - const sortedGuides = [...guides].sort((a, b) => { - let comparison = 0; - - switch (sortBy) { - case "id": - comparison = a.guideId - b.guideId; - break; - case "name": - comparison = a.fileName.localeCompare(b.fileName); - break; - case "category": - comparison = - a.categoryName.localeCompare(b.categoryName) || - a.subCategoryName.localeCompare(b.subCategoryName) || - a.fileName.localeCompare(b.fileName); - break; - default: - return 0; - } - - return sortOrder === "asc" ? comparison : -comparison; - }); - - // 정렬 변경 핸들러 - const handleSortChange = (newSortBy: "id" | "name" | "category") => { - if (sortBy === newSortBy) { - setSortOrder(sortOrder === "asc" ? "desc" : "asc"); - } else { - setSortBy(newSortBy); - setSortOrder("asc"); - } - }; - - // 토큰 상태에 따른 색상 - const getTokenStatusColor = () => { - switch (tokenStatus) { - case "valid": - return "text-green-600"; - case "invalid": - return "text-red-600"; - default: - return "text-gray-500"; - } - }; - - const getTokenStatusText = () => { - switch (tokenStatus) { - case "valid": - return "✅ 유효한 토큰"; - case "invalid": - return "❌ 유효하지 않은 토큰"; - default: - return "⚠️ 토큰 없음"; - } - }; - - // 가이드 목록에서 XML 다운로드 함수 - const handleGuideXmlDownload = async (guide: Guide) => { - try { - const xmlUrl = `https://cdn.chalpu.com/${guide.guideS3Key}`; - - // XML 파일 내용을 가져옵니다 (이미 서버에서 변환된 XML) - const response = await fetch(xmlUrl); - if (!response.ok) { - throw new Error(`XML 파일을 가져올 수 없습니다: ${response.status}`); - } - - const xmlContent = await response.text(); - - // 파일명 설정 (확장자를 .xml로 보장) - const fileName = guide.fileName.endsWith('.xml') - ? guide.fileName - : guide.fileName.replace(/\.[^.]+$/, '.xml'); - - // Blob을 생성하고 다운로드합니다 - const blob = new Blob([xmlContent], { type: "application/xml" }); - const url = URL.createObjectURL(blob); - - const link = document.createElement("a"); - link.href = url; - link.download = fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - - toast.success(`${fileName} 파일이 다운로드되었습니다.`); - } catch (error) { - console.error("XML 다운로드 실패:", error); - toast.error("XML 파일 다운로드에 실패했습니다."); - } - }; - - // 클라이언트에서만 렌더링 (하이드레이션 에러 방지) - if (!mounted) { - return null; - } - - return ( -
-
- {/* 헤더 */} -
-

가이드 관리

-

- XML 가이드 파일을 업로드하고 관리하세요. -

-
- - {/* 토큰 설정 영역 */} - - - - 🔐 인증 토큰 설정 - - {getTokenStatusText()} - - - - API 요청을 위한 인증 토큰을 설정하세요. 토큰은 브라우저에 안전하게 - 저장됩니다. - - - -
-
-
- - setTokenInput(e.target.value)} - className="font-mono" - /> -
- - {authToken && ( - <> - - - - )} -
- - {authToken && ( -
-

- 현재 토큰:{" "} - - {authToken.substring(0, 20)}... - -

-
- )} -
-
-
- - {/* 토큰이 없을 때 경고 메시지 */} - {!authToken && ( - - -
- ⚠️ -

- API를 사용하려면 먼저 인증 토큰을 설정해주세요. -

-
-
-
- )} - - {/* 파일 업로드 영역 */} - - - 파일 업로드 - - 이미지(PNG/JPG)와 XML/SVG 파일을 쌍으로 업로드하세요. SVG 파일은 - 자동으로 XML로 변환됩니다. 파일 이름은 동일해야 하며, 카테고리와 - 설명, 태그를 추가로 설정할 수 있습니다. - - - -
- {uploadTriples.map((triple, index) => ( -
-
-

- 파일 트리플 {index + 1} - {triple.fileName && ( - - ({triple.fileName}) - - )} -

- {uploadTriples.length > 1 && ( - - )} -
- -
- {/* SVG 파일 선택 */} -
- -
- { - const file = e.target.files?.[0]; - if (file) handleSvgSelect(triple.id, file); - }} - className="w-full p-2 border rounded-md text-sm" - disabled={triple.uploading || triple.completed} - /> - {triple.svgFile && ( -
- - ✓ {triple.svgFile.name} - - -
- )} -
-
- - {/* XML 파일 상태 (자동 생성) */} -
- -
-
- SVG에서 자동 생성됨 -
- {triple.xmlFile && ( -
-
- - ✓ {triple.xmlFile.name} - - 자동 생성됨 - - -
-
- )} -
-
- - {/* 이미지 파일 선택 */} -
- -
- { - const file = e.target.files?.[0]; - if (file) handleImageSelect(triple.id, file); - }} - className="w-full p-2 border rounded-md text-sm" - disabled={triple.uploading || triple.completed} - /> - {triple.imageFile && ( -
- - ✓ {triple.imageFile.name} - - -
- )} -
-
-
- - {/* 카테고리 및 추가 정보 입력 */} -
- {/* 카테고리 선택 */} -
- - -
- - {/* 설명 입력 */} -
- - { - setUploadTriples((prev) => - prev.map((t) => - t.id === triple.id - ? { ...t, content: e.target.value } - : t - ) - ); - }} - disabled={triple.uploading || triple.completed} - className="text-sm" - /> -
-
- - {/* 태그 입력 */} -
- - { - setUploadTriples((prev) => - prev.map((t) => - t.id === triple.id - ? { ...t, tagInput: e.target.value } - : t - ) - ); - }} - onCompositionStart={() => { - setUploadTriples((prev) => - prev.map((t) => - t.id === triple.id ? { ...t, isComposing: true } : t - ) - ); - }} - onCompositionEnd={() => { - setUploadTriples((prev) => - prev.map((t) => - t.id === triple.id - ? { ...t, isComposing: false } - : t - ) - ); - }} - onKeyDown={(e) => { - // composition 중이면 태그 추가하지 않음 - if (triple.isComposing) return; - - if (e.key === "Enter" || e.key === ",") { - e.preventDefault(); - const newTag = triple.tagInput.trim(); - if (newTag && !triple.tags.includes(newTag)) { - setUploadTriples((prev) => - prev.map((t) => - t.id === triple.id - ? { - ...t, - tags: [...t.tags, newTag], - tagInput: "", - } - : t - ) - ); - } else if (newTag) { - // 이미 존재하는 태그인 경우 입력 필드만 초기화 - setUploadTriples((prev) => - prev.map((t) => - t.id === triple.id ? { ...t, tagInput: "" } : t - ) - ); - } - } else if ( - e.key === "Backspace" && - triple.tagInput === "" && - triple.tags.length > 0 - ) { - // 입력 필드가 비어있고 백스페이스를 누르면 마지막 태그 삭제 - setUploadTriples((prev) => - prev.map((t) => - t.id === triple.id - ? { - ...t, - tags: t.tags.slice(0, -1), - } - : t - ) - ); - } - }} - onBlur={() => { - // 포커스를 잃을 때도 태그 추가 (composition 중이 아닐 때만) - if (!triple.isComposing) { - const newTag = triple.tagInput.trim(); - if (newTag && !triple.tags.includes(newTag)) { - setUploadTriples((prev) => - prev.map((t) => - t.id === triple.id - ? { - ...t, - tags: [...t.tags, newTag], - tagInput: "", - } - : t - ) - ); - } - } - }} - disabled={triple.uploading || triple.completed} - className="text-sm" - /> - - {/* 태그 표시 */} - {triple.tags.length > 0 && ( -
- {triple.tags.map((tag, tagIndex) => ( - - {tag} - - - ))} -
- )} - - {/* 태그 입력 도움말 */} -
- • Enter 키나 쉼표로 태그 추가 • 백스페이스로 마지막 태그 - 삭제 • 중복 태그는 자동으로 제거됩니다 -
-
- - {/* 파일명 불일치 에러 메시지 */} - {triple.nameMatchError && ( -
-
- - - - 파일명이 일치하지 않습니다. SVG와 이미지 파일의 - 이름(확장자 제외)이 동일해야 합니다. -
-
- )} - - {/* 업로드 상태 */} - {triple.uploading && ( -
-
- - 업로드 중... - - - {triple.progress}% - -
-
-
-
-
- )} - - {/* 완료 상태 */} - {triple.completed && ( -
-
- - - - 업로드 완료 -
-
- )} - - {/* 에러 상태 */} - {triple.error && ( -
-
- - - - {triple.error} -
-
- )} - - {/* 개별 업로드 버튼 */} - {triple.imageFile && - triple.xmlFile && - triple.svgFile && - !triple.completed && ( -
- -
- )} -
- ))} - - {/* 컨트롤 버튼들 */} -
- - - -
-
- - - - {/* 가이드 목록 */} - - -
-
- 가이드 목록 - - 업로드된 가이드를 확인하고 관리하세요. - -
-
- {/* 정렬 옵션 */} -
- 정렬: -
- - - -
-
- - {/* 뷰 모드 */} -
- - -
-
-
- {authToken && sortedGuides.length > 0 && ( -
- {/* 선택 기반 삭제 */} -
-
-
- 0 - } - onCheckedChange={handleSelectAll} - /> - -
- - {/* 빠른 선택 버튼들 */} -
- {selectedGuides.length > 0 && ( - <> - - - - )} - - {/* 카테고리별 빠른 선택 */} - {(() => { - const categories = [ - ...new Set(guides.map((g) => g.categoryName)), - ]; - return ( - categories.length > 1 && ( -
- -
- ) - ); - })()} -
-
- {selectedGuides.length > 0 && ( - - )} -
- - {/* 일괄삭제 옵션들 */} -
- {/* ID 목록으로 삭제 */} - - - - - - - ID 목록으로 일괄삭제 - - 삭제할 가이드의 ID를 쉼표로 구분하여 입력하세요. - - -
-
- - setBatchDeleteIds(e.target.value)} - /> -
- {selectedGuides.length > 0 && ( - - )} - -
-
-
-
- 현재 가이드 ID:{" "} - {guides.map((g) => g.guideId).join(", ")} -
- {selectedGuides.length > 0 && ( -
- 선택된 ID: {selectedGuides.join(", ")} -
- )} -
-
- - - - -
-
- - {/* 전체 삭제 */} - - - {/* 카테고리별 삭제 - 드롭다운 메뉴 */} - {(() => { - const categories = [ - ...new Set(guides.map((g) => g.categoryName)), - ]; - return ( - categories.length > 1 && ( -
- -
- ) - ); - })()} -
-
- )} -
- - {!authToken ? ( -
- 인증 토큰을 설정하면 가이드 목록을 확인할 수 있습니다. -
- ) : loading ? ( -
-
-

로딩 중...

-
- ) : sortedGuides.length === 0 ? ( -
- 업로드된 가이드가 없습니다. -
- ) : ( -
- {viewMode === "grid" ? ( -
- {sortedGuides.map((guide, index) => ( -
-
-
- - handleSelectGuide( - guide.guideId, - checked as boolean - ) - } - /> -
-
- -
-
-
-

- {guide.fileName} -

-
-

ID: {guide.guideId}

-

- 카테고리(메인 - 서브): {guide.categoryName} -{" "} - {guide.subCategoryName} -

- {guide.content &&

설명: {guide.content}

} - {guide.tags &&

태그: {guide.tags.join(", ")}

} -
-
- - -
-
-
- ))} -
- ) : ( -
- {sortedGuides.map((guide, index) => ( -
-
- - handleSelectGuide( - guide.guideId, - checked as boolean - ) - } - /> -
- -
-
-

- {guide.fileName} -

-
- ID: {guide.guideId} - - 카테고리(메인 - 서브): {guide.categoryName} -{" "} - {guide.subCategoryName} - - {guide.content && ( - 설명: {guide.content} - )} - {guide.tags && ( - 태그: {guide.tags.join(", ")} - )} -
-
-
-
-
- - -
-
-
- ))} -
- )} -
- )} -
-
-
-
- ); -} diff --git a/src/app/admin/test/page.tsx b/src/app/admin/test/page.tsx deleted file mode 100644 index 8c95629..0000000 --- a/src/app/admin/test/page.tsx +++ /dev/null @@ -1,295 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { toast } from "sonner"; -import { Button } from "@/components/landing/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/landing/ui/card"; -import { Input } from "@/components/landing/ui/input"; -import { Label } from "@/components/landing/ui/label"; -import svg2vectordrawable from "svg2vectordrawable"; - -export default function SvgToXmlPage() { - const [svgFile, setSvgFile] = useState(null); - const [xmlContent, setXmlContent] = useState(""); - const [converting, setConverting] = useState(false); - const [previewSvg, setPreviewSvg] = useState(""); - - // SVG를 안드로이드 Vector Drawable XML로 변환하는 함수 - const convertSvgToAndroidXml = async (svgFile: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (e) => { - try { - const svgContent = e.target?.result as string; - const result = svg2vectordrawable(svgContent); - console.log(result); - resolve(result); - } catch (error) { - console.error("SVG 파싱 오류:", error); - reject( - new Error( - "SVG를 안드로이드 Vector Drawable로 변환 중 오류가 발생했습니다." - ) - ); - } - }; - - reader.onerror = () => { - reject(new Error("파일 읽기 중 오류가 발생했습니다.")); - }; - - reader.readAsText(svgFile); - }); - }; - - // SVG 파일 선택 - const handleSvgSelect = (file: File) => { - setSvgFile(file); - setXmlContent(""); - - // SVG 미리보기 생성 - const reader = new FileReader(); - reader.onload = (e) => { - setPreviewSvg(e.target?.result as string); - }; - reader.readAsText(file); - }; - - // SVG to XML 변환 - const handleConvert = async () => { - if (!svgFile) { - toast.error("SVG 파일을 선택해주세요."); - return; - } - - setConverting(true); - try { - const xmlResult = await convertSvgToAndroidXml(svgFile); - setXmlContent(xmlResult); - toast.success("SVG가 성공적으로 XML로 변환되었습니다!"); - } catch (error) { - console.error("변환 실패:", error); - toast.error( - error instanceof Error - ? error.message - : "SVG 변환 중 오류가 발생했습니다." - ); - } finally { - setConverting(false); - } - }; - - // XML 다운로드 - const handleDownload = () => { - if (!xmlContent || !svgFile) return; - - const fileName = svgFile.name.replace(/\.svg$/i, ".xml"); - const blob = new Blob([xmlContent], { type: "application/xml" }); - const url = URL.createObjectURL(blob); - - const link = document.createElement("a"); - link.href = url; - link.download = fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - - toast.success(`${fileName} 파일이 다운로드되었습니다.`); - }; - - // 파일 제거 - const handleRemoveFile = () => { - setSvgFile(null); - setXmlContent(""); - setPreviewSvg(""); - - // 파일 input 초기화 - const input = document.getElementById("svg-file") as HTMLInputElement; - if (input) input.value = ""; - }; - - return ( -
-
- {/* 헤더 */} -
-

SVG to XML Converter

-

- SVG 파일을 안드로이드 Vector Drawable XML로 변환하세요 -

-
- -
- {/* 파일 업로드 및 변환 */} - - - SVG 파일 업로드 - - 변환할 SVG 파일을 선택하고 XML로 변환하세요 - - - - {/* 파일 선택 */} -
- - { - const file = e.target.files?.[0]; - if (file) handleSvgSelect(file); - }} - className="cursor-pointer" - /> -
- - {/* 선택된 파일 정보 */} - {svgFile && ( -
-
- - ✓ {svgFile.name} ({(svgFile.size / 1024).toFixed(1)}KB) - - -
-
- )} - - {/* SVG 미리보기 */} - {previewSvg && ( -
- -
-
-
-
- )} - - {/* 변환 버튼 */} - - - - - {/* XML 결과 및 다운로드 */} - - - 변환된 XML - - 안드로이드 Vector Drawable XML 결과 - - - - {xmlContent ? ( - <> - {/* XML 미리보기 */} -
- -