<template> <div class="vidinp-unique-wrapper"> <div class="container-fluid px-0"> <div class="row g-4"> <!-- ══════════════════════════════════════ LEFT COLUMN — controls ══════════════════════════════════════ --> <div class="col-lg-6"> <div class="vidinp-controls-card pt-4"> <!-- Model selector dropdown (static with Runway only) --> <div class="vidinp-form-group mb-4"> <label class="vidinp-form-label"> <i class="fas fa-microchip me-2" :style="{ color: themeColor }"></i>Model </label> <select v-model="selectedModel" class="vidinp-form-select"> <option value="runway-gen45">Runway Gen-4.5 (Image-to-Video)</option> </select> <small class="text-muted d-block mt-1"> <i class="fas fa-info-circle me-1"></i> Runway Gen-4.5 is a powerful image-to-video AI model that creates stunning videos from your images and prompts. </small> </div> <!-- Image Upload --> <div class="vidinp-form-group mb-4"> <label class="vidinp-form-label"> <i class="fas fa-images me-2" :style="{ color: themeColor }"></i> Reference Images <span class="badge ms-2" :style="{ backgroundColor: themeColor }">{{uploadedImages.length}}/6 </span> </label> <div class="vidinp-multi-upload" :class="{ 'vidinp-upload-active': isDragging }" @dragover.prevent="isDragging = true" @dragleave="isDragging = false" @drop.prevent="handleDrop"> <div class="vidinp-image-grid"> <div v-for="(img, idx) in uploadedImages" :key="idx" class="vidinp-image-item"> <img :src="img" class="vidinp-preview-thumb" /> <button @click="removeImage(idx)" class="vidinp-remove-btn" type="button"> <i class="fas fa-times"></i> </button> <div v-if="idx === 0" class="vidinp-primary-badge">Primary</div> </div> <div v-if="uploadedImages.length < 6" class="vidinp-upload-placeholder" @click="triggerFileUpload"> <i class="fas fa-plus" :style="{ color: themeColor, fontSize: '22px' }"></i> <span class="vidinp-upload-label">Add Image</span> </div> </div> <input type="file" ref="imageInput" @change="handleMultipleImages" accept="image/jpeg,image/png,image/jpg" multiple hidden /> </div> <small class="text-muted d-block mt-1"> Up to 6 images (JPG/PNG,max 10 MB each). The <strong>first image</strong> is the primary reference frame. </small> </div> <!-- Prompt --> <div class="vidinp-form-group mb-4"> <label class="vidinp-form-label"> <i class="fas fa-comment-dots me-2" :style="{ color: themeColor }"></i>Prompt / Description </label> <div class="vidinp-textarea-wrapper"> <textarea v-model="prompt" class="vidinp-textarea" rows="4" :maxlength="500" placeholder="Describe the video you want to create. The more detail, the better the result…"></textarea> <span class="vidinp-char-count">{{prompt.length}}/500</span> </div> <button class="vidinp-ai-badge mt-2" @click="enhanceWithAI" :disabled="isEnhancing || !prompt.trim()" type="button" :style="{ backgroundColor: themeColor + '18', color: themeColor }"> <i :class="isEnhancing ? 'fas fa-spinner fa-spin' : 'fas fa-magic'" class="me-1"></i>{{isEnhancing ? "Enhancing…" : "Enhance with AI"}}</button> </div> <!-- Video Ratio --> <div class="vidinp-form-group mb-4"> <label class="vidinp-form-label"> <i class="fas fa-expand-alt me-2" :style="{ color: themeColor }"></i>Aspect Ratio </label> <div class="vidinp-ratio-group"> <button v-for="ratio in videoRatios" :key="ratio.value" type="button" @click="selectedRatio = ratio.value" class="vidinp-ratio-btn" :class="{ 'vidinp-ratio-active': selectedRatio === ratio.value }" :style="selectedRatio === ratio.value
?{borderColor:themeColor,background: themeColor + "18",color: themeColor}">
<i :class="ratio.icon"></i> <span>{{ratio.label}}</span> <small class="d-block" style="font-size:10px;opacity:.7">{{ratio.hint}}</small> </button> </div> </div> <!-- Duration - Updated for Gen-4.5 (only 5 and 10 seconds) --> <div class="vidinp-form-group mb-4"> <label class="vidinp-form-label"> <i class="fas fa-clock me-2" :style="{ color: themeColor }"></i>Duration </label> <div class="vidinp-duration-group"> <button v-for="d in durations" :key="d.value" type="button" @click="selectedDuration = d.value" class="vidinp-duration-btn" :class="{ 'vidinp-duration-active': selectedDuration === d.value }" :style="selectedDuration === d.value
?{borderColor:themeColor,background: themeColor + "18",color: themeColor}">
{{d.label}}</button> </div> </div> <!-- Category (optional) --> <!-- <div class="vidinp-form-group mb-4"> <label class="vidinp-form-label"> <i class="fas fa-tag me-2" :style="{ color: themeColor }"></i>Category <small class="text-muted">(optional)</small> </label> <input v-model="category" type="text" class="vidinp-form-input" placeholder="e.g. Product, Travel, Fashion…" /> </div> --> <!-- Generate Button --> <button @click="generateVideo" type="button" class="vidinp-generate-btn" :style="{ backgroundColor: themeColor }" :disabled="isGenerating || !prompt.trim() || uploadedImages.length === 0"> <i :class="isGenerating ? 'fas fa-spinner fa-spin' : 'fas fa-play'" class="me-2"></i>{{isGenerating ? "Generating…" : "Generate Video"}}</button> <!-- Validation hint --> <div v-if="uploadedImages.length === 0" class="vidinp-hint mt-2"> <i class="fas fa-exclamation-triangle me-1 text-warning"></i> Upload at least one image to generate a video. </div> </div> </div> <!-- ══════════════════════════════════════ RIGHT COLUMN — preview / progress ══════════════════════════════════════ --> <div class="col-lg-6"> <div class="vidinp-preview-card"> <div class="vidinp-preview-header" :style="{ borderBottomColor: themeColor + '30' }"> <h5 class="mb-0"> <i class="fas fa-video me-2" :style="{ color: themeColor }"></i>Video Preview </h5> <span v-if="isGenerating" class="vidinp-live-badge" :style="{ background: themeColor }"> <i class="fas fa-circle me-1" style="font-size:8px"></i>LIVE </span> </div> <div class="vidinp-preview-body"> <!-- Empty state --> <div v-if="!isGenerating && !videoGenerated" class="vidinp-empty-state"> <div class="vidinp-empty-icon" :style="{ color: themeColor + '50' }"> <i class="fas fa-film" style="font-size:52px"></i> </div> <p class="mt-3 mb-1 fw-semibold">Your video will appear here</p> <p class="text-muted small">Fill in the form and click Generate</p> </div> <!-- Generating state with enhanced progress monitoring --> <div v-else-if="isGenerating" class="vidinp-progress-state"> <div class="vidinp-spinner-wrap"> <div class="vidinp-spinner" :style="{ borderTopColor: themeColor }"></div> </div> <h6 class="mt-3 mb-1">{{generationStatus}}</h6> <p class="text-muted small mb-3">{{generationMessage}}</p> <!-- Progress Bar --> <div class="vidinp-progress-track"> <div class="vidinp-progress-fill" :style="{ width: generationProgress + '%', backgroundColor: themeColor }"> </div> </div> <small class="text-muted">{{generationProgress}}% complete</small> <!-- Detailed Step Indicators with animated current step --> <div class="vidinp-steps mt-4"> <div v-for="(step, i) in generationSteps" :key="i" class="vidinp-step" :class="{
"vidinp-step-done": generationProgress > step.threshold,"vidinp-step-current": generationProgress > (generationSteps[i-1]?.threshold ?? 0) && generationProgress <= step.threshold }"
:style="generationProgress > step.threshold ? { color: themeColor } : {}"> <i :class="generationProgress > step.threshold 
? "fas fa-check-circle" : (generationProgress > (generationSteps[i-1]?.threshold ?? 0) && generationProgress <= step.threshold ? "fas fa-spinner fa-pulse" : "far fa-circle")"
class="me-2"></i>{{step.label}}</div> </div> <!-- Estimated time remaining --> <div class="mt-3 text-center" v-if="generationProgress > 10 && generationProgress < 90"> <small class="text-muted"> <i class="fas fa-hourglass-half me-1"></i> Estimated time remaining:{{estimatedTimeRemaining}}</small> </div> </div> <!-- Completed state --> <div v-else-if="videoGenerated && videoUrl" class="vidinp-completed-state"> <video controls :src="videoUrl" class="vidinp-video-player"></video> <div class="vidinp-video-actions mt-3"> <button class="vidinp-action-btn" :style="{ backgroundColor: themeColor, color: '#fff' }" @click="downloadVideo" type="button"> <i class="fas fa-download me-1"></i>Download </button> <button class="vidinp-action-btn vidinp-action-outline ms-2" @click="resetGeneration" type="button"> <i class="fas fa-plus me-1"></i>New Video </button> </div> </div> </div> </div> <!-- Generation history hint --> <div class="vidinp-hint-card mt-3"> <i class="fas fa-lightbulb me-2" :style="{ color: themeColor }"></i> <strong>Tip:</strong> For best results with Runway Gen-4.5,use clear,high-quality images. The first uploaded image becomes the starting frame of your video. </div> </div> </div> </div> </div> </template> <script> import axios from "axios"; import{toast}from "vue3-toastify"; export default{name: "VideoInputs",props:{campaignId:{type:[String,Number],required: true,},},emits: ["update-counts","change-section"],data(){return{// Form selectedModel: "runway-gen45",uploadedImages: [],// base64 previews uploadedImageFiles: [],// actual File objects prompt: "",category: "",selectedRatio: "16:9",selectedDuration: "5",// UPDATED: Default changed from "8" to "5" for Gen-4.5 isDragging: false,isEnhancing: false,// Generation state isGenerating: false,videoGenerated: false,videoUrl: "",generationProgress: 0,generationStatus: "Initialising…",generationMessage: "Preparing video generation with Runway Gen-4.5",currentInputId: null,startTime: null,pollInterval: null,// Static UI data videoRatios: [{value: "16:9",label: "16:9",icon: "fas fa-tv",hint: "Landscape"},{value: "9:16",label: "9:16",icon: "fas fa-mobile-alt",hint: "Portrait"},{value: "1:1",label: "1:1",icon: "fas fa-square",hint: "Square"},{value: "21:9",label: "21:9",icon: "fas fa-film",hint: "Cinema"},],// UPDATED: Durations array for Gen-4.5 - only 5 and 10 seconds supported durations: [{value: "5",label: "5 sec"},{value: "10",label: "10 sec"},],generationSteps: [{label: "Enhancing prompt with AI",threshold: 15},{label: "Uploading images to cloud",threshold: 25},{label: "Submitting to Runway Gen-4.5",threshold: 35},{label: "AI generating your video",threshold: 85},{label: "Saving & finalising",threshold: 95},],themeColor: localStorage.getItem("themeColor") || "#6f42c1",echoChannel: null,isComponentActive: true,}},computed:{estimatedTimeRemaining(){if (!this.startTime || this.generationProgress < 10) return "calculating...";const elapsed = (Date.now() - this.startTime) / 1000;// seconds const estimatedTotal = (elapsed / this.generationProgress) * 100;const remaining = Math.max(0,estimatedTotal - elapsed);if (remaining < 60) return `${Math.round(remaining)}seconds`;if (remaining < 3600) return `${Math.round(remaining / 60)}minutes`;return `${(remaining / 3600).toFixed(1)}hours`}},mounted(){this.isComponentActive = true;this.setupEchoListener();this.startPollingFallback()},beforeUnmount(){this.isComponentActive = false;this.cleanupEchoListener();this.stopPolling()},beforeDestroy(){this.isComponentActive = false;this.cleanupEchoListener();this.stopPolling()},methods:{// ── Image Upload ────────────────────────────────────────────────────── triggerFileUpload(){if (this.$refs.imageInput){this.$refs.imageInput.click()}},handleDrop(e){this.isDragging = false;const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith("image/"));this.addFiles(files)},handleMultipleImages(event){this.addFiles(Array.from(event.target.files));if (this.$refs.imageInput) this.$refs.imageInput.value = ""},addFiles(files){const slots = 6 - this.uploadedImages.length;const toAdd = files.slice(0,slots);for (const file of toAdd){if (!file.type.startsWith("image/")){toast.error(`${file.name} is not a valid image`);continue}if (file.size > 10 * 1024 * 1024){toast.error(`${file.name} exceeds 10 MB`);continue}this.uploadedImageFiles.push(file);const reader = new FileReader();reader.onload = (e) =>{if (this.isComponentActive){this.uploadedImages.push(e.target.result)}}reader.readAsDataURL(file)}},removeImage(index){this.uploadedImages.splice(index,1);this.uploadedImageFiles.splice(index,1)},// ── Prompt Enhance ──────────────────────────────────────────────────── async enhanceWithAI(){if (!this.prompt.trim()){toast.error("Enter a prompt first");return}this.isEnhancing = true;try{const token = localStorage.getItem("token");const res = await axios.post("/api/enhance-video-prompt",{prompt: this.prompt},{headers: {Authorization: `Bearer ${token}`}});if (res.data.enhanced_prompt && this.isComponentActive){this.prompt = res.data.enhanced_prompt;toast.success("Prompt enhanced with AI!")}}catch (error){console.error("Enhancement error:",error);toast.error("Enhancement failed, using original prompt")}finally{this.isEnhancing = false}},// ── WebSocket for Real-time Updates ─────────────────────────────────── setupEchoListener(){if (!window.Echo){console.warn("Laravel Echo not available — using polling fallback");return}try{this.echoChannel = window.Echo.channel("social-videos") .listen("VideoGenerationStarted",(data) => {if (!this.isUserEvent(data)) return; if (!this.isComponentActive) return; console.log("Video generation started:",data); this.generationProgress = data.progress || 0; this.generationStatus = "Processing…"; this.updateProgressMessage(this.generationProgress);}) .listen("VideoGenerationCompleted",(data) => {if (!this.isUserEvent(data)) return; if (!this.isComponentActive) return; console.log("Video generation completed:",data); this.isGenerating = false; this.videoGenerated = true; this.videoUrl = data.video_url || data.generation?.video_url || ""; this.generationProgress = 100; toast.success("🎉 Video generated successfully with Runway Gen-4.5!"); this.$emit("update-counts"); this.stopPolling();}) .listen("VideoGenerationFailed",(data) => {if (!this.isUserEvent(data)) return; if (!this.isComponentActive) return; console.error("Video generation failed:",data); this.isGenerating = false; toast.error(data.error || "Video generation failed"); this.stopPolling();})}catch (error){console.error("Failed to setup Echo listener:",error)}},updateProgressMessage(progress){if (progress < 15){this.generationMessage = "Enhancing your prompt with AI…"}else if (progress < 25){this.generationMessage = "Uploading images to cloud storage…"}else if (progress < 35){this.generationMessage = "Submitting to Runway Gen-4.5 API…"}else if (progress < 85){this.generationMessage = "Runway AI is generating your video…"}else if (progress < 95){this.generationMessage = "Saving video to cloud…"}else{this.generationMessage = "Finalising…"}},isUserEvent(data){const userId = localStorage.getItem("user_id");return String(data.user_id) === String(userId)},cleanupEchoListener(){if (this.echoChannel){try{this.echoChannel.stopListening();window.Echo.leaveChannel("social-videos")}catch (e){console.warn("Error cleaning up Echo channel:",e)}this.echoChannel = null}},// ── Polling Fallback (when WebSockets aren't available) ───────────────
startPollingFallback() {// Check every 3 seconds if we have an active generation if (this.pollInterval) {clearInterval(this.pollInterval);} this.pollInterval = setInterval(async () => {if (!this.isGenerating || !this.currentInputId) {return;} await this.pollGenerationStatus();},3000);},async pollGenerationStatus() {if (!this.currentInputId) return; try {const token = localStorage.getItem("token"); const response = await axios.get(`/api/social-video-inputs/${this.currentInputId}/status`,{headers: {Authorization: `Bearer ${token}`}}); if (response.data.status === "completed") {this.isGenerating = false; this.videoGenerated = true; this.videoUrl = response.data.video_url; this.generationProgress = 100; toast.success("🎉 Video generated successfully with Runway Gen-4.5!"); this.$emit("update-counts"); this.currentInputId = null;} else if (response.data.status === "failed") {this.isGenerating = false; toast.error(response.data.error || "Video generation failed"); this.currentInputId = null;} else {// Update progress const newProgress = response.data.progress || 0; if (newProgress > this.generationProgress) {this.generationProgress = newProgress; this.updateProgressMessage(this.generationProgress);}}} catch (error) {console.error("Polling error:",error);}},stopPolling() {if (this.pollInterval) {clearInterval(this.pollInterval); this.pollInterval = null;}},// ── Generate Video ──────────────────────────────────────────────────── async generateVideo() {// Validation if (this.uploadedImages.length === 0) {toast.error("Upload at least one image"); return;} if (!this.prompt.trim()) {toast.error("Enter a prompt"); return;} this.isGenerating = true; this.videoGenerated = false; this.generationProgress = 0; this.generationStatus = "Starting…"; this.generationMessage = "Preparing your request for Runway Gen-4.5"; this.startTime = Date.now(); // Map ratio → resolution string expected by backend const resolutionMap = {"16:9": "1280x720","9:16": "720x1280","1:1": "1080x1080","21:9": "1920x820",}; const formData = new FormData(); formData.append("campaign_id",this.campaignId); formData.append("title",this.prompt); formData.append("description",this.prompt); formData.append("duration",this.selectedDuration); formData.append("model",this.selectedModel); formData.append("resolution",resolutionMap[this.selectedRatio] || "1280x720"); formData.append("category",this.category); // Always send images for Runway for (const file of this.uploadedImageFiles) {formData.append("images[]",file);} try {const token = localStorage.getItem("token"); const res = await axios.post("/api/social-video-inputs",formData,{headers: {Authorization: `Bearer ${token}`,"Content-Type": "multipart/form-data"},}); if (res.data.status === "success" && this.isComponentActive) {toast.info("🎬 Video generation started with Runway Gen-4.5!"); this.currentInputId = res.data.data?.id; this.generationStatus = "Queued"; this.generationMessage = "Your video is in the queue"; this.generationProgress = 5; // Reset form for next input this.resetForm(); // Start polling if WebSocket isn't updating
setTimeout(() => {if (this.isGenerating && this.generationProgress < 10) {this.startPollingFallback();}},5000);} else {throw new Error(res.data.message || "Failed to start generation");}} catch (error) {console.error("Generation error:",error); const msg = error.response?.data?.message || error.message || "Generation failed"; toast.error(msg); this.isGenerating = false; this.startTime = null;}},// ── Helpers ─────────────────────────────────────────────────────────── resetForm() {this.prompt = ""; this.category = ""; this.uploadedImages = []; this.uploadedImageFiles = [];},resetGeneration() {this.videoGenerated = false; this.videoUrl = ""; this.generationProgress = 0; this.isGenerating = false; this.currentInputId = null; this.startTime = null; this.resetForm(); this.stopPolling();},downloadVideo() {if (this.videoUrl) {const a = document.createElement("a"); a.href = this.videoUrl; a.target = "_blank"; a.download = `runway-gen45-video-${Date.now()}.mp4`; a.click();}},},}; </script> <style scoped> .vidinp-unique-wrapper {width: 70%; margin-left: 190px;} .vidinp-controls-card,.vidinp-preview-card {background: #fff; border-radius: 16px; box-shadow: 0 2px 14px rgba(0,0,0,.07);} .vidinp-controls-card {padding: 2rem;} .vidinp-preview-card {overflow: hidden; position: sticky; top: 20px;} .vidinp-preview-header {display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; border-bottom: 2px solid #f0f0f0;} .vidinp-preview-body {padding: 1.5rem; min-height: 340px;} .vidinp-hint-card {background: #fffbe6; border: 1px solid #ffe58f; border-radius: 10px; padding: .85rem 1rem; font-size: .875rem;} .vidinp-form-label {font-weight: 600; font-size: .92rem; color: #1a1a2e; display: block; margin-bottom: .5rem;} .vidinp-form-select,.vidinp-form-input {width: 100%; padding: .7rem .9rem; border-radius: 10px; border: 1.5px solid #dee2e6; background: #fff; font-size: .9rem; transition: border-color .2s; outline: none;} .vidinp-form-select:focus,.vidinp-form-input:focus {border-color: #6f42c1; box-shadow: 0 0 0 3px rgba(111,66,193,.1);} .vidinp-multi-upload {border: 2px dashed #dee2e6; border-radius: 14px; padding: 1rem; transition: border-color .2s,background .2s;} .vidinp-upload-active {border-color: #6f42c1; background: rgba(111,66,193,.04);} .vidinp-image-grid {display: grid; grid-template-columns: repeat(auto-fill,minmax(88px,1fr)); gap: 10px;} .vidinp-image-item {position: relative; aspect-ratio: 1; border-radius: 8px; overflow: visible;} .vidinp-preview-thumb {width: 100%; height: 100%; object-fit: cover; border-radius: 8px; display: block;} .vidinp-remove-btn {position: absolute; top: -8px; right: -8px; width: 22px; height: 22px; border-radius: 50%; background: #fff; border: 1px solid #ddd; cursor: pointer; font-size: 10px; display: flex; align-items: center; justify-content: center; box-shadow: 0 1px 4px rgba(0,0,0,.15); transition: background .15s,color .15s; z-index: 1;} .vidinp-remove-btn:hover {background: #dc3545; color: #fff; border-color: #dc3545;} .vidinp-primary-badge {position: absolute; bottom: 4px; left: 4px; background: rgba(0,0,0,.55); color: #fff; font-size: 9px; padding: 1px 5px; border-radius: 4px; pointer-events: none;} .vidinp-upload-placeholder {aspect-ratio: 1; border: 2px dashed #d0d0d0; border-radius: 8px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; cursor: pointer; transition: border-color .2s,background .2s;} .vidinp-upload-placeholder:hover {border-color: #6f42c1; background: rgba(111,66,193,.04);} .vidinp-upload-label {font-size: 11px; color: #888;} .vidinp-textarea-wrapper {position: relative;} .vidinp-textarea {width: 100%; padding: .75rem .9rem; border-radius: 10px; border: 1.5px solid #dee2e6; resize: vertical; font-size: .9rem; transition: border-color .2s; outline: none;} .vidinp-textarea:focus {border-color: #6f42c1; box-shadow: 0 0 0 3px rgba(111,66,193,.1);} .vidinp-char-count {position: absolute; bottom: 8px; right: 10px; font-size: 11px; color: #aaa; pointer-events: none;} .vidinp-ai-badge {display: inline-flex; align-items: center; padding: .3rem 1rem; border-radius: 30px; font-size: .8rem; font-weight: 500; cursor: pointer; border: none; transition: opacity .2s;} .vidinp-ai-badge:disabled {opacity: .5; cursor: not-allowed;} .vidinp-ratio-group {display: flex; gap: 8px; flex-wrap: wrap;} .vidinp-ratio-btn {padding: 8px 10px; border: 1.5px solid #dee2e6; border-radius: 6px; background: transparent; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 3px; font-size: .85rem; font-weight: 500; transition: all .15s; color: #555; min-width: 68px;} .vidinp-ratio-btn:hover {border-color: #aaa; background: #f8f9fa;} .vidinp-ratio-active {font-weight: 600;} .vidinp-duration-group {display: flex; gap: 8px;} .vidinp-duration-btn {padding: 3px 8px; border: 1.5px solid #dee2e6; border-radius: 6px; background: transparent; cursor: pointer; font-size: .88rem; font-weight: 500; transition: all .15s; color: #555;} .vidinp-duration-btn:hover {border-color: #aaa;} .vidinp-duration-active {font-weight: 600;} .vidinp-generate-btn {width: 100%; padding: 8px; border: none; border-radius: 5px; color: #fff; font-weight: 700; font-size: 1rem; cursor: pointer; transition: opacity .2s,transform .1s;} .vidinp-generate-btn:hover:not(:disabled) {opacity: .9; transform: translateY(-1px);} .vidinp-generate-btn:disabled {opacity: .55; cursor: not-allowed; transform: none;} .vidinp-hint {font-size: .82rem; color: #6c757d;} .vidinp-empty-state,.vidinp-progress-state,.vidinp-completed-state {display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; min-height: 280px;} .vidinp-empty-state {padding: 2rem;} .vidinp-live-badge {font-size: 11px; font-weight: 700; color: #fff; padding: 2px 10px; border-radius: 30px; animation: vidinp-pulse 1.5s infinite;} @keyframes vidinp-pulse {0%,100% {opacity:1} 50% {opacity:.6}} .vidinp-spinner-wrap {width: 48px; height: 48px; display: flex; align-items: center; justify-content: center;} .vidinp-spinner {width: 44px; height: 44px; border: 4px solid #e9ecef; border-top: 4px solid #6f42c1; border-radius: 50%; animation: vidinp-spin .8s linear infinite;} @keyframes vidinp-spin {to {transform: rotate(360deg);}} .vidinp-progress-track {width: 100%; height: 8px; background: #e9ecef; border-radius: 30px; overflow: hidden;} .vidinp-progress-fill {height: 100%; border-radius: 30px; transition: width .5s ease;} .vidinp-steps {width: 100%; text-align: left;} .vidinp-step {font-size: .82rem; color: #aaa; padding: 3px 0; transition: color .3s;} .vidinp-step-done {color: inherit; font-weight: 500;} .vidinp-step-current {color: #555; font-weight: 600;} .vidinp-video-player {width: 100%; border-radius: 10px; max-height: 380px; background: #000;} .vidinp-video-actions {display: flex; gap: 8px;} .vidinp-action-btn {padding: .45rem 1.1rem; border: none; border-radius: 8px; font-size: .88rem; font-weight: 600; cursor: pointer; transition: opacity .15s;} .vidinp-action-btn:hover {opacity: .85;} .vidinp-action-outline {background: transparent; border: 1.5px solid #dee2e6; color: #555;} .vidinp-action-outline:hover {border-color: #aaa;} @media (max-width: 992px) {.vidinp-controls-card {padding: 1.25rem;} .vidinp-preview-card {position: relative; top: 0; margin-top: 1rem;}} @media (max-width: 576px) {.vidinp-ratio-group {gap: 6px;} .vidinp-ratio-btn {min-width: 58px; padding: .4rem .7rem; font-size: .8rem;} .vidinp-duration-group {gap: 6px;}} </style> <template> <!-- ROOT CLASS RENAMED from "main-content" to "vg-content" This fixes the bug where .main-content styles from this component were bleeding into the parent page's .main-content div,
causing VideoInputs content to appear under Social Posts sections. --> <div class="vg-content"> <div class="container views"> <!-- View Toggle Buttons - Only show if there are generations --> <div v-if="generations.length > 0" class="d-flex justify-content-end gap-2 mb-3 pt-3"> <button class="btn btn-sm" :style="{ backgroundColor: themeColor, color: '#fff' }" @click="viewMode = 'table'"> <i class="fas fa-list-ul"></i> </button> <button class="btn btn-sm" :style="{ backgroundColor: themeColor, color: '#fff' }" @click="viewMode = 'cards'"> <i class="fas fa-th-large"></i> </button> </div> <!-- Content when generations exist --> <template v-if="generations.length > 0"> <!-- Table View --> <table v-if="viewMode === 'table'" class="table table-hover align-middle"> <thead> <tr> <th> <input type="checkbox" v-model="selectAll" @change="toggleSelectAll" :style="{ accentColor: themeColor }" /> </th> <th><i class="fas fa-keyboard" :style="{ color: themeColor }"></i> Prompt</th> <th>Status</th> <th>Actions</th> <th><i class="fas fa-clock" :style="{ color: themeColor }"></i> Date</th> </tr> </thead> <tbody> <tr v-for="generation in generations" :key="generation.id"> <td> <input type="checkbox" v-model="selected" :value="generation.id" :style="{ accentColor: themeColor }" /> </td> <td> <i class="fas fa-video" :style="{ color: themeColor }"></i> {{limitWords(generation.post_content || generation.video_input?.title || "No Title",6)}} <small class="text-muted fst-italic d-block">{{formatDate(generation.created_at)}}</small> </td> <td class="text-center"> <span class="badge" :style="getStatusStyle(generation.status)"> {{getStatusText(generation.status)}} </span> </td> <td class="text-center"> <div class="d-flex gap-2 justify-content-center"> <a href="#" class="text-decoration-none" @click.prevent="
publishVideo(generation.id,generation.video_input_id,generation.campaign_id,generation.post_content,generation.video_url,)" :style="{color: themeColor}">
<template v-if="loadingId === generation.id"> <i class="fas fa-spinner fa-pulse"></i> </template> <template v-else> <i class="fas fa-paper-plane"></i> Publish </template> </a> <a href="#" class="text-decoration-none" @click.prevent="openDetailsModal(generation)" :style="{ color: themeColor }"> <i class="fas fa-external-link-alt"></i> Open </a> </div> </td> <td> {{formatDate(generation.created_at)}} </td> </tr> </tbody> </table> <!-- Card View --> <div v-else class="row g-3"> <div class="col-12 col-md-4 col-lg-3" v-for="generation in generations" :key="generation.id"> <div class="card h-100 shadow-sm"> <video v-if="generation.video_url" :src="generation.video_url" class="card-img-top" style="height: 180px; object-fit: cover;" muted></video> <div v-else class="card-img-top bg-light d-flex align-items-center justify-content-center" style="height: 180px;"> <i class="fas fa-video" :style="{ fontSize: '48px', color: themeColor + '60' }"></i> </div> <div class="card-body d-flex flex-column"> <p class="card-text mb-1">{{limitWords(generation.post_content,15)}}</p> <div class="d-flex gap-2 mt-auto"> <button class="btn btn-sm flex-grow-1" :style="{ backgroundColor: themeColor, color: '#fff' }" @click.prevent="publishVideo(
generation.id,generation.video_input_id,generation.campaign_id,generation.post_content,generation.video_url,)">
<template v-if="loadingId === generation.id"> <i class="fas fa-spinner fa-pulse"></i> </template> <template v-else> <i class="fas fa-paper-plane"></i> Publish </template> </button> <button class="btn btn-sm" :style="{ backgroundColor: themeColor, color: '#fff', opacity: 0.8 }" @click.prevent="openDetailsModal(generation)"> <i class="fas fa-external-link-alt"></i> Open </button> </div> <div class="form-check mt-2"> <input type="checkbox" v-model="selected" :value="generation.id" :style="{ accentColor: themeColor }" class="form-check-input" /> <label class="form-check-label" style="font-size:0.75rem;">Select</label> </div> </div> <div class="card-footer bg-light p-2" style="font-size:0.75rem;"> <i class="fas fa-clock me-1" :style="{ color: themeColor }"></i> {{formatDate(generation.created_at)}} </div> </div> </div> </div> </template> <!-- Empty State --> <div v-else class="text-center my-5"> <img src="https://cdn-icons-png.flaticon.com/512/4076/4076549.png" alt="Empty" style="max-width: 150px; opacity: 0.6" /> <h5 class="mt-3 text-muted">No videos found</h5> <p class="text-secondary">Start by generating some videos to see them here.</p> </div> <!-- Details Modal --> <div class="modal fade" id="detailsModal" tabindex="-1" ref="detailsModalRef"> <div class="modal-dialog modal-lg modal-dialog-scrollable"> <div class="modal-content"> <div class="modal-header" :style="{ borderBottomColor: themeColor }"> <h5 class="modal-title"> <i class="fas fa-video me-2" :style="{ color: themeColor }"></i> Video Details </h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body" v-if="selectedGeneration"> <div class="mb-4"> <video v-if="selectedGeneration.video_url" controls class="w-100 rounded" :src="selectedGeneration.video_url"></video> </div> <div class="mb-4"> <h6 :style="{ color: themeColor }">Prompt</h6> <div class="p-3 bg-light rounded"> {{selectedGeneration.post_content}} </div> </div> <div class="mb-4" v-if="selectedGeneration.video_input"> <h6 :style="{ color: themeColor }">Source Details</h6> <table class="table table-bordered table-sm"> <tbody> <tr> <th>Title</th> <td>{{selectedGeneration.video_input.title || "N/A"}}</td> </tr> <tr v-if="selectedGeneration.video_input.duration"> <th>Duration</th> <td>{{selectedGeneration.video_input.duration}} seconds</td> </tr> <tr v-if="selectedGeneration.video_input.resolution"> <th>Resolution</th> <td>{{selectedGeneration.video_input.resolution}}</td> </tr> </tbody> </table> </div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> <button type="button" class="btn" :style="{ backgroundColor: themeColor, color: '#fff' }" @click="publishFromModal" :disabled="modalPublishing"> <i v-if="modalPublishing" class="fas fa-spinner fa-pulse me-1"></i> <i v-else class="fas fa-paper-plane me-1"></i> {{modalPublishing ? "Publishing..." : "Publish Video"}} </button> </div> </div> </div> </div> <!-- Schedule Publish Modal --> <div class="modal fade" id="scheduleModal" tabindex="-1" ref="scheduleModalRef"> <div class="modal-dialog"> <div class="modal-content p-3"> <div class="modal-header"> <h5 class="modal-title">Schedule Video Publication</h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body"> <label class="form-label fw-bold">Select Integration</label> <select class="form-control mb-3" v-model="selectedIntegrationId"> <option disabled value="">-- Select Platform --</option> <option v-for="item in userIntegrations" :key="item.id" :value="item.id"> {{item.integration?.name || item.integration_name}} </option> </select> <label class="form-label fw-bold">Select Date & Time</label> <input type="datetime-local" class="form-control" v-model="scheduleDateTime"> </div> <div class="modal-footer"> <button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button class="btn" :style="{ backgroundColor: themeColor, color: '#fff' }" @click="confirmSchedule">Confirm Schedule</button> </div> </div> </div> </div> <!-- Bottom Action Bar --> <div v-if="selected.length && generations.length > 0" class="vg-action-bar"> <button class="btn vg-action-btn" @click="handleReturn"> <i class="fas fa-undo"></i> Return </button> <button class="btn vg-action-btn" @click="handlePublish"> <i class="fas fa-upload"></i> Publication </button> </div> </div> </div> </template> <script> import {toast} from "vue3-toastify";import axios from "axios";import moment from "moment";export default{name: "VideoGeneration",data(){return{generations:[],selected: [],selectAll: false,themeColor: "#6f42c1",loadingId: null,viewMode: "cards",userIntegrations: [],selectedGeneration: null,modalPublishing: false,selectedIntegrationId: "",scheduleDateTime: "",scheduleModal: null,detailsModal: null,currentPublishingItem: null}},mounted(){const storedTheme = localStorage.getItem("themeColor");if (storedTheme){this.themeColor = storedTheme}this.getVideoGenerations();this.fetchUserIntegrations();const scheduleModalEl = document.getElementById("scheduleModal");if (scheduleModalEl){this.scheduleModal = new bootstrap.Modal(scheduleModalEl)}const detailsModalEl = document.getElementById("detailsModal");if (detailsModalEl){this.detailsModal = new bootstrap.Modal(detailsModalEl)}if (window.Echo){const userId = localStorage.getItem("user_id");window.Echo.private(`social-videos.${userId}`) .listen("VideoGenerationCompleted",(data) => {this.getVideoGenerations(); this.$emit("update-counts"); toast.success("Video generation completed!");})}},methods:{getStatusText(status){const statusMap ={"pending": "Pending","processing": "Processing","completed": "Completed","failed": "Failed","scheduled": "Scheduled"}return statusMap[status] || status},getStatusStyle(status){const styles ={"completed":{backgroundColor:"#28a745",color: "#fff"},"processing":{backgroundColor:"#ffc107",color: "#000"},"pending":{backgroundColor:"#6c757d",color: "#fff"},"failed":{backgroundColor:"#dc3545",color: "#fff"},"scheduled":{backgroundColor:"#17a2b8",color: "#fff"}}return styles[status] || styles["pending"]},getVideoGenerations(){const campaignId = localStorage.getItem("selectedCampaignId");axios .get("/api/social-video-generations",{params: {campaign_id: campaignId},headers: {Authorization: `Bearer ${localStorage.getItem("token")}`},}) .then((response) => {this.generations = response.data.data || response.data;}) .catch(() => toast.error("Failed to fetch video generations."))},limitWords(text,limit){if (!text) return "";const words = text.split(" ");return words.length > limit ? words.slice(0,limit).join(" ") + "..." : text},formatDate(date){return moment(date).format("D MMM YYYY")},toggleSelectAll(){if (this.selectAll){this.selected = this.generations.map(g => g.id)}else{this.selected = []}},openDetailsModal(generation){this.selectedGeneration = generation;if (this.detailsModal){this.detailsModal.show()}},publishFromModal(){if (this.selectedGeneration){this.publishVideo(this.selectedGeneration.id,this.selectedGeneration.video_input_id,this.selectedGeneration.campaign_id,this.selectedGeneration.post_content,this.selectedGeneration.video_url,true)}},publishVideo(id,inputId,campaignId,postContent,videoUrl,closeModal = false){this.currentPublishingItem ={id,inputId,campaignId,postContent,videoUrl}this.scheduleDateTime = "";this.selectedIntegrationId = "";if (closeModal && this.detailsModal){this.detailsModal.hide()}if (this.scheduleModal){this.scheduleModal.show()}},async confirmSchedule(){if (!this.selectedIntegrationId){toast.error("Please select an Integration.");return}const item = this.currentPublishingItem;try{const userId = localStorage.getItem("userId") || localStorage.getItem("user_id");const token = localStorage.getItem("token");const payload ={user_id:parseInt(userId),video_generation_id: item.id,campaign_id: item.campaignId,status: this.scheduleDateTime ? "scheduled" : "pending",schedule_time: this.scheduleDateTime || null,post_content: item.postContent,video_url: item.videoUrl,social_integrations_id: this.selectedIntegrationId,}console.log("📤 Publishing payload:",payload);const response = await axios.post("/api/social-video-publications",payload,{headers: {Authorization: `Bearer ${token}`}});console.log("✅ Publication response:",response.data);if (response.data.status === "success"){toast.success(response.data.message || "Video scheduled successfully.");if (this.scheduleModal){this.scheduleModal.hide()}this.$emit("update-counts");this.$emit("change-section","videoPublicationsSection");// Remove from generations list this.generations = this.generations.filter(g => g.id !== item.id)}else{toast.error(response.data.message || "Failed to schedule video.")}}catch (error){console.error("❌ Error scheduling video:",error.response?.data || error);toast.error(error.response?.data?.message || "Failed to schedule video.")}},async fetchUserIntegrations(){try{const token = localStorage.getItem("token");const response = await axios.get("/api/social-integrationes",{headers: {Authorization: `Bearer ${token}`},});this.userIntegrations = response.data.data.filter(item => item.status === "active")}catch (error){console.error("Error fetching integrations:",error)}},async handlePublish(){if (!this.selected.length){toast.info("Please select at least one video.");return}for (const id of this.selected){const gen = this.generations.find(g => g.id === id);if (gen){await this.publishVideo(gen.id,gen.video_input_id,gen.campaign_id,gen.post_content,gen.video_url)}}this.selected = [];this.selectAll = false},handleReturn(){toast.info("Return action clicked")}}}</script> <style scoped> .views{margin-top:60px}.vg-content{margin-left:185px;margin-top:-7%;max-width:calc(100% - 10px);margin-right:auto;padding:1rem;width:80%}@media (max-width: 750px){.vg-content{margin-left:0!important;margin-top:0!important;padding:1rem .5rem;width:100%!important;max-width:100%!important}.vg-action-bar{flex-direction:column;align-items:stretch;padding:.75rem;gap:8px;left:10px;right:10px;transform:none}.vg-action-btn{width:100%;justify-content:center}}.vg-action-bar{position:fixed;bottom:10px;left:50%;transform:translate(-50%);background-color:#fff;padding:.75rem 1rem;border-radius:10px;box-shadow:0 0 10px #00000026;display:flex;flex-wrap:wrap;justify-content:center;gap:10px;z-index:1050;max-width:90%}.vg-action-btn{background-color:var(--theme-color);color:#fff;border:none;padding:.5rem 1.25rem;font-weight:500;border-radius:5px;transition:.3s}.vg-action-btn:hover{opacity:.9}.card{transition:transform .2s ease,box-shadow .2s ease}.card:hover{transform:translateY(-4px);box-shadow:0 .5rem 1rem #00000026!important}</style> please generate the advertising reel of these room in the pictures attached with the prompt that attracts the customers the company name is "Hayat Hotels"}}
