Add persistent multi-user sessions and Docker deployment

This commit is contained in:
burnintuna 2026-07-31 02:39:00 +09:00
parent 51a6e845d1
commit 104577c826
24 changed files with 2094 additions and 468 deletions

View file

@ -1,10 +1,16 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { onMounted, ref, watch } from 'vue'
import { useAuthStore } from './stores/auth'
import LoginView from './views/LoginView.vue'
import TranslatorView from './views/TranslatorView.vue'
import AdminView from './views/AdminView.vue'
const auth = useAuthStore()
const page = ref<'translator' | 'admin'>('translator')
watch(() => auth.isLoggedIn, (loggedIn) => {
if (!loggedIn) page.value = 'translator'
})
onMounted(() => {
auth.check()
@ -13,8 +19,10 @@ onMounted(() => {
<template>
<div class="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 dark:from-gray-900 dark:to-slate-800 transition-colors">
<!-- Show Login if not authenticated, otherwise Translator -->
<LoginView v-if="!auth.isLoggedIn" />
<TranslatorView v-else />
<template v-else>
<TranslatorView v-show="page === 'translator'" @open-admin="page = 'admin'" />
<AdminView v-if="page === 'admin' && auth.isAdmin" @close="page = 'translator'" />
</template>
</div>
</template>

View file

@ -1,7 +1,10 @@
/** Axios-based API client with JWT token management */
import axios from 'axios'
import type { LLMModel, PhaseProgress, ProperNoun, SessionConfig, SessionResponse } from './types'
import type {
LLMModel, PhaseProgress, ProperNoun, RevisionResponse, SessionConfig,
SessionResponse, SessionSummary, UserSummary,
} from './types'
export const AUTH_UNAUTHORIZED_EVENT = 'llm-translator:unauthorized'
@ -44,11 +47,29 @@ export async function logout(): Promise<void> {
localStorage.removeItem('jwt_token')
}
export async function me(): Promise<{ user_id: string }> {
export async function me(): Promise<{ user_id: string; is_admin: boolean }> {
const res = await api.get('/auth/me')
return res.data
}
// ── Administration ────────────────────────────────────
export async function listUsers(): Promise<UserSummary[]> {
const res = await api.get('/admin/users')
return res.data
}
export async function createUser(params: { id: string; password: string; is_admin: boolean }): Promise<void> {
await api.post('/admin/users', params)
}
export async function updateUser(
userId: string,
patch: { password?: string; is_admin?: boolean; is_active?: boolean },
): Promise<void> {
await api.patch(`/admin/users/${encodeURIComponent(userId)}`, patch)
}
// ── Models ───────────────────────────────────────────
export async function listModels(): Promise<LLMModel[]> {
@ -68,6 +89,11 @@ export async function getSession(sessionId: string): Promise<SessionResponse> {
return res.data
}
export async function listSessions(includeArchived = false): Promise<SessionSummary[]> {
const res = await api.get('/sessions', { params: { include_archived: includeArchived } })
return res.data
}
export async function getSessionProgress(
sessionId: string,
): Promise<{ progress: PhaseProgress | null }> {
@ -81,12 +107,35 @@ export async function deleteSession(sessionId: string): Promise<void> {
export async function updateSession(
sessionId: string,
patch: Partial<SessionConfig>,
patch: Partial<SessionConfig> & { title?: string; expected_version?: number },
): Promise<SessionResponse> {
const res = await api.patch(`/sessions/${sessionId}`, patch)
return res.data
}
export async function archiveSession(sessionId: string): Promise<void> {
await api.post(`/sessions/${sessionId}/archive`)
}
export async function restoreSession(sessionId: string): Promise<void> {
await api.post(`/sessions/${sessionId}/restore`)
}
export async function cloneSession(sessionId: string): Promise<{ session_id: string }> {
const res = await api.post(`/sessions/${sessionId}/clone`)
return res.data
}
export async function getRevision(sessionId: string): Promise<RevisionResponse> {
const res = await api.get(`/sessions/${sessionId}/revision`)
return res.data
}
export async function restoreRevision(sessionId: string): Promise<SessionResponse> {
const res = await api.post(`/sessions/${sessionId}/revision/restore`)
return res.data
}
// ── Phase Execution ──────────────────────────────────
export async function runPhase1(sessionId: string): Promise<SessionResponse> {
@ -98,8 +147,13 @@ export async function runPhase2(
sessionId: string,
proper_nouns: ProperNoun[],
style: string,
expected_version: number,
): Promise<SessionResponse> {
const res = await api.post(`/translate/${sessionId}/phase2`, { proper_nouns, style })
const res = await api.post(`/translate/${sessionId}/phase2`, {
proper_nouns,
style,
expected_version,
})
return res.data
}

View file

@ -5,6 +5,7 @@ const props = defineProps<{
label: string
value: string
models: LLMModel[]
disabled?: boolean
}>()
const emit = defineEmits<{ (e: 'update:value', v: string): void }>()
@ -18,8 +19,9 @@ function update(v: string) {
<label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ label }}</label>
<select
:value="value"
:disabled="disabled"
@change="update((($event.target as HTMLSelectElement).value))"
class="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
class="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
<option value="">-- 선택 --</option>
<option v-for="m in models" :key="m.alias" :value="m.alias">

View file

@ -4,6 +4,7 @@ import type { ProperNoun } from '../types'
const props = defineProps<{
proper_nouns: ProperNoun[]
style: string
disabled?: boolean
}>()
const emit = defineEmits<{
(e: 'update:proper_nouns', v: ProperNoun[]): void
@ -39,9 +40,10 @@ function emitAll() {
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">문체 (수정 가능)</label>
<input
v-model="styleText"
:disabled="disabled"
@change="emitAll()"
type="text"
class="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
class="w-full px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-60"
/>
</div>
</div>
@ -65,6 +67,7 @@ function emitAll() {
<td class="py-1 px-2">
<input
v-model="nouns[idx].final"
:disabled="disabled"
@change="emitAll()"
type="text"
class="w-full px-2 py-1 border border-gray-200 dark:border-gray-600 rounded text-sm bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"

View file

@ -4,12 +4,14 @@ import { AUTH_UNAUTHORIZED_EVENT, me as apiMe, logout as apiLogout } from '../ap
export const useAuthStore = defineStore('auth', () => {
const user_id = ref<string | null>(null)
const isAdmin = ref(false)
const loaded = ref(false)
const isLoggedIn = computed(() => !!user_id.value && loaded.value)
function handleUnauthorized() {
user_id.value = null
isAdmin.value = false
loaded.value = true
}
@ -21,8 +23,10 @@ export const useAuthStore = defineStore('auth', () => {
try {
const data = await apiMe()
user_id.value = data.user_id
isAdmin.value = data.is_admin
} catch {
user_id.value = null
isAdmin.value = false
} finally {
loaded.value = true
}
@ -31,7 +35,8 @@ export const useAuthStore = defineStore('auth', () => {
async function logout() {
await apiLogout()
user_id.value = null
isAdmin.value = false
}
return { user_id, loaded, isLoggedIn, check, logout }
return { user_id, isAdmin, loaded, isLoggedIn, check, logout }
})

View file

@ -1,11 +1,12 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { isAxiosError } from 'axios'
import type { HistoryEntry, LLMModel, Phase1Result, PhaseProgress, ProperNoun, SessionConfig, TranslationSessionData } from '../types'
import type {
LLMModel, Phase1Result, PhaseProgress, ProperNoun, SessionConfig,
SessionResponse, SessionSummary, TranslationSessionData,
} from '../types'
import * as api from '../api'
import { useAuthStore } from './auth'
const HISTORY_KEY = 'llm_translator_history'
const MODELS_KEY = 'llm_translator_models'
interface SavedModels {
@ -15,9 +16,10 @@ interface SavedModels {
}
export const useTranslationStore = defineStore('translation', () => {
const auth = useAuthStore()
// ── State ────────────────────────────────────────
const sessionId = ref<string | null>(null)
const sessionTitle = ref('')
const sessionVersion = ref(0)
const archivedAt = ref<string | null>(null)
const sourceText = ref('')
const sourceLanguage = ref('auto')
const targetLanguage = ref('한국어')
@ -25,37 +27,41 @@ export const useTranslationStore = defineStore('translation', () => {
const modelPhase3 = ref('')
const modelPhase4 = ref('')
// Phase results
const phase1Result = ref<Phase1Result | null>(null)
const phase2ProperNouns = ref<ProperNoun[]>([])
const phase2Style = ref('')
const phase3Result = ref('')
const phase4Result = ref('')
// UI state
const loading = ref(false)
const sessionLoading = ref(false)
const historyLoading = ref(false)
const saving = ref(false)
const currentPhaseLoading = ref<number | null>(null)
const error = ref<string | null>(null)
const models = ref<LLMModel[]>([])
const history = ref<HistoryEntry[]>([])
const historySnapshot = ref(false)
const history = ref<SessionSummary[]>([])
const progress = ref<PhaseProgress | null>(null)
let hydrating = false
let saveTimer: ReturnType<typeof setTimeout> | null = null
let phase2SaveTimer: ReturnType<typeof setTimeout> | null = null
let phase2EditGeneration = 0
let phase2SavePromise: Promise<void> | null = null
let editGeneration = 0
let progressTimer: ReturnType<typeof setInterval> | null = null
let progressRequestPending = false
// ── Computed ─────────────────────────────────────
const phase1Done = computed(() => !!phase1Result.value)
const phase2Ready = computed(() => phase1Done.value && !historySnapshot.value)
const phase3Ready = computed(() => phase1Done.value && !historySnapshot.value)
const phase4Ready = computed(() => !!phase3Result.value && !historySnapshot.value)
const editable = computed(() => !archivedAt.value && !loading.value && !sessionLoading.value)
const phase2Ready = computed(() => phase1Done.value && editable.value)
const phase3Ready = computed(() => phase1Done.value && editable.value)
const phase4Ready = computed(() => !!phase3Result.value && editable.value)
// ── Actions ──────────────────────────────────────
function getErrorMessage(error: unknown, fallback: string): string {
if (isAxiosError<{ detail?: string }>(error)) {
return error.response?.data?.detail || error.message || fallback
function getErrorMessage(caught: unknown, fallback: string): string {
if (isAxiosError<{ detail?: string }>(caught)) {
return caught.response?.data?.detail || caught.message || fallback
}
return error instanceof Error ? error.message : fallback
return caught instanceof Error ? caught.message : fallback
}
function loadSavedModels(): SavedModels {
@ -81,13 +87,21 @@ export const useTranslationStore = defineStore('translation', () => {
async function loadModels() {
try {
models.value = await api.listModels()
} catch (error: unknown) {
console.error('Failed to load models:', error)
} catch (caught: unknown) {
error.value = getErrorMessage(caught, '모델 목록을 불러오지 못했습니다')
}
}
function newSession() {
if (saveTimer) clearTimeout(saveTimer)
if (phase2SaveTimer) clearTimeout(phase2SaveTimer)
editGeneration += 1
phase2EditGeneration += 1
hydrating = true
sessionId.value = null
sessionTitle.value = ''
sessionVersion.value = 0
archivedAt.value = null
sourceText.value = ''
sourceLanguage.value = 'auto'
targetLanguage.value = '한국어'
@ -96,53 +110,22 @@ export const useTranslationStore = defineStore('translation', () => {
phase2Style.value = ''
phase3Result.value = ''
phase4Result.value = ''
progress.value = null
error.value = null
historySnapshot.value = false
const savedModels = loadSavedModels()
const saved = loadSavedModels()
const defaultModel = models.value[0]?.alias || ''
modelPhase1.value = validModel(savedModels.p1, defaultModel)
modelPhase3.value = validModel(savedModels.p3, defaultModel)
modelPhase4.value = validModel(savedModels.p4, models.value[1]?.alias || defaultModel)
modelPhase1.value = validModel(saved.p1, defaultModel)
modelPhase3.value = validModel(saved.p3, defaultModel)
modelPhase4.value = validModel(saved.p4, models.value[1]?.alias || defaultModel)
hydrating = false
}
function saveModelSelections() {
try {
localStorage.setItem(MODELS_KEY, JSON.stringify({
p1: modelPhase1.value,
p3: modelPhase3.value,
p4: modelPhase4.value,
}))
} catch (error) {
console.error('Failed to save model selections:', error)
}
}
watch([modelPhase1, modelPhase3, modelPhase4], saveModelSelections)
watch([sourceText, sourceLanguage, targetLanguage, modelPhase1], () => {
phase1Result.value = null
phase2ProperNouns.value = []
phase2Style.value = ''
phase3Result.value = ''
phase4Result.value = ''
}, { flush: 'sync' })
watch(modelPhase3, () => {
phase3Result.value = ''
phase4Result.value = ''
}, { flush: 'sync' })
watch(modelPhase4, () => {
phase4Result.value = ''
}, { flush: 'sync' })
watch([phase2ProperNouns, phase2Style], () => {
phase3Result.value = ''
phase4Result.value = ''
}, { deep: true, flush: 'sync' })
async function ensureSession() {
if (!sessionId.value) {
const res = await api.createSession(getSessionParams())
sessionId.value = res.session_id
}
localStorage.setItem(MODELS_KEY, JSON.stringify({
p1: modelPhase1.value,
p3: modelPhase3.value,
p4: modelPhase4.value,
}))
}
function getSessionParams(): SessionConfig {
@ -157,30 +140,223 @@ export const useTranslationStore = defineStore('translation', () => {
}
}
function hydrate(response: SessionResponse) {
hydrating = true
const data = response.data
sessionId.value = response.session_id
sessionTitle.value = response.title
sessionVersion.value = response.version
archivedAt.value = response.archived_at
sourceText.value = data.source_text
sourceLanguage.value = data.source_language
targetLanguage.value = data.target_language
const defaultModel = models.value[0]?.alias || ''
modelPhase1.value = validModel(data.model_phase1, defaultModel)
modelPhase3.value = validModel(data.model_phase3, defaultModel)
modelPhase4.value = validModel(data.model_phase4, models.value[1]?.alias || defaultModel)
phase1Result.value = data.phase1_result
phase2ProperNouns.value = data.phase2_proper_nouns || []
phase2Style.value = data.phase2_style || ''
phase3Result.value = data.phase3_result || ''
phase4Result.value = data.phase4_result || ''
progress.value = data.progress || null
hydrating = false
saveModelSelections()
}
async function loadHistory() {
historyLoading.value = true
try {
history.value = await api.listSessions(true)
} catch (caught: unknown) {
error.value = getErrorMessage(caught, '번역 기록을 불러오지 못했습니다')
} finally {
historyLoading.value = false
}
}
async function openSession(id: string) {
if (saveTimer) clearTimeout(saveTimer)
if (phase2SaveTimer) clearTimeout(phase2SaveTimer)
editGeneration += 1
phase2EditGeneration += 1
sessionLoading.value = true
error.value = null
try {
hydrate(await api.getSession(id))
} catch (caught: unknown) {
error.value = getErrorMessage(caught, '세션을 불러오지 못했습니다')
} finally {
sessionLoading.value = false
}
}
function scheduleAutoSave() {
if (hydrating || loading.value || sessionLoading.value || archivedAt.value) return
editGeneration += 1
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => void flushAutoSave(), 650)
}
async function flushAutoSave() {
saveTimer = null
if (archivedAt.value || hydrating || loading.value) return
if (saving.value) {
saveTimer = setTimeout(() => void flushAutoSave(), 250)
return
}
const generation = editGeneration
saving.value = true
try {
if (!sessionId.value) {
const created = await api.createSession(getSessionParams())
if (generation !== editGeneration) return
hydrate(await api.getSession(created.session_id))
await loadHistory()
return
}
const savingSessionId = sessionId.value
const response = await api.updateSession(savingSessionId, {
...getSessionParams(),
expected_version: sessionVersion.value,
})
if (sessionId.value === savingSessionId) {
sessionVersion.value = response.version
sessionTitle.value = response.title
}
} catch (caught: unknown) {
error.value = getErrorMessage(caught, '변경사항을 저장하지 못했습니다')
} finally {
saving.value = false
if (generation !== editGeneration) scheduleAutoSave()
else void loadHistory()
}
}
watch([modelPhase1, modelPhase3, modelPhase4], saveModelSelections)
watch([sourceText, sourceLanguage, targetLanguage, modelPhase1], () => {
if (hydrating) return
phase1Result.value = null
phase2ProperNouns.value = []
phase2Style.value = ''
phase3Result.value = ''
phase4Result.value = ''
scheduleAutoSave()
}, { flush: 'sync' })
watch(modelPhase3, () => {
if (hydrating) return
phase3Result.value = ''
phase4Result.value = ''
scheduleAutoSave()
}, { flush: 'sync' })
watch(modelPhase4, () => {
if (hydrating) return
phase4Result.value = ''
scheduleAutoSave()
}, { flush: 'sync' })
watch([phase2ProperNouns, phase2Style], () => {
if (hydrating) return
phase2EditGeneration += 1
phase3Result.value = ''
phase4Result.value = ''
if (sessionId.value && phase1Result.value && !loading.value && !archivedAt.value) {
schedulePhase2Save()
}
}, { deep: true, flush: 'sync' })
function schedulePhase2Save(delay = 650) {
if (phase2SaveTimer) clearTimeout(phase2SaveTimer)
phase2SaveTimer = setTimeout(() => void savePhase2Draft(), delay)
}
async function savePhase2Draft() {
phase2SaveTimer = null
if (!sessionId.value || !phase1Result.value || loading.value || archivedAt.value) return
if (phase2SavePromise) {
await phase2SavePromise
if (sessionId.value && phase1Result.value && !loading.value && !archivedAt.value) {
schedulePhase2Save(100)
}
return
}
const requestSessionId = sessionId.value
const requestGeneration = phase2EditGeneration
const nouns = phase2ProperNouns.value.map(noun => ({
...noun,
final: noun.final || noun.suggested,
}))
const style = phase2Style.value
phase2SavePromise = (async () => {
const response = await api.runPhase2(
requestSessionId,
nouns,
style,
sessionVersion.value,
)
if (sessionId.value !== requestSessionId) return
if (phase2EditGeneration === requestGeneration) {
hydrate(response)
} else {
sessionVersion.value = response.version
sessionTitle.value = response.title
}
})()
try {
await phase2SavePromise
await loadHistory()
} catch (caught: unknown) {
error.value = getErrorMessage(caught, '고유명사와 문체를 저장하지 못했습니다')
} finally {
phase2SavePromise = null
if (
sessionId.value === requestSessionId
&& phase2EditGeneration !== requestGeneration
&& !loading.value
) {
schedulePhase2Save(100)
}
}
}
async function ensureSession() {
if (sessionId.value) return
const created = await api.createSession(getSessionParams())
hydrate(await api.getSession(created.session_id))
await loadHistory()
}
async function syncToServer() {
if (!sessionId.value) return
await api.updateSession(sessionId.value, getSessionParams())
if (!sessionId.value || archivedAt.value) return
if (saveTimer) clearTimeout(saveTimer)
saveTimer = null
const response = await api.updateSession(sessionId.value, {
...getSessionParams(),
expected_version: sessionVersion.value,
})
sessionVersion.value = response.version
sessionTitle.value = response.title
}
async function submitPhase2() {
if (!sessionId.value) throw new Error('세션 생성 실패')
const res = await api.runPhase2(
if (phase2SaveTimer) clearTimeout(phase2SaveTimer)
phase2SaveTimer = null
if (phase2SavePromise) await phase2SavePromise
hydrate(await api.runPhase2(
sessionId.value,
phase2ProperNouns.value.map(pn => ({ ...pn, final: pn.final || pn.suggested })),
phase2ProperNouns.value.map(noun => ({ ...noun, final: noun.final || noun.suggested })),
phase2Style.value,
)
phase2ProperNouns.value = res.data.phase2_proper_nouns || []
phase2Style.value = res.data.phase2_style || ''
sessionVersion.value,
))
}
async function pollProgress() {
if (!sessionId.value || progressRequestPending) return
progressRequestPending = true
try {
const response = await api.getSessionProgress(sessionId.value)
progress.value = response.progress
progress.value = (await api.getSessionProgress(sessionId.value)).progress
} catch {
// The phase request handles user-visible errors; polling is best-effort only.
// Phase requests surface errors; progress polling is best-effort.
} finally {
progressRequestPending = false
}
@ -194,42 +370,27 @@ export const useTranslationStore = defineStore('translation', () => {
}
function stopProgressPolling() {
if (progressTimer !== null) {
clearInterval(progressTimer)
progressTimer = null
}
if (progressTimer !== null) clearInterval(progressTimer)
progressTimer = null
}
// ── Phase Execution ──────────────────────────────
async function executePhase1() {
if (!sourceText.value.trim()) {
error.value = '원문을 입력해주세요'
if (!sourceText.value.trim() || !modelPhase1.value) {
error.value = !sourceText.value.trim() ? '원문을 입력해주세요' : 'Phase 1 LLM 모델을 선택하세요'
return
}
if (!modelPhase1.value) {
error.value = 'Phase 1 LLM 모델을 선택하세요'
return
}
loading.value = true
currentPhaseLoading.value = 1
error.value = null
phase3Result.value = ''
phase4Result.value = ''
try {
await ensureSession()
if (!sessionId.value) throw new Error('세션 생성 실패')
await syncToServer()
if (!sessionId.value) throw new Error('세션 생성 실패')
startProgressPolling()
const res = await api.runPhase1(sessionId.value)
phase1Result.value = res.data.phase1_result
phase2ProperNouns.value = res.data.phase2_proper_nouns || []
phase2Style.value = res.data.phase2_style || ''
historySnapshot.value = false
saveToHistory()
} catch (caughtError: unknown) {
error.value = getErrorMessage(caughtError, 'Phase 1 실행 실패')
hydrate(await api.runPhase1(sessionId.value))
await loadHistory()
} catch (caught: unknown) {
error.value = getErrorMessage(caught, 'Phase 1 실행 실패')
} finally {
stopProgressPolling()
loading.value = false
@ -238,20 +399,15 @@ export const useTranslationStore = defineStore('translation', () => {
}
async function executePhase2() {
// Phase 2 is a user confirmation step — we just save the edited proper nouns and style
if (!sessionId.value) return
loading.value = true
currentPhaseLoading.value = 2
error.value = null
phase3Result.value = ''
phase4Result.value = ''
try {
await submitPhase2()
saveToHistory()
} catch (caughtError: unknown) {
error.value = getErrorMessage(caughtError, 'Phase 2 확인 실패')
await loadHistory()
} catch (caught: unknown) {
error.value = getErrorMessage(caught, 'Phase 2 확인 실패')
} finally {
loading.value = false
currentPhaseLoading.value = null
@ -263,29 +419,19 @@ export const useTranslationStore = defineStore('translation', () => {
error.value = '먼저 Phase 1을 실행하세요'
return
}
if (!modelPhase3.value && !modelPhase1.value) {
error.value = 'Phase 3 LLM 모델을 선택하세요'
return
}
loading.value = true
currentPhaseLoading.value = 3
error.value = null
phase3Result.value = ''
phase4Result.value = ''
try {
await ensureSession()
if (!sessionId.value) throw new Error('세션 생성 실패')
await syncToServer()
await submitPhase2()
saveToHistory()
if (!sessionId.value) throw new Error('세션 생성 실패')
startProgressPolling()
const res = await api.runPhase3(sessionId.value)
phase3Result.value = res.data.phase3_result || ''
saveToHistory()
} catch (caughtError: unknown) {
error.value = getErrorMessage(caughtError, 'Phase 3 실행 실패')
hydrate(await api.runPhase3(sessionId.value))
await loadHistory()
} catch (caught: unknown) {
error.value = getErrorMessage(caught, 'Phase 3 실행 실패')
} finally {
stopProgressPolling()
loading.value = false
@ -298,21 +444,18 @@ export const useTranslationStore = defineStore('translation', () => {
error.value = '먼저 Phase 3을 실행하세요'
return
}
loading.value = true
currentPhaseLoading.value = 4
error.value = null
try {
await ensureSession()
if (!sessionId.value) throw new Error('세션 생성 실패')
await syncToServer()
if (!sessionId.value) throw new Error('세션 생성 실패')
startProgressPolling()
const res = await api.runPhase4(sessionId.value)
phase4Result.value = res.data.phase4_result || ''
saveToHistory()
} catch (caughtError: unknown) {
error.value = getErrorMessage(caughtError, 'Phase 4 실행 실패')
hydrate(await api.runPhase4(sessionId.value))
await loadHistory()
} catch (caught: unknown) {
error.value = getErrorMessage(caught, 'Phase 4 실행 실패')
} finally {
stopProgressPolling()
loading.value = false
@ -320,129 +463,75 @@ export const useTranslationStore = defineStore('translation', () => {
}
}
// ── History (localStorage) ───────────────────────
function saveToHistory() {
const entry: HistoryEntry = {
id: sessionId.value || Date.now().toString(),
source_preview: sourceText.value.slice(0, 100),
target_language: targetLanguage.value,
timestamp: Date.now(),
data: getSessionData(),
}
// Avoid duplicates
const idx = history.value.findIndex(h => h.id === entry.id)
if (idx >= 0) {
history.value[idx] = entry
} else {
history.value.unshift(entry)
}
// Keep max 50 entries
if (history.value.length > 50) history.value.pop()
async function archive(id: string, shouldArchive: boolean) {
try {
localStorage.setItem(historyStorageKey(), JSON.stringify(history.value))
} catch (error) {
console.error('Failed to save translation history:', error)
if (shouldArchive) await api.archiveSession(id)
else await api.restoreSession(id)
if (sessionId.value === id) hydrate(await api.getSession(id))
await loadHistory()
} catch (caught: unknown) {
error.value = getErrorMessage(caught, shouldArchive ? '보관하지 못했습니다' : '복원하지 못했습니다')
}
}
function historyStorageKey(): string {
return `${HISTORY_KEY}:${auth.user_id || 'anonymous'}`
}
function loadHistory() {
async function clone(id: string) {
try {
const key = historyStorageKey()
const raw = localStorage.getItem(key) || '[]'
history.value = JSON.parse(raw) as HistoryEntry[]
localStorage.removeItem(HISTORY_KEY)
} catch {
history.value = []
const cloned = await api.cloneSession(id)
await loadHistory()
await openSession(cloned.session_id)
} catch (caught: unknown) {
error.value = getErrorMessage(caught, '세션을 복제하지 못했습니다')
}
}
function restoreFromHistory(entry: HistoryEntry) {
sessionId.value = null
sourceText.value = entry.data.source_text
sourceLanguage.value = entry.data.source_language
targetLanguage.value = entry.data.target_language
const defaultModel = models.value[0]?.alias || ''
modelPhase1.value = validModel(entry.data.model_phase1, defaultModel)
modelPhase3.value = validModel(entry.data.model_phase3, defaultModel)
modelPhase4.value = validModel(entry.data.model_phase4, models.value[1]?.alias || defaultModel)
phase1Result.value = entry.data.phase1_result
phase2ProperNouns.value = entry.data.phase2_proper_nouns || []
phase2Style.value = entry.data.phase2_style || ''
phase3Result.value = entry.data.phase3_result || ''
phase4Result.value = entry.data.phase4_result || ''
historySnapshot.value = true
// Save model selections for future sessions
saveModelSelections()
}
function getSessionData(): TranslationSessionData {
return {
source_text: sourceText.value,
source_language: sourceLanguage.value,
target_language: targetLanguage.value,
model_phase1: modelPhase1.value,
model_phase2: '',
model_phase3: modelPhase3.value,
model_phase4: modelPhase4.value,
phase1_result: phase1Result.value || null,
phase2_proper_nouns: phase2ProperNouns.value,
phase2_style: phase2Style.value,
phase3_result: phase3Result.value,
phase4_result: phase4Result.value,
async function remove(id: string) {
try {
await api.deleteSession(id)
if (sessionId.value === id) newSession()
await loadHistory()
} catch (caught: unknown) {
error.value = getErrorMessage(caught, '세션을 삭제하지 못했습니다')
}
}
// ── Export ────────────────────────────────────────
async function restorePreviousRevision() {
if (!sessionId.value) return
try {
await api.getRevision(sessionId.value)
hydrate(await api.restoreRevision(sessionId.value))
await loadHistory()
} catch (caught: unknown) {
error.value = getErrorMessage(caught, '복원할 이전 버전이 없습니다')
}
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text).catch(() => {
error.value = '클립보드 복사에 실패했습니다'
})
navigator.clipboard.writeText(text).catch(() => { error.value = '클립보드 복사에 실패했습니다' })
}
function downloadTxt(filename: string, content: string): void {
function downloadTxt(filename: string, content: string) {
const blob = new Blob([content], { type: 'text/plain; charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}
function downloadMd(filename: string, content: string): void {
// Add simple markdown formatting — frontmatter with metadata
const md = `---
source_language: ${sourceLanguage.value}
target_language: ${targetLanguage.value}
timestamp: ${new Date().toISOString()}
---\n\n${content}`
downloadTxt(filename.replace('.txt', '.md'), md)
function downloadMd(filename: string, content: string) {
const markdown = `---\nsource_language: ${sourceLanguage.value}\ntarget_language: ${targetLanguage.value}\ntimestamp: ${new Date().toISOString()}\n---\n\n${content}`
downloadTxt(filename.replace('.txt', '.md'), markdown)
}
return {
// State
sessionId, sourceText, sourceLanguage, targetLanguage,
modelPhase1, modelPhase3, modelPhase4,
phase1Result, phase2ProperNouns, phase2Style,
phase3Result, phase4Result,
loading, currentPhaseLoading, error, models, history, progress,
// Computed
phase1Done, phase2Ready, phase3Ready, phase4Ready,
// Actions
loadModels, newSession, saveModelSelections, ensureSession, syncToServer,
executePhase1, executePhase2, executePhase3, executePhase4,
saveToHistory, loadHistory, restoreFromHistory,
copyToClipboard, downloadTxt, downloadMd, getSessionData,
sessionId, sessionTitle, sessionVersion, archivedAt, sourceText, sourceLanguage, targetLanguage,
modelPhase1, modelPhase3, modelPhase4, phase1Result, phase2ProperNouns, phase2Style,
phase3Result, phase4Result, loading, sessionLoading, historyLoading, saving,
currentPhaseLoading, error, models, history, progress, phase1Done, phase2Ready,
phase3Ready, phase4Ready, editable, loadModels, newSession, ensureSession, syncToServer,
loadHistory, openSession, executePhase1, executePhase2, executePhase3, executePhase4,
archive, clone, remove, restorePreviousRevision, copyToClipboard, downloadTxt, downloadMd,
getSessionParams,
}
})

View file

@ -40,6 +40,7 @@ export interface TranslationSessionData {
phase1_chunks?: Array<{ source_text: string; translated: string }>
phase2_proper_nouns: ProperNoun[]
phase2_style: string
phase2_confirmed: boolean
phase3_result: string
phase3_chunks?: string[]
phase4_result: string
@ -60,13 +61,38 @@ export type SessionConfig = Pick<
export interface SessionResponse {
session_id: string
data: TranslationSessionData
title: string
status: string
current_phase: number
version: number
created_at: string
updated_at: string
archived_at: string | null
}
/** A saved translation history entry (localStorage) */
export interface HistoryEntry {
id: string
export interface SessionSummary {
session_id: string
title: string
source_preview: string
target_language: string
timestamp: number
data: TranslationSessionData
status: string
current_phase: number
version: number
created_at: string
updated_at: string
archived_at: string | null
}
export interface RevisionResponse {
data: TranslationSessionData
reason: string
created_at: string
}
export interface UserSummary {
id: string
is_admin: boolean
is_active: boolean
created_at: string
updated_at: string
}

View file

@ -0,0 +1,126 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { isAxiosError } from 'axios'
import * as api from '../api'
import type { UserSummary } from '../types'
import { useAuthStore } from '../stores/auth'
import { useThemeStore } from '../stores/theme'
defineEmits<{ (event: 'close'): void }>()
const auth = useAuthStore()
const theme = useThemeStore()
const users = ref<UserSummary[]>([])
const loading = ref(false)
const error = ref('')
const success = ref('')
const form = reactive({ id: '', password: '', is_admin: false })
const passwords = reactive<Record<string, string>>({})
function message(caught: unknown, fallback: string) {
return isAxiosError<{ detail?: string }>(caught)
? caught.response?.data?.detail || fallback
: fallback
}
async function loadUsers() {
loading.value = true
error.value = ''
try {
users.value = await api.listUsers()
} catch (caught: unknown) {
error.value = message(caught, '사용자 목록을 불러오지 못했습니다')
} finally {
loading.value = false
}
}
async function addUser() {
error.value = ''
success.value = ''
try {
await api.createUser(form)
success.value = `${form.id} 사용자를 만들었습니다.`
form.id = ''
form.password = ''
form.is_admin = false
await loadUsers()
} catch (caught: unknown) {
error.value = message(caught, '사용자를 만들지 못했습니다')
}
}
async function update(user: UserSummary, patch: { password?: string; is_admin?: boolean; is_active?: boolean }) {
error.value = ''
success.value = ''
try {
await api.updateUser(user.id, patch)
success.value = `${user.id} 계정을 변경했습니다.`
passwords[user.id] = ''
await loadUsers()
} catch (caught: unknown) {
error.value = message(caught, '사용자를 변경하지 못했습니다')
}
}
onMounted(loadUsers)
</script>
<template>
<div class="min-h-screen text-gray-800 dark:text-gray-100">
<header class="sticky top-0 z-10 border-b border-gray-200 bg-white/90 shadow-sm backdrop-blur dark:border-gray-700 dark:bg-gray-800/90">
<div class="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3 px-4 py-3">
<div class="flex items-center gap-3">
<button @click="$emit('close')" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600">번역기로</button>
<h1 class="text-lg font-bold">사용자 관리</h1>
</div>
<div class="flex items-center gap-3">
<button @click="theme.toggle()" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm dark:bg-gray-700">{{ theme.isDark ? '라이트' : '다크' }}</button>
<span class="text-sm text-gray-500 dark:text-gray-300">{{ auth.user_id }}</span>
<button @click="auth.logout()" class="rounded-lg bg-red-50 px-3 py-1.5 text-sm text-red-600 dark:bg-red-900/30">로그아웃</button>
</div>
</div>
</header>
<main class="mx-auto max-w-6xl space-y-6 px-4 py-6">
<section class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 font-semibold"> 사용자</h2>
<form class="grid gap-3 md:grid-cols-[1fr_1fr_auto_auto]" @submit.prevent="addUser">
<input v-model.trim="form.id" required maxlength="50" pattern="[A-Za-z0-9_.-]+" placeholder="사용자 ID" class="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900" />
<input v-model="form.password" required minlength="8" type="password" autocomplete="new-password" placeholder="비밀번호 (8자 이상)" class="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900" />
<label class="flex items-center gap-2 text-sm"><input v-model="form.is_admin" type="checkbox" /> 관리자</label>
<button :disabled="loading" class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-50">추가</button>
</form>
</section>
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">{{ error }}</div>
<div v-if="success" class="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:border-emerald-800 dark:bg-emerald-900/20 dark:text-emerald-300">{{ success }}</div>
<section class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-gray-700">
<h2 class="font-semibold">사용자 {{ users.length }}</h2>
<button @click="loadUsers" :disabled="loading" class="text-sm text-blue-600 disabled:opacity-50 dark:text-blue-400">새로고침</button>
</div>
<div v-if="loading && !users.length" class="p-8 text-center text-sm text-gray-400">불러오는 중...</div>
<div v-else class="divide-y divide-gray-100 dark:divide-gray-700">
<article v-for="user in users" :key="user.id" class="grid gap-4 p-5 lg:grid-cols-[1fr_auto_auto_2fr] lg:items-center">
<div>
<div class="font-medium">{{ user.id }} <span v-if="user.id === auth.user_id" class="text-xs text-blue-500"> 계정</span></div>
<div class="mt-1 text-xs text-gray-400">생성 {{ new Date(user.created_at).toLocaleDateString('ko-KR') }}</div>
</div>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" :checked="user.is_admin" :disabled="user.id === auth.user_id" @change="update(user, { is_admin: ($event.target as HTMLInputElement).checked })" /> 관리자
</label>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" :checked="user.is_active" :disabled="user.id === auth.user_id" @change="update(user, { is_active: ($event.target as HTMLInputElement).checked })" /> 활성
</label>
<form class="flex gap-2" @submit.prevent="update(user, { password: passwords[user.id] })">
<input v-model="passwords[user.id]" required minlength="8" type="password" autocomplete="new-password" placeholder="새 비밀번호" class="min-w-0 flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900" />
<button class="whitespace-nowrap rounded-lg bg-gray-100 px-3 py-2 text-sm hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600">재설정</button>
</form>
</article>
</div>
</section>
</main>
</div>
</template>

View file

@ -11,11 +11,12 @@ const store = useTranslationStore()
const auth = useAuthStore()
const theme = useThemeStore()
const showHistorySidebar = ref(false)
defineEmits<{ (event: 'open-admin'): void }>()
onMounted(async () => {
await store.loadModels()
store.newSession()
store.loadHistory()
await store.loadHistory()
})
// Language options
@ -56,13 +57,26 @@ function handleNewTranslation() {
}
store.newSession()
}
async function openHistorySession(id: string) {
await store.openSession(id)
showHistorySidebar.value = false
}
async function removeSession(id: string) {
if (confirm('이 번역 기록을 영구 삭제하시겠습니까?')) await store.remove(id)
}
async function restoreRevision() {
if (confirm('현재 내용을 직전 저장 버전으로 되돌리시겠습니까?')) await store.restorePreviousRevision()
}
</script>
<template>
<div class="min-h-screen">
<!-- Top Bar -->
<header class="bg-white/90 dark:bg-gray-800/90 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10 shadow-sm backdrop-blur">
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
<div class="max-w-7xl mx-auto px-4 py-3 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -72,7 +86,7 @@ function handleNewTranslation() {
<h1 class="text-lg font-bold text-gray-800 dark:text-gray-100">LLM 번역기</h1>
</div>
<div class="flex items-center gap-3">
<div class="flex flex-wrap items-center justify-end gap-2">
<!-- Dark Mode Toggle -->
<button
@click="theme.toggle()"
@ -84,13 +98,21 @@ function handleNewTranslation() {
<!-- History Button -->
<button
@click="showHistorySidebar = true"
@click="showHistorySidebar = true; store.loadHistory()"
class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors flex items-center gap-1.5"
title="히스토리"
>
🕐 히스토리 ({{ store.history.length }})
</button>
<button
v-if="auth.isAdmin"
@click="$emit('open-admin')"
class="px-3 py-1.5 text-sm bg-blue-50 dark:bg-blue-900/30 hover:bg-blue-100 dark:hover:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded-lg transition-colors"
>
사용자 관리
</button>
<!-- User Info & Logout -->
<span class="text-sm text-gray-600 dark:text-gray-300">{{ auth.user_id }}</span>
<button @click="auth.logout()" class="px-3 py-1.5 text-sm bg-red-50 dark:bg-red-900/30 hover:bg-red-100 dark:hover:bg-red-900/50 text-red-600 rounded-lg transition-colors">
@ -102,18 +124,24 @@ function handleNewTranslation() {
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 py-6 space-y-6">
<div v-if="store.archivedAt" class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-300">
<span>보관된 번역입니다. 내용을 편집하거나 단계를 실행하려면 먼저 복원하세요.</span>
<button @click="store.archive(store.sessionId!, false)" class="rounded-lg bg-amber-100 px-3 py-1.5 font-medium hover:bg-amber-200 dark:bg-amber-900/50">복원</button>
</div>
<!-- Model Selection Row -->
<section class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<div class="flex flex-wrap items-end gap-4">
<LLMSelector label="Phase 1 — 초벌번역" v-model:value="store.modelPhase1" :models="store.models" />
<LLMSelector label="Phase 3 — 재번역" v-model:value="store.modelPhase3" :models="store.models" />
<LLMSelector label="Phase 4 — 마무리" v-model:value="store.modelPhase4" :models="store.models" />
<LLMSelector label="Phase 1 — 초벌번역" v-model:value="store.modelPhase1" :models="store.models" :disabled="!store.editable" />
<LLMSelector label="Phase 3 — 재번역" v-model:value="store.modelPhase3" :models="store.models" :disabled="!store.editable" />
<LLMSelector label="Phase 4 — 마무리" v-model:value="store.modelPhase4" :models="store.models" :disabled="!store.editable" />
<!-- Language Selectors -->
<div class="flex flex-col gap-1">
<label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">출발어</label>
<select
v-model="store.sourceLanguage"
:disabled="!store.editable"
class="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
>
<option v-for="lang in languages" :key="lang.value" :value="lang.value">
@ -128,6 +156,7 @@ function handleNewTranslation() {
<label class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">도착어</label>
<select
v-model="store.targetLanguage"
:disabled="!store.editable"
class="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
>
<option v-for="lang in targetLanguages" :key="lang.value" :value="lang.value">
@ -137,9 +166,13 @@ function handleNewTranslation() {
</div>
<!-- New Translation Button -->
<button @click="handleNewTranslation()" class="px-4 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">
<button @click="handleNewTranslation()" :disabled="store.loading || store.saving" class="px-4 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors disabled:opacity-50">
🔄 번역
</button>
<button v-if="store.sessionId && store.editable" @click="restoreRevision" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
이전 버전 복원
</button>
<span v-if="store.saving" class="text-xs text-gray-400">저장 중...</span>
</div>
</section>
@ -151,9 +184,10 @@ function handleNewTranslation() {
</div>
<textarea
v-model="store.sourceText"
:disabled="!store.editable"
rows="10"
placeholder="번역할 원문을 여기에 붙여넣으세요..."
class="w-full px-4 py-3 border border-gray-200 dark:border-gray-600 rounded-lg text-sm leading-relaxed resize-y bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
class="w-full px-4 py-3 border border-gray-200 dark:border-gray-600 rounded-lg text-sm leading-relaxed resize-y bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors disabled:opacity-70 disabled:cursor-not-allowed"
></textarea>
</section>
@ -161,7 +195,7 @@ function handleNewTranslation() {
<section class="flex flex-wrap gap-3">
<button
@click="runPhase(1)"
:disabled="store.loading || !store.sourceText.trim() || !store.modelPhase1"
:disabled="store.loading || store.saving || !store.sourceText.trim() || !store.modelPhase1"
class="px-5 py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
<span v-if="store.currentPhaseLoading === 1"></span>
@ -171,7 +205,7 @@ function handleNewTranslation() {
<button
@click="runPhase(2)"
:disabled="store.loading || !store.phase2Ready"
:disabled="store.loading || store.saving || !store.phase2Ready"
class="px-5 py-2.5 bg-emerald-600 text-white rounded-lg font-medium hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
<span v-if="store.currentPhaseLoading === 2"></span>
@ -181,7 +215,7 @@ function handleNewTranslation() {
<button
@click="runPhase(3)"
:disabled="store.loading || !store.phase3Ready"
:disabled="store.loading || store.saving || !store.phase3Ready"
class="px-5 py-2.5 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
<span v-if="store.currentPhaseLoading === 3"></span>
@ -191,7 +225,7 @@ function handleNewTranslation() {
<button
@click="runPhase(4)"
:disabled="store.loading || !store.phase4Ready"
:disabled="store.loading || store.saving || !store.phase4Ready"
class="px-5 py-2.5 bg-pink-600 text-white rounded-lg font-medium hover:bg-pink-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
<span v-if="store.currentPhaseLoading === 4"></span>
@ -236,6 +270,7 @@ function handleNewTranslation() {
<ProperNounsEditor
:proper_nouns="store.phase2ProperNouns"
:style="store.phase2Style"
:disabled="!store.editable"
@update:proper_nouns="(v) => store.phase2ProperNouns = v"
@update:style="(v) => store.phase2Style = v"
/>
@ -259,31 +294,45 @@ function handleNewTranslation() {
<!-- History Sidebar (overlay) -->
<Transition name="slide">
<div v-if="showHistorySidebar" class="fixed inset-y-0 right-0 w-80 bg-white dark:bg-gray-800 shadow-2xl z-50 p-6 overflow-y-auto border-l border-gray-200 dark:border-gray-700">
<div v-if="showHistorySidebar" class="fixed inset-y-0 right-0 w-full max-w-sm bg-white dark:bg-gray-800 shadow-2xl z-50 p-6 overflow-y-auto border-l border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-bold text-gray-800 dark:text-gray-100 mb-4 flex items-center justify-between">
번역 히스토리
<button @click="showHistorySidebar = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"></button>
</h3>
<!-- History List -->
<div v-if="store.history.length === 0" class="text-sm text-gray-400 italic mt-4">
<div class="mb-4 flex items-center justify-between text-xs text-gray-400">
<span>서버에 저장된 번역과 보관함</span>
<button @click="store.loadHistory()" :disabled="store.historyLoading" class="text-blue-500 disabled:opacity-50">새로고침</button>
</div>
<div v-if="store.historyLoading && store.history.length === 0" class="text-sm text-gray-400 italic mt-4">불러오는 중...</div>
<div v-else-if="store.history.length === 0" class="text-sm text-gray-400 italic mt-4">
아직 번역 기록이 없습니다.
</div>
<ul v-else class="space-y-2">
<li v-for="(entry, idx) in store.history" :key="idx">
<li v-for="entry in store.history" :key="entry.session_id" class="rounded-lg border border-gray-100 p-3 dark:border-gray-600">
<button
@click="store.restoreFromHistory(entry); showHistorySidebar = false"
class="w-full text-left p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 border border-gray-100 dark:border-gray-600 transition-colors"
@click="openHistorySession(entry.session_id)"
:disabled="store.saving || store.sessionLoading"
class="w-full text-left rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-60"
>
<div class="text-xs text-gray-400 mb-1">
{{ new Date(entry.timestamp).toLocaleString('ko-KR') }}
<div class="mb-1 flex items-center justify-between gap-2 text-xs text-gray-400">
<span>{{ new Date(entry.updated_at).toLocaleString('ko-KR') }}</span>
<span v-if="entry.archived_at" class="rounded bg-amber-100 px-1.5 py-0.5 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">보관됨</span>
</div>
<div class="text-sm font-medium text-gray-700 dark:text-gray-200 line-clamp-2">
<div class="text-sm font-semibold text-gray-700 dark:text-gray-200 line-clamp-2">{{ entry.title }}</div>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{{ entry.source_preview || '(빈 원문)' }}
</div>
<div class="text-xs text-blue-500 mt-1"> {{ entry.target_language }}</div>
<div class="text-xs text-blue-500 mt-1">Phase {{ entry.current_phase }} · {{ entry.target_language }}</div>
</button>
<div class="mt-3 flex flex-wrap gap-2 border-t border-gray-100 pt-2 text-xs dark:border-gray-700">
<button @click="store.clone(entry.session_id)" class="text-blue-600 hover:underline dark:text-blue-400">복제</button>
<button v-if="entry.archived_at" @click="store.archive(entry.session_id, false)" class="text-emerald-600 hover:underline dark:text-emerald-400">복원</button>
<button v-else @click="store.archive(entry.session_id, true)" class="text-amber-600 hover:underline dark:text-amber-400">보관</button>
<button @click="removeSession(entry.session_id)" class="ml-auto text-red-600 hover:underline dark:text-red-400">삭제</button>
</div>
</li>
</ul>
</div>