Add settings and profile editor
This commit is contained in:
parent
80f11f782e
commit
cc41f25277
14 changed files with 1210 additions and 106 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
android/app/src/main/assets/public/blah/index.json
Normal file
4
android/app/src/main/assets/public/blah/index.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"en-us": { "english": "English (US)", "native": "English (US)" },
|
||||
"en-cat": { "english": "English (Cat)", "native": "Meowlish" }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
4
webroot/blah/index.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"en-us": { "english": "English (US)", "native": "English (US)" },
|
||||
"en-cat": { "english": "English (Cat)", "native": "Meowlish" }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
372
webroot/main.js
372
webroot/main.js
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue