mirror of
https://github.com/xxnuo/MTranServer.git
synced 2026-09-03 06:35:20 +08:00
feat(ui): use indexdb
1
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-[300px] sm:w-[400px] flex flex-col gap-0">
|
||||
<SheetHeader className="pb-4">
|
||||
<SheetContent side="right" className="w-[300px] sm:w-[500px] flex flex-col gap-0">
|
||||
<SheetHeader className="pb-4 pt-6">
|
||||
<SheetTitle>{t('history')}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{history.length === 0 ? t('noHistory') : t('historyDesc')}
|
||||
{history.length === 0 && !searchQuery ? t('noHistory') : t('historyDesc')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{history.length > 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder={t('searchPlaceholder')}
|
||||
className="pl-8"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 pb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder={t('searchPlaceholder')}
|
||||
className="pl-8"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 px-4 min-h-0">
|
||||
<div className="flex flex-col gap-4 pb-4">
|
||||
{filteredHistory.map((item) => (
|
||||
{history.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="relative flex flex-col gap-2 rounded-lg border p-3 hover:bg-accent cursor-pointer transition-colors group"
|
||||
@@ -106,11 +113,19 @@ export function HistorySheet({
|
||||
<div className="text-sm text-muted-foreground line-clamp-2 break-all">{item.translatedText}</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredHistory.length === 0 && searchQuery && (
|
||||
{history.length === 0 && searchQuery && (
|
||||
<div className="text-center text-sm text-muted-foreground py-8">
|
||||
{t('noResults')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMore && (
|
||||
<div className="flex justify-center pt-2 pb-4">
|
||||
<Button variant="outline" size="sm" onClick={onLoadMore}>
|
||||
{t('loadMore')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
|
||||
@@ -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
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
className="h-8 w-8 sm:h-9 sm:w-9"
|
||||
aria-label={t('toggleTheme')}
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 sm:h-9 sm:w-9"
|
||||
aria-label={t('theme')}
|
||||
>
|
||||
{actualTheme === 'dark' ? (
|
||||
<Moon className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
) : (
|
||||
<Sun className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('theme')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-80 p-2" align="end">
|
||||
<div className="mb-2 text-sm font-medium">{t('themeMode')}</div>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={useTheme().theme}
|
||||
onValueChange={(value) => {
|
||||
if (value) setTheme(value as any)
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full grid grid-cols-3 gap-1"
|
||||
>
|
||||
{actualTheme === 'dark' ? (
|
||||
<Sun className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
) : (
|
||||
<Moon className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('toggleTheme')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToggleGroupItem value="light" aria-label={t('light')}>
|
||||
<Sun className="h-4 w-4 mr-2" />
|
||||
<span className="sr-only sm:not-sr-only sm:inline-block">{t('light')}</span>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark" aria-label={t('dark')}>
|
||||
<Moon className="h-4 w-4 mr-2" />
|
||||
<span className="sr-only sm:not-sr-only sm:inline-block">{t('dark')}</span>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="system" aria-label={t('system')}>
|
||||
<span className="text-xs">{t('system')}</span>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<HistoryItem[]>([])
|
||||
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<HistoryItem, 'id' | 'timestamp'>) => {
|
||||
const addToHistory = useCallback(async (item: Omit<HistoryItem, 'id' | 'timestamp'>) => {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: 'テーマモード',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
119
ui/src/lib/db.ts
Normal file
119
ui/src/lib/db.ts
Normal file
@@ -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<IDBPDatabase<MTranHistoryDB>>;
|
||||
|
||||
constructor() {
|
||||
this.dbPromise = openDB<MTranHistoryDB>(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<HistoryItem[]> {
|
||||
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<HistoryItem[]> {
|
||||
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 };
|
||||
Reference in New Issue
Block a user