- {tabs.map((tab) => {
- const Icon = tab.icon
- const isActive = location.pathname === tab.path
- return (
-
- )
- })}
+
)
diff --git a/frontend/src/components/Upload.jsx b/frontend/src/components/Upload.jsx
index e902b21..60ff688 100644
--- a/frontend/src/components/Upload.jsx
+++ b/frontend/src/components/Upload.jsx
@@ -1,21 +1,56 @@
-import { useState, useRef } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { Upload as UploadIcon, Camera, Image as ImageIcon, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
-
-const API_BASE = `http://${window.location.hostname}:8000/api`
+import { useUpload } from '../contexts/UploadContext'
export default function Upload({ onUploadSuccess }) {
const { t } = useTranslation()
+ const {
+ isUploading,
+ progress,
+ statusKey,
+ current,
+ total,
+ completedSingleItem,
+ batchResult,
+ lastError,
+ uploadFiles,
+ consumeCompletedSingleItem,
+ consumeBatchResult,
+ consumeLastError
+ } = useUpload()
const [isDragging, setIsDragging] = useState(false)
- const [isUploading, setIsUploading] = useState(false)
- const [progress, setProgress] = useState(0)
- const [status, setStatus] = useState('')
const [showCamera, setShowCamera] = useState(false)
const fileInputRef = useRef(null)
const cameraInputRef = useRef(null)
const videoRef = useRef(null)
const streamRef = useRef(null)
+ const status = statusKey
+ ? (total > 1 && current > 0 ? `${t(statusKey)} (${current}/${total})` : t(statusKey))
+ : ''
+
+ useEffect(() => {
+ if (!completedSingleItem) return
+ onUploadSuccess?.(completedSingleItem)
+ consumeCompletedSingleItem()
+ }, [completedSingleItem, onUploadSuccess, consumeCompletedSingleItem])
+
+ useEffect(() => {
+ if (!batchResult) return
+ alert(t('upload.batchResult', batchResult))
+ consumeBatchResult()
+ }, [batchResult, t, consumeBatchResult])
+
+ useEffect(() => {
+ if (!lastError) return
+ const translatedError = lastError === 'INVALID_IMAGE_TYPE'
+ ? t('upload.selectImage')
+ : lastError
+ alert(`${t('upload.uploadFailed')}: ${translatedError}`)
+ consumeLastError()
+ }, [lastError, t, consumeLastError])
+
const handleDragOver = (e) => {
e.preventDefault()
setIsDragging(true)
@@ -31,7 +66,7 @@ export default function Upload({ onUploadSuccess }) {
setIsDragging(false)
const files = e.dataTransfer.files
if (files.length > 0) {
- uploadFile(files[0])
+ void uploadFiles(Array.from(files))
}
}
@@ -83,7 +118,7 @@ export default function Upload({ onUploadSuccess }) {
canvas.toBlob((blob) => {
if (blob) {
const file = new File([blob], 'camera-photo.jpg', { type: 'image/jpeg' })
- uploadFile(file)
+ void uploadFiles([file])
stopCamera()
}
}, 'image/jpeg', 0.9)
@@ -92,62 +127,11 @@ export default function Upload({ onUploadSuccess }) {
const handleFileChange = (e) => {
const files = e.target.files
if (files && files.length > 0) {
- uploadFile(files[0])
+ void uploadFiles(Array.from(files))
}
e.target.value = ''
}
- const uploadFile = async (file) => {
- if (!file.type.startsWith('image/')) {
- alert(t('upload.selectImage'))
- return
- }
-
- setIsUploading(true)
- setProgress(10)
- setStatus(t('upload.uploading'))
-
- const formData = new FormData()
- formData.append('file', file)
-
- try {
- setProgress(30)
- setStatus(t('upload.removingBg'))
-
- const response = await fetch(`${API_BASE}/upload`, {
- method: 'POST',
- body: formData
- })
-
- setProgress(70)
- setStatus(t('upload.analyzing'))
-
- if (!response.ok) {
- const error = await response.json()
- throw new Error(error.detail || t('upload.uploadFailed'))
- }
-
- const data = await response.json()
-
- setProgress(100)
- setStatus(t('upload.done'))
-
- setTimeout(() => {
- setIsUploading(false)
- setProgress(0)
- setStatus('')
- onUploadSuccess?.(data)
- }, 500)
-
- } catch (error) {
- console.error('Upload error:', error)
- alert(`${t('upload.uploadFailed')}: ${error.message}`)
- setIsUploading(false)
- setProgress(0)
- setStatus('')
- }
- }
-
if (showCamera) {
return (
@@ -203,6 +187,7 @@ export default function Upload({ onUploadSuccess }) {
ref={fileInputRef}
type="file"
accept="image/*"
+ multiple
className="hidden"
onChange={handleFileChange}
/>
diff --git a/frontend/src/contexts/RecommendationContext.jsx b/frontend/src/contexts/RecommendationContext.jsx
new file mode 100644
index 0000000..f2687ee
--- /dev/null
+++ b/frontend/src/contexts/RecommendationContext.jsx
@@ -0,0 +1,149 @@
+import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
+import { API_BASE } from '../utils/api'
+const DEFAULT_LOCATION = '上海, 上海市, 中国'
+
+const RecommendationContext = createContext(null)
+
+const INITIAL_STATE = {
+ loading: false,
+ error: '',
+ weather: null,
+ horoscope: null,
+ temperatureRule: null,
+ recommendation: '',
+ outfitSummary: '',
+ selectionReasons: {},
+ suggestedTop: null,
+ suggestedBottom: null,
+ suggestedShoes: null,
+ suggestedAccessories: [],
+ purchaseSuggestions: [],
+ goalRaw: '',
+ goalNormalized: '',
+ selectedCity: {
+ name: DEFAULT_LOCATION,
+ id: DEFAULT_LOCATION
+ }
+}
+
+export function RecommendationProvider({ children }) {
+ const [state, setState] = useState(INITIAL_STATE)
+ const requestIdRef = useRef(0)
+
+ useEffect(() => {
+ const fetchDefaultCity = async () => {
+ try {
+ const response = await fetch(`${API_BASE}/config`)
+ if (!response.ok) {
+ return
+ }
+
+ const data = await response.json()
+ const location = (data.weather_location || '').trim()
+ if (!location) {
+ return
+ }
+
+ setState(prev => ({
+ ...prev,
+ selectedCity: {
+ name: location,
+ id: location
+ }
+ }))
+ } catch (error) {
+ console.error('Failed to fetch default city config:', error)
+ }
+ }
+
+ void fetchDefaultCity()
+ }, [])
+
+ const fetchRecommendation = useCallback(async (location, preferredName = null, goal = '') => {
+ if (!location) {
+ return null
+ }
+
+ const requestId = requestIdRef.current + 1
+ requestIdRef.current = requestId
+ setState(prev => ({ ...prev, loading: true, error: '' }))
+
+ try {
+ const params = new URLSearchParams({ location })
+ const trimmedGoal = (goal || '').trim()
+ if (trimmedGoal) {
+ params.set('goal', trimmedGoal)
+ }
+ const response = await fetch(`${API_BASE}/recommendation?${params.toString()}`)
+ if (!response.ok) {
+ const errorPayload = await response.json().catch(() => ({}))
+ if (requestId === requestIdRef.current) {
+ setState(prev => ({
+ ...prev,
+ loading: false,
+ error: errorPayload.detail || 'Failed to fetch recommendation'
+ }))
+ }
+ return null
+ }
+
+ const data = await response.json()
+ if (requestId !== requestIdRef.current) {
+ return null
+ }
+
+ setState(prev => ({
+ ...prev,
+ loading: false,
+ error: '',
+ weather: data.weather || null,
+ horoscope: data.horoscope || null,
+ temperatureRule: data.temperature_rule || null,
+ recommendation: data.recommendation_text || '',
+ outfitSummary: data.outfit_summary || '',
+ selectionReasons: data.selection_reasons || {},
+ suggestedTop: data.suggested_top || null,
+ suggestedBottom: data.suggested_bottom || null,
+ suggestedShoes: data.suggested_shoes || null,
+ suggestedAccessories: data.suggested_accessories || [],
+ purchaseSuggestions: data.purchase_suggestions || [],
+ goalRaw: data.goal_raw || '',
+ goalNormalized: data.goal_normalized || '',
+ selectedCity: preferredName
+ ? { name: preferredName, id: location }
+ : (data.weather?.location ? { name: data.weather.location, id: location } : prev.selectedCity)
+ }))
+
+ return data
+ } catch (error) {
+ if (requestId === requestIdRef.current) {
+ setState(prev => ({
+ ...prev,
+ loading: false,
+ error: error?.message || 'Failed to fetch recommendation'
+ }))
+ }
+ return null
+ }
+ }, [])
+
+ const value = useMemo(() => ({
+ ...state,
+ fetchRecommendation
+ }), [state, fetchRecommendation])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useRecommendation() {
+ const context = useContext(RecommendationContext)
+ if (!context) {
+ throw new Error('useRecommendation must be used within RecommendationProvider')
+ }
+ return context
+}
+
diff --git a/frontend/src/contexts/ThemeContext.jsx b/frontend/src/contexts/ThemeContext.jsx
index aa6bb35..bc1fe49 100644
--- a/frontend/src/contexts/ThemeContext.jsx
+++ b/frontend/src/contexts/ThemeContext.jsx
@@ -1,4 +1,4 @@
-import { createContext, useContext, useState, useEffect } from 'react'
+import { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'
const ThemeContext = createContext()
@@ -22,10 +22,14 @@ export function ThemeProvider({ children }) {
}
}, [theme])
- const toggleTheme = () => setTheme(prev => prev === 'light' ? 'dark' : 'light')
+ const toggleTheme = useCallback(() => {
+ setTheme(prev => prev === 'light' ? 'dark' : 'light')
+ }, [])
+
+ const value = useMemo(() => ({ theme, setTheme, toggleTheme }), [theme, toggleTheme])
return (
-
+
{children}
)
diff --git a/frontend/src/contexts/UploadContext.jsx b/frontend/src/contexts/UploadContext.jsx
new file mode 100644
index 0000000..a15a4db
--- /dev/null
+++ b/frontend/src/contexts/UploadContext.jsx
@@ -0,0 +1,158 @@
+import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'
+import { API_BASE } from '../utils/api'
+
+const UploadContext = createContext(null)
+
+const INITIAL_UPLOAD_STATE = {
+ isUploading: false,
+ progress: 0,
+ statusKey: '',
+ current: 0,
+ total: 0,
+ completedSingleItem: null,
+ batchResult: null,
+ lastError: ''
+}
+
+export function UploadProvider({ children }) {
+ const [state, setState] = useState(INITIAL_UPLOAD_STATE)
+ const uploadingRef = useRef(false)
+
+ const setStage = useCallback((statusKey, current, total) => {
+ setState(prev => ({
+ ...prev,
+ statusKey,
+ current,
+ total
+ }))
+ }, [])
+
+ const uploadSingleFile = useCallback(async (file, current, total) => {
+ if (!file?.type?.startsWith('image/')) {
+ throw new Error('INVALID_IMAGE_TYPE')
+ }
+
+ const formData = new FormData()
+ formData.append('file', file)
+
+ setStage('upload.removingBg', current, total)
+ const response = await fetch(`${API_BASE}/upload`, {
+ method: 'POST',
+ body: formData
+ })
+
+ setStage('upload.analyzing', current, total)
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}))
+ throw new Error(error.detail || 'UPLOAD_FAILED')
+ }
+
+ return response.json()
+ }, [setStage])
+
+ const uploadFiles = useCallback(async (files) => {
+ if (!files || files.length === 0) {
+ return { successItems: [], failedMessages: [] }
+ }
+ if (uploadingRef.current) {
+ return { successItems: [], failedMessages: ['UPLOAD_IN_PROGRESS'] }
+ }
+
+ const imageFiles = files.filter(file => file?.type?.startsWith('image/'))
+ if (imageFiles.length === 0) {
+ return { successItems: [], failedMessages: ['INVALID_IMAGE_TYPE'] }
+ }
+
+ uploadingRef.current = true
+ setState(prev => ({
+ ...prev,
+ isUploading: true,
+ progress: 0,
+ statusKey: 'upload.uploading',
+ current: 0,
+ total: imageFiles.length,
+ completedSingleItem: null,
+ batchResult: null,
+ lastError: ''
+ }))
+
+ const total = imageFiles.length
+ const successItems = []
+ const failedMessages = []
+
+ try {
+ for (let i = 0; i < total; i += 1) {
+ const file = imageFiles[i]
+ const current = i + 1
+ const baseProgress = Math.round((i / total) * 100)
+
+ setState(prev => ({
+ ...prev,
+ progress: Math.max(baseProgress, 5)
+ }))
+ setStage('upload.uploading', current, total)
+
+ try {
+ const data = await uploadSingleFile(file, current, total)
+ successItems.push(data)
+ setState(prev => ({
+ ...prev,
+ progress: Math.round((current / total) * 100)
+ }))
+ } catch (error) {
+ failedMessages.push(error?.message || 'UPLOAD_FAILED')
+ }
+ }
+
+ const nextState = {
+ isUploading: false,
+ progress: 0,
+ statusKey: '',
+ current: 0,
+ total: 0,
+ completedSingleItem: total === 1 && successItems.length === 1 ? successItems[0] : null,
+ batchResult: total > 1 ? { success: successItems.length, failed: total - successItems.length } : null,
+ lastError: total === 1 && successItems.length === 0 ? (failedMessages[0] || 'UPLOAD_FAILED') : ''
+ }
+ setState(prev => ({ ...prev, ...nextState }))
+ return { successItems, failedMessages }
+ } finally {
+ uploadingRef.current = false
+ }
+ }, [setStage, uploadSingleFile])
+
+ const consumeCompletedSingleItem = useCallback(() => {
+ setState(prev => ({ ...prev, completedSingleItem: null }))
+ }, [])
+
+ const consumeBatchResult = useCallback(() => {
+ setState(prev => ({ ...prev, batchResult: null }))
+ }, [])
+
+ const consumeLastError = useCallback(() => {
+ setState(prev => ({ ...prev, lastError: '' }))
+ }, [])
+
+ const value = useMemo(() => ({
+ ...state,
+ uploadFiles,
+ consumeCompletedSingleItem,
+ consumeBatchResult,
+ consumeLastError
+ }), [state, uploadFiles, consumeCompletedSingleItem, consumeBatchResult, consumeLastError])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useUpload() {
+ const context = useContext(UploadContext)
+ if (!context) {
+ throw new Error('useUpload must be used within UploadProvider')
+ }
+ return context
+}
+
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 11d8aa5..149fb72 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -29,7 +29,15 @@
"season": "Season",
"seasonPlaceholder": "e.g. Summer, Autumn (comma separated)",
"description": "Description",
- "descriptionPlaceholder": "More details about this clothing..."
+ "descriptionPlaceholder": "More details about this clothing...",
+ "heroTag": "Quick Start",
+ "heroTitle": "Build today's look in 3 steps",
+ "heroSubtitle": "Upload your clothes and get AI-powered styling suggestions.",
+ "goWardrobe": "Open Wardrobe",
+ "goRecommendation": "View Suggestion",
+ "stepUpload": "Upload",
+ "stepEdit": "Edit",
+ "stepRecommend": "Recommend"
},
"upload": {
"title": "Add New Clothing",
@@ -43,7 +51,8 @@
"removingBg": "Removing background...",
"analyzing": "Analyzing clothing...",
"done": "Done!",
- "uploadFailed": "Upload failed"
+ "uploadFailed": "Upload failed",
+ "batchResult": "Batch import completed: {{success}} succeeded, {{failed}} failed"
},
"wardrobe": {
"tops": "Tops",
@@ -56,6 +65,19 @@
"delete": "Delete",
"searchPlaceholder": "Search name, description..."
},
+ "clothesDetail": {
+ "title": "Clothing Detail",
+ "loading": "Loading detail...",
+ "notFound": "This clothing item does not exist or has been deleted.",
+ "loadFailed": "Failed to load detail. Please try again.",
+ "retry": "Retry",
+ "description": "Description",
+ "color": "Color",
+ "style": "Style",
+ "season": "Season",
+ "usage": "Usage",
+ "empty": "N/A"
+ },
"filter": {
"season": "Season",
"style": "Style",
@@ -117,7 +139,20 @@
"fromWardrobe": "In wardrobe",
"needBuy": "Need to buy",
"purchaseFallback": "Shopping fallback",
- "buyKeywords": "Suggested items"
+ "buyKeywords": "Suggested items",
+ "goOutfit": "Go to Outfit",
+ "goWardrobe": "Go to Wardrobe",
+ "goalLabel": "Goal",
+ "goalPlaceholder": "e.g. commute / date / sport / interview",
+ "goalHint": "Type your occasion or use the microphone.",
+ "goalUsed": "Goal used",
+ "voiceStart": "Start voice input",
+ "voiceStop": "Stop voice input",
+ "voiceListening": "Listening...",
+ "voiceUnsupported": "Voice input is not supported in this browser. Please type your goal.",
+ "voicePermissionDenied": "Microphone permission is denied. Please enable it and try again.",
+ "voiceNoSpeech": "No speech detected. Please try again.",
+ "voiceError": "Voice recognition failed. Please type your goal."
},
"home": {
"today": "Today",
diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json
index 148f76a..c193b08 100644
--- a/frontend/src/i18n/locales/ja.json
+++ b/frontend/src/i18n/locales/ja.json
@@ -29,7 +29,15 @@
"season": "シーズン",
"seasonPlaceholder": "例: 夏, 秋(カンマ区切り)",
"description": "詳細説明",
- "descriptionPlaceholder": "この服の詳細..."
+ "descriptionPlaceholder": "この服の詳細...",
+ "heroTag": "クイックスタート",
+ "heroTitle": "3ステップで今日のコーデ",
+ "heroSubtitle": "衣類をアップロードすると、AIが自動分析して提案します。",
+ "goWardrobe": "衣装棚を見る",
+ "goRecommendation": "提案を見る",
+ "stepUpload": "アップロード",
+ "stepEdit": "編集",
+ "stepRecommend": "提案"
},
"upload": {
"title": "新しい衣類を追加",
@@ -43,7 +51,8 @@
"removingBg": "背景を除去中...",
"analyzing": "衣類を分析中...",
"done": "完了!",
- "uploadFailed": "アップロード失敗"
+ "uploadFailed": "アップロード失敗",
+ "batchResult": "一括インポート完了:成功 {{success}} 件、失敗 {{failed}} 件"
},
"wardrobe": {
"tops": "トップス",
@@ -56,6 +65,19 @@
"delete": "削除",
"searchPlaceholder": "名前、説明で検索..."
},
+ "clothesDetail": {
+ "title": "衣類の詳細",
+ "loading": "詳細を読み込み中...",
+ "notFound": "この衣類は存在しないか、削除されています。",
+ "loadFailed": "詳細の読み込みに失敗しました。再試行してください。",
+ "retry": "再試行",
+ "description": "詳細説明",
+ "color": "カラー",
+ "style": "スタイル",
+ "season": "シーズン",
+ "usage": "利用シーン",
+ "empty": "なし"
+ },
"filter": {
"season": "シーズン",
"style": "スタイル",
@@ -117,7 +139,20 @@
"fromWardrobe": "手持ちあり",
"needBuy": "購入推奨",
"purchaseFallback": "不足分の購入提案",
- "buyKeywords": "おすすめアイテム"
+ "buyKeywords": "おすすめアイテム",
+ "goOutfit": "コーデへ",
+ "goWardrobe": "衣装棚へ",
+ "goalLabel": "今回の目的",
+ "goalPlaceholder": "例:通勤 / デート / スポーツ / 面接",
+ "goalHint": "目的は入力またはマイクで音声入力できます。",
+ "goalUsed": "今回の目的",
+ "voiceStart": "音声入力を開始",
+ "voiceStop": "音声入力を停止",
+ "voiceListening": "聞き取り中...",
+ "voiceUnsupported": "このブラウザでは音声入力に対応していません。手動で入力してください。",
+ "voicePermissionDenied": "マイク権限がありません。権限を有効にして再試行してください。",
+ "voiceNoSpeech": "音声を検出できませんでした。もう一度お試しください。",
+ "voiceError": "音声認識に失敗しました。手動入力をご利用ください。"
},
"home": {
"today": "今日",
diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json
index 4f88514..2e5119e 100644
--- a/frontend/src/i18n/locales/zh.json
+++ b/frontend/src/i18n/locales/zh.json
@@ -29,7 +29,15 @@
"season": "季节",
"seasonPlaceholder": "如: 夏季, 秋季 (逗号分隔)",
"description": "详细描述",
- "descriptionPlaceholder": "关于这件衣服的更多细节..."
+ "descriptionPlaceholder": "关于这件衣服的更多细节...",
+ "heroTag": "快速开始",
+ "heroTitle": "3 步完成今日穿搭",
+ "heroSubtitle": "上传衣物后,AI 会自动识别并给出搭配建议。",
+ "goWardrobe": "查看衣柜",
+ "goRecommendation": "去看推荐",
+ "stepUpload": "上传",
+ "stepEdit": "编辑",
+ "stepRecommend": "推荐"
},
"upload": {
"title": "添加新衣物",
@@ -43,7 +51,8 @@
"removingBg": "正在移除背景...",
"analyzing": "正在分析衣物...",
"done": "完成!",
- "uploadFailed": "上传失败"
+ "uploadFailed": "上传失败",
+ "batchResult": "批量导入完成:成功 {{success}} 张,失败 {{failed}} 张"
},
"wardrobe": {
"tops": "上装",
@@ -56,6 +65,19 @@
"delete": "删除",
"searchPlaceholder": "搜索名称、描述..."
},
+ "clothesDetail": {
+ "title": "衣物详情",
+ "loading": "正在加载详情...",
+ "notFound": "这件衣物不存在或已被删除。",
+ "loadFailed": "加载失败,请稍后重试。",
+ "retry": "重试",
+ "description": "详细描述",
+ "color": "颜色",
+ "style": "风格",
+ "season": "季节",
+ "usage": "使用场景",
+ "empty": "暂无"
+ },
"filter": {
"season": "季节",
"style": "风格",
@@ -117,7 +139,20 @@
"fromWardrobe": "衣柜已有",
"needBuy": "建议补购",
"purchaseFallback": "购买补充建议",
- "buyKeywords": "推荐单品"
+ "buyKeywords": "推荐单品",
+ "goOutfit": "去穿搭页",
+ "goWardrobe": "去衣柜",
+ "goalLabel": "本次目标",
+ "goalPlaceholder": "例如:上班通勤 / 约会 / 运动 / 面试",
+ "goalHint": "可输入或点击麦克风语音输入目标场景。",
+ "goalUsed": "本次目标",
+ "voiceStart": "开始语音输入",
+ "voiceStop": "停止语音输入",
+ "voiceListening": "正在听你说话...",
+ "voiceUnsupported": "当前浏览器不支持语音输入,请手动输入目标。",
+ "voicePermissionDenied": "未获得麦克风权限,请开启后重试。",
+ "voiceNoSpeech": "没有识别到语音,请再试一次。",
+ "voiceError": "语音识别失败,请改用手动输入。"
},
"home": {
"today": "今天",
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 2cbf176..83f9158 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -44,7 +44,7 @@
@apply bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl shadow-sm hover:shadow-md transition-shadow duration-300;
}
.btn-primary {
- @apply bg-blue-600 text-white hover:bg-blue-700 transition-colors duration-200 rounded-lg font-medium px-4 py-2 flex items-center justify-center gap-2;
+ @apply bg-[var(--accent)] text-white hover:opacity-90 transition-all duration-200 rounded-lg font-medium px-4 py-2 flex items-center justify-center gap-2;
}
.btn-secondary {
@apply bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors duration-200 rounded-lg font-medium px-4 py-2 flex items-center justify-center gap-2;
@@ -53,7 +53,7 @@
@apply p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors;
}
.input-field {
- @apply w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-all;
+ @apply w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-accent/40 focus:border-accent transition-all;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index 628c359..95effbf 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -4,11 +4,17 @@ import './i18n'
import './index.css'
import App from './App.jsx'
import { ThemeProvider } from './contexts/ThemeContext.jsx'
+import { UploadProvider } from './contexts/UploadContext.jsx'
+import { RecommendationProvider } from './contexts/RecommendationContext.jsx'
createRoot(document.getElementById('root')).render(
-
+
+
+
+
+
,
)
diff --git a/frontend/src/pages/ClothesDetail.jsx b/frontend/src/pages/ClothesDetail.jsx
new file mode 100644
index 0000000..3fb60e5
--- /dev/null
+++ b/frontend/src/pages/ClothesDetail.jsx
@@ -0,0 +1,138 @@
+import { useEffect, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import { ArrowLeft, RefreshCw } from 'lucide-react'
+
+import { API_BASE, toImageUrl } from '../utils/api'
+
+export default function ClothesDetail() {
+ const { t } = useTranslation()
+ const navigate = useNavigate()
+ const { id } = useParams()
+ const [item, setItem] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+
+ useEffect(() => {
+ fetchClothesDetail()
+ }, [id])
+
+ const fetchClothesDetail = async () => {
+ setLoading(true)
+ setError('')
+ try {
+ const response = await fetch(`${API_BASE}/clothes/${id}`)
+ if (!response.ok) {
+ throw new Error(response.status === 404 ? 'NOT_FOUND' : 'FETCH_FAILED')
+ }
+ const data = await response.json()
+ setItem(data)
+ } catch (err) {
+ setItem(null)
+ setError(err.message || 'FETCH_FAILED')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const renderTags = (values) => {
+ if (!Array.isArray(values) || values.length === 0) {
+ return {t('clothesDetail.empty')}
+ }
+ return (
+
+ {values.map(value => (
+
+ {value}
+
+ ))}
+
+ )
+ }
+
+ if (loading) {
+ return (
+
+
+
{t('clothesDetail.loading')}
+
+ )
+ }
+
+ if (!item || error) {
+ return (
+
+
+
+
+
+
+ {error === 'NOT_FOUND' ? t('clothesDetail.notFound') : t('clothesDetail.loadFailed')}
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
{t('clothesDetail.title')}
+
+
+
+
+
+
+
})
+
+
+
{item.item}
+
{item.category}
+
+
+
+
+
+
{t('clothesDetail.description')}
+
{item.description || t('clothesDetail.empty')}
+
+
+
+
{t('clothesDetail.color')}
+
{item.color_semantics || t('clothesDetail.empty')}
+
+
+
+
{t('clothesDetail.style')}
+
{renderTags(item.style_semantics)}
+
+
+
+
{t('clothesDetail.season')}
+
{renderTags(item.season_semantics)}
+
+
+
+
{t('clothesDetail.usage')}
+
{renderTags(item.usage_semantics)}
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Entry.jsx b/frontend/src/pages/Entry.jsx
index 94add7d..003bea2 100644
--- a/frontend/src/pages/Entry.jsx
+++ b/frontend/src/pages/Entry.jsx
@@ -1,13 +1,15 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
+import { useNavigate } from 'react-router-dom'
import Upload from '../components/Upload'
import Settings from '../components/Settings'
-import { Save, ArrowLeft, Tag, Palette, Layers, CloudSun, FileText, Shirt, Settings as SettingsIcon } from 'lucide-react'
+import { Save, ArrowLeft, Tag, Palette, Layers, CloudSun, FileText, Shirt, Settings as SettingsIcon, Sparkles } from 'lucide-react'
-const API_BASE = `http://${window.location.hostname}:8000/api`
+import { API_BASE, toImageUrl } from '../utils/api'
export default function Entry() {
const { t } = useTranslation()
+ const navigate = useNavigate()
const [editingItem, setEditingItem] = useState(null)
const [loading, setLoading] = useState(false)
const [showSettings, setShowSettings] = useState(false)
@@ -99,7 +101,7 @@ export default function Entry() {

@@ -208,7 +210,7 @@ export default function Entry() {
return (