opencompletion.com/templates/base.html
Russell Ballestrini acdf653eaa Enhance user experience with multiple improvements
- Add username field to right sidebar and mobile modal with 'guest' default
- Implement real-time username sync with URL query string updates
- Add opencompletion.com button and new room creation in left sidebar
- Implement room name slugification (e.g. "a whole new world" → "a-whole-new-world")
- Create shared utils.js for common functions like slugify
- Add single search result auto-redirect functionality
- Remove redundant UI elements ("Create New Room" header, docs link)
- Preserve user settings (username, model, voice) across redirects and room creation

Technical improvements:
- Consolidated duplicate code into shared utility functions
- Enhanced search logic with parameter preservation
- Improved mobile/desktop sync for all input fields
- Better URL handling and query string management
2025-08-11 16:01:51 -04:00

695 lines
23 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 a highlight.js theme (replace 'default' with your preferred theme) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/styles/default.min.css">
<!-- 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') }}">
<style>
/* Basic styling for the chat application */
html, body {
height: 100%;
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
background-color: #f7f7f7;
display: grid;
place-items: center;
overflow: hidden; /* Prevent scrolling of the main viewport */
}
/* Styling for the chat container */
#chat-container {
display: grid;
grid-template-rows: 1fr auto;
width: 100%;
height: 90vh;
background-color: #ffffff;
border-radius: 5px;
padding: 15px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
box-sizing: border-box;
}
/* Styling for the chat area */
#chat {
overflow-y: auto;
border: 1px solid #e1e1e1;
border-radius: 5px;
padding-left: 10px;
margin-bottom: 10px;
width: 100%; /* Allow chat window to fill available space */
}
/* Styling for the message input area */
#message, .message-edit {
width: 100%;
border: 1px solid #e1e1e1;
border-radius: 5px;
padding: 5px;
display: block;
}
/* Styling for the message body wrapper that contains header and content */
.message-body {
width: 100%;
display: flex;
flex-direction: column;
}
/* Styling for the message header (username/model) */
.message-header {
width: 100%;
margin-bottom: 0;
}
/* Styling for the message div holding html/markdown content */
.message-content {
width: 100%;
}
/* Styling for individual message wrappers */
.message-wrapper {
display: grid;
grid-template-columns: auto 1fr;
align-items: start;
gap: 4px;
margin-bottom: 32px;
}
/* Styling for the delete and edit buttons next to messages */
.message-wrapper button {
margin-right: 4px;
margin-bottom: 4px;
}
/* Styling for the button container within each message */
.button-container {
display: grid;
grid-auto-rows: min-content; /* Ensure each button takes up only as much space as it needs */
gap: 4px; /* Vertical space between buttons */
}
/* Styling for paragraphs, used for messages */
p {
margin: 0;
margin-bottom: 12px;
}
/* Styling for the main container that holds the rooms list and chat */
.main-container {
display: grid;
grid-template-columns: 15% 70% 15%;
width: 100%;
height: 90vh;
}
/* Styling for the rooms list */
#rooms-list {
border-right: 1px solid #e1e1e1;
overflow-y: auto;
padding: 10px;
}
/* Styling for site header */
#site-header {
margin-bottom: 20px;
text-align: center;
}
#opencompletion-btn {
width: 100%;
padding: 10px;
background-color: #007bff;
color: white;
border: none;
border-radius: 5px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: background-color 0.3s;
}
#opencompletion-btn:hover {
background-color: #0056b3;
}
/* Styling for new room creation section */
#new-room-section {
margin-bottom: 20px;
padding: 10px;
border: 1px solid #e1e1e1;
border-radius: 5px;
background-color: #f8f9fa;
}
#new-room-section h4 {
margin: 0 0 10px 0;
font-size: 14px;
color: #495057;
}
#new-room-name {
width: 100%;
padding: 8px;
border: 1px solid #ced4da;
border-radius: 3px;
font-size: 12px;
resize: vertical;
margin-bottom: 10px;
box-sizing: border-box;
}
#create-room-btn {
width: 100%;
padding: 8px;
background-color: #28a745;
color: white;
border: none;
border-radius: 3px;
font-size: 12px;
cursor: pointer;
transition: background-color 0.3s;
}
#create-room-btn:hover {
background-color: #218838;
}
/* Styling for public rooms header */
#public-rooms-header {
margin-bottom: 10px;
}
#public-rooms-header h4 {
margin: 0;
font-size: 14px;
color: #495057;
border-bottom: 1px solid #e1e1e1;
padding-bottom: 5px;
}
/* Styling for the unordered list in the rooms list */
#rooms-list ul, #rooms-list-modal-content ul {
list-style: none; /* Removes default list styling */
padding: 0; /* Resets default padding */
margin: 0; /* Resets default margin */
}
/* Styling for list items in the rooms list */
#rooms-list li {
margin-bottom: 10px; /* Adds space between items */
padding: 5px; /* Adds padding inside each item */
border: 1px solid #e1e1e1; /* Adds a border around each item */
border-radius: 5px; /* Optional: Rounds the corners of the border */
}
/* Styling for links in the rooms list */
#rooms-list a {
text-decoration: none; /* Optional: Removes underline from links */
color: inherit; /* Optional: Ensures link color matches the text color */
}
.hljs {
position: relative;
padding-left: 46px !important;
counter-reset: line;
}
.hljs .line-numbers-rows {
position: absolute;
top: 0;
left: 0;
width: 3em; /* Adjust the width as needed */
letter-spacing: -1px;
border-right: 1px solid #ccc; /* Optional: adds a line to separate numbers */
text-align: right;
margin-top: 14px; /* Align with the code block */
color: #999;
pointer-events: none;
}
.hljs .line-numbers-rows span {
display: block;
counter-increment: line;
}
.hljs .line-numbers-rows span::before {
content: counter(line);
display: block;
padding-right: 0.8em; /* Adjust the padding as needed */
}
.download-links {
text-align: center;
}
/* Hamburger button styling */
#hamburger-button {
display: none; /* Hidden by default */
position: fixed;
top: 20px;
left: 20px;
background-color: #333;
color: white;
border: none;
border-radius: 5px;
padding: 10px;
cursor: pointer;
z-index: 1001;
}
/* Modal styling */
#room-list-modal {
display: none; /* Hidden by default */
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1002;
justify-content: center;
align-items: center;
}
#room-list-modal-content {
display: none; /* Hidden by default */
background-color: white;
padding: 20px;
border-radius: 5px;
width: 80%;
max-width: 400px;
max-height: 80vh;
overflow-y: auto; /* Make the room list scrollable */
position: relative;
}
/* Close button styling */
#close-modal-button {
display: none; /* Hidden by default */
position: fixed;
top: 10px;
right: 10px;
background-color: #333;
color: white;
border: none;
border-radius: 5px;
font-size: 20px;
cursor: pointer;
z-index: 1003; /* Ensure it is above the modal content */
padding: 5px 10px; /* Add padding for a button-like appearance */
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); /* Add a shadow for depth */
transition: background-color 0.3s; /* Smooth transition for hover effect */
}
#close-modal-button:hover {
background-color: #555; /* Darken on hover */
}
.utility-belt {
padding: 10px;
}
/* Activity controls styling */
#activity-controls {
margin-top: 20px;
padding: 10px;
border-top: 1px solid #e1e1e1;
}
#activity-controls h3 {
margin-top: 0;
margin-bottom: 10px;
}
#current-activity-info {
background-color: #f0f0f0;
padding: 10px;
border-radius: 5px;
margin-bottom: 10px;
}
#current-activity-info p {
margin: 0 0 10px 0;
}
#activity-controls button {
background-color: #4CAF50;
color: white;
border: none;
padding: 8px 16px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 14px;
margin: 4px 2px;
cursor: pointer;
border-radius: 4px;
}
#cancel-activity-btn {
background-color: #f44336;
}
#activity-controls button:hover {
opacity: 0.8;
}
#activity-select {
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 5px;
border: 1px solid #e1e1e1;
border-radius: 4px;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Model and voice select dropdowns styling */
#model-select, #voice-select, #model-select-mobile, #voice-select-mobile {
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 5px;
border: 1px solid #e1e1e1;
border-radius: 4px;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Media query for mobile devices */
@media (max-width: 768px) {
.main-container {
grid-template-columns: 1fr; /* Single column layout */
}
#rooms-list {
display: none; /* Hide the room list on mobile */
}
#hamburger-button {
display: block; /* Show the hamburger button on mobile */
}
}
</style>
</head>
<body>
<!-- Search form -->
<div id="search-form" style="width: 90%;">
<form action="/search" method="get">
<input type="text" id="search-keywords" name="keywords" placeholder="Search for keywords..." style="width: 100%; text-align: center;" value="{{ keywords }}">
<input type="hidden" id="username" name="username" value="{{ username }}">
</form>
</div>
<!-- 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">
<div id="username-chooser-mobile">
<label for="username-input-mobile">Username:</label>
<input type="text" id="username-input-mobile" placeholder="guest" maxlength="50" style="width: 100%; padding: 4px; margin-top: 2px; border: 1px solid #ccc; border-radius: 3px;">
</div>
<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">
<!-- opencompletion.com button -->
<div id="site-header">
<button id="opencompletion-btn" onclick="window.open('https://opencompletion.com', '_blank')">
opencompletion.com
</button>
</div>
<!-- New room creation section -->
<div id="new-room-section">
<textarea id="new-room-name" placeholder="Enter room name..." rows="2" maxlength="100"></textarea>
<button id="create-room-btn" onclick="createNewRoom()">Create Room</button>
</div>
<!-- Public rooms header -->
<div id="public-rooms-header">
<h4>Public Rooms</h4>
</div>
<ul id="rooms-list-ul">
<!-- Loop through rooms and create list items for each room -->
{% for room in rooms %}
<a href="{{ url_for('chat', room_name=room.name) }}?{{ request.query_string.decode('utf-8')|safe }}">
<li data-room-id="{{ room.id }}">
<!-- 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 %}
</li>
</a>
{% endfor %}
</ul>
</div>
{% block content %}{% endblock %}
</div>
<script>
// Function to perform the search
function performSearch() {
const keywords = document.getElementById("search-keywords").value;
const username = document.getElementById("username").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)}&username=${username}`;
}
// 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 site header, new room section, public rooms header, and rooms list
const siteHeader = document.getElementById("site-header").cloneNode(true);
const newRoomSection = document.getElementById("new-room-section").cloneNode(true);
const publicRoomsHeader = document.getElementById("public-rooms-header").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(siteHeader);
document.getElementById("rooms-list-modal-content").appendChild(newRoomSection);
document.getElementById("rooms-list-modal-content").appendChild(publicRoomsHeader);
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 create a new room
function createNewRoom() {
const roomName = document.getElementById("new-room-name").value.trim();
if (!roomName) {
alert("Please enter a room name.");
return;
}
// Slugify the room name to make it URL-friendly
const slugifiedRoomName = slugify(roomName);
if (!slugifiedRoomName) {
alert("Please enter a valid room name with at least some letters or numbers.");
return;
}
// Get current URL parameters to maintain username, model, voice, etc.
const urlParams = new URLSearchParams(window.location.search);
const currentParams = urlParams.toString();
// Navigate to the new room using the slugified name
window.location.href = `/chat/${slugifiedRoomName}${currentParams ? '?' + currentParams : ''}`;
}
// Add event listener for Enter key in the new room textarea
document.addEventListener('DOMContentLoaded', function() {
const newRoomTextarea = document.getElementById("new-room-name");
if (newRoomTextarea) {
newRoomTextarea.addEventListener("keydown", function(event) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
createNewRoom();
}
});
}
});
// Add event listener to the hamburger button
document.getElementById("hamburger-button").addEventListener("click", openModal);
// Populate the mobile model dropdown dynamically
document.addEventListener('DOMContentLoaded', (event) => {
const modelSelectMobile = document.getElementById("model-select-mobile");
// 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));
}
});
</script>
</body>
</html>