299 lines
9.6 KiB
Vue
299 lines
9.6 KiB
Vue
<script setup lang="ts">
|
|
interface LibraryNovel {
|
|
id: string
|
|
title: string
|
|
author: string
|
|
cover: string
|
|
addedAt: string
|
|
}
|
|
|
|
const router = useRouter()
|
|
const toast = useToast()
|
|
const libraryNovels = ref<LibraryNovel[]>([])
|
|
const isLoading = ref(true)
|
|
const fileInput = ref<HTMLInputElement | null>(null)
|
|
const searchQuery = ref('')
|
|
|
|
const filteredNovels = computed(() => {
|
|
if (!searchQuery.value) return libraryNovels.value
|
|
const query = searchQuery.value.toLowerCase()
|
|
return libraryNovels.value.filter(novel =>
|
|
novel.title.toLowerCase().includes(query)
|
|
|| novel.author.toLowerCase().includes(query)
|
|
)
|
|
})
|
|
|
|
const loadLibrary = () => {
|
|
const saved = localStorage.getItem('user_library')
|
|
if (saved) {
|
|
libraryNovels.value = JSON.parse(saved)
|
|
}
|
|
isLoading.value = false
|
|
}
|
|
|
|
const removeFromLibrary = (id: string) => {
|
|
libraryNovels.value = libraryNovels.value.filter(n => n.id !== id)
|
|
localStorage.setItem('user_library', JSON.stringify(libraryNovels.value))
|
|
}
|
|
|
|
const exportLibrary = () => {
|
|
const dataStr = JSON.stringify(libraryNovels.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 = `library-export-${new Date().toISOString().split('T')[0]}.json`
|
|
link.click()
|
|
URL.revokeObjectURL(url)
|
|
toast.add({ title: 'Library exported successfully', color: 'success' })
|
|
}
|
|
|
|
const importLibrary = () => {
|
|
fileInput.value?.click()
|
|
}
|
|
|
|
const isValidLibraryNovel = (item: any): item is LibraryNovel => {
|
|
return (
|
|
typeof item === 'object'
|
|
&& item !== null
|
|
&& typeof item.id === 'string'
|
|
&& typeof item.title === 'string'
|
|
&& typeof item.author === 'string'
|
|
&& typeof item.cover === 'string'
|
|
&& typeof item.addedAt === '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 validNovels = imported.filter(isValidLibraryNovel)
|
|
if (validNovels.length === 0) {
|
|
throw new Error('No valid library entries found')
|
|
}
|
|
|
|
if (validNovels.length < imported.length) {
|
|
toast.add({
|
|
title: 'Warning',
|
|
description: `${imported.length - validNovels.length} invalid entries skipped`,
|
|
color: 'warning'
|
|
})
|
|
}
|
|
|
|
// Merge with existing library, avoiding duplicates
|
|
const existingIds = new Set(libraryNovels.value.map(n => n.id))
|
|
const newNovels = validNovels.filter(n => !existingIds.has(n.id))
|
|
libraryNovels.value = [...libraryNovels.value, ...newNovels]
|
|
localStorage.setItem('user_library', JSON.stringify(libraryNovels.value))
|
|
toast.add({ title: `Imported ${newNovels.length} novel(s)`, color: 'success' })
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Invalid file format'
|
|
toast.add({ title: 'Failed to import library', description: message, color: 'error' })
|
|
}
|
|
}
|
|
reader.readAsText(file)
|
|
target.value = ''
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadLibrary()
|
|
})
|
|
|
|
useSeoMeta({
|
|
title: 'My Library - LastWebNovel',
|
|
description: 'Your saved novels and reading list'
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<UDashboardPanel id="library">
|
|
<template #header>
|
|
<UDashboardNavbar title="My Library" :ui="{ right: 'gap-3' }">
|
|
<template #leading>
|
|
<UDashboardSidebarCollapse />
|
|
</template>
|
|
</UDashboardNavbar>
|
|
</template>
|
|
|
|
<template #body>
|
|
<UContainer class="py-8">
|
|
<!-- Loading State -->
|
|
<div v-if="isLoading" class="space-y-4">
|
|
<div v-for="i in 3" :key="i" class="flex gap-4 p-4 rounded-lg border border-gray-200 dark:border-gray-800">
|
|
<USkeleton class="h-24 w-16 rounded flex-shrink-0" />
|
|
<div class="flex-1 space-y-2">
|
|
<USkeleton class="h-5 w-1/3" />
|
|
<USkeleton class="h-4 w-1/4" />
|
|
<USkeleton class="h-3 w-1/5 mt-2" />
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<USkeleton class="h-9 w-16 rounded" />
|
|
<USkeleton class="h-9 w-16 rounded" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div v-else-if="libraryNovels.length === 0" class="text-center py-12">
|
|
<UIcon name="i-lucide-bookmark" class="size-12 mx-auto mb-4 text-gray-400" />
|
|
<h3 class="text-lg font-semibold mb-2">
|
|
Your library is empty
|
|
</h3>
|
|
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
|
Add novels to your library to keep track of your reading
|
|
</p>
|
|
<div class="flex flex-col md:flex-row gap-2 justify-center">
|
|
<UButton
|
|
icon="i-lucide-book"
|
|
color="primary"
|
|
@click="router.push('/')"
|
|
>
|
|
Browse Novels
|
|
</UButton>
|
|
<UButton
|
|
icon="i-lucide-upload"
|
|
color="secondary"
|
|
variant="subtle"
|
|
@click="importLibrary"
|
|
>
|
|
Import Library
|
|
</UButton>
|
|
<input
|
|
ref="fileInput"
|
|
type="file"
|
|
accept=".json"
|
|
class="hidden"
|
|
@change="handleFileUpload"
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Library List -->
|
|
<div v-else class="space-y-4">
|
|
<!-- Search Bar -->
|
|
<UInput
|
|
v-model="searchQuery"
|
|
icon="i-lucide-search"
|
|
placeholder="Search by title or author..."
|
|
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">
|
|
{{ filteredNovels.length }} of {{ libraryNovels.length }} novel{{ libraryNovels.length !== 1 ? 's' : '' }}
|
|
</h2>
|
|
<div class="flex gap-2">
|
|
<UButton
|
|
size="sm"
|
|
color="secondary"
|
|
variant="soft"
|
|
icon="i-lucide-download"
|
|
@click="exportLibrary"
|
|
>
|
|
Export
|
|
</UButton>
|
|
<UButton
|
|
size="sm"
|
|
color="secondary"
|
|
variant="soft"
|
|
icon="i-lucide-upload"
|
|
@click="importLibrary"
|
|
>
|
|
Import
|
|
</UButton>
|
|
<input
|
|
ref="fileInput"
|
|
type="file"
|
|
accept=".json"
|
|
class="hidden"
|
|
@change="handleFileUpload"
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- No Results -->
|
|
<div v-if="filteredNovels.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 novels 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="novel in filteredNovels"
|
|
:key="novel.id"
|
|
class="flex flex-col md:flex-row gap-4 p-4 rounded-lg border border-gray-200 dark:border-gray-800 hover:border-primary-500 hover:shadow-md transition-all md:items-start"
|
|
>
|
|
<!-- Cover Image -->
|
|
<img
|
|
:src="novel.cover"
|
|
:alt="novel.title"
|
|
class="h-24 w-16 rounded object-cover flex-shrink-0 cursor-pointer hover:opacity-80 transition-opacity hidden md:block md:mx-0"
|
|
@click="router.push(`/novels/${novel.id}`)"
|
|
>
|
|
|
|
<!-- Content -->
|
|
<div class="flex-1 flex flex-col gap-2">
|
|
<div>
|
|
<h3
|
|
class="font-semibold text-base cursor-pointer hover:text-primary-500 transition-colors"
|
|
@click="router.push(`/novels/${novel.id}`)"
|
|
>
|
|
{{ novel.title }}
|
|
</h3>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
|
{{ novel.author }}
|
|
</p>
|
|
</div>
|
|
<p class="text-xs text-gray-500">
|
|
Added: {{ new Date(novel.addedAt).toLocaleDateString() }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex gap-2 md:flex-col md:h-auto md:justify-start">
|
|
<UButton
|
|
size="md"
|
|
color="primary"
|
|
variant="soft"
|
|
icon="i-lucide-external-link"
|
|
class="flex-1 md:flex-none"
|
|
@click="router.push(`/novels/${novel.id}`)"
|
|
>
|
|
View
|
|
</UButton>
|
|
<UButton
|
|
size="md"
|
|
color="error"
|
|
variant="soft"
|
|
icon="i-lucide-trash-2"
|
|
class="flex-1 md:flex-none"
|
|
@click="removeFromLibrary(novel.id)"
|
|
>
|
|
Remove
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</UContainer>
|
|
</template>
|
|
</UDashboardPanel>
|
|
</template>
|