albums
This commit is contained in:
2234
openapi/openapi.json
Normal file
2234
openapi/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ const navItems = computed(() => {
|
|||||||
const items = [
|
const items = [
|
||||||
{ path: '/', icon: '🏠', tooltip: 'Home' },
|
{ path: '/', icon: '🏠', tooltip: 'Home' },
|
||||||
{ path: '/assets', icon: '📂', tooltip: 'Assets' },
|
{ path: '/assets', icon: '📂', tooltip: 'Assets' },
|
||||||
|
{ path: '/albums', icon: '🖼️', tooltip: 'Albums' },
|
||||||
// { path: '/generation', icon: '🎨', tooltip: 'Image Generation' },
|
// { path: '/generation', icon: '🎨', tooltip: 'Image Generation' },
|
||||||
{ path: '/flexible', icon: '🖌️', tooltip: 'Flexible Generation' },
|
{ path: '/flexible', icon: '🖌️', tooltip: 'Flexible Generation' },
|
||||||
{ path: '/characters', icon: '👥', tooltip: 'Characters' },
|
{ path: '/characters', icon: '👥', tooltip: 'Characters' },
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ const router = createRouter({
|
|||||||
path: '/flexible',
|
path: '/flexible',
|
||||||
name: 'flexible',
|
name: 'flexible',
|
||||||
component: () => import('../views/FlexibleGenerationView.vue')
|
component: () => import('../views/FlexibleGenerationView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/albums',
|
||||||
|
name: 'albums',
|
||||||
|
component: () => import('../views/AlbumsView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/albums/:id',
|
||||||
|
name: 'album-detail',
|
||||||
|
component: () => import('../views/AlbumDetailView.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
12
src/services/albumService.js
Normal file
12
src/services/albumService.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import api from './api';
|
||||||
|
|
||||||
|
export const albumService = {
|
||||||
|
getAlbums: (limit = 10, offset = 0) => api.get('/albums', { params: { limit, offset } }),
|
||||||
|
createAlbum: (data) => api.post('/albums', data),
|
||||||
|
getAlbum: (id) => api.get(`/albums/${id}`),
|
||||||
|
updateAlbum: (id, data) => api.put(`/albums/${id}`, data),
|
||||||
|
deleteAlbum: (id) => api.delete(`/albums/${id}`),
|
||||||
|
addGenerationToAlbum: (albumId, generationId) => api.post(`/albums/${albumId}/generations/${generationId}`),
|
||||||
|
removeGenerationFromAlbum: (albumId, generationId) => api.delete(`/albums/${albumId}/generations/${generationId}`),
|
||||||
|
getAlbumGenerations: (albumId, limit = 10, offset = 0) => api.get(`/albums/${albumId}/generations`, { params: { limit, offset } })
|
||||||
|
};
|
||||||
153
src/stores/albums.js
Normal file
153
src/stores/albums.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { albumService } from '../services/albumService';
|
||||||
|
|
||||||
|
export const useAlbumStore = defineStore('albums', () => {
|
||||||
|
const albums = ref([]);
|
||||||
|
const currentAlbum = ref(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
const totalAlbums = ref(0);
|
||||||
|
|
||||||
|
async function fetchAlbums(limit = 10, offset = 0) {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const response = await albumService.getAlbums(limit, offset);
|
||||||
|
albums.value = response.data; // Assuming response.data is the list or { albums: [], total_count: ... }
|
||||||
|
// Check if response has total_count structure
|
||||||
|
if (response.data.albums) {
|
||||||
|
albums.value = response.data.albums;
|
||||||
|
totalAlbums.value = response.data.total_count;
|
||||||
|
} else {
|
||||||
|
// Fallback if structure is different
|
||||||
|
albums.value = response.data;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching albums:', err);
|
||||||
|
error.value = err.response?.data?.detail || 'Failed to fetch albums';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAlbum(data) {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await albumService.createAlbum(data);
|
||||||
|
await fetchAlbums(); // Refresh list
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating album:', err);
|
||||||
|
error.value = err.response?.data?.detail || 'Failed to create album';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAlbum(id) {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
currentAlbum.value = null;
|
||||||
|
try {
|
||||||
|
const response = await albumService.getAlbum(id);
|
||||||
|
currentAlbum.value = response.data;
|
||||||
|
return response.data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching album:', err);
|
||||||
|
error.value = err.response?.data?.detail || 'Failed to fetch album';
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAlbum(id, data) {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await albumService.updateAlbum(id, data);
|
||||||
|
if (currentAlbum.value && currentAlbum.value.id === id) {
|
||||||
|
await fetchAlbum(id);
|
||||||
|
}
|
||||||
|
await fetchAlbums();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating album:', err);
|
||||||
|
error.value = err.response?.data?.detail || 'Failed to update album';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAlbum(id) {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await albumService.deleteAlbum(id);
|
||||||
|
await fetchAlbums(); // Refresh list
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting album:', err);
|
||||||
|
error.value = err.response?.data?.detail || 'Failed to delete album';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addGenerationToAlbum(albumId, generationId) {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await albumService.addGenerationToAlbum(albumId, generationId);
|
||||||
|
// Optionally refresh current album if it matches
|
||||||
|
if (currentAlbum.value && currentAlbum.value.id === albumId) {
|
||||||
|
await fetchAlbum(albumId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error adding generation to album:', err);
|
||||||
|
error.value = err.response?.data?.detail || 'Failed to add generation to album';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeGenerationFromAlbum(albumId, generationId) {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await albumService.removeGenerationFromAlbum(albumId, generationId);
|
||||||
|
if (currentAlbum.value && currentAlbum.value.id === albumId) {
|
||||||
|
await fetchAlbum(albumId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error removing generation from album:', err);
|
||||||
|
error.value = err.response?.data?.detail || 'Failed to remove generation from album';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
albums,
|
||||||
|
currentAlbum,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
totalAlbums,
|
||||||
|
fetchAlbums,
|
||||||
|
createAlbum,
|
||||||
|
fetchAlbum,
|
||||||
|
updateAlbum,
|
||||||
|
deleteAlbum,
|
||||||
|
addGenerationToAlbum,
|
||||||
|
removeGenerationFromAlbum
|
||||||
|
};
|
||||||
|
});
|
||||||
274
src/views/AlbumDetailView.vue
Normal file
274
src/views/AlbumDetailView.vue
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useAlbumStore } from '../stores/albums'
|
||||||
|
import { albumService } from '../services/albumService'
|
||||||
|
import { aiService } from '../services/aiService'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import Skeleton from 'primevue/skeleton'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import ConfirmDialog from 'primevue/confirmdialog'
|
||||||
|
import { useConfirm } from 'primevue/useconfirm'
|
||||||
|
import Image from 'primevue/image'
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const albumStore = useAlbumStore()
|
||||||
|
const { currentAlbum, loading } = storeToRefs(albumStore)
|
||||||
|
const confirm = useConfirm()
|
||||||
|
|
||||||
|
const generations = ref([])
|
||||||
|
const loadingGenerations = ref(false)
|
||||||
|
|
||||||
|
// Gen Picker State
|
||||||
|
const isGenerationPickerVisible = ref(false)
|
||||||
|
const availableGenerations = ref([])
|
||||||
|
const loadingAvailableGenerations = ref(false)
|
||||||
|
const selectedGenerations = ref([])
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const id = route.params.id
|
||||||
|
if (id) {
|
||||||
|
await albumStore.fetchAlbum(id)
|
||||||
|
if (currentAlbum.value) {
|
||||||
|
fetchGenerations(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchGenerations = async (albumId) => {
|
||||||
|
loadingGenerations.value = true
|
||||||
|
try {
|
||||||
|
const response = await albumService.getAlbumGenerations(albumId, 100) // Increase limit for now
|
||||||
|
generations.value = response.data.generations || [] // Adjust based on actual API response structure
|
||||||
|
// If API returns list directly:
|
||||||
|
if (Array.isArray(response.data)) {
|
||||||
|
generations.value = response.data
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load generations', e)
|
||||||
|
} finally {
|
||||||
|
loadingGenerations.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
confirm.require({
|
||||||
|
message: 'Are you sure you want to delete this album? This action cannot be undone.',
|
||||||
|
header: 'Delete Album',
|
||||||
|
icon: 'pi pi-exclamation-triangle',
|
||||||
|
acceptClass: 'p-button-danger',
|
||||||
|
accept: async () => {
|
||||||
|
await albumStore.deleteAlbum(currentAlbum.value.id)
|
||||||
|
router.push('/albums')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyPrompt = (prompt) => {
|
||||||
|
navigator.clipboard.writeText(prompt)
|
||||||
|
// Could add a toast notification here
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Generation Picker ---
|
||||||
|
const openGenerationPicker = async () => {
|
||||||
|
isGenerationPickerVisible.value = true
|
||||||
|
selectedGenerations.value = []
|
||||||
|
await loadAvailableGenerations()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadAvailableGenerations = async () => {
|
||||||
|
loadingAvailableGenerations.value = true
|
||||||
|
try {
|
||||||
|
// Fetch recent generations to add
|
||||||
|
const response = await aiService.getGenerations(50, 0)
|
||||||
|
availableGenerations.value = response.generations || []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load available generations', e)
|
||||||
|
} finally {
|
||||||
|
loadingAvailableGenerations.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleGenerationSelection = (gen) => {
|
||||||
|
const index = selectedGenerations.value.findIndex(g => g.id === gen.id)
|
||||||
|
if (index === -1) {
|
||||||
|
selectedGenerations.value.push(gen)
|
||||||
|
} else {
|
||||||
|
selectedGenerations.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSelectedGenerations = async () => {
|
||||||
|
if (selectedGenerations.value.length === 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Add sequentially
|
||||||
|
for (const gen of selectedGenerations.value) {
|
||||||
|
await albumService.addGenerationToAlbum(currentAlbum.value.id, gen.id)
|
||||||
|
}
|
||||||
|
isGenerationPickerVisible.value = false
|
||||||
|
// Refresh album generations
|
||||||
|
await fetchGenerations(currentAlbum.value.id)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to add generations to album', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeGeneration = (gen) => {
|
||||||
|
confirm.require({
|
||||||
|
message: 'Are you sure you want to remove this generation from the album?',
|
||||||
|
header: 'Remove Generation',
|
||||||
|
icon: 'pi pi-exclamation-triangle',
|
||||||
|
acceptClass: 'p-button-danger',
|
||||||
|
accept: async () => {
|
||||||
|
await albumStore.removeGenerationFromAlbum(currentAlbum.value.id, gen.id)
|
||||||
|
await fetchGenerations(currentAlbum.value.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full p-8 overflow-y-auto text-slate-100">
|
||||||
|
<ConfirmDialog></ConfirmDialog>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loading" class="flex flex-col gap-8">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<Skeleton width="15rem" height="3rem" class="mb-4" />
|
||||||
|
<Skeleton width="20rem" height="1.5rem" />
|
||||||
|
</div>
|
||||||
|
<Skeleton width="8rem" height="2.5rem" />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
<Skeleton v-for="i in 8" :key="i" height="16rem" class="rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="currentAlbum">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="flex justify-between items-start mb-8 pb-8 border-b border-white/10">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-4 mb-2">
|
||||||
|
<Button icon="pi pi-arrow-left" text rounded @click="router.push('/albums')"
|
||||||
|
class="!text-slate-400 hover:!text-white hover:!bg-white/10" />
|
||||||
|
<h1 class="text-4xl font-bold m-0">{{ currentAlbum.name }}</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-slate-400 text-lg ml-14 max-w-2xl">{{ currentAlbum.description }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button label="Add Generation" icon="pi pi-plus" text @click="openGenerationPicker" />
|
||||||
|
<Button label="Delete Album" icon="pi pi-trash" severity="danger" text @click="confirmDelete" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Generations Grid -->
|
||||||
|
<div v-if="loadingGenerations" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||||
|
<Skeleton v-for="i in 8" :key="i" height="20rem" class="rounded-xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="generations.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center py-20 text-slate-500">
|
||||||
|
<i class="pi pi-image text-5xl mb-4 opacity-50"></i>
|
||||||
|
<p class="text-xl">No generations in this album yet.</p>
|
||||||
|
<p class="text-sm mt-2">Generate images and add them to this album!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||||
|
<!-- We need to adapt this based on actual generation object structure -->
|
||||||
|
<div v-for="gen in generations" :key="gen.id"
|
||||||
|
class="glass-panel rounded-xl overflow-hidden group relative transition-all hover:bg-white/5">
|
||||||
|
|
||||||
|
<div class="aspect-[2/3] w-full bg-slate-800 relative overflow-hidden">
|
||||||
|
<Image :src="gen.result || API_URL + `/assets/${gen.result_list[0]}` + '?thumbnail=true'"
|
||||||
|
preview class="w-full h-full object-cover" imageClass="w-full h-full object-cover" />
|
||||||
|
|
||||||
|
<!-- Overlay Actions -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2 pointer-events-none">
|
||||||
|
<div class="pointer-events-auto flex gap-2">
|
||||||
|
<Button icon="pi pi-copy" rounded text class="!text-white hover:!bg-white/20"
|
||||||
|
v-tooltip.top="'Copy Prompt'" @click="copyPrompt(gen.prompt)" />
|
||||||
|
<Button icon="pi pi-trash" rounded text
|
||||||
|
class="!text-white hover:!bg-red-500/80 hover:!text-white"
|
||||||
|
v-tooltip.top="'Remove from Album'" @click="removeGeneration(gen)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3">
|
||||||
|
<p class="text-xs text-slate-400 line-clamp-2" :title="gen.prompt">
|
||||||
|
{{ gen.prompt }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-col items-center justify-center h-full text-slate-400">
|
||||||
|
<i class="pi pi-exclamation-circle text-4xl mb-4 text-red-400"></i>
|
||||||
|
<p class="text-xl">Album not found</p>
|
||||||
|
<Button label="Back to Albums" class="mt-4 p-button-text" @click="router.push('/albums')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog v-model:visible="isGenerationPickerVisible" modal header="Add Generations"
|
||||||
|
:style="{ width: '80vw', maxWidth: '1000px' }"
|
||||||
|
:pt="{ root: { class: '!bg-slate-900 !border !border-white/10' }, header: { class: '!bg-slate-900 !border-b !border-white/5 !text-white' }, content: { class: '!bg-slate-900 !p-4' }, footer: { class: '!bg-slate-900 !border-t !border-white/5 !p-4' }, closeButton: { class: '!text-slate-400 hover:!text-white' } }">
|
||||||
|
|
||||||
|
<div class="h-[60vh] overflow-y-auto custom-scrollbar">
|
||||||
|
<div v-if="loadingAvailableGenerations" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
|
<Skeleton v-for="i in 10" :key="i" height="150px" class="!bg-slate-800 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="availableGenerations.length > 0"
|
||||||
|
class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
|
<div v-for="gen in availableGenerations" :key="gen.id" @click="toggleGenerationSelection(gen)"
|
||||||
|
class="relative aspect-[2/3] rounded-xl overflow-hidden cursor-pointer border-2 transition-all group"
|
||||||
|
:class="selectedGenerations.some(g => g.id === gen.id) ? 'border-violet-500 ring-2 ring-violet-500/30' : 'border-transparent hover:border-white/20'">
|
||||||
|
|
||||||
|
<img v-if="gen.result_list && gen.result_list.length > 0"
|
||||||
|
:src="gen.result_list[0].includes('http') ? gen.result_list[0] : (gen.result || API_URL + `/assets/${gen.result_list[0]}` + '?thumbnail=true')"
|
||||||
|
class="w-full h-full object-cover" />
|
||||||
|
<!-- Fallback for no result -->
|
||||||
|
<div v-else class="w-full h-full bg-slate-800 flex items-center justify-center text-slate-500">
|
||||||
|
<i class="pi pi-image text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 p-2 bg-black/60 backdrop-blur-sm">
|
||||||
|
<p class="text-[10px] text-white line-clamp-2">{{ gen.prompt }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Checkmark -->
|
||||||
|
<div v-if="selectedGenerations.some(g => g.id === gen.id)"
|
||||||
|
class="absolute top-2 right-2 w-6 h-6 bg-violet-500 rounded-full flex items-center justify-center shadow-lg animate-in zoom-in duration-200">
|
||||||
|
<i class="pi pi-check text-white text-xs"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-col items-center justify-center h-full text-slate-500">
|
||||||
|
<p>No generations found.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button label="Cancel" @click="isGenerationPickerVisible = false"
|
||||||
|
class="!text-slate-300 hover:!bg-white/5" text />
|
||||||
|
<Button :label="'Add (' + selectedGenerations.length + ')'" @click="addSelectedGenerations"
|
||||||
|
class="!bg-violet-600 !border-none hover:!bg-violet-500" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.glass-panel {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
123
src/views/AlbumsView.vue
Normal file
123
src/views/AlbumsView.vue
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAlbumStore } from '../stores/albums'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import Skeleton from 'primevue/skeleton'
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
import InputText from 'primevue/inputtext'
|
||||||
|
import Textarea from 'primevue/textarea'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import Image from 'primevue/image'
|
||||||
|
const router = useRouter()
|
||||||
|
const albumStore = useAlbumStore()
|
||||||
|
const { albums, loading } = storeToRefs(albumStore)
|
||||||
|
|
||||||
|
const showCreateDialog = ref(false)
|
||||||
|
const newAlbum = ref({ name: '', description: '' })
|
||||||
|
const submitting = ref(false)
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await albumStore.fetchAlbums()
|
||||||
|
})
|
||||||
|
|
||||||
|
const goToDetail = (id) => {
|
||||||
|
router.push({ name: 'album-detail', params: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAlbum = async () => {
|
||||||
|
if (!newAlbum.value.name) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await albumStore.createAlbum(newAlbum.value)
|
||||||
|
showCreateDialog.value = false
|
||||||
|
newAlbum.value = { name: '', description: '' }
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full p-8 overflow-y-auto text-slate-100">
|
||||||
|
<!-- Top Bar -->
|
||||||
|
<header class="flex justify-between items-end mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl font-bold m-0">Albums</h1>
|
||||||
|
<p class="mt-2 mb-0 text-slate-400">Organize your generations</p>
|
||||||
|
</div>
|
||||||
|
<Button label="Create Album" icon="pi pi-plus" @click="showCreateDialog = true"
|
||||||
|
class="p-button-outlined p-button-secondary !text-white !border-white/20 hover:!bg-white/10" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="loading && albums.length === 0" 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>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else-if="albums.length === 0" 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>
|
||||||
|
|
||||||
|
<!-- 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="goToDetail(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" />
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- 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="submitting" autofocus />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.glass-panel {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -13,15 +13,22 @@ import MultiSelect from 'primevue/multiselect'
|
|||||||
import ProgressSpinner from 'primevue/progressspinner'
|
import ProgressSpinner from 'primevue/progressspinner'
|
||||||
import ProgressBar from 'primevue/progressbar'
|
import ProgressBar from 'primevue/progressbar'
|
||||||
import Message from 'primevue/message'
|
import Message from 'primevue/message'
|
||||||
|
|
||||||
import Skeleton from 'primevue/skeleton'
|
import Skeleton from 'primevue/skeleton'
|
||||||
|
import { useAlbumStore } from '../stores/albums'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const API_URL = import.meta.env.VITE_API_URL
|
const API_URL = import.meta.env.VITE_API_URL
|
||||||
|
const albumStore = useAlbumStore()
|
||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
const prompt = ref('')
|
const prompt = ref('')
|
||||||
const selectedCharacter = ref(null)
|
const selectedCharacter = ref(null)
|
||||||
const selectedAssets = ref([])
|
const selectedAssets = ref([])
|
||||||
|
// Album Picker State
|
||||||
|
const isAlbumPickerVisible = ref(false)
|
||||||
|
const generationToAdd = ref(null)
|
||||||
|
const selectedAlbumForAdd = ref(null)
|
||||||
// Asset Picker State
|
// Asset Picker State
|
||||||
const isAssetPickerVisible = ref(false)
|
const isAssetPickerVisible = ref(false)
|
||||||
const assetPickerTab = ref('all') // 'all', 'uploaded', 'generated'
|
const assetPickerTab = ref('all') // 'all', 'uploaded', 'generated'
|
||||||
@@ -437,6 +444,26 @@ watch(assetPickerTab, () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Album Picker Logic ---
|
||||||
|
const openAlbumPicker = (gen) => {
|
||||||
|
generationToAdd.value = gen
|
||||||
|
isAlbumPickerVisible.value = true
|
||||||
|
albumStore.fetchAlbums() // Fetch albums when opening picker
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmAddToAlbum = async () => {
|
||||||
|
if (!generationToAdd.value || !selectedAlbumForAdd.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await albumStore.addGenerationToAlbum(selectedAlbumForAdd.value.id, generationToAdd.value.id)
|
||||||
|
isAlbumPickerVisible.value = false
|
||||||
|
selectedAlbumForAdd.value = null
|
||||||
|
generationToAdd.value = null
|
||||||
|
// Optional: Show success toast
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to add to album', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -504,6 +531,9 @@ watch(assetPickerTab, () => {
|
|||||||
v-tooltip.left="'Edit (Use Result)'"
|
v-tooltip.left="'Edit (Use Result)'"
|
||||||
class="!w-6 !h-6 !rounded-full !bg-white/20 !border-none !text-white text-[10px] hover:!bg-violet-500"
|
class="!w-6 !h-6 !rounded-full !bg-white/20 !border-none !text-white text-[10px] hover:!bg-violet-500"
|
||||||
@click.stop="useResultAsAsset(gen)" />
|
@click.stop="useResultAsAsset(gen)" />
|
||||||
|
<Button icon="pi pi-folder-plus" v-tooltip.left="'Add to Album'"
|
||||||
|
class="!w-6 !h-6 !rounded-full !bg-white/20 !border-none !text-white text-[10px] hover:!bg-violet-500"
|
||||||
|
@click.stop="openAlbumPicker(gen)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -757,6 +787,36 @@ watch(assetPickerTab, () => {
|
|||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<Dialog v-model:visible="isAlbumPickerVisible" modal header="Add to Album" :style="{ width: '400px' }"
|
||||||
|
:pt="{ root: { class: '!bg-slate-900 !border !border-white/10' }, header: { class: '!bg-slate-900 !border-b !border-white/5 !text-white' }, content: { class: '!bg-slate-900 !p-4' }, footer: { class: '!bg-slate-900 !border-t !border-white/5 !p-4' }, closeButton: { class: '!text-slate-400 hover:!text-white' } }">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div v-if="albumStore.loading" class="flex justify-center p-4">
|
||||||
|
<i class="pi pi-spin pi-spinner text-violet-500 text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="albumStore.albums.length === 0" class="text-center text-slate-400">
|
||||||
|
No albums found. Create one first!
|
||||||
|
</div>
|
||||||
|
<Dropdown v-else v-model="selectedAlbumForAdd" :options="albumStore.albums" optionLabel="name" placeholder="Select an Album"
|
||||||
|
class="w-full !bg-slate-800 !border-white/10 !text-white"
|
||||||
|
:pt="{
|
||||||
|
root: { class: '!bg-slate-800' },
|
||||||
|
input: { class: '!text-white' },
|
||||||
|
trigger: { class: '!text-slate-400' },
|
||||||
|
panel: { class: '!bg-slate-900 !border-white/10' },
|
||||||
|
item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white' }
|
||||||
|
}" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button label="Cancel" @click="isAlbumPickerVisible = false" class="!text-slate-300 hover:!bg-white/5" text />
|
||||||
|
<Button label="Add" @click="confirmAddToAlbum" :disabled="!selectedAlbumForAdd"
|
||||||
|
class="!bg-violet-600 !border-none hover:!bg-violet-500" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user