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:
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
|
||||||
)
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
|
||||||
@@ -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: '💬' },
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user