Add settings and profile editor
All checks were successful
Android Build / publish (push) Successful in 50s
Linux Build / publish (push) Successful in 52s

This commit is contained in:
olcxja 2026-06-23 23:41:27 +02:00
commit cc41f25277
14 changed files with 1210 additions and 106 deletions

View file

@ -33,6 +33,9 @@
"username.conditions.allowed": "cat name can only include {all}",
"password.not.hashed.properly": "meow word is not hashed properly",
"invalid.username.or.password": "invalid cat name or meow word",
"error.account.not.exist": "Meowww???",
"error.storage.limit.exceeded": "Meow too big!",
"error.username.taken": "Meeeeeow",
"account.not.exist": "cat with this name doesn't exist",
"invalid.nonce": "invalid nonce. try again",
"username.changed": "cat name changed successfully",
@ -150,5 +153,25 @@
"action.invite.send": "invite to meowchat",
"action.profile.loading": "sniffing profile...",
"error.profile.load.failed": "failed to sniff profile.",
"unknown.author": "somecat"
"unknown.author": "somecat",
"title.account": "Cat",
"title.delete.account": "Delete cat",
"desc.delete.account": "Once you delete your cat, it will go to cat heaven. Please be certain :c",
"title.avatar": "Cat picture",
"title.banner": "Cat banner",
"title.bio": "Cat bio",
"title.appearance": "Appearance",
"title.language": "Meownguage",
"action.save.profile": "Save cat profile",
"action.save.bio": "Save meow-io",
"action.uploading": "Uploading...",
"action.bio.saving": "Saving meow-io...",
"action.account.saving": "Saving cat...",
"action.account.deleting": "Deleting cat...",
"success:profile.updated": "Cat profile updated successfully :3",
"success:avatar.updated": "Cat picture updated successfully :3",
"success:banner.updated": "Cat banner updated successfully :3",
"action.save": "Save :3",
"action.delete.account": "Delete cat",
"account.deleted.scheduled": "Cat scheduled for deletion. It will go to cat heaven after a month of inactivity. Pawing in again will cancel the deletion process :3"
}

View file

@ -33,6 +33,9 @@
"username.conditions.allowed": "Username can only include {all}",
"password.not.hashed.properly": "Password is not hashed properly",
"invalid.username.or.password": "Invalid username or password",
"error.account.not.exist": "Account Does Not Exist",
"error.storage.limit.exceeded": "Storage Limit Exceeded",
"error.username.taken": "Username is Taken",
"account.not.exist": "Account with this name doesn't exist",
"invalid.nonce": "Invalid nonce. Try again",
"username.changed": "Username changed successfully",
@ -143,6 +146,7 @@
"title.profile": "Profile",
"title.settings": "Settings",
"title.logout": "Log out",
"action.edit.profile": "Edit profile",
"action.open.dm": "Open DM",
"action.invite.accept": "Accept invite",
@ -150,5 +154,25 @@
"action.invite.send": "Invite to DM",
"action.profile.loading": "Loading profile...",
"error.profile.load.failed": "Failed to load profile.",
"unknown.author": "Someone"
"unknown.author": "Someone",
"title.account": "Account",
"title.delete.account": "Delete account",
"desc.delete.account": "Once you delete your account, there is no going back. Please be certain.",
"title.avatar": "Avatar",
"title.banner": "Banner",
"title.bio": "Bio",
"title.appearance": "Appearance",
"title.language": "Language",
"action.save.profile": "Save profile",
"action.save.bio": "Save bio",
"action.uploading": "Uploading...",
"action.bio.saving": "Saving bio...",
"action.account.saving": "Saving account...",
"action.account.deleting": "Deleting account...",
"success:profile.updated": "Profile updated successfully",
"success:avatar.updated": "Avatar updated successfully",
"success:banner.updated": "Banner updated successfully",
"action.save": "Save",
"action.delete.account": "Delete account",
"account.deleted.scheduled": "Account scheduled for deletion. It will be permanently deleted after a month of inactivity. Logging in during this time will cancel the deletion process."
}

View file

@ -0,0 +1,4 @@
{
"en-us": { "english": "English (US)", "native": "English (US)" },
"en-cat": { "english": "English (Cat)", "native": "Meowlish" }
}

View file

@ -7,6 +7,10 @@
<link rel="stylesheet" href="style.css">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<script src="crypto-pq.js"></script>
<script src="https://cdn.jsdelivr.net/npm/eruda"></script> <!-- debug -->
<script>eruda.init();</script>
</head>
<body>
<loading>
@ -89,6 +93,24 @@
<div id="profile-popup-inner"></div>
</div>
</div>
<div id="cropper-popup-container" class="profile-popup-container" style="z-index: 9999;">
<div class="profile-popup-bg" onclick="closeCropper()"></div>
<div class="profile-popup-content" style="max-width: 40rem; padding: 2rem; display: flex; flex-direction: column; align-items: center; gap: 1rem; height: auto;">
<h2>Crop Image</h2>
<div id="cropper-viewport-container" style="position: relative; overflow: hidden; background: #000; width: 100%; max-width: 30rem; border-radius: 0.8rem; border: var(--border-width) solid rgba(255,255,255,0.2);">
<img id="cropper-image" src="" style="position: absolute; transform-origin: 0 0; cursor: move;">
</div>
<div style="display: flex; align-items: center; gap: 1rem; width: 100%; max-width: 30rem;">
<span>Zoom</span>
<input type="range" id="cropper-zoom" min="0.1" max="5" step="0.01" value="1" style="flex: 1;" oninput="updateCropperZoom()">
</div>
<div style="display: flex; gap: 1rem; margin-top: 1rem; width: 100%; max-width: 30rem;">
<button class="submit-button" style="background: rgba(255,255,255,0.1); color: var(--text-color); flex: 1;" onclick="closeCropper()">Cancel</button>
<button class="submit-button" style="flex: 1;" onclick="confirmCropper()">Save</button>
</div>
</div>
</div>
</body>
</html>
<script src="screens.js"></script>

View file

