| |
| |
| |
| |
| |
| 'use strict'; |
|
|
| |
| |
| |
| const BACKEND_URL = 'api/process'; |
|
|
| const LOADING_MESSAGES = [ |
| 'Connecting to AI engine...', |
| 'Fetching video metadata...', |
| 'Transcribing audio...', |
| 'Analyzing viral hooks...', |
| 'Scoring engagement moments...', |
| 'Identifying key highlights...', |
| 'Clipping best segments...', |
| 'Formatting to 9:16 vertical...', |
| 'Rendering your shorts...', |
| 'Almost there...', |
| ]; |
|
|
| const STEPS = [ |
| { id: 'step-1', label: 'Fetching video metadata', progress: 25 }, |
| { id: 'step-2', label: 'Analyzing viral hooks', progress: 50 }, |
| { id: 'step-3', label: 'Clipping & formatting', progress: 75 }, |
| { id: 'step-4', label: 'Rendering shorts', progress: 95 }, |
| ]; |
|
|
| |
| const screens = { |
| input: document.getElementById('screen-input'), |
| loading: document.getElementById('screen-loading'), |
| results: document.getElementById('screen-results'), |
| error: document.getElementById('screen-error'), |
| }; |
|
|
| const urlInput = document.getElementById('youtube-url-input'); |
| const generateBtn = document.getElementById('generate-btn'); |
| const loadingMsg = document.getElementById('loading-message'); |
| const progressBar = document.getElementById('progress-bar'); |
| const resultsGallery = document.getElementById('results-gallery'); |
| const resultsCount = document.getElementById('results-count'); |
| const newVideoBtn = document.getElementById('new-video-btn'); |
| const retryBtn = document.getElementById('retry-btn'); |
| const errorMessage = document.getElementById('error-message'); |
|
|
| |
| const captionsToggle = document.getElementById('captions-toggle'); |
| const captionStyleSelect = document.getElementById('caption-style-select'); |
| const captionStyleWrapper = document.getElementById('caption-style-wrapper'); |
| const headlineInput = document.getElementById('headline-input'); |
| const ctaInput = document.getElementById('cta-input'); |
|
|
| |
| const reframeToggle = document.getElementById('reframe-toggle'); |
| const progressBarToggle = document.getElementById('progress-bar-toggle'); |
| const vibeSelect = document.getElementById('vibe-select'); |
|
|
| |
| const retentionModeToggle = document.getElementById('retention-mode-toggle'); |
| const colorModeSelect = document.getElementById('color-mode-select'); |
| const watermarkInput = document.getElementById('watermark-input'); |
| const safeZoneToggle = document.getElementById('safe-zone-toggle'); |
| const durationRangeSelect = document.getElementById('duration-range-select'); |
| const retentionToggleBtn = document.getElementById('retention-toggle-btn'); |
| const retentionBody = document.getElementById('retention-body'); |
|
|
| |
| let loadingTimer = null; |
| let messageInterval = null; |
| let stepIndex = 0; |
| let msgIndex = 0; |
| let savedUrl = ''; |
| let clipsCount = null; |
|
|
| |
| function showScreen(name) { |
| Object.values(screens).forEach(s => s.classList.remove('active')); |
| const target = screens[name]; |
| if (target) { |
| target.classList.add('active'); |
| } |
| } |
|
|
| |
| function startLoadingAnimation() { |
| |
| progressBar.style.width = '0%'; |
| stepIndex = 0; |
| msgIndex = 0; |
| STEPS.forEach(s => { |
| const el = document.getElementById(s.id); |
| el.classList.remove('active', 'done'); |
| }); |
|
|
| |
| loadingMsg.textContent = LOADING_MESSAGES[0]; |
| messageInterval = setInterval(() => { |
| msgIndex = (msgIndex + 1) % LOADING_MESSAGES.length; |
| loadingMsg.style.opacity = '0'; |
| setTimeout(() => { |
| loadingMsg.textContent = LOADING_MESSAGES[msgIndex]; |
| loadingMsg.style.opacity = '1'; |
| }, 250); |
| }, 2200); |
|
|
| |
| advanceStep(); |
| } |
|
|
| function advanceStep() { |
| if (stepIndex >= STEPS.length) return; |
| const step = STEPS[stepIndex]; |
| const stepEl = document.getElementById(step.id); |
|
|
| |
| if (stepIndex > 0) { |
| const prevEl = document.getElementById(STEPS[stepIndex - 1].id); |
| prevEl.classList.remove('active'); |
| prevEl.classList.add('done'); |
| } |
|
|
| stepEl.classList.add('active'); |
| progressBar.style.width = step.progress + '%'; |
|
|
| stepIndex++; |
| if (stepIndex < STEPS.length) { |
| loadingTimer = setTimeout(advanceStep, 3500); |
| } |
| } |
|
|
| function stopLoadingAnimation() { |
| clearInterval(messageInterval); |
| clearTimeout(loadingTimer); |
| messageInterval = null; |
| loadingTimer = null; |
| } |
|
|
| function completeAllSteps() { |
| STEPS.forEach(s => { |
| const el = document.getElementById(s.id); |
| el.classList.remove('active'); |
| el.classList.add('done'); |
| }); |
| progressBar.style.width = '100%'; |
| } |
|
|
| |
| async function callBackend(youtubeUrl, { onTotal, onClip }) { |
| const modeEl = document.querySelector('input[name="crop-mode"]:checked'); |
| const mode = modeEl ? modeEl.value : 'fill'; |
|
|
| const payload = { |
| youtubeUrl, |
| mode, |
| captions: captionsToggle.checked, |
| captionStyle: captionStyleSelect ? captionStyleSelect.value : 'mrbeast', |
| headline: headlineInput.value.trim(), |
| cta: ctaInput.value.trim(), |
| reframe: reframeToggle.checked, |
| progressBar: progressBarToggle.checked, |
| vibe: vibeSelect.value, |
| |
| retentionMode: retentionModeToggle ? retentionModeToggle.checked : false, |
| colorMode: colorModeSelect ? colorModeSelect.value : 'off', |
| watermarkText: watermarkInput ? watermarkInput.value.trim() : '', |
| safeZone: safeZoneToggle ? safeZoneToggle.checked : false, |
| durationRange: durationRangeSelect ? durationRangeSelect.value : 'auto', |
| }; |
|
|
| if (clipsCount !== null) payload.clips = clipsCount; |
|
|
| let response; |
| try { |
| response = await fetch(BACKEND_URL, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(payload), |
| }); |
| } catch (e) { |
| console.error('[ClipperApp] Network error:', e); |
| throw new Error( |
| 'Cannot reach the local backend. Make sure start_backend.bat is running on port 5000.' |
| ); |
| } |
|
|
| const reader = response.body.getReader(); |
| const decoder = new TextDecoder(); |
| let buffer = ''; |
|
|
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
|
|
| buffer += decoder.decode(value, { stream: true }); |
| const lines = buffer.split('\n'); |
| buffer = lines.pop(); |
|
|
| for (const line of lines) { |
| if (!line.trim()) continue; |
| let msg; |
| try { msg = JSON.parse(line); } catch { continue; } |
|
|
| if (msg.error) throw new Error(msg.error); |
| if (msg.total !== undefined) await onTotal(msg.total); |
| if (msg.clip !== undefined) await onClip(msg.clip, msg.index, msg); |
| |
| } |
| } |
| } |
|
|
| |
| function renderGallery(videoUrls) { |
| resultsGallery.innerHTML = ''; |
| resultsCount.textContent = videoUrls.length; |
|
|
| videoUrls.forEach((url, i) => { |
| const card = createVideoCard(url, i + 1); |
| resultsGallery.appendChild(card); |
| }); |
| } |
|
|
| function createVideoCard(url, index, retentionData = {}) { |
| const { viral_analysis = null, pacing = null, structure = null, triggers = null } = retentionData; |
| const card = document.createElement('article'); |
| card.className = 'video-card'; |
| card.style.animationDelay = `${(index - 1) * 0.08}s`; |
| card.setAttribute('role', 'listitem'); |
|
|
| |
| if (viral_analysis) { |
| const score = viral_analysis.final_score || 0; |
| const scorePct = Math.min(100, Math.max(0, score * 100)); |
| const scoreColorClass = score > 0.75 ? 'score-green' : score >= 0.5 ? 'score-amber' : 'score-red'; |
| |
| const scoreBar = document.createElement('div'); |
| scoreBar.className = 'viral-score-container'; |
| scoreBar.innerHTML = `<div class="viral-score-fill ${scoreColorClass}" style="width: ${scorePct}%"></div>`; |
| card.appendChild(scoreBar); |
|
|
| |
| const signalsRow = document.createElement('div'); |
| signalsRow.className = 'viral-signals-row'; |
|
|
| const sigs = [ |
| { label: 'YT Heat', val: viral_analysis.heatmap_score, cls: 'badge-heat', id: 'heatmap' }, |
| { label: 'Energy', val: viral_analysis.energy_score, cls: 'badge-energy', id: 'energy' }, |
| { label: 'Text', val: viral_analysis.transcript_score, cls: 'badge-text', id: 'transcript' } |
| ]; |
|
|
| sigs.forEach(s => { |
| const badge = document.createElement('span'); |
| badge.className = `viral-badge ${s.cls}`; |
| badge.textContent = `${s.label}: ${s.val.toFixed(2)}`; |
| |
| |
| if (viral_analysis.top_signal === s.id) { |
| const topTag = document.createElement('span'); |
| topTag.className = 'top-signal-tag'; |
| topTag.textContent = 'TOP'; |
| badge.appendChild(topTag); |
| } |
| signalsRow.appendChild(badge); |
| }); |
| card.appendChild(signalsRow); |
| } |
|
|
| const videoWrapper = document.createElement('div'); |
| videoWrapper.className = 'video-wrapper'; |
|
|
| const video = document.createElement('video'); |
| video.src = url; |
| video.controls = true; |
| video.setAttribute('preload', 'metadata'); |
| video.setAttribute('playsinline', ''); |
| video.setAttribute('aria-label', `Generated Short #${index}`); |
|
|
| videoWrapper.appendChild(video); |
|
|
| const footer = document.createElement('div'); |
| footer.className = 'video-card-footer'; |
|
|
| const label = document.createElement('p'); |
| label.className = 'video-card-label'; |
| label.textContent = `Short #${index}`; |
|
|
| const downloadBtn = document.createElement('button'); |
| downloadBtn.className = 'btn-download'; |
| downloadBtn.setAttribute('aria-label', `Download Short #${index}`); |
| downloadBtn.innerHTML = ` |
| <svg class="dl-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> |
| <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/> |
| <polyline points="7 10 12 15 17 10"/> |
| <line x1="12" y1="15" x2="12" y2="3"/> |
| </svg> |
| <span class="dl-label">Download</span>`; |
|
|
| downloadBtn.addEventListener('click', async () => { |
| if (downloadBtn.disabled) return; |
| downloadBtn.disabled = true; |
| downloadBtn.querySelector('.dl-label').textContent = 'Downloadingβ¦'; |
| try { |
| const res = await fetch(url); |
| const blob = await res.blob(); |
| const blobUrl = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = blobUrl; |
| a.download = `viral-short-${index}.mp4`; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| setTimeout(() => URL.revokeObjectURL(blobUrl), 10000); |
| } catch (err) { |
| console.error('Download failed:', err); |
| alert('Download failed. Please try right-clicking the video and saving it.'); |
| } |
| downloadBtn.disabled = false; |
| downloadBtn.querySelector('.dl-label').textContent = 'Download'; |
| }); |
|
|
| footer.appendChild(label); |
| footer.appendChild(downloadBtn); |
|
|
| |
| if (pacing !== null || structure !== null || triggers !== null) { |
| const rp = document.createElement('div'); |
| rp.className = 'rp-badges'; |
|
|
| |
| if (pacing !== null) { |
| const pacingColor = pacing >= 70 ? '#22c55e' : pacing >= 40 ? '#fbbf24' : '#f87171'; |
| const pacingBadge = document.createElement('div'); |
| pacingBadge.className = 'rp-badge rp-pacing'; |
| pacingBadge.innerHTML = ` |
| <svg width="28" height="28" viewBox="0 0 36 36" aria-hidden="true"> |
| <circle cx="18" cy="18" r="14" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="3"/> |
| <circle cx="18" cy="18" r="14" fill="none" stroke="${pacingColor}" stroke-width="3" |
| stroke-dasharray="${Math.round(pacing * 0.88)} 88" |
| stroke-dashoffset="22" stroke-linecap="round" transform="rotate(-90 18 18)"/> |
| <text x="18" y="22" text-anchor="middle" fill="${pacingColor}" font-size="9" font-weight="700" |
| font-family="Inter,sans-serif">${pacing}</text> |
| </svg> |
| <span class="rp-badge-label">Pacing</span> |
| `; |
| rp.appendChild(pacingBadge); |
| } |
|
|
| |
| if (structure) { |
| const segBadge = document.createElement('div'); |
| segBadge.className = 'rp-badge rp-structure'; |
| const hookDur = Math.round((structure.hook.end - structure.hook.start) * 10) / 10; |
| const rewardDur = Math.round((structure.reward.end - structure.reward.start) * 10) / 10; |
| segBadge.innerHTML = ` |
| <span class="seg-pill seg-hook">π£ Hook ${hookDur}s</span> |
| <span class="seg-pill seg-body">πΉ Body</span> |
| <span class="seg-pill seg-reward">π Reward ${rewardDur}s</span> |
| `; |
| rp.appendChild(segBadge); |
| } |
|
|
| |
| if (triggers && triggers.tags && triggers.tags.length > 0) { |
| const tagRow = document.createElement('div'); |
| tagRow.className = 'rp-badge rp-triggers'; |
| tagRow.innerHTML = triggers.tags |
| .map(tag => `<span class="trigger-tag">${tag}</span>`) |
| .join(''); |
| rp.appendChild(tagRow); |
| } |
|
|
| card.appendChild(rp); |
| } |
|
|
| card.appendChild(videoWrapper); |
| card.appendChild(footer); |
|
|
| return card; |
| } |
|
|
| |
| function createPlaceholderCard(index) { |
| const card = document.createElement('article'); |
| card.className = 'video-card video-card-placeholder'; |
| card.dataset.cardIndex = index; |
| card.setAttribute('role', 'listitem'); |
| card.style.animationDelay = `${index * 0.07}s`; |
|
|
| const wrapper = document.createElement('div'); |
| wrapper.className = 'video-wrapper'; |
| wrapper.innerHTML = ` |
| <div class="placeholder-inner"> |
| <div class="placeholder-spinner"></div> |
| <span class="placeholder-label">PROCESSING...</span> |
| </div>`; |
|
|
| const footer = document.createElement('div'); |
| footer.className = 'video-card-footer'; |
| footer.innerHTML = ` |
| <p class="video-card-label" style="opacity:0.35">Short #${index + 1}</p> |
| <div class="btn-download-ghost"></div>`; |
|
|
| card.appendChild(wrapper); |
| card.appendChild(footer); |
| return card; |
| } |
|
|
| |
| function isValidYouTubeUrl(url) { |
| try { |
| const u = new URL(url.trim()); |
| return ( |
| (u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com') && u.searchParams.has('v') || |
| u.hostname === 'youtu.be' && u.pathname.length > 1 || |
| u.hostname === 'www.youtube.com' && u.pathname.startsWith('/shorts/') |
| ); |
| } catch { |
| return false; |
| } |
| } |
|
|
| |
| function showResultsBadge(visible) { |
| const badge = document.getElementById('results-loading-badge'); |
| if (badge) badge.style.display = visible ? 'flex' : 'none'; |
| } |
|
|
| |
| async function handleGenerate() { |
| const rawUrl = urlInput.value.trim(); |
|
|
| if (!rawUrl) { shakeInput(); return; } |
|
|
| if (!isValidYouTubeUrl(rawUrl)) { |
| shakeInput(); |
| showInputError('Please enter a valid YouTube URL.'); |
| return; |
| } |
|
|
| clearInputError(); |
| savedUrl = rawUrl; |
| urlInput.disabled = true; |
| generateBtn.disabled = true; |
|
|
| showScreen('loading'); |
| startLoadingAnimation(); |
|
|
| let firstClip = true; |
| let totalClips = 0; |
| let receivedClips = 0; |
| let receivedClipsData = []; |
|
|
| try { |
| await callBackend(rawUrl, { |
| async onTotal(n) { |
| totalClips = n; |
| stopLoadingAnimation(); |
| completeAllSteps(); |
| await delay(300); |
| resultsGallery.innerHTML = ''; |
| resultsCount.textContent = '0'; |
| showScreen('results'); |
| showResultsBadge(true); |
| for (let i = 0; i < n; i++) { |
| resultsGallery.appendChild(createPlaceholderCard(i)); |
| } |
| }, |
| async onClip(url, index, data) { |
| receivedClips++; |
| |
| receivedClipsData.push({ url, index, data }); |
| |
| |
| receivedClipsData.sort((a, b) => { |
| const sA = (a.data.viral_analysis || {}).final_score || 0; |
| const sB = (b.data.viral_analysis || {}).final_score || 0; |
| return sB - sA; |
| }); |
|
|
| |
| |
| |
| resultsGallery.innerHTML = ''; |
| |
| |
| receivedClipsData.forEach((clip, i) => { |
| const realCard = createVideoCard(clip.url, i + 1, clip.data); |
| resultsGallery.appendChild(realCard); |
| }); |
|
|
| |
| for (let i = receivedClips; i < totalClips; i++) { |
| resultsGallery.appendChild(createPlaceholderCard(i)); |
| } |
|
|
| resultsCount.textContent = receivedClips; |
| if (receivedClips >= totalClips) showResultsBadge(false); |
| }, |
| }); |
|
|
| if (totalClips === 0) { |
| throw new Error('No shorts were generated. Please try a different video.'); |
| } |
| showResultsBadge(false); |
|
|
| } catch (err) { |
| stopLoadingAnimation(); |
| console.error('[ClipperApp] Error:', err); |
|
|
| |
| const raw = (err.message || '').toLowerCase(); |
| let friendlyMsg = ''; |
|
|
| if (raw.includes('failed to resolve') || raw.includes('dns resolution failed') || raw.includes('no address associated')) { |
| friendlyMsg = 'π YouTube is unreachable from this server.\n\n' |
| + 'This hosting platform appears to block outbound connections to YouTube.\n\n' |
| + 'Try these fixes:\n' |
| + '1. Upload a cookies.txt file to the Space\n' |
| + '2. Switch to a GPU-enabled Space (better network access)\n' |
| + '3. Try again in a few minutes (may be a temporary DNS issue)'; |
| } else if (raw.includes('failed to extract') || raw.includes('player response')) { |
| friendlyMsg = 'β οΈ YouTube blocked this request.\n\n' |
| + 'YouTube detected automated access and refused the download.\n\n' |
| + 'Fix: Upload a cookies.txt file exported from your browser to bypass this restriction.'; |
| } else if (raw.includes('cannot reach') || raw.includes('network error') || raw.includes('failed to fetch')) { |
| friendlyMsg = 'π Cannot connect to the backend server.\n\n' |
| + 'Make sure your backend is running (start_backend.bat or check HF Space logs).'; |
| } |
|
|
| errorMessage.textContent = friendlyMsg || err.message || 'An unexpected error occurred.'; |
| errorMessage.style.whiteSpace = 'pre-line'; |
| showScreen('error'); |
| } |
| } |
|
|
| |
| function shakeInput() { |
| const wrapper = urlInput.closest('.input-wrapper'); |
| wrapper.style.animation = 'none'; |
| wrapper.offsetHeight; |
| wrapper.style.animation = 'shake 0.4s ease'; |
| wrapper.addEventListener('animationend', () => { |
| wrapper.style.animation = ''; |
| }, { once: true }); |
| } |
|
|
| function showInputError(msg) { |
| let errEl = document.getElementById('input-error'); |
| if (!errEl) { |
| errEl = document.createElement('p'); |
| errEl.id = 'input-error'; |
| errEl.style.cssText = 'font-size:0.8rem;color:#F87171;margin-top:-0.25rem;padding-left:0.25rem;'; |
| urlInput.closest('.input-card').insertBefore(errEl, document.querySelector('.btn-generate')); |
| } |
| errEl.textContent = msg; |
| } |
|
|
| function clearInputError() { |
| const errEl = document.getElementById('input-error'); |
| if (errEl) errEl.remove(); |
| } |
|
|
| |
| function delay(ms) { |
| return new Promise(resolve => setTimeout(resolve, ms)); |
| } |
|
|
| |
| function resetToInput() { |
| urlInput.disabled = false; |
| generateBtn.disabled = false; |
| urlInput.value = ''; |
| clearInputError(); |
| showScreen('input'); |
| urlInput.focus(); |
| } |
|
|
| |
| (function injectShakeKeyframe() { |
| const style = document.createElement('style'); |
| style.textContent = ` |
| @keyframes shake { |
| 0%,100% { transform: translateX(0); } |
| 20% { transform: translateX(-6px); } |
| 40% { transform: translateX(6px); } |
| 60% { transform: translateX(-4px); } |
| 80% { transform: translateX(4px); } |
| } |
| `; |
| document.head.appendChild(style); |
| })(); |
|
|
| |
| generateBtn.addEventListener('click', handleGenerate); |
|
|
| urlInput.addEventListener('keydown', e => { |
| if (e.key === 'Enter') handleGenerate(); |
| }); |
|
|
| newVideoBtn.addEventListener('click', resetToInput); |
| retryBtn.addEventListener('click', resetToInput); |
|
|
| |
| window.addEventListener('DOMContentLoaded', () => { |
| urlInput.focus(); |
|
|
| |
| document.querySelectorAll('input[maxlength]').forEach(input => { |
| const counter = input.parentElement.querySelector('.char-count'); |
| if (counter) { |
| const update = () => { counter.textContent = input.value.length; }; |
| input.addEventListener('input', update); |
| update(); |
| } |
| }); |
| }); |
|
|
| |
| if (captionsToggle && captionStyleWrapper) { |
| captionsToggle.addEventListener('change', () => { |
| captionStyleWrapper.style.display = captionsToggle.checked ? 'block' : 'none'; |
| }); |
| } |
|
|
| |
| const clipsDisplay = document.getElementById('clips-display'); |
| const clipsDecBtn = document.getElementById('clips-dec'); |
| const clipsIncBtn = document.getElementById('clips-inc'); |
|
|
| function updateClipsDisplay() { |
| clipsDisplay.classList.remove('count-pop'); |
| void clipsDisplay.offsetWidth; |
| clipsDisplay.classList.add('count-pop'); |
|
|
| if (clipsCount === null) { |
| clipsDisplay.textContent = 'Default'; |
| clipsDisplay.classList.add('clips-default'); |
| } else { |
| clipsDisplay.textContent = clipsCount; |
| clipsDisplay.classList.remove('clips-default'); |
| } |
| } |
|
|
| |
| |
| |
| clipsDecBtn.addEventListener('click', () => { |
| if (clipsCount === null) { |
| clipsCount = 4; |
| } else if (clipsCount <= 1) { |
| clipsCount = null; |
| } else { |
| clipsCount--; |
| } |
| updateClipsDisplay(); |
| }); |
|
|
| clipsIncBtn.addEventListener('click', () => { |
| if (clipsCount === null) { |
| clipsCount = 6; |
| } else { |
| clipsCount = Math.min(10, clipsCount + 1); |
| } |
| updateClipsDisplay(); |
| }); |
|
|
| |
| if (retentionToggleBtn && retentionBody) { |
| |
| retentionBody.style.maxHeight = '0'; |
| retentionBody.style.opacity = '0'; |
| retentionBody.style.overflow = 'hidden'; |
|
|
| retentionToggleBtn.addEventListener('click', () => { |
| const isOpen = retentionToggleBtn.getAttribute('aria-expanded') === 'true'; |
| retentionToggleBtn.setAttribute('aria-expanded', String(!isOpen)); |
|
|
| if (isOpen) { |
| retentionBody.style.maxHeight = '0'; |
| retentionBody.style.opacity = '0'; |
| } else { |
| retentionBody.style.maxHeight = retentionBody.scrollHeight + 'px'; |
| retentionBody.style.opacity = '1'; |
| } |
| retentionToggleBtn.querySelector('.retention-chevron') |
| .style.transform = isOpen ? 'rotate(0deg)' : 'rotate(180deg)'; |
| }); |
| } |
|
|