UI: Add Phase 1 QOL improvements

- Add loading spinners and disabled states for form submissions
- Implement client-side form validation with inline error messages
- Add Enter-to-submit functionality for single-line forms
- Implement relative timestamps (e.g., '2 hours ago')
- Add character counters for textareas
- Auto-resize textareas as users type
- Add toast notifications for success/error messages
- Improve form accessibility with autocomplete and max lengths
- Add password confirmation field to signup form
- Better error styling throughout the application
jocadbz
Joca 2026-01-15 22:39:30 -03:00
parent b11b4fd0a6
commit ca4268dfec
Signed by: jocadbz
GPG Key ID: B1836DCE2F50BDF7
14 changed files with 533 additions and 17 deletions

327
static/app.js Normal file
View File

@ -0,0 +1,327 @@
// ThreadR UI Enhancement JavaScript
// Show notification toast
function showNotification(message, type = 'info', duration = 3000) {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.add('hiding');
setTimeout(() => {
document.body.removeChild(notification);
}, 300);
}, duration);
}
// Add loading state to form submission
function handleFormSubmit(form, button) {
if (button) {
button.disabled = true;
button.classList.add('loading');
}
// Find all submit buttons in the form and disable them
const submitButtons = form.querySelectorAll('input[type="submit"], button[type="submit"]');
submitButtons.forEach(btn => {
btn.disabled = true;
btn.classList.add('loading');
});
}
// Remove loading state
function removeLoadingState(form) {
const submitButtons = form.querySelectorAll('input[type="submit"], button[type="submit"]');
submitButtons.forEach(btn => {
btn.disabled = false;
btn.classList.remove('loading');
});
}
// Enable Enter-to-submit for single-line inputs
function enableEnterToSubmit(input, form) {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
form.requestSubmit();
}
});
}
// Auto-resize textarea as user types
function autoResizeTextarea(textarea) {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
}
// Add character counter to textarea
function addCharacterCounter(textarea, maxLength) {
const counter = document.createElement('div');
counter.className = 'char-counter';
textarea.parentNode.insertBefore(counter, textarea.nextSibling);
function updateCounter() {
const length = textarea.value.length;
counter.textContent = `${length}${maxLength ? '/' + maxLength : ''} characters`;
if (maxLength && length > maxLength * 0.9) {
counter.classList.add('warning');
} else {
counter.classList.remove('warning');
}
}
textarea.addEventListener('input', updateCounter);
updateCounter();
}
// Client-side validation helpers
function validateUsername(username) {
if (username.length < 3) {
return 'Username must be at least 3 characters';
}
if (username.length > 30) {
return 'Username must be less than 30 characters';
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
return 'Username can only contain letters, numbers, underscores, and hyphens';
}
return null;
}
function validatePassword(password) {
if (password.length < 8) {
return 'Password must be at least 8 characters';
}
if (password.length > 128) {
return 'Password is too long';
}
return null;
}
function validateRequired(value, fieldName) {
if (!value || value.trim() === '') {
return `${fieldName} is required`;
}
return null;
}
// Show field error
function showFieldError(input, message) {
input.classList.add('error');
// Remove existing error message
const existingError = input.parentNode.querySelector('.field-error');
if (existingError) {
existingError.remove();
}
if (message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'field-error';
errorDiv.textContent = message;
input.parentNode.insertBefore(errorDiv, input.nextSibling);
}
}
// Clear field error
function clearFieldError(input) {
input.classList.remove('error');
const errorDiv = input.parentNode.querySelector('.field-error');
if (errorDiv) {
errorDiv.remove();
}
}
// Relative time formatting
function formatRelativeTime(date) {
const now = new Date();
const diff = now - date;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const months = Math.floor(days / 30);
const years = Math.floor(days / 365);
if (seconds < 60) return 'just now';
if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
if (days < 30) return `${days} day${days !== 1 ? 's' : ''} ago`;
if (months < 12) return `${months} month${months !== 1 ? 's' : ''} ago`;
return `${years} year${years !== 1 ? 's' : ''} ago`;
}
// Convert timestamps to relative time
function initRelativeTimestamps() {
document.querySelectorAll('[data-timestamp]').forEach(element => {
const timestamp = element.getAttribute('data-timestamp');
const date = new Date(timestamp);
const originalText = element.textContent;
element.textContent = formatRelativeTime(date);
element.title = originalText;
element.style.cursor = 'help';
});
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initRelativeTimestamps);
} else {
initRelativeTimestamps();
}
// Add form submission handlers
document.addEventListener('DOMContentLoaded', () => {
// Handle all form submissions
document.querySelectorAll('form').forEach(form => {
form.addEventListener('submit', (e) => {
const submitButton = form.querySelector('input[type="submit"], button[type="submit"]');
handleFormSubmit(form, submitButton);
});
});
// Auto-resize textareas
document.querySelectorAll('textarea').forEach(textarea => {
textarea.addEventListener('input', () => autoResizeTextarea(textarea));
// Add character counter for content fields
if (textarea.id === 'content' || textarea.name === 'content') {
addCharacterCounter(textarea, 10000);
}
if (textarea.id === 'bio' || textarea.name === 'bio') {
addCharacterCounter(textarea, 500);
}
});
// Enable Enter-to-submit for single-line forms (login, etc.)
const loginForm = document.querySelector('form[action*="login"]');
if (loginForm) {
const passwordInput = loginForm.querySelector('input[type="password"]');
if (passwordInput) {
enableEnterToSubmit(passwordInput, loginForm);
}
}
// Add validation to login form
const loginUsername = document.querySelector('input[name="username"]');
const loginPassword = document.querySelector('input[name="password"]');
if (loginUsername && loginPassword) {
loginUsername.addEventListener('blur', () => {
const error = validateRequired(loginUsername.value, 'Username');
if (error) {
showFieldError(loginUsername, error);
} else {
clearFieldError(loginUsername);
}
});
loginPassword.addEventListener('blur', () => {
const error = validateRequired(loginPassword.value, 'Password');
if (error) {
showFieldError(loginPassword, error);
} else {
clearFieldError(loginPassword);
}
});
}
// Add validation to signup form
const signupForm = document.querySelector('form[action*="signup"]');
if (signupForm) {
const usernameInput = signupForm.querySelector('input[name="username"]');
const passwordInput = signupForm.querySelector('input[name="password"]');
const confirmInput = signupForm.querySelector('input[name="password_confirm"]');
if (usernameInput) {
usernameInput.addEventListener('blur', () => {
const error = validateUsername(usernameInput.value);
if (error) {
showFieldError(usernameInput, error);
} else {
clearFieldError(usernameInput);
}
});
}
if (passwordInput) {
passwordInput.addEventListener('blur', () => {
const error = validatePassword(passwordInput.value);
if (error) {
showFieldError(passwordInput, error);
} else {
clearFieldError(passwordInput);
}
});
}
if (confirmInput && passwordInput) {
confirmInput.addEventListener('blur', () => {
if (confirmInput.value !== passwordInput.value) {
showFieldError(confirmInput, 'Passwords do not match');
} else {
clearFieldError(confirmInput);
}
});
}
signupForm.addEventListener('submit', (e) => {
let hasError = false;
if (usernameInput) {
const error = validateUsername(usernameInput.value);
if (error) {
showFieldError(usernameInput, error);
hasError = true;
}
}
if (passwordInput) {
const error = validatePassword(passwordInput.value);
if (error) {
showFieldError(passwordInput, error);
hasError = true;
}
}
if (confirmInput && passwordInput && confirmInput.value !== passwordInput.value) {
showFieldError(confirmInput, 'Passwords do not match');
hasError = true;
}
if (hasError) {
e.preventDefault();
removeLoadingState(signupForm);
showNotification('Please fix the errors before submitting', 'error');
}
});
}
// Add validation to thread/post forms
document.querySelectorAll('input[name="title"]').forEach(input => {
input.addEventListener('blur', () => {
const error = validateRequired(input.value, 'Title');
if (error) {
showFieldError(input, error);
} else if (input.value.length > 255) {
showFieldError(input, 'Title is too long (max 255 characters)');
} else {
clearFieldError(input);
}
});
});
document.querySelectorAll('textarea[name="content"]').forEach(textarea => {
textarea.addEventListener('blur', () => {
const error = validateRequired(textarea.value, 'Content');
if (error) {
showFieldError(textarea, error);
} else {
clearFieldError(textarea);
}
});
});
});

View File

@ -422,6 +422,175 @@ p.thread-info {
}
}
/* Loading spinner */
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 3px solid #fef6e4;
border-top: 3px solid #001858;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-left: 8px;
vertical-align: middle;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Loading state for buttons */
button:disabled, input[type="submit"]:disabled {
opacity: 0.6;
cursor: not-allowed;
}
button.loading, input[type="submit"].loading {
position: relative;
color: transparent;
}
button.loading::after, input[type="submit"].loading::after {
content: "";
position: absolute;
width: 16px;
height: 16px;
top: 50%;
left: 50%;
margin-left: -8px;
margin-top: -8px;
border: 3px solid #fef6e4;
border-top: 3px solid transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* Success/error message notifications */
.notification {
position: fixed;
top: 80px;
right: 20px;
padding: 14px 20px;
border-radius: 5px;
box-shadow: 0px 4px 12px rgba(0,0,0,0.3);
z-index: 1001;
animation: slideIn 0.3s ease-out;
max-width: 400px;
font-family: monospace;
}
.notification.success {
background-color: #8bd3dd;
color: #001858;
border: 1px solid #001858;
}
.notification.error {
background-color: #f582ae;
color: #fef6e4;
border: 1px solid #001858;
}
.notification.info {
background-color: #f3d2c1;
color: #001858;
border: 1px solid #001858;
}
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
.notification.hiding {
animation: slideOut 0.3s ease-out forwards;
}
/* Form validation styles */
input.error, textarea.error, select.error {
border-color: #f582ae;
border-width: 2px;
}
.field-error {
color: #f582ae;
font-size: 0.9em;
margin-top: 4px;
margin-bottom: 8px;
display: block;
}
/* Character counter */
.char-counter {
text-align: right;
font-size: 0.85em;
color: #001858;
opacity: 0.7;
margin-top: 4px;
}
.char-counter.warning {
color: #f582ae;
font-weight: bold;
}
@media (prefers-color-scheme: dark) {
.spinner {
border-color: #444;
border-top-color: #fef6e4;
}
button.loading::after, input[type="submit"].loading::after {
border-color: #001858;
border-top-color: transparent;
}
.notification.success {
background-color: #8bd3dd;
color: #001858;
}
.notification.error {
background-color: #f582ae;
color: #001858;
}
.notification.info {
background-color: #555;
color: #fef6e4;
}
input.error, textarea.error, select.error {
border-color: #f582ae;
}
.field-error {
color: #f582ae;
}
.char-counter {
color: #fef6e4;
}
}
@media (max-width: 600px) {
ul.topnav li {
float: none;
@ -441,4 +610,10 @@ p.thread-info {
.thread-posts {
width: 95%;
}
.notification {
right: 10px;
left: 10px;
max-width: none;
}
}

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}
@ -19,7 +20,7 @@
{{range .Threads}}
<li class="thread-item">
<a href="{{$.BasePath}}/thread/?id={{.ID}}">{{.Title}}</a>
<p class="thread-info">Updated on {{.UpdatedAt.Format "02/01/2006 - 15:04"}}</p>
<p class="thread-info" data-timestamp="{{.UpdatedAt.Format "2006-01-02T15:04:05Z07:00"}}">Updated on {{.UpdatedAt.Format "02/01/2006 - 15:04"}}</p>
</li>
{{end}}
</ul>
@ -32,7 +33,7 @@
<h3>Create New Thread</h3>
<form method="post" action="{{.BasePath}}/board/?id={{.Board.ID}}&action=create_thread">
<label for="title">Thread Title:</label>
<input type="text" id="title" name="title" required><br>
<input type="text" id="title" name="title" required maxlength="255"><br>
<input type="submit" value="Create Thread">
</form>
</section>

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}
@ -56,7 +57,7 @@
<h3>Create New Public Board</h3>
<form method="post" action="{{.BasePath}}/boards/">
<label for="name">Board Name:</label>
<input type="text" id="name" name="name" required><br>
<input type="text" id="name" name="name" required maxlength="255"><br>
<label for="description">Description:</label>
<textarea id="description" name="description"></textarea><br>
<label for="type">Board Type:</label>

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}
@ -13,13 +14,13 @@
</header>
<section>
{{if .Error}}
<p style="color: red;">{{.Error}}</p>
<p class="field-error" style="text-align: center; font-size: 1em;">{{.Error}}</p>
{{end}}
<form method="post" action="{{.BasePath}}/login/">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required><br>
<input type="text" id="username" name="username" required autocomplete="username"><br>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required><br>
<input type="password" id="password" name="password" required autocomplete="current-password"><br>
<input type="submit" value="Login">
</form>
</section>

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}
@ -15,7 +16,7 @@
{{if .News}}
<ul>
{{range .News}}
<li><strong>{{.Title}}</strong> - Posted on {{.CreatedAt.Format "02/01/2006 - 15:04"}}
<li><strong>{{.Title}}</strong> - <span data-timestamp="{{.CreatedAt.Format "2006-01-02T15:04:05Z07:00"}}">Posted on {{.CreatedAt.Format "02/01/2006 - 15:04"}}</span>
<p>{{.Content}}</p>
{{if $.IsAdmin}}
<form method="post" action="{{$.BasePath}}/news/?action=delete&id={{.ID}}" style="display:inline;">
@ -34,7 +35,7 @@
<h3>Post New Announcement</h3>
<form method="post" action="{{.BasePath}}/news/">
<label for="title">Title:</label>
<input type="text" id="title" name="title" required><br>
<input type="text" id="title" name="title" required maxlength="255"><br>
<label for="content">Content:</label>
<textarea id="content" name="content" required></textarea><br>
<input type="submit" value="Post News">

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}
@ -18,8 +19,8 @@
<img src="{{.BasePath}}/file?id={{.User.PfpFileID.Int64}}" alt="Profile Picture">
{{end}}
<p>Bio: {{.User.Bio}}</p>
<p>Joined: {{.User.CreatedAt}}</p>
<p>Last Updated: {{.User.UpdatedAt}}</p>
<p data-timestamp="{{.User.CreatedAt.Format "2006-01-02T15:04:05Z07:00"}}">Joined: {{.User.CreatedAt}}</p>
<p data-timestamp="{{.User.UpdatedAt.Format "2006-01-02T15:04:05Z07:00"}}">Last Updated: {{.User.UpdatedAt}}</p>
<p>Verified: {{.User.Verified}}</p>
<a href="{{.BasePath}}/profile/edit/">Edit Profile</a>
</section>

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}
@ -14,11 +15,11 @@
<section>
<form method="post" action="{{.BasePath}}/profile/edit/" enctype="multipart/form-data">
<label for="display_name">Display Name:</label>
<input type="text" id="display_name" name="display_name" value="{{.User.DisplayName}}"><br>
<input type="text" id="display_name" name="display_name" value="{{.User.DisplayName}}" maxlength="255"><br>
<label for="pfp">Profile Picture:</label>
<input type="file" id="pfp" name="pfp"><br>
<input type="file" id="pfp" name="pfp" accept="image/*"><br>
<label for="bio">Bio:</label>
<textarea id="bio" name="bio">{{.User.Bio}}</textarea><br>
<textarea id="bio" name="bio" maxlength="500">{{.User.Bio}}</textarea><br>
<input type="submit" value="Save">
</form>
</section>

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}
@ -13,13 +14,15 @@
</header>
<section>
{{if .Error}}
<p style="color: red;">{{.Error}}</p>
<p class="field-error" style="text-align: center; font-size: 1em;">{{.Error}}</p>
{{end}}
<form method="post" action="{{.BasePath}}/signup/">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required><br>
<input type="text" id="username" name="username" required autocomplete="username" minlength="3" maxlength="30"><br>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required><br>
<input type="password" id="password" name="password" required autocomplete="new-password" minlength="8" maxlength="128"><br>
<label for="password_confirm">Confirm Password:</label>
<input type="password" id="password_confirm" name="password_confirm" required autocomplete="new-password" minlength="8" maxlength="128"><br>
<input type="submit" value="Sign Up">
</form>
</section>

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}
@ -16,7 +17,7 @@
<article id="{{.ID}}" class="post-item" style="margin-left: {{if gt .ReplyTo 0}}20px{{else}}0px{{end}};">
<header>
<h3>{{if .Title}}{{.Title}}{{else}}Post #{{.ID}}{{end}}</h3>
<p>Posted on {{.PostTime.Format "02/01/2006 - 15:04"}}</p>
<p data-timestamp="{{.PostTime.Format "2006-01-02T15:04:05Z07:00"}}">Posted on {{.PostTime.Format "02/01/2006 - 15:04"}}</p>
{{if gt .ReplyTo 0}}
<p>Reply to post <a href="#{{.ReplyTo}}">{{.ReplyTo}}</a></p>
{{end}}

View File

@ -4,6 +4,7 @@
<head>
<title>{{.Title}}</title>
<link rel="stylesheet" href="{{.StaticPath}}/style.css">
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
{{template "navbar" .}}