Compare commits

23 Commits

Author SHA1 Message Date
xds
a737c38c53 models 2026-02-27 20:36:59 +03:00
xds
2ed2ee2937 likes 2026-02-27 13:51:14 +03:00
xds
4f9807cfe7 likes 2026-02-26 12:30:58 +03:00
xds
f89548b363 likes 2026-02-26 11:35:01 +03:00
xds
7d7cd25040 likes 2026-02-26 11:27:01 +03:00
xds
dea0916f6c likes 2026-02-24 17:32:48 +03:00
xds
122c5a7cbc likes 2026-02-24 12:47:19 +03:00
xds
a1d37ac517 likes 2026-02-24 12:04:17 +03:00
xds
1a7295aa77 fixes 2026-02-20 15:58:43 +03:00
xds
ccd7f8a2df fixes 2026-02-20 13:33:04 +03:00
xds
4136f42e70 fixes 2026-02-20 13:10:50 +03:00
xds
b0ce251914 fixes 2026-02-20 10:29:11 +03:00
xds
489fd14903 + env 2026-02-20 02:02:40 +03:00
xds
741857de92 + env 2026-02-19 21:25:48 +03:00
xds
6de5ded2fa fixes 2026-02-18 17:09:32 +03:00
xds
a6faa89686 fixes 2026-02-18 16:37:40 +03:00
xds
27da4c042e fixes 2026-02-18 16:34:40 +03:00
xds
0cc5150f9c fixes 2026-02-18 16:34:11 +03:00
xds
f8adcf33d3 feat: Implement Web Share API for mobile image sharing and standardize prompt textarea heights across views. 2026-02-17 18:17:00 +03:00
674cbb8f16 Merge pull request 'feat: Implement content planning and post management with a new service and calendar view.' (#3) from posts into main
Reviewed-on: #3
2026-02-17 12:55:21 +00:00
xds
ff07ca6ae0 feat: Implement content planning and post management with a new service and calendar view. 2026-02-17 15:54:36 +03:00
xds
6bda0db181 feat: Implement native file sharing with Web Share API, falling back to individual downloads. 2026-02-16 17:46:35 +03:00
xds
fc9048fc94 feat: Implement multi-select and download functionality for gallery images and add npm install to the deployment script. 2026-02-16 17:37:41 +03:00
28 changed files with 5262 additions and 3337 deletions

3
.gitignore vendored
View File

@@ -37,3 +37,6 @@ __screenshots__/
# Vite
*.timestamp-*-*.mjs
package-lock.json

View File

@@ -2,6 +2,7 @@
ssh root@31.59.58.220 "
cd /root/ai/ai-service-front &&
git pull &&
npm install &&
npm run build &&
cp -r dist/* /var/www/ai.luminic.space/
"

File diff suppressed because it is too large Load Diff

107
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@primeuix/themes": "^2.0.3",
"@primevue/themes": "^4.5.4",
"axios": "^1.13.4",
"jszip": "^3.10.1",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",
"primevue": "^4.5.4",
@@ -22,6 +23,7 @@
"@tailwindcss/postcss": "^4.1.18",
"@vitejs/plugin-vue": "^6.0.3",
"autoprefixer": "^10.4.24",
"baseline-browser-mapping": "^2.9.19",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"vite": "^7.3.1",
@@ -3896,6 +3898,12 @@
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4861,6 +4869,18 @@
"dev": true,
"license": "ISC"
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -5474,6 +5494,18 @@
"node": ">=0.10.0"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/kolorist": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
@@ -5491,6 +5523,15 @@
"node": ">=6"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@@ -6058,6 +6099,12 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -6244,6 +6291,12 @@
"node": ">=12.11.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -6286,6 +6339,33 @@
"safe-buffer": "^5.1.0"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
@@ -6647,6 +6727,12 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -6862,6 +6948,21 @@
"node": ">= 0.4"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -7464,6 +7565,12 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",

View File

@@ -12,6 +12,7 @@
"@primeuix/themes": "^2.0.3",
"@primevue/themes": "^4.5.4",
"axios": "^1.13.4",
"jszip": "^3.10.1",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",
"primevue": "^4.5.4",
@@ -23,6 +24,7 @@
"@tailwindcss/postcss": "^4.1.18",
"@vitejs/plugin-vue": "^6.0.3",
"autoprefixer": "^10.4.24",
"baseline-browser-mapping": "^2.9.19",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"vite": "^7.3.1",

View File

@@ -58,6 +58,12 @@
font-weight: normal;
}
input,
textarea,
select {
font-size: 16px;
}
body {
min-height: 100vh;
color: var(--color-text);

View File

@@ -218,19 +218,27 @@ h1, h2, h3, h4, h5, h6 {
/* --- Textarea / Inputs --- */
.p-textarea,
.p-inputtext {
.p-inputtext,
.p-dropdown,
.p-multiselect,
.p-autocomplete,
.p-inputnumber input {
width: 100%;
background: rgba(15, 23, 42, 0.6) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
border-radius: 8px !important;
padding: 0.5rem !important;
color: white !important;
font-size: 0.8125rem !important;
font-size: 1rem !important;
transition: all 0.3s ease !important;
}
.p-textarea:focus,
.p-inputtext:focus {
.p-inputtext:focus,
.p-dropdown:focus,
.p-multiselect:focus,
.p-autocomplete:focus,
.p-inputnumber input:focus {
outline: none !important;
border-color: #8b5cf6 !important;
box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.1) !important;
@@ -274,3 +282,59 @@ h1, h2, h3, h4, h5, h6 {
background: rgba(255,255,255,0.1) !important;
color: white !important;
}
/* --- ConfirmDialog --- */
.p-confirmdialog {
background: #1e293b !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
border-radius: 1rem !important;
}
.p-confirmdialog .p-dialog-header {
background: transparent !important;
color: white !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.05) !important;
}
.p-confirmdialog .p-dialog-content {
background: transparent !important;
color: #f8fafc !important;
padding: 2rem !important;
}
.p-confirmdialog .p-dialog-footer {
background: transparent !important;
border-top: 1px solid rgba(255, 255, 255, 0.05) !important;
padding: 1rem !important;
display: flex !important;
justify-content: flex-end !important;
gap: 0.5rem !important;
}
/* --- Specific Button Styles --- */
.p-button-danger {
background: #ef4444 !important;
border-color: #ef4444 !important;
color: white !important;
}
.p-button-danger:hover {
background: #dc2626 !important;
border-color: #dc2626 !important;
}
.p-button-secondary {
background: rgba(255, 255, 255, 0.1) !important;
border-color: transparent !important;
color: #94a3b8 !important;
}
.p-button-secondary:hover {
background: rgba(255, 255, 255, 0.15) !important;
color: white !important;
}
.p-button-text {
background: transparent !important;
border-color: transparent !important;
}

View File

@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useProjectsStore } from '@/stores/projectsStore'
import { aiService } from '@/services/aiService'
import { storeToRefs } from 'pinia'
@@ -13,6 +14,17 @@ const projectsStore = useProjectsStore()
const { projects, currentProject } = storeToRefs(projectsStore)
const selectedProject = ref(null)
const usageCost = ref(0)
const fetchUsage = async () => {
try {
// Fetch current context usage (user or project depending on header)
const report = await aiService.getUsageReport()
usageCost.value = report.summary?.total_cost || 0
} catch (e) {
console.error("Failed to fetch sidebar usage", e)
}
}
onMounted(async () => {
// Ensure we have projects
@@ -23,6 +35,7 @@ onMounted(async () => {
if (currentProject.value) {
selectedProject.value = currentProject.value.id
}
fetchUsage()
})
// Watch for external changes (like selecting from the list view)
@@ -70,11 +83,12 @@ const isActive = (path) => {
const navItems = computed(() => {
const items = [
{ path: '/', icon: '🏠', tooltip: 'Home' },
// { path: '/', icon: '🏠', tooltip: 'Home' },
{ path: '/projects', icon: '📂', tooltip: 'Projects' },
{ path: '/content-plan', icon: '📅', tooltip: 'Plan' },
{ path: '/ideas', icon: '💡', tooltip: 'Ideas' },
{ path: '/flexible', icon: '🖌️', tooltip: 'Flexible' },
{ path: '/albums', icon: '🖼️', tooltip: 'Library' },
// { path: '/albums', icon: '🖼️', tooltip: 'Library' },
{ path: '/characters', icon: '👥', tooltip: 'Characters' }
]
@@ -90,52 +104,48 @@ const navItems = computed(() => {
<div class="contents">
<!-- Sidebar (Desktop -> Top Bar) -->
<nav
class="hidden md:flex glass-panel w-[calc(100%-2rem)] mx-4 mt-4 mb-2 flex-row items-center px-8 py-3 rounded-2xl z-40 border border-white/5 bg-slate-900/50 backdrop-blur-md shrink-0 justify-between">
class="hidden md:flex glass-panel w-[calc(100%-2rem)] mx-4 mt-2 mb-1 flex-row items-center px-4 py-1.5 rounded-xl z-40 border border-white/5 bg-slate-900/50 backdrop-blur-md shrink-0 justify-between">
<!-- Logo -->
<img src="/web-app-manifest-512x512.png" alt="Logo"
class="w-10 h-10 rounded-xl shadow-lg shadow-violet-500/20 shrink-0" />
class="w-7 h-7 rounded-lg shadow-lg shadow-violet-500/20 shrink-0" />
<!-- Project Switcher -->
<div class="hidden lg:block ml-4 relative">
<div class="hidden lg:block ml-2 relative">
<button @click="isProjectMenuOpen = !isProjectMenuOpen"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-white/5 transition-colors text-slate-400 hover:text-slate-200">
<i v-if="selectedProject" class="pi pi-folder text-violet-400"></i>
<i v-else class="pi pi-user"></i>
class="flex items-center gap-1.5 px-2 py-1 rounded-lg hover:bg-white/5 transition-colors text-slate-400 hover:text-slate-200 text-xs">
<i v-if="selectedProject" class="pi pi-folder text-violet-400 text-[10px]"></i>
<i v-else class="pi pi-user text-[10px]"></i>
<span class="max-w-[150px] truncate font-medium">
{{ selectedProject ? getProjectName(selectedProject) : 'Personal Workspace' }}
<span class="max-w-[120px] truncate font-medium">
{{ selectedProject ? getProjectName(selectedProject) : 'Personal' }}
</span>
<i class="pi pi-chevron-down text-xs ml-1 opacity-50"></i>
<i class="pi pi-chevron-down text-[8px] opacity-50"></i>
</button>
<!-- Custom Dropdown Menu -->
<div v-if="isProjectMenuOpen"
class="absolute top-full left-0 mt-2 w-56 bg-slate-900 border border-white/10 shadow-xl rounded-xl overflow-hidden z-50 py-1">
class="absolute top-full left-0 mt-1 w-48 bg-slate-900 border border-white/10 shadow-xl rounded-lg overflow-hidden z-50 py-1">
<!-- Personal Workspace Option -->
<div @click="selectProject(null)"
class="flex items-center gap-3 px-4 py-3 hover:bg-white/5 cursor-pointer transition-colors"
class="flex items-center gap-2 px-3 py-2 hover:bg-white/5 cursor-pointer transition-colors text-xs"
:class="{ 'text-violet-400 bg-white/5': !selectedProject, 'text-slate-300': selectedProject }">
<i class="pi pi-user"></i>
<i class="pi pi-user text-[10px]"></i>
<span class="font-medium">Personal Workspace</span>
<i v-if="!selectedProject" class="pi pi-check ml-auto text-sm"></i>
<i v-if="!selectedProject" class="pi pi-check ml-auto text-[10px]"></i>
</div>
<div class="h-px bg-white/5 my-1"></div>
<!-- Project Options -->
<div v-for="project in projects" :key="project.id" @click="selectProject(project.id)"
class="flex items-center gap-3 px-4 py-3 hover:bg-white/5 cursor-pointer transition-colors"
class="flex items-center gap-2 px-3 py-2 hover:bg-white/5 cursor-pointer transition-colors text-xs"
:class="{ 'text-violet-400 bg-white/5': selectedProject === project.id, 'text-slate-300': selectedProject !== project.id }">
<i class="pi pi-folder"></i>
<i class="pi pi-folder text-[10px]"></i>
<span class="truncate">{{ project.name }}</span>
<i v-if="selectedProject === project.id" class="pi pi-check ml-auto text-sm"></i>
</div>
<div v-if="projects.length === 0" class="px-4 py-3 text-slate-500 text-sm font-italic">
No projects found
<i v-if="selectedProject === project.id" class="pi pi-check ml-auto text-[10px]"></i>
</div>
</div>
@@ -145,27 +155,33 @@ const navItems = computed(() => {
</div>
<!-- Nav Items -->
<div class="flex flex-row gap-2 items-center justify-center flex-1 mx-8">
<div class="flex flex-row gap-1 items-center justify-center flex-1 mx-4">
<div v-for="item in navItems" :key="item.path" :class="[
'px-4 py-2 flex items-center gap-2 rounded-xl cursor-pointer transition-all duration-300',
'px-3 py-1 flex items-center gap-1.5 rounded-lg cursor-pointer transition-all duration-300',
isActive(item.path)
? 'bg-white/10 text-slate-50 shadow-inner'
: 'text-slate-400 hover:bg-white/5 hover:text-slate-50'
]" @click="router.push(item.path)" v-tooltip.bottom="item.tooltip">
<span class="text-xl">{{ item.icon }}</span>
<span class="text-sm font-medium hidden lg:block">{{ item.tooltip }}</span>
<span class="text-base">{{ item.icon }}</span>
<span class="text-[11px] font-medium hidden lg:block">{{ item.tooltip }}</span>
</div>
</div>
<!-- Right Actions -->
<div class="flex items-center gap-4 shrink-0">
<div class="flex items-center gap-3 shrink-0">
<!-- Usage Stat -->
<div v-if="usageCost > 0" class="hidden xl:flex flex-col items-end mr-1">
<span class="text-[8px] font-bold text-slate-500 uppercase tracking-tighter">Usage</span>
<span class="text-xs font-bold text-violet-400">${{ usageCost.toFixed(2) }}</span>
</div>
<div @click="handleLogout"
class="w-10 h-10 rounded-xl bg-red-500/10 text-red-400 flex items-center justify-center cursor-pointer hover:bg-red-500/20 transition-all font-bold"
class="w-7 h-7 rounded-lg bg-red-500/10 text-red-400 flex items-center justify-center cursor-pointer hover:bg-red-500/20 transition-all font-bold"
v-tooltip.bottom="'Logout'">
<i class="pi pi-power-off"></i>
<i class="pi pi-power-off text-xs"></i>
</div>
<!-- Profile Avatar Placeholder -->
<div class="w-10 h-10 rounded-full bg-slate-800 border-2 border-violet-600 flex items-center justify-center font-bold text-slate-50 cursor-pointer hover:scale-105 transition-all"
<div class="w-7 h-7 rounded-full bg-slate-800 border-2 border-violet-600 flex items-center justify-center font-bold text-slate-50 cursor-pointer hover:scale-105 transition-all text-xs"
title="Profile">
U
</div>
@@ -174,14 +190,14 @@ const navItems = computed(() => {
<!-- Mobile Bottom Nav -->
<nav
class="md:hidden fixed bottom-0 left-0 right-0 h-16 bg-slate-900/90 backdrop-blur-xl border-t border-white/10 z-50 flex justify-around items-center px-2">
class="md:hidden fixed bottom-0 left-0 right-0 h-16 pb-4 bg-slate-900/90 backdrop-blur-xl border-t border-white/10 z-50 flex justify-around items-center px-2">
<div v-for="item in navItems" :key="item.path" :class="[
'flex flex-col items-center gap-1 p-2 rounded-xl transition-all',
'flex flex-col items-center p-1.5 rounded-lg transition-all',
isActive(item.path)
? 'text-white bg-white/10 relative top-[-10px] shadow-lg shadow-violet-500/20 border border-violet-500/30'
? 'text-white bg-white/10 shadow-lg shadow-violet-500/20 border border-violet-500/30'
: 'text-slate-400 hover:text-slate-200'
]" @click="router.push(item.path)">
<span class="text-xl">{{ item.icon }}</span>
<span class="text-lg">{{ item.icon }}</span>
</div>
</nav>
</div>

View File

@@ -0,0 +1,177 @@
<script setup>
import { ref, computed } from 'vue'
import Button from 'primevue/button'
const props = defineProps({
generation: {
type: Object,
required: true
},
apiUrl: {
type: String,
required: true
},
isSelectMode: {
type: Boolean,
default: false
},
isSelected: {
type: Boolean,
default: false
},
showNsfwGlobal: {
type: Boolean,
default: false
},
activeOverlayId: {
type: [String, Number],
default: null
}
})
const emit = defineEmits(['toggle-select', 'open-preview', 'toggle-like', 'delete', 'reuse-prompt', 'reuse-asset', 'use-result', 'toggle-overlay', 'mark-nsfw'])
const isTemporarilyUnblurred = ref(false)
const isBlurred = computed(() => {
return (props.generation.is_nsfw || props.generation.nsfw) && !props.showNsfwGlobal && !isTemporarilyUnblurred.value
})
const toggleBlur = () => {
isTemporarilyUnblurred.value = !isTemporarilyUnblurred.value
}
const handleImageClick = (e) => {
if (props.isSelectMode) {
emit('toggle-select', props.generation.result_list[0])
} else {
if (isBlurred.value) {
emit('open-preview', props.apiUrl + '/assets/' + props.generation.result_list[0])
} else {
emit('open-preview', props.apiUrl + '/assets/' + props.generation.result_list[0])
}
}
}
const handleOverlayClick = () => {
emit('toggle-overlay', props.generation.id)
}
</script>
<template>
<div class="w-full h-full relative group" @click="handleOverlayClick">
<!-- Image -->
<div class="w-full h-full overflow-hidden relative">
<img v-if="generation.result_list && generation.result_list.length > 0"
:src="apiUrl + '/assets/' + generation.result_list[0] + '?thumbnail=true'"
class="w-full h-full object-cover transition-all duration-300"
:class="{ 'blur-xl scale-110': isBlurred, 'cursor-pointer': !isSelectMode }"
@click.stop="handleImageClick"
/>
<!-- NSFW Badge / Unblur Button -->
<div v-if="isBlurred" class="absolute inset-0 flex flex-col items-center justify-center z-20 pointer-events-none">
<div class="bg-black/60 backdrop-blur-md px-3 py-1.5 rounded-full border border-white/10 flex items-center gap-2 pointer-events-auto cursor-pointer hover:bg-black/80 transition-colors" @click.stop="toggleBlur">
<i class="pi pi-eye-slash text-red-400 text-xs"></i>
<span class="text-[10px] font-bold text-red-400 uppercase tracking-wider">NSFW</span>
</div>
</div>
</div>
<!-- Liked badge -->
<div v-if="generation.is_liked && !isBlurred"
class="absolute top-2 right-2 z-20 w-6 h-6 rounded-full bg-pink-500 shadow-lg flex items-center justify-center border border-pink-400">
<i class="pi pi-heart-fill text-white text-[10px]"></i>
</div>
<!-- FAILED state -->
<div v-if="generation.status === 'failed'"
class="absolute inset-0 flex flex-col items-center justify-between p-3 text-center bg-red-500/10 border border-red-500/20">
<div class="w-full flex justify-end">
<Button icon="pi pi-trash" class="!w-6 !h-6 !rounded-full !bg-red-500/20 !border-none !text-red-400 text-[10px] hover:!bg-red-500 hover:!text-white z-10"
@click.stop="emit('delete', generation)" />
</div>
<div class="flex flex-col items-center justify-center flex-1">
<i class="pi pi-times-circle text-red-500 text-2xl mb-1"></i>
<span class="text-[10px] font-bold text-red-400 uppercase tracking-wide">Failed</span>
<span v-if="generation.failed_reason" class="text-[8px] text-red-300/70 mt-1 line-clamp-3 leading-tight"
v-tooltip.top="generation.failed_reason">{{ generation.failed_reason }}</span>
</div>
<div class="w-full flex gap-1 z-10">
<Button icon="pi pi-comment" class="!w-6 !h-6 flex-1 !bg-white/10 !border-white/10 !text-slate-200 text-[10px] hover:!bg-white/20"
@click.stop="emit('reuse-prompt', generation)" />
<Button icon="pi pi-images" class="!w-6 !h-6 flex-1 !bg-white/10 !border-white/10 !text-slate-200 text-[10px] hover:!bg-white/20"
@click.stop="emit('reuse-asset', generation)" />
</div>
</div>
<!-- PROCESSING state -->
<div v-else-if="['processing', 'starting', 'running'].includes(generation.status)"
class="absolute inset-0 flex flex-col items-center justify-center bg-slate-800/50 border border-violet-500/20">
<div class="absolute inset-0 bg-gradient-to-tr from-violet-500/5 via-violet-500/10 to-cyan-500/5 animate-pulse"></div>
<i class="pi pi-spin pi-spinner text-violet-500 text-xl mb-2 relative z-10"></i>
<span class="text-[10px] text-violet-300/70 relative z-10 capitalize">{{ generation.status }}...</span>
</div>
<!-- HOVER OVERLAY (Success & Not Blurred) -->
<div v-if="generation.result_list && generation.result_list.length > 0 && !isBlurred"
class="absolute inset-0 bg-black/60 transition-opacity duration-200 flex flex-col justify-between p-2 z-10"
:class="{ 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto': activeOverlayId !== generation.id, 'opacity-100 pointer-events-auto': activeOverlayId === generation.id }">
<!-- Top Right -->
<div class="flex justify-end items-start translate-y-[-10px] group-hover:translate-y-0 transition-transform duration-200 w-full z-10">
<div class="flex gap-1">
<Button :icon="generation.is_liked ? 'pi pi-heart-fill' : 'pi pi-heart'"
class="!w-6 !h-6 !rounded-full !border-none !text-[10px] transition-colors"
:class="generation.is_liked ? '!bg-pink-500 !text-white' : '!bg-white/20 !text-white hover:!bg-pink-500'"
@click.stop="emit('toggle-like', generation)" />
<Button icon="pi pi-pencil"
class="!w-6 !h-6 !rounded-full !bg-white/20 !border-none !text-white text-[10px] hover:!bg-violet-500"
@click.stop="emit('use-result', generation)" />
<Button :icon="(generation.is_nsfw || generation.nsfw) ? 'pi pi-eye' : 'pi pi-eye-slash'"
class="!w-6 !h-6 !rounded-full !bg-white/20 !border-none !text-white text-[10px] hover:!bg-red-500 hover:!text-white"
@click.stop="emit('mark-nsfw', generation)"
v-tooltip.bottom="(generation.is_nsfw || generation.nsfw) ? 'Unmark NSFW' : 'Mark NSFW'" />
<Button icon="pi pi-trash"
class="!w-6 !h-6 !rounded-full !bg-red-500/20 !border-none !text-red-400 text-[10px] hover:!bg-red-500 hover:!text-white"
@click.stop="emit('delete', generation)" />
</div>
</div>
<!-- Center -->
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none z-0">
<div class="flex flex-col items-center gap-0.5 mb-2 pointer-events-none">
<span class="text-[10px] font-bold text-slate-300 font-mono tracking-wider">{{ generation.cost }} $</span>
<span v-if="generation.execution_time_seconds" class="text-[8px] text-slate-500 font-mono">{{ generation.execution_time_seconds.toFixed(1) }}s</span>
</div>
<Button icon="pi pi-eye" rounded text
class="!bg-black/50 !text-white !w-12 !h-12 !rounded-full hover:!bg-black/70 hover:!scale-110 transition-all pointer-events-auto !border-2 !border-white/20"
@click.stop="emit('open-preview', apiUrl + '/assets/' + generation.result_list[0])" />
</div>
<!-- Bottom -->
<div class="translate-y-[10px] group-hover:translate-y-0 transition-transform duration-200 z-10">
<div class="flex gap-1 mb-1">
<Button icon="pi pi-comment" class="!w-6 !h-6 flex-1 !bg-white/10 !border-white/10 !text-slate-200 text-[10px] hover:!bg-white/20"
@click.stop="emit('reuse-prompt', generation)" />
<Button icon="pi pi-images" class="!w-6 !h-6 flex-1 !bg-white/10 !border-white/10 !text-slate-200 text-[10px] hover:!bg-white/20"
@click.stop="emit('reuse-asset', generation)" />
</div>
<p class="text-[10px] text-white/70 line-clamp-1 leading-tight">{{ generation.prompt }}</p>
</div>
</div>
<!-- Select mode checkbox overlay -->
<div v-if="isSelectMode && generation.result_list && generation.result_list.length > 0"
class="absolute inset-0 z-30 cursor-pointer"
@click.stop="emit('toggle-select', generation.result_list[0])">
<div class="absolute top-2 left-2 w-6 h-6 rounded-lg flex items-center justify-center transition-all"
:class="isSelected ? 'bg-violet-600 text-white' : 'bg-black/40 border border-white/30 text-transparent hover:border-white/60'">
<i class="pi pi-check" style="font-size: 12px"></i>
</div>
<div v-if="isSelected" class="absolute inset-0 bg-violet-600/20 pointer-events-none"></div>
</div>
</div>
</template>

View File

@@ -0,0 +1,362 @@
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import Dialog from 'primevue/dialog'
import Button from 'primevue/button'
import Tag from 'primevue/tag'
import { dataService } from '../services/dataService'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
previewImages: {
type: Array,
default: () => []
},
initialIndex: {
type: Number,
default: 0
},
apiUrl: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:visible', 'reusePrompt', 'reuseAsset', 'useResultAsAsset', 'liked'])
const previewIndex = ref(0)
const previewImage = computed(() => props.previewImages[previewIndex.value] || null)
// Like state management
const isTogglingLike = ref(false)
const localLikedStates = ref({}) // id -> bool
const isLiked = computed(() => {
if (!previewImage.value?.gen) return false
const id = previewImage.value.gen.id || previewImage.value.gen._id
if (localLikedStates.value[id] !== undefined) return localLikedStates.value[id]
return previewImage.value.gen.is_liked || false
})
const toggleLike = async () => {
const id = previewImage.value?.gen?.id || previewImage.value?.gen?._id
if (!id || isTogglingLike.value) return
isTogglingLike.value = true
try {
const response = await dataService.toggleLike(id)
// Assume response returns the new state or we just toggle it
const newState = response.is_liked !== undefined ? response.is_liked : !isLiked.value
localLikedStates.value[id] = newState
emit('liked', { id, is_liked: newState })
} catch (e) {
console.error('Failed to toggle like', e)
} finally {
isTogglingLike.value = false
}
}
// Reset index when modal opens
watch(() => props.visible, (newVal) => {
if (newVal) {
previewIndex.value = props.initialIndex
window.addEventListener('keydown', handlePreviewKeydown)
} else {
window.removeEventListener('keydown', handlePreviewKeydown)
}
})
const close = () => {
emit('update:visible', false)
}
const navigatePreview = (direction) => {
const count = props.previewImages.length
if (count <= 1) return
let newIndex = previewIndex.value + direction
if (newIndex < 0) newIndex = count - 1
if (newIndex >= count) newIndex = 0
previewIndex.value = newIndex
}
const handlePreviewKeydown = (e) => {
if (!props.visible) return
if (e.key === 'ArrowLeft') { navigatePreview(-1); e.preventDefault() }
if (e.key === 'ArrowRight') { navigatePreview(1); e.preventDefault() }
if (e.key === 'Escape') { close(); e.preventDefault() }
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', handlePreviewKeydown)
})
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const copyToClipboard = (text) => {
if (!text) return
navigator.clipboard.writeText(text).then(() => {
// Parent could show a toast if needed, or we just log
console.log('Copied to clipboard')
})
}
// Actions
const onReusePrompt = () => {
if (previewImage.value?.gen) emit('reusePrompt', previewImage.value.gen)
}
const onReuseAsset = () => {
if (previewImage.value?.gen) emit('reuseAsset', previewImage.value.gen)
}
const onUseResultAsAsset = () => {
if (previewImage.value?.gen) emit('useResultAsAsset', previewImage.value.gen)
}
</script>
<template>
<Dialog :visible="visible" @update:visible="val => emit('update:visible', val)" modal dismissableMask
:style="{ width: '95vw', maxWidth: '1200px' }"
class="preview-dialog"
:pt="{
root: { class: '!bg-transparent !border-none !shadow-none' },
header: { class: '!hidden' },
content: { class: '!p-0 !bg-transparent' }
}">
<div v-if="previewImage" class="flex flex-col lg:flex-row h-[90vh] w-full overflow-hidden relative bg-slate-900/95 backdrop-blur-xl border border-white/10 shadow-2xl rounded-3xl" @click.stop>
<!-- Left: Image Viewer -->
<div class="flex-1 bg-black/40 flex items-center justify-center relative min-h-[40vh] lg:min-h-0 border-r border-white/5">
<!-- Navigation Buttons -->
<Button v-if="previewImages.length > 1" icon="pi pi-chevron-left" @click.stop="navigatePreview(-1)"
rounded text
class="!absolute left-4 top-1/2 -translate-y-1/2 z-20 !text-white !bg-black/50 hover:!bg-black/70 !w-12 !h-12 !rounded-full !border !border-white/10 backdrop-blur-md transition-all hover:scale-110" />
<Button v-if="previewImages.length > 1" icon="pi pi-chevron-right" @click.stop="navigatePreview(1)"
rounded text
class="!absolute right-4 top-1/2 -translate-y-1/2 z-20 !text-white !bg-black/50 hover:!bg-black/70 !w-12 !h-12 !rounded-full !border !border-white/10 backdrop-blur-md transition-all hover:scale-110" />
<!-- Main Image -->
<img :src="previewImage.url"
class="max-w-full max-h-[85vh] object-contain shadow-2xl transition-transform duration-300"
draggable="false" />
<!-- Like Button Overlay -->
<button v-if="previewImage?.gen" @click.stop="toggleLike"
class="absolute top-6 left-6 z-30 w-12 h-12 rounded-full backdrop-blur-md flex items-center justify-center transition-all hover:scale-110 active:scale-90 border border-white/10"
:class="isLiked ? 'bg-pink-500 text-white border-pink-400 shadow-[0_0_20px_rgba(236,72,153,0.4)]' : 'bg-black/40 text-white/70 hover:text-white'">
<i :class="isLiked ? 'pi pi-heart-fill' : 'pi pi-heart'" class="text-xl"></i>
</button>
<!-- Image Index Badge -->
<div v-if="previewImages.length > 1"
class="absolute bottom-4 left-1/2 -translate-x-1/2 z-20 bg-black/60 backdrop-blur-md text-white text-xs font-mono px-3 py-1.5 rounded-full border border-white/10">
{{ previewIndex + 1 }} / {{ previewImages.length }}
</div>
<!-- Close Button (Mobile Only) -->
<Button icon="pi pi-times" @click="close" rounded text
class="absolute top-4 right-4 z-30 lg:hidden !text-white !bg-black/50 !w-10 !h-10" />
</div>
<!-- Right: Generation Info & Actions -->
<div class="w-full lg:w-96 flex flex-col bg-slate-900/50 backdrop-blur-md overflow-hidden">
<!-- Header -->
<div class="p-5 border-b border-white/10 flex justify-between items-center">
<div>
<h3 class="text-lg font-bold text-white m-0">Generation Details</h3>
<p class="text-[10px] text-slate-500 font-mono uppercase tracking-widest mt-0.5">
{{ previewImage?.gen?.id || previewImage?.gen?._id || 'Unknown ID' }}
</p>
</div>
<Button icon="pi pi-times" @click="close" rounded text
class="!hidden lg:!flex !text-slate-500 hover:!text-white !w-8 !h-8" />
</div>
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto p-5 custom-scrollbar flex flex-col gap-6">
<!-- Actions Section -->
<div v-if="previewImage?.gen" class="flex flex-col gap-2">
<label class="text-[10px] font-bold text-slate-500 uppercase tracking-widest px-1">Actions</label>
<div class="grid grid-cols-1 gap-2">
<Button :label="isLiked ? 'Liked' : 'Like'" :icon="isLiked ? 'pi pi-heart-fill' : 'pi pi-heart'"
@click="toggleLike" :loading="isTogglingLike"
:class="[
'!text-sm !justify-start',
isLiked ? '!bg-pink-600/20 !border-pink-500/30 !text-pink-400 hover:!bg-pink-600/30' : '!bg-slate-800/50 !border-white/10 !text-slate-300 hover:!bg-slate-800'
]" />
<Button label="Reuse Prompt" icon="pi pi-refresh" @click="onReusePrompt"
class="!bg-violet-600/10 !border-violet-500/30 !text-violet-400 hover:!bg-violet-600/20 !text-sm !justify-start" />
<Button label="Reuse References" icon="pi pi-clone" @click="onReuseAsset"
class="!bg-blue-600/10 !border-blue-500/30 !text-blue-400 hover:!bg-blue-600/20 !text-sm !justify-start" />
<Button label="Reference Result" icon="pi pi-image" @click="onUseResultAsAsset"
class="!bg-emerald-600/10 !border-emerald-500/30 !text-emerald-400 hover:!bg-emerald-600/20 !text-sm !justify-start" />
</div>
</div>
<!-- Prompt Section -->
<div v-if="previewImage?.gen?.prompt" class="flex flex-col gap-2">
<div class="flex justify-between items-center px-1">
<label class="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Prompt</label>
<Button icon="pi pi-copy" @click="copyToClipboard(previewImage.gen.prompt)"
text class="!p-0 !w-6 !h-6 !text-slate-500 hover:!text-white" v-tooltip.top="'Copy Prompt'" />
</div>
<div class="bg-black/30 p-3 rounded-xl border border-white/5 text-sm text-slate-300 leading-relaxed max-h-40 overflow-y-auto custom-scrollbar">
{{ previewImage.gen.prompt }}
</div>
</div>
<!-- Technical Data -->
<div v-if="previewImage?.gen" class="flex flex-col gap-2">
<label class="text-[10px] font-bold text-slate-500 uppercase tracking-widest px-1">Technical Data</label>
<div class="flex flex-col gap-3 bg-black/20 p-4 rounded-xl border border-white/5">
<!-- Grid for main params -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-0.5">
<span class="text-[10px] text-slate-500 uppercase">Model</span>
<span class="text-xs text-violet-400 font-bold truncate" :title="previewImage.gen.model">{{ previewImage.gen.model || 'N/A' }}</span>
</div>
<div v-if="previewImage.gen.seed !== undefined && previewImage.gen.seed !== null" class="flex flex-col gap-0.5">
<span class="text-[10px] text-slate-500 uppercase">Seed</span>
<div class="flex items-center gap-1">
<span class="text-xs text-slate-200 font-mono">{{ previewImage.gen.seed }}</span>
<Button icon="pi pi-copy" @click="copyToClipboard(previewImage.gen.seed.toString())"
text class="!p-0 !w-3 !h-3 !text-slate-500 hover:!text-white" />
</div>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[10px] text-slate-500 uppercase">Quality</span>
<span class="text-xs text-slate-200 font-semibold">{{ previewImage.gen.quality || 'N/A' }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[10px] text-slate-500 uppercase">Aspect Ratio</span>
<span class="text-xs text-slate-200 font-semibold">{{ previewImage.gen.aspect_ratio || 'N/A' }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[10px] text-slate-500 uppercase">Execution Time</span>
<span class="text-xs text-slate-200 font-semibold">{{ previewImage.gen.execution_time_seconds?.toFixed(2) || '0' }}s (API: {{ previewImage.gen.api_execution_time_seconds?.toFixed(2) || '0' }}s)</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[10px] text-slate-500 uppercase">Cost</span>
<span class="text-xs text-emerald-400 font-bold">${{ previewImage.gen.cost?.toFixed(4) || '0.00' }}</span>
</div>
</div>
<div class="h-px bg-white/5 w-full"></div>
<!-- Tokens Section -->
<div class="flex flex-col gap-2">
<span class="text-[10px] text-slate-500 uppercase">Token Usage</span>
<div class="grid grid-cols-3 gap-2">
<div class="flex flex-col bg-white/5 p-2 rounded-lg items-center">
<span class="text-[8px] text-slate-500 uppercase">Input</span>
<span class="text-[10px] text-slate-200 font-mono">{{ previewImage.gen.input_token_usage || 0 }}</span>
</div>
<div class="flex flex-col bg-white/5 p-2 rounded-lg items-center">
<span class="text-[8px] text-slate-500 uppercase">Output</span>
<span class="text-[10px] text-slate-200 font-mono">{{ previewImage.gen.output_token_usage || 0 }}</span>
</div>
<div class="flex flex-col bg-violet-600/20 p-2 rounded-lg items-center border border-violet-500/20">
<span class="text-[8px] text-violet-400 uppercase">Total</span>
<span class="text-[10px] text-violet-300 font-bold font-mono">{{ previewImage.gen.token_usage || 0 }}</span>
</div>
</div>
</div>
<div class="h-px bg-white/5 w-full"></div>
<!-- Technical Prompt -->
<div v-if="previewImage?.gen?.tech_prompt" class="flex flex-col gap-2">
<div class="flex justify-between items-center">
<span class="text-[10px] text-slate-500 uppercase">Technical Prompt</span>
<Button icon="pi pi-copy" @click="copyToClipboard(previewImage.gen.tech_prompt)"
text class="!p-0 !w-4 !h-4 !text-slate-500 hover:!text-white" />
</div>
<div class="bg-black/40 p-2 rounded-lg border border-white/5 text-[10px] font-mono text-slate-400 leading-relaxed max-h-24 overflow-y-auto custom-scrollbar whitespace-pre-wrap">
{{ previewImage.gen.tech_prompt }}
</div>
</div>
<div class="h-px bg-white/5 w-full"></div>
<!-- Metadata -->
<div class="flex flex-col gap-1.5 text-[10px]">
<div class="flex justify-between">
<span class="text-slate-500 uppercase">Created:</span>
<span class="text-slate-300">{{ formatDate(previewImage.gen.created_at) }}</span>
</div>
<div v-if="previewImage.gen.generation_group_id" class="flex justify-between">
<span class="text-slate-500 uppercase">Group ID:</span>
<span class="text-slate-300 font-mono">{{ previewImage.gen.generation_group_id }}</span>
</div>
<div v-if="previewImage.gen.idea_id" class="flex justify-between">
<span class="text-slate-500 uppercase">Idea ID:</span>
<span class="text-slate-300 font-mono">{{ previewImage.gen.idea_id }}</span>
</div>
<div class="flex justify-between">
<span class="text-slate-500 uppercase">Status:</span>
<Tag :value="previewImage.gen.status"
:severity="previewImage.gen.status === 'done' ? 'success' : (previewImage.gen.status === 'failed' ? 'danger' : 'info')"
class="!text-[8px] !px-1.5 !py-0 w-fit h-4" />
</div>
<div v-if="previewImage.gen.failed_reason" class="flex flex-col gap-1 mt-1 p-2 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400">
<span class="uppercase font-bold">Error:</span>
<span class="italic">{{ previewImage.gen.failed_reason }}</span>
</div>
</div>
</div>
</div>
<!-- References Used -->
<div v-if="previewImage?.gen?.assets_list?.length > 0" class="flex flex-col gap-2">
<label class="text-[10px] font-bold text-slate-500 uppercase tracking-widest px-1">References Used</label>
<div class="flex flex-wrap gap-2">
<div v-for="assetId in previewImage.gen.assets_list" :key="assetId"
class="w-10 h-10 rounded-lg overflow-hidden border border-white/10 shadow-lg">
<img :src="apiUrl + '/assets/' + assetId + '?thumbnail=true'"
class="w-full h-full object-cover" />
</div>
</div>
</div>
</div>
</div>
</div>
</Dialog>
</template>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
</style>

View File

@@ -57,6 +57,11 @@ const router = createRouter({
name: 'flexible',
component: () => import('../views/FlexibleGenerationView.vue')
},
{
path: '/content-plan',
name: 'content-plan',
component: () => import('../views/ContentPlanView.vue')
},
{
path: '/albums',
name: 'albums',

View File

@@ -52,9 +52,10 @@ export const aiService = {
},
// Get generations history
async getGenerations(limit, offset, characterId) {
async getGenerations(limit, offset, characterId, onlyLiked = false) {
const params = { limit, offset }
if (characterId) params.character_id = characterId
if (onlyLiked) params.only_liked = true
const response = await api.get('/generations', { params })
return response.data
},
@@ -66,5 +67,30 @@ export const aiService = {
linked_assets: linkedAssets
})
return response.data
},
// Mark generation as NSFW
async markGenerationNsfw(generationId, isNsfw = true) {
const response = await api.post(`/generations/${generationId}/nsfw`, {
is_nsfw: isNsfw
})
return response.data
},
// Get usage statistics (runs, tokens, cost)
async getUsageReport(breakdown = null, projectId = null) {
const params = {}
if (breakdown) params.breakdown = breakdown
const config = { params, headers: {} }
if (projectId) {
config.headers['X-Project-ID'] = projectId
} else if (projectId === false) {
// Explicitly ignore current active project header
config.headers['X-Project-ID'] = ''
}
const response = await api.get('/generations/usage', config)
return response.data
}
}

View File

@@ -13,6 +13,25 @@ export const dataService = {
return response.data
},
getAssetMetadata: async (id) => {
const response = await api.head(`/assets/${id}`)
return {
content_type: response.headers['content-type'],
content_length: response.headers['content-length']
}
},
downloadAsset: async (id) => {
// Return full response to access headers
const response = await api.get(`/assets/${id}`, {
responseType: 'blob',
headers: {
'Range': 'bytes=0-' // Explicitly request full content to play nice with backend streaming
}
});
return response;
},
getCharacters: async () => {
// Spec says /api/characters/ (with trailing slash) but usually client shouldn't matter too much if config is good,
// but let's follow spec if strictly needed. Axios usually handles this.
@@ -35,12 +54,14 @@ export const dataService = {
return response.data
},
getAssetsByCharacterId: async (charId, limit, offset) => {
const response = await api.get(`/characters/${charId}/assets`, { params: { limit, offset } })
getAssetsByCharacterId: async (charId, limit, offset, type) => {
const params = { limit, offset }
if (type && type !== 'all') params.type = type
const response = await api.get(`/characters/${charId}/assets`, { params })
return response.data
},
uploadAsset: async (file, linkedCharId) => {
uploadAsset: async (file, linkedCharId, onProgress) => {
const formData = new FormData()
formData.append('file', file)
if (linkedCharId) formData.append('linked_char_id', linkedCharId)
@@ -48,6 +69,12 @@ export const dataService = {
const response = await api.post('/assets/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress(percentCompleted)
}
}
})
return response.data
@@ -58,11 +85,38 @@ export const dataService = {
return response.data
},
toggleLike: async (id) => {
const response = await api.post(`/generations/${id}/like`)
return response.data
},
deleteGeneration: async (id) => {
const response = await api.delete(`/generations/${id}`)
return response.data
},
// Environments
getEnvironments: async (characterId) => {
if (!characterId) return []
const response = await api.get(`/environments/character/${characterId}`)
return response.data
},
createEnvironment: async (envData) => {
const response = await api.post('/environments/', envData)
return response.data
},
updateEnvironment: async (id, envData) => {
const response = await api.put(`/environments/${id}`, envData)
return response.data
},
deleteEnvironment: async (id) => {
const response = await api.delete(`/environments/${id}`)
return response.data
},
generatePromptFromImage: async (files, prompt) => {
const formData = new FormData()

View File

@@ -8,5 +8,9 @@ export const ideaService = {
deleteIdea: (id) => api.delete(`/ideas/${id}`),
addGenerationToIdea: (ideaId, generationId) => api.post(`/ideas/${ideaId}/generations/${generationId}`),
removeGenerationFromIdea: (ideaId, generationId) => api.delete(`/ideas/${ideaId}/generations/${generationId}`),
getIdeaGenerations: (ideaId, limit = 10, offset = 0) => api.get(`/ideas/${ideaId}/generations`, { params: { limit, offset } })
getIdeaGenerations: (ideaId, limit = 10, offset = 0, onlyLiked = false) => {
const params = { limit, offset };
if (onlyLiked) params.only_liked = true;
return api.get(`/ideas/${ideaId}/generations`, { params });
}
};

View File

@@ -0,0 +1,10 @@
import api from './api';
export const inspirationService = {
getInspirations: (limit = 20, offset = 0) => api.get('/inspirations', { params: { limit, offset } }),
getInspiration: (id) => api.get(`/inspirations/${id}`),
createInspiration: (data) => api.post('/inspirations', data),
updateInspiration: (id, data) => api.put(`/inspirations/${id}`, data),
deleteInspiration: (id) => api.delete(`/inspirations/${id}`),
completeInspiration: (id, is_completed = true) => api.patch(`/inspirations/${id}/complete`, null, { params: { is_completed } })
};

View File

@@ -0,0 +1,24 @@
import api from './api';
export const postService = {
getPosts: (dateFrom, dateTo) => {
const params = {};
if (dateFrom) params.date_from = dateFrom;
if (dateTo) params.date_to = dateTo;
return api.get('/posts', { params }).then(r => r.data);
},
createPost: (data) => api.post('/posts', data).then(r => r.data),
getPost: (id) => api.get(`/posts/${id}`).then(r => r.data),
updatePost: (id, data) => api.put(`/posts/${id}`, data).then(r => r.data),
deletePost: (id) => api.delete(`/posts/${id}`).then(r => r.data),
addGenerations: (postId, generationIds) =>
api.post(`/posts/${postId}/generations`, { generation_ids: generationIds }).then(r => r.data),
removeGeneration: (postId, generationId) =>
api.delete(`/posts/${postId}/generations/${generationId}`).then(r => r.data),
};

View File

@@ -1,10 +1,13 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { ideaService } from '../services/ideaService';
import { inspirationService } from '../services/inspirationService';
export const useIdeaStore = defineStore('ideas', () => {
const ideas = ref([]);
const inspirations = ref([]);
const currentIdea = ref(null);
const currentInspiration = ref(null); // New state
const loading = ref(false);
const error = ref(null);
const totalIdeas = ref(0);
@@ -32,13 +35,13 @@ export const useIdeaStore = defineStore('ideas', () => {
loading.value = true;
error.value = null;
try {
await ideaService.createIdea(data);
const response = await ideaService.createIdea(data);
await fetchIdeas(); // Refresh list
return true;
return response.data;
} catch (err) {
console.error('Error creating idea:', err);
error.value = err.response?.data?.detail || 'Failed to create idea';
return false;
return null;
} finally {
loading.value = false;
}
@@ -61,6 +64,25 @@ export const useIdeaStore = defineStore('ideas', () => {
}
}
// New action to fetch a single inspiration
async function fetchInspiration(id) {
loading.value = true;
error.value = null;
currentInspiration.value = null;
try {
const response = await inspirationService.getInspiration(id);
currentInspiration.value = response.data;
return response.data;
} catch (err) {
console.error('Error fetching inspiration:', err);
error.value = err.response?.data?.detail || 'Failed to fetch inspiration';
return null;
} finally {
loading.value = false;
}
}
async function updateIdea(id, data) {
loading.value = true;
error.value = null;
@@ -133,9 +155,9 @@ export const useIdeaStore = defineStore('ideas', () => {
}
// Assuming getIdeaGenerations is separate from getIdea
async function fetchIdeaGenerations(ideaId, limit = 100, offset = 0) {
async function fetchIdeaGenerations(ideaId, limit = 100, offset = 0, onlyLiked = false) {
try {
const response = await ideaService.getIdeaGenerations(ideaId, limit, offset);
const response = await ideaService.getIdeaGenerations(ideaId, limit, offset, onlyLiked);
return response;
} catch (err) {
console.error('Error fetching idea generations:', err);
@@ -143,9 +165,85 @@ export const useIdeaStore = defineStore('ideas', () => {
}
}
// --- Inspirations ---
async function fetchInspirations(limit = 20, offset = 0) {
loading.value = true;
try {
const response = await inspirationService.getInspirations(limit, offset);
inspirations.value = response.data.inspirations || response.data;
} catch (err) {
console.error('Error fetching inspirations:', err);
error.value = err.response?.data?.detail || 'Failed to fetch inspirations';
} finally {
loading.value = false;
}
}
async function createInspiration(data) {
loading.value = true;
try {
await inspirationService.createInspiration(data);
await fetchInspirations();
return true;
} catch (err) {
console.error('Error creating inspiration:', err);
error.value = err.response?.data?.detail || 'Failed to create inspiration';
return false;
} finally {
loading.value = false;
}
}
async function updateInspiration(id, data) {
loading.value = true;
try {
await inspirationService.updateInspiration(id, data);
await fetchInspirations();
return true;
} catch (err) {
console.error('Error updating inspiration:', err);
error.value = err.response?.data?.detail || 'Failed to update inspiration';
return false;
} finally {
loading.value = false;
}
}
async function deleteInspiration(id) {
loading.value = true;
try {
await inspirationService.deleteInspiration(id);
await fetchInspirations();
return true;
} catch (err) {
console.error('Error deleting inspiration:', err);
error.value = err.response?.data?.detail || 'Failed to delete inspiration';
return false;
} finally {
loading.value = false;
}
}
async function completeInspiration(id) {
loading.value = true;
try {
await inspirationService.completeInspiration(id);
await fetchInspirations();
return true;
} catch (err) {
console.error('Error completing inspiration:', err);
error.value = err.response?.data?.detail || 'Failed to complete inspiration';
return false;
} finally {
loading.value = false;
}
}
return {
ideas,
inspirations,
currentIdea,
currentInspiration,
loading,
error,
totalIdeas,
@@ -156,6 +254,12 @@ export const useIdeaStore = defineStore('ideas', () => {
deleteIdea,
addGenerationToIdea,
removeGenerationFromIdea,
fetchIdeaGenerations
fetchIdeaGenerations,
fetchInspirations,
createInspiration,
updateInspiration,
deleteInspiration,
completeInspiration,
fetchInspiration
};
});

View File

@@ -11,6 +11,9 @@ import ConfirmDialog from 'primevue/confirmdialog'
import { useConfirm } from 'primevue/useconfirm'
import Dialog from 'primevue/dialog'
import GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
import { dataService } from '../services/dataService'
const route = useRoute()
const router = useRouter()
const albumStore = useAlbumStore()
@@ -20,6 +23,32 @@ const confirm = useConfirm()
const generations = ref([])
const loadingGenerations = ref(false)
// Preview Modal State
const isImagePreviewVisible = ref(false)
const previewIndex = ref(0)
const previewImages = computed(() => {
return generations.value.map(gen => ({
url: API_URL + '/assets/' + (gen.result_list?.[0] || ''),
assetId: gen.result_list?.[0],
is_liked: gen.liked_assets?.includes(gen.result_list?.[0]),
gen: gen
}))
})
const handleLiked = ({ id, is_liked }) => {
// Update local state in generations
generations.value.forEach(gen => {
if (gen.result_list?.includes(id)) {
if (!gen.liked_assets) gen.liked_assets = []
if (is_liked) {
if (!gen.liked_assets.includes(id)) gen.liked_assets.push(id)
} else {
gen.liked_assets = gen.liked_assets.filter(aid => aid !== id)
}
}
})
}
// Gen Picker State
const isGenerationPickerVisible = ref(false)
const availableGenerations = ref([])
@@ -130,10 +159,9 @@ const removeGeneration = (gen) => {
}
// --- Image Preview ---
const isImagePreviewVisible = ref(false)
const previewImage = ref(null)
const openImagePreview = (url) => {
previewImage.value = { url }
const openImagePreview = (gen) => {
const idx = generations.value.findIndex(g => g.id === gen.id)
previewIndex.value = idx >= 0 ? idx : 0
isImagePreviewVisible.value = true
}
</script>
@@ -191,11 +219,17 @@ const openImagePreview = (url) => {
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 cursor-pointer"
@click="gen.result_list && gen.result_list.length > 0 ? openImagePreview(API_URL + '/assets/' + gen.result_list[0]) : null">
@click="gen.result_list && gen.result_list.length > 0 ? openImagePreview(gen) : null">
<img v-if="gen.result_list && gen.result_list.length > 0"
:src="gen.result || API_URL + `/assets/${gen.result_list[0]}` + '?thumbnail=true'"
class="w-full h-full object-cover" />
<!-- Liked Badge -->
<div v-if="gen.liked_assets?.includes(gen.result_list?.[0])"
class="absolute top-2 right-2 z-10 w-6 h-6 rounded-full bg-pink-500 shadow-lg flex items-center justify-center border border-pink-400">
<i class="pi pi-heart-fill text-white text-[10px]"></i>
</div>
<!-- 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">
@@ -273,16 +307,13 @@ const openImagePreview = (url) => {
</Dialog>
<!-- Image Preview Modal -->
<Dialog v-model:visible="isImagePreviewVisible" modal dismissableMask
:style="{ width: '90vw', maxWidth: '1000px', background: 'transparent', boxShadow: 'none' }"
:pt="{ root: { class: '!bg-transparent !border-none !shadow-none' }, header: { class: '!hidden' }, content: { class: '!bg-transparent !p-0' } }">
<div class="relative flex items-center justify-center" @click="isImagePreviewVisible = false">
<img v-if="previewImage" :src="previewImage.url"
class="max-w-full max-h-[85vh] object-contain rounded-xl shadow-2xl" />
<Button icon="pi pi-times" @click="isImagePreviewVisible = false" rounded text
class="!absolute -top-4 -right-4 !text-white !bg-black/50 hover:!bg-black/70 !w-10 !h-10" />
</div>
</Dialog>
<GenerationPreviewModal
v-model:visible="isImagePreviewVisible"
:preview-images="previewImages"
:initial-index="previewIndex"
:api-url="API_URL"
@liked="handleLiked"
/>
</div>
</template>

View File

@@ -17,6 +17,8 @@ import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import Image from 'primevue/image'
import GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
const router = useRouter()
const confirm = useConfirm()
const toast = useToast()
@@ -26,6 +28,28 @@ const activeFilter = ref('all')
// @ts-ignore
const API_URL = import.meta.env.VITE_API_URL
// Preview Modal State
const isImagePreviewVisible = ref(false)
const previewIndex = ref(0)
const previewImages = computed(() => {
return assets.value.map(asset => ({
url: API_URL + (asset.url || asset.link),
assetId: asset.id,
is_liked: asset.is_liked || asset.liked,
name: asset.name,
created_at: asset.created_at,
// For assets, we might not have a full 'gen' object, but modal expects it for technical data
gen: asset.generation_id ? { id: asset.generation_id, prompt: asset.prompt } : null
}))
})
const handleLiked = ({ id, is_liked }: { id: string, is_liked: boolean }) => {
const asset = assets.value.find(a => a.id === id)
if (asset) {
asset.is_liked = is_liked
}
}
// Albums Logic
const albumStore = useAlbumStore()
const { albums, loading: albumsLoading } = storeToRefs(albumStore)
@@ -34,7 +58,6 @@ 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)
@@ -69,8 +92,9 @@ const handleFileUpload = async (event: Event) => {
}
const openModal = (asset: Asset) => {
selectedAsset.value = asset
isModalVisible.value = true
const idx = assets.value.findIndex(a => a.id === asset.id)
previewIndex.value = idx >= 0 ? idx : 0
isImagePreviewVisible.value = true
}
const loadAssets = async () => {
@@ -245,6 +269,12 @@ const formatDate = (dateString: string) => {
:alt="asset.name"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
<!-- Liked Badge -->
<div v-if="asset.is_liked || asset.liked"
class="absolute top-2 left-2 z-10 w-6 h-6 rounded-full bg-pink-500 shadow-lg flex items-center justify-center border border-pink-400">
<i class="pi pi-heart-fill text-white text-[10px]"></i>
</div>
<!-- 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">
@@ -368,17 +398,13 @@ const formatDate = (dateString: string) => {
</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>
<GenerationPreviewModal
v-model:visible="isImagePreviewVisible"
:preview-images="previewImages"
:initial-index="previewIndex"
:api-url="API_URL"
@liked="handleLiked"
/>
<!-- Create Album Dialog -->
<Dialog v-model:visible="showCreateDialog" modal header="Create New Album" :style="{ width: '500px' }"

View File

@@ -1,15 +1,12 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { dataService } from '../services/dataService'
import { aiService } from '../services/aiService'
import {computed, nextTick, onMounted, ref, watch} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {dataService} from '../services/dataService'
import {aiService} from '../services/aiService'
import Button from 'primevue/button'
import Skeleton from 'primevue/skeleton'
import Tag from 'primevue/tag'
import Dialog from 'primevue/dialog'
import Textarea from 'primevue/textarea'
import SelectButton from 'primevue/selectbutton'
import FileUpload from 'primevue/fileupload'
import Checkbox from 'primevue/checkbox'
import ProgressBar from 'primevue/progressbar'
import Message from 'primevue/message'
@@ -22,10 +19,13 @@ import TabPanels from 'primevue/tabpanels'
import TabPanel from 'primevue/tabpanel'
import Paginator from 'primevue/paginator'
import GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
const route = useRoute()
const router = useRouter()
const character = ref(null)
const characterAssets = ref([])
const environments = ref([])
const assetsTotalRecords = ref(0)
const historyGenerations = ref([])
const historyTotal = ref(0)
@@ -34,6 +34,287 @@ const historyFirst = ref(0)
const loading = ref(true)
const API_URL = import.meta.env.VITE_API_URL
// Preview Modal State
const isImagePreviewVisible = ref(false)
const previewIndex = ref(0)
const previewImages = ref([])
const openImagePreview = (images, index = 0) => {
previewImages.value = images
previewIndex.value = index
isImagePreviewVisible.value = true
}
const handleLiked = ({ id, is_liked }) => {
// Update local state in history
historyGenerations.value.forEach(gen => {
if (gen.result_list?.includes(id)) {
if (!gen.liked_assets) gen.liked_assets = []
if (is_liked) {
if (!gen.liked_assets.includes(id)) gen.liked_assets.push(id)
} else {
gen.liked_assets = gen.liked_assets.filter(aid => aid !== id)
}
}
})
// Update in character assets
characterAssets.value.forEach(asset => {
if (asset.id === id) {
asset.is_liked = is_liked
}
})
// Update in generatedResult if it's the one liked
if (generatedResult.value) {
if (generatedResult.value.type === 'assets') {
const found = generatedResult.value.assets.find(a => a.id === id)
if (found) {
if (!generatedResult.value.liked_assets) generatedResult.value.liked_assets = []
if (is_liked) {
if (!generatedResult.value.liked_assets.includes(id)) generatedResult.value.liked_assets.push(id)
} else {
generatedResult.value.liked_assets = generatedResult.value.liked_assets.filter(aid => aid !== id)
}
}
}
}
}
const selectedEnvironment = ref(null)
const isEnvModalVisible = ref(false)
const isEnvAssetPickerVisible = ref(false)
const isDeletingEnv = ref(false)
const envForm = ref({
name: '',
asset_ids: [],
assets_list: []
})
const editingEnvId = ref(null)
// --- Env Asset Picker State ---
const envModalAssets = ref([])
const envAssetPickerTab = ref('all') // 'all', 'uploaded', 'generated'
const envModalFirst = ref(0)
const envModalRows = ref(20)
const envModalTotal = ref(0)
const isEnvModalLoading = ref(false)
const envAssetScrollContainer = ref(null)
const envAssetScrollSentinel = ref(null)
const envAssetPickerFileInput = ref(null)
const envUploadProgress = ref(0)
const isEnvUploading = ref(false)
const envCurrentEnvAssets = ref([])
let envAssetObserver = null
const envSelectedAssets = computed(() => {
// We check against all known assets, picker assets and current environment assets
return [...characterAssets.value, ...envModalAssets.value, ...envCurrentEnvAssets.value]
.filter((a, index, self) => self.findIndex(t => (t.id || t._id) === (a.id || a._id)) === index) // Unique
.filter(a => envForm.value.asset_ids.includes(a.id) || (a._id && envForm.value.asset_ids.includes(a._id)))
})
const loadEnvModalAssets = async (isNewTab = false) => {
if (isEnvModalLoading.value) return
if (isNewTab) {
envModalFirst.value = 0
envModalAssets.value = []
} else {
// Increment offset for pagination
envModalFirst.value += envModalRows.value
}
if (envModalTotal.value > 0 && envModalFirst.value >= envModalTotal.value && !isNewTab) {
return
}
isEnvModalLoading.value = true
try {
const response = await dataService.getAssetsByCharacterId(
route.params.id,
envModalRows.value,
envModalFirst.value,
envAssetPickerTab.value
)
if (response && response.assets) {
envModalAssets.value = [...envModalAssets.value, ...response.assets]
envModalTotal.value = response.total_count || 0
}
} catch (e) {
console.error('Failed to load env modal assets', e)
// Rollback offset on failure if not first page
if (!isNewTab) envModalFirst.value -= envModalRows.value
} finally {
isEnvModalLoading.value = false
}
}
const handleEnvAssetInfiniteScroll = (entries) => {
if (entries[0].isIntersecting && !isEnvModalLoading.value && (envModalTotal.value === 0 || envModalAssets.value.length < envModalTotal.value)) {
loadEnvModalAssets()
}
}
watch(isEnvAssetPickerVisible, (visible) => {
if (visible) {
loadEnvModalAssets(true)
nextTick(() => {
if (envAssetObserver) envAssetObserver.disconnect()
envAssetObserver = new IntersectionObserver(handleEnvAssetInfiniteScroll, {
root: envAssetScrollContainer.value,
rootMargin: '100px',
threshold: 0.1
})
if (envAssetScrollSentinel.value) {
envAssetObserver.observe(envAssetScrollSentinel.value)
}
})
} else {
if (envAssetObserver) envAssetObserver.disconnect()
}
})
watch(envAssetPickerTab, () => {
loadEnvModalAssets(true)
})
const toggleEnvAssetSelection = (id) => {
const idx = envForm.value.asset_ids.indexOf(id)
if (idx > -1) {
envForm.value.asset_ids.splice(idx, 1)
const listIdx = envForm.value.assets_list.indexOf(id)
if (listIdx > -1) envForm.value.assets_list.splice(listIdx, 1)
} else {
envForm.value.asset_ids.push(id)
envForm.value.assets_list.push(id)
}
}
const triggerEnvAssetUpload = () => {
if (envAssetPickerFileInput.value) envAssetPickerFileInput.value.click()
}
const handleEnvAssetUpload = async (event) => {
const file = event.target.files[0]
if (!file) return
isEnvUploading.value = true
envUploadProgress.value = 0
try {
const response = await dataService.uploadAsset(file, route.params.id, (progress) => {
envUploadProgress.value = progress
})
// Switch to uploaded tab to see the new asset
envAssetPickerTab.value = 'uploaded'
await loadEnvModalAssets(true)
// Auto-select the newly uploaded asset
if (response && response.id) {
if (!envForm.value.asset_ids.includes(response.id)) {
envForm.value.asset_ids.push(response.id)
envForm.value.assets_list.push(response.id)
}
}
} catch (e) {
console.error('Failed to upload asset in environment modal', e)
} finally {
isEnvUploading.value = false
envUploadProgress.value = 0
if (event.target) event.target.value = '' // Clear input
}
}
const removeEnvAsset = (id) => {
const idx = envForm.value.asset_ids.indexOf(id)
if (idx > -1) {
envForm.value.asset_ids.splice(idx, 1)
const listIdx = envForm.value.assets_list.indexOf(id)
if (listIdx > -1) envForm.value.assets_list.splice(listIdx, 1)
}
}
const loadEnvironments = async () => {
try {
const response = await dataService.getEnvironments(route.params.id)
environments.value = Array.isArray(response) ? response : (response.environments || [])
} catch (e) {
console.error('Failed to load environments', e)
}
}
const openEnvModal = async (env = null) => {
envCurrentEnvAssets.value = []
if (env) {
editingEnvId.value = env.id || env._id
const initialAssets = [...(env.asset_ids || [])]
envForm.value = {
name: env.name,
asset_ids: initialAssets,
assets_list: [...initialAssets]
}
// Fetch current environment assets if not already in memory
if (initialAssets.length > 0) {
const missingIds = initialAssets.filter(id =>
!characterAssets.value.find(a => (a.id || a._id) === id) &&
!envModalAssets.value.find(a => (a.id || a._id) === id)
)
if (missingIds.length > 0) {
try {
const fetchedAssets = await Promise.all(
missingIds.map(id => dataService.getAsset(id))
)
envCurrentEnvAssets.value = fetchedAssets.filter(a => !!a)
} catch (e) {
console.error('Failed to fetch missing env assets', e)
}
}
}
} else {
editingEnvId.value = null
envForm.value = {
name: '',
asset_ids: [],
assets_list: []
}
}
isEnvModalVisible.value = true
}
const saveEnvironment = async () => {
try {
const payload = {
...envForm.value,
character_id: route.params.id
}
console.log('Saving environment with payload:', payload)
if (editingEnvId.value) {
await dataService.updateEnvironment(editingEnvId.value, payload)
} else {
await dataService.createEnvironment(payload)
}
isEnvModalVisible.value = false
editingEnvId.value = null
await loadEnvironments()
} catch (e) {
console.error('Failed to save environment', e)
}
}
const deleteEnvironment = async (id) => {
if (!confirm('Are you sure you want to delete this environment?')) return
try {
await dataService.deleteEnvironment(id)
if (selectedEnvironment.value?.id === id || selectedEnvironment.value?._id === id) selectedEnvironment.value = null
await loadEnvironments()
} catch (e) {
console.error('Failed to delete environment', e)
}
}
const selectedAsset = ref(null)
const isModalVisible = ref(false)
const activeTab = ref("0")
@@ -45,8 +326,14 @@ const openModal = (asset) => {
toggleBulkSelection(asset.id)
return
}
selectedAsset.value = asset
isModalVisible.value = true
const idx = characterAssets.value.findIndex(a => a.id === asset.id)
const images = characterAssets.value.map(a => ({
url: API_URL + a.url,
assetId: a.id,
is_liked: a.is_liked || a.liked,
gen: a.generation_id ? { id: a.generation_id, prompt: a.prompt } : null
}))
openImagePreview(images, idx >= 0 ? idx : 0)
}
const toggleBulkSelection = (id) => {
@@ -132,10 +419,11 @@ const loadData = async () => {
loading.value = true
const charId = route.params.id
try {
const [char, assetsResponse, historyResponse] = await Promise.all([
const [char, assetsResponse, historyResponse, envsResponse] = await Promise.all([
dataService.getCharacterById(charId),
dataService.getAssetsByCharacterId(charId, assetsRows.value, assetsFirst.value),
aiService.getGenerations(historyRows.value, historyFirst.value, charId)
aiService.getGenerations(historyRows.value, historyFirst.value, charId),
dataService.getEnvironments(charId)
])
character.value = char
@@ -147,6 +435,8 @@ const loadData = async () => {
assetsTotalRecords.value = characterAssets.value.length
}
environments.value = Array.isArray(envsResponse) ? envsResponse : (envsResponse.environments || [])
if (historyResponse && historyResponse.generations) {
historyGenerations.value = historyResponse.generations
historyTotal.value = historyResponse.total_count || 0
@@ -226,17 +516,7 @@ const prompt = ref('')
const isGenerating = ref(false)
const generationStatus = ref('')
const generationProgress = ref(0)
const sendToTelegram = ref(false)
const useProfileImage = ref(true)
const telegramId = ref(localStorage.getItem('telegram_id') || '')
const isTelegramIdSaved = ref(!!localStorage.getItem('telegram_id'))
const saveTelegramId = () => {
if (telegramId.value) {
localStorage.setItem('telegram_id', telegramId.value)
isTelegramIdSaved.value = true
}
}
const generationSuccess = ref(false)
const generationError = ref(null)
const generatedResult = ref(null)
@@ -249,6 +529,12 @@ const previousPrompt = ref('')
const isUploading = ref(false)
const fileInput = ref(null)
const model = ref({ key: 'gemini-3-pro-image-preview', value: 'Pro' })
const modelOptions = ref([
{ key: 'gemini-3.1-flash-image-preview', value: '2' },
{ key: 'gemini-3-pro-image-preview', value: 'Pro' }
])
const selectedAssets = ref([])
const toggleAssetSelection = (asset) => {
const index = selectedAssets.value.findIndex(a => a.id === asset.id)
@@ -268,9 +554,6 @@ const quality = ref({
value: '2K'
})
const qualityOptions = ref([{
key: 'ONEK',
value: '1K'
}, {
key: 'TWOK',
value: '2K'
}, {
@@ -279,10 +562,16 @@ const qualityOptions = ref([{
}])
const aspectRatio = ref({ key: "NINESIXTEEN", value: "9:16" })
const aspectRatioOptions = ref([
{ key: "NINESIXTEEN", value: "9:16" },
{ key: "FOURTHREE", value: "4:3" },
{ key: "ONEONE", value: "1:1" },
{ key: "TWOTHREE", value: "2:3" },
{ key: "THREETWO", value: "3:2" },
{ key: "THREEFOUR", value: "3:4" },
{ key: "SIXTEENNINE", value: "16:9" }
{ key: "FOURTHREE", value: "4:3" },
{ key: "FOURFIVE", value: "4:5" },
{ key: "FIVEFOUR", value: "5:4" },
{ key: "NINESIXTEEN", value: "9:16" },
{ key: "SIXTEENNINE", value: "16:9" },
{ key: "TWENTYONENINE", value: "21:9" }
])
const assetsFirst = ref(0)
@@ -433,6 +722,10 @@ const restoreGeneration = async (gen) => {
// 1. Set prompt
prompt.value = gen.prompt
// 1.1 Set Model
const foundModel = modelOptions.value.find(opt => opt.key === gen.model)
if (foundModel) model.value = foundModel
// 2. Set Quality
const foundQuality = qualityOptions.value.find(opt => opt.key === gen.quality)
if (foundQuality) quality.value = foundQuality
@@ -491,6 +784,15 @@ const undoImprovePrompt = () => {
}
}
const pastePrompt = async () => {
try {
const text = await navigator.clipboard.readText()
if (text) prompt.value = text
} catch (err) {
console.error('Failed to read clipboard', err)
}
}
// --- Reuse Logic ---
const reusePrompt = (gen) => {
@@ -539,6 +841,29 @@ const useResultAsReference = (gen) => {
}
}
const markNsfw = async (gen) => {
// Determine new state (toggle)
const currentNsfw = gen.is_nsfw || gen.nsfw || false
const newNsfw = !currentNsfw
try {
await aiService.markGenerationNsfw(gen.id, newNsfw)
// Update local state
gen.is_nsfw = newNsfw
// Also update legacy property if present to keep UI consistent
if (gen.nsfw !== undefined) gen.nsfw = newNsfw
// If this is the currently displayed result, update it too
if (generatedResult.value && generatedResult.value.assets && generatedResult.value.assets.some(a => gen.result_list.includes(a.id))) {
generatedResult.value.is_nsfw = newNsfw
if (generatedResult.value.nsfw !== undefined) generatedResult.value.nsfw = newNsfw
}
} catch (e) {
console.error('Failed to toggle NSFW', e)
}
}
const triggerFileUpload = () => {
if (fileInput.value) fileInput.value.click()
}
@@ -570,24 +895,14 @@ const handleGenerate = async () => {
generatedResult.value = null
try {
if (sendToTelegram.value && !telegramId.value) {
alert("Please enter your Telegram ID")
isGenerating.value = false
return
}
if (telegramId.value && telegramId.value !== localStorage.getItem('telegram_id')) {
localStorage.setItem('telegram_id', telegramId.value)
isTelegramIdSaved.value = true
}
const payload = {
model: model.value.key,
linked_character_id: character.value?.id,
environment_id: selectedEnvironment.value?.id || selectedEnvironment.value?._id || null,
aspect_ratio: aspectRatio.value.key,
quality: quality.value.key,
prompt: prompt.value,
assets_list: selectedAssets.value.map(a => a.id),
telegram_id: sendToTelegram.value ? telegramId.value : null,
use_profile_image: useProfileImage.value,
count: generationCount.value
}
@@ -695,6 +1010,12 @@ const handleGenerate = async () => {
<span>Assets ({{ assetsTotalRecords }})</span>
</div>
</Tab>
<Tab value="envs">
<div class="!flex !flex-row !gap-1">
<i class="pi pi-map-marker text-[10px]" />
<span>Environments ({{ environments.length }})</span>
</div>
</Tab>
<Tab value="2" class="hidden">
<div class="!flex !flex-row !gap-1">
<i class="pi pi-history text-[10px]" />
@@ -712,34 +1033,48 @@ const handleGenerate = async () => {
<h2 class="text-sm font-bold m-0">Settings</h2>
</div>
<div class="flex flex-col gap-1.5">
<label
class="text-slate-400 text-[9px] font-semibold uppercase tracking-wider">Quality</label>
<div
class="!flex !w-full !justify-between items-center justify-center !bg-slate-900/50 !p-1 gap-1 !rounded-lg !border !border-white/10">
<div v-for="option in qualityOptions" :key="option.key"
@click="quality = option"
class="w-full items-center justify-center justify-items-center !text-center hover:bg-white/5 hover:text-white p-1 hover:rounded-lg"
:class="quality.key === option.key ? 'bg-white/9 text-white rounded-lg' : ''">
<span class="text-white w-full text-center">{{ option.value }}</span>
<div class="grid grid-cols-3 gap-2">
<div class="flex flex-col gap-1.5">
<label
class="text-slate-400 text-[9px] font-semibold uppercase tracking-wider">Model</label>
<div
class="!flex !w-full !justify-between items-center justify-center !bg-slate-900/50 !p-1 gap-1 !rounded-lg !border !border-white/10">
<div v-for="option in modelOptions" :key="option.key"
@click="model = option"
class="w-full items-center justify-center justify-items-center !text-center hover:bg-white/5 hover:text-white p-1 hover:rounded-lg cursor-pointer"
:class="model.key === option.key ? 'bg-white/10 text-white rounded-lg shadow-sm' : 'text-slate-500'">
<span class="text-white w-full text-center text-[8px]">{{ option.value }}</span>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label
class="text-slate-400 text-[9px] font-semibold uppercase tracking-wider">Aspect</label>
<div
class="!flex !w-full !justify-between items-center justify-center !bg-slate-900/50 !p-1 gap-1 !rounded-lg !border !border-white/10">
<div v-for="option in aspectRatioOptions" :key="option.key"
@click="aspectRatio = option"
class="w-full items-center justify-center justify-items-center !text-center hover:bg-white/5 hover:text-white p-1 hover:rounded-lg"
:class="aspectRatio.key === option.key ? 'bg-white/9 text-white rounded-lg' : ''">
<span class="text-white w-full text-center">{{ option.value }}</span>
<div class="flex flex-col gap-1.5">
<label
class="text-slate-400 text-[9px] font-semibold uppercase tracking-wider">Quality</label>
<div
class="!flex !w-full !justify-between items-center justify-center !bg-slate-900/50 !p-1 gap-1 !rounded-lg !border !border-white/10">
<div v-for="option in qualityOptions" :key="option.key"
@click="quality = option"
class="w-full items-center justify-center justify-items-center !text-center hover:bg-white/5 hover:text-white p-1 hover:rounded-lg cursor-pointer"
:class="quality.key === option.key ? 'bg-white/10 text-white rounded-lg' : ''">
<span class="text-white w-full text-center text-[8px]">{{ option.value }}</span>
</div>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label
class="text-slate-400 text-[9px] font-semibold uppercase tracking-wider">Aspect</label>
<div
class="!flex !w-full !justify-between items-center justify-center !bg-slate-900/50 !p-1 gap-1 !rounded-lg !border !border-white/10">
<div v-for="option in aspectRatioOptions" :key="option.key"
@click="aspectRatio = option"
class="w-full items-center justify-center justify-items-center !text-center hover:bg-white/5 hover:text-white p-1 hover:rounded-lg cursor-pointer"
:class="aspectRatio.key === option.key ? 'bg-white/10 text-white rounded-lg' : ''">
<span class="text-white w-full text-center text-[8px]">{{ option.value }}</span>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-1.5">
@@ -755,28 +1090,6 @@ const handleGenerate = async () => {
</div>
</div>
<div class="flex flex-col gap-1.5">
<label
class="text-slate-400 text-[9px] font-semibold uppercase tracking-wider">Description</label>
<div class="relative w-full">
<Textarea v-model="prompt" rows="3" autoResize placeholder="Describe..."
class="w-full bg-slate-900 border-white/10 text-white rounded-lg p-2 focus:border-violet-500 transition-all text-xs pr-10" />
<div class="absolute top-1.5 right-1.5 flex gap-1">
<Button v-if="previousPrompt" icon="pi pi-undo"
class="!p-1 !w-6 !h-6 !text-[10px] bg-slate-800 hover:bg-slate-700 border-white/10 text-slate-300"
@click="undoImprovePrompt" v-tooltip.top="'Rollback'" />
<Button icon="pi pi-sparkles" :loading="isImprovingPrompt"
:disabled="prompt.length <= 10"
class="!p-1 !w-6 !h-6 !text-[10px] bg-violet-600/20 hover:bg-violet-600/30 border-violet-500/30 text-violet-400 disabled:opacity-50 disabled:cursor-not-allowed"
@click="handleImprovePrompt"
v-tooltip.top="prompt.length <= 10 ? 'Enter at least 10 characters' : 'Improve prompt'" />
</div>
</div>
</div>
<!-- Assets Selection -->
<div class="flex flex-col gap-1.5">
<div class="flex justify-between items-center">
@@ -805,22 +1118,50 @@ const handleGenerate = async () => {
</div>
</div>
<!-- Environment Selection -->
<div class="flex flex-col gap-1.5">
<div class="flex justify-between items-center">
<label class="text-slate-400 text-[9px] font-semibold uppercase tracking-wider">Environment</label>
<Button v-if="selectedEnvironment" icon="pi pi-times" @click="selectedEnvironment = null" text size="small"
class="!p-0 !h-4 !w-4 !text-[8px] text-slate-500 hover:text-white" v-tooltip.top="'Clear'" />
</div>
<div v-if="environments.length > 0" class="flex gap-2 overflow-x-auto pb-1 custom-scrollbar no-scrollbar">
<div v-for="env in environments" :key="env.id || env._id"
@click="selectedEnvironment = env"
class="flex-shrink-0 flex items-center gap-2 px-2 py-1.5 rounded-lg border-2 transition-all cursor-pointer group bg-slate-900/30"
:class="[
(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id))
? 'border-violet-500 bg-violet-500/10 shadow-[0_0_15px_rgba(124,58,237,0.1)]'
: 'border-white/5 hover:border-white/20'
]"
>
<div class="w-6 h-6 rounded overflow-hidden flex-shrink-0 bg-slate-800 flex items-center justify-center border border-white/5">
<img v-if="env.asset_ids?.length > 0"
:src="API_URL + '/assets/' + env.asset_ids[0] + '?thumbnail=true'"
class="w-full h-full object-cover"
/>
<i v-else class="pi pi-map-marker text-[10px]"
:class="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id)) ? 'text-violet-400' : 'text-slate-500'"
></i>
</div>
<span class="text-[10px] whitespace-nowrap pr-1"
:class="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id)) ? 'text-violet-300 font-bold' : 'text-slate-400 group-hover:text-slate-200'"
>
{{ env.name }}
</span>
<i v-if="(selectedEnvironment?.id === (env.id || env._id) || selectedEnvironment?._id === (env.id || env._id))"
class="pi pi-check text-violet-400 text-[8px]"></i>
</div>
</div>
<div v-else class="py-2 text-center border border-dashed border-white/5 rounded-lg">
<p class="text-[8px] text-slate-600 uppercase m-0">No environments</p>
</div>
</div>
<!-- Removed Ref Assets section if characterAssets is empty, but it's already conditional -->
<div class="flex flex-col gap-1.5 mt-auto pt-1.5 border-t border-white/5">
<div class="flex flex-col gap-2 mb-2">
<div class="flex items-center gap-2">
<Checkbox v-model="sendToTelegram" :binary="true"
inputId="tg-check-char" />
<label for="tg-check-char"
class="text-[10px] text-slate-400 cursor-pointer select-none">Send
result to Telegram</label>
</div>
<div v-if="sendToTelegram && !isTelegramIdSaved"
class="animate-in fade-in slide-in-from-top-1 duration-200">
<InputText v-model="telegramId" placeholder="Enter Telegram ID"
class="w-full !text-[10px] !py-1" @blur="saveTelegramId" />
</div>
<div class="flex items-center gap-2 mt-1">
<Checkbox v-model="useProfileImage" :binary="true"
inputId="profile-img-check" />
@@ -987,7 +1328,7 @@ const handleGenerate = async () => {
:class="gen.children.length > 2 ? 'grid-cols-4' : 'grid-cols-2'">
<div v-for="child in gen.children" :key="child.id"
class="relative aspect-[9/16] rounded-md overflow-hidden bg-black/30 border border-white/5 group/child"
@click.stop="restoreGeneration(child)">
@click.stop="openImagePreview([{ url: API_URL + '/assets/' + child.result_list[0], assetId: child.result_list[0], is_liked: child.liked_assets?.includes(child.result_list[0]), gen: child }])">
<img v-if="child.result_list && child.result_list.length > 0"
:src="API_URL + '/assets/' + child.result_list[0] + '?thumbnail=true'"
@@ -1020,10 +1361,11 @@ const handleGenerate = async () => {
<div v-else class="flex gap-3 w-full">
<div class="w-12 h-12 rounded bg-black/40 border border-white/10 flex-shrink-0 mt-0.5 relative z-0"
@mouseenter="gen.result_list && gen.result_list[0] ? onThumbnailEnter($event, API_URL + '/assets/' + gen.result_list[0] + '?thumbnail=true') : null"
@mouseleave="onThumbnailLeave">
@mouseleave="onThumbnailLeave"
@click.stop="openImagePreview([{ url: API_URL + '/assets/' + gen.result_list[0], assetId: gen.result_list[0], is_liked: gen.liked_assets?.includes(gen.result_list[0]), gen: gen }])">
<img v-if="gen.result_list && gen.result_list.length > 0"
:src="API_URL + '/assets/' + gen.result_list[0] + '?thumbnail=true'"
class="w-full h-full object-cover rounded opacity-100" />
class="w-full h-full object-cover rounded opacity-100 cursor-pointer" />
<div v-else
class="w-full h-full flex items-center justify-center text-slate-700 overflow-hidden rounded">
<i class="pi pi-image text-lg" />
@@ -1047,6 +1389,7 @@ const handleGenerate = async () => {
<span class="capitalize"
:class="gen.status === 'done' ? 'text-green-500' : (gen.status === 'failed' ? 'text-red-500' : 'text-amber-500')">{{
gen.status }}</span>
<Tag v-if="gen.is_nsfw || gen.nsfw" value="NSFW" severity="danger" class="!text-[8px] !py-0 !px-1" />
<i v-if="gen.failed_reason"
v-tooltip.right="gen.failed_reason"
class="pi pi-exclamation-circle text-red-500"
@@ -1083,6 +1426,11 @@ const handleGenerate = async () => {
:disabled="gen.status !== 'done' || gen.result_list.length == 0"
@click.stop="useResultAsReference(gen)"
v-tooltip.bottom="'Use result as reference'" />
<Button :icon="(gen.is_nsfw || gen.nsfw) ? 'pi pi-eye' : 'pi pi-eye-slash'"
label="NSFW" size="small" text
class="!text-[10px] !py-0.5 !px-2 text-slate-400 hover:bg-white/5 flex-1"
@click.stop="markNsfw(gen)"
v-tooltip.bottom="(gen.is_nsfw || gen.nsfw) ? 'Unmark NSFW' : 'Mark NSFW'" />
</div>
</div>
</div>
@@ -1206,6 +1554,57 @@ const handleGenerate = async () => {
</div>
</TabPanel>
<TabPanel value="envs">
<div class="glass-panel p-8 rounded-3xl border border-white/5 bg-white/5">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold m-0">Environments ({{ environments.length }})</h2>
<Button label="Create Environment" icon="pi pi-plus" @click="openEnvModal()"
class="!py-2 !px-4 !text-sm font-bold bg-violet-600 hover:bg-violet-700 border-none text-white rounded-xl transition-all shadow-lg shadow-violet-500/20" />
</div>
<div v-if="environments.length === 0"
class="text-center py-16 text-slate-400 bg-white/[0.02] rounded-2xl">
<i class="pi pi-map-marker text-4xl mb-4 opacity-20"></i>
<p>No environments defined for this character.</p>
<p class="text-xs opacity-60">Environments allow grouping assets into spaces like "Bedroom" or "Kitchen".</p>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="env in environments" :key="env.id || env._id"
class="glass-panel rounded-2xl overflow-hidden border border-white/5 hover:border-violet-500/30 transition-all duration-300 group bg-white/[0.02]">
<div class="p-6">
<div class="flex justify-between items-start mb-4">
<div>
<h3 class="text-lg font-bold text-white mb-1">{{ env.name }}</h3>
<p class="text-xs text-slate-400">{{ env.asset_ids?.length || 0 }} assets linked</p>
</div>
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button icon="pi pi-pencil" text rounded size="small"
class="!text-slate-400 hover:!text-violet-400 hover:!bg-violet-500/10"
@click="openEnvModal(env)" />
<Button icon="pi pi-trash" text rounded size="small"
class="!text-slate-400 hover:!text-red-400 hover:!bg-red-500/10"
@click="deleteEnvironment(env.id || env._id)" />
</div>
</div>
<div class="flex flex-wrap gap-2">
<div v-for="assetId in env.asset_ids?.slice(0, 4)" :key="assetId"
class="w-10 h-10 rounded-lg overflow-hidden border border-white/10 bg-black/20">
<img :src="API_URL + '/assets/' + assetId + '?thumbnail=true'"
class="w-full h-full object-cover" />
</div>
<div v-if="env.asset_ids?.length > 4"
class="w-10 h-10 rounded-lg border border-dashed border-white/10 flex items-center justify-center text-[10px] text-slate-500">
+{{ env.asset_ids.length - 4 }}
</div>
</div>
</div>
</div>
</div>
</div>
</TabPanel>
</TabPanels>
</Tabs>
</div>
@@ -1228,18 +1627,16 @@ const handleGenerate = async () => {
Character not found.
</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">{{ selectedAsset.type }}</p>
</div>
</div>
</Dialog>
<GenerationPreviewModal
v-model:visible="isImagePreviewVisible"
:preview-images="previewImages"
:initial-index="previewIndex"
:api-url="API_URL"
@reuse-prompt="reusePrompt"
@reuse-asset="reuseAsset"
@use-result-as-asset="useResultAsReference"
@liked="handleLiked"
/>
<!-- Asset Selection Modal (Global) -->
<Dialog v-model:visible="isAssetSelectionVisible" modal header="Select Reference Assets"
:style="{ width: '80vw', maxWidth: '1000px' }" class="glass-panel rounded-2xl">
@@ -1275,6 +1672,120 @@ const handleGenerate = async () => {
</div>
</div>
</Dialog>
<!-- Environment Modal -->
<Dialog v-model:visible="isEnvModalVisible" modal :header="editingEnvId ? 'Edit Environment' : 'Create Environment'"
:style="{ width: '500px' }" class="glass-panel rounded-2xl">
<div class="flex flex-col gap-6 p-4">
<div class="flex flex-col gap-2">
<label class="text-xs font-bold text-slate-400 uppercase">Environment Name</label>
<InputText v-model="envForm.name" placeholder="e.g. Bedroom, Living Room..." class="w-full" />
</div>
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center">
<label class="text-xs font-bold text-slate-400 uppercase">Linked Assets ({{ envForm.asset_ids.length }})</label>
<Button label="Add Asset" icon="pi pi-plus" size="small" text
class="!text-[10px] !py-0.5 !px-1.5 text-violet-400 hover:bg-violet-500/10"
@click="isEnvAssetPickerVisible = true" />
</div>
<div v-if="envSelectedAssets.length > 0" class="flex flex-wrap gap-2 p-3 bg-slate-900/50 rounded-xl border border-white/5">
<div v-for="asset in envSelectedAssets" :key="asset.id || asset._id"
class="relative w-12 h-12 rounded overflow-hidden border border-violet-500/50 group">
<img :src="API_URL + asset.url + '?thumbnail=true'"
class="w-full h-full object-cover" />
<div @click="removeEnvAsset(asset.id || asset._id)"
class="absolute inset-0 bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer">
<i class="pi pi-times text-white text-[10px]"></i>
</div>
</div>
</div>
<div v-else
class="text-center py-6 text-xs text-slate-500 border border-dashed border-white/10 rounded-xl">
No assets selected for this environment
</div>
</div>
<div class="flex justify-end gap-3 mt-4">
<Button label="Cancel" @click="isEnvModalVisible = false" text class="text-slate-400" />
<Button :label="editingEnvId ? 'Update' : 'Create'" @click="saveEnvironment"
class="bg-violet-600 hover:bg-violet-700 border-none px-6" />
</div>
</div>
</Dialog>
<!-- Environment Asset Selection Modal (Character-specific) -->
<Dialog v-model:visible="isEnvAssetPickerVisible" modal header="Select Character Assets for Environment"
:style="{ width: '80vw', maxWidth: '800px' }" class="glass-panel rounded-2xl">
<div class="flex flex-col h-[70vh]">
<!-- Tabs & Upload -->
<div class="flex border-b border-white/5 mb-4 px-2 items-center">
<div class="flex flex-1">
<button v-for="tab in ['all', 'uploaded', 'generated']" :key="tab" @click="envAssetPickerTab = tab"
class="px-4 py-3 text-xs font-medium border-b-2 transition-colors capitalize"
:class="envAssetPickerTab === tab ? 'border-violet-500 text-violet-400' : 'border-transparent text-slate-400 hover:text-slate-200'">
{{ tab }}
</button>
</div>
<div class="flex items-center gap-2">
<input type="file" ref="envAssetPickerFileInput" @change="handleEnvAssetUpload" class="hidden"
accept="image/*" />
<Button :label="isEnvModalLoading && envAssetPickerTab === 'uploaded' ? 'Uploading...' : 'Upload'"
icon="pi pi-upload" size="small" text
class="!text-[10px] !py-1 !px-2 text-violet-400 hover:bg-violet-500/10"
@click="triggerEnvAssetUpload" />
</div>
</div>
<!-- Upload Progress -->
<div v-if="isEnvUploading" class="px-2 mb-4 animate-in fade-in slide-in-from-top-1">
<div class="flex justify-between items-center mb-1">
<span class="text-[10px] text-violet-400 font-bold uppercase tracking-wider">Uploading Asset...</span>
<span class="text-[10px] text-slate-500 font-mono">{{ envUploadProgress }}%</span>
</div>
<ProgressBar :value="envUploadProgress" style="height: 4px; width: 100%"
:showValue="false" class="rounded-full overflow-hidden !bg-slate-800" :pt="{
value: { class: '!bg-gradient-to-r !from-violet-600 !to-cyan-500 !transition-all !duration-300' }
}" />
</div>
<div ref="envAssetScrollContainer" class="flex-1 overflow-y-auto p-1 text-slate-100 custom-scrollbar">
<div v-if="envModalAssets.length > 0" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
<div v-for="asset in envModalAssets" :key="asset.id || asset._id" @click="toggleEnvAssetSelection(asset.id || asset._id)"
class="aspect-square rounded-xl overflow-hidden cursor-pointer relative border transition-all"
:class="(envForm.asset_ids.includes(asset.id) || (asset._id && envForm.asset_ids.includes(asset._id))) ? 'border-violet-500 ring-2 ring-violet-500/20' : 'border-white/10 hover:border-white/30'">
<img :src="API_URL + asset.url + '?thumbnail=true'"
class="w-full h-full object-cover" />
<div v-if="envForm.asset_ids.includes(asset.id) || (asset._id && envForm.asset_ids.includes(asset._id))"
class="absolute inset-0 bg-violet-600/30 flex items-center justify-center">
<div class="bg-violet-600 rounded-full p-1 shadow-lg">
<i class="pi pi-check text-white text-xs font-bold"></i>
</div>
</div>
<div class="absolute bottom-0 left-0 right-0 p-1 bg-black/60 backdrop-blur-sm">
<p class="text-[9px] text-white truncate">{{ asset.name }}</p>
</div>
</div>
</div>
<div v-else-if="!isEnvModalLoading" class="flex flex-col items-center justify-center py-20 text-slate-500 gap-3">
<i class="pi pi-images text-4xl opacity-20"></i>
<p>No assets found for this character.</p>
</div>
<!-- Infinite Scroll Sentinel -->
<div ref="envAssetScrollSentinel" class="w-full h-12 flex items-center justify-center mt-4">
<i v-if="isEnvModalLoading" class="pi pi-spin pi-spinner text-violet-500 text-xl"></i>
<span v-else-if="envModalAssets.length > 0 && envModalAssets.length >= envModalTotal" class="text-[10px] text-slate-600 italic">
All assets loaded
</span>
</div>
</div>
<div class="mt-4 pt-4 border-t border-white/10 flex justify-between items-center text-slate-100">
<span class="text-sm text-slate-400">{{ envForm.asset_ids.length }} selected</span>
<Button label="Done" @click="isEnvAssetPickerVisible = false" class="!px-6" />
</div>
</div>
</Dialog>
</div>
</div>
</template>

View File

@@ -0,0 +1,955 @@
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { postService } from '../services/postService'
import { aiService } from '../services/aiService'
import { useConfirm } from 'primevue/useconfirm'
import { useToast } from 'primevue/usetoast'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import DatePicker from 'primevue/datepicker'
import ConfirmDialog from 'primevue/confirmdialog'
import Toast from 'primevue/toast'
import JSZip from 'jszip'
const confirm = useConfirm()
const toast = useToast()
const API_URL = import.meta.env.VITE_API_URL || ''
// --- Timezone helpers ---
// Normalize a date to noon local time before converting to ISO
// This prevents day-shift when UTC offset crosses midnight
function toLocalNoonISO(date) {
const d = new Date(date)
d.setHours(12, 0, 0, 0)
return d.toISOString()
}
// Parse date string from API — ensure it's treated as UTC
// Server may return "2026-02-16T21:00:00" without Z, which browsers
// would interpret as local time. We append Z to force UTC parsing.
function parseUTCDate(dateStr) {
if (typeof dateStr === 'string' && !dateStr.endsWith('Z') && !/[+-]\d{2}(:\d{2})?$/.test(dateStr)) {
return new Date(dateStr + 'Z')
}
return new Date(dateStr)
}
// --- Calendar state ---
const currentDate = ref(new Date()) // tracks currently viewed month
const viewMode = ref('month') // 'month' | 'week' | 'day'
const selectedDay = ref(null)
const posts = ref([])
const loading = ref(false)
// --- New/Edit post dialog ---
const showPostDialog = ref(false)
const editingPost = ref(null) // null = create, object = edit
const postForm = ref({ date: new Date(), topic: '' })
// --- Day names (Monday first) ---
const dayNames = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
const monthNames = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь']
// --- Computeds ---
const currentMonthName = computed(() => monthNames[currentDate.value.getMonth()])
const currentYear = computed(() => currentDate.value.getFullYear())
const headerTitle = computed(() => {
if (viewMode.value === 'day' && selectedDay.value) {
const d = selectedDay.value
return `${d.getDate()} ${monthNames[d.getMonth()]} ${d.getFullYear()}`
}
if (viewMode.value === 'week' && selectedDay.value) {
const ws = getWeekStart(selectedDay.value)
const we = new Date(ws)
we.setDate(we.getDate() + 6)
const m1 = monthNames[ws.getMonth()].substring(0, 3)
const m2 = monthNames[we.getMonth()].substring(0, 3)
if (ws.getMonth() === we.getMonth()) {
return `${ws.getDate()} ${we.getDate()} ${m1} ${ws.getFullYear()}`
}
return `${ws.getDate()} ${m1} ${we.getDate()} ${m2} ${we.getFullYear()}`
}
return `${currentMonthName.value} ${currentYear.value}`
})
// Get Monday of the week containing a date
function getWeekStart(date) {
const d = new Date(date)
const day = d.getDay()
const diff = d.getDate() - day + (day === 0 ? -6 : 1) // Monday
d.setDate(diff)
d.setHours(0, 0, 0, 0)
return d
}
// Calendar grid cells for month view
const calendarDays = computed(() => {
const year = currentDate.value.getFullYear()
const month = currentDate.value.getMonth()
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
// Day of week for first day (0=Sun, we want Mon=0)
let startDow = firstDay.getDay() - 1
if (startDow < 0) startDow = 6
const days = []
// Previous month padding
for (let i = startDow - 1; i >= 0; i--) {
const d = new Date(year, month, -i)
days.push({ date: d, isOtherMonth: true })
}
// Current month
for (let i = 1; i <= lastDay.getDate(); i++) {
const d = new Date(year, month, i)
days.push({ date: d, isOtherMonth: false })
}
// Next month padding (fill to 42 cells = 6 rows)
const remaining = 42 - days.length
for (let i = 1; i <= remaining; i++) {
const d = new Date(year, month + 1, i)
days.push({ date: d, isOtherMonth: true })
}
return days
})
// Week view days
const weekDays = computed(() => {
const start = selectedDay.value ? getWeekStart(selectedDay.value) : getWeekStart(new Date())
const days = []
for (let i = 0; i < 7; i++) {
const d = new Date(start)
d.setDate(d.getDate() + i)
days.push({ date: d, isOtherMonth: false })
}
return days
})
// Today check
const isToday = (date) => {
const today = new Date()
return date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
}
const isSameDay = (a, b) => {
if (!a || !b) return false
return a.getDate() === b.getDate() && a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear()
}
// Posts for a given date
const getPostsForDate = (date) => {
return posts.value.filter(p => {
const pd = parseUTCDate(p.date)
return isSameDay(pd, date)
})
}
// Day view posts
const dayPosts = computed(() => {
if (!selectedDay.value) return []
return getPostsForDate(selectedDay.value)
})
// --- API calls ---
async function fetchPosts() {
loading.value = true
try {
const year = currentDate.value.getFullYear()
const month = currentDate.value.getMonth()
// Fetch the full visible range (prev month padding + next)
const dateFrom = toLocalNoonISO(new Date(year, month - 1, 1))
const dateTo = toLocalNoonISO(new Date(year, month + 2, 0))
const result = await postService.getPosts(dateFrom, dateTo)
posts.value = Array.isArray(result) ? result : []
} catch (e) {
console.error('Failed to fetch posts', e)
posts.value = []
} finally {
loading.value = false
}
}
// --- Navigation ---
function prevPeriod() {
if (viewMode.value === 'month') {
const d = new Date(currentDate.value)
d.setMonth(d.getMonth() - 1)
currentDate.value = d
} else if (viewMode.value === 'week') {
const d = new Date(selectedDay.value || new Date())
d.setDate(d.getDate() - 7)
selectedDay.value = d
currentDate.value = new Date(d.getFullYear(), d.getMonth(), 1)
} else {
const d = new Date(selectedDay.value || new Date())
d.setDate(d.getDate() - 1)
selectedDay.value = d
currentDate.value = new Date(d.getFullYear(), d.getMonth(), 1)
}
}
function nextPeriod() {
if (viewMode.value === 'month') {
const d = new Date(currentDate.value)
d.setMonth(d.getMonth() + 1)
currentDate.value = d
} else if (viewMode.value === 'week') {
const d = new Date(selectedDay.value || new Date())
d.setDate(d.getDate() + 7)
selectedDay.value = d
currentDate.value = new Date(d.getFullYear(), d.getMonth(), 1)
} else {
const d = new Date(selectedDay.value || new Date())
d.setDate(d.getDate() + 1)
selectedDay.value = d
currentDate.value = new Date(d.getFullYear(), d.getMonth(), 1)
}
}
function goToday() {
const today = new Date()
currentDate.value = new Date(today.getFullYear(), today.getMonth(), 1)
selectedDay.value = today
}
function selectDay(dayObj) {
selectedDay.value = dayObj.date
if (viewMode.value === 'month') {
viewMode.value = 'day'
}
}
// --- Post CRUD ---
function openNewPostDialog(date) {
editingPost.value = null
postForm.value = {
date: date ? new Date(date) : new Date(),
topic: ''
}
showPostDialog.value = true
}
function openEditPostDialog(post) {
editingPost.value = post
postForm.value = {
date: parseUTCDate(post.date),
topic: post.topic
}
showPostDialog.value = true
}
async function savePost() {
if (!postForm.value.topic.trim()) {
toast.add({ severity: 'warn', summary: 'Тема обязательна', life: 2000 })
return
}
try {
if (editingPost.value) {
await postService.updatePost(editingPost.value.id, {
date: toLocalNoonISO(postForm.value.date),
topic: postForm.value.topic
})
toast.add({ severity: 'success', summary: 'Пост обновлён', life: 2000 })
} else {
await postService.createPost({
date: toLocalNoonISO(postForm.value.date),
topic: postForm.value.topic,
generation_ids: []
})
toast.add({ severity: 'success', summary: 'Пост создан', life: 2000 })
}
showPostDialog.value = false
await fetchPosts()
} catch (e) {
console.error('Save post failed', e)
toast.add({ severity: 'error', summary: 'Ошибка', detail: 'Не удалось сохранить', life: 3000 })
}
}
function deletePost(post) {
confirm.require({
message: `Удалить пост "${post.topic}"?`,
header: 'Удаление поста',
icon: 'pi pi-trash',
acceptClass: 'p-button-danger',
accept: async () => {
try {
await postService.deletePost(post.id)
toast.add({ severity: 'info', summary: 'Удалено', life: 2000 })
await fetchPosts()
} catch (e) {
console.error('Delete post failed', e)
}
}
})
}
async function removeGenerationFromPost(post, genId) {
try {
await postService.removeGeneration(post.id, genId)
// Optimistic update
const idx = post.generation_ids.indexOf(genId)
if (idx > -1) post.generation_ids.splice(idx, 1)
toast.add({ severity: 'info', summary: 'Генерация удалена из поста', life: 2000 })
} catch (e) {
console.error(e)
}
}
// --- Download Post Assets ---
const isDownloading = ref(false)
async function downloadPostAssets(post) {
const ids = post.generation_ids || []
if (ids.length === 0) return
isDownloading.value = true
try {
const user = JSON.parse(localStorage.getItem('user'))
const headers = {}
if (user && user.access_token) headers['Authorization'] = `Bearer ${user.access_token}`
else if (user && user.token) headers['Authorization'] = `${user.tokenType} ${user.token}`
const projectId = localStorage.getItem('active_project_id')
if (projectId) headers['X-Project-ID'] = projectId
// Multi-file ZIP (if > 1)
if (ids.length > 1) {
const zip = new JSZip()
// Sanitize topic for filename
const safeTopic = (post.topic || 'post').replace(/[^a-z0-9_\-а-яё ]/gi, '').trim().replace(/\s+/g, '_')
const folderName = `${safeTopic}_assets`
let successCount = 0
await Promise.all(ids.map(async (assetId) => {
try {
const url = API_URL + '/assets/' + assetId
const resp = await fetch(url, { headers })
if (!resp.ok) return
const blob = await resp.blob()
const mime = blob.type
const ext = mime.split('/')[1] || 'png'
zip.file(`${assetId}.${ext}`, blob)
successCount++
} catch (err) {
console.error('Failed to zip asset', assetId, err)
}
}))
if (successCount === 0) throw new Error('No images available for zip')
const content = await zip.generateAsync({ type: 'blob' })
const filename = `${folderName}.zip`
const a = document.createElement('a')
a.href = URL.createObjectURL(content)
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(a.href)
toast.add({ severity: 'success', summary: 'Архив создан', detail: `Скачано ${successCount} файлов`, life: 3000 })
} else {
// Single File
const assetId = ids[0]
const url = API_URL + '/assets/' + assetId
const resp = await fetch(url, { headers })
const blob = await resp.blob()
const file = new File([blob], assetId + '.png', { type: blob.type || 'image/png' })
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || navigator.maxTouchPoints > 1
if (isMobile && navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({ files: [file] })
} else {
const a = document.createElement('a')
a.href = URL.createObjectURL(file)
a.download = file.name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(a.href)
}
toast.add({ severity: 'success', summary: 'Скачано', detail: `Файл сохранен`, life: 2000 })
}
} catch (e) {
console.error('Download failed', e)
toast.add({ severity: 'error', summary: 'Ошибка скачивания', life: 3000 })
} finally {
isDownloading.value = false
}
}
// --- Watchers ---
watch(currentDate, fetchPosts)
onMounted(() => {
selectedDay.value = new Date()
fetchPosts()
})
// --- Generation Picker ---
const showGenPicker = ref(false)
const genPickerPost = ref(null)
const availableGenerations = ref([])
const selectedGenIds = ref(new Set())
const genLoading = ref(false)
const genLoadingMore = ref(false)
const genOffset = ref(0)
const genHasMore = ref(true)
const GEN_LIMIT = 30
function openGenPicker(post) {
genPickerPost.value = post
selectedGenIds.value = new Set()
availableGenerations.value = []
genOffset.value = 0
genHasMore.value = true
showGenPicker.value = true
loadGenerations(true)
}
async function loadGenerations(initial = false) {
if (initial) {
genLoading.value = true
genOffset.value = 0
} else {
genLoadingMore.value = true
}
try {
const response = await aiService.getGenerations(GEN_LIMIT, genOffset.value)
const list = response && response.generations ? response.generations : (Array.isArray(response) ? response : [])
if (initial) {
availableGenerations.value = list
} else {
availableGenerations.value.push(...list)
}
genOffset.value += list.length
genHasMore.value = list.length >= GEN_LIMIT
} catch (e) {
console.error('Load generations failed', e)
} finally {
genLoading.value = false
genLoadingMore.value = false
}
}
function toggleGenSelection(assetId) {
const s = new Set(selectedGenIds.value)
if (s.has(assetId)) s.delete(assetId)
else s.add(assetId)
selectedGenIds.value = s
}
async function confirmAddGenerations() {
if (!genPickerPost.value || selectedGenIds.value.size === 0) return
try {
await postService.addGenerations(genPickerPost.value.id, [...selectedGenIds.value])
toast.add({ severity: 'success', summary: `Добавлено ${selectedGenIds.value.size} изображений`, life: 2000 })
showGenPicker.value = false
await fetchPosts()
} catch (e) {
console.error('Add generations failed', e)
toast.add({ severity: 'error', summary: 'Ошибка', detail: 'Не удалось добавить', life: 3000 })
}
}
function onGenPickerScroll(e) {
const el = e.target
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100 && genHasMore.value && !genLoadingMore.value) {
loadGenerations(false)
}
}
// --- Image Preview ---
const previewVisible = ref(false)
const previewImages = ref([])
const previewIndex = ref(0)
const previewCurrent = computed(() => previewImages.value[previewIndex.value] || null)
function openPreview(assetIds, clickedId) {
previewImages.value = assetIds.map(id => API_URL + '/assets/' + id)
const idx = assetIds.indexOf(clickedId)
previewIndex.value = idx >= 0 ? idx : 0
previewVisible.value = true
}
function previewNav(dir) {
if (previewImages.value.length === 0) return
let i = previewIndex.value + dir
if (i < 0) i = previewImages.value.length - 1
if (i >= previewImages.value.length) i = 0
previewIndex.value = i
}
function onPreviewKey(e) {
if (!previewVisible.value) return
if (e.key === 'ArrowLeft') { e.preventDefault(); previewNav(-1) }
else if (e.key === 'ArrowRight') { e.preventDefault(); previewNav(1) }
else if (e.key === 'Escape') { previewVisible.value = false }
}
watch(previewVisible, (v) => {
if (v) window.addEventListener('keydown', onPreviewKey)
else window.removeEventListener('keydown', onPreviewKey)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onPreviewKey)
})
</script>
<template>
<div class="flex flex-col h-full w-full bg-slate-900 text-slate-100 font-sans overflow-hidden">
<ConfirmDialog />
<Toast />
<!-- Header -->
<header
class="h-16 border-b border-white/5 flex items-center justify-between px-6 bg-slate-900/80 backdrop-blur z-20 shrink-0">
<div class="flex items-center gap-4">
<h1 class="text-lg font-bold text-slate-200 !m-0">📅 Content plan</h1>
</div>
<div class="flex items-center gap-3">
<!-- Navigation Arrows -->
<div class="flex items-center gap-1">
<button @click="prevPeriod"
class="w-8 h-8 rounded-lg bg-slate-800 border border-white/10 flex items-center justify-center text-slate-400 hover:text-white hover:bg-slate-700 transition-all">
<i class="pi pi-chevron-left text-xs"></i>
</button>
<button @click="goToday"
class="px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-800 border border-white/10 text-slate-400 hover:text-white hover:bg-slate-700 transition-all">
Сегодня
</button>
<button @click="nextPeriod"
class="w-8 h-8 rounded-lg bg-slate-800 border border-white/10 flex items-center justify-center text-slate-400 hover:text-white hover:bg-slate-700 transition-all">
<i class="pi pi-chevron-right text-xs"></i>
</button>
</div>
<!-- Period Title -->
<span class="text-sm font-semibold text-slate-200 min-w-[180px] text-center hidden md:block">{{
headerTitle }}</span>
<!-- View Toggle -->
<div class="flex bg-slate-800 rounded-lg p-1 border border-white/5">
<button @click="viewMode = 'month'"
class="px-3 py-1.5 rounded-md text-xs font-medium transition-all"
:class="viewMode === 'month' ? 'bg-violet-600 text-white shadow-lg' : 'text-slate-400 hover:text-slate-200'">
Месяц
</button>
<button @click="viewMode = 'week'; if (!selectedDay) selectedDay = new Date()"
class="px-3 py-1.5 rounded-md text-xs font-medium transition-all"
:class="viewMode === 'week' ? 'bg-violet-600 text-white shadow-lg' : 'text-slate-400 hover:text-slate-200'">
Неделя
</button>
<button @click="viewMode = 'day'; if (!selectedDay) selectedDay = new Date()"
class="px-3 py-1.5 rounded-md text-xs font-medium transition-all"
:class="viewMode === 'day' ? 'bg-violet-600 text-white shadow-lg' : 'text-slate-400 hover:text-slate-200'">
День
</button>
</div>
</div>
</header>
<!-- Mobile Period Title -->
<div class="md:hidden text-center py-2 text-sm font-semibold text-slate-200 border-b border-white/5">
{{ headerTitle }}
</div>
<!-- Content Area -->
<div class="flex-1 overflow-y-auto custom-scrollbar">
<!-- ====== MONTH VIEW ====== -->
<div v-if="viewMode === 'month'" class="h-full flex flex-col p-2 md:p-4">
<!-- Day headers -->
<div class="grid grid-cols-7 mb-1">
<div v-for="d in dayNames" :key="d"
class="text-center text-[10px] font-bold text-slate-500 uppercase tracking-wider py-2">
{{ d }}
</div>
</div>
<!-- Day cells -->
<div class="grid grid-cols-7 flex-1 gap-px bg-white/5 rounded-xl overflow-hidden">
<div v-for="(dayObj, idx) in calendarDays" :key="idx" @click="selectDay(dayObj)" :class="[
'bg-slate-900 p-1.5 min-h-[80px] md:min-h-[100px] cursor-pointer transition-all hover:bg-slate-800/80 relative group',
dayObj.isOtherMonth ? 'opacity-30' : '',
isToday(dayObj.date) ? 'ring-1 ring-violet-500/50 ring-inset' : '',
isSameDay(dayObj.date, selectedDay) ? 'bg-violet-900/20' : ''
]">
<!-- Day number -->
<div class="flex items-center justify-between mb-1">
<span :class="[
'text-xs font-bold w-6 h-6 flex items-center justify-center rounded-full',
isToday(dayObj.date) ? 'bg-violet-600 text-white' : 'text-slate-400'
]">
{{ dayObj.date.getDate() }}
</span>
<!-- Add button (show on hover) -->
<button v-if="!dayObj.isOtherMonth" @click.stop="openNewPostDialog(dayObj.date)"
class="w-5 h-5 rounded-md bg-violet-600/30 text-violet-300 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-violet-600/50">
<i class="pi pi-plus" style="font-size: 8px"></i>
</button>
</div>
<!-- Posts in cell -->
<div class="flex flex-col gap-0.5 overflow-hidden">
<div v-for="post in getPostsForDate(dayObj.date)" :key="post.id"
@click.stop="openEditPostDialog(post)"
class="px-1.5 py-0.5 rounded text-[10px] font-medium truncate transition-all hover:scale-[1.02] cursor-pointer"
:class="post.generation_ids?.length > 0
? 'bg-violet-600/30 text-violet-200 border border-violet-500/20'
: 'bg-slate-700/50 text-slate-300 border border-white/5'">
{{ post.topic }}
</div>
</div>
</div>
</div>
</div>
<!-- ====== WEEK VIEW ====== -->
<div v-else-if="viewMode === 'week'" class="h-full flex flex-col p-2 md:p-4">
<!-- Day headers -->
<div class="grid grid-cols-7 mb-1">
<div v-for="(dayObj, idx) in weekDays" :key="idx"
class="text-center text-[10px] font-bold uppercase tracking-wider py-2"
:class="isToday(dayObj.date) ? 'text-violet-400' : 'text-slate-500'">
{{ dayNames[idx] }} {{ dayObj.date.getDate() }}
</div>
</div>
<!-- Day columns -->
<div class="grid grid-cols-7 flex-1 gap-px bg-white/5 rounded-xl overflow-hidden">
<div v-for="(dayObj, idx) in weekDays" :key="idx"
@click="selectedDay = dayObj.date; viewMode = 'day'" :class="[
'bg-slate-900 p-2 min-h-[300px] cursor-pointer transition-all hover:bg-slate-800/80 relative group',
isToday(dayObj.date) ? 'ring-1 ring-violet-500/50 ring-inset' : '',
isSameDay(dayObj.date, selectedDay) ? 'bg-violet-900/20' : ''
]">
<!-- Add button -->
<button @click.stop="openNewPostDialog(dayObj.date)"
class="w-full mb-2 py-1 rounded-lg border border-dashed border-white/10 text-slate-500 text-xs opacity-0 group-hover:opacity-100 transition-opacity hover:border-violet-500/30 hover:text-violet-400">
<i class="pi pi-plus mr-1" style="font-size: 9px"></i> Добавить
</button>
<!-- Posts -->
<div class="flex flex-col gap-2">
<div v-for="post in getPostsForDate(dayObj.date)" :key="post.id"
class="bg-slate-800/80 rounded-lg border border-white/5 p-2 hover:border-violet-500/30 transition-all cursor-pointer"
@click.stop="openEditPostDialog(post)">
<p class="text-xs font-semibold text-slate-200 mb-1 line-clamp-2">{{ post.topic }}</p>
<!-- Generation thumbnails -->
<div v-if="post.generation_ids?.length > 0" class="flex gap-1 flex-wrap">
<img v-for="gid in post.generation_ids.slice(0, 4)" :key="gid"
:src="API_URL + '/assets/' + gid + '?thumbnail=true'"
class="w-8 h-8 rounded object-cover border border-white/10" />
<span v-if="post.generation_ids.length > 4"
class="w-8 h-8 rounded bg-slate-700 border border-white/10 flex items-center justify-center text-[10px] text-slate-400">
+{{ post.generation_ids.length - 4 }}
</span>
</div>
<!-- Actions -->
<div class="flex gap-1 mt-1.5 justify-end">
<button @click.stop="openGenPicker(post)"
class="w-5 h-5 rounded bg-violet-500/10 text-violet-400 flex items-center justify-center hover:bg-violet-500/20 transition-colors"
title="Добавить изображения">
<i class="pi pi-images" style="font-size: 9px"></i>
</button>
<button @click.stop="deletePost(post)"
class="w-5 h-5 rounded bg-red-500/10 text-red-400 flex items-center justify-center hover:bg-red-500/20 transition-colors">
<i class="pi pi-trash" style="font-size: 9px"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ====== DAY VIEW ====== -->
<div v-else class="max-w-3xl mx-auto p-4 md:p-8">
<!-- Add Post Button -->
<button @click="openNewPostDialog(selectedDay)"
class="w-full mb-6 py-3 rounded-xl border-2 border-dashed border-white/10 text-slate-400 text-sm font-medium hover:border-violet-500/40 hover:text-violet-300 hover:bg-violet-500/5 transition-all">
<i class="pi pi-plus mr-2"></i> Новый пост
</button>
<!-- No posts -->
<div v-if="dayPosts.length === 0 && !loading"
class="flex flex-col items-center justify-center py-20 text-slate-500 opacity-60">
<i class="pi pi-calendar text-5xl mb-4"></i>
<p>Нет постов на этот день</p>
</div>
<!-- Posts list -->
<div class="flex flex-col gap-4">
<div v-for="post in dayPosts" :key="post.id"
class="bg-slate-800/60 rounded-2xl border border-white/5 p-5 hover:border-violet-500/20 transition-all">
<!-- Header: topic + date -->
<div class="flex items-start justify-between mb-3">
<div class="flex-1">
<h3 class="text-base font-bold text-slate-100 mb-1">{{ post.topic }}</h3>
<p class="text-xs text-slate-500">
{{ parseUTCDate(post.date).toLocaleDateString('ru-RU', {
weekday: 'long', day: 'numeric', month: 'long'
}) }}
</p>
</div>
<button @click="deletePost(post)"
class="w-8 h-8 rounded-lg bg-slate-700/50 text-slate-400 flex items-center justify-center hover:bg-red-600/30 hover:text-red-300 transition-all"
title="Удалить">
<i class="pi pi-trash text-xs"></i>
</button>
</div>
<!-- Images section header with actions -->
<div class="flex items-center justify-between mb-2">
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
Изображения ({{ post.generation_ids?.length || 0 }})
</label>
<div class="flex gap-1">
<Button icon="pi pi-download" label="Скачать" size="small" text :loading="isDownloading"
:disabled="!post.generation_ids?.length" @click="downloadPostAssets(post)"
class="!text-slate-400 hover:!text-white !text-xs !py-1" />
<Button icon="pi pi-plus" label="Добавить" size="small" text
@click="openGenPicker(post)"
class="!text-violet-400 hover:!text-violet-300 !text-xs !py-1" />
<Button icon="pi pi-pencil" size="small" text @click="openEditPostDialog(post)"
class="!text-slate-400 hover:!text-white !text-xs !py-1" title="Редактировать" />
</div>
</div>
<!-- Generation grid -->
<div v-if="post.generation_ids?.length > 0"
class="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
<div v-for="gid in post.generation_ids" :key="gid"
class="aspect-square relative rounded-xl overflow-hidden bg-slate-900 border border-white/5 group/img">
<img :src="API_URL + '/assets/' + gid + '?thumbnail=true'"
class="w-full h-full object-cover cursor-pointer"
@click="openPreview(post.generation_ids, gid)" />
<button @click="removeGenerationFromPost(post, gid)"
class="absolute top-1 right-1 w-5 h-5 rounded-md bg-red-600/80 text-white flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition-opacity hover:bg-red-500">
<i class="pi pi-times" style="font-size: 8px"></i>
</button>
</div>
</div>
<div v-else
class="py-6 text-center text-slate-600 text-xs border border-dashed border-white/5 rounded-xl cursor-pointer hover:border-violet-500/30 hover:text-violet-400 transition-all"
@click="openGenPicker(post)">
<i class="pi pi-images mr-1"></i> Нажмите, чтобы добавить изображения
</div>
</div>
</div>
</div>
</div>
<!-- ====== NEW / EDIT POST DIALOG ====== -->
<Dialog v-model:visible="showPostDialog" :header="editingPost ? 'Редактировать пост' : 'Новый пост'" modal
:style="{ width: '600px', maxWidth: '95vw' }"
:pt="{ root: { class: '!bg-slate-800 !border-white/10' }, header: { class: '!bg-slate-800' }, content: { class: '!bg-slate-800' } }">
<div class="flex flex-col gap-4">
<!-- Date & Topic row -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Дата</label>
<DatePicker v-model="postForm.date" dateFormat="dd.mm.yy" showIcon class="w-full" :pt="{
root: { class: '!bg-slate-700 !border-white/10' },
pcInputText: { root: { class: '!bg-slate-700 !border-white/10 !text-white' } }
}" />
</div>
<div class="flex flex-col gap-1">
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Тема</label>
<InputText v-model="postForm.topic" placeholder="Тема поста..."
class="w-full !bg-slate-700 !border-white/10 !text-white" />
</div>
</div>
<!-- Attached Generations (edit mode only) -->
<div v-if="editingPost">
<div class="flex items-center justify-between mb-2">
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
Изображения ({{ editingPost.generation_ids?.length || 0 }})
</label>
<div class="flex gap-1">
<Button icon="pi pi-download" label="Скачать" size="small" text :loading="isDownloading"
:disabled="!editingPost.generation_ids?.length" @click="downloadPostAssets(editingPost)"
class="!text-slate-400 hover:!text-white !text-xs !py-1" />
<Button icon="pi pi-plus" label="Добавить" size="small" text
@click="openGenPicker(editingPost)"
class="!text-violet-400 hover:!text-violet-300 !text-xs !py-1" />
</div>
</div>
<!-- Generation grid -->
<div v-if="editingPost.generation_ids?.length > 0" class="grid grid-cols-4 sm:grid-cols-5 gap-2">
<div v-for="gid in editingPost.generation_ids" :key="gid"
class="aspect-square relative rounded-xl overflow-hidden bg-slate-900 border border-white/5 group/img">
<img :src="API_URL + '/assets/' + gid + '?thumbnail=true'"
class="w-full h-full object-cover cursor-pointer"
@click="openPreview(editingPost.generation_ids, gid)" />
<button @click="removeGenerationFromPost(editingPost, gid)"
class="absolute top-1 right-1 w-5 h-5 rounded-md bg-red-600/80 text-white flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition-opacity hover:bg-red-500">
<i class="pi pi-times" style="font-size: 8px"></i>
</button>
</div>
</div>
<div v-else
class="py-6 text-center text-slate-600 text-xs border border-dashed border-white/5 rounded-xl cursor-pointer hover:border-violet-500/30 hover:text-violet-400 transition-all"
@click="openGenPicker(editingPost)">
<i class="pi pi-images mr-1"></i> Нажмите, чтобы добавить изображения
</div>
</div>
<div class="flex justify-end gap-2 mt-2">
<Button label="Отмена" text @click="showPostDialog = false"
class="!text-slate-400 hover:!text-white" />
<Button :label="editingPost ? 'Сохранить' : 'Создать'" @click="savePost"
class="!bg-violet-600 !border-none hover:!bg-violet-500" />
</div>
</div>
</Dialog>
<!-- ====== GENERATION PICKER DIALOG ====== -->
<Dialog v-model:visible="showGenPicker" header="Добавить изображения" modal
:style="{ width: '700px', maxWidth: '95vw' }"
:pt="{ root: { class: '!bg-slate-800 !border-white/10' }, header: { class: '!bg-slate-800' }, content: { class: '!bg-slate-800 !p-0' } }">
<!-- Selected count bar -->
<div v-if="selectedGenIds.size > 0"
class="sticky top-0 z-10 flex items-center justify-between px-4 py-2 bg-violet-600/20 border-b border-violet-500/20">
<span class="text-sm text-violet-200 font-medium">
<i class="pi pi-check-circle mr-1"></i> Выбрано: {{ selectedGenIds.size }}
</span>
<Button label="Добавить" icon="pi pi-plus" @click="confirmAddGenerations" size="small"
class="!bg-violet-600 !border-none hover:!bg-violet-500 !text-sm !py-1" />
</div>
<!-- Loading -->
<div v-if="genLoading" class="flex items-center justify-center py-20">
<i class="pi pi-spin pi-spinner text-3xl text-violet-400"></i>
</div>
<!-- Grid -->
<div v-else class="p-4 max-h-[60vh] overflow-y-auto custom-scrollbar" @scroll="onGenPickerScroll">
<div v-if="availableGenerations.length === 0"
class="flex flex-col items-center justify-center py-16 text-slate-500">
<i class="pi pi-image text-4xl mb-3"></i>
<p class="text-sm">Нет доступных генераций</p>
</div>
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-2">
<template v-for="gen in availableGenerations" :key="gen.id">
<div v-for="assetId in (gen.result_list || [])" :key="assetId"
@click="toggleGenSelection(assetId)"
class="aspect-square relative rounded-xl overflow-hidden cursor-pointer transition-all group/gen border-2"
:class="selectedGenIds.has(assetId)
? 'border-violet-500 ring-2 ring-violet-500/30 scale-[0.95]'
: 'border-transparent hover:border-white/20'">
<img :src="API_URL + '/assets/' + assetId + '?thumbnail=true'"
class="w-full h-full object-cover" loading="lazy" />
<!-- Selection overlay -->
<div v-if="selectedGenIds.has(assetId)"
class="absolute inset-0 bg-violet-600/30 flex items-center justify-center">
<div
class="w-7 h-7 rounded-full bg-violet-600 flex items-center justify-center shadow-lg">
<i class="pi pi-check text-white" style="font-size: 12px"></i>
</div>
</div>
<!-- Hover overlay -->
<div v-else
class="absolute inset-0 bg-black/0 group-hover/gen:bg-black/20 transition-all flex items-center justify-center">
<div
class="w-7 h-7 rounded-full border-2 border-white/40 opacity-0 group-hover/gen:opacity-100 transition-opacity">
</div>
</div>
</div>
</template>
</div>
<!-- Load more spinner -->
<div v-if="genLoadingMore" class="flex justify-center py-4">
<i class="pi pi-spin pi-spinner text-xl text-slate-500"></i>
</div>
</div>
<!-- Footer with action -->
<div class="flex justify-end gap-2 px-4 py-3 border-t border-white/5">
<Button label="Отмена" text @click="showGenPicker = false" class="!text-slate-400 hover:!text-white" />
<Button label="Добавить" icon="pi pi-plus" :disabled="selectedGenIds.size === 0"
@click="confirmAddGenerations" class="!bg-violet-600 !border-none hover:!bg-violet-500" />
</div>
</Dialog>
<!-- ====== FULLSCREEN IMAGE PREVIEW ====== -->
<div v-if="previewVisible" class="fixed inset-0 z-[9999] bg-black/95 flex items-center justify-center"
@click.self="previewVisible = false">
<!-- Close -->
<button @click="previewVisible = false"
class="absolute top-4 right-4 w-10 h-10 rounded-full bg-white/10 text-white flex items-center justify-center hover:bg-white/20 transition-colors z-10">
<i class="pi pi-times text-lg"></i>
</button>
<!-- Counter -->
<div v-if="previewImages.length > 1"
class="absolute top-4 left-1/2 -translate-x-1/2 text-white/60 text-sm bg-black/40 px-3 py-1 rounded-full">
{{ previewIndex + 1 }} / {{ previewImages.length }}
</div>
<!-- Left arrow -->
<button v-if="previewImages.length > 1" @click="previewNav(-1)"
class="absolute left-3 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-white/10 text-white flex items-center justify-center hover:bg-white/20 transition-colors">
<i class="pi pi-chevron-left"></i>
</button>
<!-- Image -->
<img v-if="previewCurrent" :src="previewCurrent"
class="max-h-[90vh] max-w-[90vw] object-contain rounded-lg shadow-2xl" />
<!-- Right arrow -->
<button v-if="previewImages.length > 1" @click="previewNav(1)"
class="absolute right-3 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-white/10 text-white flex items-center justify-center hover:bg-white/20 transition-colors">
<i class="pi pi-chevron-right"></i>
</button>
</div>
</div>
</template>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,19 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { dataService } from '../services/dataService'
import { aiService } from '../services/aiService'
import {onMounted, ref} from 'vue'
import {useRouter} from 'vue-router'
import {dataService} from '../services/dataService'
import {aiService} from '../services/aiService'
import Button from 'primevue/button'
import Textarea from 'primevue/textarea'
import ProgressSpinner from 'primevue/progressspinner'
import ProgressBar from 'primevue/progressbar'
import Message from 'primevue/message'
import Tag from 'primevue/tag'
import Checkbox from 'primevue/checkbox'
import Dialog from 'primevue/dialog'
import Paginator from 'primevue/paginator'
import InputText from 'primevue/inputtext'
import Tag from 'primevue/tag'
import Dropdown from 'primevue/dropdown'
import GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
const router = useRouter()
const API_URL = import.meta.env.VITE_API_URL
@@ -44,32 +45,33 @@ const assetsTotalRecords = ref(0)
const assetsRows = ref(12)
const assetsFirst = ref(0)
const activeAssetFilter = ref('all')
const sendToTelegram = ref(false)
const telegramId = ref(localStorage.getItem('telegram_id') || '')
const isTelegramIdSaved = ref(!!localStorage.getItem('telegram_id'))
const isUploading = ref(false)
const fileInput = ref(null)
const saveTelegramId = () => {
if (telegramId.value) {
localStorage.setItem('telegram_id', telegramId.value)
isTelegramIdSaved.value = true
}
}
const model = ref({ key: 'gemini-3-pro-image-preview', value: 'Pro' })
const modelOptions = ref([
{ key: 'gemini-3.1-flash-image-preview', value: '2' },
{ key: 'gemini-3-pro-image-preview', value: 'Pro' }
])
const quality = ref({ key: 'TWOK', value: '2K' })
const qualityOptions = ref([
{ key: 'ONEK', value: '1K' },
{ key: 'TWOK', value: '2K' },
{ key: 'FOURK', value: '4K' }
])
const aspectRatio = ref({ key: "NINESIXTEEN", value: "9:16" })
const aspectRatioOptions = ref([
{ key: "NINESIXTEEN", value: "9:16" },
{ key: "FOURTHREE", value: "4:3" },
{ key: "ONEONE", value: "1:1" },
{ key: "TWOTHREE", value: "2:3" },
{ key: "THREETWO", value: "3:2" },
{ key: "THREEFOUR", value: "3:4" },
{ key: "SIXTEENNINE", value: "16:9" }
{ key: "FOURTHREE", value: "4:3" },
{ key: "FOURFIVE", value: "4:5" },
{ key: "FIVEFOUR", value: "5:4" },
{ key: "NINESIXTEEN", value: "9:16" },
{ key: "SIXTEENNINE", value: "16:9" },
{ key: "TWENTYONENINE", value: "21:9" }
])
// --- Data Loading ---
@@ -180,18 +182,6 @@ const onFileSelected = async (event) => {
const handleGenerate = async () => {
if (!prompt.value.trim()) return
// Validation for Telegram
if (sendToTelegram.value && !telegramId.value) {
alert("Please enter your Telegram ID")
return
}
// Save ID if provided
if (telegramId.value && telegramId.value !== localStorage.getItem('telegram_id')) {
localStorage.setItem('telegram_id', telegramId.value)
isTelegramIdSaved.value = true
}
isGenerating.value = true
generationSuccess.value = false
generationError.value = null
@@ -201,12 +191,12 @@ const handleGenerate = async () => {
try {
const payload = {
model: model.value.key,
aspect_ratio: aspectRatio.value.key,
quality: quality.value.key,
prompt: prompt.value,
assets_list: selectedAssets.value.map(a => a.id),
linked_character_id: null, // Explicitly null for global generation
telegram_id: sendToTelegram.value ? telegramId.value : null
}
const response = await aiService.runGeneration(payload)
@@ -288,6 +278,9 @@ const pollStatus = async (id) => {
const restoreGeneration = async (gen) => {
prompt.value = gen.prompt
const foundModel = modelOptions.value.find(opt => opt.key === gen.model)
if (foundModel) model.value = foundModel
const foundQuality = qualityOptions.value.find(opt => opt.key === gen.quality)
if (foundQuality) quality.value = foundQuality
@@ -295,23 +288,16 @@ const restoreGeneration = async (gen) => {
if (foundAspect) aspectRatio.value = foundAspect
if (gen.status === 'done' && gen.result_list && gen.result_list.length > 0) {
// We need to fetch details or just display the image
// history list usually has the main image preview
// Keep original gen object for preview and details
generatedResult.value = {
...gen,
type: 'assets',
// Mocking asset object structure from history usage in DetailView
assets: gen.result_list.map(id => ({
id,
url: `/assets/${id}`, // This might need adjustment based on how API serves files
// Ideally history API should return full asset objects or URLs.
// If not, we rely on the implementation in CharacterDetailView:
// :src="API_URL + '/assets/' + gen.result_list[0]"
// So let's construct it similarly
url: `/assets/${id}`,
})),
tech_prompt: gen.tech_prompt,
execution_time: gen.execution_time_seconds,
api_execution_time: gen.api_execution_time_seconds,
token_usage: gen.token_usage
}
}
}
@@ -339,14 +325,32 @@ const handleImprovePrompt = async () => {
const isImagePreviewVisible = ref(false)
const previewImage = ref(null)
const openImagePreview = (url, name = 'Image Preview', createdAt = null) => {
previewImage.value = { url, name, createdAt }
const openImagePreview = (url, name = 'Image Preview', createdAt = null, gen = null, assetId = null, isLiked = false) => {
previewImage.value = { url, name, createdAt, gen, assetId, is_liked: isLiked }
isImagePreviewVisible.value = true
}
const formatDate = (dateString) => {
if (!dateString) return ''
return new Date(dateString).toLocaleString()
const handleLiked = ({ id, is_liked }) => {
// Update local state in history
historyGenerations.value.forEach(gen => {
if (gen.result_list?.includes(id)) {
if (!gen.liked_assets) gen.liked_assets = []
if (is_liked) {
if (!gen.liked_assets.includes(id)) gen.liked_assets.push(id)
} else {
gen.liked_assets = gen.liked_assets.filter(aid => aid !== id)
}
}
})
// Also update generatedResult if it's the one liked
if (generatedResult.value && generatedResult.value.id === id || generatedResult.value?.result_list?.includes(id)) {
if (!generatedResult.value.liked_assets) generatedResult.value.liked_assets = []
if (is_liked) {
if (!generatedResult.value.liked_assets.includes(id)) generatedResult.value.liked_assets.push(id)
} else {
generatedResult.value.liked_assets = generatedResult.value.liked_assets.filter(aid => aid !== id)
}
}
}
const undoImprovePrompt = () => {
@@ -361,6 +365,15 @@ const clearPrompt = () => {
prompt.value = ''
}
const pastePrompt = async () => {
try {
const text = await navigator.clipboard.readText()
if (text) prompt.value = text
} catch (err) {
console.error('Failed to read clipboard', err)
}
}
// --- Reuse Logic ---
const reusePrompt = (gen) => {
@@ -412,10 +425,6 @@ const useResultAsReference = (gen) => {
// --- Utils ---
const copyToClipboard = () => {
// Implement if needed for prompt copying
}
// --- Lifecycle ---
@@ -439,13 +448,23 @@ onMounted(() => {
<!-- Settings Card -->
<div class="glass-panel p-6 rounded-2xl border border-white/5 bg-white/5 flex flex-col gap-6">
<!-- Quality & Aspect Ratio -->
<div class="grid grid-cols-2 gap-6">
<!-- Settings Row: Model, Quality & Aspect Ratio -->
<div class="grid grid-cols-3 gap-4">
<div class="flex flex-col gap-2">
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider">Model</label>
<div class="flex bg-slate-900/50 p-1 rounded-lg border border-white/10 h-[34px]">
<div v-for="option in modelOptions" :key="option.key" @click="model = option"
class="flex-1 flex items-center justify-center cursor-pointer rounded-md text-[10px] font-bold transition-all"
:class="model.key === option.key ? 'bg-white/10 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'">
{{ option.value }}
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider">Quality</label>
<div class="flex bg-slate-900/50 p-1 rounded-lg border border-white/10">
<div class="flex bg-slate-900/50 p-1 rounded-lg border border-white/10 h-[34px]">
<div v-for="option in qualityOptions" :key="option.key" @click="quality = option"
class="flex-1 text-center py-1.5 cursor-pointer rounded-md text-xs font-medium transition-all"
class="flex-1 flex items-center justify-center cursor-pointer rounded-md text-[10px] font-bold transition-all"
:class="quality.key === option.key ? 'bg-white/10 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'">
{{ option.value }}
</div>
@@ -454,14 +473,14 @@ onMounted(() => {
<div class="flex flex-col gap-2">
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider">Aspect
Ratio</label>
<div class="flex bg-slate-900/50 p-1 rounded-lg border border-white/10">
<div v-for="option in aspectRatioOptions" :key="option.key"
@click="aspectRatio = option"
class="flex-1 text-center py-1.5 cursor-pointer rounded-md text-xs font-medium transition-all"
:class="aspectRatio.key === option.key ? 'bg-white/10 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'">
{{ option.value }}
</div>
</div>
<Dropdown v-model="aspectRatio" :options="aspectRatioOptions" optionLabel="value"
class="w-full !bg-slate-900/50 !border-white/10 !text-white !rounded-lg !h-[34px]"
:pt="{
input: { class: '!text-white !text-[10px] !py-1 !px-2 !font-bold' },
trigger: { class: '!text-slate-400 !w-6' },
panel: { class: '!bg-slate-900 !border-white/10' },
item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white !text-[10px] !py-1' }
}" />
</div>
</div>
@@ -478,6 +497,9 @@ onMounted(() => {
:disabled="prompt.length <= 10" size="small"
class="!py-0.5 !px-2 !text-[10px] bg-violet-600/20 hover:bg-violet-600/30 border-violet-500/30 text-violet-400 disabled:opacity-50"
@click="handleImprovePrompt" />
<Button icon="pi pi-clipboard" label="Paste" size="small"
class="!py-0.5 !px-2 !text-[10px] bg-slate-800 hover:bg-slate-700 border-white/10 text-slate-400"
@click="pastePrompt" />
<Button icon="pi pi-times" label="Clear" size="small"
class="!py-0.5 !px-2 !text-[10px] bg-slate-800 hover:bg-slate-700 border-white/10 text-slate-400"
@click="clearPrompt" />
@@ -526,21 +548,6 @@ onMounted(() => {
<!-- Generate Button -->
<div class="mt-auto">
<div class="flex flex-col gap-2 mb-3">
<div class="flex items-center gap-2">
<Checkbox v-model="sendToTelegram" :binary="true" inputId="tg-check-gen" />
<label for="tg-check-gen"
class="text-xs text-slate-400 cursor-pointer select-none">Send result to
Telegram</label>
</div>
<div v-if="sendToTelegram && !isTelegramIdSaved"
class="animate-in fade-in slide-in-from-top-1 duration-200">
<InputText v-model="telegramId" placeholder="Enter Telegram ID"
class="w-full !text-xs !py-1.5" @blur="saveTelegramId" />
<small class="text-[10px] text-slate-500 block mt-0.5">ID will be saved for future
use</small>
</div>
</div>
<Button :label="isGenerating ? 'Generating...' : 'Generate Image'"
:icon="isGenerating ? 'pi pi-spin pi-spinner' : 'pi pi-magic'" :loading="isGenerating"
@click="handleGenerate"
@@ -594,7 +601,7 @@ onMounted(() => {
v-if="generatedResult.type === 'assets' && generatedResult.assets && generatedResult.assets.length > 0">
<!-- Displaying the first asset as main preview -->
<img :src="API_URL + '/assets/' + generatedResult.assets[0].id"
@click="openImagePreview(API_URL + '/assets/' + generatedResult.assets[0].id, 'Generated Result', new Date().toISOString())"
@click="openImagePreview(API_URL + '/assets/' + generatedResult.assets[0].id, 'Generated Result', new Date().toISOString(), generatedResult, generatedResult.assets[0].id, generatedResult.liked_assets?.includes(generatedResult.assets[0].id))"
class="w-full h-full object-contain cursor-pointer hover:scale-[1.01] transition-transform duration-300" />
</template>
<template v-else>
@@ -655,13 +662,20 @@ onMounted(() => {
<div class="flex-1 overflow-y-auto pr-2 custom-scrollbar flex flex-col gap-2">
<div v-for="gen in historyGenerations" :key="gen.id"
class="glass-panel p-2 rounded-lg border border-white/5 flex flex-col gap-2 hover:bg-white/10 transition-colors group">
class="glass-panel p-2 rounded-lg border border-white/5 flex flex-col gap-2 hover:bg-white/10 transition-colors group relative">
<!-- Liked badge on history item -->
<div v-if="gen.liked_assets?.length > 0"
class="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-pink-500 shadow-lg flex items-center justify-center border border-pink-400 z-10">
<i class="pi pi-heart-fill text-white text-[8px]"></i>
</div>
<div class="flex gap-3 items-start cursor-pointer" @click="restoreGeneration(gen)">
<div
class="w-12 h-12 rounded bg-black/40 border border-white/10 overflow-hidden flex-shrink-0 mt-0.5">
class="w-12 h-12 rounded bg-black/40 border border-white/10 overflow-hidden flex-shrink-0 mt-0.5 relative group/hist">
<img v-if="gen.result_list && gen.result_list.length > 0"
:src="API_URL + '/assets/' + gen.result_list[0] + '?thumbnail=true'"
class="w-full h-full object-cover" />
class="w-full h-full object-cover"
@click.stop="openImagePreview(API_URL + '/assets/' + gen.result_list[0], 'History Entry', gen.created_at, gen, gen.result_list[0], gen.liked_assets?.includes(gen.result_list[0]))" />
</div>
<div class="flex-1 min-w-0">
<p class="text-xs text-slate-300 truncate font-medium">{{ gen.prompt }}</p>
@@ -747,18 +761,16 @@ onMounted(() => {
</Dialog>
<!-- Image Preview Modal -->
<Dialog v-model:visible="isImagePreviewVisible" modal dismissableMask
:header="previewImage?.name || 'Image Preview'" :style="{ width: '90vw', maxWidth: '800px' }"
class="glass-panel rounded-2xl">
<div v-if="previewImage" class="flex flex-col items-center">
<img :src="previewImage.url" :alt="previewImage.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">{{ previewImage.name }}</h2>
<p v-if="previewImage.createdAt" class="text-slate-400">{{ formatDate(previewImage.createdAt) }}</p>
</div>
</div>
</Dialog>
<GenerationPreviewModal
v-model:visible="isImagePreviewVisible"
:preview-images="[previewImage].filter(i => !!i)"
:initial-index="0"
:api-url="API_URL"
@reuse-prompt="reusePrompt"
@reuse-asset="reuseAsset"
@use-result-as-asset="useResultAsReference"
@liked="handleLiked"
/>
</template>

View File

@@ -55,6 +55,15 @@ const copyToClipboard = () => {
navigator.clipboard.writeText(generatedPrompt.value)
}
const pastePrompt = async () => {
try {
const text = await navigator.clipboard.readText()
if (text) userPrompt.value = text
} catch (err) {
console.error('Failed to read clipboard', err)
}
}
</script>
@@ -124,11 +133,14 @@ const copyToClipboard = () => {
<!-- Optional Prompt -->
<div class="glass-panel p-1 rounded-2xl border border-white/5">
<div class="p-4 border-b border-white/5">
<div class="p-4 border-b border-white/5 flex justify-between items-center">
<label class="text-sm font-bold text-slate-300 flex items-center gap-2">
<i class="pi pi-align-left"></i> Additional Instructions <span
class="text-slate-500 font-normal">(Optional)</span>
</label>
<Button icon="pi pi-clipboard" label="Paste" size="small" text
class="!text-slate-400 hover:!text-white hover:!bg-white/10 !py-1"
@click="pastePrompt" />
</div>
<textarea v-model="userPrompt"
class="w-full bg-transparent border-none p-4 text-slate-100 focus:outline-none focus:ring-0 placeholder-slate-600 min-h-[100px] resize-none"

View File

@@ -1,92 +1,147 @@
<template>
<div class="container mx-auto p-4 animate-fade-in" v-if="project">
<!-- Header -->
<div class="glass-panel p-8 rounded-xl mb-8 relative overflow-hidden">
<div class="absolute top-0 right-0 p-4 opacity-10">
<i class="pi pi-folder text-9xl text-white"></i>
</div>
<div class="relative z-10">
<div class="flex items-center gap-3 mb-2">
<Button icon="pi pi-arrow-left" text rounded @click="router.push('/projects')" />
<span v-if="isCurrentProject"
class="bg-green-500/20 text-green-400 text-xs px-2 py-1 rounded-full border border-green-500/30 font-medium">
Active Project
</span>
<div class="h-full overflow-y-auto custom-scrollbar">
<div v-if="project" class="container mx-auto p-4 md:p-8 animate-fade-in">
<!-- Header -->
<div class="glass-panel p-8 rounded-xl mb-8 relative overflow-hidden">
<div class="absolute top-0 right-0 p-4 opacity-10">
<i class="pi pi-folder text-9xl text-white"></i>
</div>
<h1 class="text-4xl font-bold text-white mb-4">{{ project.name }}</h1>
<p class="text-slate-300 text-lg max-w-2xl mb-6">
{{ project.description || 'No description provided.' }}
</p>
<div class="flex gap-3">
<Button v-if="!isCurrentProject" label="Set as Active" icon="pi pi-check" @click="selectProject" />
<Button v-if="isOwner" label="Delete" icon="pi pi-trash" severity="danger" outlined
@click="confirmDelete" />
<Button label="Settings" icon="pi pi-cog" severity="secondary" outlined />
</div>
</div>
</div>
<!-- Confirm Dialog -->
<ConfirmDialog />
<!-- Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Members Column -->
<div class="lg:col-span-1">
<div class="glass-panel p-6 rounded-xl h-full">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold text-white">Team Members</h2>
<div class="relative z-10">
<div class="flex items-center gap-3 mb-2">
<Button icon="pi pi-arrow-left" text rounded @click="router.push('/projects')" />
<span v-if="isCurrentProject"
class="bg-green-500/20 text-green-400 text-xs px-2 py-1 rounded-full border border-green-500/30 font-medium">
Active Project
</span>
</div>
<!-- Inline Add Member -->
<div v-if="isOwner" class="mb-6">
<div class="flex gap-2">
<InputText v-model="inviteUsername" placeholder="Username to add"
class="w-full p-inputtext-sm" @keyup.enter="addMember" />
<Button label="Add" icon="pi pi-user-plus" size="small" @click="addMember"
:loading="inviting" :disabled="!inviteUsername.trim()" />
<h1 class="text-4xl font-bold text-white mb-4">{{ project.name }}</h1>
<p class="text-slate-300 text-lg max-w-2xl mb-6">
{{ project.description || 'No description provided.' }}
</p>
<div class="flex gap-3">
<Button v-if="!isCurrentProject" label="Set as Active" icon="pi pi-check" @click="selectProject" />
<Button v-if="isOwner" label="Delete" icon="pi pi-trash" severity="danger" outlined
@click="confirmDelete" />
<Button label="Settings" icon="pi pi-cog" severity="secondary" outlined />
</div>
</div>
</div>
<!-- Confirm Dialog -->
<ConfirmDialog />
<!-- Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Members Column -->
<div class="lg:col-span-1">
<div class="glass-panel p-6 rounded-xl h-full">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold text-white">Team Members</h2>
</div>
<!-- Inline Add Member -->
<div v-if="isOwner" class="mb-6">
<div class="flex gap-2">
<InputText v-model="inviteUsername" placeholder="Username to add"
class="w-full" @keyup.enter="addMember" />
<Button label="Add" icon="pi pi-user-plus" size="small" @click="addMember"
:loading="inviting" :disabled="!inviteUsername.trim()" />
</div>
</div>
</div>
<div class="flex flex-col gap-4">
<div v-for="memberId in project.members" :key="memberId"
class="flex items-center p-3 rounded-lg bg-slate-800/30 border border-slate-700/30">
<div
class="w-10 h-10 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white font-bold mr-3">
<i class="pi pi-user"></i>
</div>
<div class="overflow-hidden">
<p class="text-white font-medium truncate">{{ memberId === project.owner_id ? 'Owner' :
'Member' }}</p>
<p class="text-slate-500 text-xs truncate">ID: {{ memberId }}</p>
</div>
<div class="ml-auto" v-if="project.owner_id === memberId">
<i class="pi pi-crown text-yellow-500" title="Owner"></i>
</div>
<div class="flex flex-col gap-4">
<div v-for="member in project.members" :key="member.id"
class="flex items-center p-3 rounded-lg bg-slate-800/30 border border-slate-700/30">
<div
class="w-10 h-10 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white font-bold mr-3">
<i class="pi pi-user"></i>
</div>
<div class="overflow-hidden">
<p class="text-white font-bold truncate">{{ member.username }}</p>
<p class="text-slate-500 text-[10px] truncate font-mono">ID: {{ member.id }}</p>
</div>
<div class="ml-auto" v-if="project.owner_id === member.id">
<i class="pi pi-crown text-yellow-500" title="Owner"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Stats/Activity Column -->
<div class="lg:col-span-2">
<div class="glass-panel p-6 rounded-xl h-full flex flex-col">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold text-white">Usage Statistics</h2>
<Button icon="pi pi-refresh" text rounded @click="fetchUsage" :loading="loadingUsage" />
</div>
<div v-if="usageReport" class="flex-1 flex flex-col gap-8">
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="p-4 rounded-xl bg-slate-800/40 border border-white/5 text-center">
<p class="text-slate-500 text-xs uppercase font-bold tracking-wider mb-1">Total Runs</p>
<p class="text-2xl font-bold text-white">{{ usageReport.summary?.total_runs || 0 }}</p>
</div>
<div class="p-4 rounded-xl bg-slate-800/40 border border-white/5 text-center">
<p class="text-slate-500 text-xs uppercase font-bold tracking-wider mb-1">Total Tokens</p>
<p class="text-2xl font-bold text-white">{{ (usageReport.summary?.total_tokens || 0).toLocaleString() }}</p>
</div>
<div class="p-4 rounded-xl bg-violet-500/10 border border-violet-500/20 text-center">
<p class="text-violet-400 text-xs uppercase font-bold tracking-wider mb-1">Total Spend</p>
<p class="text-2xl font-bold text-violet-300">${{ (usageReport.summary?.total_cost || 0).toFixed(2) }}</p>
</div>
</div>
<!-- User Breakdown -->
<div v-if="usageReport.by_user && usageReport.by_user.length > 0">
<h3 class="text-sm font-bold text-slate-400 uppercase tracking-wider mb-4">Usage by Member</h3>
<div class="flex flex-col gap-2">
<div v-for="item in usageReport.by_user" :key="item.entity_id"
class="flex items-center justify-between p-3 rounded-lg bg-slate-800/20 border border-white/5">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-slate-700 flex items-center justify-center text-[10px] font-bold">
{{ (getMemberUsername(item.entity_id) || '??').substring(0,2).toUpperCase() }}
</div>
<div class="flex flex-col">
<span class="text-sm text-slate-300 font-bold truncate max-w-[150px]">{{ getMemberUsername(item.entity_id) || 'Unknown' }}</span>
<span class="text-[9px] text-slate-500 font-mono">{{ item.entity_id }}</span>
</div>
</div>
<div class="flex gap-6 text-right">
<div>
<p class="text-[10px] text-slate-500 uppercase font-bold">Runs</p>
<p class="text-sm text-white">{{ item.stats.total_runs }}</p>
</div>
<div>
<p class="text-[10px] text-slate-500 uppercase font-bold">Cost</p>
<p class="text-sm text-violet-300">${{ item.stats.total_cost.toFixed(2) }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="loadingUsage" class="flex-1 flex items-center justify-center py-20">
<i class="pi pi-spin pi-spinner text-4xl text-violet-500"></i>
</div>
<div v-else class="text-center py-12 text-slate-500 flex-1 flex flex-col justify-center">
<i class="pi pi-chart-line text-4xl mb-4 opacity-50"></i>
<p>No usage data available for this project.</p>
</div>
</div>
</div>
</div>
<!-- Stats/Activity Column (Placeholder) -->
<div class="lg:col-span-2">
<div class="glass-panel p-6 rounded-xl h-full">
<h2 class="text-xl font-bold text-white mb-6">Activity</h2>
<div class="text-center py-12 text-slate-500">
<i class="pi pi-chart-line text-4xl mb-4 opacity-50"></i>
<p>No recent activity registered.</p>
</div>
</div>
</div>
</div>
</div>
<div v-else class="flex justify-center items-center h-full">
<div class="text-center">
<i class="pi pi-spin pi-spinner text-4xl text-primary-500 mb-4"></i>
<p class="text-slate-400">Loading project...</p>
<div v-else class="flex justify-center items-center h-full">
<div class="text-center">
<i class="pi pi-spin pi-spinner text-4xl text-primary-500 mb-4"></i>
<p class="text-slate-400">Loading project...</p>
</div>
</div>
</div>
</template>
@@ -97,6 +152,7 @@ import { useRoute, useRouter } from 'vue-router';
import { useProjectsStore } from '@/stores/projectsStore';
import { storeToRefs } from 'pinia';
import { useAuthStore } from '@/stores/auth';
import { aiService } from '@/services/aiService';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import ConfirmDialog from 'primevue/confirmdialog';
@@ -113,14 +169,36 @@ const project = computed(() => projectsStore.getProjectById(projectId));
const isCurrentProject = computed(() => currentProject.value?.id === projectId);
const isOwner = computed(() => authStore.user && project.value && authStore.user.id === project.value.owner_id);
const getMemberUsername = (memberId) => {
if (!project.value || !project.value.members) return null;
const member = project.value.members.find(m => m.id === memberId);
return member ? member.username : null;
};
const inviteUsername = ref('');
const inviting = ref(false);
const usageReport = ref(null);
const loadingUsage = ref(false);
const fetchUsage = async () => {
loadingUsage.value = true;
try {
// Pass projectId from route params to ensure we get stats for THIS project
usageReport.value = await aiService.getUsageReport("user", projectId);
} catch (e) {
console.error("Failed to fetch usage report", e);
} finally {
loadingUsage.value = false;
}
};
onMounted(async () => {
// Ensure projects are loaded
if (projects.value.length === 0) {
await projectsStore.fetchProjects();
}
fetchUsage();
});
const selectProject = () => {
@@ -164,3 +242,22 @@ const confirmDelete = () => {
});
}
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
</style>

View File

@@ -1,73 +1,81 @@
<template>
<div class="container mx-auto p-4 animate-fade-in">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold text-white mb-2">Projects</h1>
<p class="text-slate-400">Manage your workspaces and teams</p>
</div>
<Button label="New Project" icon="pi pi-plus" @click="showCreateDialog = true" />
</div>
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Skeleton v-for="i in 3" :key="i" height="150px" class="rounded-xl" />
</div>
<div v-else-if="projects.length === 0" class="text-center py-12 glass-panel rounded-xl">
<i class="pi pi-folder-open text-6xl text-slate-600 mb-4"></i>
<h3 class="text-xl font-semibold text-white mb-2">No Projects Yet</h3>
<p class="text-slate-400 mb-6">Create your first project to get started</p>
<Button label="Create Project" icon="pi pi-plus" @click="showCreateDialog = true" text />
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="project in projects" :key="project.id"
class="glass-panel p-6 rounded-xl hover:bg-slate-800/50 transition-colors cursor-pointer group relative overflow-hidden"
@click="goToProject(project.id)">
<!-- Active Indicator -->
<div v-if="currentProject?.id === project.id" class="absolute top-0 right-0 p-2">
<span
class="bg-green-500/20 text-green-400 text-xs px-2 py-1 rounded-full border border-green-500/30 font-medium">
Active
</span>
<div class="h-full overflow-y-auto custom-scrollbar">
<div class="container mx-auto p-4 md:p-8 animate-fade-in">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold text-white mb-2">Projects</h1>
<p class="text-slate-400">Manage your workspaces and teams</p>
</div>
<Button label="New Project" icon="pi pi-plus" @click="showCreateDialog = true" />
</div>
<h3 class="text-xl font-semibold text-white mb-2 group-hover:text-primary-400 transition-colors">
{{ project.name }}
</h3>
<p class="text-slate-400 text-sm mb-4 line-clamp-2">
{{ project.description || 'No description' }}
</p>
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Skeleton v-for="i in 3" :key="i" height="150px" class="rounded-xl" />
</div>
<div class="flex items-center justify-between mt-4 border-t border-slate-700/50 pt-4">
<div class="flex items-center text-slate-500 text-sm">
<i class="pi pi-users mr-2"></i>
<span>{{ project.members.length }} members</span>
<div v-else-if="projects.length === 0" class="text-center py-12 glass-panel rounded-xl">
<i class="pi pi-folder-open text-6xl text-slate-600 mb-4"></i>
<h3 class="text-xl font-semibold text-white mb-2">No Projects Yet</h3>
<p class="text-slate-400 mb-6">Create your first project to get started</p>
<Button label="Create Project" icon="pi pi-plus" @click="showCreateDialog = true" text />
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="project in projects" :key="project.id"
class="glass-panel p-6 rounded-xl hover:bg-slate-800/50 transition-colors cursor-pointer group relative overflow-hidden"
@click="goToProject(project.id)">
<!-- Active Indicator -->
<div v-if="currentProject?.id === project.id" class="absolute top-0 right-0 p-2">
<span
class="bg-green-500/20 text-green-400 text-xs px-2 py-1 rounded-full border border-green-500/30 font-medium">
Active
</span>
</div>
<Button v-if="currentProject?.id !== project.id" icon="pi pi-check" label="Select" size="small"
severity="secondary" @click.stop="selectProject(project.id)" />
</div>
</div>
</div>
<h3 class="text-xl font-semibold text-white mb-2 group-hover:text-primary-400 transition-colors">
{{ project.name }}
</h3>
<p class="text-slate-400 text-sm mb-4 line-clamp-2">
{{ project.description || 'No description' }}
</p>
<!-- Create Project Dialog -->
<Dialog v-model:visible="showCreateDialog" modal header="Create New Project"
:style="{ width: '90vw', maxWidth: '500px' }">
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-col gap-2">
<label for="name" class="text-slate-300">Project Name</label>
<InputText id="name" v-model="newProject.name" autofocus />
</div>
<div class="flex flex-col gap-2">
<label for="description" class="text-slate-300">Description</label>
<Textarea id="description" v-model="newProject.description" rows="3" autoResize />
<div class="flex items-center justify-between mt-4 border-t border-slate-700/50 pt-4">
<div class="flex flex-col">
<div class="flex items-center text-slate-500 text-sm mb-1">
<i class="pi pi-users mr-2"></i>
<span>{{ project.members.length }} members</span>
</div>
<div v-if="projectUsage[project.id]" class="flex items-center text-violet-400 text-xs font-bold">
<i class="pi pi-bolt mr-2"></i>
<span>${{ projectUsage[project.id].toFixed(2) }} spent</span>
</div>
</div>
<Button v-if="currentProject?.id !== project.id" icon="pi pi-check" label="Select" size="small"
severity="secondary" @click.stop="selectProject(project.id)" />
</div>
</div>
</div>
<template #footer>
<Button label="Cancel" text severity="secondary" @click="showCreateDialog = false" />
<Button label="Create" icon="pi pi-check" @click="createProject" :loading="creating" />
</template>
</Dialog>
<!-- Create Project Dialog -->
<Dialog v-model:visible="showCreateDialog" modal header="Create New Project"
:style="{ width: '90vw', maxWidth: '500px' }">
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-col gap-2">
<label for="name" class="text-slate-300">Project Name</label>
<InputText id="name" v-model="newProject.name" autofocus />
</div>
<div class="flex flex-col gap-2">
<label for="description" class="text-slate-300">Description</label>
<Textarea id="description" v-model="newProject.description" rows="3" autoResize />
</div>
</div>
<template #footer>
<Button label="Cancel" text severity="secondary" @click="showCreateDialog = false" />
<Button label="Create" icon="pi pi-check" @click="createProject" :loading="creating" />
</template>
</Dialog>
</div>
</div>
</template>
@@ -75,6 +83,7 @@
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useProjectsStore } from '@/stores/projectsStore';
import { aiService } from '@/services/aiService';
import { storeToRefs } from 'pinia';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
@@ -94,8 +103,29 @@ const newProject = ref({
description: ''
});
const projectUsage = ref({});
const fetchUsage = async () => {
try {
// Fetch usage with project breakdown, pass false to ignore current active project header
const report = await aiService.getUsageReport("project", false);
if (report && report.by_project) {
const usageMap = {};
report.by_project.forEach(item => {
if (item.entity_id) {
usageMap[item.entity_id] = item.stats.total_cost;
}
});
projectUsage.value = usageMap;
}
} catch (e) {
console.error("Failed to fetch projects usage", e);
}
};
onMounted(() => {
projectsStore.fetchProjects();
fetchUsage();
});
const createProject = async () => {
@@ -121,3 +151,22 @@ const goToProject = (id) => {
router.push(`/projects/${id}`);
};
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
</style>