From dd25fd7517c115fa5e3a5ca8f713878f6aa981b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A4=80=ED=98=B8?= Date: Sat, 14 Feb 2026 19:26:58 +0900 Subject: [PATCH 01/10] first deploy --- app/RegisterSW.tsx | 16 + app/api/recipe/route.ts | 70 ++ app/layout.tsx | 5 + app/page.tsx | 1455 ++++++--------------------------------- next.config.js | 24 - package-lock.json | 125 ++-- public/manifest.json | 21 + public/sw.js | 13 + 8 files changed, 396 insertions(+), 1333 deletions(-) create mode 100644 app/RegisterSW.tsx create mode 100644 app/api/recipe/route.ts create mode 100644 public/manifest.json create mode 100644 public/sw.js diff --git a/app/RegisterSW.tsx b/app/RegisterSW.tsx new file mode 100644 index 00000000..0af4afe5 --- /dev/null +++ b/app/RegisterSW.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useEffect } from "react"; + +export default function RegisterSW() { + useEffect(() => { + if ("serviceWorker" in navigator) { + navigator.serviceWorker + .register("/sw.js") + .then(() => console.log("SW registered")) + .catch((err) => console.error("SW registration failed:", err)); + } + }, []); + + return null; +} diff --git a/app/api/recipe/route.ts b/app/api/recipe/route.ts new file mode 100644 index 00000000..368a37df --- /dev/null +++ b/app/api/recipe/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; + +export async function POST(req: Request) { + try { + const { ingredients } = await req.json(); + + if (!ingredients) { + return NextResponse.json( + { recipes: "재료를 입력해주세요 🙂" }, + { status: 400 } + ); + } + + const response = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, + }, + body: JSON.stringify({ + model: "gpt-4o-mini", + messages: [ + { role: "system", content: "너는 요리 전문가야." }, + { + role: "user", + content: `다음 재료로 만들 수 있는 요리를 3개 추천해줘: ${ingredients}`, + }, + ], + }), + }); + + const data = await response.json(); + + console.log("OPENAI RAW RESPONSE 👉", data); + + // 🔴 OpenAI에서 에러가 왔을 경우 (쿼터 초과 등) + if (!response.ok) { + console.error("OpenAI Error:", data); + + return NextResponse.json({ + recipes: `⚠️ 현재 AI 사용량이 초과되어 임시 추천을 보여드립니다. + +1. ${ingredients} 볶음 +2. ${ingredients} 오믈렛 +3. ${ingredients} 샐러드`, + }); + } + + // 🔴 choices가 없는 경우 방어 + if (!data.choices || !data.choices[0]) { + return NextResponse.json({ + recipes: "AI 응답이 비어 있습니다. 다시 시도해 주세요.", + }); + } + + // ✅ 정상 응답 + return NextResponse.json({ + recipes: data.choices[0].message.content, + }); + + } catch (error) { + console.error("SERVER ERROR:", error); + + return NextResponse.json({ + recipes: "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", + }); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 73487b0b..0ebd0bfe 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,10 +4,12 @@ import GlobalSettingsProvider from '@/contexts/GlobalSettingsContext' import { AuthProvider } from '@/contexts/AuthContext' import Header from '@/components/layout/Header' import { Metadata } from 'next' +import RegisterSW from './RegisterSW' export const metadata: Metadata = { title: 'Claudable', description: 'Claudable Application', + manifest: '/manifest.json', icons: { icon: '/Claudable_Icon.png', }, @@ -24,6 +26,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{children}
+ + {/* 👇 이 줄 추가 */} + ); diff --git a/app/page.tsx b/app/page.tsx index c06d849d..b54eb127 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,1282 +1,261 @@ "use client"; -import { useEffect, useState, useRef, useCallback } from 'react'; -import { motion } from 'framer-motion'; -import { useRouter } from 'next/navigation'; -import CreateProjectModal from '@/components/modals/CreateProjectModal'; -import DeleteProjectModal from '@/components/modals/DeleteProjectModal'; -import GlobalSettings from '@/components/settings/GlobalSettings'; -import { useGlobalSettings } from '@/contexts/GlobalSettingsContext'; -import { getDefaultModelForCli, getModelDisplayName } from '@/lib/constants/cliModels'; -import Image from 'next/image'; -import { Image as ImageIcon } from 'lucide-react'; -import type { Project as ProjectSummary } from '@/types/project'; -import { fetchCliStatusSnapshot, createCliStatusFallback } from '@/hooks/useCLI'; -import type { CLIStatus } from '@/types/cli'; -import { - ACTIVE_CLI_BRAND_COLORS, - ACTIVE_CLI_MODEL_OPTIONS, - ACTIVE_CLI_OPTIONS, - ACTIVE_CLI_OPTIONS_MAP, - DEFAULT_ACTIVE_CLI, - normalizeModelForCli, - sanitizeActiveCli, - type ActiveCliId, -} from '@/lib/utils/cliOptions'; -// Ensure fetch is available -const fetchAPI = globalThis.fetch || fetch; +import { useEffect, useState } from "react"; -const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? ''; +export default function Home() { + const [tab, setTab] = useState("home"); + const [ingredients, setIngredients] = useState(""); + const [recipes, setRecipes] = useState(""); + const [saved, setSaved] = useState([]); + const [loading, setLoading] = useState(false); + const [fade, setFade] = useState(true); -// Define assistant brand colors -const ASSISTANT_OPTIONS = ACTIVE_CLI_OPTIONS.map(({ id, name, icon }) => ({ - id, - name, - icon, -})); - -const assistantBrandColors = ACTIVE_CLI_BRAND_COLORS; - -const MODEL_OPTIONS_BY_ASSISTANT = ACTIVE_CLI_MODEL_OPTIONS; - -export default function HomePage() { - const [projects, setProjects] = useState([]); - const [showCreate, setShowCreate] = useState(false); - const [showGlobalSettings, setShowGlobalSettings] = useState(false); - const [globalSettingsTab, setGlobalSettingsTab] = useState<'general' | 'ai-assistant'>('ai-assistant'); - const [editingProject, setEditingProject] = useState(null); - const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; project: ProjectSummary | null }>({ isOpen: false, project: null }); - const [isDeleting, setIsDeleting] = useState(false); - const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); - const [prompt, setPrompt] = useState(''); - const DEFAULT_ASSISTANT: ActiveCliId = DEFAULT_ACTIVE_CLI; - const DEFAULT_MODEL = getDefaultModelForCli(DEFAULT_ASSISTANT); - const sanitizeAssistant = useCallback( - (cli?: string | null) => sanitizeActiveCli(cli, DEFAULT_ASSISTANT), - [DEFAULT_ASSISTANT] - ); - const normalizeModelForAssistant = useCallback( - (assistant: string, model?: string | null) => normalizeModelForCli(assistant, model, DEFAULT_ASSISTANT), - [DEFAULT_ASSISTANT] - ); - - const normalizeProjectPayload = useCallback((project: any): ProjectSummary => { - const preferred = sanitizeAssistant(project?.preferredCli ?? project?.preferred_cli); - const selected = normalizeModelForAssistant(preferred, project?.selectedModel ?? project?.selected_model); - - return { - id: project.id, - name: project.name, - description: project.description ?? null, - status: project.status, - previewUrl: project.previewUrl ?? project.preview_url ?? null, - createdAt: project.createdAt ?? project.created_at ?? new Date().toISOString(), - updatedAt: project.updatedAt ?? project.updated_at, - lastActiveAt: project.lastActiveAt ?? project.last_active_at ?? null, - lastMessageAt: project.lastMessageAt ?? project.last_message_at ?? null, - initialPrompt: project.initialPrompt ?? project.initial_prompt ?? null, - services: project.services, - preferredCli: preferred as ProjectSummary['preferredCli'], - selectedModel: selected, - fallbackEnabled: project.fallbackEnabled ?? project.fallback_enabled ?? false, - }; - }, [sanitizeAssistant, normalizeModelForAssistant]); - const [selectedAssistant, setSelectedAssistant] = useState(DEFAULT_ASSISTANT); - const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL); - const [usingGlobalDefaults, setUsingGlobalDefaults] = useState(true); - const [sidebarOpen, setSidebarOpen] = useState(false); - const [cliStatus, setCLIStatus] = useState({}); - const [isInitialLoad, setIsInitialLoad] = useState(true); - const selectedAssistantOption = ACTIVE_CLI_OPTIONS_MAP[selectedAssistant]; - - // Get available models based on current assistant - const availableModels = MODEL_OPTIONS_BY_ASSISTANT[selectedAssistant] || []; - - // Sync with Global Settings (until user overrides locally) - const { settings: globalSettings } = useGlobalSettings(); - - // Check if this is a fresh page load (not navigation) - useEffect(() => { - const isPageRefresh = !sessionStorage.getItem('navigationFlag'); - - if (isPageRefresh) { - // Fresh page load or refresh - use global defaults - sessionStorage.setItem('navigationFlag', 'true'); - setIsInitialLoad(true); - setUsingGlobalDefaults(true); - } else { - // Navigation within session - check for stored selections - const storedAssistantRaw = sessionStorage.getItem('selectedAssistant'); - const storedModelRaw = sessionStorage.getItem('selectedModel'); - - if (storedModelRaw) { - const storedAssistant = sanitizeAssistant(storedAssistantRaw); - const storedModel = normalizeModelForAssistant(storedAssistant, storedModelRaw); - setSelectedAssistant(storedAssistant); - setSelectedModel(storedModel); - setUsingGlobalDefaults(false); - setIsInitialLoad(false); - return; - } - } - - // Clean up navigation flag on unmount - return () => { - // Don't clear on navigation, only on actual page unload - }; - }, [sanitizeAssistant, normalizeModelForAssistant]); - - // Apply global settings when using defaults - useEffect(() => { - if (!usingGlobalDefaults || !isInitialLoad) return; - - const cli = sanitizeAssistant(globalSettings?.default_cli); - setSelectedAssistant(cli); - const modelFromGlobal = globalSettings?.cli_settings?.[cli]?.model; - setSelectedModel(normalizeModelForAssistant(cli, modelFromGlobal)); - }, [globalSettings, usingGlobalDefaults, isInitialLoad, sanitizeAssistant, normalizeModelForAssistant]); - - // Save selections to sessionStorage when they change - useEffect(() => { - if (!isInitialLoad && selectedAssistant && selectedModel) { - const normalizedAssistant = sanitizeAssistant(selectedAssistant); - sessionStorage.setItem('selectedAssistant', normalizedAssistant); - sessionStorage.setItem('selectedModel', normalizeModelForAssistant(normalizedAssistant, selectedModel)); - } - }, [selectedAssistant, selectedModel, isInitialLoad, sanitizeAssistant, normalizeModelForAssistant]); - - // Clear navigation flag on page unload - useEffect(() => { - const handleBeforeUnload = () => { - sessionStorage.removeItem('navigationFlag'); - }; - - window.addEventListener('beforeunload', handleBeforeUnload); - return () => window.removeEventListener('beforeunload', handleBeforeUnload); - }, []); - const [showAssistantDropdown, setShowAssistantDropdown] = useState(false); - const [showModelDropdown, setShowModelDropdown] = useState(false); - const [isCreatingProject, setIsCreatingProject] = useState(false); - const [uploadedImages, setUploadedImages] = useState<{ id: string; name: string; url: string; path: string; file?: File }[]>([]); - const [isUploading, setIsUploading] = useState(false); - const [isDragOver, setIsDragOver] = useState(false); - const router = useRouter(); - const prefetchTimers = useRef>(new Map()); - const fileInputRef = useRef(null); - const assistantDropdownRef = useRef(null); - const modelDropdownRef = useRef(null); - - // Check CLI installation status - useEffect(() => { - const checkingStatus = ASSISTANT_OPTIONS.reduce((acc, cli) => { - acc[cli.id] = { - installed: false, - checking: true, - available: false, - configured: false, - }; - return acc; - }, {}); - setCLIStatus(checkingStatus); - - fetchCliStatusSnapshot() - .then((status) => setCLIStatus(status)) - .catch((error) => { - console.error('Failed to check CLI status:', error); - setCLIStatus(createCliStatusFallback()); - }); - }, []); - - // Click outside handler + // ✅ localStorage 불러오기 useEffect(() => { - const handleDocumentClick = (event: MouseEvent) => { - const target = event.target as Node; - - const assistantEl = assistantDropdownRef.current; - if (assistantEl && !assistantEl.contains(target)) { - setShowAssistantDropdown(false); - } - - const modelEl = modelDropdownRef.current; - if (modelEl && !modelEl.contains(target)) { - setShowModelDropdown(false); - } - }; - - document.addEventListener('mousedown', handleDocumentClick); - return () => { - document.removeEventListener('mousedown', handleDocumentClick); - }; - }, []); - - // Format time for display - const formatTime = (dateString: string | null) => { - if (!dateString) return 'Never'; - - // Server sends UTC time without 'Z' suffix, so we need to add it - // to ensure it's parsed as UTC, not local time - let utcDateString = dateString; - - // Check if the string has timezone info - const hasTimezone = dateString.endsWith('Z') || - dateString.includes('+') || - dateString.match(/[-+]\d{2}:\d{2}$/); - - if (!hasTimezone) { - // Add 'Z' to indicate UTC - utcDateString = dateString + 'Z'; - } - - // Parse the date as UTC - const date = new Date(utcDateString); - const now = new Date(); - // Calculate the actual time difference - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / (1000 * 60)); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); - - if (diffMins < 1) return 'Just now'; - if (diffMins < 60) return `${diffMins}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - if (diffDays < 30) return `${diffDays}d ago`; - - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined - }); - }; - - // Format CLI and model information - const formatCliInfo = (cli?: string, model?: string) => { - const normalizedCli = sanitizeAssistant(cli); - const assistantOption = ACTIVE_CLI_OPTIONS_MAP[normalizedCli]; - const cliName = assistantOption?.name ?? 'Claude Code'; - const modelId = normalizeModelForAssistant(normalizedCli, model); - const modelLabel = getModelDisplayName(normalizedCli, modelId); - return `${cliName} • ${modelLabel}`; - }; - - const formatFullTime = (dateString: string) => { - return new Date(dateString).toLocaleString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - }; - - const load = useCallback(async () => { - try { - const r = await fetchAPI(`${API_BASE}/api/projects`); - if (!r.ok) { - console.warn('Failed to load projects: HTTP', r.status); - setProjects([]); - return; - } - - const payload = await r.json(); - if (payload?.success === false) { - console.error('Failed to load projects:', payload?.error || payload?.message); - setProjects([]); - return; - } - - const items: unknown[] = Array.isArray(payload?.data) - ? payload.data - : Array.isArray(payload) - ? payload - : []; - - const normalized: ProjectSummary[] = items - .filter((project): project is Record => Boolean(project && typeof project === 'object')) - .map((project) => normalizeProjectPayload(project)); - - const sortedProjects = normalized.sort((a, b) => { - const aTime = a.lastMessageAt ?? a.createdAt; - const bTime = b.lastMessageAt ?? b.createdAt; - if (!aTime) return 1; - if (!bTime) return -1; - return new Date(bTime).getTime() - new Date(aTime).getTime(); - }); - - setProjects(sortedProjects); - } catch (error) { - console.warn('Failed to load projects:', error); - setProjects([]); - } - }, [normalizeProjectPayload]); - - async function onCreated() { await load(); } - - async function start(projectId: string) { - try { - await fetchAPI(`${API_BASE}/api/projects/${projectId}/preview/start`, { method: 'POST' }); - await load(); - } catch (error) { - console.warn('Failed to start project:', error); - } - } - - async function stop(projectId: string) { - try { - await fetchAPI(`${API_BASE}/api/projects/${projectId}/preview/stop`, { method: 'POST' }); - await load(); - } catch (error) { - console.warn('Failed to stop project:', error); + const stored = localStorage.getItem("savedRecipes"); + if (stored) { + setSaved(JSON.parse(stored)); } - } - - const showToast = useCallback((message: string, type: 'success' | 'error') => { - setToast({ message, type }); - setTimeout(() => setToast(null), 4000); }, []); - const openDeleteModal = (project: ProjectSummary) => { - setDeleteModal({ isOpen: true, project }); + // ✅ 탭 전환 애니메이션 + const changeTab = (newTab: string) => { + setFade(false); + setTimeout(() => { + setTab(newTab); + setFade(true); + }, 150); }; - const closeDeleteModal = () => { - setDeleteModal({ isOpen: false, project: null }); - }; + const getRecipe = async () => { + if (!ingredients) return; - async function deleteProject() { - if (!deleteModal.project) return; - - setIsDeleting(true); - try { - const response = await fetchAPI(`${API_BASE}/api/projects/${deleteModal.project.id}`, { method: 'DELETE' }); - - if (response.ok) { - showToast('Project deleted successfully', 'success'); - await load(); - closeDeleteModal(); - } else { - const errorData = await response.json().catch(() => ({ detail: 'Failed to delete project' })); - showToast(errorData.detail || 'Failed to delete project', 'error'); - } - } catch (error) { - console.warn('Failed to delete project:', error); - showToast('Failed to delete project. Please try again.', 'error'); - } finally { - setIsDeleting(false); - } - } + setLoading(true); + setRecipes(""); - async function updateProject(projectId: string, newName: string) { try { - const response = await fetchAPI(`${API_BASE}/api/projects/${projectId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: newName }) + const res = await fetch("/api/recipe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ingredients }), }); - - if (response.ok) { - showToast('Project updated successfully', 'success'); - await load(); - setEditingProject(null); - } else { - const errorData = await response.json().catch(() => ({ detail: 'Failed to update project' })); - showToast(errorData.detail || 'Failed to update project', 'error'); - } - } catch (error) { - console.warn('Failed to update project:', error); - showToast('Failed to update project. Please try again.', 'error'); - } - } - - // Handle files (for both drag drop and file input) - const handleFiles = useCallback(async (files: FileList | File[]) => { - setIsUploading(true); - - try { - const filesArray = Array.from(files as ArrayLike); - const imagesToAdd = filesArray - .filter(file => file.type.startsWith('image/')) - .map(file => ({ - id: crypto.randomUUID(), - name: file.name, - url: URL.createObjectURL(file), - path: '', - file, - })); - if (imagesToAdd.length > 0) { - setUploadedImages(prev => [...prev, ...imagesToAdd]); - } - } catch (error) { - console.error('Image processing failed:', error); - showToast('Failed to process image. Please try again.', 'error'); - } finally { - setIsUploading(false); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } + const data = await res.json(); + setRecipes(data.recipes); + } catch { + setRecipes("에러가 발생했습니다."); } - }, [showToast]); - // Handle image upload - store locally first, upload after project creation - const handleImageUpload = async (e: React.ChangeEvent) => { - const files = e.target.files; - if (!files) return; - - await handleFiles(files); + setLoading(false); }; - // Drag and drop handlers - const handleDragEnter = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(true); - }; + const saveRecipe = () => { + if (!recipes) return; - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - // Only set to false if we're leaving the container completely - if (!e.currentTarget.contains(e.relatedTarget as Node)) { - setIsDragOver(false); - } - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - e.dataTransfer.dropEffect = 'copy'; - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - - const files = e.dataTransfer.files; - if (files.length > 0) { - handleFiles(files); - } + const updated = [...saved, recipes]; + setSaved(updated); + localStorage.setItem("savedRecipes", JSON.stringify(updated)); }; - // Remove uploaded image - const removeImage = (id: string) => { - setUploadedImages(prev => { - const imageToRemove = prev.find(img => img.id === id); - if (imageToRemove) { - URL.revokeObjectURL(imageToRemove.url); - } - return prev.filter(img => img.id !== id); - }); + // ✅ 삭제 기능 + const deleteRecipe = (index: number) => { + const updated = saved.filter((_, i) => i !== index); + setSaved(updated); + localStorage.setItem("savedRecipes", JSON.stringify(updated)); }; - const handleSubmit = async () => { - if ((!prompt.trim() && uploadedImages.length === 0) || isCreatingProject) return; - - setIsCreatingProject(true); - - // Generate a unique project ID - const projectId = `project-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - - try { - // Create a new project first - const response = await fetchAPI(`${API_BASE}/api/projects`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - project_id: projectId, - name: prompt.slice(0, 50) + (prompt.length > 50 ? '...' : ''), - initialPrompt: prompt.trim(), - preferredCli: selectedAssistant, - selectedModel - }) - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => null); - console.error('Failed to create project:', errorData); - showToast('Failed to create project', 'error'); - setIsCreatingProject(false); - return; - } - - const payload = await response.json(); - const projectData = (payload && typeof payload === 'object') ? (payload.data ?? payload) : payload; - const createdProjectId: string | undefined = projectData?.id ?? projectId; - if (!createdProjectId) { - console.error('Create project response missing id:', payload); - showToast('Failed to create project (invalid response)', 'error'); - setIsCreatingProject(false); - return; - } - if (createdProjectId !== projectId) { - console.warn('Project ID mismatch between request and response:', { - requestedId: projectId, - responseId: createdProjectId, - payload - }); - } - - // Upload images if any - let imageData: any[] = []; - - if (uploadedImages.length > 0) { - try { - for (let i = 0; i < uploadedImages.length; i++) { - const image = uploadedImages[i]; - if (!image.file) continue; - - const formData = new FormData(); - formData.append('file', image.file); - - const uploadResponse = await fetchAPI(`${API_BASE}/api/assets/${createdProjectId}/upload`, { - method: 'POST', - body: formData - }); - - if (uploadResponse.ok) { - const result = await uploadResponse.json(); - // Track image data for API - imageData.push({ - name: result.filename || image.name, - path: result.absolute_path, - public_url: typeof result.public_url === 'string' ? result.public_url : undefined - }); - } - } - } catch (uploadError) { - console.error('Image upload failed:', uploadError); - showToast('Images could not be uploaded, but project was created', 'error'); - } - } - - // Execute initial prompt directly with images - if (prompt.trim()) { - try { - const actResponse = await fetchAPI(`${API_BASE}/api/chat/${createdProjectId}/act`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - instruction: prompt.trim(), // Original prompt without image paths - images: imageData, - isInitialPrompt: true, - cliPreference: selectedAssistant, - selectedModel - }) - }); - - if (actResponse.ok) { - // Successfully kicked off ACT with image payloads - } else { - console.error('❌ ACT failed:', await actResponse.text()); - } - } catch (actError) { - console.error('❌ ACT API error:', actError); - } - } - - // Navigate to chat page with model and CLI parameters - uploadedImages.forEach(image => { - if (image.url) { - URL.revokeObjectURL(image.url); - } - }); - setUploadedImages([]); - setPrompt(''); - - const params = new URLSearchParams(); - if (selectedAssistant) params.set('cli', selectedAssistant); - if (selectedModel) params.set('model', selectedModel); - router.push(`/${createdProjectId}/chat${params.toString() ? '?' + params.toString() : ''}`); - - } catch (error) { - console.error('Failed to create project:', error); - showToast('Failed to create project', 'error'); - } finally { - setIsCreatingProject(false); - } - }; - - useEffect(() => { - load(); - - // Handle clipboard paste for images - const handlePaste = (e: ClipboardEvent) => { - const items = e.clipboardData?.items; - if (!items) return; - - const imageFiles: File[] = []; - for (let i = 0; i < items.length; i++) { - const item = items[i]; - if (item.type.startsWith('image/')) { - const file = item.getAsFile(); - if (file) { - imageFiles.push(file); - } - } - } - - if (imageFiles.length > 0) { - e.preventDefault(); - const fileList = { - length: imageFiles.length, - item: (index: number) => imageFiles[index], - [Symbol.iterator]: function* () { - for (let i = 0; i < imageFiles.length; i++) { - yield imageFiles[i]; - } - } - } as FileList; - - // Convert to FileList-like object - Object.defineProperty(fileList, 'length', { value: imageFiles.length }); - imageFiles.forEach((file, index) => { - Object.defineProperty(fileList, index, { value: file }); - }); - - handleFiles(fileList); - } - }; - - document.addEventListener('paste', handlePaste); - const timers = prefetchTimers.current; - - // Cleanup prefetch timers - return () => { - timers.forEach(timer => clearTimeout(timer)); - timers.clear(); - document.removeEventListener('paste', handlePaste); - }; - }, [selectedAssistant, handleFiles, load]); - - // Update models when assistant changes - const handleAssistantChange = (assistant: string) => { - // Don't allow selecting uninstalled CLIs - if (!cliStatus[assistant]?.installed) return; - - const sanitized = sanitizeAssistant(assistant); - setUsingGlobalDefaults(false); - setIsInitialLoad(false); - setSelectedAssistant(sanitized); - setSelectedModel(getDefaultModelForCli(sanitized)); - - setShowAssistantDropdown(false); - }; - - const handleModelChange = (modelId: string) => { - setUsingGlobalDefaults(false); - setIsInitialLoad(false); - setSelectedModel(normalizeModelForAssistant(selectedAssistant, modelId)); - setShowModelDropdown(false); - }; - - return ( -
- {/* Radial gradient background from bottom center */} -
-
-
- {/* Light mode gradient - subtle */} -
+
+
-
- - {/* Content wrapper */} -
- {/* Thin sidebar bar when closed */} -
- - - {/* Settings button when sidebar is closed */} -
- -
-
- - {/* Sidebar - Overlay style */} -
-
- {/* History header with close button */} -
-
-
-

