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,179 @@
<template>
<Card class="overflow-hidden hover:shadow-md transition-shadow">
<div class="flex gap-4 p-4">
<!-- Thumbnail -->
<div class="w-32 h-24 bg-muted rounded flex items-center justify-center flex-shrink-0 relative group cursor-pointer" @click="emit('view', submission)">
<img
v-if="thumbnailBlobUrl"
:src="thumbnailBlobUrl"
:alt="submission.file_name"
class="w-full h-full object-cover rounded"
/>
<div v-else class="flex flex-col items-center gap-1">
<FileIcon class="h-8 w-8 text-muted-foreground" />
<span class="text-xs text-muted-foreground">{{ getFileExtension(submission.file_name) }}</span>
</div>
<!-- Play icon for videos -->
<div v-if="submission.stream_url" class="absolute inset-0 flex items-center justify-center bg-black/30 group-hover:bg-black/50 transition-colors">
<Play class="h-8 w-8 text-white" />
</div>
</div>
<!-- Info -->
<div class="flex-1 space-y-2">
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-2">
<h4 class="font-semibold">Version {{ submission.version_number }}</h4>
<Badge v-if="submission.latest_review" :variant="getReviewVariant(submission.latest_review.decision)">
{{ submission.latest_review.decision }}
</Badge>
</div>
<p class="text-sm text-muted-foreground">{{ submission.file_name }}</p>
</div>
<Button
variant="ghost"
size="sm"
@click="handleDownload"
>
<Download class="h-4 w-4" />
</Button>
</div>
<div class="text-xs text-muted-foreground space-y-1">
<div class="flex items-center gap-2">
<Avatar class="h-5 w-5">
<AvatarImage
:src="`https://api.dicebear.com/7.x/initials/svg?seed=${submission.user_first_name} ${submission.user_last_name}`"
/>
<AvatarFallback class="text-[10px]">{{ getUserInitials(submission.user_first_name, submission.user_last_name) }}</AvatarFallback>
</Avatar>
<span>Submitted by {{ submission.user_first_name }} {{ submission.user_last_name }}</span>
</div>
<div>
{{ formatDateTime(submission.submitted_at) }}
</div>
</div>
<div v-if="submission.notes" class="text-sm bg-muted p-2 rounded">
<p class="font-semibold text-xs mb-1">Notes:</p>
<p class="line-clamp-2">{{ submission.notes }}</p>
</div>
<div v-if="submission.latest_review?.feedback" class="text-sm border-l-2 pl-2" :class="getReviewBorderClass(submission.latest_review.decision)">
<p class="font-semibold text-xs mb-1">Review Feedback:</p>
<p class="line-clamp-2">{{ submission.latest_review.feedback }}</p>
<p class="text-xs text-muted-foreground mt-1">
By {{ submission.latest_review.reviewer_first_name }} {{ submission.latest_review.reviewer_last_name }}
</p>
</div>
<Button
variant="outline"
size="sm"
@click="emit('view', submission)"
>
<Eye class="h-4 w-4 mr-2" />
View Details
</Button>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { FileIcon, Download, Eye, Play } from 'lucide-vue-next'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import type { Submission } from '@/services/task'
import { apiClient } from '@/services/api'
const props = defineProps<{
submission: Submission
}>()
const emit = defineEmits<{
view: [submission: Submission]
}>()
const thumbnailBlobUrl = ref<string | null>(null)
function getFileExtension(filename: string): string {
const parts = filename.split('.')
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : 'FILE'
}
function formatDateTime(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
}
function getReviewVariant(decision: string): 'default' | 'destructive' {
return decision === 'approved' ? 'default' : 'destructive'
}
function getReviewBorderClass(decision: string): string {
return decision === 'approved' ? 'border-green-500' : 'border-destructive'
}
function handleDownload() {
if (props.submission.download_url) {
const url = getThumbnailUrl(props.submission.download_url)
window.open(url, '_blank')
}
}
function getUserInitials(firstName: string, lastName: string) {
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase()
}
async function loadThumbnail() {
if (!props.submission.thumbnail_url) {
thumbnailBlobUrl.value = null
return
}
try {
const response = await apiClient.get(props.submission.thumbnail_url, {
responseType: 'blob'
})
// Revoke old blob URL if it exists
if (thumbnailBlobUrl.value) {
URL.revokeObjectURL(thumbnailBlobUrl.value)
}
// Create new blob URL
thumbnailBlobUrl.value = URL.createObjectURL(response.data)
} catch (error) {
console.error('Failed to load thumbnail:', error)
thumbnailBlobUrl.value = null
}
}
onMounted(() => {
loadThumbnail()
})
watch(() => props.submission.id, () => {
loadThumbnail()
})
function getThumbnailUrl(url: string | null | undefined) {
if (!url) return ''
if (url.startsWith('http')) return url
// Remove any leading slashes and backend prefix
const cleanUrl = url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/').replace(/^\/+/, '')
return `http://localhost:8000/${cleanUrl}`
}
</script>