refactor: Remove unused DB translation system

Static dictionary (i18n.ts CAR_TRANSLATIONS) already covers all terms.
DB translations table had only 179 entries used as fallback and was
never actually reached. Simplifies useTranslate hook to static-only.
DB table preserved for safety.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
AutonetSellCar Deploy
2026-02-18 23:24:38 +09:00
parent 46973c8508
commit 3f27297c4a
10 changed files with 10 additions and 2140 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from .database import engine, Base, SessionLocal from .database import engine, Base, SessionLocal
from .api import cars, auth, inquiries, hero_banners, carmodoo, translations, cc, settings, vehicle_requests, dealer, vehicle_share, withdrawal, referral, notification, dashboard, push, exchange_rate, verification, visitor, sns_share, bulletin, reviews from .api import cars, auth, inquiries, hero_banners, carmodoo, cc, settings, vehicle_requests, dealer, vehicle_share, withdrawal, referral, notification, dashboard, push, exchange_rate, verification, visitor, sns_share, bulletin, reviews
from .config import get_settings from .config import get_settings
from .services.exchange_rate_service import update_exchange_rates from .services.exchange_rate_service import update_exchange_rates
from .services.visitor_service import aggregate_daily_stats, cleanup_old_visitor_logs from .services.visitor_service import aggregate_daily_stats, cleanup_old_visitor_logs
@@ -223,7 +223,6 @@ app.include_router(auth.router, prefix="/api")
app.include_router(inquiries.router, prefix="/api") app.include_router(inquiries.router, prefix="/api")
app.include_router(hero_banners.router, prefix="/api") app.include_router(hero_banners.router, prefix="/api")
app.include_router(carmodoo.router, prefix="/api") app.include_router(carmodoo.router, prefix="/api")
app.include_router(translations.router, prefix="/api")
app.include_router(cc.router, prefix="/api") app.include_router(cc.router, prefix="/api")
app.include_router(settings.router, prefix="/api") app.include_router(settings.router, prefix="/api")
app.include_router(vehicle_requests.router, prefix="/api") app.include_router(vehicle_requests.router, prefix="/api")

View File

