AlgoVision / frontend /app /components /GenerationWidget.vue
AlgoVision Deployer
deploy: minimal bootloader for public Space
1a25b7f
<template>
<Transition name="widget-slide">
<div
v-if="
store.isGenerating ||
store.generationState.completed ||
store.generationState.errorMsg
"
class="fixed bottom-6 right-6 z-50 transition-all duration-500"
:class="isMinimized ? 'w-16 h-16' : 'w-88'"
>
<!-- Minimized State (Bubble) -->
<button
v-if="isMinimized"
@click="isMinimized = false"
class="w-16 h-16 rounded-full shadow-2xl flex items-center justify-center relative group overflow-hidden cursor-pointer border-2"
:class="
store.isGenerating
? 'bg-gradient-to-br from-green-400 to-green-600 border-green-300'
: store.generationState.completed
? 'bg-gradient-to-br from-emerald-400 to-emerald-600 border-emerald-300'
: 'bg-gradient-to-br from-slate-400 to-slate-600 border-slate-300'
"
>
<!-- Rhythmic Pulse Rings -->
<template v-if="store.isGenerating">
<div
class="absolute inset-0 bg-white/20 animate-ping-slow rounded-full"
></div>
<div
class="absolute inset-0 bg-white/10 animate-ping-slower rounded-full"
></div>
</template>
<!-- Icon + Progress -->
<div
class="relative z-10 flex flex-col items-center justify-center w-full h-full gap-0.5"
>
<UIcon
:name="currentIcon"
class="w-6 h-6 text-white transition-all duration-300 drop-shadow-sm"
:class="{
'animate-float': store.isGenerating,
'animate-spin-slow': store.generationState.isReconnecting,
}"
/>
<!-- Always-visible progress -->
<span
v-if="store.isGenerating"
class="text-[9px] font-black text-white/90 tabular-nums leading-none"
>
{{ store.progress }}%
</span>
</div>
<!-- Progress Ring -->
<svg
class="absolute inset-0 -rotate-90 w-full h-full pointer-events-none"
>
<circle
cx="50%"
cy="50%"
r="45%"
fill="transparent"
stroke="currentColor"
stroke-width="2.5"
class="text-white/20"
/>
<circle
cx="50%"
cy="50%"
r="45%"
fill="transparent"
stroke="currentColor"
stroke-width="3"
:stroke-dasharray="`${(store.progress / 100) * 282} 282`"
class="text-white/80 transition-all duration-700 ease-in-out"
stroke-linecap="round"
/>
</svg>
</button>
<!-- Expanded State (Card) -->
<div
v-else
class="bg-white/95 backdrop-blur-xl rounded-2xl shadow-[0_20px_50px_rgba(0,0,0,0.15)] border border-slate-200 overflow-hidden overflow-y-auto max-h-[80vh]"
>
<!-- Completion Notification View -->
<div
v-if="store.generationState.completed"
@click="handleViewProject"
class="p-5 bg-gradient-to-br from-[#00dc82] to-[#15803d] text-white cursor-pointer hover:brightness-110 transition-all flex items-center gap-4 group active:scale-[0.98] shadow-[inset_0_1px_rgba(255,255,255,0.4),0_10px_25px_-10px_rgba(0,220,130,0.5)] border-b border-white/10"
>
<div
class="p-3 bg-white/20 backdrop-blur-md rounded-2xl group-hover:rotate-12 group-hover:scale-110 transition-all duration-300 border border-white/30 shadow-lg"
>
<UIcon name="i-lucide-party-popper" class="w-7 h-7" />
</div>
<div class="flex-1 min-w-0">
<p
class="font-black text-[10px] uppercase tracking-[0.25em] mb-1 opacity-90 drop-shadow-sm"
>
Success!
</p>
<p class="font-black text-sm leading-tight truncate drop-shadow-sm">
{{
store.currentProject?.title ||
store.generationState.completedProjectTitle ||
"Your project"
}}
is ready
</p>
</div>
<div class="flex items-center gap-2">
<div
class="w-9 h-9 rounded-full bg-white/10 flex items-center justify-center group-hover:bg-white/20 transition-all border border-white/10"
>
<UIcon
name="i-lucide-arrow-right"
class="w-5 h-5 animate-bounce-x"
/>
</div>
<button
@click.stop="store.resetGenerationState()"
class="p-2 hover:bg-white/20 rounded-xl transition-colors cursor-pointer group/close flex items-center justify-center border border-transparent hover:border-white/20 shadow-none hover:shadow-lg"
title="Dismiss"
>
<UIcon
name="i-lucide-x"
class="w-4 h-4 opacity-70 group-hover/close:opacity-100"
/>
</button>
</div>
</div>
<!-- Active Generation View -->
<div v-else>
<!-- Header -->
<div
class="px-4 py-3 bg-slate-50/80 border-b border-slate-100 flex items-center justify-between"
>
<div class="flex items-center gap-2 min-w-0">
<UIcon
:name="currentIcon"
class="w-4 h-4 flex-shrink-0"
:class="[
store.isGenerating || store.generationState.isReconnecting
? 'text-green-600'
: 'text-slate-400',
{
'animate-spin':
(store.isGenerating ||
store.generationState.isReconnecting) &&
currentIcon === 'i-lucide-loader-2',
},
]"
/>
<span
class="font-black text-slate-800 text-[10px] uppercase tracking-[0.15em] truncate"
>
{{ store.currentProject?.title || "Production Studio" }}
</span>
</div>
<button
@click="isMinimized = true"
class="p-1 hover:bg-slate-200 rounded text-slate-400 transition-colors cursor-pointer"
>
<UIcon name="i-lucide-minus" class="w-4 h-4" />
</button>
</div>
<!-- Content -->
<div class="p-5 space-y-4">
<div>
<div class="flex justify-between items-end mb-2">
<div class="flex flex-col gap-1 min-w-0">
<span
class="text-[9px] font-black uppercase tracking-[0.2em] text-slate-400 leading-none truncate"
>
{{
store.generationState.isReconnecting
? "Reconnecting..."
: currentStatusLabel
}}
</span>
<!-- Core Stages Progress -->
<!-- <div
class="grid grid-cols-1 gap-2 mt-3 p-2 bg-slate-50/50 rounded-xl border border-slate-100"
>
<div
v-for="(progress, stage) in store.generationState
.stageProgress"
:key="stage"
class="space-y-1"
>
<div
class="flex justify-between items-center text-[9px] font-black uppercase tracking-tighter"
>
<span
:class="
progress > 0 ? 'text-slate-700' : 'text-slate-400'
"
>{{ stage }}</span
>
<span
:class="
progress === 100
? 'text-green-600'
: 'text-slate-500'
"
>{{ progress }}%</span
>
</div>
<div
class="h-1 w-full bg-slate-200/50 rounded-full overflow-hidden"
>
<div
class="h-full transition-all duration-700 ease-in-out"
:class="
progress === 100
? 'bg-green-500'
: progress > 0
? 'bg-green-400'
: 'bg-slate-300'
"
:style="{ width: `${progress}%` }"
></div>
</div>
</div>
</div> -->
<span
v-if="store.generationState.accumulatedCost > 0"
class="text-[9px] font-bold text-emerald-600 tabular-nums flex items-center gap-1"
>
<UIcon name="i-lucide-receipt-text" class="w-3 h-3" />
{{ store.accumulatedCostPHP }}
</span>
</div>
<span
class="text-2xl font-black text-slate-900 leading-none tabular-nums"
>{{ store.progress }}%</span
>
</div>
<!-- Progress Bar -->
<div
class="h-3 w-full bg-slate-200/40 rounded-full overflow-hidden relative border border-slate-100/50 backdrop-blur-sm shadow-[inset_0_1px_4px_rgba(0,0,0,0.05)]"
>
<!-- Progress Fill -->
<div
class="h-full bg-gradient-to-r from-green-400 via-emerald-500 to-emerald-600 transition-all duration-1000 ease-out relative"
:style="{ width: `${store.progress}%` }"
>
<!-- Inner Gloss/Sheen -->
<div
class="absolute inset-x-0 top-0 h-[35%] bg-white/20 blur-[1px]"
></div>
<!-- Main Shimmer (Fast/Sharp) -->
<div
v-if="store.isGenerating"
class="absolute top-0 bottom-0 left-0 w-[30%] bg-gradient-to-r from-transparent via-white/40 to-transparent animate-shimmer-fast"
></div>
<!-- Secondary Shimmer (Slow/Broad) -->
<div
v-if="store.isGenerating"
class="absolute top-0 bottom-0 left-0 w-[100%] bg-gradient-to-r from-transparent via-white/10 to-transparent animate-shimmer-slow"
></div>
<!-- Leading Edge Glow -->
<!-- <div
class="absolute right-0 top-1/2 -translate-y-1/2 w-4 h-full bg-white/40 blur-md"
></div>
<div
class="absolute right-0 top-1/2 -translate-y-1/2 w-1 h-2/3 bg-white/80 rounded-full blur-[1px] mr-1"
></div> -->
</div>
<!-- Animated Background Pattern (Subtle) -->
<div
class="absolute inset-0 opacity-[0.03] pointer-events-none"
style="
background-image: repeating-linear-gradient(
45deg,
#000 0,
#000 1px,
transparent 0,
transparent 10px
);
background-size: 20px 20px;
"
></div>
</div>
</div>
<!-- Current Content -->
<div
class="bg-slate-900/5 p-2.5 rounded-xl border border-slate-100 min-h-[44px] space-y-1.5"
>
<p
class="text-[9px] text-slate-700 font-black uppercase tracking-[0.14em] leading-relaxed whitespace-normal break-words"
>
{{ currentTaskTitle }}
</p>
</div>
<!-- Actions -->
<div class="pt-1">
<button
v-if="
store.isGenerating || store.generationState.isReconnecting
"
@click.stop="handleStop"
class="w-full py-2 text-red-500 text-[9px] font-black uppercase tracking-[0.2em] hover:bg-red-50 rounded-xl transition-colors border border-transparent hover:border-red-100 cursor-pointer"
>
Cancel Generation
</button>
<!-- Error State Actions -->
<div v-if="store.generationState.errorMsg" class="space-y-3">
<!-- <div class="p-3 bg-red-50 rounded-2xl border border-red-100">
<p
class="text-[10px] text-red-600 font-bold text-center leading-relaxed"
>
{{ store.generationState.errorMsg }}
</p>
</div> -->
<button
@click="store.initWebSocket(store.currentSessionId)"
class="w-full py-2.5 rounded-2xl bg-slate-900 text-white text-xs font-black uppercase tracking-widest hover:bg-slate-800 transition-all shadow-lg cursor-pointer"
>
Reconnect Now
</button>
</div>
</div>
</div>
<!-- Stop Confirmation Overlay -->
<div
v-if="confirmStop"
class="absolute inset-0 z-50 bg-white/95 backdrop-blur-sm flex flex-col items-center justify-center p-4 text-center animate-in fade-in zoom-in duration-300"
>
<div
class="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mb-3"
>
<UIcon
name="i-lucide-alert-triangle"
class="w-6 h-6 text-red-500"
/>
</div>
<h3 class="text-sm font-semibold text-slate-900 mb-1">
Stop Generation?
</h3>
<p class="text-xs text-slate-500 mb-4 px-2">
The AI will stop working immediately. This cannot be undone.
</p>
<!-- Delete Toggle -->
<label
class="mb-5 px-2 flex items-center gap-2 group cursor-pointer select-none justify-center"
>
<input
type="checkbox"
v-model="deleteDir"
class="w-4 h-4 rounded border-slate-300 text-red-600 focus:ring-red-500 accent-red-600"
/>
<span
class="text-[10px] font-bold text-slate-600 group-hover:text-slate-900 transition-colors uppercase tracking-wider"
>
Delete project directory
</span>
</label>
<div class="flex items-center gap-2 w-full max-w-[180px]">
<button
@click.stop="cancelStop"
class="flex-1 py-1.5 px-3 rounded-lg text-xs font-medium text-slate-600 bg-slate-100 hover:bg-slate-200 transition-colors"
>
No, Keep
</button>
<button
@click.stop="confirmCancellation"
class="flex-1 py-1.5 px-3 rounded-lg text-xs font-medium text-white bg-red-600 hover:bg-red-700 transition-colors shadow-sm"
>
Stop Now
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup>
import { ref, computed } from "vue";
import { useProjectStore } from "~/stores/projects";
import { useRoute, useRouter } from "vue-router";
const store = useProjectStore();
const router = useRouter();
const route = useRoute();
const isMinimized = ref(false);
const confirmStop = ref(false);
const deleteDir = ref(true);
const LOADING_MESSAGES = [
"If this takes too long, try blinking.",
"Loading… because perfection can’t be rushed.",
"Still loading… thanks for sticking with us.",
"This is taking longer than expected… we’re on it.",
"Not gonna lie, this is a bit slow today.",
"Loading… we promise it’s doing something.",
"Still working… computers need thinking time too.",
"This part always takes a second.",
"Loading… because perfection can’t be rushed.",
"Hang tight… we’re getting there.",
"Working on it… slower than we’d like.",
"Yep, still loading. Appreciate your patience.",
"Just a moment… or two… maybe three.",
"This is awkwardly slow… thanks for waiting.",
"Loading… we wish this were faster too.",
"Still spinning… not just for decoration.",
"Doing some behind-the-scenes magic… slowly.",
"Processing… one tiny step at a time.",
"Still loading… good things take time.",
"Taking its sweet time… hang in there.",
"Loading… we didn’t expect this either.",
"Almost there… we think.",
"Still going… thanks for not leaving.",
"Loading… because things are complicated.",
"Working… just not at lightning speed.",
"This might take a sec… or a few.",
"Still loading… we owe you one.",
"Crunching numbers… very, very carefully.",
"Taking a little longer than planned.",
"Loading… please don’t judge us.",
"Still trying its best…",
"This is taking longer than expected… we blame the internet.",
"Yep, it’s still loading.",
"Getting there… slowly but surely.",
"Processing… like a Monday morning.",
"Not stuck—just thinking really hard.",
"This is why people get coffee.",
"Still happening… promise.",
"Loading… we hear you sighing.",
"One moment… we mean it this time.",
"Taking a bit… thanks for hanging in.",
"Still working… no need to refresh (yet).",
"Almost… okay not quite yet.",
"Loading… we’ll be quick-ish.",
"It’s thinking… deeply.",
"Working… at its own pace.",
"Still loading… you’re doing great.",
"Just a sec… give or take a few secs.",
"This part’s a little slow—sorry about that.",
"Loading… we promise it’s worth it.",
"Still going… we haven’t forgotten you.",
"Almost ready… hang tight just a bit more.",
"Loading… thanks for your patience, seriously.",
];
const currentMessageIndex = ref(0);
let messageInterval = null;
const startMessageCycling = () => {
if (messageInterval) clearInterval(messageInterval);
messageInterval = setInterval(() => {
currentMessageIndex.value =
(currentMessageIndex.value + 1) % LOADING_MESSAGES.length;
}, 7000);
};
const stopMessageCycling = () => {
if (messageInterval) clearInterval(messageInterval);
messageInterval = null;
};
watch(
() => store.isGenerating,
(val) => {
if (val) startMessageCycling();
else stopMessageCycling();
},
{ immediate: true },
);
const currentStatusLabel = computed(() => {
const status = store.status?.replace("_", " ");
return status || "Initializing";
});
const currentTaskTitle = computed(() => {
// Use dynamic loading messages while generating
if (store.isGenerating) {
return LOADING_MESSAGES[currentMessageIndex.value];
}
const task = store.generationState.currentTask || "";
const isFixAttempt = /^fixing\s+scene\s+\d+\s+\(attempt\s+\d+\/\d+\)$/i.test(
task.trim(),
);
const isSceneCompleted = /^scene\s+\d+\s+completed$/i.test(task.trim());
if (isFixAttempt && store.progress < 100) {
return "Improving scene quality...";
}
if (isSceneCompleted && store.progress < 100) {
return "Rendering scenes...";
}
if (task) return task;
const status = (store.status || "").toLowerCase();
const phase = store.generationState.currentPhase;
if (status.includes("assembly") || status.includes("finaliz") || phase >= 5) {
return "Assembling final video...";
}
if (status.includes("render") || phase === 4) return "Rendering scenes...";
if (status.includes("audio") || phase === 3) return "Generating narration...";
if (status.includes("script") || phase === 2)
return "Writing scene scripts...";
if (status.includes("plan") || phase === 1) return "Planning video...";
return "Processing...";
});
const currentIcon = computed(() => {
if (store.generationState.completed) return "i-lucide-party-popper";
if (store.generationState.isReconnecting) return "i-lucide-loader-2";
const status = (store.status || "").toLowerCase();
const phase = store.generationState.currentPhase;
if (status.includes("script") || status.includes("writing") || phase === 2)
return "i-lucide-scroll-text";
if (status.includes("audio") || status.includes("synth") || phase === 3)
return "i-lucide-mic-2";
if (
status.includes("video") ||
status.includes("render") ||
status.includes("scene") ||
phase === 4
)
return "i-lucide-film";
if (status.includes("finaliz") || phase >= 5) return "i-lucide-package-check";
// Default: planning / initializing
return "i-lucide-sparkles";
});
async function handleStop() {
console.log("!!! [WIDGET] handleStop triggered !!!");
confirmStop.value = true;
}
function cancelStop() {
confirmStop.value = false;
}
async function confirmCancellation() {
console.log("!!! [WIDGET] confirmCancellation triggered !!!");
confirmStop.value = false;
try {
const result = await store.stopGeneration(undefined, deleteDir.value);
console.log(
"!!! [WIDGET] store.stopGeneration finished with result:",
result,
);
} catch (err) {
console.error("!!! [WIDGET] Error in confirmCancellation API call:", err);
}
}
async function handleViewProject() {
const fromCompleted = store.generationState.completedProjectId;
const fromCurrent = store.currentProject?.id;
const fromRoute =
route.path.startsWith("/project/") && route.params.id
? String(route.params.id)
: null;
const fromLatestCompleted =
[...store.projects].reverse().find((p) => p?.status === "completed")?.id ||
null;
const projectId =
fromCompleted || fromCurrent || fromRoute || fromLatestCompleted;
if (!projectId) {
console.error("!!! [WIDGET] Cannot redirect: No project ID available.");
return;
}
const target = `/project/${projectId}`;
if (route.path !== target) {
await router.push(target);
}
store.resetGenerationState();
}
</script>
<style scoped>
.widget-slide-enter-active,
.widget-slide-leave-active {
transition: all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.widget-slide-enter-from,
.widget-slide-leave-to {
transform: translateY(100px) scale(0.5);
opacity: 0;
}
@keyframes bounce-x {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(5px);
}
}
.animate-bounce-x {
animation: bounce-x 1s infinite;
}
@keyframes ping-slow {
0% {
transform: scale(1);
opacity: 0.8;
}
100% {
transform: scale(1.4);
opacity: 0;
}
}
.animate-ping-slow {
animation: ping-slow 2s cubic-bezier(0, 0, 0.2, 1) infinite;
}
@keyframes ping-slower {
0% {
transform: scale(1);
opacity: 0.5;
}
100% {
transform: scale(1.8);
opacity: 0;
}
}
.animate-ping-slower {
animation: ping-slower 3s cubic-bezier(0, 0, 0.2, 1) infinite;
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
.animate-float {
animation: float 2s ease-in-out infinite;
}
@keyframes spin-slow {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin-slow {
animation: spin-slow 3s linear infinite;
}
@keyframes shimmer-fast {
0% {
transform: translateX(-150%);
}
100% {
transform: translateX(450%);
}
}
.animate-shimmer-fast {
animation: shimmer-fast 1.8s infinite cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes shimmer-slow {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.animate-shimmer-slow {
animation: shimmer-slow 4s infinite ease-in-out;
}
</style>