This commit is contained in:
xds
2026-02-18 16:34:11 +03:00
parent f8adcf33d3
commit 0cc5150f9c
12 changed files with 489 additions and 118 deletions

1
package-lock.json generated
View File

@@ -23,6 +23,7 @@
"@tailwindcss/postcss": "^4.1.18",
"@vitejs/plugin-vue": "^6.0.3",
"autoprefixer": "^10.4.24",
"baseline-browser-mapping": "^2.9.19",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"vite": "^7.3.1",

View File

@@ -24,6 +24,7 @@
"@tailwindcss/postcss": "^4.1.18",
"@vitejs/plugin-vue": "^6.0.3",
"autoprefixer": "^10.4.24",
"baseline-browser-mapping": "^2.9.19",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"vite": "^7.3.1",

View File

@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useProjectsStore } from '@/stores/projectsStore'
import { aiService } from '@/services/aiService'
import { storeToRefs } from 'pinia'
@@ -13,6 +14,17 @@ const projectsStore = useProjectsStore()
const { projects, currentProject } = storeToRefs(projectsStore)
const selectedProject = ref(null)
const usageCost = ref(0)
const fetchUsage = async () => {
try {
// Fetch current context usage (user or project depending on header)
const report = await aiService.getUsageReport()
usageCost.value = report.summary?.total_cost || 0
} catch (e) {
console.error("Failed to fetch sidebar usage", e)
}
}
onMounted(async () => {
// Ensure we have projects
@@ -23,6 +35,7 @@ onMounted(async () => {
if (currentProject.value) {
selectedProject.value = currentProject.value.id
}
fetchUsage()
})
// Watch for external changes (like selecting from the list view)
@@ -160,6 +173,12 @@ const navItems = computed(() => {
<!-- Right Actions -->
<div class="flex items-center gap-4 shrink-0">
<!-- Usage Stat -->
<div v-if="usageCost > 0" class="hidden xl:flex flex-col items-end mr-2">
<span class="text-[10px] font-bold text-slate-500 uppercase tracking-tighter">Usage</span>
<span class="text-sm font-bold text-violet-400">${{ usageCost.toFixed(2) }}</span>
</div>
<div @click="handleLogout"
class="w-10 h-10 rounded-xl bg-red-500/10 text-red-400 flex items-center justify-center cursor-pointer hover:bg-red-500/20 transition-all font-bold"
v-tooltip.bottom="'Logout'">

View File

@@ -66,5 +66,22 @@ export const aiService = {
linked_assets: linkedAssets
})
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

@@ -1,15 +1,13 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { dataService } from '../services/dataService'
import { aiService } from '../services/aiService'
import {computed, onMounted, ref} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {dataService} from '../services/dataService'
import {aiService} from '../services/aiService'
import Button from 'primevue/button'
import Skeleton from 'primevue/skeleton'
import Tag from 'primevue/tag'
import Dialog from 'primevue/dialog'
import Textarea from 'primevue/textarea'
import SelectButton from 'primevue/selectbutton'
import FileUpload from 'primevue/fileupload'
import Checkbox from 'primevue/checkbox'
import ProgressBar from 'primevue/progressbar'
import Message from 'primevue/message'
@@ -491,6 +489,15 @@ const undoImprovePrompt = () => {
}
}
const pastePrompt = async () => {
try {
const text = await navigator.clipboard.readText()
if (text) prompt.value = text
} catch (err) {
console.error('Failed to read clipboard', err)
}
}
// --- Reuse Logic ---
const reusePrompt = (gen) => {
@@ -761,13 +768,17 @@ const handleGenerate = async () => {
<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" />
class="w-full bg-slate-900 border-white/10 text-white rounded-lg p-2 focus:border-violet-500 transition-all text-xs pr-16" />
<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-clipboard"
class="!p-1 !w-6 !h-6 !text-[10px] bg-slate-800 hover:bg-slate-700 border-white/10 text-slate-400"
@click="pastePrompt" v-tooltip.top="'Paste'" />
<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"

View File

@@ -166,8 +166,13 @@ const historyTotal = ref(0)
const historyRows = ref(50)
const historyFirst = ref(0)
const isSettingsVisible = ref(false)
const isSettingsVisible = ref(localStorage.getItem('flexible_gen_settings_visible') !== 'false')
const isSubmitting = ref(false)
watch(isSettingsVisible, (val) => {
localStorage.setItem('flexible_gen_settings_visible', val)
})
const activeOverlayId = ref(null) // For mobile tap-to-show overlay
const filterCharacter = ref(null) // Character filter for gallery
@@ -671,6 +676,15 @@ const clearPrompt = () => {
previousPrompt.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)
}
}
const deleteGeneration = async (gen) => {
if (!gen) return
try {
@@ -1144,6 +1158,9 @@ const confirmAddToAlbum = async () => {
:disabled="!prompt || prompt.length <= 10"
class="!py-0.5 !px-2 !text-[10px] !h-6 !bg-violet-600/20 hover:!bg-violet-600/30 !border-violet-500/30 !text-violet-400 disabled:opacity-50"
@click="handleImprovePrompt" />
<Button icon="pi pi-clipboard" label="Paste"
class="!py-0.5 !px-2 !text-[10px] !h-6 !bg-slate-800 hover:!bg-slate-700 !border-white/10 !text-slate-400"
@click="pastePrompt" />
<Button icon="pi pi-times" label="Clear"
class="!py-0.5 !px-2 !text-[10px] !h-6 !bg-slate-800 hover:!bg-slate-700 !border-white/10 !text-slate-400"
@click="clearPrompt" />

View File

@@ -35,6 +35,40 @@ const toast = useToast()
const { currentIdea, loading, error } = storeToRefs(ideaStore)
const generations = ref([])
// --- Idea Name Editing ---
const isEditingName = ref(false)
const editableName = ref('')
const toggleEditName = () => {
if (!currentIdea.value) return
editableName.value = currentIdea.value.name
isEditingName.value = true
nextTick(() => {
const input = document.querySelector('.idea-name-input input')
if (input) input.focus()
})
}
const saveName = async () => {
if (!editableName.value.trim() || editableName.value === currentIdea.value.name) {
isEditingName.value = false
return
}
try {
const success = await ideaStore.updateIdea(currentIdea.value.id, {
name: editableName.value.trim()
})
if (success) {
toast.add({ severity: 'success', summary: 'Success', detail: 'Idea renamed', life: 2000 })
}
} catch (e) {
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to rename idea', life: 3000 })
} finally {
isEditingName.value = false
}
}
const prompt = ref('')
const negativePrompt = ref('')
const selectedModel = ref('flux-schnell')
@@ -108,7 +142,11 @@ watch([prompt, quality, aspectRatio, imageCount, selectedModel, sendToTelegram,
const viewMode = ref('feed') // 'feed' or 'gallery'
const isSubmitting = ref(false)
const isSettingsVisible = ref(true)
const isSettingsVisible = ref(localStorage.getItem('idea_detail_settings_visible') !== 'false')
watch(isSettingsVisible, (val) => {
localStorage.setItem('idea_detail_settings_visible', val)
})
const API_URL = import.meta.env.VITE_API_URL
@@ -413,6 +451,15 @@ const clearPrompt = () => {
previousPrompt.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)
}
}
// --- Asset Picker Logic ---
const isAssetPickerVisible = ref(false)
@@ -832,14 +879,24 @@ watch(viewMode, (v) => {
<main class="flex-1 flex flex-col min-w-0 bg-slate-950/50 relative">
<!-- 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">
class="h-12 border-b border-white/5 flex items-center justify-between px-6 bg-slate-900/80 backdrop-blur z-20">
<div class="flex items-center gap-4">
<div class="flex flex-col">
<div class="flex items-center gap-2">
<h1 class="text-lg font-bold text-slate-200 truncate max-w-[200px] md:max-w-md">{{
currentIdea?.name || 'Loading...' }}</h1>
<div v-if="isEditingName" class="flex items-center gap-2">
<InputText v-model="editableName"
class="idea-name-input !bg-slate-800 !border-violet-500/50 !text-white !py-0.5 !h-8 !text-base !font-bold"
@keyup.enter="saveName"
@blur="saveName"
/>
</div>
<h1 v-else class="text-base font-bold text-slate-200 truncate max-w-[200px] md:max-w-md cursor-pointer hover:text-violet-400 transition-colors"
@click="toggleEditName">
{{ currentIdea?.name || 'Loading...' }}
<i class="pi pi-pencil text-[9px] ml-1 opacity-50"></i>
</h1>
<span
class="px-2 py-0.5 rounded-full bg-slate-800 text-[10px] text-slate-400 border border-white/5">Idea
class="px-2 py-0.5 rounded-full bg-slate-800 text-[9px] text-slate-400 border border-white/5">Idea
Session</span>
</div>
</div>
@@ -1076,6 +1133,9 @@ watch(viewMode, (v) => {
:disabled="!prompt || prompt.length <= 10"
class="!py-0 !px-1.5 !text-[9px] !h-5 !bg-violet-600/20 hover:!bg-violet-600/30 !border-violet-500/30 !text-violet-400 disabled:opacity-50"
@click="handleImprovePrompt" />
<Button icon="pi pi-clipboard" label="Paste"
class="!py-0 !px-1.5 !text-[9px] !h-5 !bg-slate-800 hover:!bg-slate-700 !border-white/10 !text-slate-400"
@click="pastePrompt" />
<Button icon="pi pi-times" label="Clear"
class="!py-0 !px-1.5 !text-[9px] !h-5 !bg-slate-800 hover:!bg-slate-700 !border-white/10 !text-slate-400"
@click="clearPrompt" />

View File

@@ -21,6 +21,40 @@ const showCreateDialog = ref(false)
const newIdea = ref({ name: '', description: '' })
const submitting = ref(false)
const API_URL = import.meta.env.VITE_API_URL
const isSettingsVisible = ref(localStorage.getItem('ideas_view_settings_visible') !== 'false')
watch(isSettingsVisible, (val) => {
localStorage.setItem('ideas_view_settings_visible', val)
})
// --- Rename Idea ---
const showRenameDialog = ref(false)
const ideaToRename = ref(null)
const newName = ref('')
const renaming = ref(false)
const openRenameDialog = (idea) => {
ideaToRename.value = idea
newName.value = idea.name
showRenameDialog.value = true
}
const handleRename = async () => {
if (!newName.value.trim() || newName.value === ideaToRename.value.name) {
showRenameDialog.value = false
return
}
renaming.value = true
try {
await ideaStore.updateIdea(ideaToRename.value.id, {
name: newName.value.trim()
})
showRenameDialog.value = false
} finally {
renaming.value = false
}
}
// --- Generation Settings ---
const prompt = ref('')
@@ -161,6 +195,15 @@ const handleGenerate = async () => {
}
}
const pastePrompt = async () => {
try {
const text = await navigator.clipboard.readText()
if (text) prompt.value = text
} catch (err) {
console.error('Failed to read clipboard', err)
}
}
// --- Asset Picker Logic ---
const isAssetPickerVisible = ref(false)
const assetPickerTab = ref('all') // 'all', 'uploaded', 'generated'
@@ -243,14 +286,14 @@ const handleAssetPickerUpload = async (event) => {
<template>
<div class="flex flex-col h-full font-sans relative">
<!-- Content Area (Scrollable) -->
<div class="flex-1 overflow-y-auto p-8 pb-48 custom-scrollbar">
<div class="flex-1 overflow-y-auto p-4 md:p-6 pb-48 custom-scrollbar">
<!-- Top Bar -->
<header class="flex justify-between items-end mb-8 border-b border-white/5 pb-6">
<header class="flex justify-between items-end mb-4 border-b border-white/5 pb-4">
<div>
<h1
class="text-4xl font-bold m-0 bg-gradient-to-r from-violet-400 to-fuchsia-400 bg-clip-text text-transparent">
class="text-2xl font-bold m-0 bg-gradient-to-r from-violet-400 to-fuchsia-400 bg-clip-text text-transparent">
Ideas</h1>
<p class="mt-2 mb-0 text-slate-400">Your creative sessions and experiments</p>
<p class="mt-1 mb-0 text-xs text-slate-400">Your creative sessions and experiments</p>
</div>
<!-- REMOVED NEW IDEA BUTTON -->
</header>
@@ -296,9 +339,15 @@ const handleAssetPickerUpload = async (event) => {
<!-- Details -->
<div class="flex-1 min-w-0">
<h3
class="m-0 text-lg font-bold text-slate-200 group-hover:text-violet-300 transition-colors truncate">
{{ idea.name }}</h3>
<div class="flex items-center gap-2">
<h3
class="m-0 text-lg font-bold text-slate-200 group-hover:text-violet-300 transition-colors truncate">
{{ idea.name }}</h3>
<Button icon="pi pi-pencil" text rounded size="small"
class="!w-6 !h-6 !text-slate-500 hover:!text-violet-400 opacity-0 group-hover:opacity-100 transition-opacity"
@click.stop="openRenameDialog(idea)"
/>
</div>
<p class="m-0 text-sm text-slate-500 truncate">{{ idea.description || 'No description' }}</p>
</div>
@@ -315,13 +364,29 @@ const handleAssetPickerUpload = async (event) => {
</div>
</div>
<!-- Create Idea Dialog (Removed) -->
<!-- Rename Dialog -->
<Dialog v-model:visible="showRenameDialog" header="Rename Idea" modal :style="{ width: '400px' }"
: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">
<div class="flex flex-col gap-1">
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">New Name</label>
<InputText v-model="newName" class="w-full !bg-slate-700 !border-white/10 !text-white" autofocus @keyup.enter="handleRename" />
</div>
<div class="flex justify-end gap-2 mt-2">
<Button label="Cancel" text @click="showRenameDialog = false" class="!text-slate-400 hover:!text-white" />
<Button label="Save" :loading="renaming" @click="handleRename" class="!bg-violet-600 !border-none hover:!bg-violet-500" />
</div>
</div>
</Dialog>
<!-- SETTINGS PANEL (Bottom - Persistent) -->
<div
<div v-if="isSettingsVisible"
class="absolute bottom-4 left-1/2 -translate-x-1/2 w-[95%] max-w-4xl glass-panel border border-white/10 bg-slate-900/95 backdrop-blur-xl px-4 py-3 z-[60] !rounded-[2rem] shadow-2xl flex flex-col gap-1 max-h-[60vh] overflow-y-auto">
<div class="w-full flex justify-center -mt-1 mb-1 cursor-pointer" @click="isSettingsVisible = false">
<div class="w-12 h-1 bg-white/20 rounded-full hover:bg-white/40 transition-colors"></div>
</div>
<div class="flex flex-col lg:flex-row gap-3">
<!-- LEFT COLUMN: Prompt + Character + Assets -->
<div class="flex-1 flex flex-col gap-2">
@@ -329,7 +394,14 @@ const handleAssetPickerUpload = async (event) => {
<div class="flex flex-col gap-1">
<div class="flex justify-between items-center">
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Prompt</label>
<!-- Optional: Add Clear/Improve buttons here if desired, keeping simple for now to match layout -->
<div class="flex gap-1">
<Button icon="pi pi-clipboard" label="Paste" size="small" text
class="!text-slate-400 hover:!text-white hover:!bg-white/10 !py-0 !px-1.5 !text-[9px] !h-5"
@click="pastePrompt" />
<Button icon="pi pi-times" label="Clear" size="small" text
class="!text-slate-400 hover:!text-white hover:!bg-white/10 !py-0 !px-1.5 !text-[9px] !h-5"
@click="prompt = ''" />
</div>
</div>
<Textarea v-model="prompt" rows="2"
placeholder="Describe what you want to create... (Auto-starts new session)"
@@ -460,6 +532,13 @@ const handleAssetPickerUpload = async (event) => {
</div>
</div>
<transition name="fade">
<div v-if="!isSettingsVisible" class="absolute bottom-8 left-1/2 -translate-x-1/2 z-10">
<Button label="Open Controls" icon="pi pi-chevron-up" @click="isSettingsVisible = true" rounded
class="!bg-violet-600 !border-none !shadow-xl !font-bold shadow-violet-500/40 !px-6 !py-3" />
</div>
</transition>
<Dialog v-model:visible="isAssetPickerVisible" modal header="Select Assets"
:style="{ width: '80vw', maxWidth: '900px' }"
:pt="{ root: { class: '!bg-slate-900 !border !border-white/10' }, header: { class: '!bg-slate-900 !border-b !border-white/5 !text-white' }, content: { class: '!bg-slate-900 !p-0' }, footer: { class: '!bg-slate-900 !border-t !border-white/5 !p-4' }, closeButton: { class: '!text-slate-400 hover:!text-white' } }">

View File

@@ -1,14 +1,12 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { dataService } from '../services/dataService'
import { aiService } from '../services/aiService'
import {onMounted, ref} from 'vue'
import {useRouter} from 'vue-router'
import {dataService} from '../services/dataService'
import {aiService} from '../services/aiService'
import Button from 'primevue/button'
import Textarea from 'primevue/textarea'
import ProgressSpinner from 'primevue/progressspinner'
import ProgressBar from 'primevue/progressbar'
import Message from 'primevue/message'
import Tag from 'primevue/tag'
import Checkbox from 'primevue/checkbox'
import Dialog from 'primevue/dialog'
import Paginator from 'primevue/paginator'
@@ -361,6 +359,15 @@ const clearPrompt = () => {
prompt.value = ''
}
const pastePrompt = async () => {
try {
const text = await navigator.clipboard.readText()
if (text) prompt.value = text
} catch (err) {
console.error('Failed to read clipboard', err)
}
}
// --- Reuse Logic ---
const reusePrompt = (gen) => {
@@ -478,6 +485,9 @@ onMounted(() => {
:disabled="prompt.length <= 10" size="small"
class="!py-0.5 !px-2 !text-[10px] bg-violet-600/20 hover:bg-violet-600/30 border-violet-500/30 text-violet-400 disabled:opacity-50"
@click="handleImprovePrompt" />
<Button icon="pi pi-clipboard" label="Paste" size="small"
class="!py-0.5 !px-2 !text-[10px] bg-slate-800 hover:bg-slate-700 border-white/10 text-slate-400"
@click="pastePrompt" />
<Button icon="pi pi-times" label="Clear" size="small"
class="!py-0.5 !px-2 !text-[10px] bg-slate-800 hover:bg-slate-700 border-white/10 text-slate-400"
@click="clearPrompt" />

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
<template>
<div class="container mx-auto p-4 animate-fade-in">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold text-white mb-2">Projects</h1>
<p class="text-slate-400">Manage your workspaces and teams</p>
<div class="h-full overflow-y-auto custom-scrollbar">
<div class="container mx-auto p-4 md:p-8 animate-fade-in">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold text-white mb-2">Projects</h1>
<p class="text-slate-400">Manage your workspaces and teams</p>
</div>
<Button label="New Project" icon="pi pi-plus" @click="showCreateDialog = true" />
</div>
<Button label="New Project" icon="pi pi-plus" @click="showCreateDialog = true" />
</div>
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Skeleton v-for="i in 3" :key="i" height="150px" class="rounded-xl" />
@@ -39,9 +40,15 @@
</p>
<div class="flex items-center justify-between mt-4 border-t border-slate-700/50 pt-4">
<div class="flex items-center text-slate-500 text-sm">
<i class="pi pi-users mr-2"></i>
<span>{{ project.members.length }} members</span>
<div class="flex flex-col">
<div class="flex items-center text-slate-500 text-sm mb-1">
<i class="pi pi-users mr-2"></i>
<span>{{ project.members.length }} members</span>
</div>
<div v-if="projectUsage[project.id]" class="flex items-center text-violet-400 text-xs font-bold">
<i class="pi pi-bolt mr-2"></i>
<span>${{ projectUsage[project.id].toFixed(2) }} spent</span>
</div>
</div>
<Button v-if="currentProject?.id !== project.id" icon="pi pi-check" label="Select" size="small"
@@ -94,8 +101,29 @@ const newProject = ref({
description: ''
});
const projectUsage = ref({});
const fetchUsage = async () => {
try {
// Fetch usage with project breakdown, pass false to ignore current active project header
const report = await aiService.getUsageReport("project", false);
if (report && report.by_project) {
const usageMap = {};
report.by_project.forEach(item => {
if (item.entity_id) {
usageMap[item.entity_id] = item.stats.total_cost;
}
});
projectUsage.value = usageMap;
}
} catch (e) {
console.error("Failed to fetch projects usage", e);
}
};
onMounted(() => {
projectsStore.fetchProjects();
fetchUsage();
});
const createProject = async () => {
@@ -121,3 +149,22 @@ const goToProject = (id) => {
router.push(`/projects/${id}`);
};
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
</style>