@ -131,7 +131,12 @@ function base64ToUint8(base64) {
}
function uint8ToBase64(uint8) {
return fixBase64Padding(btoa(String.fromCharCode(...uint8)));
let binary = '';
const chunkSize = 8192;
for (let i = 0; i < uint8.length; i += chunkSize) {
binary += String.fromCharCode.apply(null, uint8.subarray(i, i + chunkSize));
}
return fixBase64Padding(btoa(binary));
}
function concatUint8(a, b) {
@ -441,7 +446,7 @@ async function encryptString(plainText, passphrase) {
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
return fixBase64Padding(btoa(String.fromCharCode(...combined)));
return uint8ToBase64(combined);
}
async function decryptString(base64Text, passphrase) {
@ -601,6 +606,7 @@ async function fetchEncrypted(request, body = "") {
}
});
let data = await response.text();
if (data.startsWith("error:")) return data;
return await decryptString(data, passwordHash);
} finally {
release();
@ -780,6 +786,7 @@ function createAvatarSvg(name, size = 512) {
async function getAvatarUrl(id, username) {
try {
let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`;
if (id === window.id && window.profileUpdateTimestamp) pfpUrl += `&t=${window.profileUpdateTimestamp}`;
if ((await fetchAsync(pfpUrl)) == "") {
throw Error();
}
@ -839,7 +846,6 @@ async function initBlahs() {
res = await fetchAsync(`${path}blah/en-us.json`);
blah = JSON.parse(res);
}
let blahTags = document.getElementsByTagName("blah");
for (let i = 0; i < blahTags.length; i++) {
@ -1056,15 +1062,15 @@ async function ensureUserKeys() {
}
async function mainJS() {
if (localStorage.getItem('lang') != null) {
lang = localStorage.getItem('lang');
}
await initBlahs();
await loadEmotes();
passwordHash = await hashSHA3_512(password);
if (localStorage.getItem('lang') != null) {
lang = localStorage.getItem('lang');
}
if (host) {
await updateProtocolAndUrl(host);
} else {
@ -1488,6 +1494,7 @@ async function openProfile(accountId) {
let pfpUrl = await getAvatarUrl(accountId, nameWithHost);
let bannerUrl = `${url}/user/storage/public/getentry?id=${accountId}&e=larp.profile.banner`;
if (accountId === window.id && window.profileUpdateTimestamp) bannerUrl += `&t=${window.profileUpdateTimestamp}`;
let bannerHtml = `<img src="${bannerUrl}" class="profile-banner" onerror="this.outerHTML='<div class=\\'profile-banner\\'></div>'">`;
let bio = await fetchAsync(`${url}/user/storage/public/getentry?id=${accountId}&e=larp.profile.bio`);
@ -1498,7 +1505,7 @@ async function openProfile(accountId) {
let isOwnProfile = accountId === id;
let actionBtnHtml = "";
if (isOwnProfile) {
actionBtnHtml = `<button class="profile-action-btn">${processBlah('action.edit.profile')}</button>`;
actionBtnHtml = `<button class="profile-action-btn" onclick="gotoSettings('profile');closeProfile();">${processBlah('action.edit.profile')}</button>`;
} else {
let hasDm = false;
let dmRoomId = "";
@ -1920,7 +1927,7 @@ document.addEventListener('touchstart', e => {
activeTouchButton.classList.add('is-pressed');
pressDate = Date.now();
}
}, {passive: false});
}, {passive: true});
document.addEventListener('touchmove', e => {
if (!touchMoved) {
@ -1940,7 +1947,7 @@ document.addEventListener('touchmove', e => {
}
}
}
}, {passive: false});
}, {passive: true});
document.addEventListener('touchend', async (e) => {
touchEndX = e.changedTouches[0].screenX;
@ -1952,14 +1959,6 @@ document.addEventListener('touchend', async (e) => {
if (activeTouchButton) {
let elapsed = Date.now() - pressDate; //nie pomoglo ale zostawie
if (elapsed < 100) {
const remaining = 100 - elapsed;
await delay(remaining);
}
activeTouchButton.classList.remove('is-pressed');
if (!touchMoved) {
if (activeTouchButton.tagName !== 'INPUT' && activeTouchButton.tagName !== 'TEXTAREA') {
if (document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) {
@ -1974,6 +1973,14 @@ document.addEventListener('touchend', async (e) => {
activeTouchButton.focus();
}
}
let elapsed = Date.now() - pressDate; //nie pomoglo ale zostawie
if (elapsed < 100) {
const remaining = 100 - elapsed;
await delay(remaining);
}
activeTouchButton.classList.remove('is-pressed');
activeTouchButton = null;
}
});
@ -2450,7 +2457,7 @@ function setupChatScrollListener() {
let replyingToMsgId = null;
async function sendMessage(sendbutton) {
async function sendMessage(sendbutton = document.getElementById("chat-sendmessage")) {
if (!currentDmId || !currentDmKey) return;
let input = document.getElementById("chat-input");
@ -2843,4 +2850,331 @@ async function reactMessagePrompt(msgId, quickReaction = null) {
showBlahNotification("error:message.react.failed");
}
clearAction("msgreact");
}
// --- Settings Logic ---
async function gotoSettings(tab = 'account') {
fixedContextMenu.classList.remove("show");
if (roomsBarContainer) roomsBarContainer.style.display = "";
switchRoomsBar("title.settings", settingsBar);
switchSettingsTab(tab);
}
function switchSettingsTab(tabName) {
setActiveRoombarItem('tab-settings-' + tabName);
if (tabName === 'account') {
switchRoomContent("title.account", accountSettings, false);
} else if (tabName === 'profile') {
switchRoomContent("title.profile", profileSettings, false);
setTimeout(async () => {
let pfpUrl = await getAvatarUrl(window.id, window.username + ":" + window.host);
let pfpEl = document.getElementById("settings-avatar-preview");
if (pfpEl) pfpEl.src = pfpUrl;
let bannerUrl = `${url}/user/storage/public/getentry?id=${window.id}&e=larp.profile.banner`;
if (window.profileUpdateTimestamp) bannerUrl += `&t=${window.profileUpdateTimestamp}`;
let bannerEl = document.getElementById("settings-banner-preview");
if (bannerEl) bannerEl.src = bannerUrl;
let bioEl = document.getElementById("settings-bio");
if (bioEl) {
let bio = await fetchAsync(`${url}/user/storage/public/getentry?id=${window.id}&e=larp.profile.bio`);
if (!bio.startsWith("error:")) bioEl.value = bio;
}
}, 210);
} else if (tabName === 'appearance') {
switchRoomContent("title.appearance", appearanceSettings, false);
setTimeout(populateLanguageDropdown, 210);
}
}
async function populateLanguageDropdown() {
let sel = document.getElementById("settings-language");
if (!sel) return;
try {
let path = window.location.pathname.replace("/login/index.html", "/").replace("index.html", "");
let res = await fetchAsync(`${path}blah/index.json`);
let json = JSON.parse(res);
sel.innerHTML = "";
for (let code in json) {
let opt = document.createElement("option");
opt.value = code;
opt.textContent = json[code].native + " (" + json[code].english + ")";
if (code === lang) opt.selected = true;
sel.appendChild(opt);
}
} catch (e) {
console.error("Failed to load languages", e);
}
}
function changeLanguage(newLang) {
if (!newLang) return;
localStorage.setItem('lang', newLang);
window.location.reload();
}
async function changeUsername() {
let input = document.getElementById("settings-username");
let name = input.value;
if (!name) return;
showAction("action.account.saving", "accountsave");
let res = await fetchPostEnc(`${url}/chname?id=${window.id}`, name);
clearAction("accountsave");
let text = res;
if (text.startsWith("success:")) {
showBlahNotification(text);
loginUsername = name;
localStorage.setItem("username", name);
} else {
showBlahNotification(text);
}
}
async function changePassword() {
let input = document.getElementById("settings-password");
let newPass = input.value;
if (!newPass) return;
showAction("action.account.saving", "accountsave");
let newPassHash = await hashSHA3_512(newPass);
let newUserKeysEncrypted = await encryptJsonWithPassword(userKeysPrivate, newPass);
let payload = JSON.stringify({
string1: newPassHash,
string2: JSON.stringify({
string1: newUserKeysEncrypted,
string2: JSON.stringify(userKeysPublic)
})
});
let res = await fetchPostEnc(`${url}/chpass?id=${window.id}`, payload);
clearAction("accountsave");
let text = res;
if (text.startsWith("success:")) {
password = newPass;
localStorage.setItem("password", newPass);
passwordHash = newPassHash;
userKeysEncrypted = newUserKeysEncrypted;
localStorage.setItem(`userKeys.enc.v1:${window.id}`, userKeysEncrypted);
showBlahNotification(text);
} else {
showBlahNotification(text);
}
}
async function deleteAccount() {
if (!confirm(processBlah("desc.delete.account") + "\n\nAre you sure?")) return;
showAction("action.account.deleting", "accountdel");
let res = await fetchPostEnc(`${url}/deleteaccount?id=${window.id}`, "");
clearAction("accountdel");
let text = res;
if (text.startsWith("success:")) {
alert(processBlah(text));
logout();
} else {
showBlahNotification(text);
}
}
async function saveProfile() {
let bioInput = document.getElementById("settings-bio");
if (bioInput) {
let payload = { string1: "larp.profile.bio", string2: bioInput.value || "" };
showAction("action.bio.saving", "savebio");
let res = await fetchEncrypted("user/storage/public/update", JSON.stringify(payload));
clearAction("savebio");
if (res && res.startsWith("error:")) { showBlahNotification(res); return; }
}
showBlahNotification("success:profile.updated");
}
// --- Cropper Logic ---
let cropperType = 'avatar'; // 'avatar' or 'banner'
let cropperImageObj = null;
let cropperPosX = 0;
let cropperPosY = 0;
let cropperBaseZoom = 1;
let cropperOriginalType = '';
let cropperOriginalB64 = '';
let isDraggingCropper = false;
let startDragX = 0;
let startDragY = 0;
let startPosX = 0;
let startPosY = 0;
function openCropper(input, type) {
if (!input.files || input.files.length === 0) return;
let file = input.files[0];
cropperType = type;
let reader = new FileReader();
reader.onload = (e) => {
let img = new Image();
img.onload = () => {
cropperImageObj = img;
initCropper();
};
cropperOriginalType = file.type;
cropperOriginalB64 = e.target.result;
img.src = e.target.result;
document.getElementById('cropper-image').src = e.target.result;
};
reader.readAsDataURL(file);
input.value = ""; // reset input
}
function initCropper() {
let container = document.getElementById('cropper-viewport-container');
let imgEl = document.getElementById('cropper-image');
let zoomSlider = document.getElementById('cropper-zoom');
// Set aspect ratio
let targetWidth = cropperType === 'avatar' ? 512 : 494;
let targetHeight = cropperType === 'avatar' ? targetWidth : 200;
let containerWidth = container.clientWidth || 300;
container.style.height = (containerWidth * (targetHeight / targetWidth)) + 'px';
let imgWidth = cropperImageObj.width;
let imgHeight = cropperImageObj.height;
let scaleX = containerWidth / imgWidth;
let scaleY = (containerWidth * (targetHeight / targetWidth)) / imgHeight;
cropperBaseZoom = Math.max(scaleX, scaleY);
zoomSlider.min = cropperBaseZoom;
zoomSlider.max = cropperBaseZoom * 5;
zoomSlider.value = cropperBaseZoom;
cropperPosX = 0;
cropperPosY = 0;
updateCropperTransform();
document.getElementById('cropper-popup-container').classList.add('show');
}
function updateCropperZoom() {
updateCropperTransform();
}
function updateCropperTransform() {
let zoom = parseFloat(document.getElementById('cropper-zoom').value);
let imgEl = document.getElementById('cropper-image');
let container = document.getElementById('cropper-viewport-container');
if (cropperImageObj) {
let scaledW = cropperImageObj.width * zoom;
let scaledH = cropperImageObj.height * zoom;
let minX = container.clientWidth - scaledW;
let maxX = 0;
let minY = container.clientHeight - scaledH;
let maxY = 0;
if (minX > maxX) minX = maxX;
if (minY > maxY) minY = maxY;
if (cropperPosX < minX) cropperPosX = minX;
if (cropperPosX > maxX) cropperPosX = maxX;
if (cropperPosY < minY) cropperPosY = minY;
if (cropperPosY > maxY) cropperPosY = maxY;
}
imgEl.style.transform = `translate(${cropperPosX}px, ${cropperPosY}px) scale(${zoom})`;
}
function closeCropper() {
document.getElementById('cropper-popup-container').classList.remove('show');
}
async function confirmCropper() {
let zoom = parseFloat(document.getElementById('cropper-zoom').value);
let container = document.getElementById('cropper-viewport-container');
let b64;
let newSrc;
if (cropperOriginalType === 'image/gif') {
b64 = cropperOriginalB64.split(',')[1];
newSrc = cropperOriginalB64;
} else {
let canvas = document.createElement('canvas');
let ctx = canvas.getContext('2d');
let targetWidth = cropperType === 'avatar' ? 512 : 988;
let targetHeight = cropperType === 'avatar' ? 512 : 400;
canvas.width = targetWidth;
canvas.height = targetHeight;
let scaleRatio = targetWidth / container.clientWidth;
let drawX = cropperPosX * scaleRatio;
let drawY = cropperPosY * scaleRatio;
let drawW = cropperImageObj.width * zoom * scaleRatio;
let drawH = cropperImageObj.height * zoom * scaleRatio;
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, targetWidth, targetHeight);
ctx.drawImage(cropperImageObj, drawX, drawY, drawW, drawH);
b64 = canvas.toDataURL('image/jpeg', 0.9).split(',')[1];
newSrc = 'data:image/jpeg;base64,' + b64;
}
let payloadKey = cropperType === 'avatar' ? 'larp.profile.pfp' : 'larp.profile.banner';
let payload = { string1: payloadKey, string2: b64 };
showAction("action.uploading", "uploadmedia");
let res = await fetchEncrypted("user/storage/public/update", JSON.stringify(payload));
clearAction("uploadmedia");
if (res && res.startsWith("error:")) {
showBlahNotification(res);
} else {
showBlahNotification(`success:${cropperType}.updated`);
window.profileUpdateTimestamp = Date.now();
// Update all matching images on the page instantly
document.querySelectorAll('img').forEach(img => {
if (img.src.includes(`id=${window.id}&e=${payloadKey}`) || img.id === `settings-${cropperType}-preview` || (cropperType === 'avatar' && (img.id === 'my-avatar-img' || img.id === 'sidebar-pfp'))) {
img.src = newSrc;
}
});
}
closeCropper();
}
let container = document.getElementById('cropper-viewport-container');
if (container) {
const startDrag = (e) => {
isDraggingCropper = true;
startDragX = e.clientX || (e.touches && e.touches[0].clientX);
startDragY = e.clientY || (e.touches && e.touches[0].clientY);
startPosX = cropperPosX;
startPosY = cropperPosY;
if (e.type !== "touchstart") e.preventDefault();
};
const onDrag = (e) => {
if (!isDraggingCropper) return;
let clientX = e.clientX || (e.touches && e.touches[0].clientX);
let clientY = e.clientY || (e.touches && e.touches[0].clientY);
cropperPosX = startPosX + (clientX - startDragX);
cropperPosY = startPosY + (clientY - startDragY);
updateCropperTransform();
};
const endDrag = () => {
isDraggingCropper = false;
};
container.addEventListener('mousedown', startDrag);
container.addEventListener('touchstart', startDrag, {passive: true});
document.addEventListener('mousemove', onDrag);
document.addEventListener('touchmove', onDrag, {passive: true});
document.addEventListener('mouseup', endDrag);
document.addEventListener('touchend', endDrag);
}

View file

@ -143,7 +143,7 @@ var chatScreen = `
</div>
<div style="padding: 1rem; border-top: var(--border-width) solid var(--light-border-color); display: flex; gap: 0.5rem;">
<textarea id="chat-input" class="forminput" style="flex-grow: 1; margin: 0; resize: none; min-height: 2.5rem; max-height: 8rem; padding: 0.5rem;" placeholder="{blah(placeholder.message.input)}" onkeydown="handleChatInputKey(event)" oninput="handleChatInputResize(this)"></textarea>
<button class="submit-button" onclick="sendMessage(this)" style="margin: 0; padding: 0; width: 2.5rem; min-height: 2.5rem; max-height: 8rem;">
<button id="chat-sendmessage" class="submit-button" onclick="sendMessage(this)" style="margin: 0; padding: 0; width: 2.5rem; min-height: 2.5rem; max-height: 8rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="1.5rem" height="1.5rem" viewBox="0 0 16 16" fill="none">
<path stroke="var(--main-bg-color)" stroke-linecap="round" stroke-linejoin="round" d="M 4.65 8 l -1.875 5.625 l 11.25 -5.625 L 2.775 2.375 l 1.875 5.625 z m 0 0 h 3.75" stroke-width="1.1"></path>
</svg>
@ -259,3 +259,103 @@ var messageContextMenu = `
var detailsBtn = `<button class="mobile-nav-btn right" onclick="mobileNavDetails()"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M120-240v-80h720v80H120Zm0-200v-80h720v80H120Zm0-200v-80h720v80H120Z"/></svg></button>`;
var backBtnHtml = `<button class="mobile-nav-btn" onclick="mobileNavBack()"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="m313-440 224 224-57 56-320-320 320-320 57 56-224 224h487v80H313Z"/></svg></button>`;
var settingsBar = `
<div class="sidebar-section-header">
<button class="collapse-text-button" id="tab-settings-account" onclick="switchSettingsTab('account')">
<svg xmlns="http://www.w3.org/2000/svg" class="chevron" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M200-200h560v-560H200v560Zm0 80q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm40-160h480L570-480 450-320l-90-120-120 160Zm-40 80v-560 560Z"/></svg>
<span><blah>title.account</blah></span>
</button>
</div>
<div class="sidebar-section-header">
<button class="collapse-text-button" id="tab-settings-profile" onclick="switchSettingsTab('profile')">
<svg xmlns="http://www.w3.org/2000/svg" class="chevron" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M200-200h560v-560H200v560Zm0 80q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm40-160h480L570-480 450-320l-90-120-120 160Zm-40 80v-560 560Z"/></svg>
<span><blah>title.profile</blah></span>
</button>
</div>
<div class="sidebar-section-header">
<button class="collapse-text-button" id="tab-settings-appearance" onclick="switchSettingsTab('appearance')">
<svg xmlns="http://www.w3.org/2000/svg" class="chevron" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-156t86-127Q252-817 325-848.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 167-116.5 283.5T480-80Zm0-157q102 0 174-72t72-174q0-102-72-174t-174-72q-102 0-174 72t-72 174q0 102 72 174t174 72Zm0-80q-68 0-116.5-48.5T315-482q0-68 48.5-116.5T480-647q68 0 116.5 48.5T645-482q0 68-48.5 116.5T480-317Zm0-60q43 0 74-31t31-74q0-43-31-74t-74-31q-43 0-74 31t-31 74q0 43 31 74t74 31Zm0-105Z"/></svg>
<span><blah>title.appearance</blah></span>
</button>
</div>
`;
var accountSettings = `
<div style="display: flex;justify-content: center;align-items: center;height:100%;flex-direction: column;text-align: center;">
<svg xmlns="http://www.w3.org/2000/svg" width="3rem" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M480-480q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM160-160v-112q0-34 17.5-62.5T224-378q62-31 126-46.5T480-440q66 0 130 15.5T736-378q29 15 46.5 43.5T800-272v112H160Zm80-80h480v-32q0-11-5.5-20T700-306q-54-27-109-40.5T480-360q-56 0-111 13.5T260-306q-9 5-14.5 14t-5.5 20v32Zm240-320q33 0 56.5-23.5T560-640q0-33-23.5-56.5T480-720q-33 0-56.5 23.5T400-640q0 33 23.5 56.5T480-560Zm0-80Zm0 400Z"/></svg>
<herotitle><blah>title.account</blah></herotitle>
<br>
<div class="input-group">
<label for="settings-username"><blah>title.username</blah></label>
<input type="text" id="settings-username" class="forminput">
</div>
<div class="input-group">
<button class="submit-button blah" onclick="changeUsername()">action.save</button>
</div>
<div class="input-group">
<label for="settings-password"><blah>title.password</blah></label>
<input type="password" id="settings-password" class="forminput">
</div>
<div class="input-group">
<button class="submit-button blah" onclick="changePassword()">action.save</button>
</div>
<br>
<div class="input-group">
<label style="color: var(--big-red);"><blah>title.delete.account</blah></label>
<p style="font-size: 0.85rem; opacity: 0.7; margin-bottom: 0.5rem; margin-left: 0.6rem;"><blah>desc.delete.account</blah></p>
<button class="submit-button blah" onclick="deleteAccount()" style="background: var(--big-red); color: white;">action.delete.account</button>
</div>
</div>
`;
var profileSettings = `
<div style="display: flex;justify-content: center;align-items: center;height:100%;flex-direction: column;text-align: center;">
<svg xmlns="http://www.w3.org/2000/svg" width="3rem" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M200-200h560v-560H200v560Zm0 80q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm40-160h480L570-480 450-320l-90-120-120 160Zm-40 80v-560 560Z"/></svg>
<herotitle><blah>title.profile</blah></herotitle>
<br>
<div class="input-group" style="display: flex; flex-direction: row; align-items: stretch; justify-content: space-between; border: var(--border-width) solid var(--light-border-color); border-radius: 0.8rem; padding: 1rem; width: 100%; max-width: 30rem;">
<img id="settings-avatar-preview" src="" style="width: 4.5rem; height: 4.5rem; border-radius: 0.8rem; border: var(--border-width) solid rgba(255, 255, 255, 0.2); object-fit: cover; background: var(--main-bg-color);">
<div style="display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between; padding: 0.1rem 0;">
<div style="font-weight: bold;"><blah>title.avatar</blah></div>
<button class="submit-button" style="margin: 0; padding: 0.5rem 1rem; width: auto;" onclick="document.getElementById('settings-avatar-input').click()">Change Avatar</button>
<input type="file" id="settings-avatar-input" style="display: none;" accept="image/*" onchange="openCropper(this, 'avatar')">
</div>
</div>
<br>
<div class="input-group" style="display: flex; flex-direction: row; align-items: stretch; justify-content: space-between; border: var(--border-width) solid var(--light-border-color); border-radius: 0.8rem; padding: 1rem; width: 100%; max-width: 30rem;">
<div style="width: 12.4rem; aspect-ratio: 494 / 200; background: var(--main-bg-color); border-radius: 0.4rem; overflow: hidden; display: flex; align-items: center; justify-content: center; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">
<img id="settings-banner-preview" src="" style="width: 100%; height: 100%; object-fit: cover;">
</div>
<div style="display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between; padding: 0.1rem 0;">
<div style="font-weight: bold;"><blah>title.banner</blah></div>
<button class="submit-button" style="margin: 0; padding: 0.5rem 1rem; width: auto;" onclick="document.getElementById('settings-banner-input').click()">Change Banner</button>
<input type="file" id="settings-banner-input" style="display: none;" accept="image/*" onchange="openCropper(this, 'banner')">
</div>
</div>
<br>
<div class="input-group">
<label for="settings-bio"><blah>title.bio</blah></label>
<textarea id="settings-bio" class="forminput" style="min-height: 8rem; padding-top: 0.8rem;"></textarea>
</div>
<div class="input-group">
<button class="submit-button blah" onclick="saveProfile()">action.save.bio</button>
</div>
</div>
`;
var appearanceSettings = `
<div style="display: flex;justify-content: center;align-items: center;height:100%;flex-direction: column;text-align: center;">
<svg xmlns="http://www.w3.org/2000/svg" width="3rem" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-156t86-127Q252-817 325-848.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 167-116.5 283.5T480-80Zm0-157q102 0 174-72t72-174q0-102-72-174t-174-72q-102 0-174 72t-72 174q0 102 72 174t174 72Zm0-80q-68 0-116.5-48.5T315-482q0-68 48.5-116.5T480-647q68 0 116.5 48.5T645-482q0 68-48.5 116.5T480-317Zm0-60q43 0 74-31t31-74q0-43-31-74t-74-31q-43 0-74 31t-31 74q0 43 31 74t74 31Zm0-105Z"/></svg>
<herotitle><blah>title.appearance</blah></herotitle>
<br>
<div class="input-group">
<label for="settings-language"><blah>title.language</blah></label>
<select id="settings-language" onchange="changeLanguage(this.value)" style="border-radius: 0.8rem; background-color: rgba(255, 255, 255, 0.05); margin: var(--button-margin); padding: var(--button-margin); padding-left: calc(var(--button-margin) * 2); padding-right: 1.5rem; height: var(--button-height); display: flex; cursor: pointer; align-items: center; justify-content: flex-start; border: var(--border-width) solid var(--light-border-color); color: var(--text-color); font-family: inherit; font-size: inherit; appearance: none; outline: none;">
<option value="" class="blah">title.loading</option>
</select>
</div>
</div>
`;

View file

@ -110,6 +110,9 @@ input, textarea {
user-select: text !important;
-webkit-user-select: text !important;
}
textarea {
resize: none;
}
.chat-message-text, .chat-message-author, .chat-message-timestamp,
.profile-name, .profile-bio, .profile-id-host,
@ -810,15 +813,15 @@ sidebarelement:has(.icon-button:active) indicator:not(.active) {
}
.profile-popup-content {
position: relative;
width: 90vw;
max-width: 31rem;
width: 31em;
height: 85vh;
max-height: 44rem;
max-height: 44em;
font-size: min(1rem, calc(90vw / 31));
background: var(--main-bg-color);
border: var(--border-width) solid var(--light-border-color);
border-radius: 0.9rem;
border-radius: 0.9em;
overflow: hidden;
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.5);
box-shadow: 0 1em 3em rgba(0, 0, 0, 0.5);
opacity: 0;
transform: scale(0.6);
transition: all 0.2s ease;
@ -832,16 +835,16 @@ sidebarelement:has(.icon-button:active) indicator:not(.active) {
}
.profile-popup-close-btn {
position: absolute;
top: 1rem;
right: 1rem;
top: 1em;
right: 1em;
z-index: 10;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(0.25rem);
-webkit-backdrop-filter: blur(0.25rem);
backdrop-filter: blur(0.25em);
-webkit-backdrop-filter: blur(0.25em);
border: none;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.8rem;
width: 2.5em;
height: 2.5em;
border-radius: 0.8em;
display: flex;
justify-content: center;
align-items: center;
@ -850,8 +853,8 @@ sidebarelement:has(.icon-button:active) indicator:not(.active) {
cursor: pointer;
}
.profile-popup-close-btn svg {
width: 1.5rem;
height: 1.5rem;
width: 1.5em;
height: 1.5em;
}
.profile-popup-close-btn:hover {
background: rgba(255, 255, 255, 0.2);
@ -865,62 +868,104 @@ sidebarelement:has(.icon-button:active) indicator:not(.active) {
}
.profile-banner {
width: 100%;
height: 12.5rem;
aspect-ratio: 494 / 200;
background: linear-gradient(135deg, rgba(255,255,255,0.05), rgba(255,255,255,0.1));
object-fit: cover;
flex-shrink: 0;
}
.profile-avatar-container {
position: relative;
margin-top: -3.75rem;
margin-left: 2rem;
width: 7.5rem;
height: 7.5rem;
margin-top: -3.75em;
margin-left: 2em;
width: 7.5em;
height: 7.5em;
}
.profile-avatar {
width: 100%;
height: 100%;
border-radius: 0.8rem;
border-radius: 0.8em;
border: var(--border-width) solid rgba(255, 255, 255, 0.2);
object-fit: cover;
background: var(--main-bg-color);
}
.profile-info {
padding: 1rem 2rem 2rem 2rem;
padding: 1em 2em 2em 2em;
display: flex;
flex-direction: column;
gap: 1rem;
gap: 1em;
}
.profile-name {
font-size: 2rem;
font-size: 2em;
font-weight: 800;
line-height: 1.1;
margin-bottom: 0.2rem;
margin-bottom: 0.2em;
word-break: break-word;
}
.profile-id-host {
font-size: 1rem;
font-size: 1em;
opacity: 0.6;
font-weight: 600;
}
.profile-bio {
font-size: 1.05rem;
font-size: 1.05em;
line-height: 1.5;
white-space: pre-wrap;
background: rgba(255, 255, 255, 0.03);
padding: 1rem;
border-radius: 0.8rem;
padding: 1em;
border-radius: 0.8em;
border: var(--border-width) solid var(--light-border-color);
word-break: break-word;
}
.profile-action-btn {
align-self: flex-start;
padding: 0.6rem 1.5rem;
padding: 0.6em 1.5em;
font-weight: 700;
background: var(--text-color);
color: var(--main-bg-color);
border-radius: 0.8rem;
margin-top: 0.5rem;
border-radius: 0.8em;
margin-top: 0.5em;
margin-left: 0;
height: auto;
}
/* Custom Zoom Slider Styling */
#cropper-zoom {
-webkit-appearance: none;
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
outline: none;
margin: 10px 0;
}
#cropper-zoom::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--text-color);
cursor: pointer;
transition: transform 0.2s, background 0.2s;
}
#cropper-zoom::-webkit-slider-thumb:hover {
transform: scale(1.2);
background: #fff;
}
#cropper-zoom::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--text-color);
cursor: pointer;
transition: transform 0.2s, background 0.2s;
border: none;
}
#cropper-zoom::-moz-range-thumb:hover {
transform: scale(1.2);
background: #fff;
}

View file

@ -33,6 +33,9 @@
"username.conditions.allowed": "cat name can only include {all}",
"password.not.hashed.properly": "meow word is not hashed properly",
"invalid.username.or.password": "invalid cat name or meow word",
"error.account.not.exist": "Meowww???",
"error.storage.limit.exceeded": "Meow too big!",
"error.username.taken": "Meeeeeow",
"account.not.exist": "cat with this name doesn't exist",
"invalid.nonce": "invalid nonce. try again",
"username.changed": "cat name changed successfully",
@ -150,5 +153,25 @@
"action.invite.send": "invite to meowchat",
"action.profile.loading": "sniffing profile...",
"error.profile.load.failed": "failed to sniff profile.",
"unknown.author": "somecat"
"unknown.author": "somecat",
"title.account": "Cat",
"title.delete.account": "Delete cat",
"desc.delete.account": "Once you delete your cat, it will go to cat heaven. Please be certain :c",
"title.avatar": "Cat picture",
"title.banner": "Cat banner",
"title.bio": "Cat bio",
"title.appearance": "Appearance",
"title.language": "Meownguage",
"action.save.profile": "Save cat profile",
"action.save.bio": "Save meow-io",
"action.uploading": "Uploading...",
"action.bio.saving": "Saving meow-io...",
"action.account.saving": "Saving cat...",
"action.account.deleting": "Deleting cat...",
"success:profile.updated": "Cat profile updated successfully :3",
"success:avatar.updated": "Cat picture updated successfully :3",
"success:banner.updated": "Cat banner updated successfully :3",
"action.save": "Save :3",
"action.delete.account": "Delete cat",
"account.deleted.scheduled": "Cat scheduled for deletion. It will go to cat heaven after a month of inactivity. Pawing in again will cancel the deletion process :3"
}

