Spaces:
Sleeping
Sleeping
| <template> | |
| <!-- | |
| Outer wrapper is draggable. Browser suppresses click-to-navigate during | |
| a real drag, so NuxtLink navigation still works on a plain click. | |
| --> | |
| <div | |
| class="project-card project-card-wrapper" | |
| :class="{ 'is-dragging': isDragging }" | |
| draggable="true" | |
| @dragstart="onDragStart" | |
| @dragend="onDragEnd" | |
| > | |
| <NuxtLink | |
| :to="`/project/${project.id}`" | |
| draggable="false" | |
| class="premium-glass rounded-2xl overflow-hidden group hover:-translate-y-1 transition-all duration-300 block select-none" | |
| > | |
| <!-- Thumbnail --> | |
| <div | |
| class="aspect-video w-full bg-slate-900 relative overflow-hidden" | |
| @mouseenter="playPreview" | |
| @mouseleave="pausePreview" | |
| > | |
| <!-- Status Badge --> | |
| <div class="absolute top-3 left-3 z-10 flex gap-2"> | |
| <span | |
| class="px-2.5 py-1 rounded-full text-xs font-semibold backdrop-blur-md" | |
| :class="statusClasses" | |
| > | |
| {{ formattedStatus }} | |
| </span> | |
| </div> | |
| <!-- Action Buttons (Continue + Delete) --> | |
| <div class="absolute top-3 right-3 z-10 flex gap-2"> | |
| <!-- Continue button (only for non-active projects: error, planned, or stopped) --> | |
| <button | |
| v-if="canContinue" | |
| @click.prevent.stop="$emit('continue', project)" | |
| class="w-8 h-8 rounded-full bg-white/80 backdrop-blur-md border border-white/80 flex items-center justify-center text-slate-600 hover:bg-green-500 hover:text-white hover:border-green-500 transition-all shadow-sm opacity-0 group-hover:opacity-100" | |
| :title=" | |
| project.status === 'error' | |
| ? 'Retry Generation' | |
| : 'Continue Generation' | |
| " | |
| > | |
| <UIcon | |
| :name=" | |
| project.status === 'error' | |
| ? 'i-lucide-rotate-ccw' | |
| : 'i-lucide-play-circle' | |
| " | |
| class="w-4 h-4" | |
| /> | |
| </button> | |
| <!-- Delete button --> | |
| <button | |
| @click.prevent.stop="$emit('delete', project)" | |
| class="w-8 h-8 rounded-full bg-white/80 backdrop-blur-md border border-white/80 flex items-center justify-center text-slate-600 hover:bg-red-500 hover:text-white hover:border-red-500 transition-all shadow-sm opacity-0 group-hover:opacity-100" | |
| > | |
| <UIcon name="i-lucide-trash-2" class="w-4 h-4" /> | |
| </button> | |
| </div> | |
| <!-- Drag hint badge (shows on hover) --> | |
| <div | |
| class="absolute bottom-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" | |
| > | |
| <div | |
| class="flex items-center gap-1 px-2 py-1 rounded-full bg-black/40 backdrop-blur-sm text-white text-xs font-medium" | |
| > | |
| <UIcon name="i-lucide-grip" class="w-3 h-3" /> | |
| Drag to group | |
| </div> | |
| </div> | |
| <!-- Play icon overlay --> | |
| <div | |
| v-if="videoUrl" | |
| class="absolute inset-0 z-10 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" | |
| > | |
| <div | |
| class="w-12 h-12 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center" | |
| > | |
| <UIcon | |
| name="i-lucide-play" | |
| class="w-6 h-6 text-white translate-x-0.5" | |
| /> | |
| </div> | |
| </div> | |
| <!-- Video preview --> | |
| <video | |
| v-if="videoUrl" | |
| ref="videoEl" | |
| :src="videoUrl" | |
| draggable="false" | |
| class="w-full h-full object-cover transition-opacity duration-500" | |
| :class="{ | |
| 'opacity-0': !isHovered && !videoStarted, | |
| 'opacity-100': isHovered || videoStarted, | |
| }" | |
| muted | |
| loop | |
| preload="metadata" | |
| @loadedmetadata="onVideoMetadata" | |
| @play="videoStarted = true" | |
| /> | |
| <!-- Premium Thumbnail / Generating / Fallback Overlay --> | |
| <div | |
| v-if="!isHovered && !videoStarted" | |
| class="absolute inset-0 z-0 flex flex-col items-center justify-center overflow-hidden" | |
| > | |
| <!-- Thumbnail Image (if available) --> | |
| <div v-if="project.thumbnail" class="absolute inset-0 bg-slate-900"> | |
| <img | |
| :src="project.thumbnail" | |
| class="w-full h-full object-cover opacity-60 transition-transform duration-700 group-hover:scale-110" | |
| alt="Project Thumbnail" | |
| /> | |
| <div | |
| class="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-slate-950/20 to-transparent" | |
| ></div> | |
| </div> | |
| <!-- Fallback Stock Image Background --> | |
| <div v-else class="absolute inset-0 bg-slate-900"> | |
| <img | |
| src="https://images.unsplash.com/photo-1635070041078-e363dbe005cb?auto=format&fit=crop&q=80&w=1200" | |
| class="w-full h-full object-cover opacity-40 transition-transform duration-700 group-hover:scale-110" | |
| alt="Default Project Thumbnail" | |
| /> | |
| <div | |
| class="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-slate-950/20 to-transparent" | |
| ></div> | |
| </div> | |
| <!-- Generating Animation Overlay --> | |
| <div | |
| v-if="project.status !== 'completed' && project.status !== 'error'" | |
| class="absolute inset-0 z-10 flex items-center justify-center" | |
| > | |
| <div class="absolute inset-0 bg-green-500/10 animate-pulse"></div> | |
| <div | |
| class="w-full h-1 bg-gradient-to-r from-transparent via-green-400 to-transparent absolute top-0 animate-[scan_3s_linear_infinite]" | |
| ></div> | |
| <div | |
| class="w-full h-1 bg-gradient-to-r from-transparent via-green-400 to-transparent absolute bottom-0 animate-[scan_3s_linear_infinite_reverse]" | |
| ></div> | |
| </div> | |
| <!-- Project Identity --> | |
| <div | |
| class="relative z-20 flex flex-col items-center px-6 text-center" | |
| > | |
| <div | |
| class="px-5 py-3 rounded-2xl border border-white/20 backdrop-blur-md shadow-2xl transition-all duration-500 group-hover:scale-105" | |
| :class=" | |
| project.status !== 'completed' && project.status !== 'error' | |
| ? 'bg-orange-500/20 border-orange-500/40' | |
| : 'bg-green-500/20 border-green-500/30 group-hover:bg-green-500/30' | |
| " | |
| > | |
| <div | |
| v-if=" | |
| project.status !== 'completed' && project.status !== 'error' | |
| " | |
| class="flex flex-col items-center gap-2" | |
| > | |
| <UIcon | |
| :name=" | |
| project.status === 'awaiting_approval' | |
| ? 'i-lucide-scroll-text' | |
| : 'i-lucide-loader-2' | |
| " | |
| class="w-6 h-6" | |
| :class=" | |
| project.status === 'awaiting_approval' | |
| ? 'text-indigo-400' | |
| : 'text-orange-400 animate-spin' | |
| " | |
| /> | |
| <h3 class="text-xl font-bold text-white leading-tight"> | |
| {{ | |
| project.status === "awaiting_approval" | |
| ? "Review Plan" | |
| : "Generating..." | |
| }} | |
| </h3> | |
| </div> | |
| <h3 v-else class="text-xl font-bold text-white leading-tight"> | |
| {{ project.name || "Untitled" }} | |
| </h3> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Card Content --> | |
| <div class="p-5"> | |
| <h3 class="font-bold text-slate-900 text-lg mb-1 truncate"> | |
| {{ project.name || "Untitled Project" }} | |
| </h3> | |
| <p | |
| class="text-slate-500 text-sm line-clamp-2 h-10 mb-4" | |
| :title="project.overview || project.topic" | |
| > | |
| {{ project.overview || project.topic || "No description provided." }} | |
| </p> | |
| <div | |
| class="flex items-center justify-between text-xs text-slate-400 font-medium" | |
| > | |
| <div class="flex items-center gap-1.5"> | |
| <UIcon name="i-lucide-calendar" class="w-3.5 h-3.5" /> | |
| {{ formattedDate }} | |
| </div> | |
| <div class="flex items-center gap-1.5"> | |
| <UIcon name="i-lucide-clock" class="w-3.5 h-3.5" /> | |
| {{ duration || "--:--" }} | |
| </div> | |
| </div> | |
| </div> | |
| </NuxtLink> | |
| </div> | |
| </template> | |
| <script setup lang="ts"> | |
| import { ref, computed } from "vue"; | |
| import { config as apiConfig } from "~/utils/api"; | |
| import { useProjectStore } from "~/stores/projects"; | |
| const props = defineProps<{ | |
| project: any; | |
| duration?: string; | |
| }>(); | |
| const store = useProjectStore(); | |
| const emit = defineEmits<{ | |
| delete: [project: any]; | |
| continue: [project: any]; | |
| dragstart: [projectId: string]; | |
| dragend: []; | |
| }>(); | |
| const videoEl = ref<HTMLVideoElement | null>(null); | |
| const isDragging = ref(false); | |
| const isHovered = ref(false); | |
| const videoStarted = ref(false); | |
| const localDuration = ref<string>(""); | |
| const videoUrl = computed(() => { | |
| if (!props.project.video_url) return null; | |
| if (props.project.video_url.startsWith("http")) | |
| return props.project.video_url; | |
| return `${apiConfig.API_BASE_URL}${props.project.video_url}`; | |
| }); | |
| const formattedStatus = computed(() => { | |
| if (!props.project.status) return "Unknown"; | |
| return props.project.status | |
| .replace(/_/g, " ") | |
| .replace(/\b\w/g, (c: string) => c.toUpperCase()); | |
| }); | |
| const statusClasses = computed(() => { | |
| const s = props.project.status; | |
| if (s === "completed") | |
| return "bg-white/80 border border-white/50 text-green-700 bg-green-50/80 border-green-200"; | |
| if (s === "error") | |
| return "bg-white/80 border border-white/50 text-red-700 bg-red-50/80 border-red-200"; | |
| if (s === "awaiting_approval") | |
| return "bg-indigo-50/80 border border-indigo-200 text-indigo-700"; | |
| return "bg-white/80 border border-white/50 text-orange-700 bg-orange-50/80 border-orange-200"; | |
| }); | |
| // Only allow continue for: error, planned, stopped, or cancelled | |
| // BUT also check if there's already an active generation running for this project | |
| const canContinue = computed(() => { | |
| const s = props.project.status; | |
| const statusAllowsContinue = | |
| s === "error" || s === "planned" || s === "stopped" || s === "cancelled"; | |
| // Check if there's already an active generation for this project | |
| const hasActiveGeneration = Object.values(store.activeGenerations).some( | |
| (gen: any) => { | |
| const matchesProject = | |
| gen.projectId === props.project.id || | |
| gen.projectName === props.project.id; | |
| const isActive = gen.status !== "stopped" && gen.status !== "completed"; | |
| return matchesProject && isActive; | |
| }, | |
| ); | |
| // Only show continue if status allows it AND no active generation is running | |
| return statusAllowsContinue && !hasActiveGeneration; | |
| }); | |
| const formattedDate = computed(() => { | |
| if (!props.project.created_at) return ""; | |
| return new Intl.DateTimeFormat("en-US", { | |
| month: "short", | |
| day: "numeric", | |
| year: "numeric", | |
| }).format(new Date(props.project.created_at)); | |
| }); | |
| const duration = computed( | |
| () => props.duration || localDuration.value || props.project.duration, | |
| ); | |
| function playPreview() { | |
| isHovered.value = true; | |
| videoEl.value?.play().catch(() => {}); | |
| } | |
| function pausePreview() { | |
| isHovered.value = false; | |
| videoStarted.value = false; | |
| if (videoEl.value) { | |
| videoEl.value.pause(); | |
| videoEl.value.currentTime = 0; | |
| } | |
| } | |
| function onVideoMetadata(e: Event) { | |
| const secs = (e.target as HTMLVideoElement).duration; | |
| if (!secs || !isFinite(secs)) return; | |
| const m = Math.floor(secs / 60); | |
| const s = Math.floor(secs % 60) | |
| .toString() | |
| .padStart(2, "0"); | |
| localDuration.value = `${m}:${s}`; | |
| } | |
| function onDragStart(e: DragEvent) { | |
| // Small delay lets the browser snapshot the element before we dim it | |
| requestAnimationFrame(() => { | |
| isDragging.value = true; | |
| }); | |
| if (e.dataTransfer) { | |
| e.dataTransfer.effectAllowed = "move"; | |
| e.dataTransfer.setData("text/plain", props.project.id); | |
| } | |
| emit("dragstart", props.project.id); | |
| } | |
| function onDragEnd() { | |
| isDragging.value = false; | |
| emit("dragend"); | |
| } | |
| </script> | |
| <style scoped> | |
| .project-card-wrapper { | |
| cursor: grab; | |
| } | |
| .project-card-wrapper.is-dragging { | |
| opacity: 0.45; | |
| transform: scale(0.97); | |
| cursor: grabbing; | |
| } | |
| /* Prevent browser from using its default link-drag ghost */ | |
| .project-card-wrapper a { | |
| -webkit-user-drag: none; | |
| } | |
| @keyframes scan { | |
| 0% { | |
| transform: translateY(0); | |
| opacity: 0; | |
| } | |
| 10% { | |
| opacity: 1; | |
| } | |
| 90% { | |
| opacity: 1; | |
| } | |
| 100% { | |
| transform: translateY(180px); | |
| opacity: 0; | |
| } | |
| } | |
| @keyframes scan-reverse { | |
| 0% { | |
| transform: translateY(0); | |
| opacity: 0; | |
| } | |
| 10% { | |
| opacity: 1; | |
| } | |
| 90% { | |
| opacity: 1; | |
| } | |
| 100% { | |
| transform: translateY(-180px); | |
| opacity: 0; | |
| } | |
| } | |
| </style> | |