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 # Vite
*.timestamp-*-*.mjs *.timestamp-*-*.mjs
package-lock.json

View File

@@ -2,6 +2,7 @@
ssh root@31.59.58.220 " ssh root@31.59.58.220 "
cd /root/ai/ai-service-front && cd /root/ai/ai-service-front &&
git pull && git pull &&
npm install &&
npm run build && npm run build &&
cp -r dist/* /var/www/ai.luminic.space/ 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", "@primeuix/themes": "^2.0.3",
"@primevue/themes": "^4.5.4", "@primevue/themes": "^4.5.4",
"axios": "^1.13.4", "axios": "^1.13.4",
"jszip": "^3.10.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primevue": "^4.5.4", "primevue": "^4.5.4",
@@ -22,6 +23,7 @@
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@vitejs/plugin-vue": "^6.0.3", "@vitejs/plugin-vue": "^6.0.3",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"baseline-browser-mapping": "^2.9.19",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"vite": "^7.3.1", "vite": "^7.3.1",
@@ -3896,6 +3898,12 @@
"url": "https://opencollective.com/core-js" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4861,6 +4869,18 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/internal-slot": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -5474,6 +5494,18 @@
"node": ">=0.10.0" "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": { "node_modules/kolorist": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
@@ -5491,6 +5523,15 @@
"node": ">=6" "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": { "node_modules/lightningcss": {
"version": "1.30.2", "version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@@ -6058,6 +6099,12 @@
"dev": true, "dev": true,
"license": "BlueOak-1.0.0" "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": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -6244,6 +6291,12 @@
"node": ">=12.11.0" "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": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -6286,6 +6339,33 @@
"safe-buffer": "^5.1.0" "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": { "node_modules/readdirp": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
@@ -6647,6 +6727,12 @@
"node": ">= 0.4" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -6862,6 +6948,21 @@
"node": ">= 0.4" "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": { "node_modules/string-width": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -7464,6 +7565,12 @@
"browserslist": ">= 4.21.0" "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": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",

View File

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

View File

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

View File

@@ -218,19 +218,27 @@ h1, h2, h3, h4, h5, h6 {
/* --- Textarea / Inputs --- */ /* --- Textarea / Inputs --- */
.p-textarea, .p-textarea,
.p-inputtext { .p-inputtext,
.p-dropdown,
.p-multiselect,
.p-autocomplete,
.p-inputnumber input {
width: 100%; width: 100%;
background: rgba(15, 23, 42, 0.6) !important; background: rgba(15, 23, 42, 0.6) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important; border: 1px solid rgba(255, 255, 255, 0.1) !important;
border-radius: 8px !important; border-radius: 8px !important;
padding: 0.5rem !important; padding: 0.5rem !important;
color: white !important; color: white !important;
font-size: 0.8125rem !important; font-size: 1rem !important;
transition: all 0.3s ease !important; transition: all 0.3s ease !important;
} }
.p-textarea:focus, .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; outline: none !important;
border-color: #8b5cf6 !important; border-color: #8b5cf6 !important;
box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.1) !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; background: rgba(255,255,255,0.1) !important;
color: white !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 { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useProjectsStore } from '@/stores/projectsStore' import { useProjectsStore } from '@/stores/projectsStore'
import { aiService } from '@/services/aiService'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
@@ -13,6 +14,17 @@ const projectsStore = useProjectsStore()
const { projects, currentProject } = storeToRefs(projectsStore) const { projects, currentProject } = storeToRefs(projectsStore)
const selectedProject = ref(null) 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 () => { onMounted(async () => {
// Ensure we have projects // Ensure we have projects
@@ -23,6 +35,7 @@ onMounted(async () => {
if (currentProject.value) { if (currentProject.value) {
selectedProject.value = currentProject.value.id selectedProject.value = currentProject.value.id
} }
fetchUsage()
}) })
// Watch for external changes (like selecting from the list view) // Watch for external changes (like selecting from the list view)
@@ -70,11 +83,12 @@ const isActive = (path) => {
const navItems = computed(() => { const navItems = computed(() => {
const items = [ const items = [
{ path: '/', icon: '🏠', tooltip: 'Home' }, // { path: '/', icon: '🏠', tooltip: 'Home' },
{ path: '/projects', icon: '📂', tooltip: 'Projects' }, { path: '/projects', icon: '📂', tooltip: 'Projects' },
{ path: '/content-plan', icon: '📅', tooltip: 'Plan' },
{ path: '/ideas', icon: '💡', tooltip: 'Ideas' }, { path: '/ideas', icon: '💡', tooltip: 'Ideas' },
{ path: '/flexible', icon: '🖌️', tooltip: 'Flexible' }, { path: '/flexible', icon: '🖌️', tooltip: 'Flexible' },
{ path: '/albums', icon: '🖼️', tooltip: 'Library' }, // { path: '/albums', icon: '🖼️', tooltip: 'Library' },
{ path: '/characters', icon: '👥', tooltip: 'Characters' } { path: '/characters', icon: '👥', tooltip: 'Characters' }
] ]
@@ -90,52 +104,48 @@ const navItems = computed(() => {
<div class="contents"> <div class="contents">
<!-- Sidebar (Desktop -> Top Bar) --> <!-- Sidebar (Desktop -> Top Bar) -->
<nav <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 --> <!-- Logo -->
<img src="/web-app-manifest-512x512.png" alt="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 --> <!-- Project Switcher -->
<div class="hidden lg:block ml-4 relative"> <div class="hidden lg:block ml-2 relative">
<button @click="isProjectMenuOpen = !isProjectMenuOpen" <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"> 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"></i> <i v-if="selectedProject" class="pi pi-folder text-violet-400 text-[10px]"></i>
<i v-else class="pi pi-user"></i> <i v-else class="pi pi-user text-[10px]"></i>
<span class="max-w-[150px] truncate font-medium"> <span class="max-w-[120px] truncate font-medium">
{{ selectedProject ? getProjectName(selectedProject) : 'Personal Workspace' }} {{ selectedProject ? getProjectName(selectedProject) : 'Personal' }}
</span> </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> </button>
<!-- Custom Dropdown Menu --> <!-- Custom Dropdown Menu -->
<div v-if="isProjectMenuOpen" <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 --> <!-- Personal Workspace Option -->
<div @click="selectProject(null)" <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 }"> :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> <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>
<div class="h-px bg-white/5 my-1"></div> <div class="h-px bg-white/5 my-1"></div>
<!-- Project Options --> <!-- Project Options -->
<div v-for="project in projects" :key="project.id" @click="selectProject(project.id)" <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 }"> :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> <span class="truncate">{{ project.name }}</span>
<i v-if="selectedProject === project.id" class="pi pi-check ml-auto text-sm"></i> <i v-if="selectedProject === project.id" class="pi pi-check ml-auto text-[10px]"></i>
</div>
<div v-if="projects.length === 0" class="px-4 py-3 text-slate-500 text-sm font-italic">
No projects found
</div> </div>
</div> </div>
@@ -145,27 +155,33 @@ const navItems = computed(() => {
</div> </div>
<!-- Nav Items --> <!-- 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="[ <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) isActive(item.path)
? 'bg-white/10 text-slate-50 shadow-inner' ? 'bg-white/10 text-slate-50 shadow-inner'
: 'text-slate-400 hover:bg-white/5 hover:text-slate-50' : 'text-slate-400 hover:bg-white/5 hover:text-slate-50'
]" @click="router.push(item.path)" v-tooltip.bottom="item.tooltip"> ]" @click="router.push(item.path)" v-tooltip.bottom="item.tooltip">
<span class="text-xl">{{ item.icon }}</span> <span class="text-base">{{ item.icon }}</span>
<span class="text-sm font-medium hidden lg:block">{{ item.tooltip }}</span> <span class="text-[11px] font-medium hidden lg:block">{{ item.tooltip }}</span>
</div> </div>
</div> </div>
<!-- Right Actions --> <!-- 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" <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'"> v-tooltip.bottom="'Logout'">
<i class="pi pi-power-off"></i> <i class="pi pi-power-off text-xs"></i>
</div> </div>
<!-- Profile Avatar Placeholder --> <!-- 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"> title="Profile">
U U
</div> </div>
@@ -174,14 +190,14 @@ const navItems = computed(() => {
<!-- Mobile Bottom Nav --> <!-- Mobile Bottom Nav -->
<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="[ <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) 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' : 'text-slate-400 hover:text-slate-200'
]" @click="router.push(item.path)"> ]" @click="router.push(item.path)">
<span class="text-xl">{{ item.icon }}</span> <span class="text-lg">{{ item.icon }}</span>
</div> </div>
</nav> </nav>
</div> </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', name: 'flexible',
component: () => import('../views/FlexibleGenerationView.vue') component: () => import('../views/FlexibleGenerationView.vue')
}, },
{
path: '/content-plan',
name: 'content-plan',
component: () => import('../views/ContentPlanView.vue')
},
{ {
path: '/albums', path: '/albums',
name: 'albums', name: 'albums',

View File

@@ -52,9 +52,10 @@ export const aiService = {
}, },
// Get generations history // Get generations history
async getGenerations(limit, offset, characterId) { async getGenerations(limit, offset, characterId, onlyLiked = false) {
const params = { limit, offset } const params = { limit, offset }
if (characterId) params.character_id = characterId if (characterId) params.character_id = characterId
if (onlyLiked) params.only_liked = true
const response = await api.get('/generations', { params }) const response = await api.get('/generations', { params })
return response.data return response.data
}, },
@@ -66,5 +67,30 @@ export const aiService = {
linked_assets: linkedAssets linked_assets: linkedAssets
}) })
return response.data 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 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 () => { getCharacters: async () => {
// Spec says /api/characters/ (with trailing slash) but usually client shouldn't matter too much if config is good, // 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. // but let's follow spec if strictly needed. Axios usually handles this.
@@ -35,12 +54,14 @@ export const dataService = {
return response.data return response.data
}, },
getAssetsByCharacterId: async (charId, limit, offset) => { getAssetsByCharacterId: async (charId, limit, offset, type) => {
const response = await api.get(`/characters/${charId}/assets`, { params: { limit, offset } }) const params = { limit, offset }
if (type && type !== 'all') params.type = type
const response = await api.get(`/characters/${charId}/assets`, { params })
return response.data return response.data
}, },
uploadAsset: async (file, linkedCharId) => { uploadAsset: async (file, linkedCharId, onProgress) => {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
if (linkedCharId) formData.append('linked_char_id', linkedCharId) if (linkedCharId) formData.append('linked_char_id', linkedCharId)
@@ -48,6 +69,12 @@ export const dataService = {
const response = await api.post('/assets/upload', formData, { const response = await api.post('/assets/upload', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data' '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 return response.data
@@ -58,11 +85,38 @@ export const dataService = {
return response.data return response.data
}, },
toggleLike: async (id) => {
const response = await api.post(`/generations/${id}/like`)
return response.data
},
deleteGeneration: async (id) => { deleteGeneration: async (id) => {
const response = await api.delete(`/generations/${id}`) const response = await api.delete(`/generations/${id}`)
return response.data 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) => { generatePromptFromImage: async (files, prompt) => {
const formData = new FormData() const formData = new FormData()

View File

@@ -8,5 +8,9 @@ export const ideaService = {
deleteIdea: (id) => api.delete(`/ideas/${id}`), deleteIdea: (id) => api.delete(`/ideas/${id}`),
addGenerationToIdea: (ideaId, generationId) => api.post(`/ideas/${ideaId}/generations/${generationId}`), addGenerationToIdea: (ideaId, generationId) => api.post(`/ideas/${ideaId}/generations/${generationId}`),
removeGenerationFromIdea: (ideaId, generationId) => api.delete(`/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 { defineStore } from 'pinia';
import { ref } from 'vue'; import { ref } from 'vue';
import { ideaService } from '../services/ideaService'; import { ideaService } from '../services/ideaService';
import { inspirationService } from '../services/inspirationService';
export const useIdeaStore = defineStore('ideas', () => { export const useIdeaStore = defineStore('ideas', () => {
const ideas = ref([]); const ideas = ref([]);
const inspirations = ref([]);
const currentIdea = ref(null); const currentIdea = ref(null);
const currentInspiration = ref(null); // New state
const loading = ref(false); const loading = ref(false);
const error = ref(null); const error = ref(null);
const totalIdeas = ref(0); const totalIdeas = ref(0);
@@ -32,13 +35,13 @@ export const useIdeaStore = defineStore('ideas', () => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
await ideaService.createIdea(data); const response = await ideaService.createIdea(data);
await fetchIdeas(); // Refresh list await fetchIdeas(); // Refresh list
return true; return response.data;
} catch (err) { } catch (err) {
console.error('Error creating idea:', err); console.error('Error creating idea:', err);
error.value = err.response?.data?.detail || 'Failed to create idea'; error.value = err.response?.data?.detail || 'Failed to create idea';
return false; return null;
} finally { } finally {
loading.value = false; 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) { async function updateIdea(id, data) {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
@@ -133,9 +155,9 @@ export const useIdeaStore = defineStore('ideas', () => {
} }
// Assuming getIdeaGenerations is separate from getIdea // 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 { try {
const response = await ideaService.getIdeaGenerations(ideaId, limit, offset); const response = await ideaService.getIdeaGenerations(ideaId, limit, offset, onlyLiked);
return response; return response;
} catch (err) { } catch (err) {
console.error('Error fetching idea generations:', 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 { return {
ideas, ideas,
inspirations,
currentIdea, currentIdea,
currentInspiration,
loading, loading,
error, error,
totalIdeas, totalIdeas,
@@ -156,6 +254,12 @@ export const useIdeaStore = defineStore('ideas', () => {
deleteIdea, deleteIdea,
addGenerationToIdea, addGenerationToIdea,
removeGenerationFromIdea, 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 { useConfirm } from 'primevue/useconfirm'
import Dialog from 'primevue/dialog' import Dialog from 'primevue/dialog'
import GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
import { dataService } from '../services/dataService'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const albumStore = useAlbumStore() const albumStore = useAlbumStore()
@@ -20,6 +23,32 @@ const confirm = useConfirm()
const generations = ref([]) const generations = ref([])
const loadingGenerations = ref(false) 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 // Gen Picker State
const isGenerationPickerVisible = ref(false) const isGenerationPickerVisible = ref(false)
const availableGenerations = ref([]) const availableGenerations = ref([])
@@ -130,10 +159,9 @@ const removeGeneration = (gen) => {
} }
// --- Image Preview --- // --- Image Preview ---
const isImagePreviewVisible = ref(false) const openImagePreview = (gen) => {
const previewImage = ref(null) const idx = generations.value.findIndex(g => g.id === gen.id)
const openImagePreview = (url) => { previewIndex.value = idx >= 0 ? idx : 0
previewImage.value = { url }
isImagePreviewVisible.value = true isImagePreviewVisible.value = true
} }
</script> </script>
@@ -191,11 +219,17 @@ const openImagePreview = (url) => {
class="glass-panel rounded-xl overflow-hidden group relative transition-all hover:bg-white/5"> 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" <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" <img v-if="gen.result_list && gen.result_list.length > 0"
:src="gen.result || API_URL + `/assets/${gen.result_list[0]}` + '?thumbnail=true'" :src="gen.result || API_URL + `/assets/${gen.result_list[0]}` + '?thumbnail=true'"
class="w-full h-full object-cover" /> 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 --> <!-- Overlay Actions -->
<div <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"> 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> </Dialog>
<!-- Image Preview Modal --> <!-- Image Preview Modal -->
<Dialog v-model:visible="isImagePreviewVisible" modal dismissableMask <GenerationPreviewModal
:style="{ width: '90vw', maxWidth: '1000px', background: 'transparent', boxShadow: 'none' }" v-model:visible="isImagePreviewVisible"
:pt="{ root: { class: '!bg-transparent !border-none !shadow-none' }, header: { class: '!hidden' }, content: { class: '!bg-transparent !p-0' } }"> :preview-images="previewImages"
<div class="relative flex items-center justify-center" @click="isImagePreviewVisible = false"> :initial-index="previewIndex"
<img v-if="previewImage" :src="previewImage.url" :api-url="API_URL"
class="max-w-full max-h-[85vh] object-contain rounded-xl shadow-2xl" /> @liked="handleLiked"
<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>
</div> </div>
</template> </template>

View File

@@ -17,6 +17,8 @@ import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea' import Textarea from 'primevue/textarea'
import Image from 'primevue/image' import Image from 'primevue/image'
import GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
const router = useRouter() const router = useRouter()
const confirm = useConfirm() const confirm = useConfirm()
const toast = useToast() const toast = useToast()
@@ -26,6 +28,28 @@ const activeFilter = ref('all')
// @ts-ignore // @ts-ignore
const API_URL = import.meta.env.VITE_API_URL 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 // Albums Logic
const albumStore = useAlbumStore() const albumStore = useAlbumStore()
const { albums, loading: albumsLoading } = storeToRefs(albumStore) const { albums, loading: albumsLoading } = storeToRefs(albumStore)
@@ -34,7 +58,6 @@ const showCreateDialog = ref(false)
const newAlbum = ref({ name: '', description: '' }) const newAlbum = ref({ name: '', description: '' })
const submittingAlbum = ref(false) const submittingAlbum = ref(false)
const selectedAsset = ref<Asset | null>(null)
const isModalVisible = ref(false) const isModalVisible = ref(false)
const first = ref(0) const first = ref(0)
@@ -69,8 +92,9 @@ const handleFileUpload = async (event: Event) => {
} }
const openModal = (asset: Asset) => { const openModal = (asset: Asset) => {
selectedAsset.value = asset const idx = assets.value.findIndex(a => a.id === asset.id)
isModalVisible.value = true previewIndex.value = idx >= 0 ? idx : 0
isImagePreviewVisible.value = true
} }
const loadAssets = async () => { const loadAssets = async () => {
@@ -245,6 +269,12 @@ const formatDate = (dateString: string) => {
:alt="asset.name" :alt="asset.name"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" /> 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 --> <!-- Type Badge -->
<div v-if="asset.type !== 'image'" <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"> 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>
</div> </div>
<Dialog v-model:visible="isModalVisible" modal dismissableMask header="Asset View" <GenerationPreviewModal
:style="{ width: '90vw', maxWidth: '800px' }" class="glass-panel rounded-2xl"> v-model:visible="isImagePreviewVisible"
<div v-if="selectedAsset" class="flex flex-col items-center"> :preview-images="previewImages"
<img :src="selectedAsset.link ? API_URL + selectedAsset.link : (selectedAsset.url ? API_URL + selectedAsset.url : 'https://via.placeholder.com/800')" :initial-index="previewIndex"
:alt="selectedAsset.name" class="max-w-full max-h-[70vh] rounded-xl object-contain shadow-2xl" /> :api-url="API_URL"
<div class="mt-6 text-center"> @liked="handleLiked"
<h2 class="text-2xl font-bold mb-2">{{ selectedAsset.name }}</h2> />
<p class="text-slate-400">{{ formatDate(selectedAsset.created_at) }}</p>
</div>
</div>
</Dialog>
<!-- Create Album Dialog --> <!-- Create Album Dialog -->
<Dialog v-model:visible="showCreateDialog" modal header="Create New Album" :style="{ width: '500px' }" <Dialog v-model:visible="showCreateDialog" modal header="Create New Album" :style="{ width: '500px' }"

View File

@@ -1,15 +1,12 @@
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import {computed, nextTick, onMounted, ref, watch} from 'vue'
import { useRoute, useRouter } from 'vue-router' import {useRoute, useRouter} from 'vue-router'
import { dataService } from '../services/dataService' import {dataService} from '../services/dataService'
import { aiService } from '../services/aiService' import {aiService} from '../services/aiService'
import Button from 'primevue/button' import Button from 'primevue/button'
import Skeleton from 'primevue/skeleton' import Skeleton from 'primevue/skeleton'
import Tag from 'primevue/tag' import Tag from 'primevue/tag'
import Dialog from 'primevue/dialog' 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 Checkbox from 'primevue/checkbox'
import ProgressBar from 'primevue/progressbar' import ProgressBar from 'primevue/progressbar'
import Message from 'primevue/message' import Message from 'primevue/message'
@@ -22,10 +19,13 @@ import TabPanels from 'primevue/tabpanels'
import TabPanel from 'primevue/tabpanel' import TabPanel from 'primevue/tabpanel'
import Paginator from 'primevue/paginator' import Paginator from 'primevue/paginator'
import GenerationPreviewModal from '../components/GenerationPreviewModal.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const character = ref(null) const character = ref(null)
const characterAssets = ref([]) const characterAssets = ref([])
const environments = ref([])
const assetsTotalRecords = ref(0) const assetsTotalRecords = ref(0)
const historyGenerations = ref([]) const historyGenerations = ref([])
const historyTotal = ref(0) const historyTotal = ref(0)
@@ -34,6 +34,287 @@ const historyFirst = ref(0)
const loading = ref(true) const loading = ref(true)
const API_URL = import.meta.env.VITE_API_URL 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 selectedAsset = ref(null)
const isModalVisible = ref(false) const isModalVisible = ref(false)
const activeTab = ref("0") const activeTab = ref("0")
@@ -45,8 +326,14 @@ const openModal = (asset) => {
toggleBulkSelection(asset.id) toggleBulkSelection(asset.id)
return return
} }
selectedAsset.value = asset const idx = characterAssets.value.findIndex(a => a.id === asset.id)
isModalVisible.value = true 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) => { const toggleBulkSelection = (id) => {
@@ -132,10 +419,11 @@ const loadData = async () => {
loading.value = true loading.value = true
const charId = route.params.id const charId = route.params.id
try { try {
const [char, assetsResponse, historyResponse] = await Promise.all([ const [char, assetsResponse, historyResponse, envsResponse] = await Promise.all([
dataService.getCharacterById(charId), dataService.getCharacterById(charId),
dataService.getAssetsByCharacterId(charId, assetsRows.value, assetsFirst.value), 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 character.value = char
@@ -147,6 +435,8 @@ const loadData = async () => {
assetsTotalRecords.value = characterAssets.value.length assetsTotalRecords.value = characterAssets.value.length
} }
environments.value = Array.isArray(envsResponse) ? envsResponse : (envsResponse.environments || [])
if (historyResponse && historyResponse.generations) { if (historyResponse && historyResponse.generations) {
historyGenerations.value = historyResponse.generations historyGenerations.value = historyResponse.generations
historyTotal.value = historyResponse.total_count || 0 historyTotal.value = historyResponse.total_count || 0
@@ -226,17 +516,7 @@ const prompt = ref('')
const isGenerating = ref(false) const isGenerating = ref(false)
const generationStatus = ref('') const generationStatus = ref('')
const generationProgress = ref(0) const generationProgress = ref(0)
const sendToTelegram = ref(false)
const useProfileImage = ref(true) 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 generationSuccess = ref(false)
const generationError = ref(null) const generationError = ref(null)
const generatedResult = ref(null) const generatedResult = ref(null)
@@ -249,6 +529,12 @@ const previousPrompt = ref('')
const isUploading = ref(false) const isUploading = ref(false)
const fileInput = ref(null) 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 selectedAssets = ref([])
const toggleAssetSelection = (asset) => { const toggleAssetSelection = (asset) => {
const index = selectedAssets.value.findIndex(a => a.id === asset.id) const index = selectedAssets.value.findIndex(a => a.id === asset.id)
@@ -268,9 +554,6 @@ const quality = ref({
value: '2K' value: '2K'
}) })
const qualityOptions = ref([{ const qualityOptions = ref([{
key: 'ONEK',
value: '1K'
}, {
key: 'TWOK', key: 'TWOK',
value: '2K' value: '2K'
}, { }, {
@@ -279,10 +562,16 @@ const qualityOptions = ref([{
}]) }])
const aspectRatio = ref({ key: "NINESIXTEEN", value: "9:16" }) const aspectRatio = ref({ key: "NINESIXTEEN", value: "9:16" })
const aspectRatioOptions = ref([ const aspectRatioOptions = ref([
{ key: "NINESIXTEEN", value: "9:16" }, { key: "ONEONE", value: "1:1" },
{ key: "FOURTHREE", value: "4:3" }, { key: "TWOTHREE", value: "2:3" },
{ key: "THREETWO", value: "3:2" },
{ key: "THREEFOUR", value: "3:4" }, { 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) const assetsFirst = ref(0)
@@ -433,6 +722,10 @@ const restoreGeneration = async (gen) => {
// 1. Set prompt // 1. Set prompt
prompt.value = gen.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 // 2. Set Quality
const foundQuality = qualityOptions.value.find(opt => opt.key === gen.quality) const foundQuality = qualityOptions.value.find(opt => opt.key === gen.quality)
if (foundQuality) quality.value = foundQuality 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 --- // --- Reuse Logic ---
const reusePrompt = (gen) => { 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 = () => { const triggerFileUpload = () => {
if (fileInput.value) fileInput.value.click() if (fileInput.value) fileInput.value.click()
} }
@@ -570,24 +895,14 @@ const handleGenerate = async () => {
generatedResult.value = null generatedResult.value = null
try { 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 = { const payload = {
model: model.value.key,
linked_character_id: character.value?.id, linked_character_id: character.value?.id,
environment_id: selectedEnvironment.value?.id || selectedEnvironment.value?._id || null,
aspect_ratio: aspectRatio.value.key, aspect_ratio: aspectRatio.value.key,
quality: quality.value.key, quality: quality.value.key,
prompt: prompt.value, prompt: prompt.value,
assets_list: selectedAssets.value.map(a => a.id), assets_list: selectedAssets.value.map(a => a.id),
telegram_id: sendToTelegram.value ? telegramId.value : null,
use_profile_image: useProfileImage.value, use_profile_image: useProfileImage.value,
count: generationCount.value count: generationCount.value
} }
@@ -695,6 +1010,12 @@ const handleGenerate = async () => {
<span>Assets ({{ assetsTotalRecords }})</span> <span>Assets ({{ assetsTotalRecords }})</span>
</div> </div>
</Tab> </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"> <Tab value="2" class="hidden">
<div class="!flex !flex-row !gap-1"> <div class="!flex !flex-row !gap-1">
<i class="pi pi-history text-[10px]" /> <i class="pi pi-history text-[10px]" />
@@ -712,6 +1033,21 @@ const handleGenerate = async () => {
<h2 class="text-sm font-bold m-0">Settings</h2> <h2 class="text-sm font-bold m-0">Settings</h2>
</div> </div>
<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 class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<label <label
class="text-slate-400 text-[9px] font-semibold uppercase tracking-wider">Quality</label> class="text-slate-400 text-[9px] font-semibold uppercase tracking-wider">Quality</label>
@@ -719,12 +1055,11 @@ const handleGenerate = async () => {
class="!flex !w-full !justify-between items-center justify-center !bg-slate-900/50 !p-1 gap-1 !rounded-lg !border !border-white/10"> 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" <div v-for="option in qualityOptions" :key="option.key"
@click="quality = option" @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="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/9 text-white rounded-lg' : ''"> :class="quality.key === option.key ? 'bg-white/10 text-white rounded-lg' : ''">
<span class="text-white w-full text-center">{{ option.value }}</span> <span class="text-white w-full text-center text-[8px]">{{ option.value }}</span>
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
@@ -734,12 +1069,12 @@ const handleGenerate = async () => {
class="!flex !w-full !justify-between items-center justify-center !bg-slate-900/50 !p-1 gap-1 !rounded-lg !border !border-white/10"> 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" <div v-for="option in aspectRatioOptions" :key="option.key"
@click="aspectRatio = option" @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="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/9 text-white rounded-lg' : ''"> :class="aspectRatio.key === option.key ? 'bg-white/10 text-white rounded-lg' : ''">
<span class="text-white w-full text-center">{{ option.value }}</span> <span class="text-white w-full text-center text-[8px]">{{ option.value }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
@@ -755,28 +1090,6 @@ const handleGenerate = async () => {
</div> </div>
</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 --> <!-- Assets Selection -->
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
@@ -805,22 +1118,50 @@ const handleGenerate = async () => {
</div> </div>
</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 --> <!-- 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-1.5 mt-auto pt-1.5 border-t border-white/5">
<div class="flex flex-col gap-2 mb-2"> <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"> <div class="flex items-center gap-2 mt-1">
<Checkbox v-model="useProfileImage" :binary="true" <Checkbox v-model="useProfileImage" :binary="true"
inputId="profile-img-check" /> inputId="profile-img-check" />
@@ -987,7 +1328,7 @@ const handleGenerate = async () => {
:class="gen.children.length > 2 ? 'grid-cols-4' : 'grid-cols-2'"> :class="gen.children.length > 2 ? 'grid-cols-4' : 'grid-cols-2'">
<div v-for="child in gen.children" :key="child.id" <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" 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" <img v-if="child.result_list && child.result_list.length > 0"
:src="API_URL + '/assets/' + child.result_list[0] + '?thumbnail=true'" :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 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" <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" @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" <img v-if="gen.result_list && gen.result_list.length > 0"
:src="API_URL + '/assets/' + gen.result_list[0] + '?thumbnail=true'" :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 <div v-else
class="w-full h-full flex items-center justify-center text-slate-700 overflow-hidden rounded"> class="w-full h-full flex items-center justify-center text-slate-700 overflow-hidden rounded">
<i class="pi pi-image text-lg" /> <i class="pi pi-image text-lg" />
@@ -1047,6 +1389,7 @@ const handleGenerate = async () => {
<span class="capitalize" <span class="capitalize"
:class="gen.status === 'done' ? 'text-green-500' : (gen.status === 'failed' ? 'text-red-500' : 'text-amber-500')">{{ :class="gen.status === 'done' ? 'text-green-500' : (gen.status === 'failed' ? 'text-red-500' : 'text-amber-500')">{{
gen.status }}</span> 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" <i v-if="gen.failed_reason"
v-tooltip.right="gen.failed_reason" v-tooltip.right="gen.failed_reason"
class="pi pi-exclamation-circle text-red-500" class="pi pi-exclamation-circle text-red-500"
@@ -1083,6 +1426,11 @@ const handleGenerate = async () => {
:disabled="gen.status !== 'done' || gen.result_list.length == 0" :disabled="gen.status !== 'done' || gen.result_list.length == 0"
@click.stop="useResultAsReference(gen)" @click.stop="useResultAsReference(gen)"
v-tooltip.bottom="'Use result as reference'" /> 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> </div>
</div> </div>
@@ -1206,6 +1554,57 @@ const handleGenerate = async () => {
</div> </div>
</TabPanel> </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> </TabPanels>
</Tabs> </Tabs>
</div> </div>
@@ -1228,18 +1627,16 @@ const handleGenerate = async () => {
Character not found. Character not found.
</div> </div>
<Dialog v-model:visible="isModalVisible" modal dismissableMask header="Asset View" <GenerationPreviewModal
:style="{ width: '90vw', maxWidth: '800px' }" class="glass-panel rounded-2xl"> v-model:visible="isImagePreviewVisible"
<div v-if="selectedAsset" class="flex flex-col items-center"> :preview-images="previewImages"
<img :src="selectedAsset.link ? API_URL + selectedAsset.link : (selectedAsset.url ? API_URL + selectedAsset.url : 'https://via.placeholder.com/800')" :initial-index="previewIndex"
:alt="selectedAsset.name" :api-url="API_URL"
class="max-w-full max-h-[70vh] rounded-xl object-contain shadow-2xl" /> @reuse-prompt="reusePrompt"
<div class="mt-6 text-center"> @reuse-asset="reuseAsset"
<h2 class="text-2xl font-bold mb-2">{{ selectedAsset.name }}</h2> @use-result-as-asset="useResultAsReference"
<p class="text-slate-400">{{ selectedAsset.type }}</p> @liked="handleLiked"
</div> />
</div>
</Dialog>
<!-- Asset Selection Modal (Global) --> <!-- Asset Selection Modal (Global) -->
<Dialog v-model:visible="isAssetSelectionVisible" modal header="Select Reference Assets" <Dialog v-model:visible="isAssetSelectionVisible" modal header="Select Reference Assets"
:style="{ width: '80vw', maxWidth: '1000px' }" class="glass-panel rounded-2xl"> :style="{ width: '80vw', maxWidth: '1000px' }" class="glass-panel rounded-2xl">
@@ -1275,6 +1672,120 @@ const handleGenerate = async () => {
</div> </div>
</div> </div>
</Dialog> </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>
</div> </div>
</template> </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> <script setup>
import { ref, onMounted, computed, watch } from 'vue' import {onMounted, ref} from 'vue'
import { useRouter } from 'vue-router' import {useRouter} from 'vue-router'
import { dataService } from '../services/dataService' import {dataService} from '../services/dataService'
import { aiService } from '../services/aiService' import {aiService} from '../services/aiService'
import Button from 'primevue/button' import Button from 'primevue/button'
import Textarea from 'primevue/textarea' import Textarea from 'primevue/textarea'
import ProgressSpinner from 'primevue/progressspinner' import ProgressSpinner from 'primevue/progressspinner'
import ProgressBar from 'primevue/progressbar' import ProgressBar from 'primevue/progressbar'
import Message from 'primevue/message'
import Tag from 'primevue/tag'
import Checkbox from 'primevue/checkbox' import Checkbox from 'primevue/checkbox'
import Dialog from 'primevue/dialog' import Dialog from 'primevue/dialog'
import Paginator from 'primevue/paginator' import Paginator from 'primevue/paginator'
import InputText from 'primevue/inputtext' 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 router = useRouter()
const API_URL = import.meta.env.VITE_API_URL const API_URL = import.meta.env.VITE_API_URL
@@ -44,32 +45,33 @@ const assetsTotalRecords = ref(0)
const assetsRows = ref(12) const assetsRows = ref(12)
const assetsFirst = ref(0) const assetsFirst = ref(0)
const activeAssetFilter = ref('all') 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 isUploading = ref(false)
const fileInput = ref(null) const fileInput = ref(null)
const saveTelegramId = () => { const model = ref({ key: 'gemini-3-pro-image-preview', value: 'Pro' })
if (telegramId.value) { const modelOptions = ref([
localStorage.setItem('telegram_id', telegramId.value) { key: 'gemini-3.1-flash-image-preview', value: '2' },
isTelegramIdSaved.value = true { key: 'gemini-3-pro-image-preview', value: 'Pro' }
} ])
}
const quality = ref({ key: 'TWOK', value: '2K' }) const quality = ref({ key: 'TWOK', value: '2K' })
const qualityOptions = ref([ const qualityOptions = ref([
{ key: 'ONEK', value: '1K' },
{ key: 'TWOK', value: '2K' }, { key: 'TWOK', value: '2K' },
{ key: 'FOURK', value: '4K' } { key: 'FOURK', value: '4K' }
]) ])
const aspectRatio = ref({ key: "NINESIXTEEN", value: "9:16" }) const aspectRatio = ref({ key: "NINESIXTEEN", value: "9:16" })
const aspectRatioOptions = ref([ const aspectRatioOptions = ref([
{ key: "NINESIXTEEN", value: "9:16" }, { key: "ONEONE", value: "1:1" },
{ key: "FOURTHREE", value: "4:3" }, { key: "TWOTHREE", value: "2:3" },
{ key: "THREETWO", value: "3:2" },
{ key: "THREEFOUR", value: "3:4" }, { 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 --- // --- Data Loading ---
@@ -180,18 +182,6 @@ const onFileSelected = async (event) => {
const handleGenerate = async () => { const handleGenerate = async () => {
if (!prompt.value.trim()) return 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 isGenerating.value = true
generationSuccess.value = false generationSuccess.value = false
generationError.value = null generationError.value = null
@@ -201,12 +191,12 @@ const handleGenerate = async () => {
try { try {
const payload = { const payload = {
model: model.value.key,
aspect_ratio: aspectRatio.value.key, aspect_ratio: aspectRatio.value.key,
quality: quality.value.key, quality: quality.value.key,
prompt: prompt.value, prompt: prompt.value,
assets_list: selectedAssets.value.map(a => a.id), assets_list: selectedAssets.value.map(a => a.id),
linked_character_id: null, // Explicitly null for global generation linked_character_id: null, // Explicitly null for global generation
telegram_id: sendToTelegram.value ? telegramId.value : null
} }
const response = await aiService.runGeneration(payload) const response = await aiService.runGeneration(payload)
@@ -288,6 +278,9 @@ const pollStatus = async (id) => {
const restoreGeneration = async (gen) => { const restoreGeneration = async (gen) => {
prompt.value = gen.prompt 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) const foundQuality = qualityOptions.value.find(opt => opt.key === gen.quality)
if (foundQuality) quality.value = foundQuality if (foundQuality) quality.value = foundQuality
@@ -295,23 +288,16 @@ const restoreGeneration = async (gen) => {
if (foundAspect) aspectRatio.value = foundAspect if (foundAspect) aspectRatio.value = foundAspect
if (gen.status === 'done' && gen.result_list && gen.result_list.length > 0) { if (gen.status === 'done' && gen.result_list && gen.result_list.length > 0) {
// We need to fetch details or just display the image // Keep original gen object for preview and details
// history list usually has the main image preview
generatedResult.value = { generatedResult.value = {
...gen,
type: 'assets', type: 'assets',
// Mocking asset object structure from history usage in DetailView
assets: gen.result_list.map(id => ({ assets: gen.result_list.map(id => ({
id, id,
url: `/assets/${id}`, // This might need adjustment based on how API serves files url: `/assets/${id}`,
// 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
})), })),
tech_prompt: gen.tech_prompt,
execution_time: gen.execution_time_seconds, execution_time: gen.execution_time_seconds,
api_execution_time: gen.api_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 isImagePreviewVisible = ref(false)
const previewImage = ref(null) const previewImage = ref(null)
const openImagePreview = (url, name = 'Image Preview', createdAt = null) => { const openImagePreview = (url, name = 'Image Preview', createdAt = null, gen = null, assetId = null, isLiked = false) => {
previewImage.value = { url, name, createdAt } previewImage.value = { url, name, createdAt, gen, assetId, is_liked: isLiked }
isImagePreviewVisible.value = true isImagePreviewVisible.value = true
} }
const formatDate = (dateString) => { const handleLiked = ({ id, is_liked }) => {
if (!dateString) return '' // Update local state in history
return new Date(dateString).toLocaleString() 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 = () => { const undoImprovePrompt = () => {
@@ -361,6 +365,15 @@ const clearPrompt = () => {
prompt.value = '' 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 --- // --- Reuse Logic ---
const reusePrompt = (gen) => { const reusePrompt = (gen) => {
@@ -412,10 +425,6 @@ const useResultAsReference = (gen) => {
// --- Utils --- // --- Utils ---
const copyToClipboard = () => {
// Implement if needed for prompt copying
}
// --- Lifecycle --- // --- Lifecycle ---
@@ -439,13 +448,23 @@ onMounted(() => {
<!-- Settings Card --> <!-- Settings Card -->
<div class="glass-panel p-6 rounded-2xl border border-white/5 bg-white/5 flex flex-col gap-6"> <div class="glass-panel p-6 rounded-2xl border border-white/5 bg-white/5 flex flex-col gap-6">
<!-- Quality & Aspect Ratio --> <!-- Settings Row: Model, Quality & Aspect Ratio -->
<div class="grid grid-cols-2 gap-6"> <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"> <div class="flex flex-col gap-2">
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider">Quality</label> <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" <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'"> :class="quality.key === option.key ? 'bg-white/10 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'">
{{ option.value }} {{ option.value }}
</div> </div>
@@ -454,14 +473,14 @@ onMounted(() => {
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider">Aspect <label class="text-xs font-bold text-slate-400 uppercase tracking-wider">Aspect
Ratio</label> Ratio</label>
<div class="flex bg-slate-900/50 p-1 rounded-lg border border-white/10"> <Dropdown v-model="aspectRatio" :options="aspectRatioOptions" optionLabel="value"
<div v-for="option in aspectRatioOptions" :key="option.key" class="w-full !bg-slate-900/50 !border-white/10 !text-white !rounded-lg !h-[34px]"
@click="aspectRatio = option" :pt="{
class="flex-1 text-center py-1.5 cursor-pointer rounded-md text-xs font-medium transition-all" input: { class: '!text-white !text-[10px] !py-1 !px-2 !font-bold' },
:class="aspectRatio.key === option.key ? 'bg-white/10 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'"> trigger: { class: '!text-slate-400 !w-6' },
{{ option.value }} panel: { class: '!bg-slate-900 !border-white/10' },
</div> item: { class: '!text-slate-300 hover:!bg-white/10 hover:!text-white !text-[10px] !py-1' }
</div> }" />
</div> </div>
</div> </div>
@@ -478,6 +497,9 @@ onMounted(() => {
:disabled="prompt.length <= 10" size="small" :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" 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" /> @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" <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" class="!py-0.5 !px-2 !text-[10px] bg-slate-800 hover:bg-slate-700 border-white/10 text-slate-400"
@click="clearPrompt" /> @click="clearPrompt" />
@@ -526,21 +548,6 @@ onMounted(() => {
<!-- Generate Button --> <!-- Generate Button -->
<div class="mt-auto"> <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'" <Button :label="isGenerating ? 'Generating...' : 'Generate Image'"
:icon="isGenerating ? 'pi pi-spin pi-spinner' : 'pi pi-magic'" :loading="isGenerating" :icon="isGenerating ? 'pi pi-spin pi-spinner' : 'pi pi-magic'" :loading="isGenerating"
@click="handleGenerate" @click="handleGenerate"
@@ -594,7 +601,7 @@ onMounted(() => {
v-if="generatedResult.type === 'assets' && generatedResult.assets && generatedResult.assets.length > 0"> v-if="generatedResult.type === 'assets' && generatedResult.assets && generatedResult.assets.length > 0">
<!-- Displaying the first asset as main preview --> <!-- Displaying the first asset as main preview -->
<img :src="API_URL + '/assets/' + generatedResult.assets[0].id" <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" /> class="w-full h-full object-contain cursor-pointer hover:scale-[1.01] transition-transform duration-300" />
</template> </template>
<template v-else> <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 class="flex-1 overflow-y-auto pr-2 custom-scrollbar flex flex-col gap-2">
<div v-for="gen in historyGenerations" :key="gen.id" <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="flex gap-3 items-start cursor-pointer" @click="restoreGeneration(gen)">
<div <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" <img v-if="gen.result_list && gen.result_list.length > 0"
:src="API_URL + '/assets/' + gen.result_list[0] + '?thumbnail=true'" :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>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-xs text-slate-300 truncate font-medium">{{ gen.prompt }}</p> <p class="text-xs text-slate-300 truncate font-medium">{{ gen.prompt }}</p>
@@ -747,18 +761,16 @@ onMounted(() => {
</Dialog> </Dialog>
<!-- Image Preview Modal --> <!-- Image Preview Modal -->
<Dialog v-model:visible="isImagePreviewVisible" modal dismissableMask <GenerationPreviewModal
:header="previewImage?.name || 'Image Preview'" :style="{ width: '90vw', maxWidth: '800px' }" v-model:visible="isImagePreviewVisible"
class="glass-panel rounded-2xl"> :preview-images="[previewImage].filter(i => !!i)"
<div v-if="previewImage" class="flex flex-col items-center"> :initial-index="0"
<img :src="previewImage.url" :alt="previewImage.name" :api-url="API_URL"
class="max-w-full max-h-[70vh] rounded-xl object-contain shadow-2xl" /> @reuse-prompt="reusePrompt"
<div class="mt-6 text-center"> @reuse-asset="reuseAsset"
<h2 class="text-2xl font-bold mb-2">{{ previewImage.name }}</h2> @use-result-as-asset="useResultAsReference"
<p v-if="previewImage.createdAt" class="text-slate-400">{{ formatDate(previewImage.createdAt) }}</p> @liked="handleLiked"
</div> />
</div>
</Dialog>
</template> </template>

View File

@@ -55,6 +55,15 @@ const copyToClipboard = () => {
navigator.clipboard.writeText(generatedPrompt.value) 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> </script>
@@ -124,11 +133,14 @@ const copyToClipboard = () => {
<!-- Optional Prompt --> <!-- Optional Prompt -->
<div class="glass-panel p-1 rounded-2xl border border-white/5"> <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"> <label class="text-sm font-bold text-slate-300 flex items-center gap-2">
<i class="pi pi-align-left"></i> Additional Instructions <span <i class="pi pi-align-left"></i> Additional Instructions <span
class="text-slate-500 font-normal">(Optional)</span> class="text-slate-500 font-normal">(Optional)</span>
</label> </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> </div>
<textarea v-model="userPrompt" <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" 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,5 +1,6 @@
<template> <template>
<div class="container mx-auto p-4 animate-fade-in" v-if="project"> <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 --> <!-- Header -->
<div class="glass-panel p-8 rounded-xl mb-8 relative overflow-hidden"> <div class="glass-panel p-8 rounded-xl mb-8 relative overflow-hidden">
<div class="absolute top-0 right-0 p-4 opacity-10"> <div class="absolute top-0 right-0 p-4 opacity-10">
@@ -44,25 +45,24 @@
<div v-if="isOwner" class="mb-6"> <div v-if="isOwner" class="mb-6">
<div class="flex gap-2"> <div class="flex gap-2">
<InputText v-model="inviteUsername" placeholder="Username to add" <InputText v-model="inviteUsername" placeholder="Username to add"
class="w-full p-inputtext-sm" @keyup.enter="addMember" /> class="w-full" @keyup.enter="addMember" />
<Button label="Add" icon="pi pi-user-plus" size="small" @click="addMember" <Button label="Add" icon="pi pi-user-plus" size="small" @click="addMember"
:loading="inviting" :disabled="!inviteUsername.trim()" /> :loading="inviting" :disabled="!inviteUsername.trim()" />
</div> </div>
</div> </div>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div v-for="memberId in project.members" :key="memberId" <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"> class="flex items-center p-3 rounded-lg bg-slate-800/30 border border-slate-700/30">
<div <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"> 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> <i class="pi pi-user"></i>
</div> </div>
<div class="overflow-hidden"> <div class="overflow-hidden">
<p class="text-white font-medium truncate">{{ memberId === project.owner_id ? 'Owner' : <p class="text-white font-bold truncate">{{ member.username }}</p>
'Member' }}</p> <p class="text-slate-500 text-[10px] truncate font-mono">ID: {{ member.id }}</p>
<p class="text-slate-500 text-xs truncate">ID: {{ memberId }}</p>
</div> </div>
<div class="ml-auto" v-if="project.owner_id === memberId"> <div class="ml-auto" v-if="project.owner_id === member.id">
<i class="pi pi-crown text-yellow-500" title="Owner"></i> <i class="pi pi-crown text-yellow-500" title="Owner"></i>
</div> </div>
</div> </div>
@@ -70,13 +70,67 @@
</div> </div>
</div> </div>
<!-- Stats/Activity Column (Placeholder) --> <!-- Stats/Activity Column -->
<div class="lg:col-span-2"> <div class="lg:col-span-2">
<div class="glass-panel p-6 rounded-xl h-full"> <div class="glass-panel p-6 rounded-xl h-full flex flex-col">
<h2 class="text-xl font-bold text-white mb-6">Activity</h2> <div class="flex justify-between items-center mb-6">
<div class="text-center py-12 text-slate-500"> <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> <i class="pi pi-chart-line text-4xl mb-4 opacity-50"></i>
<p>No recent activity registered.</p> <p>No usage data available for this project.</p>
</div> </div>
</div> </div>
</div> </div>
@@ -89,6 +143,7 @@
<p class="text-slate-400">Loading project...</p> <p class="text-slate-400">Loading project...</p>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
@@ -97,6 +152,7 @@ import { useRoute, useRouter } from 'vue-router';
import { useProjectsStore } from '@/stores/projectsStore'; import { useProjectsStore } from '@/stores/projectsStore';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { aiService } from '@/services/aiService';
import Button from 'primevue/button'; import Button from 'primevue/button';
import InputText from 'primevue/inputtext'; import InputText from 'primevue/inputtext';
import ConfirmDialog from 'primevue/confirmdialog'; import ConfirmDialog from 'primevue/confirmdialog';
@@ -113,14 +169,36 @@ const project = computed(() => projectsStore.getProjectById(projectId));
const isCurrentProject = computed(() => currentProject.value?.id === projectId); const isCurrentProject = computed(() => currentProject.value?.id === projectId);
const isOwner = computed(() => authStore.user && project.value && authStore.user.id === project.value.owner_id); 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 inviteUsername = ref('');
const inviting = ref(false); 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 () => { onMounted(async () => {
// Ensure projects are loaded // Ensure projects are loaded
if (projects.value.length === 0) { if (projects.value.length === 0) {
await projectsStore.fetchProjects(); await projectsStore.fetchProjects();
} }
fetchUsage();
}); });
const selectProject = () => { const selectProject = () => {
@@ -164,3 +242,22 @@ const confirmDelete = () => {
}); });
} }
</script> </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,5 +1,6 @@
<template> <template>
<div class="container mx-auto p-4 animate-fade-in"> <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 class="flex justify-between items-center mb-6">
<div> <div>
<h1 class="text-3xl font-bold text-white mb-2">Projects</h1> <h1 class="text-3xl font-bold text-white mb-2">Projects</h1>
@@ -39,10 +40,16 @@
</p> </p>
<div class="flex items-center justify-between mt-4 border-t border-slate-700/50 pt-4"> <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"> <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> <i class="pi pi-users mr-2"></i>
<span>{{ project.members.length }} members</span> <span>{{ project.members.length }} members</span>
</div> </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" <Button v-if="currentProject?.id !== project.id" icon="pi pi-check" label="Select" size="small"
severity="secondary" @click.stop="selectProject(project.id)" /> severity="secondary" @click.stop="selectProject(project.id)" />
@@ -69,12 +76,14 @@
</template> </template>
</Dialog> </Dialog>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useProjectsStore } from '@/stores/projectsStore'; import { useProjectsStore } from '@/stores/projectsStore';
import { aiService } from '@/services/aiService';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import Button from 'primevue/button'; import Button from 'primevue/button';
import Dialog from 'primevue/dialog'; import Dialog from 'primevue/dialog';
@@ -94,8 +103,29 @@ const newProject = ref({
description: '' 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(() => { onMounted(() => {
projectsStore.fetchProjects(); projectsStore.fetchProjects();
fetchUsage();
}); });
const createProject = async () => { const createProject = async () => {
@@ -121,3 +151,22 @@ const goToProject = (id) => {
router.push(`/projects/${id}`); router.push(`/projects/${id}`);
}; };
</script> </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>