|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CatCutApp { |
|
|
constructor() { |
|
|
this.currentView = 'story'; |
|
|
this.isPlaying = false; |
|
|
this.currentTime = 0; |
|
|
this.duration = 0; |
|
|
this.segments = []; |
|
|
this.clips = []; |
|
|
this.lockedClips = new Set(); |
|
|
|
|
|
this.init(); |
|
|
} |
|
|
|
|
|
init() { |
|
|
this.setupEventListeners(); |
|
|
this.setupVideoPlayer(); |
|
|
this.setupTextEditor(); |
|
|
this.setupFab(); |
|
|
this.setupExport(); |
|
|
this.setupVoiceMode(); |
|
|
this.simulateLoading(); |
|
|
this.initializeMockData(); |
|
|
} |
|
|
|
|
|
simulateLoading() { |
|
|
setTimeout(() => { |
|
|
const overlay = document.getElementById('loading-overlay'); |
|
|
overlay.style.opacity = '0'; |
|
|
setTimeout(() => { |
|
|
overlay.style.display = 'none'; |
|
|
}, 300); |
|
|
}, 1500); |
|
|
} |
|
|
|
|
|
initializeMockData() { |
|
|
|
|
|
this.segments = [ |
|
|
{ |
|
|
id: 'segment-1', |
|
|
clipId: 'clip-1', |
|
|
timestamp: 0, |
|
|
text: 'The morning sun filters through the window as I start my daily routine.', |
|
|
keywords: ['morning', 'sun', 'window', 'routine'], |
|
|
locked: false, |
|
|
matched: true |
|
|
}, |
|
|
{ |
|
|
id: 'segment-2', |
|
|
clipId: 'clip-2', |
|
|
timestamp: 5, |
|
|
text: 'A perfect cup of coffee, the aroma filling the quiet space.', |
|
|
keywords: ['coffee', 'aroma', 'quiet', 'space'], |
|
|
locked: false, |
|
|
matched: true |
|
|
}, |
|
|
{ |
|
|
id: 'segment-3', |
|
|
clipId: 'clip-3', |
|
|
timestamp: 10, |
|
|
text: 'Time to focus and create something meaningful today.', |
|
|
keywords: ['focus', 'create', 'meaningful', 'today'], |
|
|
locked: false, |
|
|
matched: false |
|
|
} |
|
|
]; |
|
|
|
|
|
|
|
|
this.clips = [ |
|
|
{ |
|
|
id: 'clip-1', |
|
|
name: 'Morning Window', |
|
|
duration: 5, |
|
|
thumbnail: 'http://static.photos/nature/120x67/42', |
|
|
matched: true, |
|
|
keywords: ['morning', 'sun', 'window'] |
|
|
}, |
|
|
{ |
|
|
id: 'clip-2', |
|
|
name: 'Coffee Aroma', |
|
|
duration: 5, |
|
|
thumbnail: 'http://static.photos/food/120x67/133', |
|
|
matched: true, |
|
|
keywords: ['coffee', 'aroma', 'steam'] |
|
|
}, |
|
|
{ |
|
|
id: 'clip-3', |
|
|
name: 'Workspace', |
|
|
duration: 8, |
|
|
thumbnail: 'http://static.photos/workspace/120x67/99', |
|
|
matched: false, |
|
|
keywords: ['work', 'desk', 'computer'] |
|
|
} |
|
|
]; |
|
|
|
|
|
this.renderClips(); |
|
|
} |
|
|
|
|
|
setupEventListeners() { |
|
|
|
|
|
document.addEventListener('viewChanged', (e) => { |
|
|
this.switchView(e.detail.view); |
|
|
}); |
|
|
|
|
|
|
|
|
const themeToggle = document.querySelector('#theme-toggle'); |
|
|
if (themeToggle) { |
|
|
themeToggle.addEventListener('click', () => this.toggleTheme()); |
|
|
} |
|
|
|
|
|
|
|
|
const importBtn = document.getElementById('import-btn'); |
|
|
if (importBtn) { |
|
|
importBtn.addEventListener('click', () => this.openImport()); |
|
|
} |
|
|
|
|
|
|
|
|
const addSegmentBtn = document.getElementById('add-segment-btn'); |
|
|
if (addSegmentBtn) { |
|
|
addSegmentBtn.addEventListener('click', () => this.addSegment()); |
|
|
} |
|
|
|
|
|
|
|
|
const aiPolishBtn = document.getElementById('ai-polish-btn'); |
|
|
if (aiPolishBtn) { |
|
|
aiPolishBtn.addEventListener('click', () => this.aiPolish()); |
|
|
} |
|
|
|
|
|
|
|
|
const lockAllBtn = document.getElementById('lock-all-btn'); |
|
|
if (lockAllBtn) { |
|
|
lockAllBtn.addEventListener('click', () => this.toggleLockAll()); |
|
|
} |
|
|
|
|
|
|
|
|
document.addEventListener('click', (e) => { |
|
|
if (e.target.closest('.lock-btn')) { |
|
|
const btn = e.target.closest('.lock-btn'); |
|
|
this.toggleSegmentLock(btn); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
document.addEventListener('click', (e) => { |
|
|
if (e.target.textContent === 'Apply →') { |
|
|
this.applyAISuggestion(e.target); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
const exportCancel = document.getElementById('export-cancel'); |
|
|
const exportConfirm = document.getElementById('export-confirm'); |
|
|
if (exportCancel) exportCancel.addEventListener('click', () => this.closeExport()); |
|
|
if (exportConfirm) exportConfirm.addEventListener('click', () => this.confirmExport()); |
|
|
} |
|
|
|
|
|
setupVideoPlayer() { |
|
|
const video = document.getElementById('main-video'); |
|
|
const playBtn = document.getElementById('play-btn'); |
|
|
const progress = document.getElementById('video-progress'); |
|
|
const emptyState = document.getElementById('empty-state'); |
|
|
|
|
|
if (!video) return; |
|
|
|
|
|
|
|
|
video.addEventListener('loadedmetadata', () => { |
|
|
this.duration = video.duration; |
|
|
if (emptyState) emptyState.style.display = 'none'; |
|
|
}); |
|
|
|
|
|
|
|
|
if (playBtn) { |
|
|
playBtn.addEventListener('click', () => { |
|
|
if (this.isPlaying) { |
|
|
video.pause(); |
|
|
} else { |
|
|
video.play(); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
video.addEventListener('play', () => { |
|
|
this.isPlaying = true; |
|
|
if (playBtn) { |
|
|
playBtn.innerHTML = '<i data-feather="pause" class="w-8 h-8 text-white"></i>'; |
|
|
feather.replace(); |
|
|
} |
|
|
}); |
|
|
|
|
|
video.addEventListener('pause', () => { |
|
|
this.isPlaying = false; |
|
|
if (playBtn) { |
|
|
playBtn.innerHTML = '<i data-feather="play" class="w-8 h-8 text-white ml-1"></i>'; |
|
|
feather.replace(); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
video.addEventListener('timeupdate', () => { |
|
|
this.currentTime = video.currentTime; |
|
|
const progressPercent = (this.currentTime / this.duration) * 100; |
|
|
if (progress) { |
|
|
progress.style.width = `${progressPercent}%`; |
|
|
} |
|
|
this.highlightActiveSegment(); |
|
|
}); |
|
|
|
|
|
|
|
|
video.addEventListener('ended', () => { |
|
|
this.isPlaying = false; |
|
|
if (progress) progress.style.width = '0%'; |
|
|
if (playBtn) { |
|
|
playBtn.innerHTML = '<i data-feather="play" class="w-8 h-8 text-white ml-1"></i>'; |
|
|
feather.replace(); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
setupTextEditor() { |
|
|
const editor = document.getElementById('text-editor'); |
|
|
if (!editor) return; |
|
|
|
|
|
|
|
|
editor.addEventListener('input', (e) => { |
|
|
if (e.target.classList.contains('segment-text')) { |
|
|
this.updateSegmentText(e.target); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
editor.addEventListener('click', (e) => { |
|
|
const segment = e.target.closest('.story-segment'); |
|
|
if (segment) { |
|
|
this.selectSegment(segment); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
setupFab() { |
|
|
const fab = document.getElementById('fab-toggle-view'); |
|
|
if (!fab) return; |
|
|
|
|
|
fab.addEventListener('click', () => { |
|
|
this.toggleView(); |
|
|
}); |
|
|
} |
|
|
|
|
|
setupExport() { |
|
|
const exportBtn = document.querySelector('#export-btn'); |
|
|
if (exportBtn) { |
|
|
exportBtn.addEventListener('click', () => this.openExport()); |
|
|
} |
|
|
|
|
|
|
|
|
document.addEventListener('click', (e) => { |
|
|
if (e.target.closest('.platform-btn')) { |
|
|
const btn = e.target.closest('.platform-btn'); |
|
|
this.selectPlatform(btn); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
setupVoiceMode() { |
|
|
const voiceBtn = document.getElementById('voice-record-btn'); |
|
|
if (!voiceBtn) return; |
|
|
|
|
|
let isRecording = false; |
|
|
|
|
|
voiceBtn.addEventListener('click', () => { |
|
|
isRecording = !isRecording; |
|
|
if (isRecording) { |
|
|
voiceBtn.classList.add('voice-recording'); |
|
|
voiceBtn.innerHTML = '<i data-feather="square" class="w-4 h-4"></i><span>Stop Recording</span>'; |
|
|
feather.replace(); |
|
|
this.startVoiceRecording(); |
|
|
} else { |
|
|
voiceBtn.classList.remove('voice-recording'); |
|
|
voiceBtn.innerHTML = '<i data-feather="mic" class="w-4 h-4"></i><span>Record Narration</span>'; |
|
|
feather.replace(); |
|
|
this.stopVoiceRecording(); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
toggleView() { |
|
|
const newView = this.currentView === 'story' ? 'visual' : 'story'; |
|
|
this.currentView = newView; |
|
|
|
|
|
|
|
|
document.dispatchEvent(new CustomEvent('viewChanged', { |
|
|
detail: { view: newView } |
|
|
})); |
|
|
|
|
|
|
|
|
this.updateFabIcon(); |
|
|
} |
|
|
|
|
|
switchView(view) { |
|
|
const narrativeDeck = document.getElementById('narrative-deck'); |
|
|
const videoStage = document.getElementById('video-stage'); |
|
|
|
|
|
if (view === 'visual') { |
|
|
|
|
|
narrativeDeck.style.flex = '0.8'; |
|
|
videoStage.style.flex = '1.5'; |
|
|
|
|
|
|
|
|
if (!document.querySelector('clip-timeline')) { |
|
|
const timeline = document.createElement('clip-timeline'); |
|
|
narrativeDeck.appendChild(timeline); |
|
|
} |
|
|
} else { |
|
|
|
|
|
narrativeDeck.style.flex = '1'; |
|
|
videoStage.style.flex = '1.2'; |
|
|
|
|
|
|
|
|
const timeline = document.querySelector('clip-timeline'); |
|
|
if (timeline) { |
|
|
timeline.remove(); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
updateFabIcon() { |
|
|
const storyIcon = document.getElementById('story-icon'); |
|
|
const visualIcon = document.getElementById('visual-icon'); |
|
|
|
|
|
if (this.currentView === 'story') { |
|
|
storyIcon.classList.add('active'); |
|
|
storyIcon.classList.remove('inactive'); |
|
|
visualIcon.classList.add('inactive'); |
|
|
visualIcon.classList.remove('active'); |
|
|
} else { |
|
|
visualIcon.classList.add('active'); |
|
|
visualIcon.classList.remove('inactive'); |
|
|
storyIcon.classList.add('inactive'); |
|
|
storyIcon.classList.remove('active'); |
|
|
} |
|
|
} |
|
|
|
|
|
selectSegment(segmentEl) { |
|
|
|
|
|
document.querySelectorAll('.story-segment').forEach(s => s.classList.remove('active')); |
|
|
|
|
|
|
|
|
segmentEl.classList.add('active'); |
|
|
|
|
|
|
|
|
const timestamp = parseFloat(segmentEl.dataset.timestamp); |
|
|
const video = document.getElementById('main-video'); |
|
|
if (video && !isNaN(timestamp)) { |
|
|
video.currentTime = timestamp; |
|
|
} |
|
|
} |
|
|
|
|
|
highlightActiveSegment() { |
|
|
const segments = document.querySelectorAll('.story-segment'); |
|
|
segments.forEach((segment, index) => { |
|
|
const startTime = parseFloat(segment.dataset.timestamp); |
|
|
const endTime = index < segments.length - 1 ? |
|
|
parseFloat(segments[index + 1].dataset.timestamp) : |
|
|
this.duration; |
|
|
|
|
|
if (this.currentTime >= startTime && this.currentTime < endTime) { |
|
|
segment.classList.add('active'); |
|
|
} else { |
|
|
segment.classList.remove('active'); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
toggleSegmentLock(btn) { |
|
|
const segment = btn.closest('.story-segment'); |
|
|
const clipId = segment.dataset.clipId; |
|
|
const isLocked = btn.dataset.locked === 'true'; |
|
|
|
|
|
btn.dataset.locked = !isLocked; |
|
|
btn.innerHTML = !isLocked ? |
|
|
'<i data-feather="lock" class="w-4 h-4"></i>' : |
|
|
'<i data-feather="unlock" class="w-4 h-4"></i>'; |
|
|
|
|
|
feather.replace(); |
|
|
|
|
|
if (!isLocked) { |
|
|
this.lockedClips.add(clipId); |
|
|
segment.classList.add('locked'); |
|
|
} else { |
|
|
this.lockedClips.delete(clipId); |
|
|
segment.classList.remove('locked'); |
|
|
} |
|
|
} |
|
|
|
|
|
updateSegmentText(textEl) { |
|
|
const segment = textEl.closest('.story-segment'); |
|
|
const segmentId = segment.dataset.clipId; |
|
|
|
|
|
|
|
|
this.simulateAIMatching(segment, textEl.textContent); |
|
|
|
|
|
|
|
|
const segmentData = this.segments.find(s => s.clipId === segmentId); |
|
|
if (segmentData) { |
|
|
segmentData.text = textEl.textContent; |
|
|
} |
|
|
} |
|
|
|
|
|
simulateAIMatching(segment, text) { |
|
|
const matchStatus = segment.querySelector('.match-status'); |
|
|
if (matchStatus) { |
|
|
matchStatus.textContent = '🔍 Analyzing...'; |
|
|
matchStatus.className = 'match-status searching'; |
|
|
|
|
|
setTimeout(() => { |
|
|
matchStatus.textContent = '✓ Re-matched: ' + text.split(' ').slice(0, 3).join(', '); |
|
|
matchStatus.className = 'match-status matched'; |
|
|
}, 1000); |
|
|
} |
|
|
} |
|
|
|
|
|
renderClips() { |
|
|
|
|
|
|
|
|
} |
|
|
|
|
|
addSegment() { |
|
|
const editor = document.getElementById('text-editor'); |
|
|
const newSegment = document.createElement('div'); |
|
|
newSegment.className = 'story-segment'; |
|
|
newSegment.dataset.timestamp = this.segments.length * 5; |
|
|
newSegment.dataset.clipId = `clip-${this.segments.length + 1}`; |
|
|
|
|
|
newSegment.innerHTML = ` |
|
|
<div class="segment-controls"> |
|
|
<button class="lock-btn" data-locked="false"> |
|
|
<i data-feather="unlock" class="w-4 h-4"></i> |
|
|
</button> |
|
|
</div> |
|
|
<p class="segment-text" contenteditable="true">New story segment. Describe what should appear here.</p> |
|
|
<div class="segment-metadata"> |
|
|
<span class="match-status searching">🔍 Searching for matches...</span> |
|
|
</div> |
|
|
`; |
|
|
|
|
|
editor.appendChild(newSegment); |
|
|
feather.replace(); |
|
|
|
|
|
|
|
|
this.segments.push({ |
|
|
id: `segment-${this.segments.length + 1}`, |
|
|
clipId: `clip-${this.segments.length + 1}`, |
|
|
timestamp: this.segments.length * 5, |
|
|
text: 'New story segment. Describe what should appear here.', |
|
|
keywords: [], |
|
|
locked: false, |
|
|
matched: false |
|
|
}); |
|
|
} |
|
|
|
|
|
aiPolish() { |
|
|
const btn = document.getElementById('ai-polish-btn'); |
|
|
const originalText = btn.innerHTML; |
|
|
|
|
|
btn.innerHTML = '<i data-feather="loader" class="w-3 h-3 animate-spin"></i><span>Polishing...</span>'; |
|
|
feather.replace(); |
|
|
|
|
|
setTimeout(() => { |
|
|
|
|
|
const segments = document.querySelectorAll('.segment-text'); |
|
|
segments.forEach(textEl => { |
|
|
if (!textEl.closest('.locked')) { |
|
|
const currentText = textEl.textContent; |
|
|
textEl.textContent = this.polishText(currentText); |
|
|
} |
|
|
}); |
|
|
|
|
|
btn.innerHTML = originalText; |
|
|
feather.replace(); |
|
|
|
|
|
|
|
|
this.showNotification('AI successfully polished your story!', 'success'); |
|
|
}, 2000); |
|
|
} |
|
|
|
|
|
polishText(text) { |
|
|
|
|
|
const enhancements = [ |
|
|
'As golden rays illuminate the space, ', |
|
|
'With aromatic steam rising, ', |
|
|
'In this moment of clarity, ', |
|
|
'The serene atmosphere whispers ', |
|
|
'Every detail tells a story of ' |
|
|
]; |
|
|
|
|
|
const randomEnhancement = enhancements[Math.floor(Math.random() * enhancements.length)]; |
|
|
return randomEnhancement + text.toLowerCase(); |
|
|
} |
|
|
|
|
|
toggleLockAll() { |
|
|
const lockAllBtn = document.getElementById('lock-all-btn'); |
|
|
const isCurrentlyLocked = lockAllBtn.dataset.locked === 'true'; |
|
|
|
|
|
const lockButtons = document.querySelectorAll('.lock-btn'); |
|
|
lockButtons.forEach(btn => { |
|
|
const segmentLocked = btn.dataset.locked === 'true'; |
|
|
if (!isCurrentlyLocked && !segmentLocked) { |
|
|
btn.click(); |
|
|
} else if (isCurrentlyLocked && segmentLocked) { |
|
|
btn.click(); |
|
|
} |
|
|
}); |
|
|
|
|
|
lockAllBtn.dataset.locked = !isCurrentlyLocked; |
|
|
lockAllBtn.innerHTML = !isCurrentlyLocked ? |
|
|
'<i data-feather="unlock" class="w-3 h-3"></i><span>Unlock All</span>' : |
|
|
'<i data-feather="lock" class="w-3 h-3"></i><span>Lock All</span>'; |
|
|
|
|
|
feather.replace(); |
|
|
} |
|
|
|
|
|
applyAISuggestion(btn) { |
|
|
const card = btn.closest('.ai-suggestion-card'); |
|
|
card.style.opacity = '0.5'; |
|
|
btn.textContent = '✓ Applied'; |
|
|
|
|
|
setTimeout(() => { |
|
|
card.style.opacity = '1'; |
|
|
}, 500); |
|
|
|
|
|
this.showNotification('AI suggestion applied successfully!', 'success'); |
|
|
} |
|
|
|
|
|
startVoiceRecording() { |
|
|
|
|
|
this.showNotification('Recording started... Speak your story!', 'info'); |
|
|
} |
|
|
|
|
|
stopVoiceRecording() { |
|
|
|
|
|
this.showNotification('Transcribing your voice...', 'info'); |
|
|
|
|
|
setTimeout(() => { |
|
|
const editor = document.getElementById('text-editor'); |
|
|
const voiceText = 'I woke up feeling energized today. The sunlight was perfect for filming. I grabbed my coffee and started creating.'; |
|
|
|
|
|
|
|
|
editor.innerHTML = ''; |
|
|
this.segments = []; |
|
|
|
|
|
|
|
|
const sentences = voiceText.split('.').filter(s => s.trim()); |
|
|
sentences.forEach((sentence, index) => { |
|
|
if (sentence.trim()) { |
|
|
const newSegment = document.createElement('div'); |
|
|
newSegment.className = 'story-segment'; |
|
|
newSegment.dataset.timestamp = index * 4; |
|
|
newSegment.dataset.clipId = `clip-${index + 1}`; |
|
|
|
|
|
newSegment.innerHTML = ` |
|
|
<div class="segment-controls"> |
|
|
<button class="lock-btn" data-locked="false"> |
|
|
<i data-feather="unlock" class="w-4 h-4"></i> |
|
|
</button> |
|
|
</div> |
|
|
<p class="segment-text" contenteditable="true">${sentence.trim()}.</p> |
|
|
<div class="segment-metadata"> |
|
|
<span class="match-status matched">✓ Matched from voice</span> |
|
|
</div> |
|
|
`; |
|
|
|
|
|
editor.appendChild(newSegment); |
|
|
|
|
|
this.segments.push({ |
|
|
id: `segment-${index + 1}`, |
|
|
clipId: `clip-${index + 1}`, |
|
|
timestamp: index * 4, |
|
|
text: sentence.trim() + '.', |
|
|
keywords: sentence.trim().toLowerCase().split(' '), |
|
|
locked: false, |
|
|
matched: true |
|
|
}); |
|
|
} |
|
|
}); |
|
|
|
|
|
feather.replace(); |
|
|
this.showNotification('Voice transcription complete! Video synced to your narration.', 'success'); |
|
|
}, 3000); |
|
|
} |
|
|
|
|
|
openImport() { |
|
|
this.showNotification('Import dialog opened (simulation)', 'info'); |
|
|
|
|
|
} |
|
|
|
|
|
toggleTheme() { |
|
|
const html = document.documentElement; |
|
|
const currentTheme = html.getAttribute('data-theme'); |
|
|
const newTheme = currentTheme === 'light' ? 'dark' : 'light'; |
|
|
html.setAttribute('data-theme', newTheme); |
|
|
|
|
|
this.showNotification(`Switched to ${newTheme} mode`, 'info'); |
|
|
} |
|
|
|
|
|
openExport() { |
|
|
const modal = document.getElementById('export-modal'); |
|
|
modal.classList.add('active'); |
|
|
} |
|
|
|
|
|
closeExport() { |
|
|
const modal = document.getElementById('export-modal'); |
|
|
modal.classList.remove('active'); |
|
|
} |
|
|
|
|
|
confirmExport() { |
|
|
this.closeExport(); |
|
|
this.showNotification('Export started! Preparing your story...', 'info'); |
|
|
|
|
|
|
|
|
let progress = 0; |
|
|
const interval = setInterval(() => { |
|
|
progress += Math.random() * 20; |
|
|
if (progress >= 100) { |
|
|
progress = 100; |
|
|
clearInterval(interval); |
|
|
this.showNotification('Export complete! Your video is ready.', 'success'); |
|
|
} |
|
|
}, 500); |
|
|
} |
|
|
|
|
|
selectPlatform(btn) { |
|
|
document.querySelectorAll('.platform-btn').forEach(b => b.classList.remove('active')); |
|
|
btn.classList.add('active'); |
|
|
} |
|
|
|
|
|
showNotification(message, type = 'info') { |
|
|
|
|
|
const notification = document.createElement('div'); |
|
|
notification.className = `fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg transform translate-x-full transition-transform duration-300`; |
|
|
|
|
|
|
|
|
const colors = { |
|
|
success: 'bg-green-500 text-white', |
|
|
error: 'bg-red-500 text-white', |
|
|
info: 'bg-amber-500 text-white', |
|
|
warning: 'bg-yellow-500 text-black' |
|
|
}; |
|
|
|
|
|
notification.className += ` ${colors[type] || colors.info}`; |
|
|
notification.textContent = message; |
|
|
|
|
|
document.body.appendChild(notification); |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
notification.style.transform = 'translateX(0)'; |
|
|
}, 100); |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
notification.style.transform = 'translateX(full)'; |
|
|
setTimeout(() => notification.remove(), 300); |
|
|
}, 3000); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
window.catCutApp = new CatCutApp(); |
|
|
}); |