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
This commit is contained in:
Russell Ballestrini 2025-08-11 16:01:51 -04:00
parent 88f68e4adc
commit acdf653eaa
5 changed files with 267 additions and 17 deletions

21
app.py
View file

@ -22,6 +22,8 @@ from flask import (
send_from_directory,
jsonify,
Response,
redirect,
url_for,
)
from flask_socketio import SocketIO, emit, join_room, leave_room
@ -365,6 +367,25 @@ def search_page():
# Call the function to search messages
search_results = search_messages(keywords)
# If there's exactly one search result, redirect directly to that room
if len(search_results) == 1:
room_result = search_results[0]
room_name = room_result["room_name"]
# Build the redirect URL with current parameters
redirect_params = {}
if username and username != "guest":
redirect_params["username"] = username
# Preserve other URL parameters like model, voice, etc.
for param in ["model", "voice"]:
value = request.args.get(param)
if value:
redirect_params[param] = value
redirect_url = url_for("chat", room_name=room_name, **redirect_params)
return redirect(redirect_url)
return render_template(
"search.html",
rooms=rooms,

12
static/js/utils.js Normal file
View file

@ -0,0 +1,12 @@
/**
* Utility functions for the OpenCompletion application
*/
/**
* Convert a string to a URL-friendly slug
* @param {string} str - The string to slugify
* @returns {string} - The slugified string
*/
function slugify(str) {
return str.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]+/g, '');
}

View file

@ -22,6 +22,9 @@
<!-- 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
@ -134,6 +137,85 @@
#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 */
@ -347,7 +429,6 @@
</style>
</head>
<body>
<a href="https://github.com/russellballestrini/opencompletion#interacting-with-language-models" target="_blank">🚀 docs for models & other commands, also try /help</a>
<!-- Search form -->
<div id="search-form" style="width: 90%;">
<form action="/search" method="get">
@ -364,6 +445,10 @@
<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">
@ -428,6 +513,24 @@
<!-- 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 %}
@ -477,9 +580,20 @@
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);
document.getElementById("rooms-list-modal-content").innerHTML = ''; // Clear previous content
// 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";
@ -494,6 +608,44 @@
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);

View file

