412 lines
21 KiB
Vue
412 lines
21 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, computed, watch } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { dataService } from '../services/dataService'
|
|
import type { Asset } from '@/models/asset'
|
|
import Button from 'primevue/button'
|
|
import Skeleton from 'primevue/skeleton'
|
|
import Dialog from 'primevue/dialog'
|
|
import Paginator from 'primevue/paginator'
|
|
import ConfirmDialog from 'primevue/confirmdialog'
|
|
import { useConfirm } from "primevue/useconfirm"
|
|
import Toast from 'primevue/toast'
|
|
import { useToast } from "primevue/usetoast"
|
|
import { useAlbumStore } from '../stores/albums'
|
|
import { storeToRefs } from 'pinia'
|
|
import InputText from 'primevue/inputtext'
|
|
import Textarea from 'primevue/textarea'
|
|
import Image from 'primevue/image'
|
|
|
|
const router = useRouter()
|
|
const confirm = useConfirm()
|
|
const toast = useToast()
|
|
const assets = ref<Asset[]>([])
|
|
const loading = ref(true)
|
|
const activeFilter = ref('all')
|
|
// @ts-ignore
|
|
const API_URL = import.meta.env.VITE_API_URL
|
|
|
|
// Albums Logic
|
|
const albumStore = useAlbumStore()
|
|
const { albums, loading: albumsLoading } = storeToRefs(albumStore)
|
|
const viewMode = ref('assets') // 'assets' | 'albums'
|
|
const showCreateDialog = ref(false)
|
|
const newAlbum = ref({ name: '', description: '' })
|
|
const submittingAlbum = ref(false)
|
|
|
|
const selectedAsset = ref<Asset | null>(null)
|
|
const isModalVisible = ref(false)
|
|
|
|
const first = ref(0)
|
|
const rows = ref(18)
|
|
const totalRecords = ref(0)
|
|
const fileInput = ref<HTMLInputElement | null>(null)
|
|
|
|
const triggerFileUpload = () => {
|
|
fileInput.value?.click()
|
|
}
|
|
|
|
const handleFileUpload = async (event: Event) => {
|
|
const target = event.target as HTMLInputElement
|
|
if (target.files && target.files[0]) {
|
|
const file = target.files[0]
|
|
try {
|
|
loading.value = true
|
|
await dataService.uploadAsset(file)
|
|
toast.add({ severity: 'success', summary: 'Success', detail: 'Asset uploaded successfully', life: 3000 })
|
|
// Reset to first page and reload
|
|
first.value = 0
|
|
activeFilter.value = 'uploaded' // Switch to uploaded view to see the new asset
|
|
loadAssets()
|
|
} catch (e) {
|
|
console.error('Failed to upload asset', e)
|
|
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to upload asset', life: 3000 })
|
|
} finally {
|
|
loading.value = false
|
|
target.value = '' // Reset input
|
|
}
|
|
}
|
|
}
|
|
|
|
const openModal = (asset: Asset) => {
|
|
selectedAsset.value = asset
|
|
isModalVisible.value = true
|
|
}
|
|
|
|
const loadAssets = async () => {
|
|
loading.value = true
|
|
try {
|
|
const response = await dataService.getAssets(rows.value, first.value, activeFilter.value)
|
|
if (response && response.assets) {
|
|
assets.value = response.assets
|
|
totalRecords.value = response.total_count || 0
|
|
} else {
|
|
// Fallback for unexpected response structure
|
|
assets.value = Array.isArray(response) ? response : []
|
|
totalRecords.value = assets.value.length
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load assets', e)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// Watch view mode to load albums
|
|
watch(viewMode, async (newMode) => {
|
|
if (newMode === 'albums' && albums.value.length === 0) {
|
|
await albumStore.fetchAlbums()
|
|
}
|
|
})
|
|
|
|
onMounted(() => {
|
|
loadAssets()
|
|
// Preload albums if needed or wait for switch
|
|
})
|
|
|
|
const createAlbum = async () => {
|
|
if (!newAlbum.value.name) return
|
|
submittingAlbum.value = true
|
|
try {
|
|
await albumStore.createAlbum(newAlbum.value)
|
|
showCreateDialog.value = false
|
|
newAlbum.value = { name: '', description: '' }
|
|
toast.add({ severity: 'success', summary: 'Success', detail: 'Album created', life: 3000 })
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to create album', life: 3000 })
|
|
} finally {
|
|
submittingAlbum.value = false
|
|
}
|
|
}
|
|
|
|
const goToAlbumDetail = (id: string) => {
|
|
router.push({ name: 'album-detail', params: { id } })
|
|
}
|
|
|
|
// Server-side filtering is now used, so we don't need a local filteredAssets computed property
|
|
// unless we want to do secondary filtering, but usually it's better to let the server handle it.
|
|
|
|
const paginatedAssets = computed(() => {
|
|
// With server-side pagination, assets.value already contains only the current page
|
|
return assets.value
|
|
})
|
|
|
|
const confirmDelete = (asset: Asset) => {
|
|
confirm.require({
|
|
message: 'Do you want to delete this asset?',
|
|
header: 'Delete Confirmation',
|
|
icon: 'pi pi-info-circle',
|
|
rejectLabel: 'Cancel',
|
|
acceptLabel: 'Delete',
|
|
rejectClass: 'p-button-secondary p-button-outlined',
|
|
acceptClass: 'p-button-danger',
|
|
accept: async () => {
|
|
try {
|
|
await dataService.deleteAsset(asset.id)
|
|
toast.add({ severity: 'success', summary: 'Confirmed', detail: 'Asset deleted', life: 3000 })
|
|
loadAssets()
|
|
} catch (e) {
|
|
console.error('Failed to delete asset', e)
|
|
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to delete asset', life: 3000 })
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
const onPage = (event: any) => {
|
|
first.value = event.first
|
|
rows.value = event.rows
|
|
loadAssets()
|
|
}
|
|
|
|
const handleFilterChange = (filter: string) => {
|
|
activeFilter.value = filter
|
|
first.value = 0 // Reset to first page when filter changes
|
|
loadAssets()
|
|
}
|
|
|
|
const goBack = () => {
|
|
router.push('/')
|
|
}
|
|
|
|
const formatDate = (dateString: string) => {
|
|
if (!dateString) return ''
|
|
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date(dateString))
|
|
}
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex flex-col h-full p-8 overflow-y-auto w-full text-slate-100">
|
|
<!-- Main Content (Sidebar removed) -->
|
|
<div class="flex-1 flex flex-col overflow-hidden">
|
|
<!-- Top Bar -->
|
|
<header class="flex justify-between items-center mb-4 px-1">
|
|
<div>
|
|
<h1
|
|
class="text-xl font-bold bg-gradient-to-r from-white to-slate-400 bg-clip-text text-transparent m-0">
|
|
Library</h1>
|
|
<p class="text-xs text-slate-500 mt-1">Manage your generations and albums</p>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-4">
|
|
<!-- View Switcher -->
|
|
<div class="glass-panel p-1 flex gap-1 rounded-xl bg-slate-800/50 border border-white/5">
|
|
<Button label="Assets" icon="pi pi-images"
|
|
:class="viewMode === 'assets' ? 'bg-white/10 text-slate-50 shadow-sm' : 'bg-transparent text-slate-400 hover:text-slate-200'"
|
|
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200" text
|
|
@click="viewMode = 'assets'" />
|
|
<Button label="Albums" icon="pi pi-book"
|
|
:class="viewMode === 'albums' ? 'bg-white/10 text-slate-50 shadow-sm' : 'bg-transparent text-slate-400 hover:text-slate-200'"
|
|
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200" text
|
|
@click="viewMode = 'albums'" />
|
|
</div>
|
|
|
|
<!-- Assets Toolbar -->
|
|
<div v-if="viewMode === 'assets'" class="flex items-center gap-2">
|
|
<input type="file" ref="fileInput" @change="handleFileUpload" class="hidden" accept="image/*" />
|
|
<Button label="Upload" icon="pi pi-upload" @click="triggerFileUpload"
|
|
class="!bg-violet-600 !border-none hover:!bg-violet-500 !text-xs !px-3 !py-1.5 !rounded-lg !font-medium shadow-lg shadow-violet-500/20" />
|
|
|
|
<div class="glass-panel p-1 flex gap-1 rounded-xl bg-slate-800/50 border border-white/5">
|
|
<Button v-for="filter in ['all', 'generated', 'uploaded']" :key="filter"
|
|
:label="filter.charAt(0).toUpperCase() + filter.slice(1)"
|
|
:class="activeFilter === filter ? 'bg-white/10 text-slate-50 shadow-sm' : 'bg-transparent text-slate-400 hover:text-slate-200'"
|
|
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200" text
|
|
@click="handleFilterChange(filter)" />
|
|
</div>
|
|
</div>
|
|
<!-- Albums Toolbar -->
|
|
<div v-if="viewMode === 'albums'" class="flex items-center gap-2">
|
|
<Button label="Create Album" icon="pi pi-plus" @click="showCreateDialog = true"
|
|
class="!bg-violet-600 !border-none hover:!bg-violet-500 !text-xs !px-3 !py-1.5 !rounded-lg !font-medium shadow-lg shadow-violet-500/20" />
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- ASSETS VIEW -->
|
|
<div v-if="viewMode === 'assets'" class="contents">
|
|
<!-- Loading State -->
|
|
<div v-if="loading"
|
|
class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-6 gap-2 md:gap-1 pb-4 overflow-y-auto custom-scrollbar">
|
|
<Skeleton v-for="i in 12" :key="i" class="aspect-[9/16] !bg-slate-800 rounded-none sm:rounded-md" />
|
|
</div>
|
|
|
|
<!-- Assets Grid -->
|
|
<div v-else class="flex-1 overflow-y-auto custom-scrollbar pb-20">
|
|
<div v-if="paginatedAssets.length > 0"
|
|
class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-6 gap-2 md:gap-1">
|
|
<div v-for="asset in paginatedAssets" :key="asset.id"
|
|
class="aspect-[9/16] relative group overflow-hidden bg-slate-800 transition-all duration-300 rounded-none sm:rounded-md border border-white/5 hover:border-white/20"
|
|
@click="openModal(asset)">
|
|
|
|
<!-- Image -->
|
|
<img :src="(API_URL + asset.url + '?thumbnail=true') || 'https://via.placeholder.com/300'"
|
|
:alt="asset.name"
|
|
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
|
|
|
|
<!-- Type Badge -->
|
|
<div v-if="asset.type !== 'image'"
|
|
class="absolute top-2 right-2 bg-black/60 backdrop-blur-sm px-1.5 py-0.5 rounded text-[10px] uppercase font-bold text-white z-10 opacity-70 group-hover:opacity-100 transition-opacity">
|
|
{{ asset.type }}
|
|
</div>
|
|
|
|
<!-- Hover Overlay -->
|
|
<div
|
|
class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex flex-col justify-between p-3">
|
|
|
|
<!-- Top Actions -->
|
|
<div
|
|
class="flex justify-between items-start translate-y-[-10px] group-hover:translate-y-0 transition-transform duration-200">
|
|
<Button icon="pi pi-trash" v-tooltip.right="'Delete'"
|
|
class="!w-7 !h-7 !rounded-full !bg-red-500/20 !border-none !text-red-400 text-xs hover:!bg-red-500 hover:!text-white transition-colors"
|
|
@click.stop="confirmDelete(asset)" />
|
|
|
|
<span v-if="asset.linked_char_id"
|
|
class="text-[10px] bg-emerald-500/20 text-emerald-400 px-2 py-1 rounded-full border border-emerald-500/20"
|
|
v-tooltip.left="'Linked to Character'">
|
|
Linked
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Center View Button -->
|
|
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
<Button icon="pi pi-eye" rounded text
|
|
class="!bg-black/40 !text-white !w-10 !h-10 !rounded-full opacity-0 group-hover:opacity-100 hover:!bg-black/60 hover:!scale-110 transition-all duration-300 pointer-events-auto !border border-white/20"
|
|
@click.stop="openModal(asset)" />
|
|
</div>
|
|
|
|
<!-- Bottom Info -->
|
|
<div
|
|
class="translate-y-[10px] group-hover:translate-y-0 transition-transform duration-200">
|
|
<p class="text-xs font-medium text-white line-clamp-1 mb-0.5">{{ asset.name }}</p>
|
|
<p class="text-[10px] text-slate-400">{{ formatDate(asset.created_at) }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="flex flex-col items-center justify-center h-64 text-slate-500">
|
|
<i class="pi pi-images text-4xl mb-3 opacity-50"></i>
|
|
<p>No assets found</p>
|
|
<Button v-if="activeFilter !== 'all'" label="Clear Filters" text class="mt-2 !text-violet-400"
|
|
@click="handleFilterChange('all')" />
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div v-if="totalRecords > rows" class="mt-6 flex justify-center">
|
|
<Paginator :first="first" :rows="rows" :totalRecords="totalRecords" @page="onPage" :template="{
|
|
default: 'FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink'
|
|
}" class="!bg-transparent !border-none !p-0" :pt="{
|
|
root: { class: '!bg-transparent' },
|
|
pages: { class: 'gap-1' },
|
|
pageButton: ({ context }) => ({
|
|
class: [
|
|
'!min-w-[32px] !h-8 !rounded-lg !border-none !transition-all !duration-200 !text-xs',
|
|
context.active ? '!bg-violet-600 !text-white !shadow-lg !shadow-violet-500/30' : '!bg-white/5 !text-slate-400 hover:!bg-white/10 hover:!text-slate-200'
|
|
]
|
|
}),
|
|
firstPageButton: { root: { class: '!bg-white/5 !text-slate-400 !border-none !rounded-lg !min-w-[32px] !h-8 hover:!bg-white/10 hover:!text-slate-200 transition-all !mr-1' } },
|
|
previousPageButton: { root: { class: '!bg-white/5 !text-slate-400 !border-none !rounded-lg !min-w-[32px] !h-8 hover:!bg-white/10 hover:!text-slate-200 transition-all !mr-2' } },
|
|
nextPageButton: { root: { class: '!bg-white/5 !text-slate-400 !border-none !rounded-lg !min-w-[32px] !h-8 hover:!bg-white/10 hover:!text-slate-200 transition-all !ml-2' } },
|
|
lastPageButton: { root: { class: '!bg-white/5 !text-slate-400 !border-none !rounded-lg !min-w-[32px] !h-8 hover:!bg-white/10 hover:!text-slate-200 transition-all !ml-1' } }
|
|
}" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ALBUMS VIEW -->
|
|
<div v-else class="flex-1 overflow-y-auto custom-scrollbar pb-20">
|
|
<!-- Empty State -->
|
|
<div v-if="albums.length === 0 && !albumsLoading"
|
|
class="flex flex-col items-center justify-center h-64 text-slate-400">
|
|
<i class="pi pi-folder-open text-6xl mb-4 opacity-50"></i>
|
|
<p class="text-xl">No albums found</p>
|
|
<Button label="Create your first album" class="mt-4 p-button-text"
|
|
@click="showCreateDialog = true" />
|
|
</div>
|
|
<!-- Loading State -->
|
|
<div v-else-if="albumsLoading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
<div v-for="i in 6" :key="i" class="glass-panel rounded-2xl p-6 flex flex-col gap-4">
|
|
<Skeleton height="12rem" class="w-full rounded-xl" />
|
|
<Skeleton width="60%" height="1.5rem" />
|
|
<Skeleton width="40%" height="1rem" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Albums Grid -->
|
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
<div v-for="album in albums" :key="album.id"
|
|
class="glass-panel rounded-2xl p-6 flex flex-col gap-4 transition-all duration-300 cursor-pointer border border-white/5 hover:-translate-y-1 hover:bg-white/5 hover:border-white/10 group"
|
|
@click="goToAlbumDetail(album.id)">
|
|
|
|
<!-- Album Cover Placeholder -->
|
|
<div v-if="album.cover_asset_id"
|
|
class="aspect-video w-full bg-slate-800 rounded-xl flex items-center justify-center overflow-hidden border border-white/5">
|
|
<Image :src="API_URL + '/assets/' + album.cover_asset_id + '?thumbnail=true'" preview
|
|
class="w-full h-full object-cover" imageClass="w-full h-full object-cover"
|
|
@click.stop />
|
|
</div>
|
|
<div v-else
|
|
class="aspect-video w-full bg-slate-800 rounded-xl flex items-center justify-center overflow-hidden border border-white/5">
|
|
<i
|
|
class="pi pi-images text-4xl text-slate-600 group-hover:text-slate-400 transition-colors"></i>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 class="m-0 mb-1 text-xl font-semibold truncate">{{ album.name }}</h3>
|
|
<p class="m-0 text-sm text-slate-400 line-clamp-2 min-h-[2.5em]">
|
|
{{ album.description || 'No description' }}
|
|
</p>
|
|
<div class="mt-3 flex items-center text-xs text-slate-500">
|
|
<i class="pi pi-image mr-1"></i>
|
|
<span>{{ album.generation_ids?.length || 0 }} items</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Dialog v-model:visible="isModalVisible" modal dismissableMask header="Asset View"
|
|
:style="{ width: '90vw', maxWidth: '800px' }" class="glass-panel rounded-2xl">
|
|
<div v-if="selectedAsset" class="flex flex-col items-center">
|
|
<img :src="selectedAsset.link ? API_URL + selectedAsset.link : (selectedAsset.url ? API_URL + selectedAsset.url : 'https://via.placeholder.com/800')"
|
|
:alt="selectedAsset.name" class="max-w-full max-h-[70vh] rounded-xl object-contain shadow-2xl" />
|
|
<div class="mt-6 text-center">
|
|
<h2 class="text-2xl font-bold mb-2">{{ selectedAsset.name }}</h2>
|
|
<p class="text-slate-400">{{ formatDate(selectedAsset.created_at) }}</p>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
|
|
<!-- Create Album Dialog -->
|
|
<Dialog v-model:visible="showCreateDialog" modal header="Create New Album" :style="{ width: '500px' }"
|
|
:breakpoints="{ '960px': '75vw', '641px': '90vw' }" class="p-dialog-custom">
|
|
<div class="flex flex-col gap-4 pt-4">
|
|
<div class="flex flex-col gap-2">
|
|
<label for="name" class="font-semibold">Name</label>
|
|
<InputText id="name" v-model="newAlbum.name" class="w-full" placeholder="My Awesome Album"
|
|
autofocus />
|
|
</div>
|
|
<div class="flex flex-col gap-2">
|
|
<label for="description" class="font-semibold">Description</label>
|
|
<Textarea id="description" v-model="newAlbum.description" rows="3" class="w-full"
|
|
placeholder="Optional description..." />
|
|
</div>
|
|
</div>
|
|
<template #footer>
|
|
<Button label="Cancel" icon="pi pi-times" @click="showCreateDialog = false" text />
|
|
<Button label="Create" icon="pi pi-check" @click="createAlbum" :loading="submittingAlbum" autofocus />
|
|
</template>
|
|
</Dialog>
|
|
|
|
<ConfirmDialog></ConfirmDialog>
|
|
<Toast />
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* Additional custom styles if needed */
|
|
</style>
|