View file

@ -33,6 +33,9 @@
"username.conditions.allowed": "Username can only include {all}",
"password.not.hashed.properly": "Password is not hashed properly",
"invalid.username.or.password": "Invalid username or password",
"error.account.not.exist": "Account Does Not Exist",
"error.storage.limit.exceeded": "Storage Limit Exceeded",
"error.username.taken": "Username is Taken",
"account.not.exist": "Account with this name doesn't exist",
"invalid.nonce": "Invalid nonce. Try again",
"username.changed": "Username changed successfully",
@ -143,6 +146,7 @@
"title.profile": "Profile",
"title.settings": "Settings",
"title.logout": "Log out",
"action.edit.profile": "Edit profile",
"action.open.dm": "Open DM",
"action.invite.accept": "Accept invite",
@ -150,5 +154,25 @@
"action.invite.send": "Invite to DM",
"action.profile.loading": "Loading profile...",
"error.profile.load.failed": "Failed to load profile.",
"unknown.author": "Someone"
"unknown.author": "Someone",
"title.account": "Account",
"title.delete.account": "Delete account",
"desc.delete.account": "Once you delete your account, there is no going back. Please be certain.",
"title.avatar": "Avatar",
"title.banner": "Banner",
"title.bio": "Bio",
"title.appearance": "Appearance",
"title.language": "Language",
"action.save.profile": "Save profile",
"action.save.bio": "Save bio",
"action.uploading": "Uploading...",
"action.bio.saving": "Saving bio...",
"action.account.saving": "Saving account...",
"action.account.deleting": "Deleting account...",
"success:profile.updated": "Profile updated successfully",
"success:avatar.updated": "Avatar updated successfully",
"success:banner.updated": "Banner updated successfully",
"action.save": "Save",
"action.delete.account": "Delete account",
"account.deleted.scheduled": "Account scheduled for deletion. It will be permanently deleted after a month of inactivity. Logging in during this time will cancel the deletion process."
}

