mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
cc50b55eeb
Add original and text-only EPUB delivery choices across Kobo, KOReader, OPDS, web downloads and email while keeping source files unchanged. Persist server defaults, device overrides and captured artifact identities so retries and guided replacement retain the selected rendition. Preserve saved progress across first opens, replacement resets and stale updates, and project annotations against the copy delivered to each device. Handle XML resource references, large inodes, retry backoff and background worker state correctly. Consolidate the schema changes into one migration and retain released-client request and response compatibility. Verify with the full server and client checks, delivery and sync HTTP suites, independent code reviews, and live KOReader download/settings/reader proof. Fixes #1496 Closes #1603 Closes #1714 Fixes #1723 Ref #1633 Ref #1508 Ref #1621 Ref #1136
1215 lines
49 KiB
Vue
1215 lines
49 KiB
Vue
<script setup lang="ts">
|
|
import KoboReplacement from '@/features/delivery/components/KoboReplacement.vue'
|
|
import ReadAloudDeliverySelect from '@/features/delivery/components/ReadAloudDeliverySelect.vue'
|
|
import { useReadAloudDelivery } from '@/features/delivery/composables/useReadAloudDelivery'
|
|
import type { ReadAloudDeliveryPreference } from '@bookorbit/types'
|
|
import { Button } from '@/components/ui/button'
|
|
import { computed, onMounted, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { formatDate } from '@/i18n/formatters'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { Plus, Trash2, Copy, Check, Pencil, X, Tablet, RefreshCw, History, Download, Upload, Bookmark, Info } from '@lucide/vue'
|
|
import { toast } from 'vue-sonner'
|
|
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
|
import SettingsTabs from './components/SettingsTabs.vue'
|
|
import { copyToClipboard } from '@/lib/clipboard'
|
|
import { useKoboDevices } from '@/features/kobo/composables/useKoboDevices'
|
|
import { useKoboSettings } from '@/features/kobo/composables/useKoboSettings'
|
|
import { useKoboSyncHistory } from '@/features/kobo/composables/useKoboSyncHistory'
|
|
import type { KoboDevice, KoboSyncHistoryEntry } from '@bookorbit/types'
|
|
|
|
const { t } = useI18n()
|
|
const { supported: deliverySupported } = useReadAloudDelivery()
|
|
const readAloudDelivery = ref<ReadAloudDeliveryPreference>(null)
|
|
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
|
|
embedded: false,
|
|
})
|
|
|
|
const { devices, fetchDevices, createDevice, renameDevice, revokeDevice } = useKoboDevices()
|
|
const { settings, fetchSettings, updateSettings } = useKoboSettings()
|
|
const { history: syncHistory, loading: historyLoading, fetchHistory } = useKoboSyncHistory()
|
|
|
|
const loading = ref(true)
|
|
const error = ref<string | null>(null)
|
|
|
|
// Create device
|
|
const showCreateForm = ref(false)
|
|
const newDeviceName = ref('')
|
|
const firstDeviceDelivery = ref<ReadAloudDeliveryPreference>('audioless_epub')
|
|
const creating = ref(false)
|
|
const createError = ref<string | null>(null)
|
|
|
|
// New device token display
|
|
const newDeviceToken = ref<string | null>(null)
|
|
const newDeviceSyncUrl = ref<string | null>(null)
|
|
|
|
// Rename
|
|
const renamingId = ref<number | null>(null)
|
|
const renameValue = ref('')
|
|
const renaming = ref(false)
|
|
|
|
// Settings
|
|
const readingThreshold = ref(1)
|
|
const finishedThreshold = ref(99)
|
|
const convertToKepub = ref(true)
|
|
const forceEnableHyphenation = ref(false)
|
|
const kepubConversionLimitMb = ref(100)
|
|
const twoWayProgressSync = ref(false)
|
|
const syncBookOrbitAnnotationsToKobo = ref(false)
|
|
const storeSync = ref(false)
|
|
const savingSettings = ref(false)
|
|
const settingsError = ref<string | null>(null)
|
|
const refreshingHistory = ref(false)
|
|
const historyFilter = ref<'all' | 'failures'>('all')
|
|
const currentLimit = ref(20)
|
|
const loadingMore = ref(false)
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
type Tab = 'settings' | 'activity'
|
|
type HistoryDisplayItem = {
|
|
key: string
|
|
primary: KoboSyncHistoryEntry
|
|
entries: KoboSyncHistoryEntry[]
|
|
}
|
|
|
|
const progressGroupWindowMs = 5 * 60 * 1000
|
|
const activeTab = ref<Tab>((route.query.tab as Tab) === 'activity' ? 'activity' : 'settings')
|
|
const tabs = computed(() => [
|
|
{
|
|
id: 'settings' as const,
|
|
label: t('settings.reader.kobo.tabs.syncSettings'),
|
|
},
|
|
{
|
|
id: 'activity' as const,
|
|
label: t('settings.reader.kobo.tabs.activityLog'),
|
|
},
|
|
])
|
|
|
|
function selectTab(tab: Tab) {
|
|
activeTab.value = tab
|
|
router.replace({ name: 'settings-kobo', query: { ...route.query, tab } })
|
|
}
|
|
|
|
watch(
|
|
() => route.query.tab,
|
|
(value) => {
|
|
activeTab.value = (value as Tab) === 'activity' ? 'activity' : 'settings'
|
|
},
|
|
)
|
|
|
|
const hasSyncHistory = computed(() => syncHistory.value.length > 0)
|
|
const latestHistoryEntry = computed(() => syncHistory.value[0] ?? null)
|
|
const latestSuccessfulActivity = computed(() => syncHistory.value.find((entry) => entry.status === 'success') ?? null)
|
|
const latestFailedActivity = computed(() => syncHistory.value.find((entry) => entry.status === 'failed') ?? null)
|
|
const recentFailureCount = computed(() => syncHistory.value.filter((entry) => entry.status === 'failed').length)
|
|
const filteredSyncHistory = computed(() =>
|
|
historyFilter.value === 'failures' ? syncHistory.value.filter((entry) => entry.status === 'failed') : syncHistory.value,
|
|
)
|
|
const displayedSyncHistory = computed(() => buildHistoryDisplayItems(filteredSyncHistory.value))
|
|
const syncHealthLabel = computed(() => {
|
|
if (!latestHistoryEntry.value) return t('settings.reader.kobo.activity.waitingForActivity')
|
|
return latestHistoryEntry.value.status === 'failed'
|
|
? t('settings.reader.kobo.activity.needsAttention')
|
|
: t('settings.reader.kobo.activity.syncHealthy')
|
|
})
|
|
const latestSuccessLabel = computed(() =>
|
|
latestSuccessfulActivity.value ? formatLastSeen(latestSuccessfulActivity.value.createdAt) : t('settings.reader.kobo.activity.never'),
|
|
)
|
|
const latestFailureLabel = computed(() =>
|
|
latestFailedActivity.value ? formatLastSeen(latestFailedActivity.value.createdAt) : t('settings.reader.kobo.activity.none'),
|
|
)
|
|
const recentFailureLabel = computed(() =>
|
|
t('settings.reader.kobo.activity.failureCount', {
|
|
count: recentFailureCount.value,
|
|
}),
|
|
)
|
|
const historyHeaderDetail = computed(() => {
|
|
if (!latestHistoryEntry.value) return t('settings.reader.kobo.activity.noActivityRecorded')
|
|
const parts = [
|
|
syncHealthLabel.value,
|
|
t('settings.reader.kobo.activity.lastSuccess', {
|
|
time: latestSuccessLabel.value,
|
|
}),
|
|
]
|
|
if (latestFailedActivity.value)
|
|
parts.push(
|
|
t('settings.reader.kobo.activity.lastFailure', {
|
|
time: latestFailureLabel.value,
|
|
}),
|
|
)
|
|
if (recentFailureCount.value > 0) parts.push(recentFailureLabel.value)
|
|
return parts.join(' · ')
|
|
})
|
|
const filteredHistoryEmptyLabel = computed(() =>
|
|
historyFilter.value === 'failures' ? t('settings.reader.kobo.activity.noFailedActivity') : t('settings.reader.kobo.activity.noActivityYet'),
|
|
)
|
|
|
|
function applySettingsToLocal() {
|
|
readingThreshold.value = settings.value.readingThreshold
|
|
finishedThreshold.value = settings.value.finishedThreshold
|
|
convertToKepub.value = settings.value.convertToKepub
|
|
forceEnableHyphenation.value = settings.value.forceEnableHyphenation
|
|
kepubConversionLimitMb.value = settings.value.kepubConversionLimitMb
|
|
twoWayProgressSync.value = settings.value.twoWayProgressSync
|
|
syncBookOrbitAnnotationsToKobo.value = settings.value.syncBookOrbitAnnotationsToKobo
|
|
storeSync.value = settings.value.storeSync
|
|
readAloudDelivery.value = settings.value.readAloudDelivery ?? null
|
|
}
|
|
|
|
function formatLastSeen(date: string | null): string {
|
|
if (!date) return t('settings.reader.kobo.never')
|
|
const d = new Date(date)
|
|
const now = new Date()
|
|
const diffMs = now.getTime() - d.getTime()
|
|
const diffMins = Math.floor(diffMs / 60000)
|
|
if (diffMins < 1) return t('settings.reader.kobo.justNow')
|
|
if (diffMins < 60) return t('settings.reader.kobo.minutesAgo', { count: diffMins })
|
|
const diffHours = Math.floor(diffMins / 60)
|
|
if (diffHours < 24) return t('settings.reader.kobo.hoursAgo', { count: diffHours })
|
|
const diffDays = Math.floor(diffHours / 24)
|
|
return t('settings.reader.kobo.daysAgo', { count: diffDays })
|
|
}
|
|
|
|
function formatDateTime(date: string | null | undefined): string {
|
|
if (!date) return t('settings.reader.kobo.activity.unknown')
|
|
return formatDate(new Date(date), {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
})
|
|
}
|
|
|
|
function buildHistoryDisplayItems(entries: KoboSyncHistoryEntry[]): HistoryDisplayItem[] {
|
|
const consumed = new Set<number>()
|
|
const items: HistoryDisplayItem[] = []
|
|
|
|
for (const entry of entries) {
|
|
if (consumed.has(entry.id)) continue
|
|
if (!canGroupProgressEntry(entry)) {
|
|
items.push({
|
|
key: `entry-${entry.id}`,
|
|
primary: entry,
|
|
entries: [entry],
|
|
})
|
|
continue
|
|
}
|
|
|
|
const group = entries.filter((candidate) => (candidate.id === entry.id || !consumed.has(candidate.id)) && canMergeProgressEntry(entry, candidate))
|
|
if (group.length <= 1) {
|
|
items.push({
|
|
key: `entry-${entry.id}`,
|
|
primary: entry,
|
|
entries: [entry],
|
|
})
|
|
continue
|
|
}
|
|
|
|
for (const candidate of group) consumed.add(candidate.id)
|
|
items.push({
|
|
key: `progress-${entry.deviceId ?? 'removed'}-${historyBookKey(entry)}-${entry.id}`,
|
|
primary: entry,
|
|
entries: group,
|
|
})
|
|
}
|
|
|
|
return items
|
|
}
|
|
|
|
function canGroupProgressEntry(entry: KoboSyncHistoryEntry): boolean {
|
|
return entry.status === 'success' && entry.event === 'progress_update'
|
|
}
|
|
|
|
function canMergeProgressEntry(anchor: KoboSyncHistoryEntry, candidate: KoboSyncHistoryEntry): boolean {
|
|
if (!canGroupProgressEntry(candidate)) return false
|
|
if (anchor.deviceId !== candidate.deviceId) return false
|
|
if (historyBookKey(anchor) !== historyBookKey(candidate)) return false
|
|
if (historyCountBoolean(anchor, 'twoWayProgressSync') !== historyCountBoolean(candidate, 'twoWayProgressSync')) return false
|
|
|
|
const anchorTime = new Date(anchor.createdAt).getTime()
|
|
const candidateTime = new Date(candidate.createdAt).getTime()
|
|
return Number.isFinite(anchorTime) && Number.isFinite(candidateTime) && Math.abs(anchorTime - candidateTime) <= progressGroupWindowMs
|
|
}
|
|
|
|
const historyEventLabels = computed<Record<KoboSyncHistoryEntry['event'], string>>(() => ({
|
|
library_sync: t('settings.reader.kobo.activity.event.librarySync'),
|
|
book_download: t('settings.reader.kobo.activity.event.bookDownload'),
|
|
progress_update: t('settings.reader.kobo.activity.event.progressUpdate'),
|
|
annotations_pull: t('settings.reader.kobo.activity.event.annotationsPull'),
|
|
annotations_push: t('settings.reader.kobo.activity.event.annotationsPush'),
|
|
}))
|
|
|
|
function formatHistoryEvent(event: KoboSyncHistoryEntry['event']): string {
|
|
return historyEventLabels.value[event]
|
|
}
|
|
|
|
function historyCountNumber(entry: KoboSyncHistoryEntry, key: string): number {
|
|
const value = entry.counts[key]
|
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
}
|
|
|
|
function historyCountBoolean(entry: KoboSyncHistoryEntry, key: string): boolean {
|
|
return entry.counts[key] === true
|
|
}
|
|
|
|
function historyCountString(entry: KoboSyncHistoryEntry, key: string): string | null {
|
|
const value = entry.counts[key]
|
|
return typeof value === 'string' && value.trim() ? value.trim() : null
|
|
}
|
|
|
|
function historyBookTitle(entry: KoboSyncHistoryEntry): string | null {
|
|
return historyCountString(entry, 'bookTitle')
|
|
}
|
|
|
|
function historyBookKey(entry: KoboSyncHistoryEntry): string {
|
|
const bookId = entry.counts.bookId
|
|
if (typeof bookId === 'number' && Number.isFinite(bookId)) return String(bookId)
|
|
const title = historyBookTitle(entry)
|
|
return title ?? 'unknown'
|
|
}
|
|
|
|
function isHistoryNoOp(entry: KoboSyncHistoryEntry): boolean {
|
|
if (entry.status !== 'success') return false
|
|
switch (entry.event) {
|
|
case 'library_sync':
|
|
return historyCountNumber(entry, 'entitlements') === 0
|
|
case 'book_download':
|
|
return historyCountNumber(entry, 'downloads') === 0
|
|
case 'progress_update':
|
|
return false
|
|
case 'annotations_pull':
|
|
return historyCountNumber(entry, 'served') === 0
|
|
case 'annotations_push':
|
|
return historyCountNumber(entry, 'created') + historyCountNumber(entry, 'updated') === 0
|
|
}
|
|
}
|
|
|
|
function historyOutcomeText(entry: KoboSyncHistoryEntry): string {
|
|
if (entry.status === 'failed') return t('settings.reader.kobo.activity.outcome.failed')
|
|
|
|
if (isHistoryNoOp(entry)) {
|
|
switch (entry.event) {
|
|
case 'library_sync':
|
|
return t('settings.reader.kobo.activity.outcome.noUpdates')
|
|
case 'annotations_pull':
|
|
case 'annotations_push':
|
|
return t('settings.reader.kobo.activity.outcome.noChanges')
|
|
}
|
|
}
|
|
|
|
switch (entry.event) {
|
|
case 'library_sync':
|
|
return historyCountBoolean(entry, 'hasMore')
|
|
? t('settings.reader.kobo.activity.outcome.pending')
|
|
: t('settings.reader.kobo.activity.outcome.completed')
|
|
case 'book_download':
|
|
return t('settings.reader.kobo.activity.outcome.downloaded')
|
|
case 'progress_update':
|
|
return t('settings.reader.kobo.activity.outcome.updated')
|
|
case 'annotations_pull':
|
|
return t('settings.reader.kobo.activity.outcome.sent')
|
|
case 'annotations_push':
|
|
return t('settings.reader.kobo.activity.outcome.imported')
|
|
}
|
|
}
|
|
|
|
function historyFailureSummary(entry: KoboSyncHistoryEntry): string {
|
|
switch (entry.event) {
|
|
case 'library_sync':
|
|
return t('settings.reader.kobo.activity.failure.librarySync')
|
|
case 'book_download':
|
|
return t('settings.reader.kobo.activity.failure.bookDownload')
|
|
case 'progress_update':
|
|
return t('settings.reader.kobo.activity.failure.progressUpdate')
|
|
case 'annotations_pull':
|
|
return t('settings.reader.kobo.activity.failure.annotationsPull')
|
|
case 'annotations_push':
|
|
return t('settings.reader.kobo.activity.failure.annotationsPush')
|
|
}
|
|
}
|
|
|
|
function historyDetailChips(entry: KoboSyncHistoryEntry): string[] {
|
|
switch (entry.event) {
|
|
case 'library_sync': {
|
|
return [
|
|
historyCountBoolean(entry, 'hasMore')
|
|
? t('settings.reader.kobo.activity.chip.morePending')
|
|
: t('settings.reader.kobo.activity.chip.noUpdatesPending'),
|
|
]
|
|
}
|
|
case 'book_download': {
|
|
const count = historyCountNumber(entry, 'downloads') || 1
|
|
return [t('settings.reader.kobo.activity.chip.bookCount', { count })]
|
|
}
|
|
case 'progress_update':
|
|
return [progressSyncModeText(entry)]
|
|
case 'annotations_pull': {
|
|
const labels: string[] = []
|
|
const tombstones = historyCountNumber(entry, 'tombstones')
|
|
if (tombstones > 0) labels.push(t('settings.reader.kobo.activity.chip.deletedAnnotations', { count: tombstones }, tombstones))
|
|
labels.push(
|
|
historyCountBoolean(entry, 'notModified')
|
|
? t('settings.reader.kobo.activity.chip.deviceAlreadyCurrent')
|
|
: t('settings.reader.kobo.activity.chip.freshHighlightData'),
|
|
)
|
|
return labels
|
|
}
|
|
case 'annotations_push': {
|
|
const labels: string[] = []
|
|
const created = historyCountNumber(entry, 'created')
|
|
const updated = historyCountNumber(entry, 'updated')
|
|
const deleted = historyCountNumber(entry, 'deleted')
|
|
const unchanged = historyCountNumber(entry, 'unchanged')
|
|
if (created > 0) labels.push(t('settings.reader.kobo.activity.chip.created', { count: created }))
|
|
if (updated > 0) labels.push(t('settings.reader.kobo.activity.chip.updated', { count: updated }))
|
|
if (deleted > 0) labels.push(t('settings.reader.kobo.activity.chip.deleted', { count: deleted }))
|
|
if (unchanged > 0)
|
|
labels.push(
|
|
t('settings.reader.kobo.activity.chip.unchanged', {
|
|
count: unchanged,
|
|
}),
|
|
)
|
|
return labels.length > 0 ? labels : [t('settings.reader.kobo.activity.outcome.noChanges')]
|
|
}
|
|
}
|
|
}
|
|
|
|
function historyDetailText(entry: KoboSyncHistoryEntry): string {
|
|
return historyDetailChips(entry).join(' · ')
|
|
}
|
|
|
|
function isGroupedHistoryItem(item: HistoryDisplayItem): boolean {
|
|
return item.entries.length > 1
|
|
}
|
|
|
|
function historyItemTitle(item: HistoryDisplayItem): string {
|
|
const entry = item.primary
|
|
const title = historyBookTitle(entry)
|
|
if (isGroupedHistoryItem(item)) {
|
|
return title ? t('settings.reader.kobo.activity.readingPositionSyncedTitle', { title }) : t('settings.reader.kobo.activity.readingPositionSynced')
|
|
}
|
|
|
|
const label = formatHistoryEvent(entry.event)
|
|
if (!title || entry.event === 'library_sync') return label
|
|
return t('settings.reader.kobo.activity.labelWithTitle', { label, title })
|
|
}
|
|
|
|
function historyItemOutcomeText(item: HistoryDisplayItem): string {
|
|
return isGroupedHistoryItem(item)
|
|
? t('settings.reader.kobo.activity.updateCount', {
|
|
count: item.entries.length,
|
|
})
|
|
: historyOutcomeText(item.primary)
|
|
}
|
|
|
|
function historyDirectionText(entry: KoboSyncHistoryEntry): string {
|
|
switch (entry.event) {
|
|
case 'library_sync':
|
|
case 'book_download':
|
|
case 'annotations_pull':
|
|
return t('settings.reader.kobo.activity.directionToKobo')
|
|
case 'progress_update':
|
|
case 'annotations_push':
|
|
return t('settings.reader.kobo.activity.directionToBookOrbit')
|
|
}
|
|
}
|
|
|
|
function progressSyncModeText(entry: KoboSyncHistoryEntry): string {
|
|
return historyCountBoolean(entry, 'twoWayProgressSync')
|
|
? t('settings.reader.kobo.activity.twoWaySyncEnabled')
|
|
: t('settings.reader.kobo.activity.deviceProgressSaved')
|
|
}
|
|
|
|
function historyItemSummary(item: HistoryDisplayItem): string {
|
|
const entry = item.primary
|
|
if (entry.status === 'failed') return historyFailureSummary(entry)
|
|
|
|
const title = historyBookTitle(entry)
|
|
switch (entry.event) {
|
|
case 'library_sync': {
|
|
const updates = historyCountNumber(entry, 'entitlements')
|
|
if (updates === 0) return t('settings.reader.kobo.activity.summary.noLibraryUpdatesPending')
|
|
return t('settings.reader.kobo.activity.summary.libraryUpdatesPrepared', {
|
|
count: updates,
|
|
})
|
|
}
|
|
case 'book_download':
|
|
if (title)
|
|
return t('settings.reader.kobo.activity.summary.bookDownloadedBy', {
|
|
title,
|
|
device: historyDeviceName(entry),
|
|
})
|
|
return t('settings.reader.kobo.activity.summary.booksDownloaded', {
|
|
count: historyCountNumber(entry, 'downloads') || 1,
|
|
})
|
|
case 'progress_update':
|
|
if (isGroupedHistoryItem(item)) {
|
|
return title
|
|
? t('settings.reader.kobo.activity.summary.progressUpdatesSyncedFor', { count: item.entries.length, title })
|
|
: t('settings.reader.kobo.activity.summary.progressUpdatesSynced', {
|
|
count: item.entries.length,
|
|
})
|
|
}
|
|
return title
|
|
? t('settings.reader.kobo.activity.summary.newReadingPositionFor', {
|
|
title,
|
|
})
|
|
: t('settings.reader.kobo.activity.summary.newReadingPosition')
|
|
case 'annotations_pull': {
|
|
const served = historyCountNumber(entry, 'served')
|
|
if (served === 0)
|
|
return title
|
|
? t('settings.reader.kobo.activity.summary.noHighlightChangesNeededFor', { title })
|
|
: t('settings.reader.kobo.activity.summary.noHighlightChangesNeeded')
|
|
return title
|
|
? t('settings.reader.kobo.activity.summary.highlightsSentToKoboFor', {
|
|
count: served,
|
|
title,
|
|
})
|
|
: t('settings.reader.kobo.activity.summary.highlightsSentToKobo', {
|
|
count: served,
|
|
})
|
|
}
|
|
case 'annotations_push': {
|
|
const changed = historyCountNumber(entry, 'created') + historyCountNumber(entry, 'updated') + historyCountNumber(entry, 'deleted')
|
|
if (changed === 0)
|
|
return title
|
|
? t('settings.reader.kobo.activity.summary.noKoboHighlightChangesFor', { title })
|
|
: t('settings.reader.kobo.activity.summary.noKoboHighlightChanges')
|
|
return title
|
|
? t('settings.reader.kobo.activity.summary.highlightsImportedFromKoboFor', { count: changed, title })
|
|
: t('settings.reader.kobo.activity.summary.highlightsImportedFromKobo', { count: changed })
|
|
}
|
|
}
|
|
}
|
|
|
|
function historyItemSecondaryText(item: HistoryDisplayItem): string {
|
|
const entry = item.primary
|
|
const parts = [historyDirectionText(entry)]
|
|
if (entry.event === 'progress_update') {
|
|
parts.push(progressSyncModeText(entry))
|
|
} else {
|
|
const detail = historyDetailText(entry)
|
|
if (detail) parts.push(detail)
|
|
}
|
|
return parts.join(' · ')
|
|
}
|
|
|
|
function historyItemTimeTitle(item: HistoryDisplayItem): string {
|
|
if (!isGroupedHistoryItem(item)) return formatDateTime(item.primary.createdAt)
|
|
const oldest = item.entries[item.entries.length - 1]
|
|
return t('settings.reader.kobo.activity.timeRange', {
|
|
from: formatDateTime(oldest?.createdAt),
|
|
to: formatDateTime(item.primary.createdAt),
|
|
})
|
|
}
|
|
|
|
function historyItemGroupedLabel(item: HistoryDisplayItem): string {
|
|
return isGroupedHistoryItem(item)
|
|
? t('settings.reader.kobo.activity.eventsGrouped', {
|
|
count: item.entries.length,
|
|
})
|
|
: ''
|
|
}
|
|
|
|
function historyDeviceName(entry: KoboSyncHistoryEntry): string {
|
|
return entry.deviceName ?? t('settings.reader.kobo.activity.removedDevice')
|
|
}
|
|
|
|
function historyOutcomeClass(entry: KoboSyncHistoryEntry): string {
|
|
if (entry.status === 'failed') {
|
|
return 'border-destructive/30 bg-destructive/10 text-destructive'
|
|
}
|
|
if (isHistoryNoOp(entry)) {
|
|
return 'border-border bg-muted/60 text-muted-foreground'
|
|
}
|
|
return 'border-primary/30 bg-primary/10 text-primary'
|
|
}
|
|
|
|
function historyStatusDotClass(entry: KoboSyncHistoryEntry): string {
|
|
return entry.status === 'success' ? 'bg-primary' : 'bg-destructive'
|
|
}
|
|
|
|
function getHistoryEventIcon(event: KoboSyncHistoryEntry['event']) {
|
|
switch (event) {
|
|
case 'library_sync':
|
|
return RefreshCw
|
|
case 'book_download':
|
|
return Download
|
|
case 'progress_update':
|
|
return Bookmark
|
|
case 'annotations_pull':
|
|
return Upload
|
|
case 'annotations_push':
|
|
return Download
|
|
}
|
|
}
|
|
|
|
function showAllHistory() {
|
|
historyFilter.value = 'all'
|
|
}
|
|
|
|
function showFailedHistory() {
|
|
historyFilter.value = 'failures'
|
|
}
|
|
|
|
async function loadMoreHistory() {
|
|
if (loadingMore.value || historyLoading.value) return
|
|
loadingMore.value = true
|
|
currentLimit.value = Math.min(currentLimit.value + 20, 100)
|
|
try {
|
|
await fetchHistory(currentLimit.value)
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : t('settings.reader.kobo.activity.loadMoreFailed'))
|
|
} finally {
|
|
loadingMore.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
await Promise.all([fetchDevices(), fetchSettings(), fetchHistory(currentLimit.value)])
|
|
applySettingsToLocal()
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : t('settings.reader.kobo.loadFailed')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
})
|
|
|
|
watch(twoWayProgressSync, (enabled) => {
|
|
if (enabled) convertToKepub.value = true
|
|
})
|
|
|
|
watch(syncBookOrbitAnnotationsToKobo, (enabled) => {
|
|
if (enabled) convertToKepub.value = true
|
|
})
|
|
|
|
async function submitCreate() {
|
|
if (!newDeviceName.value.trim()) return
|
|
creating.value = true
|
|
createError.value = null
|
|
try {
|
|
if (deliverySupported.value && devices.value.length === 0) {
|
|
await updateSettings({ readAloudDelivery: firstDeviceDelivery.value })
|
|
readAloudDelivery.value = firstDeviceDelivery.value
|
|
}
|
|
const device = await createDevice(newDeviceName.value.trim())
|
|
newDeviceToken.value = device.token
|
|
newDeviceSyncUrl.value = `${window.location.origin}/api/v1/kobo/${device.token}`
|
|
showCreateForm.value = false
|
|
newDeviceName.value = ''
|
|
toast.success(t('settings.reader.kobo.deviceRegistered', { name: device.name }))
|
|
} catch (e) {
|
|
createError.value = e instanceof Error ? e.message : t('settings.reader.kobo.createDeviceFailed')
|
|
toast.error(createError.value ?? t('settings.reader.kobo.createDeviceFailed'))
|
|
} finally {
|
|
creating.value = false
|
|
}
|
|
}
|
|
|
|
function cancelCreate() {
|
|
showCreateForm.value = false
|
|
createError.value = null
|
|
newDeviceName.value = ''
|
|
}
|
|
|
|
function dismissToken() {
|
|
newDeviceToken.value = null
|
|
newDeviceSyncUrl.value = null
|
|
}
|
|
|
|
async function copyToken() {
|
|
if (!newDeviceSyncUrl.value) return
|
|
const copied = await copyToClipboard(newDeviceSyncUrl.value)
|
|
if (copied) {
|
|
toast.success(t('settings.reader.kobo.syncUrlCopied'))
|
|
} else {
|
|
toast.error(t('settings.reader.kobo.syncUrlCopyFailed'))
|
|
}
|
|
}
|
|
|
|
function startRename(device: KoboDevice) {
|
|
renamingId.value = device.id
|
|
renameValue.value = device.name
|
|
}
|
|
|
|
function cancelRename() {
|
|
renamingId.value = null
|
|
renameValue.value = ''
|
|
}
|
|
|
|
async function submitRename(device: KoboDevice) {
|
|
if (!renameValue.value.trim()) return
|
|
renaming.value = true
|
|
try {
|
|
await renameDevice(device.id, renameValue.value.trim())
|
|
toast.success(t('settings.reader.kobo.deviceRenamed'))
|
|
renamingId.value = null
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : t('settings.reader.kobo.renameDeviceFailed'))
|
|
} finally {
|
|
renaming.value = false
|
|
}
|
|
}
|
|
|
|
async function revoke(device: KoboDevice) {
|
|
if (!confirm(t('settings.reader.kobo.revokeConfirm', { name: device.name }))) return
|
|
try {
|
|
await revokeDevice(device.id)
|
|
toast.success(t('settings.reader.kobo.accessRevoked', { name: device.name }))
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : t('settings.reader.kobo.revokeFailed'))
|
|
}
|
|
}
|
|
|
|
async function refreshHistory() {
|
|
refreshingHistory.value = true
|
|
try {
|
|
await fetchHistory(currentLimit.value)
|
|
toast.success(t('settings.reader.kobo.activity.historyRefreshed'))
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : t('settings.reader.kobo.activity.refreshFailed'))
|
|
} finally {
|
|
refreshingHistory.value = false
|
|
}
|
|
}
|
|
|
|
async function saveSettings() {
|
|
if (readingThreshold.value >= finishedThreshold.value) {
|
|
settingsError.value = t('settings.reader.kobo.thresholdOrderError')
|
|
toast.error(settingsError.value ?? t('settings.reader.kobo.saveSettingsFailed'))
|
|
return
|
|
}
|
|
savingSettings.value = true
|
|
settingsError.value = null
|
|
try {
|
|
await updateSettings({
|
|
readingThreshold: readingThreshold.value,
|
|
finishedThreshold: finishedThreshold.value,
|
|
convertToKepub: convertToKepub.value,
|
|
forceEnableHyphenation: forceEnableHyphenation.value,
|
|
kepubConversionLimitMb: kepubConversionLimitMb.value,
|
|
twoWayProgressSync: twoWayProgressSync.value,
|
|
syncBookOrbitAnnotationsToKobo: syncBookOrbitAnnotationsToKobo.value,
|
|
storeSync: storeSync.value,
|
|
...(deliverySupported.value ? { readAloudDelivery: readAloudDelivery.value } : {}),
|
|
})
|
|
applySettingsToLocal()
|
|
toast.success(t('settings.reader.kobo.settingsSaved'))
|
|
} catch (e) {
|
|
settingsError.value = e instanceof Error ? e.message : t('settings.reader.kobo.saveFailed')
|
|
toast.error(settingsError.value ?? t('settings.reader.kobo.saveSettingsFailed'))
|
|
} finally {
|
|
savingSettings.value = false
|
|
}
|
|
}
|
|
function beginCreate() {
|
|
firstDeviceDelivery.value = settings.value?.readAloudDelivery ?? 'audioless_epub'
|
|
showCreateForm.value = true
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="loading" class="settings-loading-state">
|
|
{{ t('common.loading') }}
|
|
</div>
|
|
<div v-else-if="error" class="settings-error-state">{{ error }}</div>
|
|
<template v-else>
|
|
<SettingsTabs v-if="!props.embedded" :tabs="tabs" :active-tab="activeTab" @select="selectTab" />
|
|
|
|
<div v-show="activeTab === 'settings' || props.embedded">
|
|
<!-- New device token display -->
|
|
<div v-if="newDeviceSyncUrl" class="mb-8 border-2 border-primary/30 rounded-lg p-4 bg-primary/5 shadow-xs">
|
|
<div class="flex items-start justify-between gap-4 mb-3">
|
|
<div class="flex items-center gap-2.5">
|
|
<div class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-primary shrink-0">
|
|
<Check :size="13" stroke-width="3" />
|
|
</div>
|
|
<div>
|
|
<p class="settings-label leading-none mb-0.5">
|
|
{{ t('settings.reader.kobo.devicePaired') }}
|
|
</p>
|
|
<p class="settings-hint">
|
|
{{ t('settings.reader.kobo.devicePairedHint') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button variant="ghost" size="icon-sm" @click="dismissToken" class="shrink-0">
|
|
<X :size="18" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<div class="bg-background rounded-lg border border-border p-4">
|
|
<p class="text-xs font-bold text-muted-foreground uppercase tracking-widest mb-2.5">
|
|
{{ t('settings.reader.kobo.syncUrl') }}
|
|
</p>
|
|
<div class="flex items-center gap-2 px-3 py-2.5 rounded-md border border-border bg-muted/30">
|
|
<Tablet :size="14" class="text-muted-foreground shrink-0" />
|
|
<span class="flex-1 text-sm text-foreground font-mono select-all truncate min-w-0">{{ newDeviceSyncUrl }}</span>
|
|
<Button variant="outline" size="sm" class="shrink-0" @click="copyToken">
|
|
<Copy :size="12" />
|
|
{{ t('settings.reader.kobo.copy') }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="flex items-center gap-2 text-xs font-medium text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30 px-3 py-2 rounded-md border border-amber-200 dark:border-amber-900/50"
|
|
>
|
|
<X :size="14" class="shrink-0" />
|
|
{{ t('settings.reader.kobo.urlNotShownAgain') }}
|
|
</div>
|
|
<div class="flex items-start gap-2.5 rounded-md border border-border bg-background p-4 text-xs mt-4">
|
|
<Info :size="16" class="text-primary shrink-0 mt-0.5" />
|
|
<div class="space-y-1">
|
|
<p class="font-semibold text-foreground">
|
|
{{ t('settings.reader.kobo.nextSteps.title') }}
|
|
</p>
|
|
<p class="text-muted-foreground leading-normal">
|
|
{{ t('settings.reader.kobo.nextSteps.step1') }}
|
|
</p>
|
|
<i18n-t keypath="settings.reader.kobo.nextSteps.step2" tag="p" class="text-muted-foreground leading-normal" scope="global">
|
|
<template #syncToKobo>
|
|
<strong class="text-foreground">{{ t('settings.reader.kobo.syncToKobo') }}</strong>
|
|
</template>
|
|
</i18n-t>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Devices -->
|
|
<div class="mb-8">
|
|
<div class="mb-2 flex items-center justify-between">
|
|
<p class="settings-group-label mb-0">
|
|
{{ t('settings.reader.kobo.registeredDevices') }}
|
|
</p>
|
|
<Button size="sm" v-if="!showCreateForm" @click="beginCreate" type="button">
|
|
<Plus :size="12" />
|
|
{{ t('settings.reader.kobo.addDevice') }}
|
|
</Button>
|
|
</div>
|
|
|
|
<!-- Create form -->
|
|
<div v-if="showCreateForm" class="border border-border rounded-lg p-5 bg-card mb-4 space-y-4 shadow-xs">
|
|
<div>
|
|
<label class="settings-label block mb-1.5">{{ t('settings.reader.kobo.deviceName') }}</label>
|
|
<input
|
|
v-model="newDeviceName"
|
|
type="text"
|
|
:placeholder="t('settings.reader.kobo.deviceNamePlaceholder')"
|
|
autofocus
|
|
class="input-field w-full"
|
|
/>
|
|
</div>
|
|
<div v-if="deliverySupported && devices.length === 0" class="space-y-2">
|
|
<ReadAloudDeliverySelect v-model="firstDeviceDelivery" :disabled="creating" />
|
|
<p class="text-xs text-muted-foreground">{{ t('delivery.readAloud.koboHint') }}</p>
|
|
</div>
|
|
<div v-if="createError" class="text-xs text-destructive">
|
|
{{ createError }}
|
|
</div>
|
|
<div class="flex items-center gap-2 pt-1">
|
|
<Button size="sm" :disabled="creating || !newDeviceName.trim()" @click="submitCreate" type="button">
|
|
{{ creating ? t('settings.reader.kobo.creating') : t('settings.reader.kobo.createDevice') }}
|
|
</Button>
|
|
<Button variant="outline" size="sm" @click="cancelCreate" type="button">
|
|
{{ t('common.cancel') }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="devices.length === 0 && !showCreateForm" class="border border-border rounded-lg px-5 py-10 bg-card text-center shadow-xs">
|
|
<div class="w-10 h-10 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
|
|
<Tablet :size="18" class="text-muted-foreground" />
|
|
</div>
|
|
<p class="text-sm font-medium text-foreground">
|
|
{{ t('settings.reader.kobo.noDevicesYet') }}
|
|
</p>
|
|
<p class="text-xs text-muted-foreground mt-1 max-w-[240px] mx-auto">
|
|
{{ t('settings.reader.kobo.noDevicesHint') }}
|
|
</p>
|
|
</div>
|
|
|
|
<div v-else-if="devices.length > 0" class="settings-card">
|
|
<div v-for="device in devices" :key="device.id" class="px-5 py-4 bg-card transition-colors hover:bg-muted/30">
|
|
<div v-if="renamingId === device.id" class="flex items-center gap-2">
|
|
<input
|
|
v-model="renameValue"
|
|
type="text"
|
|
class="flex-1 input-field"
|
|
@keydown.enter="submitRename(device)"
|
|
@keydown.esc="cancelRename()"
|
|
/>
|
|
<Button size="sm" :disabled="renaming || !renameValue.trim()" @click="submitRename(device)" type="button">
|
|
{{ t('common.save') }}
|
|
</Button>
|
|
<Button variant="outline" size="icon-sm" class="flex" @click="cancelRename" type="button">
|
|
<X :size="14" />
|
|
</Button>
|
|
</div>
|
|
<div v-else class="flex items-center gap-3">
|
|
<div class="w-9 h-9 rounded-lg bg-muted flex items-center justify-center text-muted-foreground shrink-0 border border-border">
|
|
<Tablet :size="16" />
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="settings-label truncate leading-none mb-1.5">
|
|
{{ device.name }}
|
|
</p>
|
|
<p class="settings-hint leading-none">
|
|
{{
|
|
t('settings.reader.kobo.lastSync', {
|
|
time: formatLastSeen(device.lastSeenAt),
|
|
})
|
|
}}
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<Button variant="ghost" size="icon-sm" @click="startRename(device)" :title="t('settings.reader.kobo.renameDevice')">
|
|
<Pencil :size="14" />
|
|
</Button>
|
|
<Button variant="destructive-ghost" size="icon-sm" @click="revoke(device)" :title="t('settings.reader.kobo.revokeAccess')">
|
|
<Trash2 :size="14" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Setup Tip / Collection Sync Notice -->
|
|
<div class="mb-8 p-4 rounded-lg bg-primary/5 border border-primary/10 shadow-xs max-w-4xl">
|
|
<div class="flex items-start gap-3">
|
|
<Info :size="18" class="text-primary shrink-0 mt-0.5" />
|
|
<div class="space-y-1.5">
|
|
<p class="text-sm font-semibold text-foreground">
|
|
{{ t('settings.reader.kobo.howBooksSynced.title') }}
|
|
</p>
|
|
<i18n-t keypath="settings.reader.kobo.howBooksSynced.body" tag="p" class="text-xs leading-relaxed text-muted-foreground" scope="global">
|
|
<template #syncToKobo>
|
|
<strong class="text-foreground font-medium">{{ t('settings.reader.kobo.syncToKobo') }}</strong>
|
|
</template>
|
|
</i18n-t>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sync history -->
|
|
<div v-show="activeTab === 'activity' && !props.embedded" class="mb-8">
|
|
<div class="mb-2 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
|
<div class="min-w-0">
|
|
<div class="flex min-w-0 items-center gap-2">
|
|
<p class="settings-group-label mb-0">
|
|
{{ t('settings.reader.kobo.activity.recentActivity') }}
|
|
</p>
|
|
<span v-if="latestHistoryEntry" class="h-2 w-2 shrink-0 rounded-full" :class="historyStatusDotClass(latestHistoryEntry)" />
|
|
</div>
|
|
<p class="settings-hint mt-1 truncate" :title="historyHeaderDetail">
|
|
{{ historyHeaderDetail }}
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<div class="inline-flex h-8 rounded-md border border-border bg-muted p-0.5">
|
|
<button
|
|
class="rounded-[4px] px-2.5 text-xs font-medium transition-colors inline-flex items-center gap-1.5"
|
|
:class="historyFilter === 'all' ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'"
|
|
@click="showAllHistory"
|
|
>
|
|
<span>{{ t('settings.reader.kobo.activity.filterAll') }}</span>
|
|
<span
|
|
v-if="syncHistory.length > 0"
|
|
class="rounded-full bg-muted-foreground/15 px-1.5 py-0.5 text-[10px] font-semibold"
|
|
:class="historyFilter === 'all' ? 'text-foreground' : 'text-muted-foreground'"
|
|
>
|
|
{{ syncHistory.length }}
|
|
</span>
|
|
</button>
|
|
<button
|
|
class="rounded-[4px] px-2.5 text-xs font-medium transition-colors inline-flex items-center gap-1.5"
|
|
:class="historyFilter === 'failures' ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'"
|
|
@click="showFailedHistory"
|
|
>
|
|
<span>{{ t('settings.reader.kobo.activity.filterFailures') }}</span>
|
|
<span v-if="recentFailureCount > 0" class="rounded-full bg-destructive/15 px-1.5 py-0.5 text-[10px] font-semibold text-destructive">
|
|
{{ recentFailureCount }}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
<Button variant="outline" size="sm" :disabled="refreshingHistory || historyLoading" @click="refreshHistory">
|
|
<RefreshCw :size="12" :class="{ 'animate-spin': refreshingHistory || historyLoading }" />
|
|
{{ t('settings.reader.kobo.activity.refresh') }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="border border-border rounded-lg bg-card p-6 shadow-xs">
|
|
<div v-if="historyLoading && !hasSyncHistory" class="text-sm text-muted-foreground">
|
|
{{ t('settings.reader.kobo.activity.loadingActivity') }}
|
|
</div>
|
|
<div v-else-if="!hasSyncHistory" class="text-center py-4">
|
|
<div class="w-10 h-10 rounded-lg bg-muted flex items-center justify-center mx-auto mb-3">
|
|
<History :size="18" class="text-muted-foreground" />
|
|
</div>
|
|
<p class="text-sm font-medium text-foreground">
|
|
{{ t('settings.reader.kobo.activity.noActivityYet') }}
|
|
</p>
|
|
<p class="text-xs text-muted-foreground mt-1 max-w-sm mx-auto">
|
|
{{ t('settings.reader.kobo.activity.noActivityHint') }}
|
|
</p>
|
|
</div>
|
|
<template v-else>
|
|
<div v-if="filteredSyncHistory.length === 0" class="text-sm text-muted-foreground">
|
|
{{ filteredHistoryEmptyLabel }}
|
|
</div>
|
|
|
|
<div v-else class="relative">
|
|
<!-- Vertical track line -->
|
|
<div class="absolute left-[13px] top-3 bottom-3 w-0.5 bg-border/60"></div>
|
|
|
|
<TransitionGroup name="timeline" tag="div" class="space-y-6">
|
|
<div v-for="item in displayedSyncHistory" :key="item.key" class="relative pl-10">
|
|
<!-- Icon status indicator -->
|
|
<div
|
|
class="absolute left-0 top-0 h-7 w-7 rounded-full border bg-card flex items-center justify-center shadow-xs overflow-hidden"
|
|
:class="
|
|
item.primary.status === 'failed'
|
|
? 'border-destructive/20 text-destructive'
|
|
: isHistoryNoOp(item.primary)
|
|
? 'border-border text-muted-foreground'
|
|
: 'border-primary/20 text-primary'
|
|
"
|
|
>
|
|
<div
|
|
class="h-full w-full rounded-full flex items-center justify-center"
|
|
:class="item.primary.status === 'failed' ? 'bg-destructive/10' : isHistoryNoOp(item.primary) ? 'bg-muted/10' : 'bg-primary/10'"
|
|
>
|
|
<component :is="getHistoryEventIcon(item.primary.event)" :size="12" class="stroke-[2.5]" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-1.5 pt-0.5">
|
|
<!-- Header: Title, Outcome Badge, Time -->
|
|
<div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
|
|
<div class="flex min-w-0 items-center gap-2">
|
|
<span class="min-w-0 text-sm font-semibold text-foreground leading-snug">{{ historyItemTitle(item) }}</span>
|
|
<span
|
|
class="shrink-0 rounded-md border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider leading-none"
|
|
:class="historyOutcomeClass(item.primary)"
|
|
>
|
|
{{ historyItemOutcomeText(item) }}
|
|
</span>
|
|
</div>
|
|
<span class="text-xs text-muted-foreground" :title="historyItemTimeTitle(item)">
|
|
{{ formatLastSeen(item.primary.createdAt) }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Messages / Details -->
|
|
<div class="min-w-0">
|
|
<p class="text-sm text-foreground leading-normal">
|
|
{{ historyItemSummary(item) }}
|
|
</p>
|
|
<p v-if="historyItemSecondaryText(item)" class="mt-0.5 text-xs text-muted-foreground leading-normal">
|
|
{{ historyItemSecondaryText(item) }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Callout block for Errors -->
|
|
<div
|
|
v-if="item.primary.error"
|
|
class="mt-1 flex items-start gap-2.5 rounded-lg border border-destructive/20 bg-destructive/5 p-3 text-xs text-destructive"
|
|
>
|
|
<div class="flex flex-col gap-1 min-w-0">
|
|
<span class="font-semibold">{{ item.primary.errorClass ?? t('settings.reader.kobo.activity.error') }}</span>
|
|
<p class="whitespace-pre-wrap break-all leading-normal text-destructive">
|
|
{{ item.primary.error }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer Metadata row -->
|
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
|
<div class="flex items-center gap-1">
|
|
<Tablet :size="12" class="text-muted-foreground" />
|
|
<span>{{ historyDeviceName(item.primary) }}</span>
|
|
</div>
|
|
<div v-if="historyItemGroupedLabel(item)" class="flex items-center gap-1">
|
|
<History :size="12" class="text-muted-foreground" />
|
|
<span>{{ historyItemGroupedLabel(item) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TransitionGroup>
|
|
|
|
<!-- Load More -->
|
|
<div v-if="syncHistory.length >= currentLimit && currentLimit < 100" class="mt-6 pl-10">
|
|
<Button variant="outline" size="sm" :disabled="loadingMore || historyLoading" @click="loadMoreHistory">
|
|
<RefreshCw v-if="loadingMore || historyLoading" :size="12" class="animate-spin" />
|
|
<span>{{
|
|
loadingMore || historyLoading ? t('settings.reader.kobo.activity.loading') : t('settings.reader.kobo.activity.loadMore')
|
|
}}</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="deliverySupported && activeTab === 'settings'" class="rounded-xs border border-border bg-card p-5 space-y-3">
|
|
<ReadAloudDeliverySelect v-model="readAloudDelivery" :disabled="savingSettings" />
|
|
<p class="text-xs text-muted-foreground">{{ t('delivery.readAloud.koboHint') }}</p>
|
|
</div>
|
|
|
|
<KoboReplacement v-if="deliverySupported && activeTab === 'settings'" :devices="devices" />
|
|
|
|
<!-- Sync settings -->
|
|
<div v-show="activeTab === 'settings' || props.embedded" class="mb-8">
|
|
<p class="settings-group-label">
|
|
{{ t('settings.reader.kobo.syncPreferences') }}
|
|
</p>
|
|
<div class="settings-card">
|
|
<div class="flex items-center justify-between px-5 py-4 bg-card">
|
|
<div class="pr-8">
|
|
<p class="settings-label">
|
|
{{ t('settings.reader.kobo.twoWaySync') }}
|
|
</p>
|
|
<p class="settings-hint">
|
|
{{ t('settings.reader.kobo.twoWaySyncHint') }}
|
|
</p>
|
|
</div>
|
|
<ToggleSwitch v-model="twoWayProgressSync" />
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between px-5 py-4 bg-card">
|
|
<div class="pr-8">
|
|
<p class="settings-label">
|
|
{{ t('settings.reader.kobo.syncHighlights') }}
|
|
</p>
|
|
<p class="settings-hint">
|
|
{{ t('settings.reader.kobo.syncHighlightsHint') }}
|
|
</p>
|
|
</div>
|
|
<ToggleSwitch v-model="syncBookOrbitAnnotationsToKobo" />
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between px-5 py-4 bg-card">
|
|
<div class="pr-8">
|
|
<p class="settings-label">
|
|
{{ t('settings.reader.kobo.storeSync') }}
|
|
</p>
|
|
<p class="settings-hint">
|
|
{{ t('settings.reader.kobo.storeSyncHint') }}
|
|
</p>
|
|
</div>
|
|
<ToggleSwitch v-model="storeSync" />
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between px-5 py-4 bg-card">
|
|
<div class="pr-8">
|
|
<p class="settings-label">
|
|
{{ t('settings.reader.kobo.convertKepub') }}
|
|
</p>
|
|
<p class="settings-hint">
|
|
{{ t('settings.reader.kobo.convertKepubHint') }}
|
|
</p>
|
|
</div>
|
|
<ToggleSwitch v-model="convertToKepub" :disabled="twoWayProgressSync || syncBookOrbitAnnotationsToKobo" />
|
|
</div>
|
|
|
|
<div v-if="convertToKepub" class="flex items-center justify-between px-5 py-4 bg-card">
|
|
<div class="pr-8">
|
|
<p class="settings-label">
|
|
{{ t('settings.reader.kobo.forceHyphenation') }}
|
|
</p>
|
|
<p class="settings-hint">
|
|
{{ t('settings.reader.kobo.forceHyphenationHint') }}
|
|
</p>
|
|
</div>
|
|
<ToggleSwitch v-model="forceEnableHyphenation" />
|
|
</div>
|
|
|
|
<div class="px-5 py-5 bg-card space-y-5">
|
|
<div>
|
|
<p class="settings-label mb-1">
|
|
{{ t('settings.reader.kobo.progressThresholds') }}
|
|
</p>
|
|
<p class="settings-hint">
|
|
{{ t('settings.reader.kobo.progressThresholdsHint') }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="grid sm:grid-cols-2 gap-6">
|
|
<div class="space-y-2">
|
|
<div class="flex items-center justify-between">
|
|
<label class="text-[12px] font-bold text-muted-foreground uppercase tracking-widest">{{
|
|
t('settings.reader.kobo.markAsReading')
|
|
}}</label>
|
|
<span class="text-xs font-mono text-primary font-bold">{{ readingThreshold }}%</span>
|
|
</div>
|
|
<input v-model.number="readingThreshold" type="range" min="0.5" max="10" step="0.5" class="w-full accent-primary cursor-pointer" />
|
|
<p class="text-[12px] text-muted-foreground leading-tight">
|
|
{{ t('settings.reader.kobo.markAsReadingHint') }}
|
|
</p>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<div class="flex items-center justify-between">
|
|
<label class="text-[12px] font-bold text-muted-foreground uppercase tracking-widest">{{
|
|
t('settings.reader.kobo.markAsFinished')
|
|
}}</label>
|
|
<span class="text-xs font-mono text-primary font-bold">{{ finishedThreshold }}%</span>
|
|
</div>
|
|
<input v-model.number="finishedThreshold" type="range" min="75" max="100" step="1" class="w-full accent-primary cursor-pointer" />
|
|
<p class="text-[12px] text-muted-foreground leading-tight">
|
|
{{ t('settings.reader.kobo.markAsFinishedHint') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div class="flex items-center justify-between mb-2">
|
|
<label class="text-[12px] font-bold text-muted-foreground uppercase tracking-widest">{{ t('settings.reader.kobo.kepubLimit') }}</label>
|
|
<span class="text-xs font-mono text-primary font-bold">{{ kepubConversionLimitMb }} MB</span>
|
|
</div>
|
|
<input v-model.number="kepubConversionLimitMb" type="range" min="1" max="500" step="5" class="w-full accent-primary cursor-pointer" />
|
|
<p class="text-[12px] text-muted-foreground mt-2">
|
|
{{ t('settings.reader.kobo.kepubLimitHint') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="px-5 py-4 bg-muted/30 flex items-center justify-between">
|
|
<div v-if="settingsError" class="text-xs text-destructive font-medium flex items-center gap-1.5"><X :size="14" /> {{ settingsError }}</div>
|
|
<div v-else class="text-[12px] text-muted-foreground italic">
|
|
{{ t('settings.reader.kobo.changesMustBeSaved') }}
|
|
</div>
|
|
|
|
<Button size="sm" :disabled="savingSettings" @click="saveSettings" type="button">
|
|
{{ savingSettings ? t('settings.reader.kobo.saving') : t('settings.reader.kobo.saveSyncSettings') }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.timeline-enter-active,
|
|
.timeline-leave-active {
|
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
.timeline-enter-from,
|
|
.timeline-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(12px);
|
|
}
|
|
.timeline-leave-active {
|
|
position: absolute;
|
|
width: 100%;
|
|
}
|
|
.timeline-move {
|
|
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
</style>
|