Christian Kniep
add settings and UI/UX improvements
2e18bf2
{% 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 %}