From 3b5042dc0ae614d3d958b7db7da8d6ce95949e3e Mon Sep 17 00:00:00 2001 From: "Staub Oliver I.BSCI_F22.2101" Date: Fri, 13 Jun 2025 16:05:28 +0200 Subject: [PATCH 1/3] Implement modern blocking screen with swipeable activity cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create SwipeCards component with Tinder-like gesture support - Add ActivityTimer component with 5-minute focus timer and celebration animations - Redesign blocking screen with modern B&W aesthetic and green accents - Integrate personalized activities from iOS app questionnaire data - Add smooth touch/mouse gesture detection for desktop and mobile - Include confetti celebration for successful activity completion - Update manifest.json to include new UI components - Maintain proper cleanup and memory management - Pass all existing tests and validation πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../Resources/activity-timer.js | 499 ++++++++++++++++ Shared (Extension)/Resources/manifest.json | 2 + .../blocking-screen/blocking-screen.js | 550 ++++++++++++++++-- Shared (Extension)/Resources/swipe-cards.js | 460 +++++++++++++++ UIComponentsJs/activity-timer.js | 483 +++++++++++++++ UIComponentsJs/swipe-cards.js | 450 ++++++++++++++ 6 files changed, 2399 insertions(+), 45 deletions(-) create mode 100644 Shared (Extension)/Resources/activity-timer.js create mode 100644 Shared (Extension)/Resources/swipe-cards.js create mode 100644 UIComponentsJs/activity-timer.js create mode 100644 UIComponentsJs/swipe-cards.js diff --git a/Shared (Extension)/Resources/activity-timer.js b/Shared (Extension)/Resources/activity-timer.js new file mode 100644 index 0000000..a8a2072 --- /dev/null +++ b/Shared (Extension)/Resources/activity-timer.js @@ -0,0 +1,499 @@ +// UIComponentsJs/activity-timer.js +// 5-minute activity timer with completion celebration + +class ActivityTimer { + constructor(options = {}) { + this.options = { + duration: options.duration || 5 * 60 * 1000, // 5 minutes default + onComplete: options.onComplete || null, + onTick: options.onTick || null, + containerClass: options.containerClass || '', + ...options, + }; + + this.element = null; + this.isRunning = false; + this.startTime = null; + this.remainingTime = this.options.duration; + this.interval = null; + this.activity = null; + } + + render(container) { + if (!container) { + throw new Error('Container element is required'); + } + + this.element = document.createElement('div'); + this.element.className = `activity-timer ${this.options.containerClass}`; + this.element.style.cssText = ` + background: white; + border-radius: 16px; + padding: 32px; + text-align: center; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + border: 1px solid rgba(0, 0, 0, 0.08); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + `; + + this.createTimerContent(); + container.appendChild(this.element); + return this.element; + } + + createTimerContent() { + if (!this.element) { + return; + } + + this.element.innerHTML = ` +
+
+ ⏱️ +
+ +

+ Ready to start? +

+ +

+ Choose an activity to begin your 5-minute timer +