4
webroot/blah/index.json Normal file
View file

@ -0,0 +1,4 @@
{
"en-us": { "english": "English (US)", "native": "English (US)" },
"en-cat": { "english": "English (Cat)", "native": "Meowlish" }
}

View file

@ -7,6 +7,10 @@
<link rel="stylesheet" href="style.css">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<script src="crypto-pq.js"></script>
<script src="https://cdn.jsdelivr.net/npm/eruda"></script> <!-- debug -->
<script>eruda.init();</script>
</head>
<body>
<loading>
@ -89,6 +93,24 @@
<div id="profile-popup-inner"></div>
</div>
</div>
<div id="cropper-popup-container" class="profile-popup-container" style="z-index: 9999;">
<div class="profile-popup-bg" onclick="closeCropper()"></div>
<div class="profile-popup-content" style="max-width: 40rem; padding: 2rem; display: flex; flex-direction: column; align-items: center; gap: 1rem; height: auto;">
<h2>Crop Image</h2>
<div id="cropper-viewport-container" style="position: relative; overflow: hidden; background: #000; width: 100%; max-width: 30rem; border-radius: 0.8rem; border: var(--border-width) solid rgba(255,255,255,0.2);">
<img id="cropper-image" src="" style="position: absolute; transform-origin: 0 0; cursor: move;">
</div>
<div style="display: flex; align-items: center; gap: 1rem; width: 100%; max-width: 30rem;">
<span>Zoom</span>
<input type="range" id="cropper-zoom" min="0.1" max="5" step="0.01" value="1" style="flex: 1;" oninput="updateCropperZoom()">
</div>
<div style="display: flex; gap: 1rem; margin-top: 1rem; width: 100%; max-width: 30rem;">
<button class="submit-button" style="background: rgba(255,255,255,0.1); color: var(--text-color); flex: 1;" onclick="closeCropper()">Cancel</button>
<button class="submit-button" style="flex: 1;" onclick="confirmCropper()">Save</button>
</div>
</div>
</div>
</body>
</html>
<script src="screens.js"></script>

