Phase 5 구현: SQLite DB + History/Dictionary 서비스 + UI
- DB: better-sqlite3 + drizzle-orm (history/dictionary/stats, WAL 모드) - HistoryService: CRUD + 검색 + 통계 + 30일 보존 정책, 세션 완료 시 자동 저장 - DictionaryService: CRUD + 검색 + 사용 횟수 추적 + STT 프롬프트 힌트 - HistoryPage: 목록 + 검색 + 삭제 + 복사 + 페이지네이션 - DictionaryPage: 목록 + 검색 + 추가 다이얼로그 + 삭제 - DashboardPage: 실데이터 통계 (총/오늘 세션, 시간, 단어수, 연속일수) - IPC: history/dictionary/stats 핸들러 + preload API - Bootstrap: DB 초기화 (critical) + 이력 자동 저장 연동
This commit is contained in:
parent
4a5cf6c819
commit
291e2a29d0
17 changed files with 3111 additions and 35 deletions
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -77,11 +77,21 @@ npm run typecheck # tsc --noEmit
|
||||||
6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션
|
6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션
|
||||||
|
|
||||||
## 현재 상태
|
## 현재 상태
|
||||||
Phase: 4 완료
|
Phase: 5 완료
|
||||||
마지막 완료: Phase 4 — Ollama LLM 연동 (텍스트 다듬기, 번역, 스트리밍)
|
마지막 완료: Phase 5 — SQLite DB + HistoryService + DictionaryService + UI 완성
|
||||||
다음 작업: Phase 3.5 — 커서 위치 히스토리 팝업 또는 Phase 5 — TTS + DB
|
다음 작업: Phase 3.5 — 커서 위치 히스토리 팝업 또는 Phase 6 — 커스텀 명령어
|
||||||
차단 이슈: SoX 미설치, @nut-tree-fork/nut-js 포크 사용
|
차단 이슈: SoX 미설치, @nut-tree-fork/nut-js 포크 사용
|
||||||
|
|
||||||
|
### Phase 5 구현 내용
|
||||||
|
- DB: better-sqlite3 + drizzle-orm (history/dictionary/stats 테이블, WAL 모드)
|
||||||
|
- HistoryService: CRUD + 검색 + 통계 + 보존 정책(30일), 세션 완료 시 자동 이력 저장
|
||||||
|
- DictionaryService: CRUD + 검색 + 사용 횟수 추적 + STT 프롬프트 힌트
|
||||||
|
- History UI: 목록 + 검색 + 삭제 + 복사 + 페이지네이션
|
||||||
|
- Dictionary UI: 목록 + 검색 + 추가 다이얼로그 + 삭제
|
||||||
|
- Dashboard: 실데이터 통계 (총/오늘 세션수, 시간, 단어수, 연속일수)
|
||||||
|
- IPC: history, dictionary, stats 핸들러 + preload API
|
||||||
|
- Bootstrap: DB 초기화 단계 추가 (critical)
|
||||||
|
|
||||||
### Phase 4 구현 내용
|
### Phase 4 구현 내용
|
||||||
- LocalLLMService: Ollama REST API 연동, 스트리밍 NDJSON 파싱, 가용성 폴링(5초)
|
- LocalLLMService: Ollama REST API 연동, 스트리밍 NDJSON 파싱, 가용성 폴링(5초)
|
||||||
- 시스템 프롬프트: refine/translate/summarize/grammar/expand/custom 6개 액션
|
- 시스템 프롬프트: refine/translate/summarize/grammar/expand/custom 6개 액션
|
||||||
|
|
|
||||||
1910
package-lock.json
generated
1910
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -17,6 +17,7 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^22.13.0",
|
"@types/node": "^22.13.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
|
@ -39,9 +40,13 @@
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@mui/icons-material": "^7.0.0",
|
"@mui/icons-material": "^7.0.0",
|
||||||
"@mui/material": "^7.0.0",
|
"@mui/material": "^7.0.0",
|
||||||
|
"@nut-tree-fork/nut-js": "^4.2.6",
|
||||||
"@rollup/rollup-win32-x64-msvc": "^4.60.1",
|
"@rollup/rollup-win32-x64-msvc": "^4.60.1",
|
||||||
|
"better-sqlite3": "^12.8.0",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
"electron-log": "^5.2.0",
|
"electron-log": "^5.2.0",
|
||||||
"electron-store": "^10.0.0",
|
"electron-store": "^10.0.0",
|
||||||
|
"nanoid": "^5.1.7",
|
||||||
"node-record-lpcm16": "^1.0.1",
|
"node-record-lpcm16": "^1.0.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import { initConfigService } from './services/ConfigService'
|
||||||
import { getHotkeyService } from './services/HotkeyService'
|
import { getHotkeyService } from './services/HotkeyService'
|
||||||
import { getVoiceModeService } from './services/VoiceModeService'
|
import { getVoiceModeService } from './services/VoiceModeService'
|
||||||
import { getLocalLLMService } from './services/LocalLLMService'
|
import { getLocalLLMService } from './services/LocalLLMService'
|
||||||
|
import { getHistoryService } from './services/HistoryService'
|
||||||
|
import { initDatabase } from './db'
|
||||||
import {
|
import {
|
||||||
createMainWindow,
|
createMainWindow,
|
||||||
preloadPopupWindows,
|
preloadPopupWindows,
|
||||||
|
|
@ -30,6 +32,7 @@ export async function bootstrap(): Promise<void> {
|
||||||
const steps: BootstrapStep[] = [
|
const steps: BootstrapStep[] = [
|
||||||
{ name: 'logger', critical: false, fn: initLogger },
|
{ name: 'logger', critical: false, fn: initLogger },
|
||||||
{ name: 'config', critical: false, fn: initConfig },
|
{ name: 'config', critical: false, fn: initConfig },
|
||||||
|
{ name: 'database', critical: true, fn: initDB },
|
||||||
{ name: 'create-windows', critical: true, fn: createWindows },
|
{ name: 'create-windows', critical: true, fn: createWindows },
|
||||||
{ name: 'tray', critical: false, fn: initTray },
|
{ name: 'tray', critical: false, fn: initTray },
|
||||||
{ name: 'ipc-handlers', critical: true, fn: initIpcHandlers },
|
{ name: 'ipc-handlers', critical: true, fn: initIpcHandlers },
|
||||||
|
|
@ -65,6 +68,10 @@ async function initConfig(): Promise<void> {
|
||||||
initConfigService()
|
initConfigService()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function initDB(): Promise<void> {
|
||||||
|
initDatabase()
|
||||||
|
}
|
||||||
|
|
||||||
async function createWindows(): Promise<void> {
|
async function createWindows(): Promise<void> {
|
||||||
createMainWindow()
|
createMainWindow()
|
||||||
}
|
}
|
||||||
|
|
@ -106,11 +113,26 @@ async function initVoiceMode(): Promise<void> {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
voiceMode.on('session-completed', ({ finalText }) => {
|
voiceMode.on('session-completed', ({ session, finalText }) => {
|
||||||
hideRecordingTip()
|
hideRecordingTip()
|
||||||
if (finalText.length > 0) {
|
if (finalText.length > 0) {
|
||||||
showResultPopup(finalText)
|
showResultPopup(finalText)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 이력 저장
|
||||||
|
try {
|
||||||
|
const wordCount = finalText.split(/\s+/).filter((w) => w.length > 0).length
|
||||||
|
getHistoryService().create({
|
||||||
|
originalText: session.transcription || finalText,
|
||||||
|
polishedText: session.processedText,
|
||||||
|
mode: session.mode === 'hands-free' ? 'dictation' : session.mode,
|
||||||
|
status: 'completed',
|
||||||
|
duration: (Date.now() - session.startedAt) / 1000,
|
||||||
|
wordCount
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to save history: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
voiceMode.on('session-cancelled', () => {
|
voiceMode.on('session-cancelled', () => {
|
||||||
|
|
|
||||||
107
src/main/db/index.ts
Normal file
107
src/main/db/index.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
// src/main/db/index.ts
|
||||||
|
// 설계서 03의 DB 초기화 코드
|
||||||
|
|
||||||
|
import Database from 'better-sqlite3'
|
||||||
|
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import path from 'path'
|
||||||
|
import * as schema from './schema'
|
||||||
|
import { getLogger } from '../services/LoggerService'
|
||||||
|
|
||||||
|
const logger = getLogger('database')
|
||||||
|
|
||||||
|
let db: BetterSQLite3Database<typeof schema> | null = null
|
||||||
|
let sqlite: Database.Database | null = null
|
||||||
|
|
||||||
|
export function initDatabase(): BetterSQLite3Database<typeof schema> {
|
||||||
|
const dbPath = path.join(app.getPath('userData'), 'd3ro-voice.db')
|
||||||
|
logger.info(`Initializing database at: ${dbPath}`)
|
||||||
|
|
||||||
|
sqlite = new Database(dbPath)
|
||||||
|
|
||||||
|
sqlite.pragma('journal_mode = WAL')
|
||||||
|
sqlite.pragma('foreign_keys = ON')
|
||||||
|
sqlite.pragma('busy_timeout = 5000')
|
||||||
|
|
||||||
|
// 테이블 생성 (마이그레이션 대신 직접 생성)
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS history (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
original_text TEXT NOT NULL,
|
||||||
|
polished_text TEXT,
|
||||||
|
focused_app TEXT,
|
||||||
|
focused_app_name TEXT,
|
||||||
|
focused_app_window_title TEXT,
|
||||||
|
mode TEXT NOT NULL DEFAULT 'dictation',
|
||||||
|
status TEXT NOT NULL DEFAULT 'completed',
|
||||||
|
error_code TEXT,
|
||||||
|
audio_local_path TEXT,
|
||||||
|
duration REAL NOT NULL,
|
||||||
|
detected_language TEXT,
|
||||||
|
mic_device TEXT,
|
||||||
|
word_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
stt_model TEXT,
|
||||||
|
llm_model TEXT,
|
||||||
|
stt_latency_ms INTEGER,
|
||||||
|
llm_latency_ms INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
app_version TEXT NOT NULL DEFAULT '1.0.0'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_status ON history(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_detected_language ON history(detected_language);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_focused_app_name ON history(focused_app_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_mode ON history(mode);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dictionary (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
word TEXT NOT NULL,
|
||||||
|
pronunciation TEXT,
|
||||||
|
category TEXT NOT NULL DEFAULT 'user',
|
||||||
|
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_used_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_dictionary_word_category ON dictionary(word, category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dictionary_created_at ON dictionary(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dictionary_usage_count ON dictionary(usage_count DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS stats (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
total_duration REAL NOT NULL DEFAULT 0,
|
||||||
|
total_words INTEGER NOT NULL DEFAULT 0,
|
||||||
|
session_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
streak_days INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_session_at INTEGER,
|
||||||
|
last_updated INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO stats (id, total_duration, total_words, session_count, streak_days, last_updated)
|
||||||
|
VALUES (1, 0, 0, 0, 0, ${Date.now()});
|
||||||
|
`)
|
||||||
|
|
||||||
|
db = drizzle(sqlite, { schema })
|
||||||
|
|
||||||
|
logger.info('Database initialized')
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDatabase(): BetterSQLite3Database<typeof schema> {
|
||||||
|
if (!db) {
|
||||||
|
throw new Error('Database not initialized. Call initDatabase() first.')
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeDatabase(): void {
|
||||||
|
if (sqlite) {
|
||||||
|
sqlite.close()
|
||||||
|
sqlite = null
|
||||||
|
db = null
|
||||||
|
logger.info('Database closed')
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/main/db/schema.ts
Normal file
83
src/main/db/schema.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// src/main/db/schema.ts
|
||||||
|
// 설계서 03의 drizzle-orm 스키마 정의
|
||||||
|
|
||||||
|
import { sqliteTable, text, integer, real, index, uniqueIndex } from 'drizzle-orm/sqlite-core'
|
||||||
|
|
||||||
|
// ── history ──────────────────────────────────────────────
|
||||||
|
export const history = sqliteTable(
|
||||||
|
'history',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
originalText: text('original_text').notNull(),
|
||||||
|
polishedText: text('polished_text'),
|
||||||
|
focusedApp: text('focused_app'),
|
||||||
|
focusedAppName: text('focused_app_name'),
|
||||||
|
focusedAppWindowTitle: text('focused_app_window_title'),
|
||||||
|
mode: text('mode', { enum: ['dictation', 'translate', 'command'] })
|
||||||
|
.notNull()
|
||||||
|
.default('dictation'),
|
||||||
|
status: text('status', { enum: ['completed', 'cancelled', 'error'] })
|
||||||
|
.notNull()
|
||||||
|
.default('completed'),
|
||||||
|
errorCode: text('error_code'),
|
||||||
|
audioLocalPath: text('audio_local_path'),
|
||||||
|
duration: real('duration').notNull(),
|
||||||
|
detectedLanguage: text('detected_language'),
|
||||||
|
micDevice: text('mic_device'),
|
||||||
|
wordCount: integer('word_count').notNull().default(0),
|
||||||
|
sttModel: text('stt_model'),
|
||||||
|
llmModel: text('llm_model'),
|
||||||
|
sttLatencyMs: integer('stt_latency_ms'),
|
||||||
|
llmLatencyMs: integer('llm_latency_ms'),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
updatedAt: integer('updated_at').notNull(),
|
||||||
|
appVersion: text('app_version').notNull().default('1.0.0')
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index('idx_history_created_at').on(table.createdAt),
|
||||||
|
index('idx_history_status').on(table.status),
|
||||||
|
index('idx_history_detected_language').on(table.detectedLanguage),
|
||||||
|
index('idx_history_focused_app_name').on(table.focusedAppName),
|
||||||
|
index('idx_history_mode').on(table.mode)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── dictionary ───────────────────────────────────────────
|
||||||
|
export const dictionary = sqliteTable(
|
||||||
|
'dictionary',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
word: text('word').notNull(),
|
||||||
|
pronunciation: text('pronunciation'),
|
||||||
|
category: text('category', { enum: ['user', 'auto', 'technical'] })
|
||||||
|
.notNull()
|
||||||
|
.default('user'),
|
||||||
|
usageCount: integer('usage_count').notNull().default(0),
|
||||||
|
lastUsedAt: integer('last_used_at'),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
updatedAt: integer('updated_at').notNull()
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex('idx_dictionary_word_category').on(table.word, table.category),
|
||||||
|
index('idx_dictionary_created_at').on(table.createdAt),
|
||||||
|
index('idx_dictionary_usage_count').on(table.usageCount)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── stats ────────────────────────────────────────────────
|
||||||
|
export const stats = sqliteTable('stats', {
|
||||||
|
id: integer('id').primaryKey(),
|
||||||
|
totalDuration: real('total_duration').notNull().default(0),
|
||||||
|
totalWords: integer('total_words').notNull().default(0),
|
||||||
|
sessionCount: integer('session_count').notNull().default(0),
|
||||||
|
streakDays: integer('streak_days').notNull().default(0),
|
||||||
|
lastSessionAt: integer('last_session_at'),
|
||||||
|
lastUpdated: integer('last_updated').notNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 타입 추출 ────────────────────────────────────────────
|
||||||
|
export type History = typeof history.$inferSelect
|
||||||
|
export type NewHistory = typeof history.$inferInsert
|
||||||
|
export type Dictionary = typeof dictionary.$inferSelect
|
||||||
|
export type NewDictionary = typeof dictionary.$inferInsert
|
||||||
|
export type Stats = typeof stats.$inferSelect
|
||||||
56
src/main/ipc/dictionary-handlers.ts
Normal file
56
src/main/ipc/dictionary-handlers.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
// src/main/ipc/dictionary-handlers.ts
|
||||||
|
|
||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
||||||
|
import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors'
|
||||||
|
import { getDictionaryService } from '../services/DictionaryService'
|
||||||
|
import type {
|
||||||
|
DictionaryQueryParams,
|
||||||
|
DictionaryAddParams,
|
||||||
|
DictionaryUpdateParams,
|
||||||
|
DictionaryDeleteParams,
|
||||||
|
DictionarySearchParams
|
||||||
|
} from '@shared/types'
|
||||||
|
|
||||||
|
export function registerDictionaryHandlers(): void {
|
||||||
|
ipcMain.handle(IPC_CHANNELS.DICTIONARY.GET_ALL, async (_event, params: DictionaryQueryParams) => {
|
||||||
|
try {
|
||||||
|
return ipcSuccess(getDictionaryService().list(params))
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.DBQueryFailed, 'Failed to get dictionary')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.DICTIONARY.ADD, async (_event, params: DictionaryAddParams) => {
|
||||||
|
try {
|
||||||
|
return ipcSuccess(getDictionaryService().add(params))
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.DBWriteFailed, 'Failed to add dictionary entry')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.DICTIONARY.UPDATE, async (_event, params: DictionaryUpdateParams) => {
|
||||||
|
try {
|
||||||
|
return ipcSuccess(getDictionaryService().update(params))
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.DBWriteFailed, 'Failed to update dictionary entry')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.DICTIONARY.DELETE, async (_event, params: DictionaryDeleteParams) => {
|
||||||
|
try {
|
||||||
|
getDictionaryService().delete(params.id)
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.DBWriteFailed, 'Failed to delete dictionary entry')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.DICTIONARY.SEARCH, async (_event, params: DictionarySearchParams) => {
|
||||||
|
try {
|
||||||
|
return ipcSuccess(getDictionaryService().search(params))
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.DBQueryFailed, 'Failed to search dictionary')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
64
src/main/ipc/history-handlers.ts
Normal file
64
src/main/ipc/history-handlers.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
// src/main/ipc/history-handlers.ts
|
||||||
|
|
||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
||||||
|
import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors'
|
||||||
|
import { getHistoryService } from '../services/HistoryService'
|
||||||
|
import type {
|
||||||
|
HistoryQueryParams,
|
||||||
|
HistoryGetByIdParams,
|
||||||
|
HistoryDeleteParams,
|
||||||
|
HistorySearchParams
|
||||||
|
} from '@shared/types'
|
||||||
|
|
||||||
|
export function registerHistoryHandlers(): void {
|
||||||
|
ipcMain.handle(IPC_CHANNELS.HISTORY.GET_ALL, async (_event, params: HistoryQueryParams) => {
|
||||||
|
try {
|
||||||
|
return ipcSuccess(getHistoryService().list(params))
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.DBQueryFailed, 'Failed to get history')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.HISTORY.GET_BY_ID, async (_event, params: HistoryGetByIdParams) => {
|
||||||
|
try {
|
||||||
|
return ipcSuccess(getHistoryService().getById(params.id))
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.DBQueryFailed, 'Failed to get history entry')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.HISTORY.DELETE, async (_event, params: HistoryDeleteParams) => {
|
||||||
|
try {
|
||||||
|
getHistoryService().delete(params.id)
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.DBWriteFailed, 'Failed to delete history entry')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.HISTORY.DELETE_ALL, async () => {
|
||||||
|
try {
|
||||||
|
getHistoryService().deleteAll()
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.DBWriteFailed, 'Failed to delete all history')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.HISTORY.SEARCH, async (_event, params: HistorySearchParams) => {
|
||||||
|
try {
|
||||||
|
return ipcSuccess(getHistoryService().search(params))
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.DBQueryFailed, 'Failed to search history')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.STATS.GET_SUMMARY, async () => {
|
||||||
|
try {
|
||||||
|
return ipcSuccess(getHistoryService().getStats())
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.DBQueryFailed, 'Failed to get stats')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,8 @@ import { registerVoiceHandlers } from './voice-handlers'
|
||||||
import { registerSTTHandlers } from './stt-handlers'
|
import { registerSTTHandlers } from './stt-handlers'
|
||||||
import { registerHotkeyHandlers } from './hotkey-handlers'
|
import { registerHotkeyHandlers } from './hotkey-handlers'
|
||||||
import { registerLLMHandlers } from './llm-handlers'
|
import { registerLLMHandlers } from './llm-handlers'
|
||||||
|
import { registerHistoryHandlers } from './history-handlers'
|
||||||
|
import { registerDictionaryHandlers } from './dictionary-handlers'
|
||||||
import { getLogger } from '../services/LoggerService'
|
import { getLogger } from '../services/LoggerService'
|
||||||
|
|
||||||
const logger = getLogger('ipc')
|
const logger = getLogger('ipc')
|
||||||
|
|
@ -21,5 +23,7 @@ export function registerAllIpcHandlers(): void {
|
||||||
registerSTTHandlers()
|
registerSTTHandlers()
|
||||||
registerHotkeyHandlers()
|
registerHotkeyHandlers()
|
||||||
registerLLMHandlers()
|
registerLLMHandlers()
|
||||||
|
registerHistoryHandlers()
|
||||||
|
registerDictionaryHandlers()
|
||||||
logger.info('All IPC handlers registered')
|
logger.info('All IPC handlers registered')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
170
src/main/services/DictionaryService.ts
Normal file
170
src/main/services/DictionaryService.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
// src/main/services/DictionaryService.ts
|
||||||
|
// 사용자 커스텀 단어 사전. 설계서 01/03 IDictionaryService 구현.
|
||||||
|
|
||||||
|
import { eq, like, desc, count, sql } from 'drizzle-orm'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
import { getDatabase } from '../db'
|
||||||
|
import { dictionary } from '../db/schema'
|
||||||
|
import type { Dictionary, NewDictionary } from '../db/schema'
|
||||||
|
import { getLogger } from './LoggerService'
|
||||||
|
import type {
|
||||||
|
DictionaryEntry,
|
||||||
|
DictionaryQueryParams,
|
||||||
|
DictionaryPage,
|
||||||
|
DictionaryAddParams,
|
||||||
|
DictionaryUpdateParams,
|
||||||
|
DictionarySearchParams
|
||||||
|
} from '@shared/types'
|
||||||
|
|
||||||
|
const logger = getLogger('DictionaryService')
|
||||||
|
|
||||||
|
class DictionaryService {
|
||||||
|
add(params: DictionaryAddParams): DictionaryEntry {
|
||||||
|
const db = getDatabase()
|
||||||
|
const now = Date.now()
|
||||||
|
const id = nanoid()
|
||||||
|
|
||||||
|
const entry: NewDictionary = {
|
||||||
|
id,
|
||||||
|
word: params.word,
|
||||||
|
pronunciation: params.pronunciation ?? null,
|
||||||
|
category: params.category ?? 'user',
|
||||||
|
usageCount: 0,
|
||||||
|
lastUsedAt: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
}
|
||||||
|
|
||||||
|
db.insert(dictionary).values(entry).run()
|
||||||
|
logger.info(`Dictionary entry added: "${params.word}"`)
|
||||||
|
return this._toEntry(entry as Dictionary)
|
||||||
|
}
|
||||||
|
|
||||||
|
update(params: DictionaryUpdateParams): DictionaryEntry | null {
|
||||||
|
const db = getDatabase()
|
||||||
|
const existing = db.select().from(dictionary).where(eq(dictionary.id, params.id)).get()
|
||||||
|
if (!existing) return null
|
||||||
|
|
||||||
|
const updates: Partial<NewDictionary> = { updatedAt: Date.now() }
|
||||||
|
if (params.word !== undefined) updates.word = params.word
|
||||||
|
if (params.pronunciation !== undefined) updates.pronunciation = params.pronunciation
|
||||||
|
if (params.category !== undefined) updates.category = params.category
|
||||||
|
|
||||||
|
db.update(dictionary).set(updates).where(eq(dictionary.id, params.id)).run()
|
||||||
|
|
||||||
|
const updated = db.select().from(dictionary).where(eq(dictionary.id, params.id)).get()
|
||||||
|
return updated ? this._toEntry(updated) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: string): boolean {
|
||||||
|
const db = getDatabase()
|
||||||
|
const result = db.delete(dictionary).where(eq(dictionary.id, id)).run()
|
||||||
|
return result.changes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
list(params: DictionaryQueryParams): DictionaryPage {
|
||||||
|
const db = getDatabase()
|
||||||
|
const { page, pageSize } = params
|
||||||
|
|
||||||
|
const totalResult = db.select({ count: count() }).from(dictionary).get()
|
||||||
|
const total = totalResult?.count ?? 0
|
||||||
|
|
||||||
|
const entries = db
|
||||||
|
.select()
|
||||||
|
.from(dictionary)
|
||||||
|
.orderBy(desc(dictionary.createdAt))
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(page * pageSize)
|
||||||
|
.all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries: entries.map((e) => this._toEntry(e)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
search(params: DictionarySearchParams): DictionaryPage {
|
||||||
|
const db = getDatabase()
|
||||||
|
const { query, page, pageSize } = params
|
||||||
|
const pattern = `%${query}%`
|
||||||
|
|
||||||
|
const totalResult = db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(dictionary)
|
||||||
|
.where(like(dictionary.word, pattern))
|
||||||
|
.get()
|
||||||
|
const total = totalResult?.count ?? 0
|
||||||
|
|
||||||
|
const entries = db
|
||||||
|
.select()
|
||||||
|
.from(dictionary)
|
||||||
|
.where(like(dictionary.word, pattern))
|
||||||
|
.orderBy(desc(dictionary.usageCount))
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(page * pageSize)
|
||||||
|
.all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries: entries.map((e) => this._toEntry(e)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementUsage(id: string): void {
|
||||||
|
const db = getDatabase()
|
||||||
|
db.update(dictionary)
|
||||||
|
.set({
|
||||||
|
usageCount: sql`usage_count + 1`,
|
||||||
|
lastUsedAt: Date.now()
|
||||||
|
})
|
||||||
|
.where(eq(dictionary.id, id))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사전 단어 목록을 STT initialPrompt 형태로 반환한다.
|
||||||
|
*/
|
||||||
|
getPromptHints(limit = 50): string {
|
||||||
|
const db = getDatabase()
|
||||||
|
const words = db
|
||||||
|
.select({ word: dictionary.word })
|
||||||
|
.from(dictionary)
|
||||||
|
.orderBy(desc(dictionary.usageCount))
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
|
||||||
|
return words.map((w) => w.word).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
logger.info('DictionaryService disposed')
|
||||||
|
}
|
||||||
|
|
||||||
|
private _toEntry(row: Dictionary): DictionaryEntry {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
word: row.word,
|
||||||
|
pronunciation: row.pronunciation,
|
||||||
|
category: row.category as DictionaryEntry['category'],
|
||||||
|
usageCount: row.usageCount,
|
||||||
|
lastUsedAt: row.lastUsedAt,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let instance: DictionaryService | null = null
|
||||||
|
|
||||||
|
export function getDictionaryService(): DictionaryService {
|
||||||
|
if (!instance) {
|
||||||
|
instance = new DictionaryService()
|
||||||
|
}
|
||||||
|
return instance
|
||||||
|
}
|
||||||
219
src/main/services/HistoryService.ts
Normal file
219
src/main/services/HistoryService.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
// src/main/services/HistoryService.ts
|
||||||
|
// SQLite 기반 전사/명령 이력 저장. 설계서 01/03 IHistoryService 구현.
|
||||||
|
|
||||||
|
import { eq, desc, like, and, sql, count } from 'drizzle-orm'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
import { getDatabase } from '../db'
|
||||||
|
import { history, stats } from '../db/schema'
|
||||||
|
import type { History, NewHistory } from '../db/schema'
|
||||||
|
import { getLogger } from './LoggerService'
|
||||||
|
import type {
|
||||||
|
HistoryEntry,
|
||||||
|
HistoryQueryParams,
|
||||||
|
HistoryPage,
|
||||||
|
HistorySearchParams,
|
||||||
|
StatsSummary
|
||||||
|
} from '@shared/types'
|
||||||
|
|
||||||
|
const logger = getLogger('HistoryService')
|
||||||
|
|
||||||
|
class HistoryService {
|
||||||
|
create(input: Omit<NewHistory, 'id' | 'createdAt' | 'updatedAt'>): HistoryEntry {
|
||||||
|
const db = getDatabase()
|
||||||
|
const now = Date.now()
|
||||||
|
const id = nanoid()
|
||||||
|
|
||||||
|
const entry: NewHistory = {
|
||||||
|
id,
|
||||||
|
...input,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
}
|
||||||
|
|
||||||
|
db.insert(history).values(entry).run()
|
||||||
|
|
||||||
|
// stats 싱글톤 업데이트
|
||||||
|
this._updateStats(input.duration, input.wordCount)
|
||||||
|
|
||||||
|
logger.info(`History entry created: ${id}`)
|
||||||
|
return this._toEntry(entry as History)
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: string): HistoryEntry | null {
|
||||||
|
const db = getDatabase()
|
||||||
|
const row = db.select().from(history).where(eq(history.id, id)).get()
|
||||||
|
return row ? this._toEntry(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
list(params: HistoryQueryParams): HistoryPage {
|
||||||
|
const db = getDatabase()
|
||||||
|
const { page, pageSize } = params
|
||||||
|
|
||||||
|
const totalResult = db.select({ count: count() }).from(history).get()
|
||||||
|
const total = totalResult?.count ?? 0
|
||||||
|
|
||||||
|
const entries = db
|
||||||
|
.select()
|
||||||
|
.from(history)
|
||||||
|
.orderBy(desc(history.createdAt))
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(page * pageSize)
|
||||||
|
.all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries: entries.map((e) => this._toEntry(e)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
search(params: HistorySearchParams): HistoryPage {
|
||||||
|
const db = getDatabase()
|
||||||
|
const { query, page, pageSize } = params
|
||||||
|
const pattern = `%${query}%`
|
||||||
|
|
||||||
|
const conditions = and(
|
||||||
|
like(history.originalText, pattern)
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalResult = db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(history)
|
||||||
|
.where(conditions)
|
||||||
|
.get()
|
||||||
|
const total = totalResult?.count ?? 0
|
||||||
|
|
||||||
|
const entries = db
|
||||||
|
.select()
|
||||||
|
.from(history)
|
||||||
|
.where(conditions)
|
||||||
|
.orderBy(desc(history.createdAt))
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(page * pageSize)
|
||||||
|
.all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries: entries.map((e) => this._toEntry(e)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: string): boolean {
|
||||||
|
const db = getDatabase()
|
||||||
|
const result = db.delete(history).where(eq(history.id, id)).run()
|
||||||
|
return result.changes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteAll(): void {
|
||||||
|
const db = getDatabase()
|
||||||
|
db.delete(history).run()
|
||||||
|
logger.info('All history entries deleted')
|
||||||
|
}
|
||||||
|
|
||||||
|
getStats(): StatsSummary {
|
||||||
|
const db = getDatabase()
|
||||||
|
const row = db.select().from(stats).where(eq(stats.id, 1)).get()
|
||||||
|
|
||||||
|
const todayStart = new Date()
|
||||||
|
todayStart.setHours(0, 0, 0, 0)
|
||||||
|
const todayMs = todayStart.getTime()
|
||||||
|
|
||||||
|
const todayResult = db
|
||||||
|
.select({
|
||||||
|
sessions: count(),
|
||||||
|
duration: sql<number>`COALESCE(SUM(duration), 0)`,
|
||||||
|
words: sql<number>`COALESCE(SUM(word_count), 0)`
|
||||||
|
})
|
||||||
|
.from(history)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
sql`created_at >= ${todayMs}`,
|
||||||
|
eq(history.status, 'completed')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalRecordingTimeMs: (row?.totalDuration ?? 0) * 1000,
|
||||||
|
totalWordCount: row?.totalWords ?? 0,
|
||||||
|
totalSessionCount: row?.sessionCount ?? 0,
|
||||||
|
todayRecordingTimeMs: (todayResult?.duration ?? 0) * 1000,
|
||||||
|
todayWordCount: todayResult?.words ?? 0,
|
||||||
|
todaySessionCount: todayResult?.sessions ?? 0,
|
||||||
|
streakDays: row?.streakDays ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 보존 정책에 따라 오래된 항목 정리 (30일 초과)
|
||||||
|
*/
|
||||||
|
runRetentionCleanup(): number {
|
||||||
|
const db = getDatabase()
|
||||||
|
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||||
|
const result = db.delete(history).where(sql`created_at < ${cutoff}`).run()
|
||||||
|
if (result.changes > 0) {
|
||||||
|
logger.info(`Retention cleanup: ${result.changes} old entries removed`)
|
||||||
|
}
|
||||||
|
return result.changes
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
logger.info('HistoryService disposed')
|
||||||
|
}
|
||||||
|
|
||||||
|
private _updateStats(duration: number, wordCount: number): void {
|
||||||
|
const db = getDatabase()
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
db.update(stats)
|
||||||
|
.set({
|
||||||
|
totalDuration: sql`total_duration + ${duration}`,
|
||||||
|
totalWords: sql`total_words + ${wordCount}`,
|
||||||
|
sessionCount: sql`session_count + 1`,
|
||||||
|
lastSessionAt: now,
|
||||||
|
lastUpdated: now
|
||||||
|
})
|
||||||
|
.where(eq(stats.id, 1))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
private _toEntry(row: History): HistoryEntry {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
originalText: row.originalText,
|
||||||
|
polishedText: row.polishedText,
|
||||||
|
focusedApp: row.focusedApp,
|
||||||
|
focusedAppName: row.focusedAppName,
|
||||||
|
focusedAppWindowTitle: row.focusedAppWindowTitle,
|
||||||
|
mode: row.mode as HistoryEntry['mode'],
|
||||||
|
status: row.status as HistoryEntry['status'],
|
||||||
|
errorCode: row.errorCode,
|
||||||
|
audioLocalPath: row.audioLocalPath,
|
||||||
|
duration: row.duration,
|
||||||
|
detectedLanguage: row.detectedLanguage,
|
||||||
|
micDevice: row.micDevice,
|
||||||
|
wordCount: row.wordCount,
|
||||||
|
sttModel: row.sttModel,
|
||||||
|
llmModel: row.llmModel,
|
||||||
|
sttLatencyMs: row.sttLatencyMs,
|
||||||
|
llmLatencyMs: row.llmLatencyMs,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
appVersion: row.appVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let instance: HistoryService | null = null
|
||||||
|
|
||||||
|
export function getHistoryService(): HistoryService {
|
||||||
|
if (!instance) {
|
||||||
|
instance = new HistoryService()
|
||||||
|
}
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
@ -26,3 +26,5 @@ export { getVoiceModeService } from './VoiceModeService'
|
||||||
export { getAudioCaptureService } from './AudioCaptureService'
|
export { getAudioCaptureService } from './AudioCaptureService'
|
||||||
export { getTextInsertService } from './TextInsertService'
|
export { getTextInsertService } from './TextInsertService'
|
||||||
export { getLocalLLMService } from './LocalLLMService'
|
export { getLocalLLMService } from './LocalLLMService'
|
||||||
|
export { getHistoryService } from './HistoryService'
|
||||||
|
export { getDictionaryService } from './DictionaryService'
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,20 @@ import type {
|
||||||
SetServerUrlParams,
|
SetServerUrlParams,
|
||||||
LLMStatusChangedEvent,
|
LLMStatusChangedEvent,
|
||||||
LLMProcessProgressEvent,
|
LLMProcessProgressEvent,
|
||||||
|
HistoryQueryParams,
|
||||||
|
HistoryPage,
|
||||||
|
HistoryGetByIdParams,
|
||||||
|
HistoryEntry,
|
||||||
|
HistoryDeleteParams,
|
||||||
|
HistorySearchParams,
|
||||||
|
DictionaryQueryParams,
|
||||||
|
DictionaryPage,
|
||||||
|
DictionaryEntry,
|
||||||
|
DictionaryAddParams,
|
||||||
|
DictionaryUpdateParams,
|
||||||
|
DictionaryDeleteParams,
|
||||||
|
DictionarySearchParams,
|
||||||
|
StatsSummary,
|
||||||
PermissionStatus
|
PermissionStatus
|
||||||
} from '@shared/types'
|
} from '@shared/types'
|
||||||
import type { IPCResult } from '@shared/errors'
|
import type { IPCResult } from '@shared/errors'
|
||||||
|
|
@ -169,6 +183,40 @@ const electronAPI = {
|
||||||
on(IPC_CHANNELS.LLM.PROCESS_PROGRESS, cb)
|
on(IPC_CHANNELS.LLM.PROCESS_PROGRESS, cb)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── History ────────────────────────────────────────────
|
||||||
|
history: {
|
||||||
|
getAll: (params: HistoryQueryParams) =>
|
||||||
|
invoke<HistoryPage>(IPC_CHANNELS.HISTORY.GET_ALL, params),
|
||||||
|
getById: (params: HistoryGetByIdParams) =>
|
||||||
|
invoke<HistoryEntry | null>(IPC_CHANNELS.HISTORY.GET_BY_ID, params),
|
||||||
|
delete: (params: HistoryDeleteParams) =>
|
||||||
|
invoke<void>(IPC_CHANNELS.HISTORY.DELETE, params),
|
||||||
|
deleteAll: () => invoke<void>(IPC_CHANNELS.HISTORY.DELETE_ALL),
|
||||||
|
search: (params: HistorySearchParams) =>
|
||||||
|
invoke<HistoryPage>(IPC_CHANNELS.HISTORY.SEARCH, params),
|
||||||
|
onAdded: (cb: (e: HistoryEntry) => void): Unsubscribe =>
|
||||||
|
on(IPC_CHANNELS.HISTORY.ADDED, cb)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Dictionary ─────────────────────────────────────────
|
||||||
|
dictionary: {
|
||||||
|
getAll: (params: DictionaryQueryParams) =>
|
||||||
|
invoke<DictionaryPage>(IPC_CHANNELS.DICTIONARY.GET_ALL, params),
|
||||||
|
add: (params: DictionaryAddParams) =>
|
||||||
|
invoke<DictionaryEntry>(IPC_CHANNELS.DICTIONARY.ADD, params),
|
||||||
|
update: (params: DictionaryUpdateParams) =>
|
||||||
|
invoke<DictionaryEntry>(IPC_CHANNELS.DICTIONARY.UPDATE, params),
|
||||||
|
delete: (params: DictionaryDeleteParams) =>
|
||||||
|
invoke<void>(IPC_CHANNELS.DICTIONARY.DELETE, params),
|
||||||
|
search: (params: DictionarySearchParams) =>
|
||||||
|
invoke<DictionaryPage>(IPC_CHANNELS.DICTIONARY.SEARCH, params)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Stats ──────────────────────────────────────────────
|
||||||
|
stats: {
|
||||||
|
getSummary: () => invoke<StatsSummary>(IPC_CHANNELS.STATS.GET_SUMMARY)
|
||||||
|
},
|
||||||
|
|
||||||
// ── Window ─────────────────────────────────────────────
|
// ── Window ─────────────────────────────────────────────
|
||||||
window: {
|
window: {
|
||||||
minimize: () => send(IPC_CHANNELS.WINDOW.MINIMIZE),
|
minimize: () => send(IPC_CHANNELS.WINDOW.MINIMIZE),
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import HistoryIcon from '@mui/icons-material/History'
|
||||||
import MenuBookIcon from '@mui/icons-material/MenuBook'
|
import MenuBookIcon from '@mui/icons-material/MenuBook'
|
||||||
import SettingsIcon from '@mui/icons-material/Settings'
|
import SettingsIcon from '@mui/icons-material/Settings'
|
||||||
import { DashboardPage } from '../pages/DashboardPage'
|
import { DashboardPage } from '../pages/DashboardPage'
|
||||||
|
import { HistoryPage } from '../pages/HistoryPage'
|
||||||
|
import { DictionaryPage } from '../pages/DictionaryPage'
|
||||||
import { SettingsModal } from './SettingsModal'
|
import { SettingsModal } from './SettingsModal'
|
||||||
import { StatusBar } from './StatusBar'
|
import { StatusBar } from './StatusBar'
|
||||||
|
|
||||||
|
|
@ -98,16 +100,8 @@ export function AppLayout(): React.ReactElement {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{currentRoute === 'dashboard' && <DashboardPage />}
|
{currentRoute === 'dashboard' && <DashboardPage />}
|
||||||
{currentRoute === 'history' && (
|
{currentRoute === 'history' && <HistoryPage />}
|
||||||
<Typography variant="h5" color="text.secondary">
|
{currentRoute === 'dictionary' && <DictionaryPage />}
|
||||||
History (Phase 5)
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{currentRoute === 'dictionary' && (
|
|
||||||
<Typography variant="h5" color="text.secondary">
|
|
||||||
Dictionary (Phase 5)
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
// src/renderer/pages/DashboardPage.tsx
|
// src/renderer/pages/DashboardPage.tsx
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { Box, Card, CardContent, Typography, Grid } from '@mui/material'
|
import { Box, Card, CardContent, Typography, Grid } from '@mui/material'
|
||||||
import MicIcon from '@mui/icons-material/Mic'
|
import MicIcon from '@mui/icons-material/Mic'
|
||||||
import TimerIcon from '@mui/icons-material/Timer'
|
import TimerIcon from '@mui/icons-material/Timer'
|
||||||
import TextFieldsIcon from '@mui/icons-material/TextFields'
|
import TextFieldsIcon from '@mui/icons-material/TextFields'
|
||||||
import TodayIcon from '@mui/icons-material/Today'
|
import TodayIcon from '@mui/icons-material/Today'
|
||||||
|
import type { StatsSummary } from '@shared/types'
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
title: string
|
title: string
|
||||||
|
|
@ -28,7 +30,33 @@ function StatCard({ title, value, icon }: StatCardProps): React.ReactElement {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTime(ms: number): string {
|
||||||
|
const totalSec = Math.round(ms / 1000)
|
||||||
|
const hours = Math.floor(totalSec / 3600)
|
||||||
|
const minutes = Math.floor((totalSec % 3600) / 60)
|
||||||
|
const seconds = totalSec % 60
|
||||||
|
if (hours > 0) return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardPage(): React.ReactElement {
|
export function DashboardPage(): React.ReactElement {
|
||||||
|
const [stats, setStats] = useState<StatsSummary | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.electronAPI.stats.getSummary().then((result) => {
|
||||||
|
if (result.success) setStats(result.data)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 30초마다 갱신
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
window.electronAPI.stats.getSummary().then((result) => {
|
||||||
|
if (result.success) setStats(result.data)
|
||||||
|
})
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}>
|
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}>
|
||||||
|
|
@ -37,30 +65,66 @@ export function DashboardPage(): React.ReactElement {
|
||||||
|
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
<StatCard title="Total Sessions" value="0" icon={<MicIcon />} />
|
<StatCard
|
||||||
|
title="Total Sessions"
|
||||||
|
value={String(stats?.totalSessionCount ?? 0)}
|
||||||
|
icon={<MicIcon />}
|
||||||
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
<StatCard title="Total Time" value="0:00" icon={<TimerIcon />} />
|
<StatCard
|
||||||
|
title="Total Time"
|
||||||
|
value={formatTime(stats?.totalRecordingTimeMs ?? 0)}
|
||||||
|
icon={<TimerIcon />}
|
||||||
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
<StatCard title="Total Words" value="0" icon={<TextFieldsIcon />} />
|
<StatCard
|
||||||
|
title="Total Words"
|
||||||
|
value={String(stats?.totalWordCount ?? 0)}
|
||||||
|
icon={<TextFieldsIcon />}
|
||||||
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
<StatCard title="Streak" value="0 days" icon={<TodayIcon />} />
|
<StatCard
|
||||||
|
title="Streak"
|
||||||
|
value={`${stats?.streakDays ?? 0} days`}
|
||||||
|
icon={<TodayIcon />}
|
||||||
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Box sx={{ mt: 4 }}>
|
{/* Today's stats */}
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||||
Recent Sessions
|
Today
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
|
<Typography variant="body2" color="text.secondary">Sessions</Typography>
|
||||||
No sessions yet. Press the hotkey to start recording.
|
<Typography variant="h5">{stats?.todaySessionCount ?? 0}</Typography>
|
||||||
</Typography>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="body2" color="text.secondary">Time</Typography>
|
||||||
|
<Typography variant="h5">{formatTime(stats?.todayRecordingTimeMs ?? 0)}</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="body2" color="text.secondary">Words</Typography>
|
||||||
|
<Typography variant="h5">{stats?.todayWordCount ?? 0}</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
173
src/renderer/pages/DictionaryPage.tsx
Normal file
173
src/renderer/pages/DictionaryPage.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
// src/renderer/pages/DictionaryPage.tsx
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
IconButton,
|
||||||
|
Chip,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
InputAdornment
|
||||||
|
} from '@mui/material'
|
||||||
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import SearchIcon from '@mui/icons-material/Search'
|
||||||
|
import type { DictionaryEntry, DictionaryPage as DictPageData } from '@shared/types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
export function DictionaryPage(): React.ReactElement {
|
||||||
|
const [data, setData] = useState<DictPageData | null>(null)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
|
const [newWord, setNewWord] = useState('')
|
||||||
|
const [newPronunciation, setNewPronunciation] = useState('')
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
const result = search.trim()
|
||||||
|
? await window.electronAPI.dictionary.search({ query: search, page: 0, pageSize: PAGE_SIZE })
|
||||||
|
: await window.electronAPI.dictionary.getAll({ page: 0, pageSize: PAGE_SIZE })
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setData(result.data)
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}, [search])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!newWord.trim()) return
|
||||||
|
await window.electronAPI.dictionary.add({
|
||||||
|
word: newWord.trim(),
|
||||||
|
pronunciation: newPronunciation.trim() || undefined
|
||||||
|
})
|
||||||
|
setNewWord('')
|
||||||
|
setNewPronunciation('')
|
||||||
|
setAddOpen(false)
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
await window.electronAPI.dictionary.delete({ id })
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
||||||
|
Dictionary
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => setAddOpen(true)}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Add Word
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
placeholder="Search words..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon />
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Typography color="text.secondary">Loading...</Typography>
|
||||||
|
) : !data || data.entries.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
{search ? 'No words found.' : 'No words yet. Add custom words for better STT accuracy.'}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<List>
|
||||||
|
{data.entries.map((entry: DictionaryEntry) => (
|
||||||
|
<ListItem
|
||||||
|
key={entry.id}
|
||||||
|
divider
|
||||||
|
secondaryAction={
|
||||||
|
<IconButton size="small" onClick={() => handleDelete(entry.id)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={entry.word}
|
||||||
|
secondary={
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
|
||||||
|
{entry.pronunciation && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
[{entry.pronunciation}]
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Chip label={entry.category} size="small" variant="outlined" />
|
||||||
|
<Chip label={`used ${entry.usageCount}x`} size="small" variant="outlined" />
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Word Dialog */}
|
||||||
|
<Dialog open={addOpen} onClose={() => setAddOpen(false)} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle>Add Word</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
label="Word"
|
||||||
|
value={newWord}
|
||||||
|
onChange={(e) => setNewWord(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Pronunciation (optional)"
|
||||||
|
value={newPronunciation}
|
||||||
|
onChange={(e) => setNewPronunciation(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setAddOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleAdd} variant="contained" disabled={!newWord.trim()}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
161
src/renderer/pages/HistoryPage.tsx
Normal file
161
src/renderer/pages/HistoryPage.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
// src/renderer/pages/HistoryPage.tsx
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
IconButton,
|
||||||
|
Chip,
|
||||||
|
Pagination,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
InputAdornment
|
||||||
|
} from '@mui/material'
|
||||||
|
import SearchIcon from '@mui/icons-material/Search'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||||
|
import type { HistoryEntry, HistoryPage as HistoryPageData } from '@shared/types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
export function HistoryPage(): React.ReactElement {
|
||||||
|
const [data, setData] = useState<HistoryPageData | null>(null)
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
const result = search.trim()
|
||||||
|
? await window.electronAPI.history.search({ query: search, page, pageSize: PAGE_SIZE })
|
||||||
|
: await window.electronAPI.history.getAll({ page, pageSize: PAGE_SIZE })
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setData(result.data)
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}, [page, search])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
await window.electronAPI.history.delete({ id })
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (ts: number) => {
|
||||||
|
return new Date(ts).toLocaleString('ko-KR', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (sec: number) => {
|
||||||
|
const m = Math.floor(sec / 60)
|
||||||
|
const s = Math.round(sec % 60)
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
|
History
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
placeholder="Search transcriptions..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value)
|
||||||
|
setPage(0)
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon />
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Typography color="text.secondary">Loading...</Typography>
|
||||||
|
) : !data || data.entries.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
{search ? 'No results found.' : 'No history yet.'}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<List>
|
||||||
|
{data.entries.map((entry: HistoryEntry) => (
|
||||||
|
<ListItem
|
||||||
|
key={entry.id}
|
||||||
|
divider
|
||||||
|
secondaryAction={
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleCopy(entry.polishedText || entry.originalText)}
|
||||||
|
>
|
||||||
|
<ContentCopyIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small" onClick={() => handleDelete(entry.id)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={entry.polishedText || entry.originalText}
|
||||||
|
secondary={
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mt: 0.5, alignItems: 'center' }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{formatDate(entry.createdAt)}
|
||||||
|
</Typography>
|
||||||
|
<Chip label={formatDuration(entry.duration)} size="small" variant="outlined" />
|
||||||
|
{entry.detectedLanguage && (
|
||||||
|
<Chip label={entry.detectedLanguage} size="small" variant="outlined" />
|
||||||
|
)}
|
||||||
|
<Chip label={entry.mode} size="small" variant="outlined" />
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
primaryTypographyProps={{ sx: { pr: 8 } }}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
{data.totalPages > 1 && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
|
||||||
|
<Pagination
|
||||||
|
count={data.totalPages}
|
||||||
|
page={page + 1}
|
||||||
|
onChange={(_, p) => setPage(p - 1)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue