This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

add dark mode

+412 -67
+5 -6
app/src/app/api/bluesky/stats/route.ts
··· 50 50 .map(([date, count]): {date: string, count: number} => ({ date, count })) 51 51 .sort((a, b) => a.date.localeCompare(b.date)); 52 52 53 - // Calculate flushes per day 53 + // Calculate flushes per day based on actual active days 54 54 let flushesPerDay = 0; 55 55 if (chartData.length > 0 && totalCount !== null) { 56 - // Calculate days between first and last flush 57 - const firstDate = new Date(chartData[0].date); 58 - const lastDate = new Date(chartData[chartData.length - 1].date); 59 - const daysDiff = Math.max(1, Math.ceil((lastDate.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24))); 60 - flushesPerDay = parseFloat(((totalCount || 0) / daysDiff).toFixed(1)); 56 + // Use the number of days with at least one flush (which is the length of chartData) 57 + // This gives us the actual active days count 58 + const activeDaysCount = chartData.length; 59 + flushesPerDay = parseFloat(((totalCount || 0) / activeDaysCount).toFixed(1)); 61 60 } 62 61 63 62 // 3. Get top flushers (leaderboard)
+13 -13
app/src/app/feed/feed.module.css
··· 106 106 } 107 107 108 108 .loader { 109 - border: 4px solid #f3f3f3; 110 - border-top: 4px solid #3897f0; 109 + border: 4px solid var(--background-color); 110 + border-top: 4px solid var(--primary-color); 111 111 border-radius: 50%; 112 112 width: 40px; 113 113 height: 40px; ··· 127 127 } 128 128 129 129 .feedItem { 130 - background-color: white; 130 + background-color: var(--card-background); 131 131 border: 1px solid var(--tile-border); 132 132 border-radius: 8px; 133 133 padding: 1rem; 134 - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); 134 + box-shadow: 0 2px 5px var(--shadow-color); 135 135 transition: transform 0.2s, box-shadow 0.2s; 136 136 background-image: repeating-linear-gradient(0deg, var(--tile-border), var(--tile-border) 1px, transparent 1px, transparent 20px); 137 137 } 138 138 139 139 .feedItem:hover { 140 140 transform: translateY(-2px); 141 - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 141 + box-shadow: 0 4px 8px var(--shadow-color); 142 142 } 143 143 144 144 .feedHeader { ··· 182 182 .emptyState { 183 183 text-align: center; 184 184 padding: 2rem; 185 - background-color: #f9f9f9; 185 + background-color: var(--background-color); 186 186 border-radius: 8px; 187 - border: 1px dashed #ccc; 187 + border: 1px dashed var(--tile-border); 188 188 } 189 189 190 190 .loadMoreButton { 191 191 width: 100%; 192 - background-color: #f1f1f1; 193 - color: #444; 194 - border: 1px solid #ddd; 192 + background-color: var(--button-background); 193 + color: var(--button-text); 194 + border: 1px solid var(--input-border); 195 195 border-radius: 8px; 196 196 padding: 1rem; 197 197 font-size: 1rem; ··· 206 206 } 207 207 208 208 .loadMoreButton:hover { 209 - background-color: #e5e5e5; 209 + background-color: var(--button-hover); 210 210 } 211 211 212 212 .loadMoreButton:disabled { 213 - background-color: #f5f5f5; 214 - color: #aaa; 213 + background-color: var(--button-disabled); 214 + color: var(--button-disabled-text); 215 215 cursor: not-allowed; 216 216 } 217 217
+16 -1
app/src/app/feed/page.tsx
··· 67 67 // Function to load older entries 68 68 const loadOlderEntries = async () => { 69 69 try { 70 + // Save current scroll position 71 + const scrollPosition = window.scrollY; 72 + 70 73 setLoading(true); 71 74 setError(null); 72 75 ··· 96 99 if (data.entries && data.entries.length > 0) { 97 100 // Append the new entries to our existing list 98 101 setEntries([...entries, ...data.entries]); 102 + 103 + // Wait for DOM to update with new entries 104 + setTimeout(() => { 105 + // Restore scroll position after state update and render 106 + window.scrollTo({ 107 + top: scrollPosition, 108 + behavior: 'instant' // Use instant to avoid additional animation 109 + }); 110 + }, 0); 99 111 } 100 112 } catch (err: any) { 101 113 console.error('Error fetching older entries:', err); ··· 170 182 171 183 <button 172 184 className={styles.loadMoreButton} 173 - onClick={loadOlderEntries} 185 + onClick={(e) => { 186 + e.preventDefault(); // Prevent default action 187 + loadOlderEntries(); 188 + }} 174 189 disabled={loading} 175 190 > 176 191 {loading ? 'Loading...' : 'Load older flushes'}
+106 -4
app/src/app/globals.css
··· 1 + /* Light mode variables */ 1 2 :root { 2 3 --primary-color: #5badf0; 3 - --secondary-color: #6d4aff; 4 + --secondary-color: #1968a8; 4 5 --background-color: #f9f9f9; 6 + --card-background: #ffffff; 5 7 --text-color: #333; 8 + --title-color: #272727; 6 9 --error-color: #ff5252; 10 + --error-background: #ffebee; 11 + --success-background: rgba(76, 175, 80, 0.1); 12 + --success-text: #4caf50; 13 + --notice-background: #fff3e0; 14 + --notice-text: #e65100; 15 + --notice-border: #ff9800; 16 + --input-background: #ffffff; 17 + --input-border: #ddd; 18 + --input-focus-border: #5badf0; 19 + --input-prefix-background: #f8f8f8; 20 + --button-background: #f1f1f1; 21 + --button-text: #444; 22 + --button-hover: #e5e5e5; 23 + --button-disabled: #f5f5f5; 24 + --button-disabled-text: #aaa; 25 + --chart-bar: #5badf0; 26 + --chart-background: #f8f9fa; 27 + --timestamp-color: #888; 28 + --shadow-color: rgba(0, 0, 0, 0.1); 29 + --emoji-button-bg: #f5f5f5; 30 + --emoji-button-border: #ddd; 31 + --emoji-grid-bg: #fcfcfc; 32 + --tile-border: rgba(0, 0, 0, 0.1); 33 + } 34 + 35 + /* Dark mode variables */ 36 + [data-theme="dark"] { 37 + --primary-color: #5badf0; 38 + --secondary-color: #69c0ff; 39 + --background-color: #121212; 40 + --card-background: #1e1e1e; 41 + --text-color: #dddddd; 42 + --title-color: #e0e0e0; 43 + --error-color: #ff7070; 44 + --error-background: #4a161a; 45 + --success-background: rgba(76, 175, 80, 0.2); 46 + --success-text: #7dff83; 47 + --notice-background: #3d2e15; 48 + --notice-text: #ffae5e; 49 + --notice-border: #ff9800; 50 + --input-background: #2d2d2d; 51 + --input-border: #444; 52 + --input-focus-border: #5badf0; 53 + --input-prefix-background: #252525; 54 + --button-background: #2d2d2d; 55 + --button-text: #e0e0e0; 56 + --button-hover: #3a3a3a; 57 + --button-disabled: #252525; 58 + --button-disabled-text: #666; 59 + --chart-bar: #5badf0; 60 + --chart-background: #252525; 61 + --timestamp-color: #aaa; 62 + --shadow-color: rgba(0, 0, 0, 0.3); 63 + --emoji-button-bg: #2d2d2d; 64 + --emoji-button-border: #444; 65 + --emoji-grid-bg: #252525; 66 + --tile-border: rgba(255, 255, 255, 0.1); 67 + } 68 + 69 + /* Automatically use dark mode if user prefers it */ 70 + @media (prefers-color-scheme: dark) { 71 + :root:not([data-theme="light"]) { 72 + --primary-color: #5badf0; 73 + --secondary-color: #69c0ff; 74 + --background-color: #121212; 75 + --card-background: #1e1e1e; 76 + --text-color: #dddddd; 77 + --title-color: #e0e0e0; 78 + --error-color: #ff7070; 79 + --error-background: #4a161a; 80 + --success-background: rgba(76, 175, 80, 0.2); 81 + --success-text: #7dff83; 82 + --notice-background: #3d2e15; 83 + --notice-text: #ffae5e; 84 + --notice-border: #ff9800; 85 + --input-background: #2d2d2d; 86 + --input-border: #444; 87 + --input-focus-border: #5badf0; 88 + --input-prefix-background: #252525; 89 + --button-background: #2d2d2d; 90 + --button-text: #e0e0e0; 91 + --button-hover: #3a3a3a; 92 + --button-disabled: #252525; 93 + --button-disabled-text: #666; 94 + --chart-bar: #5badf0; 95 + --chart-background: #252525; 96 + --timestamp-color: #aaa; 97 + --shadow-color: rgba(0, 0, 0, 0.3); 98 + --emoji-button-bg: #2d2d2d; 99 + --emoji-button-border: #444; 100 + --emoji-grid-bg: #252525; 101 + --tile-border: rgba(255, 255, 255, 0.1); 102 + } 103 + 104 + body { 105 + color-scheme: dark; 106 + } 7 107 } 8 108 9 109 * { ··· 61 161 } 62 162 63 163 .card { 64 - background: white; 164 + background: var(--card-background); 65 165 border-radius: 8px; 66 - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 166 + box-shadow: 0 2px 10px var(--shadow-color); 67 167 padding: 2rem; 68 168 margin: 1rem 0; 69 169 width: 100%; ··· 84 184 .form-group select { 85 185 width: 100%; 86 186 padding: 0.5rem; 87 - border: 1px solid #ddd; 187 + border: 1px solid var(--input-border); 88 188 border-radius: 4px; 89 189 font-size: 1rem; 190 + background-color: var(--input-background); 191 + color: var(--text-color); 90 192 } 91 193 92 194 .error {
+14 -1
app/src/app/layout.tsx
··· 1 1 import type { Metadata } from 'next'; 2 2 import './globals.css'; 3 3 import { AuthProvider } from '@/lib/auth-context'; 4 + import { ThemeProvider } from '@/lib/theme-context'; 5 + import ThemeToggle from '@/components/ThemeToggle'; 4 6 5 7 export const metadata: Metadata = { 6 8 title: "im.flushing", ··· 38 40 <html lang="en"> 39 41 <body> 40 42 <AuthProvider> 41 - <main>{children}</main> 43 + <ThemeProvider> 44 + <header style={{ 45 + display: 'flex', 46 + justifyContent: 'flex-end', 47 + padding: '0.5rem 1rem', 48 + backgroundColor: 'var(--card-background)', 49 + borderBottom: '1px solid var(--tile-border)' 50 + }}> 51 + <ThemeToggle /> 52 + </header> 53 + <main>{children}</main> 54 + </ThemeProvider> 42 55 </AuthProvider> 43 56 </body> 44 57 </html>
+14 -14
app/src/app/page.module.css
··· 185 185 } 186 186 187 187 .card { 188 - background: white; 188 + background: var(--card-background); 189 189 border-radius: 8px; 190 - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 190 + box-shadow: 0 2px 10px var(--shadow-color); 191 191 padding: 2rem; 192 192 } 193 193 ··· 489 489 } 490 490 491 491 .feedItem { 492 - background-color: white; 493 - border: 1px solid #e1e1e1; 492 + background-color: var(--card-background); 493 + border: 1px solid var(--tile-border); 494 494 border-radius: 8px; 495 495 padding: 1rem; 496 - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); 496 + box-shadow: 0 2px 5px var(--shadow-color); 497 497 transition: transform 0.2s, box-shadow 0.2s; 498 498 } 499 499 500 500 .feedItem:hover { 501 501 transform: translateY(-2px); 502 - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 502 + box-shadow: 0 4px 8px var(--shadow-color); 503 503 } 504 504 505 505 @media (max-width: 600px) { ··· 659 659 .emptyState { 660 660 text-align: center; 661 661 padding: 2rem; 662 - background-color: #f9f9f9; 662 + background-color: var(--background-color); 663 663 border-radius: 8px; 664 - border: 1px dashed #ccc; 664 + border: 1px dashed var(--tile-border); 665 665 } 666 666 667 667 .loadMoreButton { 668 668 width: 100%; 669 - background-color: #f1f1f1; 670 - color: #444; 671 - border: 1px solid #ddd; 669 + background-color: var(--button-background); 670 + color: var(--button-text); 671 + border: 1px solid var(--input-border); 672 672 border-radius: 8px; 673 673 padding: 1rem; 674 674 font-size: 1rem; ··· 683 683 } 684 684 685 685 .loadMoreButton:hover { 686 - background-color: #e5e5e5; 686 + background-color: var(--button-hover); 687 687 } 688 688 689 689 .loadMoreButton:disabled { 690 - background-color: #f5f5f5; 691 - color: #aaa; 690 + background-color: var(--button-disabled); 691 + color: var(--button-disabled-text); 692 692 cursor: not-allowed; 693 693 } 694 694
+26 -5
app/src/app/page.tsx
··· 55 55 setSelectedEmoji(emoji); 56 56 }; 57 57 58 - // Check rate limit - 2 posts per 30 minutes 58 + // Check rate limit - 2 posts per 30 minutes, except for the plumber account 59 59 const checkRateLimit = (): boolean => { 60 + // Exempt the plumber account from rate limiting 61 + if (did === 'did:plc:fouf3svmcxzn6bpiw3lgwz22') { 62 + console.log('Plumber account detected - bypassing rate limits'); 63 + return true; // Always return true (under limit) for the plumber account 64 + } 65 + 60 66 const now = Date.now(); 61 67 const thirtyMinutesAgo = now - 30 * 60 * 1000; // 30 minutes in milliseconds 62 68 ··· 86 92 87 93 // Check for banned words 88 94 if (text && containsBannedWords(text)) { 89 - setStatusError('Your status contains inappropriate language. Please revise it.'); 95 + setStatusError('Uh oh, looks like you have a potty mouth. Try flushing again, but go a bit easier on the language please... this is a semi-family-friendly restroom'); 90 96 return; 91 97 } 92 98 93 - // Check rate limit - 2 posts per 30 minutes 99 + // Check rate limit - 2 posts per 30 minutes (except for the plumber account) 94 100 if (!checkRateLimit()) { 95 - setStatusError("Trying to make more than 2 flushes in 30 minutes?? Might be time to get the plunger. 🪠"); 101 + setStatusError("Trying to make more than 2 flushes in 30 minutes?? Might be time to get the plunger. 🪠 Regular users are limited to 2 flushes per 30 minutes."); 96 102 return; 97 103 } 98 104 ··· 238 244 // Function to load older entries 239 245 const loadOlderEntries = async () => { 240 246 try { 247 + // Save current scroll position 248 + const scrollPosition = window.scrollY; 249 + 241 250 setLoading(true); 242 251 setError(null); 243 252 ··· 267 276 if (data.entries && data.entries.length > 0) { 268 277 // Append the new entries to our existing list 269 278 setEntries([...entries, ...data.entries]); 279 + 280 + // Wait for DOM to update with new entries 281 + setTimeout(() => { 282 + // Restore scroll position after state update and render 283 + window.scrollTo({ 284 + top: scrollPosition, 285 + behavior: 'instant' // Use instant to avoid additional animation 286 + }); 287 + }, 0); 270 288 } 271 289 } catch (err: any) { 272 290 console.error('Error fetching older entries:', err); ··· 466 484 467 485 <button 468 486 className={styles.loadMoreButton} 469 - onClick={loadOlderEntries} 487 + onClick={(e) => { 488 + e.preventDefault(); // Prevent default action 489 + loadOlderEntries(); 490 + }} 470 491 disabled={loading} 471 492 > 472 493 {loading ? 'Loading...' : 'Load older flushes'}
+13 -15
app/src/app/profile/[handle]/page.tsx
··· 60 60 61 61 // Calculate statistics and chart data 62 62 if (userEntries.length > 0) { 63 - // Calculate flushes per day 64 - const sortedEntries = [...userEntries].sort((a: FlushingEntry, b: FlushingEntry) => 65 - new Date(a.created_at).getTime() - new Date(b.created_at).getTime() 66 - ); 63 + // Calculate actual active days count (days with at least one flush) 64 + const dateSet = new Set<string>(); 65 + userEntries.forEach((entry: FlushingEntry) => { 66 + const date = new Date(entry.created_at); 67 + const dateKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; 68 + dateSet.add(dateKey); 69 + }); 67 70 68 - if (sortedEntries.length > 1) { 69 - const firstDate = new Date(sortedEntries[0].created_at); 70 - const lastDate = new Date(sortedEntries[sortedEntries.length - 1].created_at); 71 - const daysDiff = Math.max(1, Math.ceil((lastDate.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24))); 72 - const perDay = parseFloat((sortedEntries.length / daysDiff).toFixed(1)); 73 - setFlushesPerDay(perDay); 74 - } else { 75 - setFlushesPerDay(1); // Just one flush on a single day 76 - } 71 + // Calculate true average: total flushes divided by number of active days 72 + const activeDaysCount = Math.max(1, dateSet.size); 73 + const perDay = parseFloat((userEntries.length / activeDaysCount).toFixed(1)); 74 + setFlushesPerDay(perDay); 77 75 78 76 // Generate chart data (group by day) 79 77 const chartDataMap = new Map<string, number>(); ··· 148 146 <h3 className={styles.statsHeader}>Flushing Statistics</h3> 149 147 <p className={styles.statDetails}> 150 148 {totalCount} total {totalCount === 1 ? 'flush' : 'flushes'} 151 - {flushesPerDay > 0 && `, ${flushesPerDay} ${flushesPerDay === 1 ? 'flush' : 'flushes'} per day`} 149 + {flushesPerDay > 0 && `, averaging ${flushesPerDay} ${flushesPerDay === 1 ? 'flush' : 'flushes'} per active day`} 152 150 </p> 153 151 154 152 {chartData.length > 0 ? ( ··· 183 181 className={styles.shareStatsButton} 184 182 onClick={() => { 185 183 // Open a new window to compose a post on Bluesky 186 - const statsText = `I've made ${totalCount} decentralized ${totalCount === 1 ? 'flush' : 'flushes'}${flushesPerDay > 0 ? ` (or ${flushesPerDay} per day)` : ''} on @flushing.im. Flush with me here: https://flushing.im/profile/${handle}`; 184 + const statsText = `I've made ${totalCount} decentralized ${totalCount === 1 ? 'flush' : 'flushes'}${flushesPerDay > 0 ? ` (averaging ${flushesPerDay} per active day)` : ''} on @flushing.im. Flush with me here: https://flushing.im/profile/${handle}`; 187 185 window.open(`https://bsky.app/intent/compose?text=${encodeURIComponent(statsText)}`, '_blank'); 188 186 }} 189 187 >
-1
app/src/app/profile/[handle]/profile.module.css
··· 102 102 .statDetails { 103 103 font-size: 1.1rem; 104 104 color: #666; 105 - margin-bottom: 1.5rem; 106 105 } 107 106 108 107 .chartContainer {
+2 -2
app/src/app/stats/page.tsx
··· 157 157 </div> 158 158 <div className={styles.statCard}> 159 159 <div className={styles.statValue}>{statsData.flushesPerDay}</div> 160 - <div className={styles.statLabel}>Flushes Per Day</div> 160 + <div className={styles.statLabel}>Flushes Per Active Day</div> 161 161 </div> 162 162 </div> 163 163 </section> ··· 236 236 className={styles.shareButton} 237 237 onClick={() => { 238 238 // Generate share text 239 - const statsText = `There have been ${statsData.totalCount} flushes on @flushing.im! That's ${statsData.flushesPerDay} flushes per day. Check out the stats and leaderboard: https://flushing.im/stats`; 239 + const statsText = `There have been ${statsData.totalCount} flushes on @flushing.im! That's averaging ${statsData.flushesPerDay} flushes per active day. Check out the stats and leaderboard: https://flushing.im/stats`; 240 240 window.open(`https://bsky.app/intent/compose?text=${encodeURIComponent(statsText)}`, '_blank'); 241 241 }} 242 242 >
+37
app/src/components/ThemeToggle.module.css
··· 1 + .themeToggle { 2 + display: flex; 3 + align-items: center; 4 + gap: 0.5rem; 5 + background-color: var(--button-background); 6 + color: var(--button-text); 7 + border: 1px solid var(--input-border); 8 + border-radius: 8px; 9 + padding: 0.5rem 0.8rem; 10 + cursor: pointer; 11 + transition: all 0.2s; 12 + margin-left: auto; 13 + } 14 + 15 + .themeToggle:hover { 16 + background-color: var(--button-hover); 17 + } 18 + 19 + .themeToggle svg { 20 + width: 18px; 21 + height: 18px; 22 + } 23 + 24 + .themeLabel { 25 + font-size: 0.9rem; 26 + font-weight: 500; 27 + } 28 + 29 + @media (max-width: 600px) { 30 + .themeToggle { 31 + padding: 0.4rem 0.6rem; 32 + } 33 + 34 + .themeLabel { 35 + display: none; 36 + } 37 + }
+64
app/src/components/ThemeToggle.tsx
··· 1 + 'use client'; 2 + 3 + import { useTheme } from '@/lib/theme-context'; 4 + import React from 'react'; 5 + import styles from './ThemeToggle.module.css'; 6 + 7 + export default function ThemeToggle() { 8 + const { theme, setTheme } = useTheme(); 9 + 10 + const toggleTheme = () => { 11 + if (theme === 'light') { 12 + setTheme('dark'); 13 + } else if (theme === 'dark') { 14 + setTheme('system'); 15 + } else { 16 + setTheme('light'); 17 + } 18 + }; 19 + 20 + const getIcon = () => { 21 + if (theme === 'light') { 22 + return ( 23 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 24 + <circle cx="12" cy="12" r="5"></circle> 25 + <line x1="12" y1="1" x2="12" y2="3"></line> 26 + <line x1="12" y1="21" x2="12" y2="23"></line> 27 + <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line> 28 + <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line> 29 + <line x1="1" y1="12" x2="3" y2="12"></line> 30 + <line x1="21" y1="12" x2="23" y2="12"></line> 31 + <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line> 32 + <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line> 33 + </svg> 34 + ); 35 + } else if (theme === 'dark') { 36 + return ( 37 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 38 + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path> 39 + </svg> 40 + ); 41 + } else { 42 + return ( 43 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 44 + <circle cx="12" cy="12" r="10"></circle> 45 + <line x1="2" y1="12" x2="22" y2="12"></line> 46 + <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path> 47 + </svg> 48 + ); 49 + } 50 + }; 51 + 52 + const getLabel = () => { 53 + if (theme === 'light') return 'Light'; 54 + if (theme === 'dark') return 'Dark'; 55 + return 'System'; 56 + }; 57 + 58 + return ( 59 + <button className={styles.themeToggle} onClick={toggleTheme} aria-label={`Switch to ${theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light'} theme`}> 60 + {getIcon()} 61 + <span className={styles.themeLabel}>{getLabel()}</span> 62 + </button> 63 + ); 64 + }
+2 -5
app/src/lib/content-filter.ts
··· 2 2 // These words will be filtered from all posts in the application 3 3 const BANNED_WORDS: string[] = [ 4 4 // Generic offensive terms 5 - 'offensive', 'inappropriate', 'slur', 5 + 'slur', 6 6 7 7 // Hate speech related 8 8 'racist', 'bigot', 'bigotry', 'homophobic', 'transphobic', 9 9 10 10 // Profanity 11 - 'shit', 'fuck', 'damn', 'ass', 'asshole', 'bitch', 11 + 'fuck', 'damn', 'ass', 'asshole', 'bitch', 12 12 13 13 // Violence 14 14 'kill', 'murder', 'attack', 'violence', 'harm', 'hurt', ··· 20 20 'penis', 'vagina', 'dick', 'cock', 'pussy', 'sex', 21 21 'masturbate', 'orgasm', 'horny', 'erection', 22 22 'blowjob', 'handjob', 23 - 24 - // Bathroom-inappropriate terms (since this is a family-friendly app) 25 - 'diarrhea', 'constipation', 'explosive', 'bloody', 26 23 27 24 // Spam-related 28 25 'viagra', 'cialis', 'enlarge', 'cryptocurrency', 'bitcoin', 'ethereum',
+100
app/src/lib/theme-context.tsx
··· 1 + 'use client'; 2 + 3 + import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; 4 + 5 + type Theme = 'light' | 'dark' | 'system'; 6 + 7 + interface ThemeContextType { 8 + theme: Theme; 9 + setTheme: (theme: Theme) => void; 10 + } 11 + 12 + const ThemeContext = createContext<ThemeContextType | undefined>(undefined); 13 + 14 + export function ThemeProvider({ children }: { children: ReactNode }) { 15 + const [theme, setTheme] = useState<Theme>('system'); 16 + const [mounted, setMounted] = useState(false); 17 + 18 + // Get stored theme preference or use system default 19 + useEffect(() => { 20 + const storedTheme = localStorage.getItem('theme') as Theme | null; 21 + if (storedTheme) { 22 + setTheme(storedTheme); 23 + } 24 + setMounted(true); 25 + }, []); 26 + 27 + // Apply theme class to document 28 + useEffect(() => { 29 + if (!mounted) return; 30 + 31 + // Save to local storage 32 + localStorage.setItem('theme', theme); 33 + 34 + // Apply data-theme attribute 35 + const root = window.document.documentElement; 36 + 37 + if (theme === 'system') { 38 + // Check system preference 39 + const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 40 + root.removeAttribute('data-theme'); 41 + 42 + // Only add this class if needed to override system preference 43 + if (systemPrefersDark) { 44 + root.classList.add('dark'); 45 + } else { 46 + root.classList.remove('dark'); 47 + } 48 + } else { 49 + // Apply explicit preference 50 + root.setAttribute('data-theme', theme); 51 + if (theme === 'dark') { 52 + root.classList.add('dark'); 53 + } else { 54 + root.classList.remove('dark'); 55 + } 56 + } 57 + }, [theme, mounted]); 58 + 59 + // Handle system preference changes 60 + useEffect(() => { 61 + if (!mounted) return; 62 + 63 + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 64 + 65 + const handleChange = () => { 66 + if (theme === 'system') { 67 + // Update the UI if we're in system mode 68 + const root = window.document.documentElement; 69 + if (mediaQuery.matches) { 70 + root.classList.add('dark'); 71 + } else { 72 + root.classList.remove('dark'); 73 + } 74 + } 75 + }; 76 + 77 + mediaQuery.addEventListener('change', handleChange); 78 + return () => mediaQuery.removeEventListener('change', handleChange); 79 + }, [theme, mounted]); 80 + 81 + const value = { 82 + theme, 83 + setTheme, 84 + }; 85 + 86 + // Don't render until mounted to prevent hydration mismatch 87 + if (!mounted) { 88 + return <>{children}</>; 89 + } 90 + 91 + return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>; 92 + } 93 + 94 + export function useTheme() { 95 + const context = useContext(ThemeContext); 96 + if (context === undefined) { 97 + throw new Error('useTheme must be used within a ThemeProvider'); 98 + } 99 + return context; 100 + }