lastwebnovel-app/app/pages/myreading/history.vue
2026-04-11 22:55:16 +02:00

290 lines
9.0 KiB
Vue

<script setup lang="ts">
interface ReadingHistoryEntry {
novelId: string
novelTitle: string
chapterId: string
chapterTitle: string
cover: string
readAt: string
}
const router = useRouter()
const toast = useToast()
const history = ref<ReadingHistoryEntry[]>([])
const fileInput = ref<HTMLInputElement | null>(null)
const searchQuery = ref('')
const filteredHistory = computed(() => {
if (!searchQuery.value) return history.value
const query = searchQuery.value.toLowerCase()
return history.value.filter(entry =>
entry.novelTitle.toLowerCase().includes(query)
|| entry.chapterTitle.toLowerCase().includes(query)
)
})
const loadHistory = () => {
const saved = localStorage.getItem('reading_history')
if (saved) {
history.value = JSON.parse(saved).sort((a: ReadingHistoryEntry, b: ReadingHistoryEntry) =>
new Date(b.readAt).getTime() - new Date(a.readAt).getTime()
)
}
}
const clearHistory = () => {
history.value = []
localStorage.removeItem('reading_history')
toast.add({ title: 'History cleared', color: 'info' })
}
const removeEntry = (entry: ReadingHistoryEntry) => {
const index = history.value.findIndex(e =>
e.novelId === entry.novelId
&& e.chapterId === entry.chapterId
&& e.readAt === entry.readAt
)
if (index !== -1) {
history.value.splice(index, 1)
localStorage.setItem('reading_history', JSON.stringify(history.value))
}
}
const exportHistory = () => {
const dataStr = JSON.stringify(history.value, null, 2)
const dataBlob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(dataBlob)
const link = document.createElement('a')
link.href = url
link.download = `history-export-${new Date().toISOString().split('T')[0]}.json`
link.click()
URL.revokeObjectURL(url)
toast.add({ title: 'History exported successfully', color: 'success' })
}
const importHistory = () => {
fileInput.value?.click()
}
const isValidHistoryEntry = (item: any): item is ReadingHistoryEntry => {
return (
typeof item === 'object'
&& item !== null
&& typeof item.novelId === 'string'
&& typeof item.novelTitle === 'string'
&& typeof item.chapterId === 'string'
&& typeof item.chapterTitle === 'string'
&& typeof item.cover === 'string'
&& typeof item.readAt === 'string'
)
}
const handleFileUpload = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
try {
const imported = JSON.parse(e.target?.result as string)
if (!Array.isArray(imported)) {
throw new Error('Invalid format: expected an array')
}
// Validate each item has correct structure
const validEntries = imported.filter(isValidHistoryEntry)
if (validEntries.length === 0) {
throw new Error('No valid history entries found')
}
if (validEntries.length < imported.length) {
toast.add({
title: 'Warning',
description: `${imported.length - validEntries.length} invalid entries skipped`,
color: 'warning'
})
}
// Merge with existing history
history.value = [...history.value, ...validEntries].sort((a, b) =>
new Date(b.readAt).getTime() - new Date(a.readAt).getTime()
)
localStorage.setItem('reading_history', JSON.stringify(history.value))
toast.add({ title: `Imported ${validEntries.length} entries`, color: 'success' })
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid file format'
toast.add({ title: 'Failed to import history', description: message, color: 'error' })
}
}
reader.readAsText(file)
target.value = ''
}
onMounted(() => {
loadHistory()
})
useSeoMeta({
title: 'Reading History - LastWebNovel',
description: 'Your reading history and recently read chapters'
})
</script>
<template>
<UDashboardPanel id="history">
<template #header>
<UDashboardNavbar title="My History" :ui="{ right: 'gap-3' }">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
<UContainer class="py-8">
<div v-if="history.length === 0" class="text-center py-12">
<UIcon name="i-lucide-history" class="size-12 mx-auto mb-4 text-gray-400" />
<h3 class="text-lg font-semibold mb-2">
No reading history yet
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
Your recently read chapters will appear here
</p>
<div class="flex flex-col md:flex-row gap-2 justify-center">
<UButton
icon="i-lucide-book"
color="primary"
@click="router.push('/')"
>
Start Reading
</UButton>
<UButton
icon="i-lucide-upload"
color="secondary"
variant="subtle"
@click="importHistory"
>
Import History
</UButton>
<input
ref="fileInput"
type="file"
accept=".json"
class="hidden"
@change="handleFileUpload"
>
</div>
</div>
<div v-else class="space-y-4">
<!-- Search Bar -->
<UInput
v-model="searchQuery"
icon="i-lucide-search"
placeholder="Search by novel or chapter title..."
size="xl"
class="mb-8 w-full"
/>
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
<h2 class="text-xl font-semibold">
{{ filteredHistory.length }} of {{ history.length }} entries
</h2>
<div class="flex gap-2">
<UButton
size="sm"
color="secondary"
variant="soft"
icon="i-lucide-download"
@click="exportHistory"
>
Export
</UButton>
<UButton
size="sm"
color="secondary"
variant="soft"
icon="i-lucide-upload"
@click="importHistory"
>
Import
</UButton>
<UButton
size="sm"
color="error"
variant="soft"
icon="i-lucide-trash-2"
@click="clearHistory"
>
Clear history
</UButton>
<input
ref="fileInput"
type="file"
accept=".json"
class="hidden"
@change="handleFileUpload"
>
</div>
</div>
<!-- No Results -->
<div v-if="filteredHistory.length === 0" class="text-center py-12">
<UIcon name="i-lucide-search-x" class="size-12 mx-auto mb-4 text-gray-400" />
<h3 class="text-lg font-semibold mb-2">
No entries found
</h3>
<p class="text-gray-600 dark:text-gray-400">
Try adjusting your search query
</p>
</div>
<div v-else class="grid gap-4">
<div
v-for="(entry, index) in filteredHistory"
:key="index"
class="flex flex-col md:flex-row md:items-start gap-4 p-4 rounded-lg border border-gray-200 dark:border-gray-800 hover:border-primary-500 hover:shadow-md transition-all"
>
<div class="flex-1">
<h3 class="font-semibold cursor-pointer hover:text-primary-500 transition-colors" @click="router.push(`/novel/${entry.novelId}/${entry.chapterId}`)">
{{ entry.novelTitle }}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ entry.chapterTitle }}
</p>
<p class="text-xs text-gray-500 mt-1">
Read: {{ new Date(entry.readAt).toLocaleDateString() }} at {{ new Date(entry.readAt).toLocaleTimeString() }}
</p>
</div>
<div class="flex gap-2 md:flex-col md:h-auto md:justify-start">
<UButton
size="md"
icon="i-lucide-play"
color="primary"
variant="soft"
class="flex-1 md:flex-none"
@click="router.push(`/novel/${entry.novelId}/${entry.chapterId}`)"
>
Continue
</UButton>
<UButton
size="md"
icon="i-lucide-x"
color="error"
variant="soft"
class="flex-1 md:flex-none"
@click="removeEntry(entry)"
>
Remove
</UButton>
</div>
</div>
</div>
</div>
</UContainer>
</template>
</UDashboardPanel>
</template>