Spaces:
Sleeping
Sleeping
| <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> | |