+ +
+ 5:00 +
+ +
+
+
+ +
+ +
+
+ `; + + this.createControls(); + } + + createControls() { + const controlsContainer = this.element.querySelector('#timer-controls'); + if (!controlsContainer) { + return; + } + + if (!this.isRunning) { + // Show start button + const startButton = new HeadlessButton('Start Timer', { + color: 'zinc', + size: 'lg', + onClick: () => this.start(), + }); + + controlsContainer.appendChild(startButton.element); + } else { + // Show pause/resume and stop buttons + const pauseButton = new HeadlessButton('Pause', { + color: 'zinc', + outline: true, + onClick: () => this.pause(), + }); + + const stopButton = new HeadlessButton('Stop', { + color: 'red', + outline: true, + onClick: () => this.stop(), + }); + + const buttonContainer = document.createElement('div'); + buttonContainer.style.cssText = ` + display: flex; + gap: 12px; + justify-content: center; + `; + + buttonContainer.appendChild(pauseButton.element); + buttonContainer.appendChild(stopButton.element); + controlsContainer.appendChild(buttonContainer); + } + } + + setActivity(activity) { + this.activity = activity; + + const emoji = this.element.querySelector('#activity-emoji'); + const title = this.element.querySelector('#activity-title'); + const description = this.element.querySelector('#activity-description'); + + if (emoji) { + emoji.textContent = activity.emoji; + } + if (title) { + title.textContent = activity.text; + } + if (description) { + description.textContent = `Take 5 minutes to focus on this activity`; + } + } + + start() { + if (this.isRunning) { + return; + } + + this.isRunning = true; + this.startTime = Date.now(); + + // If we're resuming, adjust start time + if (this.remainingTime < this.options.duration) { + this.startTime = Date.now() - (this.options.duration - this.remainingTime); + } + + this.interval = setInterval(() => { + this.tick(); + }, 100); // Update every 100ms for smooth progress + + this.updateDisplay(); + this.createControls(); + } + + pause() { + if (!this.isRunning) { + return; + } + + this.isRunning = false; + this.remainingTime = Math.max(0, this.options.duration - (Date.now() - this.startTime)); + + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + + this.updateDisplay(); + this.createControls(); + } + + stop() { + this.isRunning = false; + this.remainingTime = this.options.duration; + + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + + this.updateDisplay(); + this.createControls(); + } + + tick() { + if (!this.isRunning) { + return; + } + + const elapsed = Date.now() - this.startTime; + this.remainingTime = Math.max(0, this.options.duration - elapsed); + + this.updateDisplay(); + + if (this.options.onTick) { + this.options.onTick(this.remainingTime, elapsed); + } + + if (this.remainingTime <= 0) { + this.complete(); + } + } + + complete() { + this.isRunning = false; + + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + + this.showCompletionDialog(); + + if (this.options.onComplete) { + this.options.onComplete(); + } + } + + updateDisplay() { + const timerDisplay = this.element.querySelector('#timer-display'); + const progressBar = this.element.querySelector('#progress-bar'); + + if (timerDisplay) { + const minutes = Math.floor(this.remainingTime / 60000); + const seconds = Math.floor((this.remainingTime % 60000) / 1000); + timerDisplay.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`; + } + + if (progressBar) { + const progress = ((this.options.duration - this.remainingTime) / this.options.duration) * 100; + progressBar.style.width = `${Math.min(100, progress)}%`; + } + } + + showCompletionDialog() { + // Replace timer content with completion dialog + this.element.innerHTML = ` +
+
+ πŸŽ‰ +
+ +

+ Time's Up! +

+ +

+ Did you successfully complete "${this.activity?.text || 'your activity'}"? +

+ +
+ +
+
+ `; + + this.createCompletionControls(); + } + + createCompletionControls() { + const controlsContainer = this.element.querySelector('#completion-controls'); + if (!controlsContainer) { + return; + } + + const yesButton = new HeadlessButton('Yes! πŸŽ‰', { + color: 'green', + size: 'lg', + onClick: () => this.handleCompletion(true), + }); + + const noButton = new HeadlessButton('Not quite', { + color: 'zinc', + outline: true, + size: 'lg', + onClick: () => this.handleCompletion(false), + }); + + controlsContainer.appendChild(yesButton.element); + controlsContainer.appendChild(noButton.element); + } + + handleCompletion(successful) { + if (successful) { + this.showCelebration(); + } else { + this.showEncouragement(); + } + } + + showCelebration() { + this.element.innerHTML = ` +
+
+ πŸ† +
+ +

+ Awesome! πŸŽ‰ +

+ +

+ You completed your activity! That's a great step towards breaking the doomscroll habit. +

+ +
+ ${this.createConfetti()} +
+
+ `; + + this.animateCelebration(); + } + + showEncouragement() { + this.element.innerHTML = ` +
+
+ πŸ’ͺ +
+ +

+ That's okay! +

+ +

+ Taking a break from scrolling is already a win. Every small step counts! +

+
+ `; + } + + createConfetti() { + const colors = ['#10b981', '#6366f1', '#f59e0b', '#ef4444', '#8b5cf6']; + let confetti = ''; + + for (let i = 0; i < 20; i++) { + const color = colors[Math.floor(Math.random() * colors.length)]; + const delay = Math.random() * 2; + const duration = 2 + Math.random() * 2; + const left = Math.random() * 100; + + confetti += ` +
+ `; + } + + return `
${confetti}
`; + } + + animateCelebration() { + // Add CSS animations + const style = document.createElement('style'); + style.textContent = ` + @keyframes bounce { + 0%, 20%, 50%, 80%, 100% { + transform: translateY(0); + } + 40% { + transform: translateY(-20px); + } + 60% { + transform: translateY(-10px); + } + } + + @keyframes confetti-fall { + 0% { + transform: translateY(-10px) rotate(0deg); + opacity: 1; + } + 100% { + transform: translateY(100px) rotate(360deg); + opacity: 0; + } + } + `; + + document.head.appendChild(style); + + // Remove style after animation + setTimeout(() => { + if (style.parentNode) { + style.parentNode.removeChild(style); + } + }, 10000); + } + + // Public methods + reset() { + this.stop(); + this.createTimerContent(); + } + + getRemainingTime() { + return this.remainingTime; + } + + isTimerRunning() { + return this.isRunning; + } + + destroy() { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + + if (this.element && this.element.parentNode) { + this.element.parentNode.removeChild(this.element); + this.element = null; + } + } +} + +// Export for use in modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = ActivityTimer; +} else { + window.ActivityTimer = ActivityTimer; +} diff --git a/Shared (Extension)/Resources/manifest.json b/Shared (Extension)/Resources/manifest.json index 6f493a6..c1c2a3d 100644 --- a/Shared (Extension)/Resources/manifest.json +++ b/Shared (Extension)/Resources/manifest.json @@ -29,6 +29,8 @@ "timer-tracker.js", "button.js", "dialog.js", + "swipe-cards.js", + "activity-timer.js", "choice-dialog.js", "doomscroll-detector.js", "doomscroll-animation.js", diff --git a/Shared (Extension)/Resources/modules/blocking-screen/blocking-screen.js b/Shared (Extension)/Resources/modules/blocking-screen/blocking-screen.js index b31e6ef..b2b9362 100644 --- a/Shared (Extension)/Resources/modules/blocking-screen/blocking-screen.js +++ b/Shared (Extension)/Resources/modules/blocking-screen/blocking-screen.js @@ -1,5 +1,5 @@ // modules/blocking-screen/blocking-screen.js -// Module for showing the full blocking screen with countdown +// Modern blocking screen with activity suggestions and timer if (typeof window.BlockingScreen === 'undefined') { class BlockingScreen { @@ -12,6 +12,10 @@ if (typeof window.BlockingScreen === 'undefined') { this.blockingElement = null; this.countdownInterval = null; this.hostname = window.location.hostname; + this.swipeCards = null; + this.activityTimer = null; + this.currentMode = 'blocked'; // 'blocked', 'activities', 'timer' + this.selectedActivity = null; } /** @@ -48,16 +52,16 @@ if (typeof window.BlockingScreen === 'undefined') { this.blockingElement.id = 'time-block-screen'; this.blockingElement.className = 'blocking-screen'; - this.applyBlockingStyles(); - await this.updateBlockingContent(); + this.applyModernStyles(); + await this.createModernContent(); document.body.appendChild(this.blockingElement); } /** - * Apply styles to blocking screen element + * Apply modern B&W styles to blocking screen element */ - applyBlockingStyles() { + applyModernStyles() { if (!this.blockingElement) { return; } @@ -69,66 +73,506 @@ if (typeof window.BlockingScreen === 'undefined') { top: 0; left: 0; z-index: 9999999; - display: flex; - justify-content: center; - align-items: center; - flex-direction: column; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; - text-align: center; - padding: 2rem; + background: #f8fafc; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + overflow-y: auto; box-sizing: border-box; `; } /** - * Update blocking screen content + * Create modern blocking screen content */ - async updateBlockingContent() { + async createModernContent() { if (!this.blockingElement) { return; } - const suggestionsHTML = await this.getSuggestionsHTML(); - // Check if this is a news site const isNews = await StorageHelper.isCurrentSiteNews(window.location.href, this.hostname); - - const emoji = isNews ? 'πŸ“°' : '🌱'; - const title = isNews ? 'News Break Time!' : 'Time to Touch Grass!'; - const message = isNews - ? "You've spent 20 minutes reading news today. Take a break for 60 minutes." - : "You've been scrolling too much. This site is blocked for 60 minutes."; + const siteName = this.hostname.replace('www.', '').split('.')[0]; this.blockingElement.innerHTML = ` -
-
${emoji}
-

- ${title} -

-

- ${message} -

- -
+
-
- Loading... + +
+
⏸️
+ +

+ ${siteName} is blocked +

+ +

+ ${ + isNews + ? "You've reached your 20-minute daily news limit" + : 'Take a break from scrolling and try something productive' + } +

+ + +
+
+ Loading... +
+
+ Time remaining +
+
-
- until you can access ${isNews ? 'news sites' : 'this site'} again + + +
+

+ Better things to do right now +

+ +
+ +
+
+ + + + + +
+
- - ${suggestionsHTML}
`; + + await this.initializeActivityCards(); + this.createActionButtons(); + } + + /** + * Initialize swipeable activity cards + */ + async initializeActivityCards() { + const container = document.getElementById('activities-container'); + if (!container) { + return; + } + + try { + const personalData = await this.getPersonalData(); + const activities = this.generateActivityCards(personalData); + + if (activities.length === 0) { + container.innerHTML = ` +
+
🎯
+

No activities available

+
+ `; + return; + } + + // Create swipe cards component + this.swipeCards = new window.SwipeCards(activities, { + onSwipeRight: (activity) => this.handleActivityAccepted(activity), + onSwipeLeft: (activity) => this.handleActivityRejected(activity), + onComplete: () => this.handleAllActivitiesCompleted(), + }); + + this.swipeCards.render(container); + } catch (error) { + console.error('Error initializing activity cards:', error); + container.innerHTML = ` +
+ Error loading activities +
+ `; + } + } + + /** + * Generate activity cards from personal data + */ + generateActivityCards(personalData) { + const activities = []; + + if (!personalData.hasData) { + return this.getDefaultActivityCards(); + } + + // Add current tasks (highest priority) + if (personalData.currentTasks && personalData.currentTasks.length > 0) { + personalData.currentTasks.forEach((task) => { + activities.push({ + emoji: 'βœ…', + text: task, + description: 'Current task to complete', + category: 'current', + }); + }); + } + + // Add household tasks + if (personalData.householdTasks && personalData.householdTasks.length > 0) { + personalData.householdTasks.forEach((task) => { + activities.push({ + emoji: '🏠', + text: task, + description: 'Household task', + category: 'household', + }); + }); + } + + // Add friends to contact + if (personalData.friends && personalData.friends.length > 0) { + personalData.friends.forEach((friend) => { + activities.push({ + emoji: 'πŸ“ž', + text: `Call ${friend}`, + description: 'Connect with someone you care about', + category: 'social', + }); + }); + } + + // Add hobbies + if (personalData.hobbies && personalData.hobbies.length > 0) { + personalData.hobbies.forEach((hobby) => { + activities.push({ + emoji: '🎯', + text: hobby, + description: 'Pursue your interests', + category: 'hobby', + }); + }); + } + + // Add books + if (personalData.books && personalData.books.length > 0) { + personalData.books.forEach((book) => { + activities.push({ + emoji: 'πŸ“š', + text: `Read "${book}"`, + description: 'Expand your mind', + category: 'reading', + }); + }); + } + + // Add goals + if (personalData.goals && personalData.goals.length > 0) { + personalData.goals.forEach((goal) => { + activities.push({ + emoji: '🎯', + text: `Work on: ${goal}`, + description: 'Make progress on your goals', + category: 'goal', + }); + }); + } + + // Shuffle activities for variety + return this.shuffleArray(activities).slice(0, 10); // Max 10 activities + } + + /** + * Get default activity cards when no personal data is available + */ + getDefaultActivityCards() { + return [ + { + emoji: 'πŸšΆβ€β™‚οΈ', + text: 'Take a walk outside', + description: 'Get some fresh air and movement', + }, + { + emoji: 'πŸ“š', + text: 'Read a book', + description: 'Engage your mind with something meaningful', + }, + { + emoji: 'πŸ§˜β€β™€οΈ', + text: 'Meditate for 5 minutes', + description: 'Practice mindfulness and calm your thoughts', + }, + { + emoji: 'πŸ’ͺ', + text: 'Do some exercise', + description: 'Get your body moving and energy flowing', + }, + { + emoji: 'πŸ‘₯', + text: 'Call a friend or family member', + description: 'Connect with someone you care about', + }, + { + emoji: '🎨', + text: 'Be creative', + description: 'Draw, write, or make something with your hands', + }, + { + emoji: '🧹', + text: 'Tidy up your space', + description: 'Organize your environment for mental clarity', + }, + { + emoji: 'πŸ’§', + text: 'Drink some water', + description: 'Hydrate and take care of your body', + }, + { + emoji: '🌱', + text: 'Do some stretching', + description: 'Release tension and improve flexibility', + }, + { + emoji: 'πŸ“', + text: 'Write in a journal', + description: 'Reflect on your thoughts and feelings', + }, + ]; + } + + /** + * Shuffle array for randomness + */ + shuffleArray(array) { + const shuffled = [...array]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; + } + + /** + * Handle when user swipes right (accepts) an activity + */ + handleActivityAccepted(activity) { + console.log('Activity accepted:', activity); + this.selectedActivity = activity; + this.showActivityTimer(); + } + + /** + * Handle when user swipes left (rejects) an activity + */ + handleActivityRejected(activity) { + console.log('Activity rejected:', activity); + // Just continue to next card + } + + /** + * Handle when all activities are completed + */ + handleAllActivitiesCompleted() { + const container = document.getElementById('activities-container'); + if (container) { + container.innerHTML = ` +
+
πŸŽ‰
+

+ You've seen all available activities! +

+
+ `; + } + } + + /** + * Show the activity timer section + */ + showActivityTimer() { + this.currentMode = 'timer'; + + // Hide activities section + const activitiesSection = document.getElementById('activities-section'); + if (activitiesSection) { + activitiesSection.style.display = 'none'; + } + + // Show timer section + const timerSection = document.getElementById('timer-section'); + if (timerSection) { + timerSection.style.display = 'block'; + } + + // Create activity timer + const timerContainer = document.getElementById('timer-container'); + if (timerContainer && this.selectedActivity) { + this.activityTimer = new window.ActivityTimer({ + duration: 5 * 60 * 1000, // 5 minutes + onComplete: () => this.handleTimerComplete(), + onTick: (remaining) => this.handleTimerTick(remaining), + }); + + this.activityTimer.render(timerContainer); + this.activityTimer.setActivity(this.selectedActivity); + } + + // Update action buttons + this.createActionButtons(); + } + + /** + * Handle timer completion + */ + handleTimerComplete() { + console.log('Activity timer completed'); + // Timer component handles the completion dialog internally + } + + /** + * Handle timer tick + */ + handleTimerTick(_remaining) { + // Optional: Update any global state or UI + } + + /** + * Create action buttons based on current mode + */ + createActionButtons() { + const container = document.getElementById('action-buttons'); + if (!container) { + return; + } + + container.innerHTML = ''; + + if (this.currentMode === 'blocked') { + // Show "Skip Activities" button with subtle green accent + const skipButton = new window.HeadlessButton('Skip activities', { + color: 'zinc', + outline: true, + onClick: () => this.skipActivities(), + }); + + container.appendChild(skipButton.element); + } else if (this.currentMode === 'timer') { + // Show "Back to Activities" button + const backButton = new window.HeadlessButton('← Back to activities', { + color: 'zinc', + outline: true, + onClick: () => this.backToActivities(), + }); + + container.appendChild(backButton.element); + } + } + + /** + * Skip activities and just show countdown + */ + skipActivities() { + const activitiesSection = document.getElementById('activities-section'); + const actionButtons = document.getElementById('action-buttons'); + + if (activitiesSection) { + activitiesSection.style.display = 'none'; + } + if (actionButtons) { + actionButtons.style.display = 'none'; + } + } + + /** + * Go back to activities from timer + */ + backToActivities() { + this.currentMode = 'blocked'; + + // Clean up timer + if (this.activityTimer) { + this.activityTimer.destroy(); + this.activityTimer = null; + } + + // Hide timer section + const timerSection = document.getElementById('timer-section'); + if (timerSection) { + timerSection.style.display = 'none'; + } + + // Show activities section + const activitiesSection = document.getElementById('activities-section'); + if (activitiesSection) { + activitiesSection.style.display = 'block'; + } + + // Update action buttons + this.createActionButtons(); } /** @@ -414,10 +858,26 @@ if (typeof window.BlockingScreen === 'undefined') { cleanup() { this.stopCountdownUpdates(); + // Clean up swipe cards + if (this.swipeCards) { + this.swipeCards.destroy(); + this.swipeCards = null; + } + + // Clean up activity timer + if (this.activityTimer) { + this.activityTimer.destroy(); + this.activityTimer = null; + } + if (this.blockingElement && this.blockingElement.parentNode) { this.blockingElement.parentNode.removeChild(this.blockingElement); this.blockingElement = null; } + + // Reset state + this.currentMode = 'blocked'; + this.selectedActivity = null; } /** diff --git a/Shared (Extension)/Resources/swipe-cards.js b/Shared (Extension)/Resources/swipe-cards.js new file mode 100644 index 0000000..be4e308 --- /dev/null +++ b/Shared (Extension)/Resources/swipe-cards.js @@ -0,0 +1,460 @@ +// UIComponentsJs/swipe-cards.js +// Tinder-like swipeable card component for activity suggestions + +class SwipeCards { + constructor(cards, options = {}) { + this.cards = cards || []; + this.currentIndex = 0; + this.options = { + containerClass: options.containerClass || '', + onSwipeRight: options.onSwipeRight || null, + onSwipeLeft: options.onSwipeLeft || null, + onComplete: options.onComplete || null, + ...options, + }; + + this.element = null; + this.isDragging = false; + this.startX = 0; + this.startY = 0; + this.currentX = 0; + this.currentY = 0; + this.offset = 0; + + // Bind methods + this.handleTouchStart = this.handleTouchStart.bind(this); + this.handleTouchMove = this.handleTouchMove.bind(this); + this.handleTouchEnd = this.handleTouchEnd.bind(this); + this.handleMouseDown = this.handleMouseDown.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleMouseUp = this.handleMouseUp.bind(this); + } + + render(container) { + if (!container) { + throw new Error('Container element is required'); + } + + this.element = document.createElement('div'); + this.element.className = `swipe-cards-container ${this.options.containerClass}`; + this.element.style.cssText = ` + position: relative; + width: 100%; + height: 400px; + overflow: hidden; + touch-action: none; + user-select: none; + `; + + this.createCards(); + this.setupEventListeners(); + + container.appendChild(this.element); + return this.element; + } + + createCards() { + if (!this.element) { + return; + } + + this.element.innerHTML = ''; + + if (this.cards.length === 0) { + this.showEmptyState(); + return; + } + + // Create cards stack (show up to 3 cards) + const visibleCards = this.cards.slice(this.currentIndex, this.currentIndex + 3); + + visibleCards.forEach((card, index) => { + const cardElement = this.createCard(card, index); + this.element.appendChild(cardElement); + }); + + // Add instructions + this.addInstructions(); + } + + createCard(card, stackIndex) { + const cardElement = document.createElement('div'); + cardElement.className = 'swipe-card'; + cardElement.dataset.stackIndex = stackIndex; + + const zIndex = 10 - stackIndex; + const scale = 1 - stackIndex * 0.05; + const yOffset = stackIndex * 10; + + cardElement.style.cssText = ` + position: absolute; + top: ${yOffset}px; + left: 0; + right: 0; + bottom: 0; + background: white; + border-radius: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + border: 1px solid rgba(0, 0, 0, 0.08); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 32px; + cursor: grab; + transform: scale(${scale}); + transform-origin: center; + z-index: ${zIndex}; + transition: transform 0.3s ease, opacity 0.3s ease; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + `; + + cardElement.innerHTML = ` +
+ ${card.emoji} +
+

+ ${card.text} +

+ ${ + card.description + ? ` +

+ ${card.description} +

+ ` + : '' + } + `; + + // Only make the top card interactive + if (stackIndex === 0) { + this.makeCardInteractive(cardElement); + } + + return cardElement; + } + + makeCardInteractive(cardElement) { + // Touch events + cardElement.addEventListener('touchstart', this.handleTouchStart, { passive: false }); + cardElement.addEventListener('touchmove', this.handleTouchMove, { passive: false }); + cardElement.addEventListener('touchend', this.handleTouchEnd, { passive: false }); + + // Mouse events for desktop + cardElement.addEventListener('mousedown', this.handleMouseDown); + cardElement.addEventListener('mousemove', this.handleMouseMove); + cardElement.addEventListener('mouseup', this.handleMouseUp); + cardElement.addEventListener('mouseleave', this.handleMouseUp); + + // Prevent default drag behavior + cardElement.addEventListener('dragstart', (e) => e.preventDefault()); + } + + addInstructions() { + const instructions = document.createElement('div'); + instructions.className = 'swipe-instructions'; + instructions.style.cssText = ` + position: absolute; + bottom: -60px; + left: 0; + right: 0; + text-align: center; + font-size: 0.9rem; + color: #6b7280; + display: flex; + justify-content: space-between; + padding: 0 20px; + `; + + instructions.innerHTML = ` + + ← Pass + + + Swipe to choose + + + Do it! β†’ + + `; + + this.element.appendChild(instructions); + } + + showEmptyState() { + this.element.innerHTML = ` +
+
🎯
+

No more activities

+
+ `; + } + + setupEventListeners() { + // Global mouse events for when dragging outside card + document.addEventListener('mousemove', this.handleMouseMove); + document.addEventListener('mouseup', this.handleMouseUp); + } + + handleTouchStart(e) { + this.isDragging = true; + this.startX = e.touches[0].clientX; + this.startY = e.touches[0].clientY; + this.currentX = this.startX; + this.currentY = this.startY; + + e.target.style.cursor = 'grabbing'; + e.preventDefault(); + } + + handleTouchMove(e) { + if (!this.isDragging) { + return; + } + + this.currentX = e.touches[0].clientX; + this.currentY = e.touches[0].clientY; + + this.updateCardPosition(e.target); + e.preventDefault(); + } + + handleTouchEnd(e) { + if (!this.isDragging) { + return; + } + + this.isDragging = false; + this.finishSwipe(e.target); + e.target.style.cursor = 'grab'; + } + + handleMouseDown(e) { + this.isDragging = true; + this.startX = e.clientX; + this.startY = e.clientY; + this.currentX = this.startX; + this.currentY = this.startY; + + e.target.style.cursor = 'grabbing'; + e.preventDefault(); + } + + handleMouseMove(e) { + if (!this.isDragging) { + return; + } + + this.currentX = e.clientX; + this.currentY = e.clientY; + + const topCard = this.element.querySelector('[data-stack-index="0"]'); + if (topCard) { + this.updateCardPosition(topCard); + } + } + + handleMouseUp(_e) { + if (!this.isDragging) { + return; + } + + this.isDragging = false; + const topCard = this.element.querySelector('[data-stack-index="0"]'); + if (topCard) { + this.finishSwipe(topCard); + topCard.style.cursor = 'grab'; + } + } + + updateCardPosition(cardElement) { + const deltaX = this.currentX - this.startX; + const deltaY = this.currentY - this.startY; + + // Limit vertical movement + const limitedDeltaY = Math.max(-50, Math.min(50, deltaY)); + + this.offset = deltaX; + + const rotation = deltaX * 0.1; // Subtle rotation + const opacity = Math.max(0.7, 1 - Math.abs(deltaX) / 200); + + cardElement.style.transform = `translateX(${deltaX}px) translateY(${limitedDeltaY}px) rotate(${rotation}deg)`; + cardElement.style.opacity = opacity; + + // Update visual feedback + this.updateSwipeFeedback(deltaX); + } + + updateSwipeFeedback(deltaX) { + const threshold = 100; + + // Remove any existing feedback + this.clearSwipeFeedback(); + + if (Math.abs(deltaX) > threshold) { + const feedback = document.createElement('div'); + feedback.className = 'swipe-feedback'; + feedback.style.cssText = ` + position: absolute; + top: 50%; + transform: translateY(-50%); + font-size: 2rem; + font-weight: bold; + z-index: 1000; + pointer-events: none; + transition: opacity 0.1s ease; + `; + + if (deltaX > threshold) { + // Swiping right (accept) + feedback.style.right = '20px'; + feedback.style.color = '#10b981'; + feedback.textContent = 'DO IT! βœ“'; + } else { + // Swiping left (reject) + feedback.style.left = '20px'; + feedback.style.color = '#ef4444'; + feedback.textContent = 'PASS βœ—'; + } + + this.element.appendChild(feedback); + } + } + + clearSwipeFeedback() { + const feedback = this.element.querySelector('.swipe-feedback'); + if (feedback) { + feedback.remove(); + } + } + + finishSwipe(cardElement) { + const threshold = 100; + const shouldSwipe = Math.abs(this.offset) > threshold; + + this.clearSwipeFeedback(); + + if (shouldSwipe) { + this.completeSwipe(cardElement, this.offset > 0 ? 'right' : 'left'); + } else { + this.resetCard(cardElement); + } + + this.offset = 0; + } + + completeSwipe(cardElement, direction) { + const finalX = direction === 'right' ? window.innerWidth : -window.innerWidth; + const rotation = direction === 'right' ? 30 : -30; + + cardElement.style.transition = 'transform 0.3s ease, opacity 0.3s ease'; + cardElement.style.transform = `translateX(${finalX}px) rotate(${rotation}deg)`; + cardElement.style.opacity = '0'; + + // Get current card data + const currentCard = this.cards[this.currentIndex]; + + // Trigger callbacks + if (direction === 'right' && this.options.onSwipeRight) { + this.options.onSwipeRight(currentCard, this.currentIndex); + } else if (direction === 'left' && this.options.onSwipeLeft) { + this.options.onSwipeLeft(currentCard, this.currentIndex); + } + + // Move to next card after animation + setTimeout(() => { + this.nextCard(); + }, 300); + } + + resetCard(cardElement) { + cardElement.style.transition = 'transform 0.3s ease, opacity 0.3s ease'; + cardElement.style.transform = 'translateX(0px) translateY(0px) rotate(0deg)'; + cardElement.style.opacity = '1'; + + setTimeout(() => { + cardElement.style.transition = ''; + }, 300); + } + + nextCard() { + this.currentIndex++; + + if (this.currentIndex >= this.cards.length) { + // All cards swiped + if (this.options.onComplete) { + this.options.onComplete(); + } + return; + } + + // Recreate cards with new stack + this.createCards(); + } + + // Public methods + addCard(card) { + this.cards.push(card); + if (this.element && this.currentIndex >= this.cards.length - 1) { + this.createCards(); + } + } + + reset() { + this.currentIndex = 0; + if (this.element) { + this.createCards(); + } + } + + getCurrentCard() { + return this.cards[this.currentIndex]; + } + + getRemainingCards() { + return this.cards.slice(this.currentIndex); + } + + destroy() { + if (this.element) { + // Remove global event listeners + document.removeEventListener('mousemove', this.handleMouseMove); + document.removeEventListener('mouseup', this.handleMouseUp); + + // Remove element + if (this.element.parentNode) { + this.element.parentNode.removeChild(this.element); + } + this.element = null; + } + } +} + +// Export for use in modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = SwipeCards; +} else { + window.SwipeCards = SwipeCards; +} diff --git a/UIComponentsJs/activity-timer.js b/UIComponentsJs/activity-timer.js new file mode 100644 index 0000000..f1cec99 --- /dev/null +++ b/UIComponentsJs/activity-timer.js @@ -0,0 +1,483 @@ +// UIComponentsJs/activity-timer.js +// 5-minute activity timer with completion celebration + +class ActivityTimer { + constructor(options = {}) { + this.options = { + duration: options.duration || 5 * 60 * 1000, // 5 minutes default + onComplete: options.onComplete || null, + onTick: options.onTick || null, + containerClass: options.containerClass || '', + ...options, + }; + + this.element = null; + this.isRunning = false; + this.startTime = null; + this.remainingTime = this.options.duration; + this.interval = null; + this.activity = null; + } + + render(container) { + if (!container) { + throw new Error('Container element is required'); + } + + this.element = document.createElement('div'); + this.element.className = `activity-timer ${this.options.containerClass}`; + this.element.style.cssText = ` + background: white; + border-radius: 16px; + padding: 32px; + text-align: center; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + border: 1px solid rgba(0, 0, 0, 0.08); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + `; + + this.createTimerContent(); + container.appendChild(this.element); + return this.element; + } + + createTimerContent() { + if (!this.element) return; + + this.element.innerHTML = ` +
+
+ ⏱️ +
+ +

+ Ready to start? +

+ +

+ Choose an activity to begin your 5-minute timer +

+ +
+ 5:00 +
+ +
+
+
+ +
+ +
+
+ `; + + this.createControls(); + } + + createControls() { + const controlsContainer = this.element.querySelector('#timer-controls'); + if (!controlsContainer) return; + + if (!this.isRunning) { + // Show start button + const startButton = new HeadlessButton('Start Timer', { + color: 'zinc', + size: 'lg', + onClick: () => this.start(), + }); + + controlsContainer.appendChild(startButton.element); + } else { + // Show pause/resume and stop buttons + const pauseButton = new HeadlessButton('Pause', { + color: 'zinc', + outline: true, + onClick: () => this.pause(), + }); + + const stopButton = new HeadlessButton('Stop', { + color: 'red', + outline: true, + onClick: () => this.stop(), + }); + + const buttonContainer = document.createElement('div'); + buttonContainer.style.cssText = ` + display: flex; + gap: 12px; + justify-content: center; + `; + + buttonContainer.appendChild(pauseButton.element); + buttonContainer.appendChild(stopButton.element); + controlsContainer.appendChild(buttonContainer); + } + } + + setActivity(activity) { + this.activity = activity; + + const emoji = this.element.querySelector('#activity-emoji'); + const title = this.element.querySelector('#activity-title'); + const description = this.element.querySelector('#activity-description'); + + if (emoji) emoji.textContent = activity.emoji; + if (title) title.textContent = activity.text; + if (description) { + description.textContent = `Take 5 minutes to focus on this activity`; + } + } + + start() { + if (this.isRunning) return; + + this.isRunning = true; + this.startTime = Date.now(); + + // If we're resuming, adjust start time + if (this.remainingTime < this.options.duration) { + this.startTime = Date.now() - (this.options.duration - this.remainingTime); + } + + this.interval = setInterval(() => { + this.tick(); + }, 100); // Update every 100ms for smooth progress + + this.updateDisplay(); + this.createControls(); + } + + pause() { + if (!this.isRunning) return; + + this.isRunning = false; + this.remainingTime = Math.max(0, this.options.duration - (Date.now() - this.startTime)); + + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + + this.updateDisplay(); + this.createControls(); + } + + stop() { + this.isRunning = false; + this.remainingTime = this.options.duration; + + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + + this.updateDisplay(); + this.createControls(); + } + + tick() { + if (!this.isRunning) return; + + const elapsed = Date.now() - this.startTime; + this.remainingTime = Math.max(0, this.options.duration - elapsed); + + this.updateDisplay(); + + if (this.options.onTick) { + this.options.onTick(this.remainingTime, elapsed); + } + + if (this.remainingTime <= 0) { + this.complete(); + } + } + + complete() { + this.isRunning = false; + + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + + this.showCompletionDialog(); + + if (this.options.onComplete) { + this.options.onComplete(); + } + } + + updateDisplay() { + const timerDisplay = this.element.querySelector('#timer-display'); + const progressBar = this.element.querySelector('#progress-bar'); + + if (timerDisplay) { + const minutes = Math.floor(this.remainingTime / 60000); + const seconds = Math.floor((this.remainingTime % 60000) / 1000); + timerDisplay.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`; + } + + if (progressBar) { + const progress = ((this.options.duration - this.remainingTime) / this.options.duration) * 100; + progressBar.style.width = `${Math.min(100, progress)}%`; + } + } + + showCompletionDialog() { + // Replace timer content with completion dialog + this.element.innerHTML = ` +
+
+ πŸŽ‰ +
+ +

+ Time's Up! +

+ +

+ Did you successfully complete "${this.activity?.text || 'your activity'}"? +

+ +
+ +
+
+ `; + + this.createCompletionControls(); + } + + createCompletionControls() { + const controlsContainer = this.element.querySelector('#completion-controls'); + if (!controlsContainer) return; + + const yesButton = new HeadlessButton('Yes! πŸŽ‰', { + color: 'green', + size: 'lg', + onClick: () => this.handleCompletion(true), + }); + + const noButton = new HeadlessButton('Not quite', { + color: 'zinc', + outline: true, + size: 'lg', + onClick: () => this.handleCompletion(false), + }); + + controlsContainer.appendChild(yesButton.element); + controlsContainer.appendChild(noButton.element); + } + + handleCompletion(successful) { + if (successful) { + this.showCelebration(); + } else { + this.showEncouragement(); + } + } + + showCelebration() { + this.element.innerHTML = ` +
+
+ πŸ† +
+ +

+ Awesome! πŸŽ‰ +

+ +

+ You completed your activity! That's a great step towards breaking the doomscroll habit. +

+ +
+ ${this.createConfetti()} +
+
+ `; + + this.animateCelebration(); + } + + showEncouragement() { + this.element.innerHTML = ` +
+
+ πŸ’ͺ +
+ +

+ That's okay! +

+ +

+ Taking a break from scrolling is already a win. Every small step counts! +

+
+ `; + } + + createConfetti() { + const colors = ['#10b981', '#6366f1', '#f59e0b', '#ef4444', '#8b5cf6']; + let confetti = ''; + + for (let i = 0; i < 20; i++) { + const color = colors[Math.floor(Math.random() * colors.length)]; + const delay = Math.random() * 2; + const duration = 2 + Math.random() * 2; + const left = Math.random() * 100; + + confetti += ` +
+ `; + } + + return `
${confetti}
`; + } + + animateCelebration() { + // Add CSS animations + const style = document.createElement('style'); + style.textContent = ` + @keyframes bounce { + 0%, 20%, 50%, 80%, 100% { + transform: translateY(0); + } + 40% { + transform: translateY(-20px); + } + 60% { + transform: translateY(-10px); + } + } + + @keyframes confetti-fall { + 0% { + transform: translateY(-10px) rotate(0deg); + opacity: 1; + } + 100% { + transform: translateY(100px) rotate(360deg); + opacity: 0; + } + } + `; + + document.head.appendChild(style); + + // Remove style after animation + setTimeout(() => { + if (style.parentNode) { + style.parentNode.removeChild(style); + } + }, 10000); + } + + // Public methods + reset() { + this.stop(); + this.createTimerContent(); + } + + getRemainingTime() { + return this.remainingTime; + } + + isTimerRunning() { + return this.isRunning; + } + + destroy() { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + + if (this.element && this.element.parentNode) { + this.element.parentNode.removeChild(this.element); + this.element = null; + } + } +} + +// Export for use in modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = ActivityTimer; +} else { + window.ActivityTimer = ActivityTimer; +} diff --git a/UIComponentsJs/swipe-cards.js b/UIComponentsJs/swipe-cards.js new file mode 100644 index 0000000..1bafd2c --- /dev/null +++ b/UIComponentsJs/swipe-cards.js @@ -0,0 +1,450 @@ +// UIComponentsJs/swipe-cards.js +// Tinder-like swipeable card component for activity suggestions + +class SwipeCards { + constructor(cards, options = {}) { + this.cards = cards || []; + this.currentIndex = 0; + this.options = { + containerClass: options.containerClass || '', + onSwipeRight: options.onSwipeRight || null, + onSwipeLeft: options.onSwipeLeft || null, + onComplete: options.onComplete || null, + ...options, + }; + + this.element = null; + this.isDragging = false; + this.startX = 0; + this.startY = 0; + this.currentX = 0; + this.currentY = 0; + this.offset = 0; + + // Bind methods + this.handleTouchStart = this.handleTouchStart.bind(this); + this.handleTouchMove = this.handleTouchMove.bind(this); + this.handleTouchEnd = this.handleTouchEnd.bind(this); + this.handleMouseDown = this.handleMouseDown.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleMouseUp = this.handleMouseUp.bind(this); + } + + render(container) { + if (!container) { + throw new Error('Container element is required'); + } + + this.element = document.createElement('div'); + this.element.className = `swipe-cards-container ${this.options.containerClass}`; + this.element.style.cssText = ` + position: relative; + width: 100%; + height: 400px; + overflow: hidden; + touch-action: none; + user-select: none; + `; + + this.createCards(); + this.setupEventListeners(); + + container.appendChild(this.element); + return this.element; + } + + createCards() { + if (!this.element) return; + + this.element.innerHTML = ''; + + if (this.cards.length === 0) { + this.showEmptyState(); + return; + } + + // Create cards stack (show up to 3 cards) + const visibleCards = this.cards.slice(this.currentIndex, this.currentIndex + 3); + + visibleCards.forEach((card, index) => { + const cardElement = this.createCard(card, index); + this.element.appendChild(cardElement); + }); + + // Add instructions + this.addInstructions(); + } + + createCard(card, stackIndex) { + const cardElement = document.createElement('div'); + cardElement.className = 'swipe-card'; + cardElement.dataset.stackIndex = stackIndex; + + const zIndex = 10 - stackIndex; + const scale = 1 - stackIndex * 0.05; + const yOffset = stackIndex * 10; + + cardElement.style.cssText = ` + position: absolute; + top: ${yOffset}px; + left: 0; + right: 0; + bottom: 0; + background: white; + border-radius: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + border: 1px solid rgba(0, 0, 0, 0.08); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 32px; + cursor: grab; + transform: scale(${scale}); + transform-origin: center; + z-index: ${zIndex}; + transition: transform 0.3s ease, opacity 0.3s ease; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + `; + + cardElement.innerHTML = ` +
+ ${card.emoji} +
+

+ ${card.text} +

+ ${ + card.description + ? ` +

+ ${card.description} +

+ ` + : '' + } + `; + + // Only make the top card interactive + if (stackIndex === 0) { + this.makeCardInteractive(cardElement); + } + + return cardElement; + } + + makeCardInteractive(cardElement) { + // Touch events + cardElement.addEventListener('touchstart', this.handleTouchStart, { passive: false }); + cardElement.addEventListener('touchmove', this.handleTouchMove, { passive: false }); + cardElement.addEventListener('touchend', this.handleTouchEnd, { passive: false }); + + // Mouse events for desktop + cardElement.addEventListener('mousedown', this.handleMouseDown); + cardElement.addEventListener('mousemove', this.handleMouseMove); + cardElement.addEventListener('mouseup', this.handleMouseUp); + cardElement.addEventListener('mouseleave', this.handleMouseUp); + + // Prevent default drag behavior + cardElement.addEventListener('dragstart', (e) => e.preventDefault()); + } + + addInstructions() { + const instructions = document.createElement('div'); + instructions.className = 'swipe-instructions'; + instructions.style.cssText = ` + position: absolute; + bottom: -60px; + left: 0; + right: 0; + text-align: center; + font-size: 0.9rem; + color: #6b7280; + display: flex; + justify-content: space-between; + padding: 0 20px; + `; + + instructions.innerHTML = ` + + ← Pass + + + Swipe to choose + + + Do it! β†’ + + `; + + this.element.appendChild(instructions); + } + + showEmptyState() { + this.element.innerHTML = ` +
+
🎯
+

No more activities

+
+ `; + } + + setupEventListeners() { + // Global mouse events for when dragging outside card + document.addEventListener('mousemove', this.handleMouseMove); + document.addEventListener('mouseup', this.handleMouseUp); + } + + handleTouchStart(e) { + this.isDragging = true; + this.startX = e.touches[0].clientX; + this.startY = e.touches[0].clientY; + this.currentX = this.startX; + this.currentY = this.startY; + + e.target.style.cursor = 'grabbing'; + e.preventDefault(); + } + + handleTouchMove(e) { + if (!this.isDragging) return; + + this.currentX = e.touches[0].clientX; + this.currentY = e.touches[0].clientY; + + this.updateCardPosition(e.target); + e.preventDefault(); + } + + handleTouchEnd(e) { + if (!this.isDragging) return; + + this.isDragging = false; + this.finishSwipe(e.target); + e.target.style.cursor = 'grab'; + } + + handleMouseDown(e) { + this.isDragging = true; + this.startX = e.clientX; + this.startY = e.clientY; + this.currentX = this.startX; + this.currentY = this.startY; + + e.target.style.cursor = 'grabbing'; + e.preventDefault(); + } + + handleMouseMove(e) { + if (!this.isDragging) return; + + this.currentX = e.clientX; + this.currentY = e.clientY; + + const topCard = this.element.querySelector('[data-stack-index="0"]'); + if (topCard) { + this.updateCardPosition(topCard); + } + } + + handleMouseUp(e) { + if (!this.isDragging) return; + + this.isDragging = false; + const topCard = this.element.querySelector('[data-stack-index="0"]'); + if (topCard) { + this.finishSwipe(topCard); + topCard.style.cursor = 'grab'; + } + } + + updateCardPosition(cardElement) { + const deltaX = this.currentX - this.startX; + const deltaY = this.currentY - this.startY; + + // Limit vertical movement + const limitedDeltaY = Math.max(-50, Math.min(50, deltaY)); + + this.offset = deltaX; + + const rotation = deltaX * 0.1; // Subtle rotation + const opacity = Math.max(0.7, 1 - Math.abs(deltaX) / 200); + + cardElement.style.transform = `translateX(${deltaX}px) translateY(${limitedDeltaY}px) rotate(${rotation}deg)`; + cardElement.style.opacity = opacity; + + // Update visual feedback + this.updateSwipeFeedback(deltaX); + } + + updateSwipeFeedback(deltaX) { + const threshold = 100; + + // Remove any existing feedback + this.clearSwipeFeedback(); + + if (Math.abs(deltaX) > threshold) { + const feedback = document.createElement('div'); + feedback.className = 'swipe-feedback'; + feedback.style.cssText = ` + position: absolute; + top: 50%; + transform: translateY(-50%); + font-size: 2rem; + font-weight: bold; + z-index: 1000; + pointer-events: none; + transition: opacity 0.1s ease; + `; + + if (deltaX > threshold) { + // Swiping right (accept) + feedback.style.right = '20px'; + feedback.style.color = '#10b981'; + feedback.textContent = 'DO IT! βœ“'; + } else { + // Swiping left (reject) + feedback.style.left = '20px'; + feedback.style.color = '#ef4444'; + feedback.textContent = 'PASS βœ—'; + } + + this.element.appendChild(feedback); + } + } + + clearSwipeFeedback() { + const feedback = this.element.querySelector('.swipe-feedback'); + if (feedback) { + feedback.remove(); + } + } + + finishSwipe(cardElement) { + const threshold = 100; + const shouldSwipe = Math.abs(this.offset) > threshold; + + this.clearSwipeFeedback(); + + if (shouldSwipe) { + this.completeSwipe(cardElement, this.offset > 0 ? 'right' : 'left'); + } else { + this.resetCard(cardElement); + } + + this.offset = 0; + } + + completeSwipe(cardElement, direction) { + const finalX = direction === 'right' ? window.innerWidth : -window.innerWidth; + const rotation = direction === 'right' ? 30 : -30; + + cardElement.style.transition = 'transform 0.3s ease, opacity 0.3s ease'; + cardElement.style.transform = `translateX(${finalX}px) rotate(${rotation}deg)`; + cardElement.style.opacity = '0'; + + // Get current card data + const currentCard = this.cards[this.currentIndex]; + + // Trigger callbacks + if (direction === 'right' && this.options.onSwipeRight) { + this.options.onSwipeRight(currentCard, this.currentIndex); + } else if (direction === 'left' && this.options.onSwipeLeft) { + this.options.onSwipeLeft(currentCard, this.currentIndex); + } + + // Move to next card after animation + setTimeout(() => { + this.nextCard(); + }, 300); + } + + resetCard(cardElement) { + cardElement.style.transition = 'transform 0.3s ease, opacity 0.3s ease'; + cardElement.style.transform = 'translateX(0px) translateY(0px) rotate(0deg)'; + cardElement.style.opacity = '1'; + + setTimeout(() => { + cardElement.style.transition = ''; + }, 300); + } + + nextCard() { + this.currentIndex++; + + if (this.currentIndex >= this.cards.length) { + // All cards swiped + if (this.options.onComplete) { + this.options.onComplete(); + } + return; + } + + // Recreate cards with new stack + this.createCards(); + } + + // Public methods + addCard(card) { + this.cards.push(card); + if (this.element && this.currentIndex >= this.cards.length - 1) { + this.createCards(); + } + } + + reset() { + this.currentIndex = 0; + if (this.element) { + this.createCards(); + } + } + + getCurrentCard() { + return this.cards[this.currentIndex]; + } + + getRemainingCards() { + return this.cards.slice(this.currentIndex); + } + + destroy() { + if (this.element) { + // Remove global event listeners + document.removeEventListener('mousemove', this.handleMouseMove); + document.removeEventListener('mouseup', this.handleMouseUp); + + // Remove element + if (this.element.parentNode) { + this.element.parentNode.removeChild(this.element); + } + this.element = null; + } + } +} + +// Export for use in modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = SwipeCards; +} else { + window.SwipeCards = SwipeCards; +} From 621f5bd5d7af53fc85fa475a7cc094255968edcf Mon Sep 17 00:00:00 2001 From: "Staub Oliver I.BSCI_F22.2101" Date: Fri, 13 Jun 2025 23:59:30 +0200 Subject: [PATCH 2/3] zwischenstand - nicht gut --- CLAUDE.md | 323 ++++-------- ScrollStop.xcodeproj/project.pbxproj | 14 + .../xcschemes/xcschememanagement.plist | 4 +- .../Resources/activity-timer.js | 499 ------------------ Shared (Extension)/Resources/content.js | 80 ++- Shared (Extension)/Resources/manifest.json | 9 +- .../blocking-screen/blocking-screen.js | 81 ++- .../modules/choice-dialog/choice-dialog.js | 41 +- .../modules/timer-tracker/timer-tracker.js | 18 - Shared (Extension)/Resources/popup.html | 15 +- Shared (Extension)/Resources/swipe-cards.js | 460 ---------------- 11 files changed, 271 insertions(+), 1273 deletions(-) delete mode 100644 Shared (Extension)/Resources/activity-timer.js delete mode 100644 Shared (Extension)/Resources/swipe-cards.js diff --git a/CLAUDE.md b/CLAUDE.md index 92a745d..9d48f55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,21 +100,23 @@ ScrollStop monitors three distinct categories of sites with different blocking b - Extended 4-hour block duration as stronger deterrent - Comprehensive blocklist: Pornhub, OnlyFans, Xvideos, cam sites, etc. -### iOS Welcome Walkthrough +### Cross-Platform Configuration -The iOS app includes an interactive setup walkthrough that guides users through: +ScrollStop now features browser-based configuration that works identically on both iOS and macOS: -1. **Welcome screen** - Explains ScrollStop's purpose -2. **Safari extension setup** - Step-by-step instructions for enabling in Settings -3. **iOS Shortcuts guidance** - Explains why/how to redirect social media apps to Safari -4. **Completion screen** - Confirms setup and explains how ScrollStop works +**Browser-Based Questionnaire:** + +1. **Choice Dialog Integration** - "Configure Activities" option in choice dialog +2. **Multi-Step Setup** - 6 categories: household tasks, hobbies, current tasks, friends, goals, books +3. **Cross-Platform Storage** - Uses browser.storage.local with localStorage fallback +4. **Personalized Blocking Screen** - Shows user-specific activity suggestions **Key Features:** -- Remembers completion state using localStorage -- Shows simplified version after walkthrough is completed -- Includes placeholder for YouTube tutorial link -- Responsive design with dark mode support +- Identical functionality on iOS and macOS extensions +- Real-time data persistence during configuration +- Smart suggestion algorithm prioritizing current tasks +- Fallback to default suggestions when no personal data available ### File Structure Notes @@ -130,164 +132,100 @@ The iOS app includes an interactive setup walkthrough that guides users through: - The build process flattens all files into one directory, so paths with directories will break - Even though files are organized in subdirectories during development, manifest must reference them by filename only -## Memories - -- **UI Components Policy**: Only use UIComponentsJS components for all web UI elements. No other UI frameworks or plain HTML/CSS elements. iOS app uses SwiftUI natively. -- **Choice Dialog Feature**: When accessing blocked sites, users see a dialog with 3 options: Continue with ScrollStop (full functionality), Timer Only (no blocking), or Block Now (immediate block). Appears on every page reload. -- **Adult Sites Blocking**: Third site category with 4-hour block duration (vs 1-hour for social/news). Comprehensive 89+ site blocklist including major platforms, streaming, cam sites, and hentai. Timer tracking and choice dialog work identically to other categories. -- **Precommit Workflow**: ALWAYS run `npm run precommit` before committing. This automatically formats code with Prettier, then runs full validation (ESLint, tests, manifest validation). Never commit without this. -- **Feature Branch Workflow**: ⚠️ **CRITICAL WORKFLOW RULE** ⚠️ When working with Claude Code, ALWAYS create feature branches for new development using descriptive names like `fix/reddit-choice-dialog-text-alignment` or `feature/add-system-language-detection`. Create pull requests to trigger CI/CD validation before merging to main. This ensures all tests pass and code quality is maintained. **NEVER COMMIT DIRECTLY TO MAIN BRANCH.** Use feature branches for EVERY bug fix or new feature. **BEFORE ANY `git commit` COMMAND, VERIFY YOU ARE ON A FEATURE BRANCH WITH `git branch --show-current`** -- **npm Caching**: CI pipeline uses comprehensive caching strategy (setup-node + actions/cache) to reduce npm install time from ~5 minutes to ~30 seconds on cache hits. -- **Dependabot Dependency Management**: Use GitHub Dependabot for automatic dependency updates instead of manual package version management. Prevents CI issues with bleeding-edge versions by using stable, tested version ranges. -- **Stable Package Versions**: Use tilde (~) for patch version pinning on critical packages like Puppeteer to avoid registry sync issues with brand-new releases. - -### iOS App Personalization Features Implementation (Session Summary) - -**What We Built Today:** - -- **Comprehensive Questionnaire System**: Created QuestionnaireView with 5 detailed categories for personalized recommendations -- **Bilingual Support**: Full German/English language support with LanguageManager -- **Personalized Blocking Screen**: Updated blocking screen to show user-specific suggestions based on questionnaire data -- **iOS UI Improvements**: Removed welcome screen and disabled dark mode for better UX - -**Key Features Implemented:** - -**1. QuestionnaireView System:** - -- **5 Categories**: Household tasks, hobbies, current tasks, friends, goals, books -- **Smart Data Management**: Auto-deletion of current tasks after 2 weeks -- **Progress Tracking**: Visual progress indicator with step-by-step navigation -- **Real-time Persistence**: Data saved immediately as user adds items -- **Bilingual Interface**: Complete German/English support throughout - -**2. Personalized Blocking Screen:** - -- **Smart Recommendations**: Shows actual user tasks instead of generic suggestions -- **Priority System**: Current tasks β†’ household β†’ friends β†’ hobbies β†’ books β†’ goals -- **Randomization**: Picks random items from each category for variety -- **Fallback System**: Default suggestions when no personal data available -- **Cross-Platform Data**: Bridge between iOS app and Safari extension storage - -**3. Bilingual Support (LanguageManager):** - -- **Complete Translation**: All UI elements, placeholders, descriptions in German/English -- **Auto-Detection**: Detects system language preference -- **Manual Override**: Users can switch languages in questionnaire -- **Consistent Keys**: Standardized localization key system - -**4. iOS App UX Improvements:** - -- **Streamlined Walkthrough**: Removed welcome screen, starts directly with extension setup -- **Light Mode Only**: Disabled dark mode for consistent appearance -- **Integrated Questionnaire**: Replaced simple ProfileSetupView with comprehensive QuestionnaireView - -**Technical Architecture:** - -- **Data Bridge**: iOS app stores questionnaire data with `scrollstop_` prefixes for extension access -- **Storage Strategy**: UserDefaults for iOS, browser.storage.local for extension with localStorage fallback -- **Async Blocking Screen**: Updated blocking screen to handle async data loading -- **Smart Suggestions**: Algorithm that prioritizes current tasks and mixes categories - -**User Experience Flow:** - -1. **Setup Extension**: User enables Safari extension -2. **Setup Shortcuts**: User configures iOS shortcuts for social media redirection -3. **Personal Questionnaire**: User fills out 5 categories of personal data -4. **Personalized Blocking**: When blocked, sees specific tasks from their questionnaire -5. **Meaningful Alternatives**: Easy transition from scrolling to productive activities - -**Data Categories & Examples:** - -- **Household Tasks**: "Do laundry", "Clean table", "Empty dishwasher" -- **Hobbies**: "Cycling", "Gym", "Programming", "Guitar" -- **Current Tasks**: "Study for exam", "Buy groceries" (auto-deleted after 2 weeks) -- **Friends**: "Sister", "Flavio", "Samu" β†’ becomes "Call Sister" -- **Goals**: "Be more patient", "Exercise daily" β†’ becomes "Work on: Be more patient" -- **Books**: "Atomic Habits", "The Lean Startup" β†’ becomes "Read 'Atomic Habits'" - -**Implementation Benefits:** - -- **Higher Engagement**: Personal tasks more motivating than generic suggestions -- **Cultural Support**: German users get native language experience -- **Smart Prioritization**: Current tasks appear first when user is blocked -- **Reduced Friction**: Easier to leave social media when you see specific alternatives -- **Data Persistence**: Questionnaire data survives app updates and device changes - -**Technical Lessons:** - -- **Async Patterns**: Updated blocking screen methods to handle async suggestion generation -- **Cross-Platform Storage**: Used consistent key prefixes for data sharing between app and extension -- **Language Management**: Centralized translation system with fallback to keys -- **State Management**: Proper state handling for multi-step questionnaire flow - -**Code Quality Improvements:** - -- **Comprehensive Error Handling**: Graceful fallbacks when personal data unavailable -- **Memory Management**: Proper cleanup of old current tasks -- **Type Safety**: Strong typing throughout Swift components -- **Modular Design**: Separate concerns for data management, UI, and storage - -**User Impact:** -The app now transforms from a simple blocking tool into a personalized productivity assistant that knows the user's specific goals, tasks, and relationships, making it much easier to break the scrolling habit by providing meaningful, actionable alternatives. - -### GitHub Pages Component Integration Lessons (Session Summary) - -**Critical Web Component Styling Rules:** +## Component Architecture + +**CRITICAL RULE: Use only HeadlessButton components for all interactive UI elements.** + +### Available Components + +**Component Locations:** +- **Primary**: `/Shared (Extension)/Resources/components/` - Extension components +- **Source**: `/UIComponentsJs/` - Full component library (source of truth) + +**Core Components:** +- `HeadlessButton` - All buttons must use this component +- `HeadlessDialog` - Dialog containers with glassmorphism styling +- `HeadlessDivider` - Section dividers (soft/hard styles) +- `SwipeCards` - Activity card interface for blocking screen +- `ActivityTimer` - 5-minute focus timer component +- `QuestionnaireConfig` - Browser-based configuration interface + +### Component Usage Policy +```javascript +// βœ… CORRECT - Use HeadlessButton +const button = new HeadlessButton('Configure Activities', { + color: 'blue', + onClick: () => this.handleClick() +}); + +// ❌ WRONG - Never use custom CSS buttons + +``` + +**CRITICAL RULE: If component is not available, make it available - NEVER write custom CSS.** + +Examples: +- Popup context: Load `button.js` and make `window.HeadlessButton` available +- Missing component: Copy from `/UIComponentsJs/` to `/components/` and add to manifest.json +- Always use standard components, never fallback to custom styling + +### Color Scheme +- **Primary**: `color: 'blue'` (ScrollStop brand green) +- **Secondary**: `color: 'zinc', outline: true` (gray outline) +- **Destructive**: `color: 'red'` (delete/cancel actions) + +## Implementation Standards + +- **Choice Dialog**: Continue (blue), Configure Activities (zinc outline), Block Now (red outline) with HeadlessDivider separation +- **Questionnaire**: Add (blue), Previous (zinc outline), Cancel (red outline), Next/Finish (blue) with fallback support +- **Popup**: Configure Activities (blue) with minimal CSS, HeadlessButton for all interactive elements +- **Cross-Platform**: Extensions use HeadlessButton, iOS app uses SwiftUI +- **Adult Sites Blocking**: 4-hour blocks, 89+ sites, timer tracking and choice dialog +- **Workflow**: Feature branches, `npm run precommit`, PR validation, Dependabot updates + +## Custom CSS Cleanup + +**NEVER use custom CSS for buttons.** Files requiring cleanup: +- `/modules/choice-dialog/choice-dialog.js` - Simple dialog fallback has extensive custom CSS +- `/modules/blocking-screen/blocking-screen.js` - Custom button styles +- `/components/activity-timer.js` - Custom button implementations +- **Always use HeadlessButton components. No fallback strategies needed - if you have a component, you have a component.** + +### Key Features + +**Personalized Blocking Screen:** +- Browser-based questionnaire system with 6 categories: household tasks, hobbies, current tasks, friends, goals, books +- Swipeable activity cards showing user's personal data instead of generic suggestions +- Cross-platform compatibility - identical on iOS and macOS +- Smart suggestion algorithm prioritizing current tasks + +**Modern Blocking Interface:** +- Tinder-like swipe cards for activity selection (right = accept, left = reject) +- 5-minute focus timer for selected activities with completion tracking +- Choice dialog: "Continue with ScrollStop", "Configure Activities", or "Block Now" +- Grayscale filter activates after 5 minutes only in "Continue" mode +- Browser-based configuration system replaces iOS-specific questionnaire + +### Technical Notes + +**Component Architecture:** +- Components in `/components/`, modules in `/modules/` +- HeadlessButton components must use `window.` prefix in extension context +- SwipeCards and ActivityTimer components moved to proper folders for build process + +**Build Process:** +- Xcode flattens directory structure for Safari extension +- Manifest.json must use flat file names only (no folder paths) +- Files organized in subdirectories during development but referenced by filename only + +**Recent Changes:** +- Removed "Timer Only" option from choice dialog - simplified to 2 options +- Grayscale filter now only activates when user chooses "Continue with ScrollStop" +- Eliminated timer-only mode logic from coordinator and timer-tracker modules +- Added ScrollStop branding title to blocking screen +- Implemented dark mode support for blocking screen +- Platform-specific features: activities/swipe cards only show on iOS (has companion app), macOS shows clean countdown-only interface -**NEVER override TailwindUI/HeadlessUI component styles with aggressive CSS:** - -- Components have sophisticated built-in dark/light mode styling (e.g., `text-zinc-950 dark:text-white`) -- Custom CSS with `!important` rules breaks component functionality -- Components are designed to handle their own theming automatically - -**Proper Implementation:** - -- Let components manage their own styling through their built-in classes -- Use system dark mode detection: `window.matchMedia('(prefers-color-scheme: dark)')` -- Apply `dark` class to `document.documentElement` for proper Tailwind dark mode -- Add minimal custom CSS only for branding (accent colors, fonts) -- Test components in isolation to verify proper rendering - -**Common Mistakes to Avoid:** - -- Forcing text colors with `!important` overrides -- Overriding component `data-slot` attributes with custom styles -- Fighting component's natural theming system -- Not testing dark/light mode transitions - -**Best Practices:** - -- Create test pages to verify component functionality -- Use component's built-in color and styling options -- Respect component design systems and let them work as intended -- Focus custom styling on layout and branding, not core component appearance - -### Choice Dialog Reddit Mobile Bug Fix (Session Summary) - -**Issue:** Button text in choice dialog was cut off/misaligned on Reddit mobile, showing only half of "Timer Only" text. - -**Root Cause:** Reddit's aggressive CSS was interfering with button text positioning and alignment in the fallback simple dialog implementation. - -**Solution Applied:** - -- Added defensive CSS styling with `!important` declarations for all button properties -- Used `display: flex !important` with `align-items: center` and `justify-content: center` for proper text centering -- Added `min-height: 44px` for consistent button height on mobile -- Reset all potential conflicting properties: `line-height`, `vertical-align`, `text-align`, `text-indent`, etc. -- Used `setProperty()` with `!important` in hover effects to maintain alignment -- Added `appearance: none` and `-webkit-appearance: none` to override browser defaults - -**Key Defensive Properties:** - -- `line-height: 1.2 !important` - Prevents text spacing issues -- `text-align: center !important` - Centers text horizontally -- `vertical-align: middle !important` - Centers text vertically -- `display: flex !important` - Enables proper flexbox centering -- `align-items: center !important` - Centers content vertically in flex container -- `justify-content: center !important` - Centers content horizontally in flex container -- `text-rendering: auto !important` - Prevents text rendering conflicts - -**Why This Happened:** Third-party sites like Reddit have CSS that can override extension styles, especially affecting text positioning in buttons. Using `!important` and defensive styling prevents these conflicts. ## Design System @@ -315,54 +253,3 @@ All extension UI elements use consistent glassmorphism styling: - Border radius: `20px` for dialogs, `12px` for buttons - Transitions: `all 0.2s ease` -### Timer Tracker Feature Implementation (Session Summary) - -**What We Built Today:** - -- **Timer Tracker Feature**: A persistent, draggable timer that tracks cumulative time spent on social media sites -- **HeadlessButton Integration**: Replaced all HTML buttons with a standardized HeadlessButton component across the iOS app -- **Glassmorphism Timer Component**: Enhanced existing timer.js with better visibility on bright backgrounds - -**Key Features Implemented:** - -1. **Persistent Time Tracking**: Timer accumulates time across different social media sites (Instagram β†’ YouTube β†’ Twitter, etc.) -2. **Daily Reset**: Automatically resets at 12 AM each day -3. **Click to Hide**: Click timer to hide until next page reload -4. **Drag & Drop**: Grab and move timer anywhere on screen, position persists -5. **Smart Dragging**: Prevents accidental hiding during drag operations -6. **Cross-Browser Storage**: Compatible with Safari extension APIs with localStorage fallback -7. **Position Reset**: Timer returns to default center-top position on page reload - -**Technical Architecture:** - -- **timer-tracker.js**: Main module managing timer logic, storage, and UI interactions -- **StorageWrapper**: Cross-browser storage compatibility layer -- **Integration**: Added to content.js coordinator and manifest.json loading order -- **Visual Improvements**: Dark glassmorphism design with white text and strong shadows for visibility - -**Button System Overhaul:** - -- **Standardized Design**: All iOS app buttons now use HeadlessButton component -- **Consistent Styling**: Blue primary buttons, outline secondary buttons -- **Fallback Support**: macOS app includes fallback if HeadlessButton fails to load -- **Touch Targets**: Enhanced mobile accessibility - -**Build Process Notes:** - -- **Critical Lesson**: manifest.json must use flat file names only (no folder paths) due to build flattening -- **File Organization**: Development files in subdirectories, but manifest references by filename only -- **Xcode Integration**: Remember to add new files to build targets in Xcode - -**User Experience:** - -- **Awareness Tool**: Continuous visibility of time spent on social media -- **Non-Intrusive**: Can be hidden with single click, reappears on reload -- **Customizable Position**: User can drag to preferred screen location -- **Persistent Data**: Time accumulates across sessions and different sites - -**iOS App UI Recommendation:** - -- **Issue Identified**: Web-based walkthrough with JavaScript buttons is unreliable on iOS -- **Recommended Solution**: Replace HTML/CSS/JS walkthrough with native SwiftUI interface -- **Benefits**: More reliable, better iOS integration, proper native styling, no script loading issues -- **Implementation**: Create SwiftUI views for welcome, setup steps, and completion screens diff --git a/ScrollStop.xcodeproj/project.pbxproj b/ScrollStop.xcodeproj/project.pbxproj index c1b3551..1c791d9 100644 --- a/ScrollStop.xcodeproj/project.pbxproj +++ b/ScrollStop.xcodeproj/project.pbxproj @@ -113,8 +113,13 @@ membershipExceptions = ( Resources/_locales, Resources/background.js, + "Resources/components/activity-timer.js", + Resources/components/badge.js, Resources/components/button.js, Resources/components/dialog.js, + Resources/components/divider.js, + "Resources/components/questionnaire-config.js", + "Resources/components/swipe-cards.js", Resources/components/timer.js, Resources/content.js, Resources/images, @@ -132,7 +137,9 @@ "Resources/modules/transition-screen/transition-screen.js", "Resources/modules/utils/storage-helper.js", "Resources/modules/utils/time-manager.js", + Resources/popup.css, Resources/popup.html, + Resources/popup.js, Resources/sites.json, SafariWebExtensionHandler.swift, ); @@ -143,8 +150,13 @@ membershipExceptions = ( Resources/_locales, Resources/background.js, + "Resources/components/activity-timer.js", + Resources/components/badge.js, Resources/components/button.js, Resources/components/dialog.js, + Resources/components/divider.js, + "Resources/components/questionnaire-config.js", + "Resources/components/swipe-cards.js", Resources/components/timer.js, Resources/content.js, Resources/images, @@ -162,7 +174,9 @@ "Resources/modules/transition-screen/transition-screen.js", "Resources/modules/utils/storage-helper.js", "Resources/modules/utils/time-manager.js", + Resources/popup.css, Resources/popup.html, + Resources/popup.js, Resources/sites.json, SafariWebExtensionHandler.swift, ); diff --git a/ScrollStop.xcodeproj/xcuserdata/oliverstaub.xcuserdatad/xcschemes/xcschememanagement.plist b/ScrollStop.xcodeproj/xcuserdata/oliverstaub.xcuserdatad/xcschemes/xcschememanagement.plist index fa55ff4..9dd4baa 100644 --- a/ScrollStop.xcodeproj/xcuserdata/oliverstaub.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/ScrollStop.xcodeproj/xcuserdata/oliverstaub.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,12 +7,12 @@ ScrollStop (iOS).xcscheme_^#shared#^_ orderHint - 1 + 0 ScrollStop (macOS).xcscheme_^#shared#^_ orderHint - 0 + 1 diff --git a/Shared (Extension)/Resources/activity-timer.js b/Shared (Extension)/Resources/activity-timer.js deleted file mode 100644 index a8a2072..0000000 --- a/Shared (Extension)/Resources/activity-timer.js +++ /dev/null @@ -1,499 +0,0 @@ -// UIComponentsJs/activity-timer.js -// 5-minute activity timer with completion celebration - -class ActivityTimer { - constructor(options = {}) { - this.options = { - duration: options.duration || 5 * 60 * 1000, // 5 minutes default - onComplete: options.onComplete || null, - onTick: options.onTick || null, - containerClass: options.containerClass || '', - ...options, - }; - - this.element = null; - this.isRunning = false; - this.startTime = null; - this.remainingTime = this.options.duration; - this.interval = null; - this.activity = null; - } - - render(container) { - if (!container) { - throw new Error('Container element is required'); - } - - this.element = document.createElement('div'); - this.element.className = `activity-timer ${this.options.containerClass}`; - this.element.style.cssText = ` - background: white; - border-radius: 16px; - padding: 32px; - text-align: center; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); - border: 1px solid rgba(0, 0, 0, 0.08); - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - `; - - this.createTimerContent(); - container.appendChild(this.element); - return this.element; - } - - createTimerContent() { - if (!this.element) { - return; - } - - this.element.innerHTML = ` -
-
- ⏱️ -
- -

- Ready to start? -

- -

- Choose an activity to begin your 5-minute timer -

- -
- 5:00 -
- -
-
-
- -
- -
-
- `; - - this.createControls(); - } - - createControls() { - const controlsContainer = this.element.querySelector('#timer-controls'); - if (!controlsContainer) { - return; - } - - if (!this.isRunning) { - // Show start button - const startButton = new HeadlessButton('Start Timer', { - color: 'zinc', - size: 'lg', - onClick: () => this.start(), - }); - - controlsContainer.appendChild(startButton.element); - } else { - // Show pause/resume and stop buttons - const pauseButton = new HeadlessButton('Pause', { - color: 'zinc', - outline: true, - onClick: () => this.pause(), - }); - - const stopButton = new HeadlessButton('Stop', { - color: 'red', - outline: true, - onClick: () => this.stop(), - }); - - const buttonContainer = document.createElement('div'); - buttonContainer.style.cssText = ` - display: flex; - gap: 12px; - justify-content: center; - `; - - buttonContainer.appendChild(pauseButton.element); - buttonContainer.appendChild(stopButton.element); - controlsContainer.appendChild(buttonContainer); - } - } - - setActivity(activity) { - this.activity = activity; - - const emoji = this.element.querySelector('#activity-emoji'); - const title = this.element.querySelector('#activity-title'); - const description = this.element.querySelector('#activity-description'); - - if (emoji) { - emoji.textContent = activity.emoji; - } - if (title) { - title.textContent = activity.text; - } - if (description) { - description.textContent = `Take 5 minutes to focus on this activity`; - } - } - - start() { - if (this.isRunning) { - return; - } - - this.isRunning = true; - this.startTime = Date.now(); - - // If we're resuming, adjust start time - if (this.remainingTime < this.options.duration) { - this.startTime = Date.now() - (this.options.duration - this.remainingTime); - } - - this.interval = setInterval(() => { - this.tick(); - }, 100); // Update every 100ms for smooth progress - - this.updateDisplay(); - this.createControls(); - } - - pause() { - if (!this.isRunning) { - return; - } - - this.isRunning = false; - this.remainingTime = Math.max(0, this.options.duration - (Date.now() - this.startTime)); - - if (this.interval) { - clearInterval(this.interval); - this.interval = null; - } - - this.updateDisplay(); - this.createControls(); - } - - stop() { - this.isRunning = false; - this.remainingTime = this.options.duration; - - if (this.interval) { - clearInterval(this.interval); - this.interval = null; - } - - this.updateDisplay(); - this.createControls(); - } - - tick() { - if (!this.isRunning) { - return; - } - - const elapsed = Date.now() - this.startTime; - this.remainingTime = Math.max(0, this.options.duration - elapsed); - - this.updateDisplay(); - - if (this.options.onTick) { - this.options.onTick(this.remainingTime, elapsed); - } - - if (this.remainingTime <= 0) { - this.complete(); - } - } - - complete() { - this.isRunning = false; - - if (this.interval) { - clearInterval(this.interval); - this.interval = null; - } - - this.showCompletionDialog(); - - if (this.options.onComplete) { - this.options.onComplete(); - } - } - - updateDisplay() { - const timerDisplay = this.element.querySelector('#timer-display'); - const progressBar = this.element.querySelector('#progress-bar'); - - if (timerDisplay) { - const minutes = Math.floor(this.remainingTime / 60000); - const seconds = Math.floor((this.remainingTime % 60000) / 1000); - timerDisplay.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`; - } - - if (progressBar) { - const progress = ((this.options.duration - this.remainingTime) / this.options.duration) * 100; - progressBar.style.width = `${Math.min(100, progress)}%`; - } - } - - showCompletionDialog() { - // Replace timer content with completion dialog - this.element.innerHTML = ` -
-
- πŸŽ‰ -
- -

- Time's Up! -

- -

- Did you successfully complete "${this.activity?.text || 'your activity'}"? -

- -
- -
-
- `; - - this.createCompletionControls(); - } - - createCompletionControls() { - const controlsContainer = this.element.querySelector('#completion-controls'); - if (!controlsContainer) { - return; - } - - const yesButton = new HeadlessButton('Yes! πŸŽ‰', { - color: 'green', - size: 'lg', - onClick: () => this.handleCompletion(true), - }); - - const noButton = new HeadlessButton('Not quite', { - color: 'zinc', - outline: true, - size: 'lg', - onClick: () => this.handleCompletion(false), - }); - - controlsContainer.appendChild(yesButton.element); - controlsContainer.appendChild(noButton.element); - } - - handleCompletion(successful) { - if (successful) { - this.showCelebration(); - } else { - this.showEncouragement(); - } - } - - showCelebration() { - this.element.innerHTML = ` -
-
- πŸ† -
- -

- Awesome! πŸŽ‰ -

- -

- You completed your activity! That's a great step towards breaking the doomscroll habit. -

- -
- ${this.createConfetti()} -
-
- `; - - this.animateCelebration(); - } - - showEncouragement() { - this.element.innerHTML = ` -
-
- πŸ’ͺ -
- -

- That's okay! -

- -

- Taking a break from scrolling is already a win. Every small step counts! -

-
- `; - } - - createConfetti() { - const colors = ['#10b981', '#6366f1', '#f59e0b', '#ef4444', '#8b5cf6']; - let confetti = ''; - - for (let i = 0; i < 20; i++) { - const color = colors[Math.floor(Math.random() * colors.length)]; - const delay = Math.random() * 2; - const duration = 2 + Math.random() * 2; - const left = Math.random() * 100; - - confetti += ` -
- `; - } - - return `
${confetti}
`; - } - - animateCelebration() { - // Add CSS animations - const style = document.createElement('style'); - style.textContent = ` - @keyframes bounce { - 0%, 20%, 50%, 80%, 100% { - transform: translateY(0); - } - 40% { - transform: translateY(-20px); - } - 60% { - transform: translateY(-10px); - } - } - - @keyframes confetti-fall { - 0% { - transform: translateY(-10px) rotate(0deg); - opacity: 1; - } - 100% { - transform: translateY(100px) rotate(360deg); - opacity: 0; - } - } - `; - - document.head.appendChild(style); - - // Remove style after animation - setTimeout(() => { - if (style.parentNode) { - style.parentNode.removeChild(style); - } - }, 10000); - } - - // Public methods - reset() { - this.stop(); - this.createTimerContent(); - } - - getRemainingTime() { - return this.remainingTime; - } - - isTimerRunning() { - return this.isRunning; - } - - destroy() { - if (this.interval) { - clearInterval(this.interval); - this.interval = null; - } - - if (this.element && this.element.parentNode) { - this.element.parentNode.removeChild(this.element); - this.element = null; - } - } -} - -// Export for use in modules -if (typeof module !== 'undefined' && module.exports) { - module.exports = ActivityTimer; -} else { - window.ActivityTimer = ActivityTimer; -} diff --git a/Shared (Extension)/Resources/content.js b/Shared (Extension)/Resources/content.js index a650537..db907c8 100644 --- a/Shared (Extension)/Resources/content.js +++ b/Shared (Extension)/Resources/content.js @@ -104,6 +104,15 @@ class ScrollStopCoordinator { // Store site type for later use this.currentSiteType = siteType; + // Check if user wants to configure activities (from popup) + const shouldShowQuestionnaire = localStorage.getItem('scrollstop_show_questionnaire'); + if (shouldShowQuestionnaire === 'true') { + localStorage.removeItem('scrollstop_show_questionnaire'); + console.log('ScrollStop: User requested questionnaire from popup, showing directly'); + this.showQuestionnaireConfig(); + return; + } + // Always show choice dialog on every page load (no session persistence) console.log('ScrollStop: No session persistence - will show choice dialog'); @@ -271,12 +280,14 @@ class ScrollStopCoordinator { if (this.currentSiteType && this.currentSiteType.isBlocked) { this.startDoomscrollDetection(); } + // Start grayscale filter tracking ONLY for 'continue' choice + this.startGrayscaleFilter(); break; - case 'timer-only': - // Only show timer, no blocking - await this.initializeTimerOnly(); - break; + case 'configure': + // Show configuration interface + this.showQuestionnaireConfig(); + return; // Don't proceed with normal initialization case 'block': // Initialize timer first, then immediately block the site @@ -298,6 +309,8 @@ class ScrollStopCoordinator { if (this.currentSiteType && this.currentSiteType.isBlocked) { this.startDoomscrollDetection(); } + // Start grayscale filter for default case too + this.startGrayscaleFilter(); break; } @@ -305,32 +318,8 @@ class ScrollStopCoordinator { if (choice !== 'block') { this.startPeriodicReminder(); } - - // Start grayscale filter tracking for all choices (except when immediately blocked) - if (choice !== 'block') { - this.startGrayscaleFilter(); - } } - /** - * Initialize timer-only mode - */ - async initializeTimerOnly() { - try { - // Initialize timer but not doomscroll detection - if (!this.timerTracker) { - this.timerTracker = new TimerTracker(); - await this.timerTracker.initialize(); - } - - // Set timer to timer-only mode to prevent hiding - if (this.timerTracker.setTimerOnlyMode) { - this.timerTracker.setTimerOnlyMode(true); - } - } catch (error) { - console.error('Error initializing timer-only mode:', error); - } - } /** * Start periodic reminder system (5-minute intervals) @@ -371,6 +360,32 @@ class ScrollStopCoordinator { } } + /** + * Show questionnaire configuration interface + */ + showQuestionnaireConfig() { + try { + console.log('ScrollStop: Showing questionnaire configuration'); + + const config = new window.QuestionnaireConfig({ + onComplete: () => { + console.log('ScrollStop: Configuration completed, proceeding with continue mode'); + this.proceedWithChoice('continue'); + }, + onCancel: () => { + console.log('ScrollStop: Configuration cancelled, showing choice dialog again'); + this.showChoiceDialog(); + }, + }); + + config.render(); + } catch (error) { + console.error('ScrollStop: Error showing questionnaire config:', error); + // Fallback to continue mode if config fails + this.proceedWithChoice('continue'); + } + } + /** * Clean up all modules and event listeners */ @@ -428,7 +443,7 @@ class ScrollStopCoordinator { } /** - * Handle messages from background script + * Handle messages from background script and popup */ handleMessage(message, sender, sendResponse) { if (message.action === 'checkBlockedSite') { @@ -438,6 +453,13 @@ class ScrollStopCoordinator { } }); return true; // Indicates async response + } else if (message.action === 'showQuestionnaire') { + // Show questionnaire configuration directly + this.showQuestionnaireConfig(); + if (sendResponse) { + sendResponse({ success: true }); + } + return true; } } } diff --git a/Shared (Extension)/Resources/manifest.json b/Shared (Extension)/Resources/manifest.json index c1c2a3d..85f3678 100644 --- a/Shared (Extension)/Resources/manifest.json +++ b/Shared (Extension)/Resources/manifest.json @@ -28,9 +28,12 @@ "time-manager.js", "timer-tracker.js", "button.js", + "badge.js", "dialog.js", + "divider.js", "swipe-cards.js", "activity-timer.js", + "questionnaire-config.js", "choice-dialog.js", "doomscroll-detector.js", "doomscroll-animation.js", @@ -50,7 +53,7 @@ "default_icon": "images/toolbar-icon.svg" }, - "permissions": ["storage"], + "permissions": ["storage", "tabs"], "host_permissions": [""], "web_accessible_resources": [ @@ -59,7 +62,9 @@ "sites.json", "doomscroll-animation.css", "transition-screen.css", - "blocking-screen.css" + "blocking-screen.css", + "popup.css", + "popup.js" ], "matches": [""] } diff --git a/Shared (Extension)/Resources/modules/blocking-screen/blocking-screen.js b/Shared (Extension)/Resources/modules/blocking-screen/blocking-screen.js index b2b9362..72b7d1c 100644 --- a/Shared (Extension)/Resources/modules/blocking-screen/blocking-screen.js +++ b/Shared (Extension)/Resources/modules/blocking-screen/blocking-screen.js @@ -59,13 +59,16 @@ if (typeof window.BlockingScreen === 'undefined') { } /** - * Apply modern B&W styles to blocking screen element + * Apply modern B&W styles to blocking screen element with dark mode support */ applyModernStyles() { if (!this.blockingElement) { return; } + // Detect dark mode + const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + this.blockingElement.style.cssText = ` height: 100vh; width: 100vw; @@ -73,7 +76,8 @@ if (typeof window.BlockingScreen === 'undefined') { top: 0; left: 0; z-index: 9999999; - background: #f8fafc; + background: ${isDarkMode ? '#1f2937' : '#f8fafc'}; + color: ${isDarkMode ? '#f9fafb' : '#1f2937'}; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; overflow-y: auto; box-sizing: border-box; @@ -91,6 +95,9 @@ if (typeof window.BlockingScreen === 'undefined') { // Check if this is a news site const isNews = await StorageHelper.isCurrentSiteNews(window.location.href, this.hostname); const siteName = this.hostname.replace('www.', '').split('.')[0]; + + // Detect dark mode for styling + const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; this.blockingElement.innerHTML = `
-
+
+
⏸️
+ font-size: 1.8rem; + font-weight: 700; + color: ${isDarkMode ? '#34d399' : '#059669'}; + margin-bottom: 20px; + text-transform: uppercase; + letter-spacing: 0.1em; + opacity: 0.9; + "> + ScrollStop +

@@ -126,8 +140,8 @@ if (typeof window.BlockingScreen === 'undefined') {

${ @@ -137,18 +151,19 @@ if (typeof window.BlockingScreen === 'undefined') { }

- +
@@ -156,7 +171,7 @@ if (typeof window.BlockingScreen === 'undefined') {
@@ -166,22 +181,22 @@ if (typeof window.BlockingScreen === 'undefined') {
-
+

Better things to do right now

@@ -207,7 +222,9 @@ if (typeof window.BlockingScreen === 'undefined') {
`; + // Initialize activity cards for all platforms await this.initializeActivityCards(); + this.createActionButtons(); } @@ -239,6 +256,16 @@ if (typeof window.BlockingScreen === 'undefined') { } // Create swipe cards component + if (!window.SwipeCards) { + console.error('SwipeCards not available on window'); + container.innerHTML = ` +
+ SwipeCards component not loaded +
+ `; + return; + } + this.swipeCards = new window.SwipeCards(activities, { onSwipeRight: (activity) => this.handleActivityAccepted(activity), onSwipeLeft: (activity) => this.handleActivityRejected(activity), @@ -501,7 +528,7 @@ if (typeof window.BlockingScreen === 'undefined') { } /** - * Create action buttons based on current mode + * Create action buttons based on current mode (only on iOS) */ createActionButtons() { const container = document.getElementById('action-buttons'); @@ -511,6 +538,8 @@ if (typeof window.BlockingScreen === 'undefined') { container.innerHTML = ''; + // Action buttons available on all platforms now + if (this.currentMode === 'blocked') { // Show "Skip Activities" button with subtle green accent const skipButton = new window.HeadlessButton('Skip activities', { diff --git a/Shared (Extension)/Resources/modules/choice-dialog/choice-dialog.js b/Shared (Extension)/Resources/modules/choice-dialog/choice-dialog.js index 4044d62..4458541 100644 --- a/Shared (Extension)/Resources/modules/choice-dialog/choice-dialog.js +++ b/Shared (Extension)/Resources/modules/choice-dialog/choice-dialog.js @@ -77,34 +77,41 @@ class ChoiceDialog { // Create dialog body (empty, no subtitle) const body = new HeadlessDialogBody(); - // Create action buttons using HeadlessButton default styling + // Create action buttons using HeadlessButton with better visibility const continueBtn = new HeadlessButton('Continue with ScrollStop', { - color: 'blue', + color: 'green', onClick: () => this.handleChoice('continue', resolve), }); - const timerOnlyBtn = new HeadlessButton('Timer Only', { - color: 'zinc', - outline: true, - onClick: () => this.handleChoice('timer-only', resolve), - }); - const blockBtn = new HeadlessButton('Block Now', { color: 'red', - outline: true, onClick: () => this.handleChoice('block', resolve), }); + const configureBtn = new HeadlessButton('Configure Activities', { + color: 'dark', + onClick: () => this.handleChoice('configure', resolve), + }); + console.log('ChoiceDialog: Buttons created, assembling content...'); - // Create simple button container without descriptions + // Create button container with divider structure const buttonContainer = document.createElement('div'); buttonContainer.className = 'space-y-3'; + // Main actions (above divider) buttonContainer.appendChild(continueBtn.element); - buttonContainer.appendChild(timerOnlyBtn.element); buttonContainer.appendChild(blockBtn.element); + // Add divider + if (window.HeadlessDivider) { + const divider = new window.HeadlessDivider({ soft: true }); + divider.render(buttonContainer); + } + + // Configuration action (below divider) + buttonContainer.appendChild(configureBtn.element); + console.log('ChoiceDialog: Adding content to dialog panel...'); // Add all content to dialog panel manually to avoid render issues @@ -231,7 +238,7 @@ class ChoiceDialog { appearance: none !important; -webkit-appearance: none !important; ">Continue with ScrollStop - + ">Configure Activities +; ``` **CRITICAL RULE: If component is not available, make it available - NEVER write custom CSS.** Examples: + - Popup context: Load `button.js` and make `window.HeadlessButton` available - Missing component: Copy from `/UIComponentsJs/` to `/components/` and add to manifest.json - Always use standard components, never fallback to custom styling ### Color Scheme + - **Primary**: `color: 'blue'` (ScrollStop brand green) - **Secondary**: `color: 'zinc', outline: true` (gray outline) - **Destructive**: `color: 'red'` (delete/cancel actions) @@ -178,7 +183,7 @@ Examples: - **Choice Dialog**: Continue (blue), Configure Activities (zinc outline), Block Now (red outline) with HeadlessDivider separation - **Questionnaire**: Add (blue), Previous (zinc outline), Cancel (red outline), Next/Finish (blue) with fallback support -- **Popup**: Configure Activities (blue) with minimal CSS, HeadlessButton for all interactive elements +- **Popup**: Configure Activities (blue) with minimal CSS, HeadlessButton for all interactive elements - **Cross-Platform**: Extensions use HeadlessButton, iOS app uses SwiftUI - **Adult Sites Blocking**: 4-hour blocks, 89+ sites, timer tracking and choice dialog - **Workflow**: Feature branches, `npm run precommit`, PR validation, Dependabot updates @@ -186,20 +191,23 @@ Examples: ## Custom CSS Cleanup **NEVER use custom CSS for buttons.** Files requiring cleanup: + - `/modules/choice-dialog/choice-dialog.js` - Simple dialog fallback has extensive custom CSS -- `/modules/blocking-screen/blocking-screen.js` - Custom button styles +- `/modules/blocking-screen/blocking-screen.js` - Custom button styles - `/components/activity-timer.js` - Custom button implementations - **Always use HeadlessButton components. No fallback strategies needed - if you have a component, you have a component.** ### Key Features **Personalized Blocking Screen:** + - Browser-based questionnaire system with 6 categories: household tasks, hobbies, current tasks, friends, goals, books - Swipeable activity cards showing user's personal data instead of generic suggestions - Cross-platform compatibility - identical on iOS and macOS - Smart suggestion algorithm prioritizing current tasks **Modern Blocking Interface:** + - Tinder-like swipe cards for activity selection (right = accept, left = reject) - 5-minute focus timer for selected activities with completion tracking - Choice dialog: "Continue with ScrollStop", "Configure Activities", or "Block Now" @@ -209,16 +217,19 @@ Examples: ### Technical Notes **Component Architecture:** + - Components in `/components/`, modules in `/modules/` - HeadlessButton components must use `window.` prefix in extension context - SwipeCards and ActivityTimer components moved to proper folders for build process **Build Process:** + - Xcode flattens directory structure for Safari extension - Manifest.json must use flat file names only (no folder paths) - Files organized in subdirectories during development but referenced by filename only **Recent Changes:** + - Removed "Timer Only" option from choice dialog - simplified to 2 options - Grayscale filter now only activates when user chooses "Continue with ScrollStop" - Eliminated timer-only mode logic from coordinator and timer-tracker modules @@ -226,7 +237,6 @@ Examples: - Implemented dark mode support for blocking screen - Platform-specific features: activities/swipe cards only show on iOS (has companion app), macOS shows clean countdown-only interface - ## Design System ### Glassmorphism Color Scheme @@ -252,4 +262,3 @@ All extension UI elements use consistent glassmorphism styling: - Hover: `scale(1.02)` with `box-shadow: 0px 6px 20px rgba(0, 0, 0, 0.3)` - Border radius: `20px` for dialogs, `12px` for buttons - Transitions: `all 0.2s ease` - diff --git a/Shared (Extension)/Resources/components/badge.js b/Shared (Extension)/Resources/components/badge.js new file mode 100644 index 0000000..9817cbb --- /dev/null +++ b/Shared (Extension)/Resources/components/badge.js @@ -0,0 +1,239 @@ +/** + * HeadlessBadge - A comprehensive badge component + * + * USAGE EXAMPLES: + * + * // Basic badge + * new HeadlessBadge('New').render('#container'); + * + * // Colored badge + * new HeadlessBadge('Error', { color: 'red' }).render('#status'); + * + * // Badge button with delete functionality + * new HeadlessBadgeButton('Clickable', { + * color: 'blue', + * onClick: () => console.log('Badge clicked!') + * }).render('#actions'); + */ +class HeadlessBadge { + constructor(text, options = {}) { + this.text = text; + this.options = { + color: options.color || 'zinc', + className: options.className || '', + ...options, + }; + + this.element = this.createElement(); + } + + static colors = { + red: 'bg-red-500/15 text-red-700 group-data-hover:bg-red-500/25 dark:bg-red-500/10 dark:text-red-400 dark:group-data-hover:bg-red-500/20', + orange: + 'bg-orange-500/15 text-orange-700 group-data-hover:bg-orange-500/25 dark:bg-orange-500/10 dark:text-orange-400 dark:group-data-hover:bg-orange-500/20', + amber: + 'bg-amber-400/20 text-amber-700 group-data-hover:bg-amber-400/30 dark:bg-amber-400/10 dark:text-amber-400 dark:group-data-hover:bg-amber-400/15', + yellow: + 'bg-yellow-400/20 text-yellow-700 group-data-hover:bg-yellow-400/30 dark:bg-yellow-400/10 dark:text-yellow-300 dark:group-data-hover:bg-yellow-400/15', + lime: 'bg-lime-400/20 text-lime-700 group-data-hover:bg-lime-400/30 dark:bg-lime-400/10 dark:text-lime-300 dark:group-data-hover:bg-lime-400/15', + green: + 'bg-green-500/15 text-green-700 group-data-hover:bg-green-500/25 dark:bg-green-500/10 dark:text-green-400 dark:group-data-hover:bg-green-500/20', + emerald: + 'bg-emerald-500/15 text-emerald-700 group-data-hover:bg-emerald-500/25 dark:bg-emerald-500/10 dark:text-emerald-400 dark:group-data-hover:bg-emerald-500/20', + teal: 'bg-teal-500/15 text-teal-700 group-data-hover:bg-teal-500/25 dark:bg-teal-500/10 dark:text-teal-300 dark:group-data-hover:bg-teal-500/20', + cyan: 'bg-cyan-400/20 text-cyan-700 group-data-hover:bg-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-300 dark:group-data-hover:bg-cyan-400/15', + sky: 'bg-sky-500/15 text-sky-700 group-data-hover:bg-sky-500/25 dark:bg-sky-500/10 dark:text-sky-300 dark:group-data-hover:bg-sky-500/20', + blue: 'bg-blue-500/15 text-blue-700 group-data-hover:bg-blue-500/25 dark:text-blue-400 dark:group-data-hover:bg-blue-500/25', + indigo: + 'bg-indigo-500/15 text-indigo-700 group-data-hover:bg-indigo-500/25 dark:text-indigo-400 dark:group-data-hover:bg-indigo-500/20', + violet: + 'bg-violet-500/15 text-violet-700 group-data-hover:bg-violet-500/25 dark:text-violet-400 dark:group-data-hover:bg-violet-500/20', + purple: + 'bg-purple-500/15 text-purple-700 group-data-hover:bg-purple-500/25 dark:text-purple-400 dark:group-data-hover:bg-purple-500/20', + fuchsia: + 'bg-fuchsia-400/15 text-fuchsia-700 group-data-hover:bg-fuchsia-400/25 dark:bg-fuchsia-400/10 dark:text-fuchsia-400 dark:group-data-hover:bg-fuchsia-400/20', + pink: 'bg-pink-400/15 text-pink-700 group-data-hover:bg-pink-400/25 dark:bg-pink-400/10 dark:text-pink-400 dark:group-data-hover:bg-pink-400/20', + rose: 'bg-rose-400/15 text-rose-700 group-data-hover:bg-rose-400/25 dark:bg-rose-400/10 dark:text-rose-400 dark:group-data-hover:bg-rose-400/20', + zinc: 'bg-zinc-600/10 text-zinc-700 group-data-hover:bg-zinc-600/20 dark:bg-white/5 dark:text-zinc-400 dark:group-data-hover:bg-white/10', + }; + + static styles = { + base: [ + 'inline-flex items-center gap-x-1.5 rounded-md px-1.5 py-0.5 text-sm/5 font-medium sm:text-xs/5', + ], + }; + + createElement() { + const span = document.createElement('span'); + span.className = this.getClasses(); + span.textContent = this.text; + return span; + } + + getClasses() { + const classes = [...HeadlessBadge.styles.base]; + + const colorClass = HeadlessBadge.colors[this.options.color]; + if (colorClass) { + classes.push(colorClass); + } + + if (this.options.className) { + classes.push(this.options.className); + } + + return classes.join(' '); + } + + setText(text) { + this.text = text; + this.element.textContent = text; + return this; + } + + setColor(color) { + this.options.color = color; + this.element.className = this.getClasses(); + return this; + } + + render(container) { + if (typeof container === 'string') { + container = document.querySelector(container); + } + + if (!container) { + throw new Error('Container not found'); + } + + container.appendChild(this.element); + return this; + } + + remove() { + if (this.element && this.element.parentNode) { + this.element.parentNode.removeChild(this.element); + } + return this; + } + + getElement() { + return this.element; + } +} + +class HeadlessBadgeButton { + constructor(text, options = {}) { + this.text = text; + this.options = { + color: options.color || 'zinc', + className: options.className || '', + onClick: options.onClick || null, + href: options.href || null, + target: options.target || null, + ...options, + }; + + this.badge = new HeadlessBadge(text, { color: this.options.color }); + this.element = this.createElement(); + } + + static styles = { + base: [ + 'group relative inline-flex rounded-md focus:outline-2 focus:outline-offset-2 focus:outline-blue-500', + ], + touchTarget: [ + 'absolute top-1/2 left-1/2 size-[max(100%,2.75rem)] -translate-x-1/2 -translate-y-1/2 pointer-fine:hidden', + ], + }; + + createElement() { + const element = this.options.href + ? document.createElement('a') + : document.createElement('button'); + + element.className = this.getClasses(); + + if (this.options.href) { + element.href = this.options.href; + if (this.options.target) { + element.target = this.options.target; + } + } else { + element.type = 'button'; + } + + if (this.options.onClick) { + element.addEventListener('click', this.options.onClick); + } + + // Add touch target + const touchTarget = document.createElement('span'); + touchTarget.className = HeadlessBadgeButton.styles.touchTarget.join(' '); + touchTarget.setAttribute('aria-hidden', 'true'); + element.appendChild(touchTarget); + + // Add badge + element.appendChild(this.badge.getElement()); + + return element; + } + + getClasses() { + const classes = [...HeadlessBadgeButton.styles.base]; + + if (this.options.className) { + classes.push(this.options.className); + } + + return classes.join(' '); + } + + setText(text) { + this.text = text; + this.badge.setText(text); + return this; + } + + setColor(color) { + this.options.color = color; + this.badge.setColor(color); + return this; + } + + render(container) { + if (typeof container === 'string') { + container = document.querySelector(container); + } + + if (!container) { + throw new Error('Container not found'); + } + + container.appendChild(this.element); + return this; + } + + remove() { + if (this.element && this.element.parentNode) { + this.element.parentNode.removeChild(this.element); + } + return this; + } + + getElement() { + return this.element; + } + + getBadge() { + return this.badge; + } +} + +// Export for use in modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = { HeadlessBadge, HeadlessBadgeButton }; +} else { + window.HeadlessBadge = HeadlessBadge; + window.HeadlessBadgeButton = HeadlessBadgeButton; +} diff --git a/Shared (Extension)/Resources/components/divider.js b/Shared (Extension)/Resources/components/divider.js new file mode 100644 index 0000000..65f3a88 --- /dev/null +++ b/Shared (Extension)/Resources/components/divider.js @@ -0,0 +1,109 @@ +/** + * HeadlessDivider - A simple divider component inspired by Headless UI + * + * USAGE EXAMPLES: + * + * // Basic divider + * new HeadlessDivider().render('#container'); + * + * // Soft divider (lighter color) + * new HeadlessDivider({ + * soft: true + * }).render(document.body); + * + * // Hard divider (default, darker color) + * new HeadlessDivider({ + * soft: false + * }).render('#content'); + */ +class HeadlessDivider { + constructor(options = {}) { + this.options = { + soft: options.soft !== undefined ? options.soft : false, + className: options.className || '', + id: options.id || null, + ...options, + }; + + this.element = this.createElement(); + } + + // Style configurations (converted from the original React component) + static styles = { + base: ['w-full border-t'], + + soft: ['border-zinc-950/5 dark:border-white/5'], + + hard: ['border-zinc-950/10 dark:border-white/10'], + }; + + createElement() { + // Create the divider element (hr) + const divider = document.createElement('hr'); + divider.setAttribute('role', 'presentation'); + + // Apply CSS classes + divider.className = this.getClasses(); + + // Set id if provided + if (this.options.id) { + divider.id = this.options.id; + } + + // Set any additional attributes (excluding the ones we handle specially) + Object.keys(this.options).forEach((key) => { + if (!['soft', 'className', 'id'].includes(key)) { + divider.setAttribute(key, this.options[key]); + } + }); + + return divider; + } + + getClasses() { + const classes = [...HeadlessDivider.styles.base]; + + // Add soft or hard styles + if (this.options.soft) { + classes.push(...HeadlessDivider.styles.soft); + } else { + classes.push(...HeadlessDivider.styles.hard); + } + + // Add custom classes + if (this.options.className) { + classes.push(this.options.className); + } + + return classes.join(' '); + } + + // Method to render the divider to a container + render(container) { + if (typeof container === 'string') { + container = document.querySelector(container); + } + + if (!container) { + throw new Error('Container not found'); + } + + container.appendChild(this.element); + return this; + } + + // Method to remove from DOM + remove() { + if (this.element.parentNode) { + this.element.parentNode.removeChild(this.element); + } + return this; + } +} + +// Export for use in modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = HeadlessDivider; +} else { + window.HeadlessDivider = HeadlessDivider; +} diff --git a/Shared (Extension)/Resources/components/questionnaire-config.js b/Shared (Extension)/Resources/components/questionnaire-config.js new file mode 100644 index 0000000..4ef5aed --- /dev/null +++ b/Shared (Extension)/Resources/components/questionnaire-config.js @@ -0,0 +1,469 @@ +// components/questionnaire-config.js +// Browser-based questionnaire configuration interface + +class QuestionnaireConfig { + constructor(options = {}) { + this.options = { + onComplete: options.onComplete || null, + onCancel: options.onCancel || null, + ...options, + }; + + this.element = null; + this.currentStep = 0; + this.data = { + householdTasks: [], + hobbies: [], + currentTasks: [], + friends: [], + goals: [], + books: [], + }; + + this.steps = [ + { + key: 'householdTasks', + title: 'Household Tasks', + placeholder: 'e.g., Do laundry, Clean kitchen', + emoji: '🏠', + defaults: ['Do laundry', 'Clean kitchen', 'Take out trash', 'Vacuum'], + }, + { + key: 'hobbies', + title: 'Hobbies & Interests', + placeholder: 'e.g., Reading, Guitar, Cycling', + emoji: '🎯', + defaults: ['Reading', 'Exercise', 'Music', 'Cooking'], + }, + { + key: 'currentTasks', + title: 'Current Tasks', + placeholder: 'e.g., Study for exam, Buy groceries', + emoji: 'βœ…', + defaults: ['Buy groceries', 'Pay bills', 'Call doctor'], + }, + { + key: 'friends', + title: 'Friends & Family', + placeholder: 'e.g., Mom, Sarah, Alex', + emoji: 'πŸ‘₯', + defaults: ['Mom', 'Dad', 'Best friend'], + }, + { + key: 'goals', + title: 'Personal Goals', + placeholder: 'e.g., Exercise daily, Learn Spanish', + emoji: '🎯', + defaults: ['Exercise daily', 'Read more', 'Learn new skill'], + }, + { + key: 'books', + title: 'Books to Read', + placeholder: 'e.g., Atomic Habits, 1984', + emoji: 'πŸ“š', + defaults: ['Atomic Habits', 'The Lean Startup', '1984'], + }, + ]; + + // Load existing data + this.loadExistingData(); + } + + async loadExistingData() { + try { + if (typeof browser !== 'undefined' && browser.storage && browser.storage.local) { + const result = await browser.storage.local.get([ + 'scrollstop_householdTasks', + 'scrollstop_hobbies', + 'scrollstop_currentTasks', + 'scrollstop_friends', + 'scrollstop_goals', + 'scrollstop_books', + ]); + + this.data.householdTasks = result.scrollstop_householdTasks || []; + this.data.hobbies = result.scrollstop_hobbies || []; + this.data.currentTasks = result.scrollstop_currentTasks || []; + this.data.friends = result.scrollstop_friends || []; + this.data.goals = result.scrollstop_goals || []; + this.data.books = result.scrollstop_books || []; + } else { + // Fallback to localStorage + this.data.householdTasks = JSON.parse( + localStorage.getItem('scrollstop_householdTasks') || '[]' + ); + this.data.hobbies = JSON.parse(localStorage.getItem('scrollstop_hobbies') || '[]'); + this.data.currentTasks = JSON.parse( + localStorage.getItem('scrollstop_currentTasks') || '[]' + ); + this.data.friends = JSON.parse(localStorage.getItem('scrollstop_friends') || '[]'); + this.data.goals = JSON.parse(localStorage.getItem('scrollstop_goals') || '[]'); + this.data.books = JSON.parse(localStorage.getItem('scrollstop_books') || '[]'); + } + } catch (error) { + console.error('Error loading questionnaire data:', error); + } + } + + render(container) { + if (!container) { + // Create full-screen overlay + container = document.createElement('div'); + container.style.cssText = ` + position: fixed !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; + background: rgba(0, 0, 0, 0.9) !important; + z-index: 2147483647 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important; + `; + document.body.appendChild(container); + this.overlay = container; + } + + this.element = document.createElement('div'); + this.element.style.cssText = ` + background: white !important; + border-radius: 16px !important; + padding: 32px !important; + max-width: 500px !important; + width: 90% !important; + max-height: 80vh !important; + overflow-y: auto !important; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3) !important; + color: #1f2937 !important; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important; + `; + + this.renderCurrentStep(); + container.appendChild(this.element); + return this.element; + } + + renderCurrentStep() { + if (this.currentStep >= this.steps.length) { + this.renderComplete(); + return; + } + + const step = this.steps[this.currentStep]; + const currentItems = this.data[step.key]; + + this.element.innerHTML = ` +
+
${step.emoji}
+

+ ${step.title} +

+

+ Step ${this.currentStep + 1} of ${this.steps.length} +

+
+
+
+
+ +
+
+
+ +
+ +
+
+
+ +
+
+ `; + + // Create HeadlessButton components with fallback + const addBtnContainer = this.element.querySelector('#add-btn-container'); + const buttonContainer = this.element.querySelector('#button-container'); + + // Add button + if (window.HeadlessButton && addBtnContainer) { + try { + const addBtn = new window.HeadlessButton('Add', { + color: 'blue', + onClick: () => this.addItem(), + }); + addBtnContainer.appendChild(addBtn.element); + } catch (error) { + console.error('HeadlessButton failed, using fallback:', error); + this.createFallbackButton(addBtnContainer, 'Add', () => this.addItem(), 'blue'); + } + } else { + // Fallback if HeadlessButton not available + this.createFallbackButton(addBtnContainer, 'Add', () => this.addItem(), 'blue'); + } + + // Navigation buttons + if (window.HeadlessButton && buttonContainer) { + try { + // Previous button + if (this.currentStep > 0) { + const prevBtn = new window.HeadlessButton('Previous', { + color: 'zinc', + outline: true, + onClick: () => this.previousStep(), + }); + buttonContainer.appendChild(prevBtn.element); + } else { + // Empty div for spacing + const spacer = document.createElement('div'); + buttonContainer.appendChild(spacer); + } + + // Cancel button + const cancelBtn = new window.HeadlessButton('Cancel', { + color: 'red', + outline: true, + onClick: () => this.cancel(), + }); + buttonContainer.appendChild(cancelBtn.element); + + // Next/Finish button + const nextBtn = new window.HeadlessButton( + this.currentStep === this.steps.length - 1 ? 'Finish' : 'Next', + { + color: 'blue', + onClick: () => this.nextStep(), + } + ); + buttonContainer.appendChild(nextBtn.element); + } catch (error) { + console.error('HeadlessButton failed, using fallback:', error); + this.createFallbackButtons(buttonContainer); + } + } else { + // Fallback if HeadlessButton not available + this.createFallbackButtons(buttonContainer); + } + + // Set up input handlers + const input = this.element.querySelector('#new-item-input'); + if (input) { + input.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + this.addItem(); + } + }); + input.focus(); + } + + // Create badge buttons for items + const itemsList = this.element.querySelector('#items-list'); + if (itemsList && window.HeadlessBadgeButton) { + currentItems.forEach((item, index) => { + const badgeButton = new window.HeadlessBadgeButton(`${item} Γ—`, { + color: 'zinc', + onClick: () => this.removeItem(index), + }); + itemsList.appendChild(badgeButton.element); + }); + } + + // Add default items button if list is empty + if (currentItems.length === 0 && itemsList && step.defaults) { + const addDefaultsBtn = new window.HeadlessButton('Add suggested items', { + color: 'zinc', + outline: true, + onClick: () => this.addDefaultItems(), + }); + itemsList.appendChild(addDefaultsBtn.element); + } + + // Store reference for global access + window.questionnaireConfig = this; + } + + // Fallback button creation methods - still try to use HeadlessButton + createFallbackButton(container, text, onClick, color) { + if (!container) return; + + // Try to use HeadlessButton again with a delay + setTimeout(() => { + if (window.HeadlessButton) { + container.innerHTML = ''; // Clear any existing content + const button = new window.HeadlessButton(text, { + color: color, + outline: color !== 'blue', + onClick: onClick, + }); + container.appendChild(button.element); + } + }, 100); + + // Immediate basic button as emergency fallback + const button = document.createElement('button'); + button.textContent = text; + button.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + onClick(); + }); + container.appendChild(button); + } + + createFallbackButtons(container) { + if (!container) return; + + // Previous button + if (this.currentStep > 0) { + this.createFallbackButton(container, 'Previous', () => this.previousStep(), 'zinc'); + } else { + const spacer = document.createElement('div'); + container.appendChild(spacer); + } + + // Cancel button + this.createFallbackButton(container, 'Cancel', () => this.cancel(), 'red'); + + // Next/Finish button + const nextText = this.currentStep === this.steps.length - 1 ? 'Finish' : 'Next'; + this.createFallbackButton(container, nextText, () => this.nextStep(), 'blue'); + } + + addItem() { + const input = this.element.querySelector('#new-item-input'); + const value = input.value.trim(); + + if (value) { + const step = this.steps[this.currentStep]; + this.data[step.key].push(value); + input.value = ''; + this.renderCurrentStep(); + } + } + + addDefaultItems() { + const step = this.steps[this.currentStep]; + if (step.defaults) { + this.data[step.key] = [...step.defaults]; + this.renderCurrentStep(); + } + } + + removeItem(index) { + const step = this.steps[this.currentStep]; + this.data[step.key].splice(index, 1); + this.renderCurrentStep(); + } + + nextStep() { + this.currentStep++; + this.renderCurrentStep(); + } + + previousStep() { + if (this.currentStep > 0) { + this.currentStep--; + this.renderCurrentStep(); + } + } + + cancel() { + if (this.options.onCancel) { + this.options.onCancel(); + } + this.remove(); + } + + renderComplete() { + this.element.innerHTML = ` +
+
πŸŽ‰
+

+ Configuration Complete! +

+

+ Your personalized activities have been saved. You'll now see customized suggestions when ScrollStop blocks a site. +

+
+
+ `; + + // Create HeadlessButton for done button that just closes the window + const doneBtnContainer = this.element.querySelector('#done-btn-container'); + if (window.HeadlessButton && doneBtnContainer) { + const doneBtn = new window.HeadlessButton('Finish', { + color: 'green', + onClick: () => { + // Save data and close window without callback + this.saveData().then(() => { + this.remove(); + }); + }, + }); + doneBtnContainer.appendChild(doneBtn.element); + } + } + + async complete() { + await this.saveData(); + if (this.options.onComplete) { + this.options.onComplete(); + } + this.remove(); + } + + async saveData() { + try { + const storageData = { + scrollstop_householdTasks: this.data.householdTasks, + scrollstop_hobbies: this.data.hobbies, + scrollstop_currentTasks: this.data.currentTasks, + scrollstop_friends: this.data.friends, + scrollstop_goals: this.data.goals, + scrollstop_books: this.data.books, + }; + + if (typeof browser !== 'undefined' && browser.storage && browser.storage.local) { + await browser.storage.local.set(storageData); + } else { + // Fallback to localStorage + for (const [key, value] of Object.entries(storageData)) { + localStorage.setItem(key, JSON.stringify(value)); + } + } + + console.log('Questionnaire data saved successfully'); + } catch (error) { + console.error('Error saving questionnaire data:', error); + } + } + + remove() { + if (this.overlay && this.overlay.parentNode) { + this.overlay.parentNode.removeChild(this.overlay); + } else if (this.element && this.element.parentNode) { + this.element.parentNode.removeChild(this.element); + } + delete window.questionnaireConfig; + } +} + +// Export for use in modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = QuestionnaireConfig; +} else { + window.QuestionnaireConfig = QuestionnaireConfig; +} diff --git a/Shared (Extension)/Resources/components/swipe-cards.js b/Shared (Extension)/Resources/components/swipe-cards.js new file mode 100644 index 0000000..be4e308 --- /dev/null +++ b/Shared (Extension)/Resources/components/swipe-cards.js @@ -0,0 +1,460 @@ +// UIComponentsJs/swipe-cards.js +// Tinder-like swipeable card component for activity suggestions + +class SwipeCards { + constructor(cards, options = {}) { + this.cards = cards || []; + this.currentIndex = 0; + this.options = { + containerClass: options.containerClass || '', + onSwipeRight: options.onSwipeRight || null, + onSwipeLeft: options.onSwipeLeft || null, + onComplete: options.onComplete || null, + ...options, + }; + + this.element = null; + this.isDragging = false; + this.startX = 0; + this.startY = 0; + this.currentX = 0; + this.currentY = 0; + this.offset = 0; + + // Bind methods + this.handleTouchStart = this.handleTouchStart.bind(this); + this.handleTouchMove = this.handleTouchMove.bind(this); + this.handleTouchEnd = this.handleTouchEnd.bind(this); + this.handleMouseDown = this.handleMouseDown.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleMouseUp = this.handleMouseUp.bind(this); + } + + render(container) { + if (!container) { + throw new Error('Container element is required'); + } + + this.element = document.createElement('div'); + this.element.className = `swipe-cards-container ${this.options.containerClass}`; + this.element.style.cssText = ` + position: relative; + width: 100%; + height: 400px; + overflow: hidden; + touch-action: none; + user-select: none; + `; + + this.createCards(); + this.setupEventListeners(); + + container.appendChild(this.element); + return this.element; + } + + createCards() { + if (!this.element) { + return; + } + + this.element.innerHTML = ''; + + if (this.cards.length === 0) { + this.showEmptyState(); + return; + } + + // Create cards stack (show up to 3 cards) + const visibleCards = this.cards.slice(this.currentIndex, this.currentIndex + 3); + + visibleCards.forEach((card, index) => { + const cardElement = this.createCard(card, index); + this.element.appendChild(cardElement); + }); + + // Add instructions + this.addInstructions(); + } + + createCard(card, stackIndex) { + const cardElement = document.createElement('div'); + cardElement.className = 'swipe-card'; + cardElement.dataset.stackIndex = stackIndex; + + const zIndex = 10 - stackIndex; + const scale = 1 - stackIndex * 0.05; + const yOffset = stackIndex * 10; + + cardElement.style.cssText = ` + position: absolute; + top: ${yOffset}px; + left: 0; + right: 0; + bottom: 0; + background: white; + border-radius: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + border: 1px solid rgba(0, 0, 0, 0.08); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 32px; + cursor: grab; + transform: scale(${scale}); + transform-origin: center; + z-index: ${zIndex}; + transition: transform 0.3s ease, opacity 0.3s ease; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + `; + + cardElement.innerHTML = ` +
+ ${card.emoji} +
+

+ ${card.text} +

+ ${ + card.description + ? ` +

+ ${card.description} +

+ ` + : '' + } + `; + + // Only make the top card interactive + if (stackIndex === 0) { + this.makeCardInteractive(cardElement); + } + + return cardElement; + } + + makeCardInteractive(cardElement) { + // Touch events + cardElement.addEventListener('touchstart', this.handleTouchStart, { passive: false }); + cardElement.addEventListener('touchmove', this.handleTouchMove, { passive: false }); + cardElement.addEventListener('touchend', this.handleTouchEnd, { passive: false }); + + // Mouse events for desktop + cardElement.addEventListener('mousedown', this.handleMouseDown); + cardElement.addEventListener('mousemove', this.handleMouseMove); + cardElement.addEventListener('mouseup', this.handleMouseUp); + cardElement.addEventListener('mouseleave', this.handleMouseUp); + + // Prevent default drag behavior + cardElement.addEventListener('dragstart', (e) => e.preventDefault()); + } + + addInstructions() { + const instructions = document.createElement('div'); + instructions.className = 'swipe-instructions'; + instructions.style.cssText = ` + position: absolute; + bottom: -60px; + left: 0; + right: 0; + text-align: center; + font-size: 0.9rem; + color: #6b7280; + display: flex; + justify-content: space-between; + padding: 0 20px; + `; + + instructions.innerHTML = ` + + ← Pass + + + Swipe to choose + + + Do it! β†’ + + `; + + this.element.appendChild(instructions); + } + + showEmptyState() { + this.element.innerHTML = ` +
+
🎯
+

No more activities

+
+ `; + } + + setupEventListeners() { + // Global mouse events for when dragging outside card + document.addEventListener('mousemove', this.handleMouseMove); + document.addEventListener('mouseup', this.handleMouseUp); + } + + handleTouchStart(e) { + this.isDragging = true; + this.startX = e.touches[0].clientX; + this.startY = e.touches[0].clientY; + this.currentX = this.startX; + this.currentY = this.startY; + + e.target.style.cursor = 'grabbing'; + e.preventDefault(); + } + + handleTouchMove(e) { + if (!this.isDragging) { + return; + } + + this.currentX = e.touches[0].clientX; + this.currentY = e.touches[0].clientY; + + this.updateCardPosition(e.target); + e.preventDefault(); + } + + handleTouchEnd(e) { + if (!this.isDragging) { + return; + } + + this.isDragging = false; + this.finishSwipe(e.target); + e.target.style.cursor = 'grab'; + } + + handleMouseDown(e) { + this.isDragging = true; + this.startX = e.clientX; + this.startY = e.clientY; + this.currentX = this.startX; + this.currentY = this.startY; + + e.target.style.cursor = 'grabbing'; + e.preventDefault(); + } + + handleMouseMove(e) { + if (!this.isDragging) { + return; + } + + this.currentX = e.clientX; + this.currentY = e.clientY; + + const topCard = this.element.querySelector('[data-stack-index="0"]'); + if (topCard) { + this.updateCardPosition(topCard); + } + } + + handleMouseUp(_e) { + if (!this.isDragging) { + return; + } + + this.isDragging = false; + const topCard = this.element.querySelector('[data-stack-index="0"]'); + if (topCard) { + this.finishSwipe(topCard); + topCard.style.cursor = 'grab'; + } + } + + updateCardPosition(cardElement) { + const deltaX = this.currentX - this.startX; + const deltaY = this.currentY - this.startY; + + // Limit vertical movement + const limitedDeltaY = Math.max(-50, Math.min(50, deltaY)); + + this.offset = deltaX; + + const rotation = deltaX * 0.1; // Subtle rotation + const opacity = Math.max(0.7, 1 - Math.abs(deltaX) / 200); + + cardElement.style.transform = `translateX(${deltaX}px) translateY(${limitedDeltaY}px) rotate(${rotation}deg)`; + cardElement.style.opacity = opacity; + + // Update visual feedback + this.updateSwipeFeedback(deltaX); + } + + updateSwipeFeedback(deltaX) { + const threshold = 100; + + // Remove any existing feedback + this.clearSwipeFeedback(); + + if (Math.abs(deltaX) > threshold) { + const feedback = document.createElement('div'); + feedback.className = 'swipe-feedback'; + feedback.style.cssText = ` + position: absolute; + top: 50%; + transform: translateY(-50%); + font-size: 2rem; + font-weight: bold; + z-index: 1000; + pointer-events: none; + transition: opacity 0.1s ease; + `; + + if (deltaX > threshold) { + // Swiping right (accept) + feedback.style.right = '20px'; + feedback.style.color = '#10b981'; + feedback.textContent = 'DO IT! βœ“'; + } else { + // Swiping left (reject) + feedback.style.left = '20px'; + feedback.style.color = '#ef4444'; + feedback.textContent = 'PASS βœ—'; + } + + this.element.appendChild(feedback); + } + } + + clearSwipeFeedback() { + const feedback = this.element.querySelector('.swipe-feedback'); + if (feedback) { + feedback.remove(); + } + } + + finishSwipe(cardElement) { + const threshold = 100; + const shouldSwipe = Math.abs(this.offset) > threshold; + + this.clearSwipeFeedback(); + + if (shouldSwipe) { + this.completeSwipe(cardElement, this.offset > 0 ? 'right' : 'left'); + } else { + this.resetCard(cardElement); + } + + this.offset = 0; + } + + completeSwipe(cardElement, direction) { + const finalX = direction === 'right' ? window.innerWidth : -window.innerWidth; + const rotation = direction === 'right' ? 30 : -30; + + cardElement.style.transition = 'transform 0.3s ease, opacity 0.3s ease'; + cardElement.style.transform = `translateX(${finalX}px) rotate(${rotation}deg)`; + cardElement.style.opacity = '0'; + + // Get current card data + const currentCard = this.cards[this.currentIndex]; + + // Trigger callbacks + if (direction === 'right' && this.options.onSwipeRight) { + this.options.onSwipeRight(currentCard, this.currentIndex); + } else if (direction === 'left' && this.options.onSwipeLeft) { + this.options.onSwipeLeft(currentCard, this.currentIndex); + } + + // Move to next card after animation + setTimeout(() => { + this.nextCard(); + }, 300); + } + + resetCard(cardElement) { + cardElement.style.transition = 'transform 0.3s ease, opacity 0.3s ease'; + cardElement.style.transform = 'translateX(0px) translateY(0px) rotate(0deg)'; + cardElement.style.opacity = '1'; + + setTimeout(() => { + cardElement.style.transition = ''; + }, 300); + } + + nextCard() { + this.currentIndex++; + + if (this.currentIndex >= this.cards.length) { + // All cards swiped + if (this.options.onComplete) { + this.options.onComplete(); + } + return; + } + + // Recreate cards with new stack + this.createCards(); + } + + // Public methods + addCard(card) { + this.cards.push(card); + if (this.element && this.currentIndex >= this.cards.length - 1) { + this.createCards(); + } + } + + reset() { + this.currentIndex = 0; + if (this.element) { + this.createCards(); + } + } + + getCurrentCard() { + return this.cards[this.currentIndex]; + } + + getRemainingCards() { + return this.cards.slice(this.currentIndex); + } + + destroy() { + if (this.element) { + // Remove global event listeners + document.removeEventListener('mousemove', this.handleMouseMove); + document.removeEventListener('mouseup', this.handleMouseUp); + + // Remove element + if (this.element.parentNode) { + this.element.parentNode.removeChild(this.element); + } + this.element = null; + } + } +} + +// Export for use in modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = SwipeCards; +} else { + window.SwipeCards = SwipeCards; +} diff --git a/Shared (Extension)/Resources/components/timer.js b/Shared (Extension)/Resources/components/timer.js deleted file mode 100644 index d55e1be..0000000 --- a/Shared (Extension)/Resources/components/timer.js +++ /dev/null @@ -1,446 +0,0 @@ -/** - * GlassmorphismTimer - A customizable timer component with Face ID styling - * - * USAGE EXAMPLES: - * - * // Basic 30-second timer with default size - * new GlassmorphismTimer(30).render('#container'); - * - * // Custom size and click handler - * new GlassmorphismTimer(120, { - * width: '80px', - * height: '36px', - * fontSize: '16px', - * onClick: (timer) => console.log('Timer clicked!', timer.getTime()) - * }).render(document.body); - * - * // Small timer with callbacks - * new GlassmorphismTimer(60, { - * width: '48px', - * height: '24px', - * fontSize: '10px', - * onStart: () => console.log('Timer started'), - * onStop: () => console.log('Timer stopped'), - * onComplete: () => console.log('Timer finished!'), - * onTick: (timeLeft) => console.log(`${timeLeft}s remaining`) - * }).render('.timer-container'); - * - * // Manual control with large size - * const timer = new GlassmorphismTimer(300, { - * width: '120px', - * height: '48px', - * fontSize: '20px', - * padding: '12px 24px' - * }); - * timer.render('#app'); - * timer.start(); - * - * SIZE OPTIONS: - * width: CSS width value (default: '56px') - * height: CSS height value (default: '27px') - * fontSize: CSS font-size (default: '12px') - * padding: CSS padding (default: '5px 10px') - * borderRadius: CSS border-radius (default: '35px') - * - * METHODS: - * start() - Start the timer - * stop() - Stop/pause the timer - * reset() - Reset to initial time - * setTime(seconds) - Set new time - * getTime() - Get current time remaining - * isRunning() - Check if timer is active - * destroy() - Clean up and remove from DOM - */ -class GlassmorphismTimer { - constructor(initialSeconds, options = {}) { - this.initialTime = initialSeconds; - this.currentTime = initialSeconds; - this.isActive = false; - this.intervalId = null; - - this.options = { - width: options.width || '56px', - height: options.height || '27px', - fontSize: options.fontSize || '12px', - padding: options.padding || '5px 10px', - borderRadius: options.borderRadius || '35px', - onClick: options.onClick || null, - onStart: options.onStart || null, - onStop: options.onStop || null, - onComplete: options.onComplete || null, - onTick: options.onTick || null, - className: options.className || '', - clickToToggle: options.clickToToggle !== false, // default true - ...options, - }; - - this.element = null; - this.timeDisplay = null; - this.createElement(); - } - - // Base styles - static styles = { - container: [ - 'position: relative', - 'display: inline-flex', - 'align-items: center', - 'justify-content: center', - 'cursor: pointer', - 'user-select: none', - 'transition: all 0.2s ease', - 'outline: none', - ], - - background: [ - 'background: rgba(0, 0, 0, 0.25)', - 'backdrop-filter: blur(12px) saturate(150%)', - '-webkit-backdrop-filter: blur(12px) saturate(150%)', - 'border: 1.5px solid rgba(255, 255, 255, 0.22)', - 'box-shadow: 0px 4px 32px rgba(0, 0, 0, 0.35), 0px 8px 64px rgba(0, 0, 0, 0.12), inset 0px 1px 0px rgba(255, 255, 255, 0.27)', - 'position: absolute', - 'top: 0', - 'left: 0', - 'width: 100%', - 'height: 100%', - ], - - timeDisplay: [ - 'color: #34c759', - 'text-shadow: 0px 1px 2px rgba(0, 0, 0, 0.8)', - 'font-family: "SF Mono", "Monaco", "Consolas", monospace', - 'font-weight: 500', - 'text-align: center', - 'line-height: 1.2', - 'position: absolute', - 'top: 50%', - 'left: 50%', - 'transform: translate(-48%, -43%)', - 'z-index: 1', - 'white-space: nowrap', - 'width: 100%', - 'display: flex', - 'align-items: center', - 'justify-content: center', - ], - - hover: ['transform: scale(1.02)', 'box-shadow: 0px 6px 40px rgba(0, 0, 0, 0.2)'], - - active: ['transform: scale(0.98)'], - - running: [ - 'box-shadow: 0px 4px 32px rgba(0, 0, 0, 0.35), 0px 8px 64px rgba(0, 0, 0, 0.12), 0px 0px 20px rgba(52, 199, 89, 0.6), inset 0px 1px 0px rgba(255, 255, 255, 0.27)', - ], - }; - - createElement() { - // Create main container - this.element = document.createElement('div'); - this.element.className = 'glassmorphism-timer'; - this.element.style.cssText = this.getContainerStyles(); - - // Create background element - const background = document.createElement('div'); - background.className = 'timer-background'; - background.style.cssText = this.getBackgroundStyles(); - - // Create time display - this.timeDisplay = document.createElement('div'); - this.timeDisplay.className = 'timer-display'; - this.timeDisplay.style.cssText = this.getTimeDisplayStyles(); - this.timeDisplay.textContent = this.formatTime(this.currentTime); - - // Assemble elements - this.element.appendChild(background); - this.element.appendChild(this.timeDisplay); - - // Add event listeners - this.addEventListeners(); - - // Add custom classes - if (this.options.className) { - this.element.classList.add(this.options.className); - } - } - - getContainerStyles() { - const size = this.getSizeConfig(); - const baseStyles = GlassmorphismTimer.styles.container.join('; '); - - return `${baseStyles}; width: ${size.width}; height: ${size.height}; border-radius: ${size.borderRadius};`; - } - - getBackgroundStyles() { - const size = this.getSizeConfig(); - const baseStyles = GlassmorphismTimer.styles.background.join('; '); - - return `${baseStyles}; border-radius: ${size.borderRadius};`; - } - - getTimeDisplayStyles() { - const size = this.getSizeConfig(); - const baseStyles = GlassmorphismTimer.styles.timeDisplay.join('; '); - - return `${baseStyles}; font-size: ${size.fontSize}; padding: ${size.padding};`; - } - - getSizeConfig() { - return { - width: this.options.width, - height: this.options.height, - fontSize: this.options.fontSize, - padding: this.options.padding, - borderRadius: this.options.borderRadius, - }; - } - - addEventListeners() { - // Click handler - this.element.addEventListener('click', (e) => { - e.preventDefault(); - - if (this.options.clickToToggle) { - this.toggle(); - } - - if (this.options.onClick) { - this.options.onClick(this); - } - }); - - // Hover effects - this.element.addEventListener('mouseenter', () => { - this.element.style.transform = 'scale(1.02)'; - const bg = this.element.querySelector('.timer-background'); - bg.style.boxShadow = '0px 6px 40px rgba(0, 0, 0, 0.2)'; - }); - - this.element.addEventListener('mouseleave', () => { - this.element.style.transform = 'scale(1)'; - this.updateBackgroundShadow(); - }); - - // Active state - this.element.addEventListener('mousedown', () => { - this.element.style.transform = 'scale(0.98)'; - }); - - this.element.addEventListener('mouseup', () => { - this.element.style.transform = 'scale(1.02)'; - }); - - // Keyboard support - this.element.addEventListener('keydown', (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - this.element.click(); - } - }); - - // Make focusable - this.element.tabIndex = 0; - } - - formatTime(seconds) { - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; - } - - updateDisplay() { - if (this.timeDisplay) { - this.timeDisplay.textContent = this.formatTime(this.currentTime); - } - } - - updateBackgroundShadow() { - const bg = this.element?.querySelector('.timer-background'); - if (bg) { - if (this.isActive) { - bg.style.boxShadow = - '0px 4px 32px rgba(0, 0, 0, 0.35), 0px 8px 64px rgba(0, 0, 0, 0.12), 0px 0px 20px rgba(52, 199, 89, 0.6), inset 0px 1px 0px rgba(255, 255, 255, 0.27)'; - } else { - bg.style.boxShadow = - '0px 4px 32px rgba(0, 0, 0, 0.35), 0px 8px 64px rgba(0, 0, 0, 0.12), inset 0px 1px 0px rgba(255, 255, 255, 0.27)'; - } - } - } - - // Timer control methods - start() { - if (this.isActive || this.currentTime <= 0) { - return this; - } - - this.isActive = true; - this.updateBackgroundShadow(); - - if (this.options.onStart) { - this.options.onStart(this); - } - - this.intervalId = setInterval(() => { - this.currentTime--; - this.updateDisplay(); - - if (this.options.onTick) { - this.options.onTick(this.currentTime, this); - } - - if (this.currentTime <= 0) { - this.complete(); - } - }, 1000); - - return this; - } - - stop() { - if (!this.isActive) { - return this; - } - - this.isActive = false; - this.updateBackgroundShadow(); - - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = null; - } - - if (this.options.onStop) { - this.options.onStop(this); - } - - return this; - } - - toggle() { - if (this.isActive) { - this.stop(); - } else { - this.start(); - } - return this; - } - - reset() { - this.stop(); - this.currentTime = this.initialTime; - this.updateDisplay(); - return this; - } - - complete() { - this.stop(); - - if (this.options.onComplete) { - this.options.onComplete(this); - } - - return this; - } - - // Getter/setter methods - setTime(seconds) { - const wasRunning = this.isActive; - this.stop(); - this.currentTime = Math.max(0, Math.floor(seconds)); - this.updateDisplay(); - - if (wasRunning && this.currentTime > 0) { - this.start(); - } - - return this; - } - - getTime() { - return this.currentTime; - } - - isRunning() { - return this.isActive; - } - - // Utility methods - updateSize(sizeOptions) { - // Update size options - if (sizeOptions.width) { - this.options.width = sizeOptions.width; - } - if (sizeOptions.height) { - this.options.height = sizeOptions.height; - } - if (sizeOptions.fontSize) { - this.options.fontSize = sizeOptions.fontSize; - } - if (sizeOptions.padding) { - this.options.padding = sizeOptions.padding; - } - if (sizeOptions.borderRadius) { - this.options.borderRadius = sizeOptions.borderRadius; - } - - // Reapply styles - this.element.style.cssText = this.getContainerStyles(); - - const bg = this.element.querySelector('.timer-background'); - bg.style.cssText = this.getBackgroundStyles(); - - this.timeDisplay.style.cssText = this.getTimeDisplayStyles(); - - return this; - } - - // Render method - render(container) { - if (typeof container === 'string') { - container = document.querySelector(container); - } - - if (!container) { - throw new Error('Container not found'); - } - - container.appendChild(this.element); - return this; - } - - // Cleanup method - destroy() { - this.stop(); - - if (this.element && this.element.parentNode) { - this.element.parentNode.removeChild(this.element); - } - - this.element = null; - this.timeDisplay = null; - - return this; - } - - // Static helper methods - static createGroup(container, timers) { - const group = document.createElement('div'); - group.className = 'timer-group'; - group.style.cssText = 'display: flex; gap: 8px; align-items: center;'; - - timers.forEach((config) => { - new GlassmorphismTimer(config.time, config.options).render(group); - }); - - if (typeof container === 'string') { - container = document.querySelector(container); - } - container.appendChild(group); - - return group; - } -} - -// Export for use in modules -if (typeof module !== 'undefined' && module.exports) { - module.exports = GlassmorphismTimer; -} diff --git a/Shared (Extension)/Resources/content.js b/Shared (Extension)/Resources/content.js index db907c8..139c6d6 100644 --- a/Shared (Extension)/Resources/content.js +++ b/Shared (Extension)/Resources/content.js @@ -7,14 +7,13 @@ class ScrollStopCoordinator { this.doomscrollAnimation = null; this.transitionScreen = null; this.blockingScreen = null; - this.timerTracker = null; this.choiceDialog = null; this.periodicReminder = null; this.grayscaleFilter = null; this.isInitialized = false; this.currentHostname = window.location.hostname; - this.userChoice = null; // 'continue', 'timer-only', 'block' + this.userChoice = null; // 'continue', 'configure', 'block', 'bypass' // Bind event handlers this.handleDoomscrollDetected = this.handleDoomscrollDetected.bind(this); @@ -51,25 +50,6 @@ class ScrollStopCoordinator { } } - /** - * Initialize timer tracker for all tracked sites (social media and news) - */ - async initializeTimerTracker() { - try { - // Check if current site is tracked (either blocked or news site) - const url = window.location.href; - const hostname = window.location.hostname; - const siteType = await StorageHelper.getCurrentSiteType(url, hostname); - - if (siteType.isBlocked || siteType.isNews || siteType.isAdult) { - this.timerTracker = new TimerTracker(); - await this.timerTracker.initialize(siteType.isNews); - } - } catch (error) { - console.error('Error initializing timer tracker:', error); - } - } - /** * Set up event listeners for inter-module communication */ @@ -104,6 +84,12 @@ class ScrollStopCoordinator { // Store site type for later use this.currentSiteType = siteType; + // Check if bypass mode is active + if (this.checkBypassStatus()) { + console.log('ScrollStop: Bypass mode active, not showing dialog'); + return; + } + // Check if user wants to configure activities (from popup) const shouldShowQuestionnaire = localStorage.getItem('scrollstop_show_questionnaire'); if (shouldShowQuestionnaire === 'true') { @@ -274,8 +260,7 @@ class ScrollStopCoordinator { async proceedWithChoice(choice) { switch (choice) { case 'continue': - // Full ScrollStop functionality - initialize timer then start detection - await this.initializeTimerTracker(); + // Full ScrollStop functionality - start detection // Only start doomscroll detection for blocked sites, not news sites if (this.currentSiteType && this.currentSiteType.isBlocked) { this.startDoomscrollDetection(); @@ -290,8 +275,7 @@ class ScrollStopCoordinator { return; // Don't proceed with normal initialization case 'block': - // Initialize timer first, then immediately block the site - await this.initializeTimerTracker(); + // Immediately block the site if (this.currentSiteType && this.currentSiteType.isNews) { // For news sites, create news time block await TimeManager.createNewsTimeBlock(); @@ -302,10 +286,14 @@ class ScrollStopCoordinator { this.showBlockingScreen(); break; + case 'bypass': + // Bypass ScrollStop completely - set a delay before showing choice dialog again + this.setBypassMode(); + break; + default: console.warn('Unknown choice:', choice); // Default to continue - await this.initializeTimerTracker(); if (this.currentSiteType && this.currentSiteType.isBlocked) { this.startDoomscrollDetection(); } @@ -314,12 +302,48 @@ class ScrollStopCoordinator { break; } - // Start periodic reminder for all choices (except when immediately blocked) - if (choice !== 'block') { + // Start periodic reminder for all choices (except when immediately blocked or bypassed) + if (choice !== 'block' && choice !== 'bypass') { this.startPeriodicReminder(); } } + /** + * Set bypass mode - extension acts inactive for 5 minutes (same as periodic reminder) + */ + setBypassMode() { + console.log('ScrollStop: Setting bypass mode for 5 minutes'); + + // Store bypass timestamp + const bypassUntil = Date.now() + 5 * 60 * 1000; // 5 minutes + localStorage.setItem('scrollstop_bypass_until', bypassUntil.toString()); + + // Clean up any active modules + this.cleanup(); + + // Set a timer to show choice dialog again after bypass period + setTimeout( + () => { + this.checkBypassStatus(); + }, + 5 * 60 * 1000 + ); // 5 minutes + } + + /** + * Check if bypass mode is still active + */ + checkBypassStatus() { + const bypassUntil = localStorage.getItem('scrollstop_bypass_until'); + if (bypassUntil && Date.now() < parseInt(bypassUntil)) { + return true; // Still in bypass mode + } + + // Bypass expired, remove flag and show choice dialog + localStorage.removeItem('scrollstop_bypass_until'); + this.showChoiceDialog(); + return false; + } /** * Start periodic reminder system (5-minute intervals) @@ -349,7 +373,7 @@ class ScrollStopCoordinator { if (!this.grayscaleFilter) { this.grayscaleFilter = new window.GrayscaleFilter({ - timeLimit: 5 * 60 * 1000, // 5 minutes + timeLimit: 45 * 60 * 1000, // 45 minutes filterDuration: 60 * 60 * 1000, // 1 hour }); } @@ -411,11 +435,6 @@ class ScrollStopCoordinator { this.blockingScreen = null; } - if (this.timerTracker) { - this.timerTracker.cleanup(); - this.timerTracker = null; - } - if (this.choiceDialog) { this.choiceDialog.cleanup(); this.choiceDialog = null; diff --git a/Shared (Extension)/Resources/manifest.json b/Shared (Extension)/Resources/manifest.json index 85f3678..4aa139e 100644 --- a/Shared (Extension)/Resources/manifest.json +++ b/Shared (Extension)/Resources/manifest.json @@ -23,16 +23,13 @@ "content_scripts": [ { "js": [ - "timer.js", "storage-helper.js", "time-manager.js", - "timer-tracker.js", "button.js", "badge.js", "dialog.js", "divider.js", "swipe-cards.js", - "activity-timer.js", "questionnaire-config.js", "choice-dialog.js", "doomscroll-detector.js", diff --git a/Shared (Extension)/Resources/modules/blocking-screen/blocking-screen.js b/Shared (Extension)/Resources/modules/blocking-screen/blocking-screen.js index 72b7d1c..f4dfd3d 100644 --- a/Shared (Extension)/Resources/modules/blocking-screen/blocking-screen.js +++ b/Shared (Extension)/Resources/modules/blocking-screen/blocking-screen.js @@ -67,8 +67,9 @@ if (typeof window.BlockingScreen === 'undefined') { } // Detect dark mode - const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - + const isDarkMode = + window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + this.blockingElement.style.cssText = ` height: 100vh; width: 100vw; @@ -95,9 +96,10 @@ if (typeof window.BlockingScreen === 'undefined') { // Check if this is a news site const isNews = await StorageHelper.isCurrentSiteNews(window.location.href, this.hostname); const siteName = this.hostname.replace('www.', '').split('.')[0]; - + // Detect dark mode for styling - const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + const isDarkMode = + window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; this.blockingElement.innerHTML = `
Continue with ScrollStop +
- +