Customizable avatars for users

This commit is contained in:
Cohee 2024-04-13 17:52:37 +03:00
parent 10aa268ea2
commit 1a372abaff
12 changed files with 225 additions and 37 deletions

View File

@ -20,7 +20,7 @@ body.login .userSelect {
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 5px;
padding: 3px 5px;
width: min-content;
width: 30%;
cursor: pointer;
margin: 5px 0;
transition: background-color 0.15s ease-in-out;
@ -28,6 +28,15 @@ body.login .userSelect {
align-items: center;
justify-content: center;
text-align: center;
overflow: hidden;
}
body.login .userSelect .userName,
body.login .userSelect .userHandle {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
body.login .userSelect:hover {

View File

@ -98,6 +98,14 @@
justify-content: space-between;
}
.justifySpaceEvenly {
justify-content: space-evenly;
}
.justifySpaceAround {
justify-content: space-around;
}
.alignitemsflexstart {
align-items: flex-start !important;
}

View File

@ -45,7 +45,7 @@
Enter Login Details
</h3>
<div id="userListBlock" class="wide100p">
<div id="userList" class="flex-container justifyCenter"></div>
<div id="userList" class="flex-container justifySpaceEvenly"></div>
<div id="handleEntryBlock" style="display:none;" class="flex-container flexFlowColumn alignItemsCenter">
<input id="userHandle" class="text_pole" type="text" placeholder="User handle" autocomplete="username">
</div>

View File

@ -7073,10 +7073,10 @@ function onScenarioOverrideRemoveClick() {
* @param {string} type
* @param {string} inputValue - Value to set the input to.
* @param {PopupOptions} options - Options for the popup.
* @typedef {{okButton?: string, rows?: number, wide?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean }} PopupOptions - Options for the popup.
* @typedef {{okButton?: string, rows?: number, wide?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean, cropAspect?: number }} PopupOptions - Options for the popup.
* @returns
*/
function callPopup(text, type, inputValue = '', { okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling } = {}) {
function callPopup(text, type, inputValue = '', { okButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling, cropAspect } = {}) {
dialogueCloseStop = true;
if (type) {
popup_type = type;
@ -7133,7 +7133,7 @@ function callPopup(text, type, inputValue = '', { okButton, rows, wide, large, a
crop_data = undefined;
$('#avatarToCrop').cropper({
aspectRatio: 2 / 3,
aspectRatio: cropAspect ?? 2 / 3,
autoCropArea: 1,
viewMode: 2,
rotatable: false,

View File

@ -215,8 +215,8 @@ function configureNormalLogin(userList) {
const avatarBlock = $('<div></div>').addClass('avatar');
avatarBlock.append($('<img>').attr('src', user.avatar));
userBlock.append(avatarBlock);
userBlock.append($('<span></span>').text(user.name));
userBlock.append($('<small></small>').text(user.handle));
userBlock.append($('<span></span>').addClass('userName').text(user.name));
userBlock.append($('<small></small>').addClass('userHandle').text(user.handle));
userBlock.on('click', () => onUserSelected(user));
$('#userList').append(userBlock);
}

View File

@ -9,10 +9,21 @@
</nav>
<div class="userAccountTemplate template_element">
<div class="flex-container userAccount alignItemsCenter flexGap10">
<div>
<div class="avatar">
<div class="flex-container flexFlowColumn alignItemsCenter flexNoGap">
<div class="avatar" title="If a custom avatar is not set, the user's default persona image will be displayed.">
<img src="img/ai4.png" alt="avatar">
</div>
<div class="flex-container alignItemsCenter">
<div class="userAvatarChange right_menu_button" title="Set a custom avatar.">
<i class="fa-fw fa-solid fa-image"></i>
</div>
<div class="userAvatarRemove right_menu_button" title="Remove a custom avatar.">
<i class="fa-fw fa-solid fa-trash"></i>
</div>
</div>
<form>
<input type="file" class="avatarUpload" accept="image/*" hidden>
</form>
</div>
<div class="flex1 flex-container flexFlowColumn flexNoGap justifyLeft">
<div class="flex-container flexGap10 alignItemsCenter">

View File

@ -15,10 +15,21 @@
Account Info
</h3>
<div class="flex-container flexGap10">
<div>
<div class="avatar" title="To change your user avatar, select a default persona in the Persona Management menu.">
<div class="flex-container flexFlowColumn alignItemsCenter flexNoGap">
<div class="avatar" title="To change your user avatar, use the buttons below or select a default persona in the Persona Management menu.">
<img src="img/ai4.png" alt="avatar">
</div>
<div class="flex-container alignItemsCenter">
<div class="userAvatarChange right_menu_button" title="Set your custom avatar.">
<i class="fa-fw fa-solid fa-image"></i>
</div>
<div class="userAvatarRemove right_menu_button" title="Remove your custom avatar.">
<i class="fa-fw fa-solid fa-trash"></i>
</div>
</div>
<form>
<input type="file" class="avatarUpload" accept="image/*" hidden>
</form>
</div>
<div class="flex1 flex-container flexGap10">
<div class="flex-container flexFlowColumn">

View File

@ -1,7 +1,7 @@
import { getRequestHeaders } from '../script.js';
import { callPopup, getCropPopup, getRequestHeaders } from '../script.js';
import { POPUP_RESULT, POPUP_TYPE, callGenericPopup } from './popup.js';
import { renderTemplateAsync } from './templates.js';
import { humanFileSize } from './utils.js';
import { ensureImageFormatSupported, getBase64Async, humanFileSize } from './utils.js';
/**
* @type {import('../../src/users.js').UserViewModel} Logged in user
@ -683,6 +683,26 @@ async function openUserProfile() {
});
template.find('.userResetSettingsButton').on('click', () => resetSettings(currentUser.handle, () => location.reload()));
template.find('.userResetAllButton').on('click', () => resetEverything(() => location.reload()));
template.find('.userAvatarChange').on('click', () => template.find('.avatarUpload').trigger('click'));
template.find('.avatarUpload').on('change', async function () {
if (!(this instanceof HTMLInputElement)) {
return;
}
const file = this.files[0];
if (!file) {
return;
}
await cropAndUploadAvatar(currentUser.handle, file);
await getCurrentUser();
template.find('.avatar img').attr('src', currentUser.avatar);
});
template.find('.userAvatarRemove').on('click', async function () {
await changeAvatar(currentUser.handle, '');
await getCurrentUser();
template.find('.avatar img').attr('src', currentUser.avatar);
});
if (!accountsEnabled) {
template.find('[data-require-accounts]').hide();
@ -699,6 +719,48 @@ async function openUserProfile() {
callGenericPopup(template, POPUP_TYPE.TEXT, '', popupOptions);
}
/**
* Crop and upload an avatar image.
* @param {string} handle User handle
* @param {File} file Avatar file
* @returns {Promise<string>}
*/
async function cropAndUploadAvatar(handle, file) {
const dataUrl = await getBase64Async(await ensureImageFormatSupported(file));
const croppedImage = await callPopup(getCropPopup(dataUrl), 'avatarToCrop', '', { cropAspect: 1 });
if (!croppedImage) {
return;
}
await changeAvatar(handle, String(croppedImage));
return croppedImage;
}
/**
* Change the avatar of the user.
* @param {string} handle User handle
* @param {string} avatar File to upload or base64 string
* @returns {Promise<void>} Avatar URL
*/
async function changeAvatar(handle, avatar) {
try {
const response = await fetch('/api/users/change-avatar', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ avatar, handle }),
});
if (!response.ok) {
const data = await response.json();
toastr.error(data.error || 'Unknown error', 'Failed to change avatar');
return;
}
} catch (error) {
console.error('Error changing avatar:', error);
}
}
async function openAdminPanel() {
async function renderUsers() {
const users = await getUsers();
@ -724,6 +786,24 @@ async function openAdminPanel() {
$(this).addClass('disabled').off('click');
backupUserData(user.handle, renderUsers);
});
userBlock.find('.userAvatarChange').on('click', () => userBlock.find('.avatarUpload').trigger('click'));
userBlock.find('.avatarUpload').on('change', async function () {
if (!(this instanceof HTMLInputElement)) {
return;
}
const file = this.files[0];
if (!file) {
return;
}
await cropAndUploadAvatar(user.handle, file);
renderUsers();
});
userBlock.find('.userAvatarRemove').on('click', async function () {
await changeAvatar(user.handle, '');
renderUsers();
});
template.find('.usersList').append(userBlock);
}
}

View File

@ -19,23 +19,29 @@ const { DEFAULT_USER } = require('../constants');
const router = express.Router();
router.post('/get', requireAdminMiddleware, jsonParser, async (request, response) => {
router.post('/get', requireAdminMiddleware, jsonParser, async (_request, response) => {
try {
/** @type {import('../users').User[]} */
const users = await storage.values(x => x.key.startsWith(KEY_PREFIX));
const viewModels = users
.sort((x, y) => x.created - y.created)
.map(user => ({
handle: user.handle,
name: user.name,
avatar: getUserAvatar(user.handle),
admin: user.admin,
enabled: user.enabled,
created: user.created,
password: !!user.password,
/** @type {Promise<import('../users').UserViewModel>[]} */
const viewModelPromises = users
.map(user => new Promise(resolve => {
getUserAvatar(user.handle).then(avatar =>
resolve({
handle: user.handle,
name: user.name,
avatar: avatar,
admin: user.admin,
enabled: user.enabled,
created: user.created,
password: !!user.password,
}),
);
}));
const viewModels = await Promise.all(viewModelPromises);
viewModels.sort((x, y) => (x.created ?? 0) - (y.created ?? 0));
return response.json(viewModels);
} catch (error) {
console.error('User list failed:', error);

View File

@ -4,7 +4,7 @@ const storage = require('node-persist');
const express = require('express');
const crypto = require('crypto');
const { jsonParser } = require('../express-common');
const { getUserAvatar, toKey, getPasswordHash, getPasswordSalt, createBackupArchive, ensurePublicDirectoriesExist } = require('../users');
const { getUserAvatar, toKey, getPasswordHash, getPasswordSalt, createBackupArchive, ensurePublicDirectoriesExist, toAvatarKey } = require('../users');
const { SETTINGS_FILE } = require('../constants');
const contentManager = require('./content-manager');
const { color, Cache } = require('../util');
@ -39,7 +39,7 @@ router.get('/me', async (request, response) => {
const viewModel = {
handle: user.handle,
name: user.name,
avatar: getUserAvatar(user.handle),
avatar: await getUserAvatar(user.handle),
admin: user.admin,
password: !!user.password,
created: user.created,
@ -52,6 +52,41 @@ router.get('/me', async (request, response) => {
}
});
router.post('/change-avatar', jsonParser, async (request, response) => {
try {
if (!request.body.handle) {
console.log('Change avatar failed: Missing required fields');
return response.status(400).json({ error: 'Missing required fields' });
}
if (request.body.handle !== request.user.profile.handle && !request.user.profile.admin) {
console.log('Change avatar failed: Unauthorized');
return response.status(403).json({ error: 'Unauthorized' });
}
// Avatar is not a data URL or not an empty string
if (!request.body.avatar.startsWith('data:image/') && request.body.avatar !== '') {
console.log('Change avatar failed: Invalid data URL');
return response.status(400).json({ error: 'Invalid data URL' });
}
/** @type {import('../users').User} */
const user = await storage.getItem(toKey(request.body.handle));
if (!user) {
console.log('Change avatar failed: User not found');
return response.status(404).json({ error: 'User not found' });
}
await storage.setItem(toAvatarKey(request.body.handle), request.body.avatar);
return response.sendStatus(204);
} catch (error) {
console.error(error);
return response.sendStatus(500);
}
});
router.post('/change-password', jsonParser, async (request, response) => {
try {
if (!request.body.handle) {
@ -185,7 +220,7 @@ router.post('/reset-step1', jsonParser, async (request, response) => {
});
router.post('/reset-step2', jsonParser, async (request, response) => {
try{
try {
if (!request.body.code) {
console.log('Recover step 2 failed: Missing required fields');
return response.status(400).json({ error: 'Missing required fields' });

View File

@ -27,16 +27,24 @@ router.post('/list', async (_request, response) => {
/** @type {import('../users').User[]} */
const users = await storage.values(x => x.key.startsWith(KEY_PREFIX));
const viewModels = users
/** @type {Promise<import('../users').UserViewModel>[]} */
const viewModelPromises = users
.filter(x => x.enabled)
.sort((x, y) => x.created - y.created)
.map(user => ({
handle: user.handle,
name: user.name,
avatar: getUserAvatar(user.handle),
password: !!user.password,
.map(user => new Promise(async (resolve) => {
getUserAvatar(user.handle).then(avatar =>
resolve({
handle: user.handle,
name: user.name,
created: user.created,
avatar: avatar,
password: !!user.password,
}),
);
}));
const viewModels = await Promise.all(viewModelPromises);
viewModels.sort((x, y) => (x.created ?? 0) - (y.created ?? 0));
return response.json(viewModels);
} catch (error) {
console.error('User list failed:', error);

View File

@ -15,6 +15,7 @@ const { getConfigValue, color, delay, setConfigValue, generateTimestamp } = requ
const { readSecret, writeSecret } = require('./endpoints/secrets');
const KEY_PREFIX = 'user:';
const AVATAR_PREFIX = 'avatar:';
const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false);
const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64');
@ -51,7 +52,7 @@ const STORAGE_KEYS = {
* @property {string} handle - The user's short handle. Used for directories and other references
* @property {string} name - The user's name. Displayed in the UI
* @property {string} avatar - The user's avatar image
* @property {boolean} admin - Whether the user is an admin (can manage other users)
* @property {boolean} [admin] - Whether the user is an admin (can manage other users)
* @property {boolean} password - Whether the user is password protected
* @property {boolean} [enabled] - Whether the user is enabled
* @property {number} [created] - The timestamp when the user was created
@ -315,6 +316,15 @@ function toKey(handle) {
return `${KEY_PREFIX}${handle}`;
}
/**
* Converts a user handle to a storage key for avatars.
* @param {string} handle User handle
* @returns {string} The key for the avatar storage
*/
function toAvatarKey(handle) {
return `${AVATAR_PREFIX}${handle}`;
}
/**
* Initializes the user storage. Currently a no-op.
* @param {string} dataRoot The root directory for user data
@ -435,10 +445,19 @@ function getUserDirectories(handle) {
/**
* Gets the avatar URL for the provided user.
* @param {string} handle User handle
* @returns {string} User avatar URL
* @returns {Promise<string>} User avatar URL
*/
function getUserAvatar(handle) {
async function getUserAvatar(handle) {
try {
// Check if the user has a custom avatar
const avatarKey = toAvatarKey(handle);
const avatar = await storage.getItem(avatarKey);
if (avatar) {
return avatar;
}
// Fallback to reading from files if custom avatar is not set
const directory = getUserDirectories(handle);
const pathToSettings = path.join(directory.root, SETTINGS_FILE);
const settings = fs.existsSync(pathToSettings) ? JSON.parse(fs.readFileSync(pathToSettings, 'utf8')) : {};
@ -665,6 +684,7 @@ router.use('/scripts/extensions/third-party/*', createRouteHandler(req => req.us
module.exports = {
KEY_PREFIX,
toKey,
toAvatarKey,
initUserStorage,
ensurePublicDirectoriesExist,
getAllUserHandles,