diff --git a/ui/bun.lock b/ui/bun.lock index fafceeb..1e7556a 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -40,6 +40,7 @@ "embla-carousel-react": "^8.6.0", "i18next": "^25.6.2", "i18next-browser-languagedetector": "^8.2.0", + "idb": "^8.0.3", "input-otp": "^1.4.2", "lucide-react": "^0.553.0", "next-themes": "^0.4.6", @@ -582,6 +583,8 @@ "i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.0", "https://registry.npmmirror.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g=="], + "idb": ["idb@8.0.3", "https://registry.npmmirror.com/idb/-/idb-8.0.3.tgz", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="], + "ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], diff --git a/ui/package.json b/ui/package.json index 04362ba..b275208 100644 --- a/ui/package.json +++ b/ui/package.json @@ -45,6 +45,7 @@ "embla-carousel-react": "^8.6.0", "i18next": "^25.6.2", "i18next-browser-languagedetector": "^8.2.0", + "idb": "^8.0.3", "input-otp": "^1.4.2", "lucide-react": "^0.553.0", "next-themes": "^0.4.6", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 53d9536..fce9803 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -40,7 +40,7 @@ function MainPage() { return ['panel-' + Date.now()] }) - const { history, addToHistory, clearHistory, deleteItem } = useHistory() + const { history, addToHistory, clearHistory, deleteItem, loadMore, hasMore, search } = useHistory() const fetchVersion = async () => { try { @@ -244,6 +244,9 @@ function MainPage() { }} onClear={clearHistory} onDelete={deleteItem} + onLoadMore={loadMore} + hasMore={hasMore} + onSearch={search} /> ) diff --git a/ui/src/components/HistorySheet.tsx b/ui/src/components/HistorySheet.tsx index af04301..0322dc3 100644 --- a/ui/src/components/HistorySheet.tsx +++ b/ui/src/components/HistorySheet.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from "react" +import { useState, useEffect } from "react" import { Sheet, SheetContent, @@ -22,6 +22,9 @@ interface HistorySheetProps { onSelect: (item: HistoryItem) => void onClear: () => void onDelete: (id: string) => void + onLoadMore: () => void + hasMore: boolean + onSearch: (query: string) => void } export function HistorySheet({ @@ -31,47 +34,51 @@ export function HistorySheet({ onSelect, onClear, onDelete, + onLoadMore, + hasMore, + onSearch }: HistorySheetProps) { const { t } = useTranslation() const [searchQuery, setSearchQuery] = useState("") + const [debouncedQuery, setDebouncedQuery] = useState("") - const filteredHistory = useMemo(() => { - if (!searchQuery.trim()) return history - const lowerQuery = searchQuery.toLowerCase() - return history.filter( - (item) => - item.sourceText.toLowerCase().includes(lowerQuery) || - item.translatedText.toLowerCase().includes(lowerQuery) - ) - }, [history, searchQuery]) + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(searchQuery) + }, 300) + + return () => clearTimeout(timer) + }, [searchQuery]) + + useEffect(() => { + onSearch(debouncedQuery) + }, [debouncedQuery, onSearch]) return ( - - + + {t('history')} - {history.length === 0 ? t('noHistory') : t('historyDesc')} + {history.length === 0 && !searchQuery ? t('noHistory') : t('historyDesc')} - {history.length > 0 && ( -
-
- - setSearchQuery(e.target.value)} - /> -
+
+
+ + setSearchQuery(e.target.value)} + />
- )} +
- {filteredHistory.map((item) => ( + {history.map((item) => (
{item.translatedText}
))} - {filteredHistory.length === 0 && searchQuery && ( + {history.length === 0 && searchQuery && (
{t('noResults')}
)} + + {hasMore && ( +
+ +
+ )}
diff --git a/ui/src/components/SettingsMenu.tsx b/ui/src/components/SettingsMenu.tsx index f8366b6..acc6852 100644 --- a/ui/src/components/SettingsMenu.tsx +++ b/ui/src/components/SettingsMenu.tsx @@ -61,9 +61,7 @@ export function SettingsMenu({ showTokenDialog, setShowTokenDialog, onTokenSaved { code: 'ja', name: '日本語', short: '日' } ] - const toggleTheme = () => { - setTheme(actualTheme === 'dark' ? 'light' : 'dark') - } + const getSystemLanguage = () => { const lang = navigator.language?.toLowerCase() || 'en' @@ -263,26 +261,54 @@ export function SettingsMenu({ showTokenDialog, setShowTokenDialog, onTokenSaved - - - + + + +

{t('theme')}

+
+
+ +
{t('themeMode')}
+ { + if (value) setTheme(value as any) + }} + variant="outline" + size="sm" + className="w-full grid grid-cols-3 gap-1" > - {actualTheme === 'dark' ? ( - - ) : ( - - )} - - - -

{t('toggleTheme')}

-
- + + + {t('light')} + + + + {t('dark')} + + + {t('system')} + +
+
+ ) } diff --git a/ui/src/hooks/use-history.ts b/ui/src/hooks/use-history.ts index 72a9a85..550be18 100644 --- a/ui/src/hooks/use-history.ts +++ b/ui/src/hooks/use-history.ts @@ -1,66 +1,131 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' +import { dbService, type HistoryItem } from '@/lib/db' -export interface HistoryItem { - id: string - from: string - to: string - sourceText: string - translatedText: string - timestamp: number -} +const PAGE_SIZE = 20 -const HISTORY_KEY = 'translation_history' -const MAX_HISTORY = 50 +export type { HistoryItem } export function useHistory() { const [history, setHistory] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [hasMore, setHasMore] = useState(true) + const [page, setPage] = useState(0) + const [searchQuery, setSearchQuery] = useState('') + // Initial load and migration useEffect(() => { + const init = async () => { + setIsLoading(true) + await dbService.migrateFromLocalStorage() + const items = await dbService.getAll(PAGE_SIZE, 0) + setHistory(items) + setHasMore(items.length >= PAGE_SIZE) + setIsLoading(false) + } + init() + }, []) + + // Load more function + const loadMore = useCallback(async () => { + if (isLoading || !hasMore) return + + setIsLoading(true) try { - const stored = localStorage.getItem(HISTORY_KEY) - if (stored) { - setHistory(JSON.parse(stored)) + const nextPage = page + 1 + const offset = nextPage * PAGE_SIZE + + let newItems: HistoryItem[] + + if (searchQuery) { + newItems = [] + } else { + newItems = await dbService.getAll(PAGE_SIZE, offset) + } + + if (newItems.length > 0) { + setHistory(prev => [...prev, ...newItems]) + setPage(nextPage) + setHasMore(newItems.length >= PAGE_SIZE) + } else { + setHasMore(false) + } + } catch (error) { + console.error('Failed to load more history:', error) + } finally { + setIsLoading(false) + } + }, [page, isLoading, hasMore, searchQuery]) + + const search = useCallback(async (query: string) => { + setSearchQuery(query) + setPage(0) + setIsLoading(true) + try { + if (!query.trim()) { + const items = await dbService.getAll(PAGE_SIZE, 0) + setHistory(items) + setHasMore(items.length >= PAGE_SIZE) + } else { + const items = await dbService.search(query, 50) + setHistory(items) + setHasMore(false) } } catch (e) { - console.error('Failed to load history', e) + console.error('Search failed', e) + } finally { + setIsLoading(false) } }, []) - const addToHistory = (item: Omit) => { + const addToHistory = useCallback(async (item: Omit) => { const newItem: HistoryItem = { ...item, id: `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, timestamp: Date.now(), } - setHistory((prev) => { - // Remove duplicates if same source text and languages - const filtered = prev.filter( - (h) => - !( - h.sourceText === item.sourceText && - h.from === item.from && - h.to === item.to - ) - ) - const updated = [newItem, ...filtered].slice(0, MAX_HISTORY) - localStorage.setItem(HISTORY_KEY, JSON.stringify(updated)) - return updated - }) - } + try { + await dbService.add(newItem) + setHistory((prev) => { + const filtered = prev.filter( + (h) => !(h.sourceText === item.sourceText && h.from === item.from && h.to === item.to) + ) - const clearHistory = () => { - setHistory([]) - localStorage.removeItem(HISTORY_KEY) - } + return [newItem, ...filtered] + }) + } catch (e) { + console.error('Failed to add to history', e) + } + }, []) - const deleteItem = (id: string) => { - setHistory((prev) => { - const updated = prev.filter((item) => item.id !== id) - localStorage.setItem(HISTORY_KEY, JSON.stringify(updated)) - return updated - }) - } + const clearHistory = useCallback(async () => { + try { + await dbService.clear() + setHistory([]) + setPage(0) + setHasMore(false) + } catch (e) { + console.error('Failed to clear history', e) + } + }, []) - return { history, addToHistory, clearHistory, deleteItem } + const deleteItem = useCallback(async (id: string) => { + try { + await dbService.delete(id) + setHistory((prev) => prev.filter((item) => item.id !== id)) + } catch (e) { + console.error('Failed to delete item', e) + } + }, []) + + return { + history, + addToHistory, + clearHistory, + deleteItem, + loadMore, + hasMore, + isLoading, + search + } } diff --git a/ui/src/i18n/index.ts b/ui/src/i18n/index.ts index 59d139e..e0bf73d 100644 --- a/ui/src/i18n/index.ts +++ b/ui/src/i18n/index.ts @@ -113,7 +113,9 @@ const resources = { desktopOnly: 'Desktop only', desktopOnlyDesc: 'Desktop settings are only available in the desktop client', version: 'Version', - saving: 'Saving...' + saving: 'Saving...', + loadMore: 'Load More', + themeMode: 'Theme Mode', } }, zh: { @@ -226,7 +228,9 @@ const resources = { desktopOnly: '仅限桌面端', desktopOnlyDesc: '桌面端设置仅在桌面客户端可用', version: '版本', - saving: '保存中...' + saving: '保存中...', + loadMore: '加载更多', + themeMode: '主题模式', } }, ja: { @@ -339,7 +343,9 @@ const resources = { desktopOnly: 'デスクトップ専用', desktopOnlyDesc: 'デスクトップ設定はデスクトップクライアントのみ利用できます', version: 'バージョン', - saving: '保存中...' + saving: '保存中...', + loadMore: 'もっと読み込む', + themeMode: 'テーマモード', } } } diff --git a/ui/src/lib/db.ts b/ui/src/lib/db.ts new file mode 100644 index 0000000..42d3b99 --- /dev/null +++ b/ui/src/lib/db.ts @@ -0,0 +1,119 @@ +import { openDB, type DBSchema, type IDBPDatabase } from 'idb'; + +interface HistoryItem { + id: string; + from: string; + to: string; + sourceText: string; + translatedText: string; + timestamp: number; +} + +interface MTranHistoryDB extends DBSchema { + history: { + key: string; + value: HistoryItem; + indexes: { 'timestamp': number }; + }; +} + +const DB_NAME = 'MTranHistoryDB'; +const STORE_NAME = 'history'; +const LOCAL_STORAGE_KEY = 'translation_history'; + +class DbService { + private dbPromise: Promise>; + + constructor() { + this.dbPromise = openDB(DB_NAME, 1, { + upgrade(db) { + const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' }); + store.createIndex('timestamp', 'timestamp'); + }, + }); + } + + async migrateFromLocalStorage() { + try { + const stored = localStorage.getItem(LOCAL_STORAGE_KEY); + if (stored) { + const items: HistoryItem[] = JSON.parse(stored); + if (Array.isArray(items) && items.length > 0) { + const db = await this.dbPromise; + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + + for (const item of items) { + await store.put(item); + } + await tx.done; + console.log(`Migrated ${items.length} items from localStorage to IndexedDB`); + } + localStorage.removeItem(LOCAL_STORAGE_KEY); + } + } catch (e) { + console.error('Migration failed:', e); + } + } + + async getAll(limit: number = 20, offset: number = 0): Promise { + const db = await this.dbPromise; + const items: HistoryItem[] = []; + + let cursor = await db.transaction(STORE_NAME).store.index('timestamp').openCursor(null, 'prev'); + + if (offset > 0 && cursor) { + try { + await cursor.advance(offset); + } catch (e) { + cursor = null + } + } + + while (cursor && items.length < limit) { + items.push(cursor.value); + cursor = await cursor.continue(); + } + + return items; + } + + async add(item: HistoryItem) { + const db = await this.dbPromise; + await db.put(STORE_NAME, item); + } + + async delete(id: string) { + const db = await this.dbPromise; + await db.delete(STORE_NAME, id); + } + + async clear() { + const db = await this.dbPromise; + await db.clear(STORE_NAME); + } + + async search(query: string, limit: number = 50): Promise { + const db = await this.dbPromise; + const lowerQuery = query.toLowerCase(); + const items: HistoryItem[] = []; + + let cursor = await db.transaction(STORE_NAME).store.index('timestamp').openCursor(null, 'prev'); + + while (cursor && items.length < limit) { + const item = cursor.value; + if ( + item.sourceText.toLowerCase().includes(lowerQuery) || + item.translatedText.toLowerCase().includes(lowerQuery) + ) { + items.push(item); + } + cursor = await cursor.continue(); + } + + return items; + } +} + +export const dbService = new DbService(); +export type { HistoryItem };