Init Repo

This commit is contained in:
2026-02-28 03:22:04 +08:00
commit de59b57ee7
883 changed files with 156857 additions and 0 deletions
@@ -0,0 +1,269 @@
<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>
</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 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 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
}
}
async function handleDelete(attachmentId: number) {
if (!confirm('Are you sure you want to delete this attachment?')) return
try {
await taskService.deleteTaskAttachment(props.taskId, attachmentId)
emit('attachmentsUpdated')
toast({
title: 'Success',
description: 'Attachment deleted successfully'
})
} catch (error: any) {
console.error('Error deleting attachment:', error)
toast({
title: 'Error',
description: error.response?.data?.detail || 'Failed to delete attachment',
variant: 'destructive'
})
}
}
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>