This repository has no description
0

Configure Feed

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

at main 28 kB View raw
1'use client'; 2 3import { useState, useEffect } from 'react'; 4import Link from 'next/link'; 5import { useRouter } from 'next/navigation'; 6import styles from './page.module.css'; 7import { useAuth } from '@/lib/auth-context'; 8import { containsBannedWords, sanitizeText, isAllowedEmoji } from '@/lib/content-filter'; 9import { formatRelativeTime } from '@/lib/time-utils'; 10import EditFlushModal from '@/components/EditFlushModal'; 11 12// Types for feed entries 13interface FlushingEntry { 14 id: string; 15 uri: string; 16 cid: string; 17 authorDid: string; 18 authorHandle: string; 19 text: string; 20 emoji: string; 21 createdAt: string; 22} 23 24export default function Home() { 25 const router = useRouter(); 26 const { isAuthenticated, session, signOut } = useAuth(); 27 const did = session?.sub; 28 const handle = null; // Will be fetched when needed 29 30 // Status update state 31 const [text, setText] = useState('is '); 32 const [selectedEmoji, setSelectedEmoji] = useState('🚽'); 33 const [statusOpen, setStatusOpen] = useState(false); 34 const [isSubmitting, setIsSubmitting] = useState(false); 35 const [statusError, setStatusError] = useState<string | null>(null); 36 const [success, setSuccess] = useState<string | null>(null); 37 38 // Feed state 39 const [entries, setEntries] = useState<FlushingEntry[]>([]); 40 const [loading, setLoading] = useState(true); 41 const [error, setError] = useState<string | null>(null); 42 const [newEntryIds, setNewEntryIds] = useState<Set<string>>(new Set()); 43 const [editingFlush, setEditingFlush] = useState<FlushingEntry | null>(null); 44 const [actionError, setActionError] = useState<string | null>(null); 45 const [actionSuccess, setActionSuccess] = useState<string | null>(null); 46 47 useEffect(() => { 48 // Fetch the latest entries when the component mounts 49 fetchLatestEntries(true); // Force refresh on initial load 50 51 // Removed auto-refresh to avoid excessive API calls 52 }, []); 53 54 // Toggle status update form 55 const toggleStatusUpdate = () => { 56 setStatusOpen(!statusOpen); 57 setStatusError(null); 58 setSuccess(null); 59 }; 60 61 // Handle emoji selection 62 const handleEmojiSelect = (emoji: string) => { 63 setSelectedEmoji(emoji); 64 }; 65 66 // Check rate limit - 2 posts per 30 minutes, except for the plumber account 67 const checkRateLimit = (): boolean => { 68 // Define the plumber's DID 69 const PLUMBER_DID = 'did:plc:fouf3svmcxzn6bpiw3lgwz22'; 70 71 // Exempt the plumber account from rate limiting 72 if (did === PLUMBER_DID) { 73 console.log('Plumber account detected - bypassing rate limits'); 74 return true; // Always return true (under limit) for the plumber account 75 } 76 77 const now = Date.now(); 78 const thirtyMinutesAgo = now - 30 * 60 * 1000; // 30 minutes in milliseconds 79 80 // Filter entries to get only the user's entries from the last 30 minutes 81 const userRecentEntries = entries.filter(entry => 82 entry.authorDid === did && 83 new Date(entry.createdAt).getTime() > thirtyMinutesAgo 84 ); 85 86 // Return true if under limit, false if over limit 87 return userRecentEntries.length < 2; 88 }; 89 90 // Submit flushing status 91 const handleSubmit = async (e: React.FormEvent) => { 92 e.preventDefault(); 93 94 if (!session || !isAuthenticated) { 95 setStatusError('Please sign in to post a flush'); 96 return; 97 } 98 99 // Check for banned words 100 if (text && containsBannedWords(text)) { 101 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'); 102 return; 103 } 104 105 // Check character limit - 59 characters maximum 106 if (text.length > 59) { 107 setStatusError("Your flush status is too long! Please keep it under 59 characters."); 108 return; 109 } 110 111 // Check rate limit - 2 posts per 30 minutes (except for the plumber account) 112 if (!checkRateLimit()) { 113 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."); 114 return; 115 } 116 117 setIsSubmitting(true); 118 setStatusError(null); 119 setSuccess(null); 120 121 try { 122 // Use the new simplified API client 123 const { createPost } = await import('@/lib/api-client'); 124 125 // Format status text to ensure it begins with "is" 126 let formattedText = text.trim(); 127 128 // If text is empty or just "is", use default "is flushing" 129 if (!formattedText || formattedText === "is") { 130 formattedText = "is flushing"; 131 } 132 // If text doesn't start with "is", add it 133 else if (!formattedText.toLowerCase().startsWith("is ")) { 134 formattedText = `is ${formattedText}`; 135 } 136 137 // Create the status update with the simplified API 138 // Just send the formatted text with emoji - no need to add handle/name since OAuth session handles user identity 139 const result = await createPost(session, { 140 text: `${formattedText} ${selectedEmoji}`, 141 langs: ['en'] 142 }); 143 144 console.log('Status update result:', result); 145 146 // Reset form and show success message 147 setText('is '); 148 setSuccess('Your flushing status has been updated!'); 149 150 // Close status form after successful submission 151 setTimeout(() => { 152 setStatusOpen(false); 153 }, 2000); 154 155 // Refresh the feed after a delay to get the newly created entry 156 setTimeout(() => { 157 console.log('Refreshing feed to show new entry...'); 158 fetchLatestEntries(true); 159 }, 2500); 160 } catch (err: any) { 161 console.error('Failed to update status:', err); 162 setStatusError(`Failed to update status: ${err.message || 'Unknown error'}`); 163 } finally { 164 setIsSubmitting(false); 165 } 166 }; 167 168 // Function to fetch the latest entries 169 const fetchLatestEntries = async (forceRefresh = false) => { 170 try { 171 setLoading(true); 172 setError(null); 173 174 // Add a timestamp to ensure we bypass browser caching 175 const timestamp = Date.now(); 176 177 // Use our simple API endpoint for reliability 178 // Add refresh=true when forcing a refresh to ensure we get fresh data 179 const url = forceRefresh 180 ? `/api/bluesky/feed-simple?refresh=true&_t=${timestamp}` 181 : `/api/bluesky/feed-simple?_t=${timestamp}`; 182 183 console.log(`Fetching feed from ${url} at ${new Date().toISOString()}`); 184 185 const response = await fetch(url, { 186 method: 'GET', 187 cache: 'no-store', 188 headers: { 189 'Cache-Control': 'no-cache, no-store, must-revalidate', 190 'Pragma': 'no-cache', 191 'Expires': '0' 192 } 193 }); 194 195 if (!response.ok) { 196 throw new Error(`Failed to fetch feed: ${response.status}`); 197 } 198 199 const data = await response.json(); 200 console.log(`Received ${data.entries?.length || 0} entries from API`); 201 202 // Debug: Log the most recent entries we received 203 if (data.entries && data.entries.length > 0) { 204 console.log('Latest entries from API:'); 205 for (let i = 0; i < Math.min(3, data.entries.length); i++) { 206 const entry = data.entries[i]; 207 console.log(` ${i+1}. ID: ${entry.id}, Handle: @${entry.authorHandle}, Text: "${entry.text.substring(0, 20)}..."`); 208 } 209 } 210 211 // Check for new entries 212 if (entries.length > 0) { 213 const currentIds = new Set(entries.map((entry: FlushingEntry) => entry.id)); 214 const newEntries = data.entries.filter((entry: FlushingEntry) => !currentIds.has(entry.id)); 215 216 // Log new entries 217 if (newEntries.length > 0) { 218 console.log(`Found ${newEntries.length} new entries`); 219 220 // Mark new entries for animation 221 setNewEntryIds(new Set(newEntries.map((entry: FlushingEntry) => entry.id))); 222 223 // Clear the animation markers after animation completes 224 setTimeout(() => { 225 setNewEntryIds(new Set()); 226 }, 2000); 227 } else { 228 console.log('No new entries found in this update'); 229 } 230 } 231 232 setEntries(data.entries); 233 } catch (err: any) { 234 console.error('Error fetching feed:', err); 235 setError(err.message || 'Failed to load feed'); 236 } finally { 237 setLoading(false); 238 } 239 }; 240 241 // Function to load older entries 242 const loadOlderEntries = async () => { 243 try { 244 // Save current scroll position 245 const scrollPosition = window.scrollY; 246 247 setLoading(true); 248 setError(null); 249 250 // Get the oldest entry we currently have 251 const oldestEntry = entries[entries.length - 1]; 252 if (!oldestEntry) { 253 return; // No entries to use as cursor 254 } 255 256 console.log(`Loading older entries before ID ${oldestEntry.id}`); 257 258 // Use the oldest entry's ID as the cursor, plus add a unique timestamp 259 // Use our simple API for reliable pagination 260 const url = `/api/bluesky/feed-simple?before=${oldestEntry.id}&_t=${Date.now()}`; 261 262 const response = await fetch(url, { 263 cache: 'no-store', 264 headers: { 265 'Cache-Control': 'no-cache, no-store, must-revalidate', 266 'Pragma': 'no-cache', 267 'Expires': '0' 268 } 269 }); 270 271 if (!response.ok) { 272 throw new Error(`Failed to fetch older entries: ${response.status}`); 273 } 274 275 const data = await response.json(); 276 277 if (data.entries && data.entries.length > 0) { 278 console.log(`Loaded ${data.entries.length} older entries`); 279 280 // Debug: log the first few older entries 281 for (let i = 0; i < Math.min(3, data.entries.length); i++) { 282 const entry = data.entries[i]; 283 console.log(` Older ${i+1}. ID: ${entry.id}, Handle: @${entry.authorHandle}, Text: "${entry.text.substring(0, 20)}..."`); 284 } 285 286 // Append the new entries to our existing list 287 setEntries([...entries, ...data.entries]); 288 289 // Wait for DOM to update with new entries 290 setTimeout(() => { 291 // Restore scroll position after state update and render 292 window.scrollTo({ 293 top: scrollPosition, 294 behavior: 'instant' // Use instant to avoid additional animation 295 }); 296 }, 0); 297 } else { 298 console.log('No older entries found'); 299 } 300 } catch (err: any) { 301 console.error('Error fetching older entries:', err); 302 setError(err.message || 'Failed to load older entries'); 303 } finally { 304 setLoading(false); 305 } 306 }; 307 308 // Function to handle logout 309 const handleLogout = async () => { 310 await signOut(); 311 }; 312 313 // Check if the current user owns this flush 314 const isOwnFlush = (authorDid: string) => { 315 if (!session) return false; 316 return session.sub === authorDid; 317 }; 318 319 // Handle updating a flush 320 const handleUpdateFlush = async (text: string, emoji: string) => { 321 if (!session || !editingFlush) { 322 setActionError('You must be logged in to update a flush'); 323 return; 324 } 325 326 try { 327 setActionError(null); 328 setActionSuccess(null); 329 330 const { updateFlushRecord } = await import('@/lib/api-client'); 331 332 await updateFlushRecord( 333 session, 334 editingFlush.uri, 335 text, 336 emoji, 337 editingFlush.createdAt 338 ); 339 340 setActionSuccess('Flush updated successfully!'); 341 342 // Update the local state 343 setEntries(entries.map(entry => 344 entry.uri === editingFlush.uri 345 ? { ...entry, text, emoji } 346 : entry 347 )); 348 349 // Clear success message after 3 seconds 350 setTimeout(() => setActionSuccess(null), 3000); 351 } catch (error: any) { 352 console.error('Error updating flush:', error); 353 setActionError(error.message || 'Failed to update flush'); 354 } 355 }; 356 357 // Handle deleting a flush 358 const handleDeleteFlush = async () => { 359 if (!session || !editingFlush) { 360 setActionError('You must be logged in to delete a flush'); 361 return; 362 } 363 364 try { 365 setActionError(null); 366 setActionSuccess(null); 367 368 const { deleteFlushRecord } = await import('@/lib/api-client'); 369 370 await deleteFlushRecord(session, editingFlush.uri); 371 372 setActionSuccess('Flush deleted successfully!'); 373 374 // Remove from local state 375 setEntries(entries.filter(entry => entry.uri !== editingFlush.uri)); 376 377 // Clear success message after 3 seconds 378 setTimeout(() => setActionSuccess(null), 3000); 379 } catch (error: any) { 380 console.error('Error deleting flush:', error); 381 setActionError(error.message || 'Failed to delete flush'); 382 } 383 }; 384 385 // List of emojis for status selection 386 const EMOJIS = [ 387 '🚽', '🧻', '💩', '💨', '🚾', '🧼', '🪠', '🚻', '🩸', '💧', '💦', '😌', 388 '😣', '🤢', '🤮', '🥴', '😮‍💨', '😳', '😵', '🌾', '🍦', '📱', '📖', '💭', 389 '1️⃣', '2️⃣', '🟡', '🟤' 390 ]; 391 392 return ( 393 <div className={styles.container}> 394 395 {/* Action messages */} 396 {actionError && ( 397 <div className={styles.error}> 398 {actionError} 399 </div> 400 )} 401 402 {actionSuccess && ( 403 <div className={styles.success}> 404 {actionSuccess} 405 </div> 406 )} 407 408 {/* Edit Modal */} 409 <EditFlushModal 410 isOpen={editingFlush !== null} 411 flushData={editingFlush ? { 412 uri: editingFlush.uri, 413 text: editingFlush.text, 414 emoji: editingFlush.emoji, 415 created_at: editingFlush.createdAt 416 } : null} 417 onSave={handleUpdateFlush} 418 onDelete={handleDeleteFlush} 419 onClose={() => setEditingFlush(null)} 420 /> 421 422 <header className={styles.header}> 423 <div className={styles.headerContent}> 424 {/* New header layout with 4 center-aligned lines */} 425 <h1 className={styles.tagline}> 426 The Decentralized Toilet Network of Planet Earth & Simulation 12B 427 </h1> 428 <p className={styles.description}> 429 Share a "flush" whenever you're in the bathroom. 430 </p> 431 <p className={styles.donateText}> 432 Like the app? Donate to <a href="https://ko-fi.com/dameis" target="_blank" rel="noopener noreferrer" className={styles.kofiLink}>our toilet paper fund</a>. 433 </p> 434 <p className={styles.creditLine}> 435 Made by <a href="https://bsky.app/profile/dame.is" target="_blank" rel="noopener noreferrer">@dame.is</a> and <a href="https://bsky.app/profile/atpota.to" target="_blank" rel="noopener noreferrer">@atpota.to</a> 436 </p> 437 </div> 438 </header> 439 440 {/* Status update section - only visible when logged in */} 441 {isAuthenticated && ( 442 <> 443 {/* Status update toggle button */} 444 <button 445 className={`${styles.toggleButton} ${statusOpen ? styles.toggleButtonActive : ''}`} 446 onClick={toggleStatusUpdate} 447 > 448 {statusOpen ? 'Close' : 'Update your status'} 449 <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 450 <path d="M19 9L12 16L5 9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> 451 </svg> 452 </button> 453 454 {/* Collapsible status update form */} 455 <div className={`${styles.statusUpdateContainer} ${statusOpen ? styles.statusUpdateOpen : ''}`}> 456 <div className={styles.card}> 457 {statusError && <div className={styles.error}>{statusError}</div>} 458 {success && <div className={styles.success}>{success}</div>} 459 460 <form onSubmit={handleSubmit} className={styles.form}> 461 <div className={styles.formGroup}> 462 <label>Select an emoji for your status</label> 463 <div className={styles.emojiGrid}> 464 {EMOJIS.map((emoji) => ( 465 <button 466 key={emoji} 467 type="button" 468 className={`${styles.emojiButton} ${ 469 emoji === selectedEmoji ? styles.selectedEmoji : '' 470 }`} 471 onClick={() => handleEmojiSelect(emoji)} 472 disabled={isSubmitting} 473 aria-label={`Select emoji ${emoji}`} 474 > 475 {emoji} 476 </button> 477 ))} 478 </div> 479 </div> 480 481 <div className={styles.formGroup}> 482 <label htmlFor="status">What&apos;s your status? (optional)</label> 483 <div className={styles.inputWrapper}> 484 <span className={styles.inputPrefix}>is </span> 485 <input 486 type="text" 487 id="status" 488 value={text.startsWith("is ") ? text.substring(3) : text} 489 onChange={(e) => setText(`is ${e.target.value}`)} 490 placeholder="flushing" 491 maxLength={56} /* 60 - 3 for "is " */ 492 className={styles.inputWithPrefix} 493 disabled={isSubmitting} 494 /> 495 </div> 496 <div className={styles.charCount}> 497 {text.length}/59 498 </div> 499 </div> 500 501 <button 502 type="submit" 503 className={styles.submitButton} 504 disabled={isSubmitting || text.length > 59} 505 > 506 {isSubmitting ? 'Flushing...' : 'Post Flush'} 507 </button> 508 </form> 509 </div> 510 </div> 511 </> 512 )} 513 514 {/* Feed Section */} 515 <div className={styles.feedSection}> 516 517 <div className={styles.feedHeader}> 518 <div className={styles.feedHeaderLeft}> 519 <h2>Recent flushes</h2> 520 <p className={styles.feedSubheader}> 521 Click on a username to see their flushes profile. 522 </p> 523 </div> 524 <button 525 onClick={async () => { 526 try { 527 setLoading(true); 528 setError(null); 529 530 // Use the simple API endpoint with a refresh parameter and timestamp 531 const timestamp = Date.now(); 532 const url = `/api/bluesky/feed-simple?refresh=true&_t=${timestamp}`; 533 console.log(`🔄 MANUAL REFRESH @ ${new Date().toISOString()}`); 534 console.log(`Using simple API URL: ${url}`); 535 536 // Use strong no-cache headers to ensure browsers don't use cached responses 537 const response = await fetch(url, { 538 method: 'GET', 539 cache: 'no-store', 540 headers: { 541 'Cache-Control': 'no-cache, no-store, must-revalidate', 542 'Pragma': 'no-cache', 543 'Expires': '0', 544 'X-Force-Fresh-Data': 'true' // Custom header to signal intent 545 } 546 }); 547 548 if (!response.ok) { 549 console.error(`API error: ${response.status}, ${response.statusText}`); 550 throw new Error(`API error: ${response.status}`); 551 } 552 553 // Attempt to extract response headers for debugging 554 console.log('Response headers:', Object.fromEntries(response.headers.entries())); 555 556 const data = await response.json(); 557 console.log(`Refresh received ${data.entries?.length || 0} entries`); 558 559 if (data.entries && data.entries.length > 0) { 560 console.log(`🔍 Highest ID from refresh: ${data.entries[0].id}`); 561 for (let i = 0; i < Math.min(5, data.entries.length); i++) { 562 console.log(` ${i+1}. ID: ${data.entries[i].id}, Handle: @${data.entries[i].authorHandle}, Text: "${data.entries[i].text.substring(0, 20)}..."`); 563 } 564 565 // Compare with current entries 566 if (entries.length > 0) { 567 const currentHighestId = entries[0].id; 568 const newHighestId = data.entries[0].id; 569 console.log(`📊 Comparison - Current highest ID: ${currentHighestId}, New highest ID: ${newHighestId}`); 570 571 if (newHighestId > currentHighestId) { 572 console.log('✅ Refresh successful! New entries are more recent.'); 573 } else if (newHighestId === currentHighestId) { 574 console.log('⚠️ Refresh returned same highest ID - no newer entries available.'); 575 } else { 576 console.warn('❌ WARNING: New entries have lower IDs than existing ones!'); 577 } 578 } 579 } else { 580 console.log('No entries returned from refresh'); 581 } 582 583 // Update the entries with the new data 584 setEntries(data.entries || []); 585 } catch (err) { 586 console.error('Manual refresh error:', err); 587 setError('Failed to refresh. Try again.'); 588 } finally { 589 setLoading(false); 590 } 591 }} 592 className={styles.refreshButton} 593 disabled={loading} 594 > 595 {loading ? 'Loading...' : 'Refresh'} 596 </button> 597 </div> 598 599 {error && <div className={styles.error}>{error}</div>} 600 601 {/* Debug info (hidden in production) */} 602 {entries && entries.length > 0 && ( 603 <div className={styles.debugInfo} style={{ fontSize: '10px', color: '#666', margin: '5px 0', display: 'none' }}> 604 <p>Debug: Latest entry ID: {entries[0].id}, Count: {entries.length}</p> 605 </div> 606 )} 607 608 {loading ? ( 609 <div className={styles.loadingContainer}> 610 <div className={styles.loader}></div> 611 <p>Loading latest entries...</p> 612 </div> 613 ) : ( 614 <div className={styles.feedList}> 615 {entries.length > 0 ? ( 616 // Filter first to determine if we have any valid entries 617 (() => { 618 const validEntries = entries.filter(entry => isAllowedEmoji(entry.emoji)); 619 return validEntries.length > 0 ? ( 620 <> 621 {validEntries.map((entry) => ( 622 <div 623 key={entry.id} 624 className={`${styles.feedItem} ${newEntryIds.has(entry.id) ? styles.newFeedItem : ''}`} 625 > 626 <div className={styles.content}> 627 <div className={styles.contentLeft}> 628 <span className={styles.emoji}>{entry.emoji}</span> 629 <Link 630 href={`/profile/${entry.authorHandle}`} 631 className={styles.authorLink} 632 > 633 @{entry.authorHandle} 634 </Link> 635 <span className={styles.text}> 636 {entry.text ? ( 637 // Check if handle ends with .is 638 entry.authorHandle && entry.authorHandle.endsWith('.is') ? 639 // For handles ending with .is, remove the "is" prefix if it exists 640 (sanitizeText(entry.text).toLowerCase().startsWith('is ') ? 641 (entry.text.length > 63 ? `${sanitizeText(entry.text.substring(3, 63))}...` : sanitizeText(entry.text.substring(3))) : 642 (entry.text.length > 60 ? `${sanitizeText(entry.text.substring(0, 60))}...` : sanitizeText(entry.text)) 643 ) : 644 // For regular handles, display normal text 645 (entry.text.length > 60 ? `${sanitizeText(entry.text.substring(0, 60))}...` : sanitizeText(entry.text)) 646 ) : ( 647 // If no text, show default message 648 entry.authorHandle && entry.authorHandle.endsWith('.is') ? 649 'flushing' : 'is flushing' 650 )} 651 </span> 652 </div> 653 <div className={styles.contentRight}> 654 <span className={styles.timestamp}> 655 {formatRelativeTime(entry.createdAt)} 656 </span> 657 {isOwnFlush(entry.authorDid) && isAuthenticated && ( 658 <button 659 className={styles.editButton} 660 onClick={() => setEditingFlush(entry)} 661 aria-label="Edit flush" 662 title="Edit or delete this flush" 663 > 664 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 665 <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path> 666 <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path> 667 </svg> 668 </button> 669 )} 670 </div> 671 </div> 672 </div> 673 ))} 674 675 <button 676 className={styles.loadMoreButton} 677 onClick={(e) => { 678 e.preventDefault(); // Prevent default action 679 loadOlderEntries(); 680 }} 681 disabled={loading} 682 > 683 {loading ? 'Loading...' : 'Load older flushes'} 684 {!loading && ( 685 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 686 <polyline points="7 13 12 18 17 13"></polyline> 687 <polyline points="7 6 12 11 17 6"></polyline> 688 </svg> 689 )} 690 </button> 691 </> 692 ) : ( 693 <div className={styles.emptyState}> 694 <p>No valid entries found. Login and be the first to share your status!</p> 695 </div> 696 ); 697 })() 698 ) : ( 699 <div className={styles.emptyState}> 700 <p>No entries found. Login and be the first to share your status!</p> 701 </div> 702 )} 703 </div> 704 )} 705 </div> 706 </div> 707 ); 708}