├── .eslintrc.json ├── pnpm-lock.yaml ├── public └── uploads │ └── .gitkeep ├── postcss.config.mjs ├── lib ├── utils.ts ├── constants.ts ├── metadata.ts ├── reset-images.ts └── cleanup-storage.ts ├── next.config.mjs ├── components ├── theme-provider.tsx ├── site-title.tsx ├── ui │ ├── toaster.tsx │ ├── card.tsx │ ├── button.tsx │ ├── use-toast.ts │ └── toast.tsx ├── save-to-file-button.tsx ├── theme-toggle.tsx ├── save-field-button.tsx ├── global-save-button.tsx ├── editable │ ├── editable-text.tsx │ ├── editable-icon.tsx │ ├── editable-list.tsx │ ├── editable-media.tsx │ └── editable-background.tsx ├── navbar.tsx ├── footer.tsx ├── header.tsx └── hero.tsx ├── .vscode └── settings.json ├── .gitignore ├── components.json ├── app ├── page.tsx ├── safelist.css ├── api │ ├── delete-image │ │ └── route.ts │ ├── og-image │ │ └── route.ts │ ├── upload-video │ │ └── route.ts │ ├── upload-image │ │ └── route.ts │ ├── update-field │ │ └── route.ts │ └── update-component │ │ └── route.ts ├── layout.tsx └── globals.css ├── tsconfig.json ├── hooks └── use-toast.ts ├── package.json ├── contexts └── inline-editor-context.tsx ├── README.md └── GEMINI.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false -------------------------------------------------------------------------------- /public/uploads/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file ensures the uploads directory structure is maintained in git 2 | # All uploaded files in this directory will be included in your repository -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | '@tailwindcss/postcss': {}, 5 | }, 6 | } 7 | 8 | export default config 9 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | eslint: { 4 | ignoreDuringBuilds: true, 5 | }, 6 | typescript: { 7 | ignoreBuildErrors: true, 8 | }, 9 | images: { 10 | unoptimized: true, 11 | }, 12 | } 13 | 14 | export default nextConfig 15 | -------------------------------------------------------------------------------- /components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { 5 | ThemeProvider as NextThemesProvider, 6 | type ThemeProviderProps, 7 | } from 'next-themes' 8 | 9 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 10 | return {children} 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "css.validate": false, 3 | "tailwindCSS.includeLanguages": { 4 | "html": "html", 5 | "javascript": "javascript", 6 | "typescript": "typescript", 7 | "javascriptreact": "javascript", 8 | "typescriptreact": "typescript" 9 | }, 10 | "editor.quickSuggestions": { 11 | "strings": true 12 | }, 13 | "files.associations": { 14 | "*.css": "tailwindcss" 15 | } 16 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # next.js 7 | /.next/ 8 | /out/ 9 | 10 | # production 11 | /build 12 | 13 | # debug 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | .pnpm-debug.log* 18 | 19 | # env files 20 | .env* 21 | 22 | # vercel 23 | .vercel 24 | 25 | # typescript 26 | *.tsbuildinfo 27 | next-env.d.ts -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib" 18 | }, 19 | "iconLibrary": "lucide" 20 | } -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Header } from "@/components/header" 2 | import { Hero } from "@/components/hero" 3 | import { About } from "@/components/about" 4 | import { Projects } from "@/components/projects" 5 | import { Contact } from "@/components/contact" 6 | import { Footer } from "@/components/footer" 7 | 8 | export default function Home() { 9 | return ( 10 |
11 |
12 | 13 | 14 | 15 | 16 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | // 공통 스타일 상수 2 | export const COMMON_STYLES = { 3 | // 삭제 버튼 (X) 스타일 - 모든 섹션에서 통일 4 | deleteButton: "absolute -top-1 -right-1 p-0.5 bg-destructive text-destructive-foreground rounded-full hover:bg-destructive/90 z-10 flex items-center justify-center", 5 | deleteIcon: "h-3 w-3", 6 | 7 | // 추가 버튼 스타일 8 | addButton: "border-2 border-dashed border-muted-foreground/30 rounded-lg p-8 hover:border-primary hover:bg-primary/5 transition-all cursor-pointer flex flex-col items-center justify-center", 9 | addIcon: "h-8 w-8 text-muted-foreground mb-2", 10 | 11 | // 카드 스타일 12 | card: "border-0 shadow-lg hover:shadow-xl transition-all duration-300 relative", 13 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "target": "ES6", 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /lib/metadata.ts: -------------------------------------------------------------------------------- 1 | import { defaultConfig } from "@/components/header" 2 | 3 | // 메타데이터 헬퍼 함수 4 | export function getMetadata() { 5 | // localStorage는 서버 사이드에서 사용할 수 없으므로 기본값 사용 6 | const defaultInfo = { 7 | name: "당신의 이름", 8 | title: "프론트엔드 개발자", 9 | description: defaultConfig.siteDescription, // header의 defaultConfig에서 가져오기 10 | profileImage: "", 11 | siteTitle: defaultConfig.siteTitle, // header의 defaultConfig에서 가져오기 12 | } 13 | 14 | // 클라이언트 사이드에서만 localStorage 접근 15 | if (typeof window !== 'undefined') { 16 | const saved = localStorage.getItem('hero-info') 17 | if (saved) { 18 | try { 19 | const parsed = JSON.parse(saved) 20 | return { ...defaultInfo, ...parsed } 21 | } catch { 22 | return defaultInfo 23 | } 24 | } 25 | } 26 | 27 | return defaultInfo 28 | } -------------------------------------------------------------------------------- /components/site-title.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect } from "react" 4 | import { defaultConfig } from "@/components/header" 5 | 6 | export function SiteTitle() { 7 | useEffect(() => { 8 | // 항상 파일의 defaultConfig.siteTitle 값을 사용 9 | document.title = defaultConfig.siteTitle 10 | 11 | // localStorage도 파일 값으로 동기화 12 | localStorage.setItem('portfolio-site-title', defaultConfig.siteTitle) 13 | 14 | // storage 이벤트 리스너 (다른 탭에서 변경시) 15 | const handleStorageChange = () => { 16 | const savedTitle = localStorage.getItem('portfolio-site-title') 17 | if (savedTitle) { 18 | document.title = savedTitle 19 | } else { 20 | document.title = defaultConfig.siteTitle 21 | } 22 | } 23 | 24 | window.addEventListener('storage', handleStorageChange) 25 | 26 | return () => { 27 | window.removeEventListener('storage', handleStorageChange) 28 | } 29 | }, []) 30 | 31 | return null 32 | } -------------------------------------------------------------------------------- /app/safelist.css: -------------------------------------------------------------------------------- 1 | /* Safelist for dynamic Tailwind classes */ 2 | 3 | /* Social media colors - ensure these classes are included in the build */ 4 | .bg-yellow-500\/10 { } 5 | .hover\:bg-yellow-500\/20:hover { } 6 | .text-yellow-600 { } 7 | 8 | .bg-pink-500\/10 { } 9 | .hover\:bg-pink-500\/20:hover { } 10 | .text-pink-600 { } 11 | 12 | .bg-red-500\/10 { } 13 | .hover\:bg-red-500\/20:hover { } 14 | .text-red-600 { } 15 | 16 | .bg-blue-600\/10 { } 17 | .hover\:bg-blue-600\/20:hover { } 18 | .text-blue-600 { } 19 | 20 | .bg-sky-500\/10 { } 21 | .hover\:bg-sky-500\/20:hover { } 22 | .text-sky-600 { } 23 | 24 | .bg-blue-700\/10 { } 25 | .hover\:bg-blue-700\/20:hover { } 26 | .text-blue-700 { } 27 | 28 | .bg-blue-500\/10 { } 29 | .hover\:bg-blue-500\/20:hover { } 30 | .text-blue-500 { } 31 | 32 | .bg-green-500\/10 { } 33 | .hover\:bg-green-500\/20:hover { } 34 | .text-green-600 { } 35 | 36 | /* Other dynamic colors that might be needed */ 37 | .bg-primary\/10 { } 38 | .hover\:bg-primary\/20:hover { } 39 | .text-primary { } -------------------------------------------------------------------------------- /components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useToast } from "@/hooks/use-toast" 4 | import type { Toast as ToastType } from "@/hooks/use-toast" 5 | import { 6 | Toast, 7 | ToastClose, 8 | ToastDescription, 9 | ToastProvider, 10 | ToastTitle, 11 | ToastViewport, 12 | } from "@/components/ui/toast" 13 | 14 | export function Toaster() { 15 | const { toasts } = useToast() 16 | 17 | return ( 18 | 19 | {toasts.map(function ({ id, title, description, action, ...props }: ToastType) { 20 | return ( 21 | 22 |
23 | {title && {title}} 24 | {description && ( 25 | {description} 26 | )} 27 |
28 | {action} 29 | 30 |
31 | ) 32 | })} 33 | 34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /lib/reset-images.ts: -------------------------------------------------------------------------------- 1 | // 이미지 관련 localStorage 데이터만 초기화 2 | export function resetImagePaths() { 3 | if (typeof window === 'undefined') return 4 | 5 | const keysToClean = [ 6 | 'portfolio-hero-info', 7 | 'portfolio-hero-background', 8 | 'portfolio-about-info', 9 | 'portfolio-about-background', 10 | 'portfolio-projects' 11 | ] 12 | 13 | keysToClean.forEach(key => { 14 | const data = localStorage.getItem(key) 15 | if (!data) return 16 | 17 | try { 18 | const parsed = JSON.parse(data) 19 | 20 | // 이미지 필드 초기화 21 | if (parsed.profileImage?.includes('/uploads/')) parsed.profileImage = '' 22 | if (parsed.backgroundImage?.includes('/uploads/')) parsed.backgroundImage = '' 23 | if (parsed.image?.includes('/uploads/')) parsed.image = '' 24 | if (parsed.video?.includes('/uploads/')) parsed.video = '' 25 | 26 | // 배열인 경우 (projects) 27 | if (Array.isArray(parsed)) { 28 | parsed.forEach(item => { 29 | if (item.image?.includes('/uploads/')) item.image = '' 30 | if (item.video?.includes('/uploads/')) item.video = '' 31 | }) 32 | } 33 | 34 | localStorage.setItem(key, JSON.stringify(parsed)) 35 | console.log(`Cleaned image paths from ${key}`) 36 | } catch (e) { 37 | console.error(`Error cleaning ${key}:`, e) 38 | } 39 | }) 40 | 41 | console.log('Image paths reset complete. Please refresh the page.') 42 | } -------------------------------------------------------------------------------- /app/api/delete-image/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server' 2 | import fs from 'fs/promises' 3 | import path from 'path' 4 | 5 | export async function DELETE(request: NextRequest) { 6 | // 개발 환경에서만 작동 7 | if (process.env.NODE_ENV !== 'development') { 8 | return NextResponse.json( 9 | { error: '개발 모드에서만 사용 가능합니다' }, 10 | { status: 403 } 11 | ) 12 | } 13 | 14 | try { 15 | const { imagePath } = await request.json() 16 | 17 | if (!imagePath) { 18 | return NextResponse.json( 19 | { error: '파일 경로가 필요합니다' }, 20 | { status: 400 } 21 | ) 22 | } 23 | 24 | // 안전성 검사: uploads 폴더 내의 파일만 삭제 가능 25 | if (!imagePath.startsWith('/uploads/')) { 26 | return NextResponse.json( 27 | { error: 'uploads 폴더의 파일만 삭제 가능합니다' }, 28 | { status: 400 } 29 | ) 30 | } 31 | 32 | // 파일 경로 생성 33 | const fileName = imagePath.replace('/uploads/', '') 34 | const filePath = path.join(process.cwd(), 'public', 'uploads', fileName) 35 | 36 | // 파일 존재 확인 37 | try { 38 | await fs.access(filePath) 39 | } catch { 40 | return NextResponse.json( 41 | { error: '파일을 찾을 수 없습니다' }, 42 | { status: 404 } 43 | ) 44 | } 45 | 46 | // 파일 삭제 47 | await fs.unlink(filePath) 48 | 49 | const fileType = imagePath.includes('video') ? '비디오' : '이미지' 50 | console.log(`🗑️ ${fileType} 삭제 완료: ${imagePath}`) 51 | 52 | return NextResponse.json({ 53 | success: true, 54 | message: `${fileType}가 삭제되었습니다` 55 | }) 56 | 57 | } catch (error) { 58 | console.error('파일 삭제 오류:', error) 59 | return NextResponse.json( 60 | { error: '파일 삭제 중 오류가 발생했습니다' }, 61 | { status: 500 } 62 | ) 63 | } 64 | } -------------------------------------------------------------------------------- /components/save-to-file-button.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState } from "react" 4 | import { Save, Check } from "lucide-react" 5 | import { useInlineEditor } from "@/contexts/inline-editor-context" 6 | 7 | interface SaveToFileButtonProps { 8 | componentName: string 9 | sectionName?: string 10 | data: unknown 11 | onSaveSuccess?: () => void 12 | className?: string 13 | buttonText?: string 14 | } 15 | 16 | export function SaveToFileButton({ 17 | componentName, 18 | sectionName = 'Info', 19 | data, 20 | onSaveSuccess, 21 | className = "", 22 | buttonText = "파일에 저장" 23 | }: SaveToFileButtonProps) { 24 | const { saveToFile } = useInlineEditor() 25 | const [isSaving, setIsSaving] = useState(false) 26 | const [saved, setSaved] = useState(false) 27 | 28 | const handleSave = async () => { 29 | setIsSaving(true) 30 | 31 | try { 32 | const success = await saveToFile(componentName, sectionName, data) 33 | 34 | if (success) { 35 | setSaved(true) 36 | setTimeout(() => setSaved(false), 3000) 37 | 38 | if (onSaveSuccess) { 39 | onSaveSuccess() 40 | } 41 | } else { 42 | alert('❌ 파일 저장에 실패했습니다.') 43 | } 44 | } catch (error) { 45 | console.error('저장 오류:', error) 46 | alert('❌ 파일 저장 중 오류가 발생했습니다.') 47 | } finally { 48 | setIsSaving(false) 49 | } 50 | } 51 | 52 | return ( 53 | 70 | ) 71 | } -------------------------------------------------------------------------------- /hooks/use-toast.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | 3 | export interface Toast { 4 | id: string 5 | title?: string 6 | description?: string 7 | action?: React.ReactNode 8 | duration?: number 9 | } 10 | 11 | interface ToastState { 12 | toasts: Toast[] 13 | } 14 | 15 | let listeners: Array<(state: ToastState) => void> = [] 16 | let memoryState: ToastState = { toasts: [] } 17 | 18 | function dispatch(action: { type: 'ADD_TOAST' | 'UPDATE_TOAST' | 'REMOVE_TOAST'; toast?: Toast; id?: string }) { 19 | switch (action.type) { 20 | case 'ADD_TOAST': 21 | memoryState = { 22 | toasts: [...memoryState.toasts, action.toast!], 23 | } 24 | break 25 | case 'UPDATE_TOAST': 26 | memoryState = { 27 | toasts: memoryState.toasts.map((t) => 28 | t.id === action.id ? { ...t, ...action.toast } : t 29 | ), 30 | } 31 | break 32 | case 'REMOVE_TOAST': 33 | memoryState = { 34 | toasts: memoryState.toasts.filter((t) => t.id !== action.id), 35 | } 36 | break 37 | } 38 | listeners.forEach((listener) => listener(memoryState)) 39 | } 40 | 41 | export function useToast() { 42 | const [state, setState] = useState(memoryState) 43 | 44 | useEffect(() => { 45 | listeners.push(setState) 46 | return () => { 47 | const index = listeners.indexOf(setState) 48 | if (index > -1) { 49 | listeners.splice(index, 1) 50 | } 51 | } 52 | }, [state]) 53 | 54 | return { 55 | toasts: state.toasts, 56 | toast: (toast: Omit) => { 57 | const id = Math.random().toString(36).substring(2, 9) 58 | dispatch({ type: 'ADD_TOAST', toast: { ...toast, id } }) 59 | 60 | if (toast.duration !== Infinity) { 61 | setTimeout(() => { 62 | dispatch({ type: 'REMOVE_TOAST', id }) 63 | }, toast.duration || 3000) 64 | } 65 | }, 66 | dismiss: (id: string) => dispatch({ type: 'REMOVE_TOAST', id }), 67 | } 68 | } -------------------------------------------------------------------------------- /app/api/og-image/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server' 2 | import fs from 'fs' 3 | import path from 'path' 4 | 5 | export async function GET(req: NextRequest) { 6 | try { 7 | // uploads 폴더에서 hero-profile로 시작하는 이미지 찾기 8 | const uploadsDir = path.join(process.cwd(), 'public', 'uploads') 9 | 10 | if (fs.existsSync(uploadsDir)) { 11 | const files = fs.readdirSync(uploadsDir) 12 | const profileImage = files.find(file => 13 | file.startsWith('hero-profile') && 14 | (file.endsWith('.jpg') || file.endsWith('.jpeg') || file.endsWith('.png') || file.endsWith('.webp')) 15 | ) 16 | 17 | if (profileImage) { 18 | const imagePath = path.join(uploadsDir, profileImage) 19 | const imageBuffer = fs.readFileSync(imagePath) 20 | 21 | // 이미지 타입 결정 22 | const ext = path.extname(profileImage).toLowerCase() 23 | const contentType = 24 | ext === '.png' ? 'image/png' : 25 | ext === '.webp' ? 'image/webp' : 26 | 'image/jpeg' 27 | 28 | return new NextResponse(imageBuffer, { 29 | headers: { 30 | 'Content-Type': contentType, 31 | 'Cache-Control': 'public, max-age=3600', 32 | }, 33 | }) 34 | } 35 | } 36 | 37 | // 프로필 이미지가 없으면 기본 OG 이미지 생성 (단색 배경) 38 | const svg = ` 39 | 40 | 41 | 42 | 나의 포트폴리오 43 | 44 | 45 | ` 46 | 47 | return new NextResponse(svg, { 48 | headers: { 49 | 'Content-Type': 'image/svg+xml', 50 | 'Cache-Control': 'public, max-age=3600', 51 | }, 52 | }) 53 | } catch (error) { 54 | console.error('OG 이미지 생성 실패:', error) 55 | return NextResponse.json({ error: 'Failed to generate OG image' }, { status: 500 }) 56 | } 57 | } -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | } 93 | -------------------------------------------------------------------------------- /lib/cleanup-storage.ts: -------------------------------------------------------------------------------- 1 | // localStorage에서 존재하지 않는 이미지 경로 정리 2 | export function cleanupInvalidImages() { 3 | if (typeof window === 'undefined') return 4 | 5 | const keysToCheck = [ 6 | 'portfolio-hero-info', 7 | 'portfolio-hero-background', 8 | 'portfolio-about-info', 9 | 'portfolio-about-background', 10 | 'portfolio-projects', 11 | 'portfolio-contact-info' 12 | ] 13 | 14 | keysToCheck.forEach(key => { 15 | const data = localStorage.getItem(key) 16 | if (!data) return 17 | 18 | try { 19 | const parsed = JSON.parse(data) 20 | let modified = false 21 | 22 | // 이미지 필드 확인 및 정리 23 | const imageFields = ['profileImage', 'backgroundImage', 'image', 'avatar', 'photo'] 24 | 25 | imageFields.forEach(field => { 26 | if (parsed[field] && parsed[field].includes('/uploads/')) { 27 | // 이미지 존재 여부 확인 28 | const img = new Image() 29 | img.onerror = () => { 30 | // 이미지 로드 실패 시 필드 초기화 31 | parsed[field] = '' 32 | modified = true 33 | localStorage.setItem(key, JSON.stringify(parsed)) 34 | console.log(`Cleaned invalid image from ${key}.${field}`) 35 | } 36 | img.src = parsed[field] 37 | } 38 | }) 39 | 40 | // 배열인 경우 (projects 등) 41 | if (Array.isArray(parsed)) { 42 | parsed.forEach((item, index) => { 43 | imageFields.forEach(field => { 44 | if (item[field] && item[field].includes('/uploads/')) { 45 | const img = new Image() 46 | img.onerror = () => { 47 | item[field] = '' 48 | modified = true 49 | localStorage.setItem(key, JSON.stringify(parsed)) 50 | console.log(`Cleaned invalid image from ${key}[${index}].${field}`) 51 | } 52 | img.src = item[field] 53 | } 54 | }) 55 | }) 56 | } 57 | } catch (e) { 58 | console.error(`Error parsing ${key}:`, e) 59 | } 60 | }) 61 | } 62 | 63 | // 모든 localStorage 데이터 초기화 (개발용) 64 | export function resetAllData() { 65 | if (typeof window === 'undefined') return 66 | 67 | const keys = Object.keys(localStorage).filter(key => key.startsWith('portfolio-')) 68 | keys.forEach(key => localStorage.removeItem(key)) 69 | console.log('All portfolio data has been reset') 70 | window.location.reload() 71 | } -------------------------------------------------------------------------------- /components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState, useEffect } from "react" 4 | import { Moon, Sun } from "lucide-react" 5 | import { cn } from "@/lib/utils" 6 | import { useTheme } from "next-themes" 7 | 8 | interface ThemeToggleProps { 9 | className?: string 10 | } 11 | 12 | export function ThemeToggle({ className }: ThemeToggleProps) { 13 | const [mounted, setMounted] = useState(false) 14 | const { theme, setTheme } = useTheme() 15 | const isDark = theme === "dark" 16 | 17 | useEffect(() => { 18 | setMounted(true) 19 | }, []) 20 | 21 | if (!mounted) { 22 | return null 23 | } 24 | 25 | return ( 26 |
setTheme(isDark ? "light" : "dark")} 35 | role="button" 36 | tabIndex={0} 37 | > 38 |
39 |
47 | {isDark ? ( 48 | 52 | ) : ( 53 | 57 | )} 58 |
59 |
67 | {isDark ? ( 68 | 72 | ) : ( 73 | 77 | )} 78 |
79 |
80 |
81 | ) 82 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react" 2 | import type { Metadata } from "next" 3 | import { ThemeProvider } from "@/components/theme-provider" 4 | import { InlineEditorProvider } from "@/contexts/inline-editor-context" 5 | import { SiteTitle } from "@/components/site-title" 6 | import { getMetadata } from "@/lib/metadata" 7 | import "./globals.css" 8 | 9 | const metadataInfo = getMetadata() 10 | 11 | export const metadata: Metadata = { 12 | metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'), 13 | title: metadataInfo.siteTitle, 14 | description: metadataInfo.description, 15 | keywords: ["포트폴리오", "개발자", "프론트엔드", "웹개발"], 16 | authors: [{ name: "당신의 이름" }], 17 | openGraph: { 18 | type: "website", 19 | locale: "ko_KR", 20 | url: "https://your-domain.com", 21 | title: "나의 포트폴리오", 22 | description: "창의적인 아이디어로 웹 경험을 디자인합니다.", 23 | siteName: "나의 포트폴리오", 24 | images: [ 25 | { 26 | url: "/api/og-image", // 동적 OG 이미지 API 27 | width: 1200, 28 | height: 630, 29 | alt: "프로필 이미지", 30 | }, 31 | ], 32 | }, 33 | twitter: { 34 | card: "summary_large_image", 35 | title: "나의 포트폴리오", 36 | description: "창의적인 아이디어로 웹 경험을 디자인합니다.", 37 | images: ["/api/og-image"], 38 | }, 39 | robots: { 40 | index: true, 41 | follow: true, 42 | googleBot: { 43 | index: true, 44 | follow: true, 45 | "max-video-preview": -1, 46 | "max-image-preview": "large", 47 | "max-snippet": -1, 48 | }, 49 | }, 50 | } 51 | 52 | export default function RootLayout({ 53 | children, 54 | }: Readonly<{ 55 | children: React.ReactNode 56 | }>) { 57 | return ( 58 | 59 | 60 | 66 | {/* 카카오톡 공유 최적화 */} 67 | 68 | 69 | 70 | 71 | 77 | 78 | 79 | {children} 80 | 81 | 82 | 83 | 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-v0-project", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next dev", 8 | "lint": "next lint", 9 | "start": "next start" 10 | }, 11 | "dependencies": { 12 | "@hookform/resolvers": "^3.10.0", 13 | "@radix-ui/react-accordion": "1.2.2", 14 | "@radix-ui/react-alert-dialog": "1.1.4", 15 | "@radix-ui/react-aspect-ratio": "1.1.1", 16 | "@radix-ui/react-avatar": "1.1.2", 17 | "@radix-ui/react-checkbox": "1.1.3", 18 | "@radix-ui/react-collapsible": "1.1.2", 19 | "@radix-ui/react-context-menu": "2.2.4", 20 | "@radix-ui/react-dialog": "1.1.4", 21 | "@radix-ui/react-dropdown-menu": "2.1.4", 22 | "@radix-ui/react-hover-card": "1.1.4", 23 | "@radix-ui/react-label": "2.1.1", 24 | "@radix-ui/react-menubar": "1.1.4", 25 | "@radix-ui/react-navigation-menu": "1.2.3", 26 | "@radix-ui/react-popover": "1.1.4", 27 | "@radix-ui/react-progress": "1.1.1", 28 | "@radix-ui/react-radio-group": "1.2.2", 29 | "@radix-ui/react-scroll-area": "1.2.2", 30 | "@radix-ui/react-select": "2.1.4", 31 | "@radix-ui/react-separator": "1.1.1", 32 | "@radix-ui/react-slider": "1.2.2", 33 | "@radix-ui/react-slot": "1.1.1", 34 | "@radix-ui/react-switch": "1.1.2", 35 | "@radix-ui/react-tabs": "1.1.2", 36 | "@radix-ui/react-toast": "1.2.4", 37 | "@radix-ui/react-toggle": "1.1.1", 38 | "@radix-ui/react-toggle-group": "1.1.1", 39 | "@radix-ui/react-tooltip": "1.1.6", 40 | "autoprefixer": "^10.4.20", 41 | "class-variance-authority": "^0.7.1", 42 | "clsx": "^2.1.1", 43 | "cmdk": "1.0.4", 44 | "date-fns": "4.1.0", 45 | "embla-carousel-react": "8.5.1", 46 | "framer-motion": "^12.23.12", 47 | "geist": "^1.3.1", 48 | "gsap": "^3.13.0", 49 | "input-otp": "1.4.1", 50 | "lucide-react": "^0.454.0", 51 | "next": "15.2.4", 52 | "next-themes": "latest", 53 | "react": "^19", 54 | "react-day-picker": "9.8.0", 55 | "react-dom": "^19", 56 | "react-hook-form": "^7.60.0", 57 | "react-resizable-panels": "^2.1.7", 58 | "recharts": "2.15.4", 59 | "sonner": "^1.7.4", 60 | "tailwind-merge": "^2.5.5", 61 | "tailwindcss-animate": "^1.0.7", 62 | "vaul": "^1.1.2", 63 | "zod": "3.25.67" 64 | }, 65 | "devDependencies": { 66 | "@tailwindcss/postcss": "^4.1.9", 67 | "@types/node": "^22.17.2", 68 | "@types/react": "^19.1.11", 69 | "@types/react-dom": "^19.1.7", 70 | "eslint": "^9.34.0", 71 | "eslint-config-next": "^15.5.0", 72 | "postcss": "^8.5", 73 | "tailwindcss": "^4.1.9", 74 | "tw-animate-css": "1.3.3", 75 | "typescript": "^5" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ArrowRight } from "lucide-react" 5 | import { cn } from "@/lib/utils" 6 | 7 | export interface ButtonProps extends React.ButtonHTMLAttributes { 8 | size?: "sm" | "md" | "lg" 9 | variant?: "default" | "outline" | "ghost" 10 | } 11 | 12 | const Button = React.forwardRef( 13 | ({ children, className, size = "md", variant = "default", ...props }, ref) => { 14 | const sizeClasses = { 15 | sm: "w-28 p-1.5 text-sm", 16 | md: "w-32 p-2 text-base", 17 | lg: "w-36 p-2.5 text-lg" 18 | } 19 | 20 | if (variant === "ghost") { 21 | return ( 22 | 33 | ) 34 | } 35 | 36 | if (variant === "outline") { 37 | return ( 38 | 50 | ) 51 | } 52 | 53 | // Default variant with interactive hover effect 54 | return ( 55 | 73 | ) 74 | } 75 | ) 76 | 77 | Button.displayName = "Button" 78 | 79 | export { Button } 80 | -------------------------------------------------------------------------------- /components/save-field-button.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState } from "react" 4 | import { Save, Check, AlertCircle } from "lucide-react" 5 | 6 | interface SaveFieldButtonProps { 7 | component: string 8 | field: string 9 | value: unknown 10 | onSave?: () => void 11 | className?: string 12 | } 13 | 14 | export function SaveFieldButton({ 15 | component, 16 | field, 17 | value, 18 | onSave, 19 | className = "" 20 | }: SaveFieldButtonProps) { 21 | const [isSaving, setIsSaving] = useState(false) 22 | const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle') 23 | 24 | const handleSave = async () => { 25 | if (process.env.NODE_ENV !== 'development') { 26 | console.warn('파일 저장은 개발 모드에서만 가능합니다') 27 | return 28 | } 29 | 30 | setIsSaving(true) 31 | setSaveStatus('saving') 32 | 33 | try { 34 | const response = await fetch('/api/update-field', { 35 | method: 'POST', 36 | headers: { 37 | 'Content-Type': 'application/json', 38 | }, 39 | body: JSON.stringify({ 40 | component, 41 | field, 42 | value 43 | }) 44 | }) 45 | 46 | const result = await response.json() 47 | 48 | if (result.success) { 49 | setSaveStatus('success') 50 | console.log(`✅ ${component}.tsx의 ${field} 필드가 저장되었습니다`) 51 | onSave?.() 52 | 53 | setTimeout(() => { 54 | setSaveStatus('idle') 55 | }, 2000) 56 | } else { 57 | setSaveStatus('error') 58 | console.error('필드 저장 실패:', result.error) 59 | 60 | setTimeout(() => { 61 | setSaveStatus('idle') 62 | }, 3000) 63 | } 64 | } catch (error) { 65 | console.error('필드 저장 중 오류:', error) 66 | setSaveStatus('error') 67 | 68 | setTimeout(() => { 69 | setSaveStatus('idle') 70 | }, 3000) 71 | } 72 | 73 | setIsSaving(false) 74 | } 75 | 76 | const getIcon = () => { 77 | switch (saveStatus) { 78 | case 'success': 79 | return 80 | case 'error': 81 | return 82 | default: 83 | return 84 | } 85 | } 86 | 87 | const getColor = () => { 88 | switch (saveStatus) { 89 | case 'success': 90 | return 'bg-green-600 hover:bg-green-700' 91 | case 'error': 92 | return 'bg-red-600 hover:bg-red-700' 93 | default: 94 | return 'bg-blue-600 hover:bg-blue-700' 95 | } 96 | } 97 | 98 | return ( 99 | 112 | ) 113 | } -------------------------------------------------------------------------------- /app/api/upload-video/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server' 2 | import fs from 'fs/promises' 3 | import path from 'path' 4 | 5 | export async function POST(request: NextRequest) { 6 | // 개발 환경에서만 작동 7 | if (process.env.NODE_ENV !== 'development') { 8 | return NextResponse.json( 9 | { error: '개발 모드에서만 사용 가능합니다' }, 10 | { status: 403 } 11 | ) 12 | } 13 | 14 | try { 15 | const formData = await request.formData() 16 | const file = formData.get('file') as File 17 | const purpose = formData.get('purpose') as string || 'general-video' 18 | const oldPath = formData.get('oldPath') as string || '' 19 | 20 | if (!file) { 21 | return NextResponse.json( 22 | { error: '파일이 없습니다' }, 23 | { status: 400 } 24 | ) 25 | } 26 | 27 | // 파일 확장자 확인 28 | const validExtensions = ['.mp4', '.webm', '.ogg'] 29 | const fileExtension = path.extname(file.name).toLowerCase() 30 | 31 | if (!validExtensions.includes(fileExtension)) { 32 | return NextResponse.json( 33 | { error: '비디오 파일만 업로드 가능합니다 (MP4, WebM)' }, 34 | { status: 400 } 35 | ) 36 | } 37 | 38 | // 파일 크기 제한 (20MB) 39 | if (file.size > 20 * 1024 * 1024) { 40 | return NextResponse.json( 41 | { error: '파일 크기는 20MB 이하여야 합니다' }, 42 | { status: 400 } 43 | ) 44 | } 45 | 46 | // 의미있는 파일명 생성 (purpose-timestamp.ext) 47 | const timestamp = Date.now() 48 | const uniqueFileName = `${purpose}-${timestamp}${fileExtension}` 49 | const publicPath = path.join(process.cwd(), 'public', 'uploads') 50 | 51 | // uploads 폴더가 없으면 생성 52 | try { 53 | await fs.access(publicPath) 54 | } catch { 55 | await fs.mkdir(publicPath, { recursive: true }) 56 | } 57 | 58 | // 기존 파일 삭제 (같은 purpose의 이전 파일들 삭제) 59 | if (oldPath && oldPath.startsWith('/uploads/')) { 60 | try { 61 | const oldFileName = oldPath.replace('/uploads/', '') 62 | const oldFilePath = path.join(publicPath, oldFileName) 63 | await fs.unlink(oldFilePath) 64 | console.log(`🗑️ 기존 비디오 삭제: ${oldPath}`) 65 | } catch (error) { 66 | console.log('기존 비디오 삭제 실패 (파일이 없을 수 있음):', error) 67 | } 68 | } 69 | 70 | // 같은 purpose의 다른 파일들도 삭제 71 | try { 72 | const files = await fs.readdir(publicPath) 73 | for (const existingFile of files) { 74 | if (existingFile.startsWith(`${purpose}-`) && existingFile !== uniqueFileName) { 75 | const fileToDelete = path.join(publicPath, existingFile) 76 | await fs.unlink(fileToDelete) 77 | console.log(`🗑️ 이전 ${purpose} 비디오 삭제: ${existingFile}`) 78 | } 79 | } 80 | } catch (error) { 81 | console.log('이전 비디오 정리 중 오류:', error) 82 | } 83 | 84 | // 파일 저장 85 | const filePath = path.join(publicPath, uniqueFileName) 86 | const bytes = await file.arrayBuffer() 87 | const buffer = Buffer.from(bytes) 88 | 89 | await fs.writeFile(filePath, buffer) 90 | 91 | // 웹에서 접근 가능한 경로 반환 92 | const webPath = `/uploads/${uniqueFileName}` 93 | 94 | console.log(`✅ 비디오 업로드 완료: ${webPath}`) 95 | 96 | return NextResponse.json({ 97 | success: true, 98 | path: webPath, 99 | filename: uniqueFileName 100 | }) 101 | 102 | } catch (error) { 103 | console.error('파일 업로드 오류:', error) 104 | return NextResponse.json( 105 | { error: '파일 업로드 중 오류가 발생했습니다' }, 106 | { status: 500 } 107 | ) 108 | } 109 | } -------------------------------------------------------------------------------- /app/api/upload-image/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server' 2 | import fs from 'fs/promises' 3 | import path from 'path' 4 | 5 | export async function POST(request: NextRequest) { 6 | // 개발 환경에서만 작동 7 | if (process.env.NODE_ENV !== 'development') { 8 | return NextResponse.json( 9 | { error: '개발 모드에서만 사용 가능합니다' }, 10 | { status: 403 } 11 | ) 12 | } 13 | 14 | try { 15 | const formData = await request.formData() 16 | const file = formData.get('file') as File 17 | const purpose = formData.get('purpose') as string || 'general' // hero-profile, hero-background, about-image 등 18 | const oldPath = formData.get('oldPath') as string || '' // 기존 파일 경로 19 | 20 | if (!file) { 21 | return NextResponse.json( 22 | { error: '파일이 없습니다' }, 23 | { status: 400 } 24 | ) 25 | } 26 | 27 | // 파일 확장자 확인 28 | const validExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'] 29 | const fileExtension = path.extname(file.name).toLowerCase() 30 | 31 | if (!validExtensions.includes(fileExtension)) { 32 | return NextResponse.json( 33 | { error: '이미지 파일만 업로드 가능합니다' }, 34 | { status: 400 } 35 | ) 36 | } 37 | 38 | // 파일 크기 제한 (5MB) 39 | if (file.size > 5 * 1024 * 1024) { 40 | return NextResponse.json( 41 | { error: '파일 크기는 5MB 이하여야 합니다' }, 42 | { status: 400 } 43 | ) 44 | } 45 | 46 | // 의미있는 파일명 생성 (purpose-timestamp.ext) 47 | const timestamp = Date.now() 48 | const uniqueFileName = `${purpose}-${timestamp}${fileExtension}` 49 | const publicPath = path.join(process.cwd(), 'public', 'uploads') 50 | 51 | // uploads 폴더가 없으면 생성 52 | try { 53 | await fs.access(publicPath) 54 | } catch { 55 | await fs.mkdir(publicPath, { recursive: true }) 56 | } 57 | 58 | // 기존 파일 삭제 (같은 purpose의 이전 파일들 삭제) 59 | if (oldPath && oldPath.startsWith('/uploads/')) { 60 | try { 61 | const oldFileName = oldPath.replace('/uploads/', '') 62 | const oldFilePath = path.join(publicPath, oldFileName) 63 | await fs.unlink(oldFilePath) 64 | console.log(`🗑️ 기존 파일 삭제: ${oldPath}`) 65 | } catch (error) { 66 | console.log('기존 파일 삭제 실패 (파일이 없을 수 있음):', error) 67 | } 68 | } 69 | 70 | // 같은 purpose의 다른 파일들도 삭제 71 | try { 72 | const files = await fs.readdir(publicPath) 73 | for (const existingFile of files) { 74 | if (existingFile.startsWith(`${purpose}-`) && existingFile !== uniqueFileName) { 75 | const fileToDelete = path.join(publicPath, existingFile) 76 | await fs.unlink(fileToDelete) 77 | console.log(`🗑️ 이전 ${purpose} 파일 삭제: ${existingFile}`) 78 | } 79 | } 80 | } catch (error) { 81 | console.log('이전 파일 정리 중 오류:', error) 82 | } 83 | 84 | // 파일 저장 85 | const filePath = path.join(publicPath, uniqueFileName) 86 | const bytes = await file.arrayBuffer() 87 | const buffer = Buffer.from(bytes) 88 | 89 | await fs.writeFile(filePath, buffer) 90 | 91 | // 웹에서 접근 가능한 경로 반환 92 | const webPath = `/uploads/${uniqueFileName}` 93 | 94 | console.log(`✅ 이미지 업로드 완료: ${webPath}`) 95 | 96 | return NextResponse.json({ 97 | success: true, 98 | path: webPath, 99 | filename: uniqueFileName 100 | }) 101 | 102 | } catch (error) { 103 | console.error('파일 업로드 오류:', error) 104 | return NextResponse.json( 105 | { error: '파일 업로드 중 오류가 발생했습니다' }, 106 | { status: 500 } 107 | ) 108 | } 109 | } -------------------------------------------------------------------------------- /components/global-save-button.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState } from "react" 4 | import { Save, Check, AlertCircle } from "lucide-react" 5 | import { useInlineEditor } from "@/contexts/inline-editor-context" 6 | 7 | export function GlobalSaveButton() { 8 | const { isEditMode, saveToFile, getData } = useInlineEditor() 9 | const [isSaving, setIsSaving] = useState(false) 10 | const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle') 11 | const [message, setMessage] = useState("") 12 | 13 | if (!isEditMode) return null 14 | 15 | const handleSaveAll = async () => { 16 | setIsSaving(true) 17 | setSaveStatus('saving') 18 | setMessage("저장 중...") 19 | 20 | const components = [ 21 | { name: 'hero', section: 'Info' }, 22 | { name: 'about', section: 'Info' }, 23 | { name: 'projects', section: 'Info' }, 24 | { name: 'contact', section: 'Info' }, 25 | { name: 'contact', section: 'SocialLinks', dataKey: 'contact-social-links' }, 26 | { name: 'footer', section: 'Info' }, 27 | { name: 'header', section: 'Config' } 28 | ] 29 | 30 | let successCount = 0 31 | const failedComponents: string[] = [] 32 | 33 | for (const comp of components) { 34 | const dataKey = comp.dataKey || (comp.name === 'header' ? 'nav-config' : `${comp.name}-info`) 35 | const data = getData(dataKey) 36 | 37 | if (data) { 38 | try { 39 | const success = await saveToFile(comp.name, comp.section, data) 40 | if (success) { 41 | successCount++ 42 | } else { 43 | failedComponents.push(comp.name) 44 | } 45 | } catch (error) { 46 | console.error(`${comp.name} 저장 실패:`, error) 47 | failedComponents.push(comp.name) 48 | } 49 | } 50 | } 51 | 52 | if (failedComponents.length === 0) { 53 | setSaveStatus('success') 54 | setMessage(`✅ 모든 변경사항이 저장되었습니다 (${successCount}개 컴포넌트)`) 55 | setTimeout(() => { 56 | setSaveStatus('idle') 57 | setMessage("") 58 | }, 3000) 59 | } else { 60 | setSaveStatus('error') 61 | setMessage(`⚠️ 일부 저장 실패: ${failedComponents.join(', ')}`) 62 | setTimeout(() => { 63 | setSaveStatus('idle') 64 | setMessage("") 65 | }, 5000) 66 | } 67 | 68 | setIsSaving(false) 69 | } 70 | 71 | return ( 72 |
73 | 94 | 95 | {message && ( 96 |
102 | {message} 103 |
104 | )} 105 |
106 | ) 107 | } -------------------------------------------------------------------------------- /components/editable/editable-text.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState, useEffect, useRef } from 'react' 4 | import { useInlineEditor } from '@/contexts/inline-editor-context' 5 | import { Check, X, Edit2 } from 'lucide-react' 6 | 7 | interface EditableTextProps { 8 | value: string 9 | onChange: (value: string) => void 10 | className?: string 11 | placeholder?: string 12 | multiline?: boolean 13 | storageKey?: string 14 | } 15 | 16 | export function EditableText({ 17 | value, 18 | onChange, 19 | className = '', 20 | placeholder = '텍스트를 입력하세요', 21 | multiline = false, 22 | storageKey 23 | }: EditableTextProps) { 24 | const { isEditMode, saveData, getData } = useInlineEditor() 25 | const [isEditing, setIsEditing] = useState(false) 26 | const [tempValue, setTempValue] = useState(value) 27 | const [isHovered, setIsHovered] = useState(false) 28 | const inputRef = useRef(null) 29 | 30 | // 초기값 로드 31 | useEffect(() => { 32 | if (storageKey) { 33 | const saved = getData(storageKey) as string | null 34 | if (saved) { 35 | onChange(saved) 36 | setTempValue(saved) 37 | } 38 | } 39 | }, [storageKey]) 40 | 41 | const handleSave = () => { 42 | onChange(tempValue) 43 | if (storageKey) { 44 | saveData(storageKey, tempValue) 45 | } 46 | setIsEditing(false) 47 | } 48 | 49 | const handleCancel = () => { 50 | setTempValue(value) 51 | setIsEditing(false) 52 | } 53 | 54 | const handleKeyDown = (e: React.KeyboardEvent) => { 55 | if (e.key === 'Enter' && !multiline) { 56 | handleSave() 57 | } else if (e.key === 'Escape') { 58 | handleCancel() 59 | } 60 | } 61 | 62 | useEffect(() => { 63 | if (isEditing && inputRef.current) { 64 | inputRef.current.focus() 65 | inputRef.current.select() 66 | } 67 | }, [isEditing]) 68 | 69 | if (!isEditMode) { 70 | return {value || placeholder} 71 | } 72 | 73 | if (isEditing) { 74 | return ( 75 | 76 | {multiline ? ( 77 |