AlgoVision / frontend /app /components /ProjectCard.vue
AlgoVision Deployer
deploy: minimal bootloader for public Space
1a25b7f
<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>