@ -20,6 +20,10 @@
<a href="/download_chat_history_md?room_name={{ room_name }}" download="{{ room_name }}.md">Markdown</a>
</div>
<br>
<div>
<label for="username-input">Username</label>
<input type="text" id="username-input" placeholder="guest" maxlength="50" style="width: 100%; padding: 4px; margin-top: 2px; border: 1px solid #ccc; border-radius: 3px;">
</div>
<div>
<label for="model-select">Model</label>
<select id="model-select">
@ -82,7 +86,14 @@
const API_KEY = "dummy-api-key";
const TTS_API_URL = "https://speech.ai.unturf.com/v1/audio/speech";
const urlParams = new URLSearchParams(window.location.search);
const username = urlParams.get("username");
let username = urlParams.get("username") || "guest"; // Default to "guest" if no username in URL
// If no username was in the URL, add it now
if (!urlParams.get("username")) {
const newUrl = new URL(window.location.href);
newUrl.searchParams.set("username", username);
window.history.replaceState({}, '', newUrl);
}
const room_name = "{{ room_name }}";
// Global constants for valid voices
@ -119,24 +130,35 @@ function sanitizeUsername(username) {
return username.split(',')[0].trim();
}
// Function to sync dropdowns and update the query string
function syncDropdownsAndQueryString() {
const sanitizedUsername = sanitizeUsername(username);
// Function to sync all inputs and update the query string
function syncInputsAndQueryString() {
const usernameInputDesktop = document.getElementById("username-input");
const usernameInputMobile = document.getElementById("username-input-mobile");
const modelSelectDesktop = document.getElementById("model-select");
const voiceSelectDesktop = document.getElementById("voice-select");
const modelSelectMobile = document.getElementById("model-select-mobile");
const voiceSelectMobile = document.getElementById("voice-select-mobile");
// Determine the current model and voice from any dropdown
// Get current values
const currentUsername = usernameInputDesktop?.value || username || "guest";
const currentModel = modelSelectDesktop.value;
const currentVoice = VALID_VOICES.includes(voiceSelectDesktop.value) ? voiceSelectDesktop.value : 'onyx';
// Sync both desktop and mobile dropdowns
// Update global username variable
username = currentUsername;
const sanitizedUsername = sanitizeUsername(username);
// Sync username inputs
if (usernameInputDesktop) usernameInputDesktop.value = sanitizedUsername;
if (usernameInputMobile) usernameInputMobile.value = sanitizedUsername;
// Sync dropdowns
modelSelectDesktop.value = currentModel;
voiceSelectDesktop.value = currentVoice;
modelSelectMobile.value = currentModel;
voiceSelectMobile.value = currentVoice;
if (modelSelectMobile) modelSelectMobile.value = currentModel;
if (voiceSelectMobile) voiceSelectMobile.value = currentVoice;
// Update URL
const newUrl = new URL(window.location.href);
newUrl.searchParams.set("username", sanitizedUsername);
newUrl.searchParams.set("model", currentModel);
@ -144,6 +166,11 @@ function syncDropdownsAndQueryString() {
window.history.replaceState({}, '', newUrl);
}
// Backward compatibility
function syncDropdownsAndQueryString() {
syncInputsAndQueryString();
}
document.addEventListener('DOMContentLoaded', (event) => {
const chatContainer = document.getElementById("chat");
const modelSelectDesktop = document.getElementById("model-select");
@ -204,13 +231,21 @@ document.addEventListener('DOMContentLoaded', (event) => {
userHasScrolledUp = distanceFromBottom > 5;
});
// Set initial model and voice from query string
// Set initial model, voice, and username from query string
const initialModel = urlParams.get("model") || "None";
const initialVoice = urlParams.get("voice") || "onyx";
const initialUsername = username; // Already set to URL param or "guest"
modelSelectDesktop.value = initialModel;
voiceSelectDesktop.value = initialVoice;
modelSelectMobile.value = initialModel;
voiceSelectMobile.value = initialVoice;
// Set initial username values
const usernameInputDesktop = document.getElementById("username-input");
const usernameInputMobile = document.getElementById("username-input-mobile");
if (usernameInputDesktop) usernameInputDesktop.value = initialUsername;
if (usernameInputMobile) usernameInputMobile.value = initialUsername;
// Add event listeners for desktop dropdowns
modelSelectDesktop.addEventListener("change", () => {
@ -245,6 +280,39 @@ document.addEventListener('DOMContentLoaded', (event) => {
syncDropdownsAndQueryString();
isSyncingDropdowns = false;
});
// Add event listeners for username inputs
if (usernameInputDesktop) {
usernameInputDesktop.addEventListener("input", () => {
if (isSyncingDropdowns) return;
isSyncingDropdowns = true;
if (usernameInputMobile) {
usernameInputMobile.value = usernameInputDesktop.value;
}
syncInputsAndQueryString();
isSyncingDropdowns = false;
});
usernameInputDesktop.addEventListener("blur", () => {
syncInputsAndQueryString();
});
}
if (usernameInputMobile) {
usernameInputMobile.addEventListener("input", () => {
if (isSyncingDropdowns) return;
isSyncingDropdowns = true;
if (usernameInputDesktop) {
usernameInputDesktop.value = usernameInputMobile.value;
}
syncInputsAndQueryString();
isSyncingDropdowns = false;
});
usernameInputMobile.addEventListener("blur", () => {
syncInputsAndQueryString();
});
}
});
// Socket event when the user connects
@ -252,8 +320,8 @@ socket.on("connect", () => {
// Sanitize the username before joining
const sanitizedUsername = sanitizeUsername(username);
socket.emit("join", {"username": sanitizedUsername, "room_name": room_name});
// Sync dropdowns and update the query string
syncDropdownsAndQueryString();
// Sync inputs and update the query string
syncInputsAndQueryString();
});
// Function to update the active and inactive user lists in the DOM

View file

@ -6,6 +6,7 @@
<title>Chatroom</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.min.js"></script>
<script src="{{ url_for('static', filename='js/utils.js') }}"></script>
<style>
body {
font-family: Arial, sans-serif;
@ -52,10 +53,6 @@
</div>
<script>
function slugify(str) {
return str.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]+/g, '');
}
document.getElementById('join-room-form').addEventListener('submit', function(e) {
e.preventDefault();
const username = document.getElementById('username').value;