cd2efe3587
Phase 1 of frontend_tasks.md - stop showing fabricated/broken UI:
- Dashboard now fetches real stats (projects, tasks, users, pending
approvals, API keys, developer stats, pending reviews, admin
activity) instead of hardcoded numbers. Added services/developer.ts
and services/review.ts wrappers for previously-unused backend
endpoints.
- Wired ActivityFeed into every project's Overview page in place of
the "coming soon" placeholder.
- Registered the missing /projects/:id/technical-specs route (view
and service already existed, just unreachable).
- Fixed AssetDeleteConfirmDialog's raw styled divs to use the shared
Alert component and wired it into AssetBrowser, matching
ShotBrowser's impact-summary + type-to-confirm safety pattern
(asset deletion was previously less safe than shot deletion).
- Fixed a shared bug in both delete dialogs where the impact-summary
section never rendered (watch on the open prop needed
{ immediate: true }).
- Replaced native confirm()/alert() with styled AlertDialog/Dialog in
NoteItem, TaskAttachments, and UserMenu's keyboard-shortcuts item.
- Removed dead-end UI: Google OAuth stub buttons, UserMenu items
pointing at non-existent routes, the /developer/docs dead link, and
the no-op action button on the API Keys placeholder page.
- Removed leftover debug console logging across 8 files.
Added frontend_report.md (full audit) and frontend_tasks.md (phased
checklist) as the reference for this and future phases.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
306 lines
9.1 KiB
Vue
306 lines
9.1 KiB
Vue
<template>
|
|
<div class="flex flex-col h-full">
|
|
<!-- Attachments History (Top) -->
|
|
<div class="flex-1 overflow-y-auto p-2">
|
|
<!-- Filter by Type -->
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<span class="text-xs font-medium">Filter:</span>
|
|
<Select v-model="filterType">
|
|
<SelectTrigger class="w-[140px] h-8 text-xs">
|
|
<SelectValue placeholder="Filter by type" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem
|
|
v-for="type in attachmentTypes"
|
|
:key="type.value"
|
|
:value="type.value"
|
|
>
|
|
{{ type.label }}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<!-- Attachments Grid -->
|
|
<div v-if="filteredAttachments.length === 0" class="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
|
<Upload class="h-10 w-10 mb-2 opacity-50" />
|
|
<p class="text-sm">No attachments found. Upload files below.</p>
|
|
</div>
|
|
|
|
<div v-else class="grid grid-cols-2 gap-2">
|
|
<AttachmentCard
|
|
v-for="attachment in filteredAttachments"
|
|
:key="attachment.id"
|
|
:attachment="attachment"
|
|
@delete="handleDelete"
|
|
@view="handleView"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Upload Area (Bottom) -->
|
|
<div class="flex-shrink-0 border-t bg-background p-2">
|
|
<div class="space-y-2">
|
|
<div class="border-2 border-dashed rounded-lg p-3 text-center hover:border-primary/50 transition-colors">
|
|
<input
|
|
ref="fileInput"
|
|
type="file"
|
|
class="hidden"
|
|
@change="handleFileSelect"
|
|
multiple
|
|
/>
|
|
<Upload class="h-6 w-6 mx-auto mb-1 text-muted-foreground" />
|
|
<p class="text-xs font-medium mb-1">Upload attachments</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
@click="fileInput?.click()"
|
|
:disabled="uploading"
|
|
class="h-7 text-xs"
|
|
>
|
|
<Upload class="h-3 w-3 mr-1" />
|
|
{{ uploading ? 'Uploading...' : 'Choose Files' }}
|
|
</Button>
|
|
</div>
|
|
|
|
<!-- Attachment Type Selection -->
|
|
<Select v-model="attachmentType">
|
|
<SelectTrigger class="h-8 text-xs">
|
|
<SelectValue placeholder="Select attachment type" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="reference">Reference</SelectItem>
|
|
<SelectItem value="work_file">Work File</SelectItem>
|
|
<SelectItem value="preview">Preview</SelectItem>
|
|
<SelectItem value="documentation">Documentation</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Image Viewer Dialog -->
|
|
<Dialog v-model:open="viewerOpen">
|
|
<DialogContent class="max-w-4xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{{ selectedAttachment?.file_name }}</DialogTitle>
|
|
<DialogDescription>
|
|
Attachment preview
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div class="flex items-center justify-center">
|
|
<img
|
|
v-if="selectedAttachment?.thumbnail_url && mediaBlobUrl"
|
|
:src="mediaBlobUrl"
|
|
:alt="selectedAttachment.file_name"
|
|
class="max-w-full max-h-[70vh] object-contain"
|
|
/>
|
|
<!-- Fallback for other file types -->
|
|
<div v-else class="text-center p-8">
|
|
<p class="text-muted-foreground mb-4">Preview not available for this file type</p>
|
|
<Button @click="handleDownload(selectedAttachment)" variant="outline">
|
|
Download File
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<AlertDialog :open="showDeleteDialog" @update:open="showDeleteDialog = $event">
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete Attachment</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to delete this attachment? This action cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction @click="confirmDeleteAttachment" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
Delete
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed } from 'vue'
|
|
import { Upload } from 'lucide-vue-next'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from '@/components/ui/dialog'
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog'
|
|
import AttachmentCard from './AttachmentCard.vue'
|
|
import { taskService, type TaskAttachment } from '@/services/task'
|
|
import { useToast } from '@/components/ui/toast/use-toast'
|
|
import { apiClient } from '@/services/api'
|
|
|
|
const props = defineProps<{
|
|
taskId: number
|
|
attachments: TaskAttachment[]
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
attachmentsUpdated: []
|
|
}>()
|
|
|
|
const { toast } = useToast()
|
|
|
|
const fileInput = ref<HTMLInputElement>()
|
|
const uploading = ref(false)
|
|
const attachmentType = ref('reference')
|
|
const filterType = ref('all')
|
|
const viewerOpen = ref(false)
|
|
const selectedAttachment = ref<TaskAttachment | null>(null)
|
|
const mediaBlobUrl = ref<string | null>(null)
|
|
const showDeleteDialog = ref(false)
|
|
const attachmentToDelete = ref<number | null>(null)
|
|
|
|
const attachmentTypes = [
|
|
{ value: 'all', label: 'All' },
|
|
{ value: 'reference', label: 'Reference' },
|
|
{ value: 'work_file', label: 'Work Files' },
|
|
{ value: 'preview', label: 'Previews' },
|
|
{ value: 'documentation', label: 'Documentation' }
|
|
]
|
|
|
|
const filteredAttachments = computed(() => {
|
|
if (filterType.value === 'all') return props.attachments
|
|
return props.attachments.filter(a => a.attachment_type === filterType.value)
|
|
})
|
|
|
|
async function handleFileSelect(event: Event) {
|
|
const target = event.target as HTMLInputElement
|
|
const files = target.files
|
|
if (!files || files.length === 0) return
|
|
|
|
uploading.value = true
|
|
try {
|
|
for (const file of Array.from(files)) {
|
|
await taskService.uploadTaskAttachment(
|
|
props.taskId,
|
|
file,
|
|
attachmentType.value
|
|
)
|
|
}
|
|
emit('attachmentsUpdated')
|
|
toast({
|
|
title: 'Success',
|
|
description: `${files.length} file(s) uploaded successfully`
|
|
})
|
|
// Reset input
|
|
if (fileInput.value) {
|
|
fileInput.value.value = ''
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Error uploading files:', error)
|
|
toast({
|
|
title: 'Error',
|
|
description: error.response?.data?.detail || 'Failed to upload files',
|
|
variant: 'destructive'
|
|
})
|
|
} finally {
|
|
uploading.value = false
|
|
}
|
|
}
|
|
|
|
function handleDelete(attachmentId: number) {
|
|
attachmentToDelete.value = attachmentId
|
|
showDeleteDialog.value = true
|
|
}
|
|
|
|
async function confirmDeleteAttachment() {
|
|
if (attachmentToDelete.value === null) return
|
|
|
|
try {
|
|
await taskService.deleteTaskAttachment(props.taskId, attachmentToDelete.value)
|
|
emit('attachmentsUpdated')
|
|
toast({
|
|
title: 'Success',
|
|
description: 'Attachment deleted successfully'
|
|
})
|
|
} catch (error: any) {
|
|
toast({
|
|
title: 'Error',
|
|
description: error.response?.data?.detail || 'Failed to delete attachment',
|
|
variant: 'destructive'
|
|
})
|
|
} finally {
|
|
showDeleteDialog.value = false
|
|
attachmentToDelete.value = null
|
|
}
|
|
}
|
|
|
|
async function handleView(attachment: TaskAttachment) {
|
|
selectedAttachment.value = attachment
|
|
viewerOpen.value = true
|
|
|
|
// Load media for viewer
|
|
if (attachment.thumbnail_url || attachment.download_url) {
|
|
await loadMediaForViewer(attachment)
|
|
}
|
|
}
|
|
|
|
async function loadMediaForViewer(attachment: TaskAttachment) {
|
|
try {
|
|
if (attachment.download_url) {
|
|
// Load image as blob
|
|
const response = await apiClient.get(attachment.download_url, {
|
|
responseType: 'blob'
|
|
})
|
|
if (mediaBlobUrl.value) {
|
|
URL.revokeObjectURL(mediaBlobUrl.value)
|
|
}
|
|
mediaBlobUrl.value = URL.createObjectURL(response.data)
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load media:', error)
|
|
mediaBlobUrl.value = null
|
|
}
|
|
}
|
|
|
|
async function handleDownload(attachment: TaskAttachment | null) {
|
|
if (!attachment?.download_url) return
|
|
|
|
try {
|
|
const response = await apiClient.get(attachment.download_url, {
|
|
responseType: 'blob'
|
|
})
|
|
|
|
// Create download link
|
|
const url = URL.createObjectURL(response.data)
|
|
const link = document.createElement('a')
|
|
link.href = url
|
|
link.download = attachment.file_name
|
|
document.body.appendChild(link)
|
|
link.click()
|
|
document.body.removeChild(link)
|
|
URL.revokeObjectURL(url)
|
|
} catch (error) {
|
|
console.error('Failed to download file:', error)
|
|
}
|
|
}
|
|
</script>
|