View file

@ -131,7 +131,12 @@ function base64ToUint8(base64) {
}
function uint8ToBase64(uint8) {
return fixBase64Padding(btoa(String.fromCharCode(...uint8)));
let binary = '';
const chunkSize = 8192;
for (let i = 0; i < uint8.length; i += chunkSize) {
binary += String.fromCharCode.apply(null, uint8.subarray(i, i + chunkSize));
}
return fixBase64Padding(btoa(binary));
}
function concatUint8(a, b) {
@ -441,7 +446,7 @@ async function encryptString(plainText, passphrase) {
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
return fixBase64Padding(btoa(String.fromCharCode(...combined)));
return uint8ToBase64(combined);
}
async function decryptString(base64Text, passphrase) {
@ -601,6 +606,7 @@ async function fetchEncrypted(request, body = "") {
}
});
let data = await response.text();
if (data.startsWith("error:")) return data;
return await decryptString(data, passwordHash);
} finally {
release();
@ -780,6 +786,7 @@ function createAvatarSvg(name, size = 512) {
async function getAvatarUrl(id, username) {
try {
let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`;
if (id === window.id && window.profileUpdateTimestamp) pfpUrl += `&t=${window.profileUpdateTimestamp}`;
if ((await fetchAsync(pfpUrl)) == "") {
throw Error();
}
@ -839,7 +846,6 @@ async function initBlahs() {
res = await fetchAsync(`${path}blah/en-us.json`);
blah = JSON.parse(res);
}
let blahTags = document.getElementsByTagName("blah");
for (let i = 0; i < blahTags.length; i++) {
@ -1056,15 +1062,15 @@ async function ensureUserKeys() {
}
async function mainJS() {
if (localStorage.getItem('lang') != null) {
lang = localStorage.getItem('lang');
}
await initBlahs();
await loadEmotes();
passwordHash = await hashSHA3_512(password);
if (localStorage.getItem('lang') != null) {
lang = localStorage.getItem('lang');
}
if (host) {
await updateProtocolAndUrl(host);
} else {
@ -1488,6 +1494,7 @@ async function openProfile(accountId) {
let pfpUrl = await getAvatarUrl(accountId, nameWithHost);
let bannerUrl = `${url}/user/storage/public/getentry?id=${accountId}&e=larp.profile.banner`;
if (accountId === window.id && window.profileUpdateTimestamp) bannerUrl += `&t=${window.profileUpdateTimestamp}`;
let bannerHtml = `<img src="${bannerUrl}" class="profile-banner" onerror="this.outerHTML='<div class=\\'profile-banner\\'></div>'">`;
let bio = await fetchAsync(`${url}/user/storage/public/getentry?id=${accountId}&e=larp.profile.bio`);
@ -1498,7 +1505,7 @@ async function openProfile(accountId) {
let isOwnProfile = accountId === id;
let actionBtnHtml = "";
if (isOwnProfile) {
actionBtnHtml = `<button class="profile-action-btn">${processBlah('action.edit.profile')}</button>`;
actionBtnHtml = `<button class="profile-action-btn" onclick="gotoSettings('profile');closeProfile();">${processBlah('action.edit.profile')}</button>`;
} else {
let hasDm = false;
let dmRoomId = "";
@ -1920,7 +1927,7 @@ document.addEventListener('touchstart', e => {
activeTouchButton.classList.add('is-pressed');
pressDate = Date.now();
}
}, {passive: false});
}, {passive: true});
document.addEventListener('touchmove', e => {
if (!touchMoved) {
@ -1940,7 +1947,7 @@ document.addEventListener('touchmove', e => {
}
}
}
}, {passive: false});
}, {passive: true});
document.addEventListener('touchend', async (e) => {
touchEndX = e.changedTouches[0].screenX;
@ -1952,14 +1959,6 @@ document.addEventListener('touchend', async (e) => {
if (activeTouchButton) {
let elapsed = Date.now() - pressDate; //nie pomoglo ale zostawie
if (elapsed < 100) {
const remaining = 100 - elapsed;
await delay(remaining);
}
activeTouchButton.classList.remove('is-pressed');
if (!touchMoved) {
if (activeTouchButton.tagName !== 'INPUT' && activeTouchButton.tagName !== 'TEXTAREA') {
if (document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) {
@ -1974,6 +1973,14 @@ document.addEventListener('touchend', async (e) => {
activeTouchButton.focus();
}
}
let elapsed = Date.now() - pressDate; //nie pomoglo ale zostawie
if (elapsed < 100) {
const remaining = 100 - elapsed;
await delay(remaining);
}
activeTouchButton.classList.remove('is-pressed');
activeTouchButton = null;
}
});
@ -2450,7 +2457,7 @@ function setupChatScrollListener() {
let replyingToMsgId = null;
async function sendMessage(sendbutton) {
async function sendMessage(sendbutton = document.getElementById("chat-sendmessage")) {
if (!currentDmId || !currentDmKey) return;
let input = document.getElementById("chat-input");
@ -2843,4 +2850,331 @@ async function reactMessagePrompt(msgId, quickReaction = null) {
showBlahNotification("error:message.react.failed");
}
clearAction("msgreact");
}
// --- Settings Logic ---
async function gotoSettings(tab = 'account') {
fixedContextMenu.classList.remove("show");
if (roomsBarContainer) roomsBarContainer.style.display = "";
switchRoomsBar("title.settings", settingsBar);
switchSettingsTab(tab);
}
function switchSettingsTab(tabName) {
setActiveRoombarItem('tab-settings-' + tabName);
if (tabName === 'account') {
switchRoomContent("title.account", accountSettings, false);
} else if (tabName === 'profile') {
switchRoomContent("title.profile", profileSettings, false);
setTimeout(async () => {
let pfpUrl = await getAvatarUrl(window.id, window.username + ":" + window.host);
let pfpEl = document.getElementById("settings-avatar-preview");
if (pfpEl) pfpEl.src = pfpUrl;
let bannerUrl = `${url}/user/storage/public/getentry?id=${window.id}&e=larp.profile.banner`;
if (window.profileUpdateTimestamp) bannerUrl += `&t=${window.profileUpdateTimestamp}`;
let bannerEl = document.getElementById("settings-banner-preview");
if (bannerEl) bannerEl.src = bannerUrl;
let bioEl = document.getElementById("settings-bio");
if (bioEl) {
let bio = await fetchAsync(`${url}/user/storage/public/getentry?id=${window.id}&e=larp.profile.bio`);
if (!bio.startsWith("error:")) bioEl.value = bio;
}
}, 210);
} else if (tabName === 'appearance') {
switchRoomContent("title.appearance", appearanceSettings, false);
setTimeout(populateLanguageDropdown, 210);
}
}
async function populateLanguageDropdown() {
let sel = document.getElementById("settings-language");
if (!sel) return;
try {
let path = window.location.pathname.replace("/login/index.html", "/").replace("index.html", "");
let res = await fetchAsync(`${path}blah/index.json`);
let json = JSON.parse(res);
sel.innerHTML = "";
for (let code in json) {
let opt = document.createElement("option");
opt.value = code;
opt.textContent = json[code].native + " (" + json[code].english + ")";
if (code === lang) opt.selected = true;
sel.appendChild(opt);
}
} catch (e) {
console.error("Failed to load languages", e);
}
}
function changeLanguage(newLang) {
if (!newLang) return;
localStorage.setItem('lang', newLang);
window.location.reload();
}
async function changeUsername() {
let input = document.getElementById("settings-username");
let name = input.value;
if (!name) return;
showAction("action.account.saving", "accountsave");
let res = await fetchPostEnc(`${url}/chname?id=${window.id}`, name);
clearAction("accountsave");
let text = res;
if (text.startsWith("success:")) {
showBlahNotification(text);
loginUsername = name;
localStorage.setItem("username", name);
} else {
showBlahNotification(text);
}
}
async function changePassword() {
let input = document.getElementById("settings-password");
let newPass = input.value;
if (!newPass) return;
showAction("action.account.saving", "accountsave");
let newPassHash = await hashSHA3_512(newPass);
let newUserKeysEncrypted = await encryptJsonWithPassword(userKeysPrivate, newPass);
let payload = JSON.stringify({
string1: newPassHash,
string2: JSON.stringify({
string1: newUserKeysEncrypted,
string2: JSON.stringify(userKeysPublic)
})
});
let res = await fetchPostEnc(`${url}/chpass?id=${window.id}`, payload);
clearAction("accountsave");
let text = res;
if (text.startsWith("success:")) {
password = newPass;
localStorage.setItem("password", newPass);
passwordHash = newPassHash;
userKeysEncrypted = newUserKeysEncrypted;
localStorage.setItem(`userKeys.enc.v1:${window.id}`, userKeysEncrypted);
showBlahNotification(text);
} else {
showBlahNotification(text);
}
}
async function deleteAccount() {
if (!confirm(processBlah("desc.delete.account") + "\n\nAre you sure?")) return;
showAction("action.account.deleting", "accountdel");
let res = await fetchPostEnc(`${url}/deleteaccount?id=${window.id}`, "");
clearAction("accountdel");
let text = res;
if (text.startsWith("success:")) {
alert(processBlah(text));
logout();
} else {
showBlahNotification(text);
}
}
async function saveProfile() {
let bioInput = document.getElementById("settings-bio");
if (bioInput) {
let payload = { string1: "larp.profile.bio", string2: bioInput.value || "" };
showAction("action.bio.saving", "savebio");
let res = await fetchEncrypted("user/storage/public/update", JSON.stringify(payload));
clearAction("savebio");
if (res && res.startsWith("error:")) { showBlahNotification(res); return; }
}
showBlahNotification("success:profile.updated");
}
// --- Cropper Logic ---
let cropperType = 'avatar'; // 'avatar' or 'banner'
let cropperImageObj = null;
let cropperPosX = 0;
let cropperPosY = 0;
let cropperBaseZoom = 1;
let cropperOriginalType = '';
let cropperOriginalB64 = '';
let isDraggingCropper = false;
let startDragX = 0;
let startDragY = 0;
let startPosX = 0;
let startPosY = 0;
function openCropper(input, type) {
if (!input.files || input.files.length === 0) return;
let file = input.files[0];
cropperType = type;
let reader = new FileReader();
reader.onload = (e) => {
let img = new Image();
img.onload = () => {
cropperImageObj = img;
initCropper();
};
cropperOriginalType = file.type;
cropperOriginalB64 = e.target.result;
img.src = e.target.result;
document.getElementById('cropper-image').src = e.target.result;
};
reader.readAsDataURL(file);
input.value = ""; // reset input
}
function initCropper() {
let container = document.getElementById('cropper-viewport-container');
let imgEl = document.getElementById('cropper-image');
let zoomSlider = document.getElementById('cropper-zoom');
// Set aspect ratio
let targetWidth = cropperType === 'avatar' ? 512 : 494;
let targetHeight = cropperType === 'avatar' ? targetWidth : 200;
let containerWidth = container.clientWidth || 300;
container.style.height = (containerWidth * (targetHeight / targetWidth)) + 'px';
let imgWidth = cropperImageObj.width;
let imgHeight = cropperImageObj.height;
let scaleX = containerWidth / imgWidth;
let scaleY = (containerWidth * (targetHeight / targetWidth)) / imgHeight;
cropperBaseZoom = Math.max(scaleX, scaleY);
zoomSlider.min = cropperBaseZoom;
zoomSlider.max = cropperBaseZoom * 5;
zoomSlider.value = cropperBaseZoom;
cropperPosX = 0;
cropperPosY = 0;
updateCropperTransform();
document.getElementById('cropper-popup-container').classList.add('show');
}
function updateCropperZoom() {
updateCropperTransform();
}
function updateCropperTransform() {
let zoom = parseFloat(document.getElementById('cropper-zoom').value);
let imgEl = document.getElementById('cropper-image');
let container = document.getElementById('cropper-viewport-container');
if (cropperImageObj) {
let scaledW = cropperImageObj.width * zoom;
let scaledH = cropperImageObj.height * zoom;
let minX = container.clientWidth - scaledW;
let maxX = 0;
let minY = container.clientHeight - scaledH;
let maxY = 0;
if (minX > maxX) minX = maxX;
if (minY > maxY) minY = maxY;
if (cropperPosX < minX) cropperPosX = minX;
if (cropperPosX > maxX) cropperPosX = maxX;
if (cropperPosY < minY) cropperPosY = minY;
if (cropperPosY > maxY) cropperPosY = maxY;
}
imgEl.style.transform = `translate(${cropperPosX}px, ${cropperPosY}px) scale(${zoom})`;
}
function closeCropper() {
document.getElementById('cropper-popup-container').classList.remove('show');
}
async function confirmCropper() {
let zoom = parseFloat(document.getElementById('cropper-zoom').value);
let container = document.getElementById('cropper-viewport-container');
let b64;
let newSrc;
if (cropperOriginalType === 'image/gif') {
b64 = cropperOriginalB64.split(',')[1];
newSrc = cropperOriginalB64;
} else {
let canvas = document.createElement('canvas');
let ctx = canvas.getContext('2d');
let targetWidth = cropperType === 'avatar' ? 512 : 988;
let targetHeight = cropperType === 'avatar' ? 512 : 400;
canvas.width = targetWidth;
canvas.height = targetHeight;
let scaleRatio = targetWidth / container.clientWidth;
let drawX = cropperPosX * scaleRatio;
let drawY = cropperPosY * scaleRatio;
let drawW = cropperImageObj.width * zoom * scaleRatio;
let drawH = cropperImageObj.height * zoom * scaleRatio;
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, targetWidth, targetHeight);
ctx.drawImage(cropperImageObj, drawX, drawY, drawW, drawH);
b64 = canvas.toDataURL('image/jpeg', 0.9).split(',')[1];
newSrc = 'data:image/jpeg;base64,' + b64;
}
let payloadKey = cropperType === 'avatar' ? 'larp.profile.pfp' : 'larp.profile.banner';
let payload = { string1: payloadKey, string2: b64 };
showAction("action.uploading", "uploadmedia");
let res = await fetchEncrypted("user/storage/public/update", JSON.stringify(payload));
clearAction("uploadmedia");
if (res && res.startsWith("error:")) {
showBlahNotification(res);
} else {
showBlahNotification(`success:${cropperType}.updated`);
window.profileUpdateTimestamp = Date.now();
// Update all matching images on the page instantly
document.querySelectorAll('img').forEach(img => {
if (img.src.includes(`id=${window.id}&e=${payloadKey}`) || img.id === `settings-${cropperType}-preview` || (cropperType === 'avatar' && (img.id === 'my-avatar-img' || img.id === 'sidebar-pfp'))) {
img.src = newSrc;
}
});
}
closeCropper();
}
let container = document.getElementById('cropper-viewport-container');
if (container) {
const startDrag = (e) => {
isDraggingCropper = true;
startDragX = e.clientX || (e.touches && e.touches[0].clientX);
startDragY = e.clientY || (e.touches && e.touches[0].clientY);
startPosX = cropperPosX;
startPosY = cropperPosY;
if (e.type !== "touchstart") e.preventDefault();
};
const onDrag = (e) => {
if (!isDraggingCropper) return;
let clientX = e.clientX || (e.touches && e.touches[0].clientX);
let clientY = e.clientY || (e.touches && e.touches[0].clientY);
cropperPosX = startPosX + (clientX - startDragX);
cropperPosY = startPosY + (clientY - startDragY);
updateCropperTransform();
};
const endDrag = () => {
isDraggingCropper = false;
};
container.addEventListener('mousedown', startDrag);
container.addEventListener('touchstart', startDrag, {passive: true});
document.addEventListener('mousemove', onDrag);
document.addEventListener('touchmove', onDrag, {passive: true});
document.addEventListener('mouseup', endDrag);
document.addEventListener('touchend', endDrag);
}

View file

@ -143,7 +143,7 @@ var chatScreen = `
</div>
<div style="padding: 1rem; border-top: var(--border-width) solid var(--light-border-color); display: flex; gap: 0.5rem;">
<textarea id="chat-input" class="forminput" style="flex-grow: 1; margin: 0; resize: none; min-height: 2.5rem; max-height: 8rem; padding: 0.5rem;" placeholder="{blah(placeholder.message.input)}" onkeydown="handleChatInputKey(event)" oninput="handleChatInputResize(this)"></textarea>
<button class="submit-button" onclick="sendMessage(this)" style="margin: 0; padding: 0; width: 2.5rem; min-height: 2.5rem; max-height: 8rem;">
<button id="chat-sendmessage" class="submit-button" onclick="sendMessage(this)" style="margin: 0; padding: 0; width: 2.5rem; min-height: 2.5rem; max-height: 8rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="1.5rem" height="1.5rem" viewBox="0 0 16 16" fill="none">
<path stroke="var(--main-bg-color)" stroke-linecap="round" stroke-linejoin="round" d="M 4.65 8 l -1.875 5.625 l 11.25 -5.625 L 2.775 2.375 l 1.875 5.625 z m 0 0 h 3.75" stroke-width="1.1"></path>
</svg>
@ -259,3 +259,103 @@ var messageContextMenu = `
var detailsBtn = `<button class="mobile-nav-btn right" onclick="mobileNavDetails()"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M120-240v-80h720v80H120Zm0-200v-80h720v80H120Zm0-200v-80h720v80H120Z"/></svg></button>`;
var backBtnHtml = `<button class="mobile-nav-btn" onclick="mobileNavBack()"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="m313-440 224 224-57 56-320-320 320-320 57 56-224 224h487v80H313Z"/></svg></button>`;
var settingsBar = `
<div class="sidebar-section-header">
<button class="collapse-text-button" id="tab-settings-account" onclick="switchSettingsTab('account')">
<svg xmlns="http://www.w3.org/2000/svg" class="chevron" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M200-200h560v-560H200v560Zm0 80q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm40-160h480L570-480 450-320l-90-120-120 160Zm-40 80v-560 560Z"/></svg>
<span><blah>title.account</blah></span>
</button>
</div>
<div class="sidebar-section-header">
<button class="collapse-text-button" id="tab-settings-profile" onclick="switchSettingsTab('profile')">
<svg xmlns="http://www.w3.org/2000/svg" class="chevron" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M200-200h560v-560H200v560Zm0 80q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm40-160h480L570-480 450-320l-90-120-120 160Zm-40 80v-560 560Z"/></svg>
<span><blah>title.profile</blah></span>
</button>
</div>
<div class="sidebar-section-header">
<button class="collapse-text-button" id="tab-settings-appearance" onclick="switchSettingsTab('appearance')">
<svg xmlns="http://www.w3.org/2000/svg" class="chevron" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-156t86-127Q252-817 325-848.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 167-116.5 283.5T480-80Zm0-157q102 0 174-72t72-174q0-102-72-174t-174-72q-102 0-174 72t-72 174q0 102 72 174t174 72Zm0-80q-68 0-116.5-48.5T315-482q0-68 48.5-116.5T480-647q68 0 116.5 48.5T645-482q0 68-48.5 116.5T480-317Zm0-60q43 0 74-31t31-74q0-43-31-74t-74-31q-43 0-74 31t-31 74q0 43 31 74t74 31Zm0-105Z"/></svg>
<span><blah>title.appearance</blah></span>
</button>
</div>
`;
var accountSettings = `
<div style="display: flex;justify-content: center;align-items: center;height:100%;flex-direction: column;text-align: center;">
<svg xmlns="http://www.w3.org/2000/svg" width="3rem" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M480-480q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM160-160v-112q0-34 17.5-62.5T224-378q62-31 126-46.5T480-440q66 0 130 15.5T736-378q29 15 46.5 43.5T800-272v112H160Zm80-80h480v-32q0-11-5.5-20T700-306q-54-27-109-40.5T480-360q-56 0-111 13.5T260-306q-9 5-14.5 14t-5.5 20v32Zm240-320q33 0 56.5-23.5T560-640q0-33-23.5-56.5T480-720q-33 0-56.5 23.5T400-640q0 33 23.5 56.5T480-560Zm0-80Zm0 400Z"/></svg>
<herotitle><blah>title.account</blah></herotitle>
<br>
<div class="input-group">
<label for="settings-username"><blah>title.username</blah></label>
<input type="text" id="settings-username" class="forminput">
</div>
<div class="input-group">
<button class="submit-button blah" onclick="changeUsername()">action.save</button>
</div>
<div class="input-group">
<label for="settings-password"><blah>title.password</blah></label>
<input type="password" id="settings-password" class="forminput">
</div>
<div class="input-group">
<button class="submit-button blah" onclick="changePassword()">action.save</button>
</div>
<br>
<div class="input-group">
<label style="color: var(--big-red);"><blah>title.delete.account</blah></label>
<p style="font-size: 0.85rem; opacity: 0.7; margin-bottom: 0.5rem; margin-left: 0.6rem;"><blah>desc.delete.account</blah></p>
<button class="submit-button blah" onclick="deleteAccount()" style="background: var(--big-red); color: white;">action.delete.account</button>
</div>
</div>
`;
var profileSettings = `
<div style="display: flex;justify-content: center;align-items: center;height:100%;flex-direction: column;text-align: center;">
<svg xmlns="http://www.w3.org/2000/svg" width="3rem" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M200-200h560v-560H200v560Zm0 80q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm40-160h480L570-480 450-320l-90-120-120 160Zm-40 80v-560 560Z"/></svg>
<herotitle><blah>title.profile</blah></herotitle>
<br>
<div class="input-group" style="display: flex; flex-direction: row; align-items: stretch; justify-content: space-between; border: var(--border-width) solid var(--light-border-color); border-radius: 0.8rem; padding: 1rem; width: 100%; max-width: 30rem;">
<img id="settings-avatar-preview" src="" style="width: 4.5rem; height: 4.5rem; border-radius: 0.8rem; border: var(--border-width) solid rgba(255, 255, 255, 0.2); object-fit: cover; background: var(--main-bg-color);">
<div style="display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between; padding: 0.1rem 0;">
<div style="font-weight: bold;"><blah>title.avatar</blah></div>
<button class="submit-button" style="margin: 0; padding: 0.5rem 1rem; width: auto;" onclick="document.getElementById('settings-avatar-input').click()">Change Avatar</button>
<input type="file" id="settings-avatar-input" style="display: none;" accept="image/*" onchange="openCropper(this, 'avatar')">
</div>
</div>
<br>
<div class="input-group" style="display: flex; flex-direction: row; align-items: stretch; justify-content: space-between; border: var(--border-width) solid var(--light-border-color); border-radius: 0.8rem; padding: 1rem; width: 100%; max-width: 30rem;">
<div style="width: 12.4rem; aspect-ratio: 494 / 200; background: var(--main-bg-color); border-radius: 0.4rem; overflow: hidden; display: flex; align-items: center; justify-content: center; border: var(--border-width) solid rgba(255, 255, 255, 0.2);">
<img id="settings-banner-preview" src="" style="width: 100%; height: 100%; object-fit: cover;">
</div>
<div style="display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between; padding: 0.1rem 0;">
<div style="font-weight: bold;"><blah>title.banner</blah></div>
<button class="submit-button" style="margin: 0; padding: 0.5rem 1rem; width: auto;" onclick="document.getElementById('settings-banner-input').click()">Change Banner</button>
<input type="file" id="settings-banner-input" style="display: none;" accept="image/*" onchange="openCropper(this, 'banner')">
</div>
</div>
<br>
<div class="input-group">
<label for="settings-bio"><blah>title.bio</blah></label>
<textarea id="settings-bio" class="forminput" style="min-height: 8rem; padding-top: 0.8rem;"></textarea>
</div>
<div class="input-group">
<button class="submit-button blah" onclick="saveProfile()">action.save.bio</button>
</div>
</div>
`;
var appearanceSettings = `
<div style="display: flex;justify-content: center;align-items: center;height:100%;flex-direction: column;text-align: center;">
<svg xmlns="http://www.w3.org/2000/svg" width="3rem" viewBox="0 -960 960 960" fill="var(--text-color)"><path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-156t86-127Q252-817 325-848.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 167-116.5 283.5T480-80Zm0-157q102 0 174-72t72-174q0-102-72-174t-174-72q-102 0-174 72t-72 174q0 102 72 174t174 72Zm0-80q-68 0-116.5-48.5T315-482q0-68 48.5-116.5T480-647q68 0 116.5 48.5T645-482q0 68-48.5 116.5T480-317Zm0-60q43 0 74-31t31-74q0-43-31-74t-74-31q-43 0-74 31t-31 74q0 43 31 74t74 31Zm0-105Z"/></svg>
<herotitle><blah>title.appearance</blah></herotitle>
<br>
<div class="input-group">
<label for="settings-language"><blah>title.language</blah></label>
<select id="settings-language" onchange="changeLanguage(this.value)" style="border-radius: 0.8rem; background-color: rgba(255, 255, 255, 0.05); margin: var(--button-margin); padding: var(--button-margin); padding-left: calc(var(--button-margin) * 2); padding-right: 1.5rem; height: var(--button-height); display: flex; cursor: pointer; align-items: center; justify-content: flex-start; border: var(--border-width) solid var(--light-border-color); color: var(--text-color); font-family: inherit; font-size: inherit; appearance: none; outline: none;">
<option value="" class="blah">title.loading</option>
</select>
</div>
</div>
`;

View file

@ -110,6 +110,9 @@ input, textarea {
user-select: text !important;
-webkit-user-select: text !important;
}
textarea {
resize: none;
}
.chat-message-text, .chat-message-author, .chat-message-timestamp,
.profile-name, .profile-bio, .profile-id-host,
@ -810,15 +813,15 @@ sidebarelement:has(.icon-button:active) indicator:not(.active) {
}
.profile-popup-content {
position: relative;
width: 90vw;
max-width: 31rem;
width: 31em;
height: 85vh;
max-height: 44rem;
max-height: 44em;
font-size: min(1rem, calc(90vw / 31));
background: var(--main-bg-color);
border: var(--border-width) solid var(--light-border-color);
border-radius: 0.9rem;
border-radius: 0.9em;
overflow: hidden;
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.5);
box-shadow: 0 1em 3em rgba(0, 0, 0, 0.5);
opacity: 0;
transform: scale(0.6);
transition: all 0.2s ease;
@ -832,16 +835,16 @@ sidebarelement:has(.icon-button:active) indicator:not(.active) {
}
.profile-popup-close-btn {
position: absolute;
top: 1rem;
right: 1rem;
top: 1em;
right: 1em;
z-index: 10;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(0.25rem);
-webkit-backdrop-filter: blur(0.25rem);
backdrop-filter: blur(0.25em);
-webkit-backdrop-filter: blur(0.25em);
border: none;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.8rem;
width: 2.5em;
height: 2.5em;
border-radius: 0.8em;
display: flex;
justify-content: center;
align-items: center;
@ -850,8 +853,8 @@ sidebarelement:has(.icon-button:active) indicator:not(.active) {
cursor: pointer;
}
.profile-popup-close-btn svg {
width: 1.5rem;
height: 1.5rem;
width: 1.5em;
height: 1.5em;
}
.profile-popup-close-btn:hover {
background: rgba(255, 255, 255, 0.2);
@ -865,62 +868,104 @@ sidebarelement:has(.icon-button:active) indicator:not(.active) {
}
.profile-banner {
width: 100%;
height: 12.5rem;
aspect-ratio: 494 / 200;
background: linear-gradient(135deg, rgba(255,255,255,0.05), rgba(255,255,255,0.1));
object-fit: cover;
flex-shrink: 0;
}
.profile-avatar-container {
position: relative;
margin-top: -3.75rem;
margin-left: 2rem;
width: 7.5rem;
height: 7.5rem;
margin-top: -3.75em;
margin-left: 2em;
width: 7.5em;
height: 7.5em;
}
.profile-avatar {
width: 100%;
height: 100%;
border-radius: 0.8rem;
border-radius: 0.8em;
border: var(--border-width) solid rgba(255, 255, 255, 0.2);
object-fit: cover;
background: var(--main-bg-color);
}
.profile-info {
padding: 1rem 2rem 2rem 2rem;
padding: 1em 2em 2em 2em;
display: flex;
flex-direction: column;
gap: 1rem;
gap: 1em;
}
.profile-name {
font-size: 2rem;
font-size: 2em;
font-weight: 800;
line-height: 1.1;
margin-bottom: 0.2rem;
margin-bottom: 0.2em;
word-break: break-word;
}
.profile-id-host {
font-size: 1rem;
font-size: 1em;
opacity: 0.6;
font-weight: 600;
}
.profile-bio {
font-size: 1.05rem;
font-size: 1.05em;
line-height: 1.5;
white-space: pre-wrap;
background: rgba(255, 255, 255, 0.03);
padding: 1rem;
border-radius: 0.8rem;
padding: 1em;
border-radius: 0.8em;
border: var(--border-width) solid var(--light-border-color);
word-break: break-word;
}
.profile-action-btn {
align-self: flex-start;
padding: 0.6rem 1.5rem;
padding: 0.6em 1.5em;
font-weight: 700;
background: var(--text-color);
color: var(--main-bg-color);
border-radius: 0.8rem;
margin-top: 0.5rem;
border-radius: 0.8em;
margin-top: 0.5em;
margin-left: 0;
height: auto;
}
/* Custom Zoom Slider Styling */
#cropper-zoom {
-webkit-appearance: none;
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
outline: none;
margin: 10px 0;
}
#cropper-zoom::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--text-color);
cursor: pointer;
transition: transform 0.2s, background 0.2s;
}
#cropper-zoom::-webkit-slider-thumb:hover {
transform: scale(1.2);
background: #fff;
}
#cropper-zoom::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--text-color);
cursor: pointer;
transition: transform 0.2s, background 0.2s;
border: none;
}
#cropper-zoom::-moz-range-thumb:hover {
transform: scale(1.2);
background: #fff;
}