History

-
- -
-
- -
-
- {projects.length === 0 ? ( -
-

No conversations yet

+ + {recipes && ( +
+
{recipes}
+
- ) : ( - projects.map((project) => { - const projectCli = sanitizeAssistant(project.preferredCli); - const projectColor = assistantBrandColors[projectCli] || assistantBrandColors[DEFAULT_ASSISTANT]; - return ( -
{ - e.currentTarget.style.backgroundColor = `${projectColor}15`; - }} - onMouseLeave={(e) => { - e.currentTarget.style.backgroundColor = 'transparent'; - }} - > - {editingProject?.id === project.id ? ( - // Edit mode -
{ - e.preventDefault(); - const formData = new FormData(e.target as HTMLFormElement); - const newName = formData.get('name') as string; - if (newName.trim()) { - updateProject(project.id, newName.trim()); - } - }} - className="space-y-2" - > - setEditingProject(null)} - /> -
- - -
-
- ) : ( - // View mode -
-
{ - // Pass current model selection when navigating from sidebar - const params = new URLSearchParams(); - if (selectedAssistant) params.set('cli', selectedAssistant); - if (selectedModel) params.set('model', selectedModel); - router.push(`/${project.id}/chat${params.toString() ? '?' + params.toString() : ''}`); - }} - > -

- - {project.name.length > 28 - ? `${project.name.substring(0, 28)}...` - : project.name - } - -

-
-
- {formatTime(project.lastMessageAt || project.createdAt)} -
- {project.preferredCli && ( -
- - - {formatCliInfo(projectCli, project.selectedModel ?? undefined)} - -
- )} -
-
-
- - -
-
- )} -
- ); - }) )} -
-
- -
- -
-
-
- - {/* Main Content - Not affected by sidebar */} -
-
-
-
-
-

- Claudable -

-
-

- Connect CLI Agent • Build what you want • Deploy instantly -

-
- - {/* Image thumbnails */} - {uploadedImages.length > 0 && ( -
- {uploadedImages.map((image, index) => ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {image.name} -
- Image #{index + 1} -
+ + )} + + {tab === "saved" && ( + <> +

⭐ 저장된 레시피

+ + {saved.length === 0 ? ( +

저장된 레시피가 없습니다.

+ ) : ( + saved.map((item, index) => ( +
+
{item}
- ))} -
- )} - - {/* Main Input Form */} -
{ e.preventDefault(); handleSubmit(); }} - onDragEnter={handleDragEnter} - onDragLeave={handleDragLeave} - onDragOver={handleDragOver} - onDrop={handleDrop} - className={`group flex flex-col gap-4 p-4 w-full rounded-[28px] border backdrop-blur-xl text-base shadow-xl transition-all duration-150 ease-in-out mb-6 relative overflow-visible ${ - isDragOver - ? 'border-[#DE7356] bg-[#DE7356]/10 ' - : 'border-gray-200 bg-white ' - }`} - > -
-