Spaces:
Sleeping
Sleeping
| {% extends "base.html" %} | |
| {% block title %}My Profile - PrepMate{% endblock %} | |
| {% block content %} | |
| <div class="container mt-4"> | |
| <!-- Profile Header --> | |
| <div class="row mb-4"> | |
| <div class="col-12 col-lg-3"> | |
| <div class="d-flex align-items-start"> | |
| {% if user_profile and user_profile.profile_picture_url %} | |
| <img src="{{ user_profile.profile_picture_url }}" | |
| alt="{{ user_profile.display_name }}" | |
| class="rounded-circle me-3" | |
| style="width: 64px; height: 64px; object-fit: cover;"> | |
| {% else %} | |
| <img src="https://archive.fosdem.org/2024/schedule/speaker/8MXH39/57d45efea75fcc9aceab1e6bbb44ea7f2362f8d9570b4a876e6c68b6deee5da3.png" | |
| alt="Default Avatar" | |
| class="rounded-circle me-3" | |
| style="width: 64px; height: 64px; object-fit: cover;"> | |
| {% endif %} | |
| <div class="flex-grow-1"> | |
| <h1 class="h3 mb-0"> | |
| {% if user_profile %} | |
| {{ user_profile.display_name }} | |
| {% else %} | |
| My Profile | |
| {% endif %} | |
| </h1> | |
| <p class="text-muted mb-0"> | |
| {% if user_profile %} | |
| @{{ user_profile.user_id }} | |
| {% endif %} | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="col-12 col-lg-9 mt-3 mt-lg-0"> | |
| <div class="card"> | |
| <div class="card-header"> | |
| <h3 class="h6 mb-0">About Profile Facts</h3> | |
| </div> | |
| <div class="card-body"> | |
| <p class="small mb-1"> | |
| Add facts that PrepMate should remember across all conversations. | |
| </p> | |
| <p class="small mb-1">Examples:</p> | |
| <ul class="small mb-0" style="padding-left: 1.2rem;"> | |
| <li>Software Engineer</li> | |
| <li>2 children</li> | |
| <li>Allergic to peanuts</li> | |
| </ul> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Facts Section with Chat-like Interface --> | |
| <div class="row"> | |
| <div class="col-12"> | |
| <div class="card" style="height: 600px; display: flex; flex-direction: column;"> | |
| <div class="card-header"> | |
| <h2 class="h5 mb-0"> | |
| <i class="bi bi-person-badge"></i> Profile Facts | |
| </h2> | |
| <small class="text-muted">Add information about yourself that PrepMate should remember</small> | |
| </div> | |
| <!-- Facts List (scrollable) --> | |
| <div class="card-body flex-grow-1" style="overflow-y: auto;" id="factsContainer"> | |
| {% if facts %} | |
| {% for fact in facts %} | |
| <div class="mb-2 fact-message"> | |
| <div class="d-flex align-items-start"> | |
| <div class="flex-grow-1"> | |
| <div class="fact-card p-2"> | |
| <div class="d-flex justify-content-between align-items-center"> | |
| <span class="flex-grow-1">{{ fact.content }}</span> | |
| <div class="d-flex align-items-center ms-3"> | |
| <small class="text-muted me-2" style="font-size: 0.75rem;">{{ fact.created_at }}</small> | |
| <button type="button" | |
| class="btn btn-sm btn-link text-danger p-0" | |
| onclick="deleteFact('{{ fact.message_id }}')" | |
| title="Delete fact"> | |
| <i class="bi bi-trash" style="font-size: 0.875rem;"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {% endfor %} | |
| {% else %} | |
| <div class="empty-state h-100 d-flex flex-column justify-content-center align-items-center"> | |
| <i class="bi bi-lightbulb" style="font-size: 3rem; color: #6c757d;"></i> | |
| <p class="mt-3 text-muted">No facts yet. Start adding information about yourself below!</p> | |
| <p class="small text-muted mb-0">Examples: "I work as a software engineer" or "I have 2 children"</p> | |
| </div> | |
| {% endif %} | |
| </div> | |
| <!-- Add Fact Input (chat-like) --> | |
| <div class="card-footer bg-light"> | |
| <!-- Error Messages (T009: Feature 001-refine-memory-producer-logic) --> | |
| {% with messages = get_flashed_messages(with_categories=true) %} | |
| {% if messages %} | |
| {% for category, message in messages %} | |
| <div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show mb-2" role="alert"> | |
| {{ message }} | |
| <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> | |
| </div> | |
| {% endfor %} | |
| {% endif %} | |
| {% endwith %} | |
| <form method="POST" action="{{ url_for('profile.add_fact') }}" id="addFactForm" onsubmit="handleSubmit(event); return false;"> | |
| <div class="input-group"> | |
| <textarea class="form-control" | |
| id="factContent" | |
| name="content" | |
| rows="1" | |
| maxlength="500" | |
| placeholder="Add a fact about yourself..." | |
| required | |
| style="resize: none;">{{ preserved_content or '' }}</textarea> | |
| <button type="submit" class="btn btn-primary" id="submitBtn"> | |
| <i class="bi bi-send-fill" id="submitIcon"></i> | |
| <span class="spinner-border spinner-border-sm d-none" id="submitSpinner" role="status" aria-hidden="true"></span> | |
| </button> | |
| </div> | |
| <div class="d-flex justify-content-between align-items-center mt-2"> | |
| <small class="text-muted"> | |
| <i class="bi bi-info-circle"></i> | |
| Press Enter to add (Shift+Enter for new line) | |
| </small> | |
| <small class="char-counter" id="charCounter">{{ (preserved_content|length) if preserved_content else 0 }} / 500</small> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- T019: Include message-cache.js for optimistic UI updates --> | |
| <script src="{{ url_for('static', filename='js/message-cache.js') }}"></script> | |
| <script> | |
| // T020: Initialize cache on page load with session_id | |
| {% if user_profile and user_profile.session_id %} | |
| const SESSION_ID = '{{ user_profile.session_id }}'; | |
| messageCache.init(SESSION_ID); | |
| {% endif %} | |
| // Auto-resize textarea as user types | |
| const textarea = document.getElementById('factContent'); | |
| const charCounter = document.getElementById('charCounter'); | |
| const factsContainer = document.getElementById('factsContainer'); | |
| const submitBtn = document.getElementById('submitBtn'); | |
| const submitIcon = document.getElementById('submitIcon'); | |
| const submitSpinner = document.getElementById('submitSpinner'); | |
| if (textarea) { | |
| // Auto-resize textarea | |
| textarea.addEventListener('input', function() { | |
| this.style.height = 'auto'; | |
| this.style.height = Math.min(this.scrollHeight, 120) + 'px'; | |
| // Update character counter | |
| charCounter.textContent = this.value.length + ' / 500'; | |
| // Change color when approaching limit | |
| if (this.value.length > 450) { | |
| charCounter.classList.add('text-danger'); | |
| } else { | |
| charCounter.classList.remove('text-danger'); | |
| } | |
| }); | |
| // Handle Enter key (submit) vs Shift+Enter (new line) | |
| textarea.addEventListener('keydown', function(e) { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| if (this.value.trim().length > 0) { | |
| document.getElementById('addFactForm').submit(); | |
| } | |
| } | |
| }); | |
| // Initialize height for preserved content (T009) | |
| if (textarea.value.length > 0) { | |
| textarea.style.height = 'auto'; | |
| textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; | |
| } | |
| } | |
| // Auto-scroll to bottom on page load if there are facts | |
| if (factsContainer) { | |
| const hasFacts = factsContainer.querySelector('.fact-message'); | |
| if (hasFacts) { | |
| factsContainer.scrollTop = factsContainer.scrollHeight; | |
| } | |
| } | |
| // T021: Handle form submission with optimistic UI update | |
| async function handleSubmit(event) { | |
| event.preventDefault(); | |
| if (submitBtn.disabled) { | |
| return false; | |
| } | |
| const content = textarea.value.trim(); | |
| if (!content) { | |
| return false; | |
| } | |
| // Disable button and show spinner | |
| submitBtn.disabled = true; | |
| submitIcon.classList.add('d-none'); | |
| submitSpinner.classList.remove('d-none'); | |
| try { | |
| // Add optimistic message to cache (displays immediately) | |
| await messageCache.addOptimisticMessage(content, 'user', 'fact'); | |
| // Clear textarea | |
| textarea.value = ''; | |
| textarea.style.height = 'auto'; | |
| charCounter.textContent = '0 / 500'; | |
| charCounter.classList.remove('text-danger'); | |
| // Re-enable button | |
| submitBtn.disabled = false; | |
| submitIcon.classList.remove('d-none'); | |
| submitSpinner.classList.add('d-none'); | |
| } catch (error) { | |
| console.error('Error adding fact:', error); | |
| submitBtn.disabled = false; | |
| submitIcon.classList.remove('d-none'); | |
| submitSpinner.classList.add('d-none'); | |
| alert('Failed to add fact. Please try again.'); | |
| } | |
| return false; | |
| } | |
| // Delete fact function | |
| function deleteFact(messageId) { | |
| if (confirm('Are you sure you want to delete this fact?')) { | |
| const form = document.createElement('form'); | |
| form.method = 'POST'; | |
| form.action = '/profile/facts/delete/' + messageId; | |
| document.body.appendChild(form); | |
| form.submit(); | |
| } | |
| } | |
| // T022: Render messages with sync status indicators | |
| function renderMessages() { | |
| const messages = messageCache.getAllMessages(); | |
| const container = factsContainer; | |
| // Clear existing facts (except empty state) | |
| const emptyState = container.querySelector('.empty-state'); | |
| container.innerHTML = ''; | |
| if (messages.length === 0) { | |
| if (emptyState) { | |
| container.appendChild(emptyState); | |
| } else { | |
| container.innerHTML = ` | |
| <div class="empty-state h-100 d-flex flex-column justify-content-center align-items-center"> | |
| <i class="bi bi-lightbulb" style="font-size: 3rem; color: #6c757d;"></i> | |
| <p class="mt-3 text-muted">No facts yet. Start adding information about yourself below!</p> | |
| <p class="small text-muted mb-0">Examples: "I work as a software engineer" or "I have 2 children"</p> | |
| </div> | |
| `; | |
| } | |
| return; | |
| } | |
| // Render each message | |
| messages.forEach(message => { | |
| const messageDiv = document.createElement('div'); | |
| messageDiv.className = 'mb-2 fact-message'; | |
| messageDiv.dataset.messageId = message.id || message.tempId; | |
| // Add pending/failed classes | |
| if (message.syncStatus === 'pending' || message.syncStatus === 'syncing') { | |
| messageDiv.classList.add('message-pending'); | |
| } else if (message.syncStatus === 'failed') { | |
| messageDiv.classList.add('message-failed'); | |
| } | |
| const timestamp = message.timestamp | |
| ? new Date(message.timestamp).toLocaleString() | |
| : new Date(message.clientTimestamp).toLocaleString(); | |
| // Render sync indicator | |
| let syncIndicatorHTML = ''; | |
| if (message.syncStatus === 'pending' || message.syncStatus === 'syncing') { | |
| syncIndicatorHTML = '<span class="sync-indicator pending" title="Syncing..." aria-label="Message syncing with server">⏳</span>'; | |
| } else if (message.syncStatus === 'failed') { | |
| syncIndicatorHTML = ` | |
| <button class="sync-indicator failed retry-btn" | |
| onclick="retryMessage('${message.tempId}')" | |
| title="Sync failed. Click to retry." | |
| aria-label="Sync failed. Click to retry sending message"> | |
| ⚠️ Retry | |
| </button> | |
| `; | |
| } else if (message.syncStatus === 'confirmed') { | |
| syncIndicatorHTML = '<span class="sync-indicator confirmed" title="Synced" aria-label="Message successfully synced">✓</span>'; | |
| } | |
| messageDiv.innerHTML = ` | |
| <div class="d-flex align-items-start"> | |
| <div class="flex-grow-1"> | |
| <div class="fact-card p-2"> | |
| <div class="d-flex justify-content-between align-items-center"> | |
| <span class="flex-grow-1">${escapeHtml(message.content)}</span> | |
| <div class="d-flex align-items-center ms-3"> | |
| ${syncIndicatorHTML} | |
| <small class="text-muted ms-2 me-2" style="font-size: 0.75rem;">${timestamp}</small> | |
| ${message.id ? ` | |
| <button type="button" | |
| class="btn btn-sm btn-link text-danger p-0" | |
| onclick="deleteFact('${message.id}')" | |
| title="Delete fact"> | |
| <i class="bi bi-trash" style="font-size: 0.875rem;"></i> | |
| </button> | |
| ` : ''} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| container.appendChild(messageDiv); | |
| }); | |
| // Auto-scroll to bottom | |
| container.scrollTop = container.scrollHeight; | |
| } | |
| // Helper: Escape HTML to prevent XSS | |
| function escapeHtml(text) { | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| // Retry failed message | |
| async function retryMessage(tempId) { | |
| try { | |
| await messageCache.retryFailedMessage(tempId); | |
| } catch (error) { | |
| console.error('Error retrying message:', error); | |
| alert('Failed to retry. Please try again.'); | |
| } | |
| } | |
| // T023: Listen to cache:message-added event and re-render | |
| document.addEventListener('cache:message-added', (event) => { | |
| console.log('Message added:', event.detail); | |
| renderMessages(); | |
| }); | |
| // T024: Listen to cache:message-confirmed event and update indicator | |
| document.addEventListener('cache:message-confirmed', (event) => { | |
| console.log('Message confirmed:', event.detail); | |
| renderMessages(); | |
| }); | |
| // Listen to cache:message-failed event | |
| document.addEventListener('cache:message-failed', (event) => { | |
| console.log('Message failed:', event.detail); | |
| renderMessages(); | |
| }); | |
| // Initial render from cache on page load | |
| {% if user_profile and user_profile.session_id %} | |
| // Wait for DOM to be ready | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', initializeFacts); | |
| } else { | |
| initializeFacts(); | |
| } | |
| // Load server-rendered facts into cache on first load | |
| function initializeFacts() { | |
| const cache = messageCache.getCache(); | |
| // Only load server facts if cache is empty (first visit) | |
| if (cache.confirmedMessages.length === 0) { | |
| {% if facts %} | |
| // Load existing facts from server into cache | |
| const serverFacts = [ | |
| {% for fact in facts %} | |
| { | |
| id: '{{ fact.message_id }}', | |
| content: {{ fact.content|tojson }}, | |
| role: 'user', | |
| type: 'fact', | |
| timestamp: '{{ fact.created_at }}', | |
| created_at: '{{ fact.created_at }}', | |
| syncStatus: 'confirmed' | |
| }{% if not loop.last %},{% endif %} | |
| {% endfor %} | |
| ]; | |
| // Add to cache | |
| cache.confirmedMessages = serverFacts; | |
| cache.lastFetchTimestamp = new Date().toISOString(); | |
| messageCache.saveCacheToBrowser(); | |
| {% endif %} | |
| } | |
| // Now render from cache | |
| renderMessages(); | |
| } | |
| {% endif %} | |
| </script> | |
| {% endblock %} | |