opencompletion.com/templates/base.html
Russell d46818b5dd
Redesign user interface across all pages (#42)
* Redesign UI: unified design system across all pages

- Replace scattered inline CSS with comprehensive single style.css
- Implement modern design system:
  - System font stack (-apple-system, Segoe UI, etc.)
  - Consistent spacing scale (4px base)
  - Unified color tokens for light/dark themes
  - Reusable component classes (buttons, cards, badges, forms)

- Update all templates to use new CSS classes:
  - browse.html: Less chunky, better space usage with room-grid
  - index.html: Cleaner centered card layout
  - auth.html: Streamlined 4-step flow with gradient background
  - profile.html: Modern settings interface
  - search.html: Browse-style card layout

- Chat page improvements:
  - Tighter layouts (240px/280px sidebars instead of 15%/25%)
  - Better message spacing
  - Improved code blocks with proper padding
  - Cleaner utility belt

- Better responsive design and dark mode support

* modified:   app.py
	modified:   templates/base.html
	modified:   templates/profile.html

* Revert standalone pages to original design

- Browse, index, auth, profile restored to original inline CSS
- Chat page improvements preserved in style.css
- Search page still uses improved card layout

* Add dark mode support to index and browse pages

- index.html now supports dark mode with CSS variables
- browse.html now supports dark mode with CSS variables
- Theme persists from localStorage across pages

* Remove theme toggle from chat page

- Theme toggle button removed from desktop chat sidebar
- Theme toggle button removed from mobile chat modal
- Theme management now done via profile page only

* Make usernames clickable links to profile pages in chat

* Update search page to match browse page layout and CSS

* Fix chat layout positioning

* Fix room list auto-update when title changes

* Fix new room creation appearing in sidebar

- Add socketio.emit in create_room_api() to broadcast new rooms
- Update socket handler to add new rooms to sidebar dynamically
- Rooms now appear without hard refresh

* Fix /title and /cancel commands being sent to LLM

- Add missing return statements after command handlers
- Commands now properly terminate message processing
- Prevents commands from being interpreted as chat messages

* Make usernames in user lists clickable links to profiles

- Update updateUserLists() to create links for all usernames
- Add hover effect CSS for user list links
- Works for both active and inactive users
- Works for both desktop and mobile views

* Revert user list profile links and update profile page layout

- Remove profile links from user lists (no backend route for other users)
- Update profile page to full-screen layout like browse page
- Add header with navigation buttons
- Remove centered container, use full-width layout
- Add box shadows to sections for visual separation

* Convert all flexbox layouts to CSS grid

- Replace all display: flex with CSS grid equivalents
- Update templates: profile, browse, search, index, auth
- Update static CSS for consistent grid usage
- Use grid-template-columns, grid-auto-flow, and place-items
- Improve layout consistency across all pages

* Fix chatroom horizontal scrolling

- Add overflow-x: hidden to #chat-container and #chat to prevent horizontal scroll
- Add word-break and overflow-wrap to message content for text wrapping
- Change pre tags from overflow: hidden to overflow-x: auto for individual scrolling
- Add min-width: 0 to grid containers to prevent overflow
- Code blocks can now scroll individually while chatroom wraps content

* Remove duplicate CSS variables and fix XSS vulnerability

- search.html: Remove inline styles, link to style.css
- index.html: Remove duplicate CSS variable blocks, link to style.css
- browse.html: Remove duplicate CSS variable blocks, link to style.css
- chat.html: Fix XSS vulnerability in room list updates
  - Use textContent/createTextNode instead of innerHTML for user data
  - Use DOM methods instead of string concatenation
  - Encode URL components with encodeURIComponent
  - Extract user count from textContent instead of innerHTML regex

* Merge duplicate CSS rules and replace inline styles with design system

style.css:
- Merge duplicate html, body rules (lines 137-143 and 159-167)
- Consolidate typography and layout properties in single rule
- Remove duplicate BASE LAYOUT section

profile.html:
- Replace style.display mutations with classList API
- Add .availability-indicator.show CSS rule for visibility
- Use classList.add('show') and classList.remove('show')
- Consistent with existing .message.show pattern

browse.html:
- Replace hard-coded gradient colors with CSS variables
- Use var(--gradient-start) and var(--gradient-end) for buttons
- Replace #667eea with var(--button-primary) for tabs and room names
- Remove inline .room-badge styles, use .badge .badge-public/.badge-private
- Apply existing badge classes from style.css for dark mode support

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Russell Ballestrini <russell@unturf.com>
2025-11-12 09:24:49 -05:00

630 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Chatroom{% endblock %}</title>
<!-- Include highlight.js library for syntax highlighting -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/highlight.min.js"></script>
<!-- Include highlight.js themes for light and dark modes -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/styles/github.min.css" id="highlight-theme-light">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/styles/github-dark.min.css" id="highlight-theme-dark" disabled>
<!-- Include socket.io for real-time bidirectional event-based communication -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.min.js"></script>
<!-- Include marked.js for markdown parsing -->
<script
src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.2/marked.min.js"
integrity="sha512-rfX4p3RNnxdwLT3wWP1K0NR3ztTobn+sISlT9WhxDDK00zNYbQ6MCHA5OHm0hqKAzEMXYCgFrp8iY/ER5MkXqA=="
crossorigin="anonymous"
referrerpolicy="no-referrer">
</script>
<!-- Include DOMPurify to sanitize HTML and prevent XSS attacks -->
<script src="https://cdn.jsdelivr.net/npm/dompurify@2/dist/purify.min.js"></script>
<!-- Include utility functions -->
<script src="{{ url_for('static', filename='js/utils.js') }}"></script>
<script>
// Connect to the server using socket.io
const socket = io.connect(window.location.protocol + "//" + document.domain + ":" + location.port);
</script>
<!-- Link to the favicon -->
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
<!-- Link to external stylesheet -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<!-- Hamburger button for mobile -->
<button id="hamburger-button"></button>
<!-- Modal for room list and utility belt -->
<div id="room-list-modal">
<div id="room-list-modal-content">
<button id="close-modal-button" onclick="closeModal()">×</button>
<div id="utility-belt-mobile" class="utility-belt">
<div id="model-chooser-mobile">
<label for="model-select-mobile">Choose Model:</label>
<select id="model-select-mobile">
<option value="None">None</option>
</select>
</div>
<div id="voice-chooser-mobile">
<label for="voice-select-mobile">Choose Voice:</label>
<select id="voice-select-mobile">
<option value="onyx">Onyx</option>
<option value="alloy">Alloy</option>
<option value="echo">Echo</option>
<option value="fable">Fable</option>
<option value="nova">Nova</option>
<option value="shimmer">Shimmer</option>
</select>
</div>
<div id="auto-play-tts-mobile">
<button id="auto-play-tts-btn-mobile" onclick="toggleAutoPlayTTS()" style="width: 100%; margin-top: 10px; background-color: #f44336; color: white; border: none; padding: 8px; border-radius: 4px; cursor: pointer;">
Auto-Play TTS: OFF
</button>
</div>
<div id="activity-controls-mobile">
<h3>Activities</h3>
<div id="current-activity-info-mobile" style="display: none;">
<p>Current Activity: <span id="current-activity-name-mobile"></span></p>
<button id="cancel-activity-btn-mobile" onclick="cancelActivity()">Cancel Activity</button>
</div>
<div id="activity-list-section-mobile">
<div style="display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 5px; margin-bottom: 5px;">
<select id="activity-select-mobile" style="width: 100%; max-width: 100%; box-sizing: border-box;">
<option value="">-- Select an Activity --</option>
</select>
<button id="refresh-activities-btn-mobile" onclick="refreshActivityList()">🔄</button>
</div>
<button id="load-activity-btn-mobile" onclick="loadSelectedActivityMobile()" style="margin-top: 5px;">Load Activity</button>
</div>
</div>
<div id="user-lists-mobile">
<div id="active-users-list">
<h3>Active Users</h3>
<ul id="active-users-mobile">
<!-- Active users will be dynamically populated here -->
</ul>
</div>
<div id="inactive-users-list">
<h3>Inactive Users</h3>
<ul id="inactive-users-mobile">
<!-- Inactive users will be dynamically populated here -->
</ul>
</div>
</div>
</div>
<div id="rooms-list-modal-content">
<!-- Room list will be cloned here for mobile view -->
</div>
</div>
</div>
<!-- Chatroom list -->
<div class="main-container">
<div id="rooms-list">
<!-- Create Room button with grid layout -->
<div id="create-room-section" style="display: grid; gap: 10px; margin-bottom: 15px;">
<a href="/">
<button id="create-room-btn">Create Room</button>
</a>
</div>
<!-- Room tabs with dynamic active state based on current room -->
<div id="room-tabs">
<div class="room-tab {% if not current_room or not current_room.is_private %}active{% endif %}" id="public-rooms-tab" onclick="switchRoomTab('public')">
🌍 Public
</div>
<div class="room-tab {% if current_room and current_room.is_private %}active{% endif %}" id="private-rooms-tab" onclick="switchRoomTab('private')">
🔐 Private
</div>
</div>
<!-- Private rooms section (show if viewing a private room) -->
<div id="private-rooms-section" class="room-section" style="display: {% if current_room and current_room.is_private %}block{% else %}none{% endif %};">
<div id="private-rooms-content">
{% if user %}
{% if private_rooms %}
<ul class="rooms-list">
{% for room in private_rooms %}
<a href="{{ url_for('chat', room_name=room.name) }}">
<li data-room-id="{{ room.id }}" class="private-room">
<b>{{ room.name }}</b>
{% if room.title %}
<br />{{ room.title }}
{% endif %}
{% if room.get_active_users()|length %}
<br /> {{ room.get_active_users()|length }} users
{% endif %}
</li>
</a>
{% endfor %}
</ul>
{% else %}
<p class="empty-state">No private rooms yet. Create one to get started!</p>
{% endif %}
{% else %}
<div class="auth-prompt">
<p>🔒 Private rooms are only visible to you</p>
<p>Sign in to create and access private rooms</p>
<button class="auth-btn" onclick="showAuthModal()">Sign In / Sign Up</button>
</div>
{% endif %}
</div>
</div>
<!-- Public rooms section (show if viewing a public room or no room) -->
<div id="public-rooms-section" class="room-section" style="display: {% if not current_room or not current_room.is_private %}block{% else %}none{% endif %};">
<ul id="rooms-list-ul" class="rooms-list">
<!-- Loop through public rooms and create list items for each room -->
{% for room in public_rooms %}
<a href="{{ url_for('chat', room_name=room.name) }}">
<li data-room-id="{{ room.id }}" class="public-room">
<!-- Display the room title if available, otherwise the room name -->
<b>{{ room.name }}</b>
{% if room.title %}
<br />{{ room.title }}
{% endif %}
{% if room.get_active_users()|length %}
<br /> {{ room.get_active_users()|length }} users
{% endif %}
{% if user and room.owner_id == user.id %}
<span class="owner-badge">👑 Owner</span>
{% endif %}
</li>
</a>
{% endfor %}
</ul>
</div>
</div>
{% block content %}{% endblock %}
</div>
<!-- Authentication Modal -->
<div id="auth-modal" class="modal" style="display: none;">
<div class="modal-content-auth">
<button class="close-btn" onclick="closeAuthModal()">×</button>
<!-- Step 1: Enter email -->
<div id="auth-step-email" class="auth-step">
<h2>Sign In / Sign Up</h2>
<p>Enter your email to receive a verification code</p>
<input type="email" id="auth-email" placeholder="your@email.com" />
<button onclick="sendOTP()">Send Code</button>
<div id="auth-email-error" class="error-message"></div>
</div>
<!-- Step 2: Enter OTP -->
<div id="auth-step-otp" class="auth-step" style="display: none;">
<h2>Enter Verification Code</h2>
<p>We sent a 6-digit code to <span id="auth-email-display"></span></p>
<input type="text" id="auth-otp" placeholder="123456" maxlength="6" />
<button onclick="verifyOTP()">Verify</button>
<button class="secondary-btn" onclick="backToEmailStep()">Back</button>
<div id="auth-otp-error" class="error-message"></div>
</div>
<!-- Step 3: Claim display name (new users only) -->
<div id="auth-step-name" class="auth-step" style="display: none;">
<h2>Choose Display Name</h2>
<p>Pick a unique display name (3-50 characters)</p>
<input type="text" id="auth-display-name" placeholder="username" maxlength="50" />
<button onclick="claimName()">Complete Sign Up</button>
<div id="auth-name-error" class="error-message"></div>
</div>
<!-- Success -->
<div id="auth-step-success" class="auth-step" style="display: none;">
<h2>✅ Success!</h2>
<p>Welcome, <span id="auth-success-name"></span>!</p>
<button onclick="closeAuthModalAndReload()">Continue</button>
</div>
</div>
</div>
<script>
// Function to perform the search
function performSearch() {
const keywords = document.getElementById("search-keywords").value;
if (!keywords) {
alert("Please enter keywords to search.");
return;
}
// Navigate to the search results page with the keywords as a query parameter
window.location.href = `/search?keywords=${encodeURIComponent(keywords)}`;
}
// Add event listener for keyword search the "Enter" key
document.getElementById("search-keywords").addEventListener("keydown", function(event) {
if (event.key === "Enter") {
event.preventDefault();
performSearch();
}
});
// Function to open the modal
function openModal() {
const modal = document.getElementById("room-list-modal");
const modalContent = document.getElementById("room-list-modal-content");
const closeButton = document.getElementById("close-modal-button");
// Clone the create room section and rooms list
const createRoomSection = document.getElementById("create-room-section").cloneNode(true);
const roomsList = document.getElementById("rooms-list-ul").cloneNode(true);
// Clear previous content and add all sections
document.getElementById("rooms-list-modal-content").innerHTML = '';
document.getElementById("rooms-list-modal-content").appendChild(createRoomSection);
document.getElementById("rooms-list-modal-content").appendChild(roomsList);
modal.style.display = "flex";
modalContent.style.display = "block";
closeButton.style.display = "block";
}
// Function to close the modal
function closeModal() {
const modal = document.getElementById("room-list-modal");
const modalContent = document.getElementById("room-list-modal-content");
const closeButton = document.getElementById("close-modal-button");
modal.style.display = "none";
modalContent.style.display = "none";
closeButton.style.display = "none";
}
// Function to update all room links (no query parameters needed)
function updateRoomLinksWithCurrentParams() {
// No longer needed - links don't use username in query string
// Keeping function for compatibility
}
// Add event listener to the hamburger button
document.getElementById("hamburger-button").addEventListener("click", openModal);
// Theme switching functionality
function toggleTheme() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
// Update the data-theme attribute
html.setAttribute('data-theme', newTheme);
// Save to localStorage
localStorage.setItem('theme', newTheme);
// Update button text for both desktop and mobile
updateThemeButtonText(newTheme);
// Switch highlight.js theme
updateHighlightTheme(newTheme);
}
function updateHighlightTheme(theme) {
const lightTheme = document.getElementById('highlight-theme-light');
const darkTheme = document.getElementById('highlight-theme-dark');
if (theme === 'dark') {
lightTheme.disabled = true;
darkTheme.disabled = false;
} else {
lightTheme.disabled = false;
darkTheme.disabled = true;
}
}
function updateThemeButtonText(theme) {
// Theme toggle moved to profile page
}
// Apply saved theme on page load
function applySavedTheme() {
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
updateThemeButtonText(savedTheme);
updateHighlightTheme(savedTheme);
}
// Apply theme immediately (before DOMContentLoaded to prevent flash)
applySavedTheme();
// Populate the mobile model dropdown dynamically
document.addEventListener('DOMContentLoaded', (event) => {
const modelSelectMobile = document.getElementById("model-select-mobile");
// Ensure theme button text is correct on page load
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
updateThemeButtonText(currentTheme);
// Function to populate the dropdown
function populateModelDropdown(models) {
while (modelSelectMobile.options.length > 1) {
modelSelectMobile.remove(1);
}
models.forEach(modelId => {
const option = document.createElement('option');
option.value = modelId;
option.textContent = modelId;
modelSelectMobile.appendChild(option);
});
}
// Memoization with localStorage (1-minute cache)
const cacheKey = 'modelList';
const cacheExpirationKey = 'modelListExpiration';
const cacheDuration = 60 * 1000; // 1 minute in milliseconds
const cachedData = localStorage.getItem(cacheKey);
const cachedExpiration = localStorage.getItem(cacheExpirationKey);
if (cachedData && cachedExpiration && Date.now() < parseInt(cachedExpiration)) {
const models = JSON.parse(cachedData);
populateModelDropdown(models);
} else {
fetch('/models')
.then(response => response.json())
.then(data => {
const models = data.models;
populateModelDropdown(models);
localStorage.setItem(cacheKey, JSON.stringify(models));
localStorage.setItem(cacheExpirationKey, Date.now() + cacheDuration);
})
.catch(error => console.error("Error fetching models:", error));
}
});
// Room tab switching
function switchRoomTab(tab) {
const publicTab = document.getElementById('public-rooms-tab');
const privateTab = document.getElementById('private-rooms-tab');
const publicSection = document.getElementById('public-rooms-section');
const privateSection = document.getElementById('private-rooms-section');
if (tab === 'public') {
publicTab.classList.add('active');
privateTab.classList.remove('active');
publicSection.style.display = 'block';
privateSection.style.display = 'none';
} else {
privateTab.classList.add('active');
publicTab.classList.remove('active');
privateSection.style.display = 'block';
publicSection.style.display = 'none';
}
}
// Authentication modal functions
let pendingEmail = '';
function showAuthModal() {
document.getElementById('auth-modal').style.display = 'flex';
showAuthStep('email');
}
function closeAuthModal() {
document.getElementById('auth-modal').style.display = 'none';
clearAuthErrors();
}
function closeAuthModalAndReload() {
closeAuthModal();
window.location.reload();
}
function showAuthStep(step) {
document.querySelectorAll('.auth-step').forEach(el => el.style.display = 'none');
document.getElementById('auth-step-' + step).style.display = 'block';
clearAuthErrors();
}
function clearAuthErrors() {
document.querySelectorAll('.error-message').forEach(el => el.textContent = '');
}
function backToEmailStep() {
showAuthStep('email');
}
async function sendOTP() {
const email = document.getElementById('auth-email').value.trim();
const errorEl = document.getElementById('auth-email-error');
if (!email) {
errorEl.textContent = 'Please enter your email';
return;
}
try {
const response = await fetch('/auth/send-otp', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({email})
});
const data = await response.json();
if (response.ok) {
pendingEmail = email;
document.getElementById('auth-email-display').textContent = email;
showAuthStep('otp');
} else {
errorEl.textContent = data.error || 'Failed to send code';
}
} catch (error) {
errorEl.textContent = 'Network error. Please try again.';
}
}
async function verifyOTP() {
const otpCode = document.getElementById('auth-otp').value.trim();
const errorEl = document.getElementById('auth-otp-error');
if (!otpCode) {
errorEl.textContent = 'Please enter the code';
return;
}
try {
const response = await fetch('/auth/verify-otp', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({email: pendingEmail, otp_code: otpCode})
});
const data = await response.json();
if (response.ok) {
if (data.needs_display_name) {
showAuthStep('name');
} else {
document.getElementById('auth-success-name').textContent = data.user.display_name;
showAuthStep('success');
}
} else {
errorEl.textContent = data.error || 'Invalid code';
}
} catch (error) {
errorEl.textContent = 'Network error. Please try again.';
}
}
async function claimName() {
const displayName = document.getElementById('auth-display-name').value.trim();
const errorEl = document.getElementById('auth-name-error');
if (!displayName) {
errorEl.textContent = 'Please enter a display name';
return;
}
try {
const response = await fetch('/auth/claim-name', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({display_name: displayName})
});
const data = await response.json();
if (response.ok) {
document.getElementById('auth-success-name').textContent = data.user.display_name;
showAuthStep('success');
} else {
errorEl.textContent = data.error || 'Failed to claim name';
}
} catch (error) {
errorEl.textContent = 'Network error. Please try again.';
}
}
// Room management functions
async function forkRoom(roomId) {
const makePrivate = confirm('Fork as private room? (Cancel for public)');
try {
const response = await fetch(`/api/rooms/${roomId}/fork`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({private: makePrivate})
});
const data = await response.json();
if (response.ok) {
window.location.href = `/chat/${data.room.name}`;
} else {
alert(data.error || 'Failed to fork room');
}
} catch (error) {
alert('Network error. Please try again.');
}
}
async function archiveRoom(roomId) {
if (!confirm('Archive this room? It will be hidden but not deleted.')) {
return;
}
try {
const response = await fetch(`/api/rooms/${roomId}/archive`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
const data = await response.json();
if (response.ok) {
alert('Room archived successfully!');
window.location.reload();
} else {
alert(data.error || 'Failed to archive room');
}
} catch (error) {
alert('Network error. Please try again.');
}
}
async function deleteRoom(roomId) {
if (!confirm('Delete this room permanently? This cannot be undone!')) {
return;
}
// Determine if current room is private by checking if we're in private section
const currentRoomElement = document.querySelector(`[data-room-id="${roomId}"]`);
const isPrivate = currentRoomElement ? currentRoomElement.classList.contains('private-room') : false;
try {
const response = await fetch(`/api/rooms/${roomId}/delete`, {
method: 'DELETE',
headers: {'Content-Type': 'application/json'}
});
const data = await response.json();
if (response.ok) {
// Navigate to the top room of the appropriate list (public or private)
const roomList = isPrivate
? document.querySelectorAll('#private-rooms-section .rooms-list li')
: document.querySelectorAll('#public-rooms-section .rooms-list li');
// Find first room that isn't the deleted one
let targetRoom = null;
for (let room of roomList) {
if (room.getAttribute('data-room-id') != roomId) {
targetRoom = room;
break;
}
}
if (targetRoom) {
// Navigate to the first available room in the list
const link = targetRoom.closest('a');
if (link) {
window.location.href = link.href;
} else {
window.location.href = '/';
}
} else {
// No other rooms available, go to home
window.location.href = '/';
}
} else {
alert(data.error || 'Failed to delete room');
}
} catch (error) {
alert('Network error. Please try again.');
}
}
</script>
</body>
</html>