@@ -2,7 +2,6 @@ from .car import CarMaker, CarModel, Car, CarImage, CarOption
from .user import User, CarView, PerformanceCheckView, ChargeHistory, VerificationCode from .user import User, CarView, PerformanceCheckView, ChargeHistory, VerificationCode
from .inquiry import Inquiry, InquiryMessage, InquiryStatus, InquiryCategory from .inquiry import Inquiry, InquiryMessage, InquiryStatus, InquiryCategory
from .hero_banner import HeroBanner, HeroBannerSettings from .hero_banner import HeroBanner, HeroBannerSettings
from .translation import Translation
from .cache import CarCache, CarDetailCache, CacheRequestQueue from .cache import CarCache, CarDetailCache, CacheRequestQueue
from .settings import SystemSettings from .settings import SystemSettings
from .vehicle_request import VehicleRequest, RequestVehicle, PurchasedVehicle from .vehicle_request import VehicleRequest, RequestVehicle, PurchasedVehicle
@@ -40,7 +39,6 @@ __all__ = [
"InquiryCategory", "InquiryCategory",
"HeroBanner", "HeroBanner",
"HeroBannerSettings", "HeroBannerSettings",
"Translation",
"CarCache", "CarCache",
"CarDetailCache", "CarDetailCache",
"CacheRequestQueue", "CacheRequestQueue",

View File

@@ -1,28 +0,0 @@
from sqlalchemy import Column, Integer, String, DateTime, Index
from sqlalchemy.sql import func
from ..database import Base
class Translation(Base):
"""Translation dictionary for car-related terms"""
__tablename__ = "translations"
id = Column(Integer, primary_key=True, index=True)
# Source text (Korean)
source_text = Column(String(500), nullable=False, index=True)
# Category: maker, model, fuel, transmission, color, car_name, etc.
category = Column(String(50), nullable=False, index=True)
# Translations
text_en = Column(String(500)) # English
text_mn = Column(String(500)) # Mongolian
text_ru = Column(String(500)) # Russian
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
__table_args__ = (
Index('ix_translations_source_category', 'source_text', 'category', unique=True),
)

View File

@@ -16,10 +16,6 @@ from .hero_banner import (
HeroBannerListResponse, HeroBannerLocalizedResponse, HeroBannerListResponse, HeroBannerLocalizedResponse,
HeroBannerSettingsUpdate, HeroBannerSettingsResponse, HeroBannerSettingsUpdate, HeroBannerSettingsResponse,
) )
from .translation import (
TranslationCreate, TranslationUpdate, TranslationResponse,
TranslationListResponse, TranslationBulkRequest, TranslationBulkResponse,
)
from .vehicle_request import ( from .vehicle_request import (
VehicleRequestCreate, VehicleRequestResponse, VehicleRequestCreate, VehicleRequestResponse,
RequestVehicleCreate, RequestVehicleResponse, RequestVehicleApprove, RequestVehicleCreate, RequestVehicleResponse, RequestVehicleApprove,
@@ -65,8 +61,6 @@ __all__ = [
"HeroBannerCreate", "HeroBannerUpdate", "HeroBannerResponse", "HeroBannerCreate", "HeroBannerUpdate", "HeroBannerResponse",
"HeroBannerListResponse", "HeroBannerLocalizedResponse", "HeroBannerListResponse", "HeroBannerLocalizedResponse",
"HeroBannerSettingsUpdate", "HeroBannerSettingsResponse", "HeroBannerSettingsUpdate", "HeroBannerSettingsResponse",
"TranslationCreate", "TranslationUpdate", "TranslationResponse",
"TranslationListResponse", "TranslationBulkRequest", "TranslationBulkResponse",
"VehicleRequestCreate", "VehicleRequestResponse", "VehicleRequestCreate", "VehicleRequestResponse",
"RequestVehicleCreate", "RequestVehicleResponse", "RequestVehicleApprove", "RequestVehicleCreate", "RequestVehicleResponse", "RequestVehicleApprove",
"PurchasedVehicleCreate", "PurchasedVehicleResponse", "PurchasedVehicleUpdateStatus", "PurchasedVehicleCreate", "PurchasedVehicleResponse", "PurchasedVehicleUpdateStatus",

View File

@@ -1,52 +0,0 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
class TranslationCreate(BaseModel):
source_text: str
category: str
text_en: Optional[str] = None
text_mn: Optional[str] = None
text_ru: Optional[str] = None
class TranslationUpdate(BaseModel):
source_text: Optional[str] = None
category: Optional[str] = None
text_en: Optional[str] = None
text_mn: Optional[str] = None
text_ru: Optional[str] = None
class TranslationResponse(BaseModel):
id: int
source_text: str
category: str
text_en: Optional[str] = None
text_mn: Optional[str] = None
text_ru: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TranslationListResponse(BaseModel):
total: int
page: int
page_size: int
translations: List[TranslationResponse]
class TranslationBulkRequest(BaseModel):
"""Bulk translation lookup request"""
texts: List[str]
category: Optional[str] = None
lang: str = "en"
class TranslationBulkResponse(BaseModel):
"""Returns a dictionary mapping source text to translated text"""
translations: dict # {source_text: translated_text}

View File

@@ -16,7 +16,6 @@ const menuItems = [
{ href: '/admin/withdrawals', label: 'Withdrawals', icon: '💸' }, { href: '/admin/withdrawals', label: 'Withdrawals', icon: '💸' },
{ href: '/admin/sns-shares', label: 'SNS Shares', icon: '📢' }, { href: '/admin/sns-shares', label: 'SNS Shares', icon: '📢' },
{ href: '/admin/notifications', label: 'Notifications', icon: '🔔' }, { href: '/admin/notifications', label: 'Notifications', icon: '🔔' },
{ href: '/admin/translations', label: 'Translations', icon: '🌐' },
{ href: '/admin/dealer-translations', label: 'Dealer Descriptions', icon: '📝' }, { href: '/admin/dealer-translations', label: 'Dealer Descriptions', icon: '📝' },
{ href: '/admin/users', label: 'Users', icon: '👥' }, { href: '/admin/users', label: 'Users', icon: '👥' },
{ href: '/admin/inquiries', label: 'Inquiries', icon: '💬' }, { href: '/admin/inquiries', label: 'Inquiries', icon: '💬' },

View File

@@ -1,765 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { translationsApi, Translation, TranslationListResponse } from '@/lib/api';
const CATEGORY_LABELS: Record<string, string> = {
maker: 'Maker (제조사)',
model: 'Model (모델)',
fuel: 'Fuel (연료)',
transmission: 'Transmission (변속기)',
color: 'Color (색상)',
car_name: 'Car Name (차량명)',
general: 'General (일반)',
};
interface TranslationStats {
total_entries: number;
by_category: Record<string, number>;
translation_coverage: {
english: { translated: number; total: number; percentage: number };
mongolian: { translated: number; total: number; percentage: number };
russian: { translated: number; total: number; percentage: number };
};
}
export default function TranslationsPage() {
const [translations, setTranslations] = useState<Translation[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [selectedCategory, setSelectedCategory] = useState<string>('');
const [searchTerm, setSearchTerm] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const [editData, setEditData] = useState<Partial<Translation>>({});
const [showAddModal, setShowAddModal] = useState(false);
const [showBatchModal, setShowBatchModal] = useState(false);
const [translatingId, setTranslatingId] = useState<number | null>(null);
const [batchTranslating, setBatchTranslating] = useState(false);
const [stats, setStats] = useState<TranslationStats | null>(null);
const [batchOptions, setBatchOptions] = useState({
category: '',
overwriteExisting: false,
targetLangs: ['en', 'mn', 'ru'] as string[],
});
const [batchResult, setBatchResult] = useState<{
total_processed: number;
successful: number;
failed: number;
} | null>(null);
const [newTranslation, setNewTranslation] = useState({
source_text: '',
category: 'general',
text_en: '',
text_mn: '',
text_ru: '',
});
const pageSize = 20;
useEffect(() => {
loadCategories();
loadStats();
}, []);
useEffect(() => {
loadTranslations();
}, [page, selectedCategory, searchTerm]);
const loadCategories = async () => {
try {
const data = await translationsApi.getCategories();
setCategories(data);
} catch (err) {
console.error('Failed to load categories:', err);
}
};
const loadStats = async () => {
try {
const data = await translationsApi.getStats();
setStats(data);
} catch (err) {
console.error('Failed to load stats:', err);
}
};
const loadTranslations = async () => {
setLoading(true);
try {
const data = await translationsApi.getList({
page,
page_size: pageSize,
category: selectedCategory || undefined,
search: searchTerm || undefined,
});
setTranslations(data.translations);
setTotal(data.total);
} catch (err) {
console.error('Failed to load translations:', err);
} finally {
setLoading(false);
}
};
const handleAutoExtract = async () => {
try {
const result = await translationsApi.autoExtract();
alert(result.message);
loadTranslations();
loadStats();
} catch (err) {
console.error('Failed to auto-extract:', err);
alert('Failed to auto-extract translations');
}
};
const handleSeedAllDefaults = async () => {
try {
const result = await translationsApi.seedAllDefaults();
alert(`${result.message}\n\nCategories: ${result.categories.join(', ')}`);
loadTranslations();
loadStats();
} catch (err) {
console.error('Failed to seed defaults:', err);
alert('Failed to seed default translations');
}
};
const handleAutoTranslate = async (translation: Translation) => {
setTranslatingId(translation.id);
try {
const result = await translationsApi.autoTranslate(translation.id);
alert(`Auto-translated: ${result.message}`);
loadTranslations();
loadStats();
} catch (err: any) {
console.error('Failed to auto-translate:', err);
alert(err.response?.data?.detail || 'Failed to auto-translate');
} finally {
setTranslatingId(null);
}
};
const handleBatchTranslate = async () => {
setBatchTranslating(true);
setBatchResult(null);
try {
const result = await translationsApi.autoTranslateBatch(
batchOptions.targetLangs,
batchOptions.category || undefined,
batchOptions.overwriteExisting
);
setBatchResult({
total_processed: result.total_processed,
successful: result.successful,
failed: result.failed,
});
loadTranslations();
loadStats();
} catch (err: any) {
console.error('Failed to batch translate:', err);
alert(err.response?.data?.detail || 'Failed to batch translate');
} finally {
setBatchTranslating(false);
}
};
const handleEdit = (translation: Translation) => {
setEditingId(translation.id);
setEditData({
text_en: translation.text_en || '',
text_mn: translation.text_mn || '',
text_ru: translation.text_ru || '',
});
};
const handleSave = async (id: number) => {
try {
await translationsApi.update(id, editData);
setEditingId(null);
loadTranslations();
loadStats();
} catch (err) {
console.error('Failed to save:', err);
alert('Failed to save translation');
}
};
const handleDelete = async (id: number) => {
if (!confirm('Delete this translation?')) return;
try {
await translationsApi.delete(id);
loadTranslations();
loadStats();
} catch (err) {
console.error('Failed to delete:', err);
alert('Failed to delete translation');
}
};
const handleAdd = async () => {
if (!newTranslation.source_text.trim()) {
alert('Source text is required');
return;
}
try {
await translationsApi.create(newTranslation);
setShowAddModal(false);
setNewTranslation({
source_text: '',
category: 'general',
text_en: '',
text_mn: '',
text_ru: '',
});
loadTranslations();
loadStats();
} catch (err: any) {
console.error('Failed to add:', err);
alert(err.response?.data?.detail || 'Failed to add translation');
}
};
const totalPages = Math.ceil(total / pageSize);
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-800">Translations Management</h1>
<div className="flex gap-2">
<button
onClick={handleSeedAllDefaults}
className="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 flex items-center gap-2"
title="Load all predefined translations (makers, models, colors, fuels, etc.)"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Seed Defaults
</button>
<button
onClick={handleAutoExtract}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2"
title="Extract terms from cars in database"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Auto Extract
</button>
<button
onClick={() => setShowBatchModal(true)}
className="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
</svg>
Batch Translate
</button>
<button
onClick={() => setShowAddModal(true)}
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add New
</button>
</div>
</div>
{/* Translation Statistics */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-xl shadow-sm p-4">
<div className="text-sm text-gray-500">Total Entries</div>
<div className="text-2xl font-bold text-gray-800">{stats.total_entries}</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-4">
<div className="text-sm text-gray-500">English Coverage</div>
<div className="flex items-center gap-2">
<div className="text-2xl font-bold text-blue-600">{stats.translation_coverage.english.percentage.toFixed(1)}%</div>
<div className="text-xs text-gray-400">({stats.translation_coverage.english.translated}/{stats.translation_coverage.english.total})</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
<div className="bg-blue-600 h-2 rounded-full" style={{ width: `${stats.translation_coverage.english.percentage}%` }}></div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-4">
<div className="text-sm text-gray-500">Mongolian Coverage</div>
<div className="flex items-center gap-2">
<div className="text-2xl font-bold text-green-600">{stats.translation_coverage.mongolian.percentage.toFixed(1)}%</div>
<div className="text-xs text-gray-400">({stats.translation_coverage.mongolian.translated}/{stats.translation_coverage.mongolian.total})</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
<div className="bg-green-600 h-2 rounded-full" style={{ width: `${stats.translation_coverage.mongolian.percentage}%` }}></div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-4">
<div className="text-sm text-gray-500">Russian Coverage</div>
<div className="flex items-center gap-2">
<div className="text-2xl font-bold text-red-600">{stats.translation_coverage.russian.percentage.toFixed(1)}%</div>
<div className="text-xs text-gray-400">({stats.translation_coverage.russian.translated}/{stats.translation_coverage.russian.total})</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
<div className="bg-red-600 h-2 rounded-full" style={{ width: `${stats.translation_coverage.russian.percentage}%` }}></div>
</div>
</div>
</div>
)}
{/* Category Stats */}
{stats && Object.keys(stats.by_category).length > 0 && (
<div className="bg-white rounded-xl shadow-sm p-4 mb-6">
<h3 className="text-sm font-medium text-gray-700 mb-3">Entries by Category</h3>
<div className="flex flex-wrap gap-2">
{Object.entries(stats.by_category).map(([cat, count]) => (
<span key={cat} className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm">
{CATEGORY_LABELS[cat] || cat}: {count}
</span>
))}
</div>
</div>
)}
{/* Filters */}
<div className="bg-white rounded-xl shadow-sm p-4 mb-6">
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium text-gray-700 mb-1">Search</label>
<input
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setPage(1);
}}
placeholder="Search translations..."
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
<div className="w-48">
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select
value={selectedCategory}
onChange={(e) => {
setSelectedCategory(e.target.value);
setPage(1);
}}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{CATEGORY_LABELS[cat] || cat}
</option>
))}
</select>
</div>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Category</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Korean (Source)</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">English</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Mongolian</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Russian</th>
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600 w-40">Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={6} className="py-8 text-center">
<div className="flex justify-center">
<div className="w-8 h-8 border-4 border-primary-600 border-t-transparent rounded-full animate-spin"></div>
</div>
</td>
</tr>
) : translations.length === 0 ? (
<tr>
<td colSpan={6} className="py-8 text-center text-gray-500">
No translations found
</td>
</tr>
) : (
translations.map((trans) => (
<tr key={trans.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-3 px-4">
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">
{CATEGORY_LABELS[trans.category] || trans.category}
</span>
</td>
<td className="py-3 px-4 font-medium text-gray-800">{trans.source_text}</td>
<td className="py-3 px-4">
{editingId === trans.id ? (
<input
type="text"
value={editData.text_en || ''}
onChange={(e) => setEditData({ ...editData, text_en: e.target.value })}
className="w-full border border-gray-300 rounded px-2 py-1 text-sm"
/>
) : (
<span className={trans.text_en ? 'text-gray-700' : 'text-gray-400 italic'}>
{trans.text_en || 'Not set'}
</span>
)}
</td>
<td className="py-3 px-4">
{editingId === trans.id ? (
<input
type="text"
value={editData.text_mn || ''}
onChange={(e) => setEditData({ ...editData, text_mn: e.target.value })}
className="w-full border border-gray-300 rounded px-2 py-1 text-sm"
/>
) : (
<span className={trans.text_mn ? 'text-gray-700' : 'text-gray-400 italic'}>
{trans.text_mn || 'Not set'}
</span>
)}
</td>
<td className="py-3 px-4">
{editingId === trans.id ? (
<input
type="text"
value={editData.text_ru || ''}
onChange={(e) => setEditData({ ...editData, text_ru: e.target.value })}
className="w-full border border-gray-300 rounded px-2 py-1 text-sm"
/>
) : (
<span className={trans.text_ru ? 'text-gray-700' : 'text-gray-400 italic'}>
{trans.text_ru || 'Not set'}
</span>
)}
</td>
<td className="py-3 px-4">
{editingId === trans.id ? (
<div className="flex gap-2">
<button
onClick={() => handleSave(trans.id)}
className="text-green-600 hover:text-green-700"
title="Save"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
<button
onClick={() => setEditingId(null)}
className="text-gray-600 hover:text-gray-700"
title="Cancel"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
) : (
<div className="flex gap-2">
<button
onClick={() => handleAutoTranslate(trans)}
disabled={translatingId === trans.id}
className={`text-purple-600 hover:text-purple-700 ${translatingId === trans.id ? 'opacity-50' : ''}`}
title="Auto Translate"
>
{translatingId === trans.id ? (
<div className="w-5 h-5 border-2 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
</svg>
)}
</button>
<button
onClick={() => handleEdit(trans)}
className="text-blue-600 hover:text-blue-700"
title="Edit"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(trans.id)}
className="text-red-600 hover:text-red-700"
title="Delete"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-2 py-4 border-t border-gray-200">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
Prev
</button>
<span className="px-4 text-gray-600">
Page {page} of {totalPages} ({total} total)
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
Next
</button>
</div>
)}
</div>
{/* Add Modal */}
{showAddModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl max-w-lg w-full p-6">
<h2 className="text-xl font-bold text-gray-800 mb-4">Add Translation</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Korean (Source Text) *
</label>
<input
type="text"
value={newTranslation.source_text}
onChange={(e) => setNewTranslation({ ...newTranslation, source_text: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
placeholder="Enter Korean text"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select
value={newTranslation.category}
onChange={(e) => setNewTranslation({ ...newTranslation, category: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
>
{categories.map((cat) => (
<option key={cat} value={cat}>
{CATEGORY_LABELS[cat] || cat}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">English</label>
<input
type="text"
value={newTranslation.text_en}
onChange={(e) => setNewTranslation({ ...newTranslation, text_en: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
placeholder="English translation"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Mongolian</label>
<input
type="text"
value={newTranslation.text_mn}
onChange={(e) => setNewTranslation({ ...newTranslation, text_mn: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
placeholder="Mongolian translation"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Russian</label>
<input
type="text"
value={newTranslation.text_ru}
onChange={(e) => setNewTranslation({ ...newTranslation, text_ru: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
placeholder="Russian translation"
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setShowAddModal(false)}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleAdd}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
Add
</button>
</div>
</div>
</div>
)}
{/* Batch Translate Modal */}
{showBatchModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl max-w-lg w-full p-6">
<h2 className="text-xl font-bold text-gray-800 mb-4">Batch Auto-Translate</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Category (Optional)</label>
<select
value={batchOptions.category}
onChange={(e) => setBatchOptions({ ...batchOptions, category: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{CATEGORY_LABELS[cat] || cat}
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">Leave empty to translate all categories</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Target Languages</label>
<div className="flex gap-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={batchOptions.targetLangs.includes('en')}
onChange={(e) => {
if (e.target.checked) {
setBatchOptions({ ...batchOptions, targetLangs: [...batchOptions.targetLangs, 'en'] });
} else {
setBatchOptions({ ...batchOptions, targetLangs: batchOptions.targetLangs.filter(l => l !== 'en') });
}
}}
className="rounded"
/>
<span>English</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={batchOptions.targetLangs.includes('mn')}
onChange={(e) => {
if (e.target.checked) {
setBatchOptions({ ...batchOptions, targetLangs: [...batchOptions.targetLangs, 'mn'] });
} else {
setBatchOptions({ ...batchOptions, targetLangs: batchOptions.targetLangs.filter(l => l !== 'mn') });
}
}}
className="rounded"
/>
<span>Mongolian</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={batchOptions.targetLangs.includes('ru')}
onChange={(e) => {
if (e.target.checked) {
setBatchOptions({ ...batchOptions, targetLangs: [...batchOptions.targetLangs, 'ru'] });
} else {
setBatchOptions({ ...batchOptions, targetLangs: batchOptions.targetLangs.filter(l => l !== 'ru') });
}
}}
className="rounded"
/>
<span>Russian</span>
</label>
</div>
</div>
<div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={batchOptions.overwriteExisting}
onChange={(e) => setBatchOptions({ ...batchOptions, overwriteExisting: e.target.checked })}
className="rounded"
/>
<span className="text-sm text-gray-700">Overwrite existing translations</span>
</label>
<p className="text-xs text-gray-500 mt-1">If unchecked, only empty translations will be filled</p>
</div>
{batchResult && (
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-medium text-gray-700 mb-2">Translation Results</h3>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-gray-800">{batchResult.total_processed}</div>
<div className="text-xs text-gray-500">Processed</div>
</div>
<div>
<div className="text-2xl font-bold text-green-600">{batchResult.successful}</div>
<div className="text-xs text-gray-500">Successful</div>
</div>
<div>
<div className="text-2xl font-bold text-red-600">{batchResult.failed}</div>
<div className="text-xs text-gray-500">Failed</div>
</div>
</div>
</div>
)}
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => {
setShowBatchModal(false);
setBatchResult(null);
}}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
>
Close
</button>
<button
onClick={handleBatchTranslate}
disabled={batchTranslating || batchOptions.targetLangs.length === 0}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center gap-2"
>
{batchTranslating ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
Translating...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
</svg>
Start Batch Translate
</>
)}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -235,146 +235,6 @@ export const heroBannersApi = {
}, },
}; };
// Translations API
export interface Translation {
id: number;
source_text: string;
category: string;
text_en?: string;
text_mn?: string;
text_ru?: string;
created_at: string;
updated_at: string;
}
export interface TranslationListResponse {
total: number;
page: number;
page_size: number;
translations: Translation[];
}
export const translationsApi = {
getCategories: async (): Promise<string[]> => {
const { data } = await api.get('/translations/categories');
return data;
},
getList: async (params: {
page?: number;
page_size?: number;
category?: string;
search?: string;
}): Promise<TranslationListResponse> => {
const { data } = await api.get('/translations', { params });
return data;
},
getById: async (id: number): Promise<Translation> => {
const { data } = await api.get(`/translations/${id}`);
return data;
},
create: async (translationData: {
source_text: string;
category: string;
text_en?: string;
text_mn?: string;
text_ru?: string;
}): Promise<Translation> => {
const { data } = await api.post('/translations', translationData);
return data;
},
update: async (id: number, translationData: {
source_text?: string;
category?: string;
text_en?: string;
text_mn?: string;
text_ru?: string;
}): Promise<Translation> => {
const { data } = await api.put(`/translations/${id}`, translationData);
return data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`/translations/${id}`);
},
autoExtract: async (): Promise<{ message: string }> => {
const { data } = await api.post('/translations/auto-extract');
return data;
},
seedAllDefaults: async (): Promise<{ message: string; added: number; skipped: number; categories: string[] }> => {
const { data } = await api.post('/translations/seed-all-defaults');
return data;
},
bulkLookup: async (texts: string[], lang: string, category?: string): Promise<{ translations: Record<string, string> }> => {
const { data } = await api.post('/translations/bulk-lookup', {
texts,
lang,
category,
});
return data;
},
// Auto-translation endpoints
autoTranslate: async (translationId: number, targetLangs?: string[]): Promise<{
id: number;
source_text: string;
translations: Record<string, string>;
message: string;
}> => {
const { data } = await api.post(`/translations/auto-translate/${translationId}`, {
target_langs: targetLangs || ['en', 'mn', 'ru']
});
return data;
},
autoTranslateBatch: async (targetLangs?: string[], category?: string, overwriteExisting?: boolean): Promise<{
total_processed: number;
successful: number;
failed: number;
results: Array<{ id: number; source_text: string; success: boolean; error?: string }>;
}> => {
const { data } = await api.post('/translations/auto-translate-batch', {
target_langs: targetLangs || ['en', 'mn', 'ru'],
category,
overwrite_existing: overwriteExisting || false
});
return data;
},
translateOnDemand: async (text: string, sourceLang: string, targetLang: string): Promise<{
source_text: string;
translated_text: string;
source_lang: string;
target_lang: string;
}> => {
const { data } = await api.post('/translations/translate-on-demand', {
text,
source_lang: sourceLang,
target_lang: targetLang
});
return data;
},
getStats: async (): Promise<{
total_entries: number;
by_category: Record<string, number>;
translation_coverage: {
english: { translated: number; total: number; percentage: number };
mongolian: { translated: number; total: number; percentage: number };
russian: { translated: number; total: number; percentage: number };
};
}> => {
const { data } = await api.get('/translations/stats');
return data;
},
};
// Carmodoo API // Carmodoo API
export interface CarmodooMaker { export interface CarmodooMaker {
code: string; code: string;

View File

@@ -1,74 +1,16 @@
import { useState, useEffect, useCallback } from 'react'; import { useCallback } from 'react';
import { translationsApi } from './api';
import { useLanguageStore, translateCarName, Language } from './i18n'; import { useLanguageStore, translateCarName, Language } from './i18n';
// Cache for translations to avoid repeated API calls
const translationCache: Record<string, Record<string, string>> = {};
export function useTranslate() { export function useTranslate() {
const { language } = useLanguageStore(); const { language } = useLanguageStore();
const [translations, setTranslations] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
// Get cache key for current language // Translate a single text using static dictionary
const cacheKey = `trans_${language}`;
// Load translations from cache on mount
useEffect(() => {
if (translationCache[cacheKey]) {
setTranslations(translationCache[cacheKey]);
}
}, [cacheKey]);
// Translate a single text
const translate = useCallback((text: string | undefined | null): string => { const translate = useCallback((text: string | undefined | null): string => {
if (!text) return ''; if (!text) return '';
if (language === 'ko') return text; // Korean is source, no translation needed if (language === 'ko') return text; // Korean is source, no translation needed
// Try static translations FIRST (for fuel, transmission, car names, etc.) return translateCarName(text, language as Language);
const staticTranslation = translateCarName(text, language as Language); }, [language]);
if (staticTranslation !== text) {
return staticTranslation;
}
// Then check API cache for other translations
const cached = translationCache[cacheKey]?.[text];
if (cached) return cached;
return text; // Fallback to original if no translation found
}, [language, cacheKey]);
// Bulk load translations for multiple texts
const loadTranslations = useCallback(async (texts: string[], category?: string) => {
if (language === 'ko') return; // No need to translate Korean
// Filter out already cached texts
const uncachedTexts = texts.filter(
t => t && !translationCache[cacheKey]?.[t]
);
if (uncachedTexts.length === 0) return;
setLoading(true);
try {
// Map language code to API expected format
const langCode = language === 'mn' ? 'mn' : language === 'ru' ? 'ru' : 'en';
const result = await translationsApi.bulkLookup(uncachedTexts, langCode, category);
// Update cache
if (!translationCache[cacheKey]) {
translationCache[cacheKey] = {};
}
Object.assign(translationCache[cacheKey], result.translations);
setTranslations({ ...translationCache[cacheKey] });
} catch (err) {
console.error('Failed to load translations:', err);
} finally {
setLoading(false);
}
}, [language, cacheKey]);
// Translate car object fields // Translate car object fields
const translateCar = useCallback((car: { const translateCar = useCallback((car: {
@@ -89,8 +31,8 @@ export function useTranslate() {
}; };
}, [translate]); }, [translate]);
// Preload translations for a list of cars // Kept for API compatibility - static translations are synchronous, so this is a no-op
const preloadCarTranslations = useCallback(async (cars: Array<{ const preloadCarTranslations = useCallback(async (_cars: Array<{
car_name?: string; car_name?: string;
fuel?: string; fuel?: string;
transmission?: string; transmission?: string;
@@ -98,37 +40,13 @@ export function useTranslate() {
maker?: { name: string }; maker?: { name: string };
model?: { name: string }; model?: { name: string };
}>) => { }>) => {
const textsToTranslate: string[] = []; // No-op: static dictionary translations are synchronous
}, []);
cars.forEach(car => {
if (car.car_name) textsToTranslate.push(car.car_name);
if (car.fuel) textsToTranslate.push(car.fuel);
if (car.transmission) textsToTranslate.push(car.transmission);
if (car.color) textsToTranslate.push(car.color);
if (car.maker?.name) textsToTranslate.push(car.maker.name);
if (car.model?.name) textsToTranslate.push(car.model.name);
});
// Remove duplicates
const uniqueTexts = Array.from(new Set(textsToTranslate));
if (uniqueTexts.length > 0) {
await loadTranslations(uniqueTexts);
}
}, [loadTranslations]);
return { return {
translate, translate,
translateCar, translateCar,
loadTranslations,
preloadCarTranslations, preloadCarTranslations,
loading, loading: false,
}; };
} }
// Clear translation cache (useful when translations are updated)
export function clearTranslationCache() {
Object.keys(translationCache).forEach(key => {
delete translationCache[key];
});
}