<script lang="ts">
|
import { Call } from '@wailsio/runtime'
|
import { t } from '../../lib/i18n'
|
|
interface HistoryEntry {
|
text: string
|
timestamp: number
|
exportedAt?: number
|
}
|
|
interface HistoryPageResult {
|
entries: HistoryEntry[]
|
total: number
|
page: number
|
pageSize: number
|
totalPages: number
|
}
|
|
interface HistoryStatus {
|
retentionDays: number
|
storagePath: string
|
storageSizeBytes: number
|
storageExists: boolean
|
storageSizeAvailable: boolean
|
totalCount: number
|
exportedCount: number
|
unexportedTodayCount: number
|
unexportedLast7DaysCount: number
|
unexportedAllCount: number
|
}
|
|
interface CorrectionCSVExportResult {
|
path?: string
|
canceled?: boolean
|
noEntries?: boolean
|
}
|
|
interface ClearExportedResult {
|
deletedCount: number
|
remainingCount: number
|
}
|
|
type ExportScope = 'today' | 'last7Days' | 'all'
|
type ConfirmMode = 'clearAll' | 'clearExported' | null
|
|
const PAGE_SIZE = 100
|
const MB = 1024 * 1024
|
|
let entries = $state<HistoryEntry[]>([])
|
let page = $state(1)
|
let pageInput = $state('1')
|
let pageSize = $state(PAGE_SIZE)
|
let total = $state(0)
|
let totalPages = $state(0)
|
let pageLoaded = $state(false)
|
let isPageLoading = $state(false)
|
|
let retentionDays = $state(30)
|
let statusLoaded = $state(false)
|
let storagePath = $state('')
|
let storageExists = $state(false)
|
let storageSizeAvailable = $state(false)
|
let storageSizeBytes = $state(0)
|
let totalCount = $state(0)
|
let exportedCount = $state(0)
|
let unexportedTodayCount = $state(0)
|
let unexportedLast7DaysCount = $state(0)
|
let unexportedAllCount = $state(0)
|
|
let copiedTs = $state<number | null>(null)
|
let confirmMode = $state<ConfirmMode>(null)
|
let showRetention = $state(false)
|
let showExportMenu = $state(false)
|
let showClearMenu = $state(false)
|
let storagePathCopied = $state(false)
|
let statusText = $state('')
|
let statusTone = $state<'success' | 'error' | 'info'>('info')
|
let isExportingCorrectionCsv = $state(false)
|
let isClearingExported = $state(false)
|
let isClearingAll = $state(false)
|
let isOpeningFolder = $state(false)
|
let deletingTs = $state<number | null>(null)
|
|
let pageRequestSeq = 0
|
|
const retentionOptions = [
|
{ value: 7, label: () => t('history.days7') },
|
{ value: 30, label: () => t('history.days30') },
|
{ value: 90, label: () => t('history.days90') },
|
{ value: 0, label: () => t('history.forever') },
|
]
|
|
const exportScopeOptions: { value: ExportScope; label: () => string; count: () => number }[] = [
|
{ value: 'today', label: () => t('history.exportTodayUnprocessed'), count: () => unexportedTodayCount },
|
{ value: 'last7Days', label: () => t('history.exportLast7DaysUnprocessed'), count: () => unexportedLast7DaysCount },
|
{ value: 'all', label: () => t('history.exportAllUnprocessed'), count: () => unexportedAllCount },
|
]
|
|
function currentRetentionLabel(): string {
|
const opt = retentionOptions.find(o => o.value === retentionDays)
|
return opt ? opt.label() : t('history.days30')
|
}
|
|
function hasHistory(): boolean {
|
return totalCount > 0
|
}
|
|
function hasPagination(): boolean {
|
return totalPages > 1
|
}
|
|
function canExportAny(): boolean {
|
return unexportedAllCount > 0
|
}
|
|
function canGoPrev(): boolean {
|
return page > 1
|
}
|
|
function canGoNext(): boolean {
|
return totalPages > 0 && page < totalPages
|
}
|
|
async function loadPage(targetPage = page) {
|
const seq = ++pageRequestSeq
|
isPageLoading = true
|
try {
|
const result: HistoryPageResult = await Call.ByName(
|
'voicesnap/services.HistoryService.GetPage',
|
targetPage,
|
PAGE_SIZE,
|
)
|
if (seq !== pageRequestSeq) return
|
entries = result?.entries || []
|
total = result?.total || 0
|
page = result?.page || 1
|
pageInput = String(page)
|
pageSize = result?.pageSize || PAGE_SIZE
|
totalPages = result?.totalPages || 0
|
pageLoaded = true
|
} catch {
|
if (seq !== pageRequestSeq) return
|
entries = []
|
total = 0
|
page = 1
|
pageInput = '1'
|
totalPages = 0
|
pageLoaded = true
|
flash(t('history.loadFailed'), 'error')
|
} finally {
|
if (seq === pageRequestSeq) {
|
isPageLoading = false
|
}
|
}
|
}
|
|
async function loadStatus() {
|
try {
|
const status: HistoryStatus = await Call.ByName('voicesnap/services.HistoryService.GetStatus')
|
retentionDays = status?.retentionDays ?? 30
|
storagePath = status?.storagePath || ''
|
storageExists = !!status?.storageExists
|
storageSizeAvailable = !!status?.storageSizeAvailable
|
storageSizeBytes = status?.storageSizeBytes || 0
|
totalCount = status?.totalCount || 0
|
exportedCount = status?.exportedCount || 0
|
unexportedTodayCount = status?.unexportedTodayCount || 0
|
unexportedLast7DaysCount = status?.unexportedLast7DaysCount || 0
|
unexportedAllCount = status?.unexportedAllCount || 0
|
} catch {
|
storageSizeAvailable = false
|
totalCount = total
|
}
|
statusLoaded = true
|
}
|
|
async function refresh(targetPage = page) {
|
await loadStatus()
|
await loadPage(targetPage)
|
if (totalPages > 0 && page > totalPages) {
|
await loadPage(totalPages)
|
}
|
}
|
|
async function loadInitial() {
|
await loadStatus()
|
await loadPage(1)
|
}
|
|
async function setRetention(days: number) {
|
const previous = retentionDays
|
retentionDays = days
|
showRetention = false
|
try {
|
await Call.ByName('voicesnap/services.HistoryService.SetRetentionDays', days)
|
await refresh(1)
|
} catch {
|
retentionDays = previous
|
flash(t('history.retentionFailed'), 'error')
|
}
|
}
|
|
async function copyStoragePath() {
|
if (!storagePath) return
|
try {
|
await navigator.clipboard.writeText(storagePath)
|
storagePathCopied = true
|
setTimeout(() => { storagePathCopied = false }, 1500)
|
} catch {}
|
}
|
|
async function openStorageFolder() {
|
if (isOpeningFolder) return
|
isOpeningFolder = true
|
try {
|
await Call.ByName('voicesnap/services.HistoryService.OpenStorageFolder')
|
} catch {
|
flash(t('history.openFolderFailed'), 'error')
|
} finally {
|
isOpeningFolder = false
|
}
|
}
|
|
async function copyText(entry: HistoryEntry) {
|
try {
|
await navigator.clipboard.writeText(entry.text)
|
copiedTs = entry.timestamp
|
setTimeout(() => { if (copiedTs === entry.timestamp) copiedTs = null }, 1500)
|
} catch {}
|
}
|
|
function flash(message: string, tone: 'success' | 'error' | 'info' = 'info') {
|
statusText = message
|
statusTone = tone
|
setTimeout(() => {
|
if (statusText === message) statusText = ''
|
}, 2600)
|
}
|
|
async function exportCorrectionCsv(scope: ExportScope) {
|
const option = exportScopeOptions.find(opt => opt.value === scope)
|
if (!option || option.count() === 0) {
|
flash(t('history.exportCorrectionCsvNoEntries'), 'info')
|
return
|
}
|
if (isExportingCorrectionCsv) return
|
|
showExportMenu = false
|
isExportingCorrectionCsv = true
|
try {
|
const result: CorrectionCSVExportResult = await Call.ByName(
|
'voicesnap/services.CorrectionCSVService.ExportHistoryCorrectionCSVToFile',
|
scope,
|
)
|
if (result?.noEntries) {
|
flash(t('history.exportCorrectionCsvNoEntries'), 'info')
|
} else if (result?.path) {
|
flash(t('history.exportCorrectionCsvSuccess', { path: result.path }), 'success')
|
await refresh(page)
|
}
|
} catch {
|
flash(t('history.exportCorrectionCsvFailed'), 'error')
|
} finally {
|
isExportingCorrectionCsv = false
|
}
|
}
|
|
async function deleteEntry(timestamp: number) {
|
if (deletingTs !== null) return
|
deletingTs = timestamp
|
try {
|
await Call.ByName('voicesnap/services.HistoryService.Delete', timestamp)
|
await refresh(page)
|
} catch {
|
flash(t('history.deleteFailed'), 'error')
|
} finally {
|
deletingTs = null
|
}
|
}
|
|
async function clearAll() {
|
if (isClearingAll) return
|
confirmMode = null
|
showClearMenu = false
|
isClearingAll = true
|
try {
|
await Call.ByName('voicesnap/services.HistoryService.ClearAll')
|
await refresh(1)
|
flash(t('history.clearAllSuccess'), 'success')
|
} catch {
|
flash(t('history.clearFailed'), 'error')
|
} finally {
|
isClearingAll = false
|
}
|
}
|
|
async function clearExported() {
|
if (isClearingExported) return
|
confirmMode = null
|
showClearMenu = false
|
isClearingExported = true
|
try {
|
const result: ClearExportedResult = await Call.ByName('voicesnap/services.HistoryService.ClearExported')
|
await refresh(page)
|
if ((result?.deletedCount || 0) === 0) {
|
flash(t('history.clearExportedNoEntries'), 'info')
|
} else {
|
flash(t('history.clearExportedSuccess', { count: String(result.deletedCount) }), 'success')
|
}
|
} catch {
|
flash(t('history.clearFailed'), 'error')
|
} finally {
|
isClearingExported = false
|
}
|
}
|
|
function goToPage(targetPage: number) {
|
if (isPageLoading) return
|
loadPage(targetPage)
|
}
|
|
function commitPageInput() {
|
const parsed = Number.parseInt(pageInput.trim(), 10)
|
if (!Number.isFinite(parsed)) {
|
pageInput = String(page)
|
return
|
}
|
const max = totalPages > 0 ? totalPages : 1
|
const target = Math.min(Math.max(parsed, 1), max)
|
pageInput = String(target)
|
if (target !== page) {
|
goToPage(target)
|
}
|
}
|
|
function formatTime(ts: number): string {
|
const date = new Date(ts)
|
const now = new Date()
|
const hours = date.getHours().toString().padStart(2, '0')
|
const mins = date.getMinutes().toString().padStart(2, '0')
|
const time = `${hours}:${mins}`
|
|
const isToday = date.toDateString() === now.toDateString()
|
if (isToday) return time
|
|
const yesterday = new Date(now)
|
yesterday.setDate(yesterday.getDate() - 1)
|
if (date.toDateString() === yesterday.toDateString()) {
|
return `${t('history.yesterday')} ${time}`
|
}
|
|
const month = (date.getMonth() + 1).toString()
|
const day = date.getDate().toString()
|
return `${month}/${day} ${time}`
|
}
|
|
function displayPath(path: string): string {
|
if (!path || path.length <= 64) return path
|
const head = path.slice(0, 24)
|
const tail = path.slice(-34)
|
return `${head}......${tail}`
|
}
|
|
function formatSize(): string {
|
if (!storageExists || !storageSizeAvailable) return t('history.sizeUnknown')
|
if (storageSizeBytes < 1024) return `${storageSizeBytes} B`
|
if (storageSizeBytes < MB) return `${(storageSizeBytes / 1024).toFixed(1)} KB`
|
return `${(storageSizeBytes / MB).toFixed(1)} MB`
|
}
|
|
function storageSizeTone(): 'normal' | 'warning' | 'danger' {
|
if (!storageExists || !storageSizeAvailable) return 'normal'
|
if (storageSizeBytes >= 100 * MB) return 'danger'
|
if (storageSizeBytes >= 10 * MB) return 'warning'
|
return 'normal'
|
}
|
|
function closeDropdowns(e: MouseEvent) {
|
const target = e.target as HTMLElement
|
if (!target.closest('.retention-selector')) {
|
showRetention = false
|
}
|
if (!target.closest('.export-selector')) {
|
showExportMenu = false
|
}
|
if (!target.closest('.clear-selector')) {
|
showClearMenu = false
|
}
|
}
|
|
loadInitial()
|
</script>
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<div class="page" onclick={closeDropdowns}>
|
<div class="toolbar">
|
<div class="toolbar-left">
|
<span class="toolbar-label">{t('history.retention')}</span>
|
<div class="retention-selector">
|
{#if statusLoaded}
|
<button class="retention-btn" onclick={() => showRetention = !showRetention}>
|
<span>{currentRetentionLabel()}</span>
|
<svg class="chevron" class:open={showRetention} width="10" height="6" viewBox="0 0 10 6" fill="none">
|
<path d="M1 1L5 5L9 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
</svg>
|
</button>
|
{#if showRetention}
|
<div class="retention-dropdown">
|
{#each retentionOptions as opt}
|
<button
|
class="retention-option"
|
class:selected={retentionDays === opt.value}
|
onclick={() => setRetention(opt.value)}
|
>
|
{opt.label()}
|
{#if retentionDays === opt.value}
|
<svg width="12" height="9" viewBox="0 0 12 9" fill="none">
|
<path d="M1 4L4.5 7.5L11 1" stroke="var(--color-blue)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
</svg>
|
{/if}
|
</button>
|
{/each}
|
</div>
|
{/if}
|
{:else}
|
<span class="retention-btn"><span>...</span></span>
|
{/if}
|
</div>
|
</div>
|
|
{#if storagePath}
|
<div class="storage-path" title={storagePath}>
|
<span class="storage-label">{t('history.storageLocation')}</span>
|
<button class="storage-link" onclick={copyStoragePath} title={storagePath}>
|
<span>{displayPath(storagePath)}</span>
|
</button>
|
<span
|
class="storage-size"
|
class:warning={storageSizeTone() === 'warning'}
|
class:danger={storageSizeTone() === 'danger'}
|
title={storageSizeAvailable ? `${storageSizeBytes} bytes` : t('history.sizeUnknown')}
|
>
|
{t('history.storageSize', { size: formatSize() })}
|
</span>
|
<button
|
class="storage-icon-btn"
|
onclick={copyStoragePath}
|
title={storagePathCopied ? t('history.pathCopied') : t('history.copyPath')}
|
aria-label={storagePathCopied ? t('history.pathCopied') : t('history.copyPath')}
|
>
|
{#if storagePathCopied}
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
<path d="M2 7L5.5 10.5L12 4" stroke="var(--color-green)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
</svg>
|
{:else}
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
<rect x="5" y="5" width="7.5" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
|
<path d="M9 5V3C9 2.17 8.33 1.5 7.5 1.5H3C2.17 1.5 1.5 2.17 1.5 3V7.5C1.5 8.33 2.17 9 3 9H5" stroke="currentColor" stroke-width="1.2"/>
|
</svg>
|
{/if}
|
</button>
|
<button
|
class="storage-icon-btn"
|
onclick={openStorageFolder}
|
disabled={isOpeningFolder}
|
title={t('history.openFolder')}
|
aria-label={t('history.openFolder')}
|
>
|
<svg width="15" height="14" viewBox="0 0 15 14" fill="none">
|
<path d="M1.5 4.5H13.5V10.5C13.5 11.33 12.83 12 12 12H3C2.17 12 1.5 11.33 1.5 10.5V4.5Z" stroke="currentColor" stroke-width="1.2"/>
|
<path d="M1.5 4.8V3.5C1.5 2.67 2.17 2 3 2H5.4L6.6 3.2H12C12.83 3.2 13.5 3.87 13.5 4.7V4.8" stroke="currentColor" stroke-width="1.2"/>
|
</svg>
|
</button>
|
</div>
|
{/if}
|
|
<div class="toolbar-right">
|
<div class="export-selector">
|
<button
|
class="export-btn"
|
onclick={() => canExportAny() ? showExportMenu = !showExportMenu : flash(t('history.exportCorrectionCsvNoEntries'), 'info')}
|
disabled={isExportingCorrectionCsv || !statusLoaded}
|
title={canExportAny() ? t('history.exportCorrectionCsvPrivacy') : t('history.exportCorrectionCsvNoEntries')}
|
>
|
<span>{isExportingCorrectionCsv ? t('history.exportingCorrectionCsv') : t('history.exportCorrectionCsv')}</span>
|
<svg class="chevron" class:open={showExportMenu} width="10" height="6" viewBox="0 0 10 6" fill="none">
|
<path d="M1 1L5 5L9 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
</svg>
|
</button>
|
{#if showExportMenu}
|
<div class="export-dropdown">
|
{#each exportScopeOptions as opt}
|
<button
|
class="export-option"
|
disabled={isExportingCorrectionCsv || opt.count() === 0}
|
title={opt.count() === 0 ? t('history.exportCorrectionCsvNoEntries') : ''}
|
onclick={() => exportCorrectionCsv(opt.value)}
|
>
|
<span>{opt.label()}</span>
|
<span class="option-count">{opt.count()}</span>
|
</button>
|
{/each}
|
</div>
|
{/if}
|
</div>
|
</div>
|
</div>
|
|
{#if statusText}
|
<div class="status-line" class:success={statusTone === 'success'} class:error={statusTone === 'error'} title={statusText}>
|
{statusText}
|
</div>
|
{/if}
|
|
<div class="section list-section">
|
{#if !pageLoaded || isPageLoading}
|
<div class="empty">
|
<p class="empty-title">{t('history.loading')}</p>
|
</div>
|
{:else if totalCount === 0}
|
<div class="empty">
|
<p class="empty-title">{t('history.empty')}</p>
|
<p class="empty-desc">{t('history.emptyDesc')}</p>
|
</div>
|
{:else if entries.length === 0}
|
<div class="empty">
|
<p class="empty-title">{t('history.pageEmpty')}</p>
|
</div>
|
{:else}
|
{#each entries as entry, i}
|
{#if i > 0}
|
<div class="divider"></div>
|
{/if}
|
<div class="history-row">
|
<div class="history-content">
|
<span class="history-time">{formatTime(entry.timestamp)}</span>
|
<span class="history-text">{entry.text}</span>
|
</div>
|
<div class="history-actions">
|
<button
|
class="action-btn copy-btn"
|
onclick={() => copyText(entry)}
|
title={copiedTs === entry.timestamp ? t('history.copied') : t('history.copy')}
|
aria-label={copiedTs === entry.timestamp ? t('history.copied') : t('history.copy')}
|
>
|
{#if copiedTs === entry.timestamp}
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
<path d="M2 7L5.5 10.5L12 4" stroke="var(--color-green)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
</svg>
|
{:else}
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
<rect x="5" y="5" width="7.5" height="7.5" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
|
<path d="M9 5V3C9 2.17 8.33 1.5 7.5 1.5H3C2.17 1.5 1.5 2.17 1.5 3V7.5C1.5 8.33 2.17 9 3 9H5" stroke="currentColor" stroke-width="1.2"/>
|
</svg>
|
{/if}
|
</button>
|
<button
|
class="action-btn delete-btn"
|
onclick={() => deleteEntry(entry.timestamp)}
|
disabled={deletingTs !== null}
|
title={t('history.delete')}
|
aria-label={t('history.delete')}
|
>
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
<path d="M3 3.5L11 11.5M11 3.5L3 11.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
|
</svg>
|
</button>
|
</div>
|
</div>
|
{/each}
|
{/if}
|
</div>
|
|
{#if hasHistory()}
|
<div class="bottom-bar">
|
<div class="bottom-left">
|
<div class="clear-selector">
|
<button class="clear-menu-btn" onclick={() => showClearMenu = !showClearMenu}>
|
<span>{t('history.clearMenu')}</span>
|
<svg class="chevron" class:open={showClearMenu} width="10" height="6" viewBox="0 0 10 6" fill="none">
|
<path d="M1 1L5 5L9 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
</svg>
|
</button>
|
{#if showClearMenu}
|
<div class="clear-dropdown">
|
<button
|
class="clear-option"
|
disabled={exportedCount === 0 || isClearingExported}
|
onclick={() => { confirmMode = 'clearExported'; showClearMenu = false }}
|
>
|
<span>{t('history.clearExported')}</span>
|
<span class="option-count">{exportedCount}</span>
|
</button>
|
<button
|
class="clear-option danger-option"
|
disabled={isClearingAll}
|
onclick={() => { confirmMode = 'clearAll'; showClearMenu = false }}
|
>
|
{t('history.clearAll')}
|
</button>
|
</div>
|
{/if}
|
</div>
|
|
{#if confirmMode}
|
<div class="confirm-line">
|
<span>
|
{confirmMode === 'clearExported' ? t('history.clearExportedConfirm') : t('history.clearConfirm')}
|
</span>
|
<button
|
class="confirm-action"
|
onclick={confirmMode === 'clearExported' ? clearExported : clearAll}
|
>
|
{confirmMode === 'clearExported' ? t('history.clearExported') : t('history.clearAll')}
|
</button>
|
<button class="cancel-btn" onclick={() => confirmMode = null}>×</button>
|
</div>
|
{/if}
|
</div>
|
|
{#if hasPagination()}
|
<div class="pagination">
|
<button class="page-btn" onclick={() => goToPage(1)} disabled={!canGoPrev() || isPageLoading}>{t('history.firstPage')}</button>
|
<button class="page-btn" onclick={() => goToPage(page - 1)} disabled={!canGoPrev() || isPageLoading}>{t('history.prevPage')}</button>
|
<span class="page-input-wrap">
|
<input
|
class="page-input"
|
value={pageInput}
|
oninput={(e) => pageInput = (e.currentTarget as HTMLInputElement).value}
|
onkeydown={(e) => { if (e.key === 'Enter') commitPageInput() }}
|
onblur={commitPageInput}
|
aria-label={t('history.pageInput')}
|
/>
|
<span>{t('history.totalPages', { total: String(totalPages) })}</span>
|
</span>
|
<span class="page-total">{t('history.totalEntries', { total: String(total) })}</span>
|
<button class="page-btn" onclick={() => goToPage(page + 1)} disabled={!canGoNext() || isPageLoading}>{t('history.nextPage')}</button>
|
<button class="page-btn" onclick={() => goToPage(totalPages)} disabled={!canGoNext() || isPageLoading}>{t('history.lastPage')}</button>
|
</div>
|
{/if}
|
</div>
|
{/if}
|
</div>
|
|
<style>
|
.page {
|
display: flex;
|
flex-direction: column;
|
height: calc(100vh - 40px);
|
}
|
|
.toolbar {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
gap: 12px;
|
margin-bottom: var(--spacing-md);
|
padding: 0 2px;
|
}
|
|
.toolbar-left,
|
.toolbar-right {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
flex-shrink: 0;
|
}
|
|
.toolbar-label {
|
font-size: var(--font-size-sm);
|
color: var(--color-secondary-label);
|
}
|
|
.storage-path {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
gap: 6px;
|
min-width: 0;
|
flex: 1;
|
}
|
|
.storage-label,
|
.storage-size {
|
font-size: var(--font-size-xs);
|
color: var(--color-tertiary-label);
|
white-space: nowrap;
|
flex-shrink: 0;
|
}
|
|
.storage-size.warning {
|
color: var(--color-orange);
|
}
|
|
.storage-size.danger {
|
color: var(--color-red);
|
font-weight: 600;
|
}
|
|
.storage-link {
|
display: block;
|
min-width: 96px;
|
max-width: 420px;
|
padding: 3px 0;
|
background: transparent;
|
border: none;
|
font-size: var(--font-size-xs);
|
color: var(--color-tertiary-label);
|
cursor: pointer;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
text-align: left;
|
}
|
|
.storage-link:hover {
|
color: var(--color-blue);
|
}
|
|
.storage-icon-btn {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
width: 24px;
|
height: 24px;
|
border: none;
|
background: transparent;
|
border-radius: var(--radius-sm);
|
color: var(--color-tertiary-label);
|
cursor: pointer;
|
flex-shrink: 0;
|
}
|
|
.storage-icon-btn:hover:not(:disabled) {
|
color: var(--color-blue);
|
background: var(--color-bg-secondary);
|
}
|
|
.storage-icon-btn:disabled {
|
opacity: 0.5;
|
cursor: default;
|
}
|
|
.retention-selector,
|
.export-selector,
|
.clear-selector {
|
position: relative;
|
}
|
|
.retention-btn,
|
.export-btn,
|
.clear-menu-btn {
|
display: flex;
|
align-items: center;
|
gap: 4px;
|
padding: 3px 0;
|
background: transparent;
|
border: none;
|
font-size: var(--font-size-sm);
|
font-weight: 500;
|
color: var(--color-blue);
|
cursor: pointer;
|
transition: opacity var(--transition-fast);
|
}
|
|
.clear-menu-btn {
|
color: var(--color-tertiary-label);
|
}
|
|
.retention-btn:hover,
|
.export-btn:hover,
|
.clear-menu-btn:hover {
|
opacity: 0.65;
|
}
|
|
.export-btn:disabled {
|
color: var(--color-tertiary-label);
|
cursor: default;
|
opacity: 0.5;
|
}
|
|
.chevron {
|
color: var(--color-tertiary-label);
|
transition: transform 0.2s ease;
|
flex-shrink: 0;
|
}
|
|
.chevron.open {
|
transform: rotate(180deg);
|
}
|
|
.retention-dropdown,
|
.export-dropdown,
|
.clear-dropdown {
|
position: absolute;
|
top: calc(100% + 4px);
|
min-width: 120px;
|
background: var(--color-bg-grouped-secondary);
|
border: 1px solid var(--color-separator);
|
border-radius: var(--radius-sm);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
z-index: 100;
|
overflow: hidden;
|
}
|
|
.retention-dropdown {
|
left: 0;
|
}
|
|
.export-dropdown,
|
.clear-dropdown {
|
right: 0;
|
}
|
|
.export-dropdown {
|
min-width: 210px;
|
}
|
|
.clear-dropdown {
|
min-width: 245px;
|
}
|
|
.retention-option,
|
.export-option,
|
.clear-option {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
gap: 12px;
|
width: 100%;
|
padding: 7px 12px;
|
background: transparent;
|
border: none;
|
font-size: var(--font-size-sm);
|
color: var(--color-label);
|
cursor: pointer;
|
text-align: left;
|
transition: background 0.15s ease;
|
}
|
|
.retention-option:hover,
|
.export-option:hover:not(:disabled),
|
.clear-option:hover:not(:disabled) {
|
background: var(--color-bg-secondary);
|
}
|
|
.export-option:disabled,
|
.clear-option:disabled {
|
cursor: default;
|
opacity: 0.5;
|
}
|
|
.clear-option {
|
white-space: nowrap;
|
}
|
|
.danger-option {
|
color: var(--color-red);
|
}
|
|
.retention-option.selected {
|
color: var(--color-blue);
|
font-weight: 500;
|
}
|
|
.retention-option + .retention-option,
|
.export-option + .export-option,
|
.clear-option + .clear-option {
|
border-top: 1px solid var(--color-separator);
|
}
|
|
.option-count {
|
color: var(--color-tertiary-label);
|
font-size: var(--font-size-xs);
|
font-variant-numeric: tabular-nums;
|
}
|
|
.confirm-line {
|
display: flex;
|
align-items: center;
|
justify-content: flex-start;
|
gap: 8px;
|
min-height: 28px;
|
min-width: 0;
|
font-size: var(--font-size-xs);
|
color: var(--color-secondary-label);
|
}
|
|
.confirm-line span {
|
max-width: 520px;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
|
.confirm-action {
|
padding: 3px 0;
|
background: transparent;
|
border: none;
|
color: var(--color-red);
|
font-size: var(--font-size-xs);
|
font-weight: 600;
|
cursor: pointer;
|
white-space: nowrap;
|
}
|
|
.cancel-btn {
|
padding: 0 4px;
|
background: transparent;
|
border: none;
|
font-size: var(--font-size-base);
|
color: var(--color-tertiary-label);
|
cursor: pointer;
|
line-height: 1;
|
}
|
|
.cancel-btn:hover {
|
color: var(--color-label);
|
}
|
|
.status-line {
|
min-height: 18px;
|
margin: -6px 2px var(--spacing-sm);
|
font-size: var(--font-size-xs);
|
color: var(--color-secondary-label);
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
|
.status-line.success {
|
color: var(--color-green);
|
}
|
|
.status-line.error {
|
color: var(--color-red);
|
}
|
|
.section {
|
background: var(--color-bg-grouped-secondary);
|
border-radius: var(--radius-md);
|
padding: var(--spacing-lg);
|
}
|
|
.list-section {
|
flex: 1;
|
min-height: 0;
|
overflow-y: auto;
|
}
|
|
.empty {
|
text-align: center;
|
padding: 40px 0;
|
}
|
|
.empty-title {
|
font-size: var(--font-size-base);
|
color: var(--color-secondary-label);
|
font-weight: 500;
|
}
|
|
.empty-desc {
|
font-size: var(--font-size-xs);
|
color: var(--color-tertiary-label);
|
margin-top: 6px;
|
}
|
|
.history-row {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
padding: 8px 0;
|
gap: 12px;
|
}
|
|
.history-content {
|
display: flex;
|
align-items: baseline;
|
gap: 10px;
|
min-width: 0;
|
flex: 1;
|
}
|
|
.history-time {
|
font-size: var(--font-size-xs);
|
color: var(--color-tertiary-label);
|
white-space: nowrap;
|
flex-shrink: 0;
|
min-width: 40px;
|
}
|
|
.history-text {
|
font-size: var(--font-size-sm);
|
color: var(--color-label);
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
|
.history-actions {
|
display: flex;
|
gap: 4px;
|
flex-shrink: 0;
|
opacity: 0;
|
transition: opacity 0.15s ease;
|
}
|
|
.history-row:hover .history-actions {
|
opacity: 1;
|
}
|
|
.action-btn {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
width: 28px;
|
height: 28px;
|
border: none;
|
background: transparent;
|
border-radius: var(--radius-sm);
|
color: var(--color-tertiary-label);
|
cursor: pointer;
|
transition: all 0.15s ease;
|
}
|
|
.action-btn:hover:not(:disabled) {
|
background: var(--color-bg-secondary);
|
}
|
|
.action-btn:disabled {
|
opacity: 0.45;
|
cursor: default;
|
}
|
|
.copy-btn:hover {
|
color: var(--color-blue);
|
}
|
|
.delete-btn:hover {
|
color: var(--color-red);
|
}
|
|
.divider {
|
height: 1px;
|
background: var(--color-separator);
|
}
|
|
.bottom-bar {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
gap: 8px;
|
padding: 10px 4px 0;
|
flex-shrink: 0;
|
}
|
|
.bottom-left {
|
display: flex;
|
align-items: center;
|
gap: 10px;
|
min-width: 0;
|
}
|
|
.bottom-bar .clear-dropdown {
|
top: auto;
|
bottom: calc(100% + 4px);
|
left: 0;
|
right: auto;
|
}
|
|
.pagination {
|
display: flex;
|
align-items: center;
|
justify-content: flex-end;
|
gap: 8px;
|
color: var(--color-secondary-label);
|
font-size: var(--font-size-xs);
|
flex-shrink: 0;
|
}
|
|
.page-btn {
|
height: 26px;
|
padding: 0 8px;
|
border: none;
|
border-radius: var(--radius-sm);
|
background: transparent;
|
color: var(--color-blue);
|
cursor: pointer;
|
font-size: var(--font-size-xs);
|
}
|
|
.page-btn:hover:not(:disabled) {
|
background: var(--color-bg-secondary);
|
}
|
|
.page-btn:disabled {
|
color: var(--color-tertiary-label);
|
cursor: default;
|
opacity: 0.55;
|
}
|
|
.page-input-wrap {
|
display: flex;
|
align-items: center;
|
gap: 5px;
|
}
|
|
.page-input {
|
width: 42px;
|
height: 24px;
|
padding: 0 6px;
|
border: 1px solid var(--color-separator);
|
border-radius: var(--radius-sm);
|
background: var(--color-bg-grouped-secondary);
|
color: var(--color-label);
|
font-size: var(--font-size-xs);
|
text-align: center;
|
}
|
|
.page-input:focus {
|
outline: none;
|
border-color: var(--color-blue);
|
}
|
|
.page-total {
|
color: var(--color-tertiary-label);
|
}
|
</style>
|