autonomous.contact/public/sidebar/sidebar.html
2025-10-25 14:57:28 -07:00

826 lines
21 KiB
HTML

<script>
APP.on("topics_updated", ({ topics }) => {
const topic_list = document.getElementById("topic-list");
topic_list.innerHTML = "";
for (const topic of topics.sort((lhs, rhs) => lhs.name.localeCompare(rhs.name))) {
topic_list.insertAdjacentHTML(
"beforeend",
`<li id="topic-selector-${topic.id}" class="topic" data-topic-selector-for="${topic.id}"><a href="#/topic/${topic.id}/chat">${topic.name}</a></li>`,
);
}
});
function update_topic_indicators(event) {
document
.querySelectorAll("[data-topic-selector-for]")
.forEach((element) => element.classList.remove("active"));
const new_topic_id = event?.detail?.topic_id ?? document.body.dataset.topic;
if (new_topic_id) {
document
.querySelectorAll(`[data-topic-selector-for="${new_topic_id}"]`)
.forEach((element) => element.classList.add("active"));
}
for ( const watch of APP.user_watches ) {
// find the topic indicator for this watch
// if there is new stuff - TODO implement a HEAD for getting latest event id?
// add a class of 'new-content'
}
}
APP.on("topics_updated", update_topic_indicators);
APP.on("topic_changed", update_topic_indicators);
APP.on("user_logged_in", update_topic_indicators);
function clear_invite_popup() {
document.body.querySelectorAll(".invitepopover").forEach((element) => element.remove());
}
function generate_invite(click_event) {
click_event.preventDefault();
const button = click_event.target;
clear_invite_popup();
const invite_div = document.createElement("div");
invite_div.classList.add("invitepopover");
invite_div.innerHTML = `
<div class="icon close" onclick="clear_invite_popup()"></div>
<form>
<input name="code" type="text" placeholder="Custom code (optional)">
<button onclick="create_invite(event);">Generate</button>
</form>`;
document.body.appendChild(invite_div);
invite_div.style.left = button.getBoundingClientRect().left + "px";
invite_div.style.top = button.getBoundingClientRect().top + "px";
}
async function create_invite(click_event) {
click_event.preventDefault();
const button = click_event.target;
const invite_popover = document.body.querySelector(".invitepopover");
if (!invite_popover) {
alert("Unknown error, try again.");
return;
}
const user = APP.user;
if (!user) {
alert("You must be logged in.");
return;
}
const form = button.closest("form");
const code_input = form.querySelector('[name="code"]');
const invite_code_response = await api.fetch(`/api/users/${user.id}/invites`, {
method: "POST",
json: {
code: code_input.value,
},
});
if (!invite_code_response.ok) {
const error = await invite_code_response.json();
return alert(error?.error?.message ?? error?.errors?.[0]?.message ?? "Unknown error.");
}
const invite_code = await invite_code_response.json();
invite_popover.innerHTML = `
<div>
<div class="icon close" onclick="clear_invite_popup()"></div>
<div class="share-option">
<span class="name">Code</span>
<input readonly type="text" name="code" value="${invite_code.code}" />
<button onclick="navigator.clipboard.writeText('${invite_code.code}');" />Copy</button>
</div>
<div class="share-option">
<span class="name">Link</span>
<input readonly type="text" name="code" value="${window.location.protocol + "//" + window.location.host + "/?invite_code=" + encodeURIComponent(invite_code.code)}" />
<button onclick="navigator.clipboard.writeText('${window.location.protocol + "//" + window.location.host + "/?invite_code=" + encodeURIComponent(invite_code.code)}');" />Copy</button>
</div>
<button onclick="( () => document.querySelectorAll( '.invitepopover' ).forEach( (element) => element.remove() ) )()">Done</button>
</div>`;
}
document.addEventListener(
"contextmenu",
(event) => {
if (!event.target?.closest("#sidebar")) {
return;
}
const topic_selector = event.target.closest("li.topic");
if (!topic_selector) {
return;
}
event.preventDefault();
const context_menu = document.getElementById("sidebar-context-menu");
context_menu.dataset.prepare = true;
const position = get_best_coords_for_popup({
target_element: topic_selector,
popup: {
width: context_menu.getBoundingClientRect().width,
height: context_menu.getBoundingClientRect().height,
},
offset: {
x: 4,
y: 4,
},
});
context_menu.style.left = position.x + "px";
context_menu.style.top = position.y + "px";
context_menu.dataset.show = true;
},
false,
);
document.addEventListener("click", (event) => {
if (!event.target?.closest("#sidebar-context-menu")) {
const context_menu = document.getElementById("sidebar-context-menu");
delete context_menu.dataset.show;
delete context_menu.dataset.prepare;
}
});
</script>
<style type="text/css">
#sidebar {
z-index: 100;
background: var(--bg);
position: relative;
width: auto;
left: 0;
max-width: 32rem;
padding-left: 6rem;
transition: all ease-in-out 0.33s;
border-right: 1px solid var(--border-subtle);
}
#sidebar #sidebar-context-menu {
display: none;
visibility: hidden;
opacity: 0;
position: fixed;
z-index: 100;
min-width: 220px;
overflow: hidden;
flex-direction: column;
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--border-highlight);
border-radius: var(--border-radius);
box-shadow:
0 0 25px hsla(var(--accent), 100%, 70%, 0.4),
inset 0 0 10px hsla(var(--accent), 100%, 60%, 0.2);
backdrop-filter: blur(var(--blur-radius));
}
#sidebar #sidebar-context-menu[data-prepare] {
display: block;
visibility: visible;
}
#sidebar #sidebar-context-menu[data-show] {
display: block;
visibility: visible;
opacity: 1;
}
#sidebar #sidebar-context-menu button {
padding: 1rem;
display: flex;
align-items: center;
color: var(--text);
letter-spacing: 0.5px;
transition:
background 0.25s,
color 0.25s;
position: relative;
margin: 0;
border-bottom-width: 0;
}
#sidebar #sidebar-context-menu button:last-of-type {
border-bottom-width: 1px;
}
#sidebar #sidebar-context-menu button::before {
content: "";
position: absolute;
left: 0;
width: 3px;
height: 0;
top: 50%;
transform: translateY(-50%);
background: hsla(var(--accent), 100%, 75%, 0.8);
transition: height 0.25s;
border-radius: var(--border-radius);
}
#sidebar #sidebar-context-menu button:hover {
color: var(--accent);
}
#sidebar #sidebar-context-menu button:hover::before {
height: 70%;
}
#sidebar #sidebar-toggle,
#sidebar #sidebar-toggle-icon {
opacity: 0;
display: none;
z-index: 1000;
}
@media screen and (max-width: 1200px) {
#sidebar {
position: absolute;
top: 0;
bottom: 0;
transform: translateX(-100%);
}
#sidebar #sidebar-toggle-icon {
opacity: 1;
display: block;
position: absolute;
top: 0.1rem;
right: -2rem;
cursor: pointer;
transition: all ease-in-out 0.33s;
background: rgba(128, 128, 128, 0.5);
border-radius: 0 1rem 1rem 0;
padding: 0.25rem;
}
#sidebar .icon {
transition: all ease-in-out 0.15s;
}
#sidebar:has(#sidebar-toggle:checked) {
transform: translateX(0);
}
#sidebar:has(#sidebar-toggle:checked) #sidebar-toggle-icon {
right: 0;
rotate: 180deg;
}
}
#sidebar .title {
text-transform: uppercase;
font-size: small;
font-weight: bold;
line-height: 2rem;
}
#sidebar #topic-creation-container {
margin-top: 0.5rem;
}
#sidebar #topic-creation-container #toggle-topic-creation-form-button {
transform: scale(0.8);
}
#sidebar .topic-list {
list-style-type: none;
margin-left: 1rem;
}
#sidebar .topic-list > li.topic a:before {
position: absolute;
left: -1.75rem;
top: 0;
font-weight: bold;
font-size: x-large;
content: "#";
color: var(--text);
}
#sidebar .topic-list > li.topic a {
position: relative;
display: block;
width: 100%;
min-height: 1.5rem;
line-height: 1.5rem;
font-weight: bold;
font-size: large;
margin-left: 1.75rem;
text-decoration: none;
margin-bottom: 0.75rem;
}
#sidebar .topic-list > li.topic.active a {
color: var(--accent);
}
.invitepopover {
position: fixed;
z-index: 1000;
background: var(--bg);
margin: 1rem;
border: 1px solid var(--border-normal);
border-radius: var(--border-radius);
padding: 1rem;
max-width: 90%;
}
.invitepopover .share-option .name {
text-transform: uppercase;
min-width: 4rem;
display: inline-block;
}
.invitepopover .icon.close {
cursor: pointer;
margin-right: -0.25rem;
margin-top: -0.25rem;
margin-bottom: 0.75rem;
margin-left: auto;
}
.invitepopover .share-option input {
padding: 0.75rem;
margin: 0 1rem 1rem 0;
background: none;
color: var(--text);
border: 1px solid var(--border-highlight);
border-radius: var(--border-radius);
box-shadow: none;
font-size: large;
width: 100%;
max-width: 16rem;
}
.invitepopover .share-option button {
padding: 1rem;
text-transform: uppercase;
border: 1px solid var(--border-subtle);
border-radius: var(--border-radius);
margin-left: -0.75rem;
}
@media screen and (max-width: 1200px) {
.invitepopover {
margin: 0;
padding: 0.5rem;
}
.invitepopover .share-option .name {
display: block;
text-align: center;
margin-bottom: 1rem;
}
.invitepopover .share-option button {
display: block;
margin: 0 auto 1rem;
}
}
</style>
<div id="sidebar">
<div id="sidebar-context-menu">
<form
data-smart="true"
data-method="POST"
action="/api/users/${ APP.user?.id }/watches"
>
<input type="hidden" name="target" value="" />
<button data-sidebar-context-menu-item="true">👁️‍🗨️ Watch</button>
<button data-sidebar-context-menu-item="true">🕳️ Hide</button>
</form>
</div>
<input type="checkbox" id="sidebar-toggle" />
<label id="sidebar-toggle-icon" for="sidebar-toggle">
<div class="icon right"></div>
</label>
<script>
async function update_servers_list() {
const template = document.getElementById( 'server-list-entry-template');
try {
const server = APP.server;
const entry = eval("`" + template.innerHTML.trim() + "`");
document.getElementById('this-server-container').innerHTML = entry;
}
catch( error ) {
console.error( error );
}
try {
document.getElementById( 'suggested-servers-container').innerHTML = '';
for ( const server of APP.suggested_servers ) {
const entry = eval( "`" + template.innerHTML.trim() + "`");
document.getElementById( 'suggested-servers-container').insertAdjacentHTML( 'beforeend', entry );
}
}
catch( error ) {
console.error( error );
}
try {
document.getElementById( 'user-servers-container').innerHTML = '';
for ( const server of APP.user_servers ) {
const entry = eval( "`" + template.innerHTML.trim() + "`");
document.getElementById( 'user-servers-container').insertAdjacentHTML( 'beforeend', entry );
}
}
catch( error ) {
console.error( error );
}
}
APP.on( 'load', update_servers_list );
</script>
<style>
#server-list-container {
position: absolute;
top: 0;
left: 0;
bottom: 0;
padding: 0.75rem;
width: 6rem;
border-right: 1px solid var(--border-subtle);
overflow-y: auto;
}
.server-list-entry {
width: 100%;
padding: 0.5rem;
margin: 0.5rem auto;
overflow: hidden;
text-align: center;
margin-bottom: 1rem;
}
.server-list-entry a {
text-decoration: none;
}
.server-list-entry img {
width: 3rem;
height: 3rem;
margin: 0 auto 0.75rem;
padding: 0.2rem;
border-radius: var(--border-radius);
object-fit: scale-down;
align-content: center;
border: 1px solid var(--border-subtle);
background: var(--icon-background);
}
.server-list-entry .server-name {
font-size: x-small;
word-wrap: break-word;
font-weight: bold;
max-height: 3rem;
}
</style>
<div id="server-list-container">
<template id="server-list-entry-template">
<div class="server-list-entry">
<a href="${ server.url }">
<img class="server-icon" src="${ server.icon ?? ( server.url + '/favicon.ico' ) }" alt="${ server.name ?? server.url } icon" style="${ server.icon_background ? `--icon-background: ${ server.icon_background };` : '' }" />
<div class="server-name">${ server.name ?? server.url }</div>
</a>
</div>
</template>
<div id="this-server-container"></div>
<div id="suggested-servers-container"></div>
<div id="user-servers-container"></div>
</div>
<style>
#server-info {
position: relative;
height: 100%;
padding: 0.75rem;
}
</style>
<div id="server-info">
<script>
const DEFAULT_AVATAR_URL = `//${window.location.host}/images/default_avatar.gif`;
new MutationObserver((mutations, observer) => {
mutations.forEach((mutation) => {
const user = APP.user;
const user_bound_elements = document.querySelectorAll("[data-bind-to-user-field]");
for (const user_bound_element of user_bound_elements) {
const key =
user_bound_element.dataset
.bindToUserField; /* I hate that it converts the name */
const key_elements = key.split(".");
let value = undefined;
if (user) {
let current = user;
for (const key_element of key_elements) {
current = current[key_element];
if (!current) {
break;
}
}
value = current;
}
const target =
typeof user_bound_element.dataset.userFieldTarget === "string" &&
user_bound_element.dataset.userFieldTarget.length > 0
? user_bound_element.dataset.userFieldTarget
: "innerHTML";
const default_value =
typeof user_bound_element.dataset.userFieldDefault === "string" &&
user_bound_element.dataset.userFieldDefault.length > 0
? user_bound_element.dataset.userFieldDefault
: "";
user_bound_element[target] = value ?? default_value;
}
const primary_color_setting = user?.meta?.primary_color;
if (primary_color_setting) {
const root = document.querySelector(":root");
root.style.setProperty("--base-color", primary_color_setting);
}
});
}).observe(document.body, {
attributes: true,
attributeFilter: ["data-user"],
});
</script>
<style type="text/css">
.profile-container {
max-width: 1024px;
padding: 1rem;
padding-bottom: 0;
}
.profile-container .avatar-container {
position: relative;
width: 100%;
max-width: 200px;
}
.profile-container .avatar-container input[type="file"] {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
opacity: 0;
cursor: pointer;
}
</style>
<form class="profile-container">
<script>
const profile_form = document.currentScript.closest("form");
document.addEventListener("DOMContentLoaded", () => {
const inputs = profile_form.querySelectorAll("input");
async function update_from_input(input) {
delete input.__debounce_timeout;
if (!document.body.dataset.user) {
return;
}
const user = APP.user;
const updated_user = { ...user };
switch (input.name) {
case "meta.avatar":
const avatar = input.files[0];
if (!avatar || !avatar.type || !avatar.type.includes("image")) {
return alert(
"You must select a valid image to upload as your avatar.",
);
}
// TODO: actually enforce this on the upload in serverus somehow
if (avatar.size > 512_000) {
return alert("512K is the largest allowed avatar size.");
}
const avatar_upload_body = new FormData();
avatar_upload_body.append(
"file",
avatar,
encodeURIComponent(avatar.name),
);
const avatar_path = `/files/users/${user.id}/avatars/${encodeURIComponent(avatar.name)}`;
const avatar_upload_response = await api.fetch(avatar_path, {
method: "PUT",
body: avatar_upload_body,
});
if (!avatar_upload_response.ok) {
const error = await avatar_upload_response.json();
return alert(error?.error?.message ?? "Unknown error.");
}
updated_user.meta = updated_user.meta ?? {};
updated_user.meta.avatar = `//${window.location.host}${avatar_path}`;
break;
default:
const elements = input.name.split(".");
let current = updated_user;
for (const element of elements.slice(0, elements.length - 1)) {
current[element] = current[element] ?? {};
current = current[element];
}
current[elements.slice(elements.length - 1).shift()] =
input.value.trim();
break;
}
const saved_user_response = await api.fetch(`/api/users/${user.id}`, {
method: "PUT",
json: updated_user,
});
if (!saved_user_response.ok) {
const error = await avatar_upload_response.json();
return alert(error?.error?.message ?? "Unknown error.");
}
const saved_user = await saved_user_response.json();
APP.update_user( saved_user );
}
for (const input of inputs) {
function on_updated(event) {
if (input.__debounce_timeout) {
clearTimeout(input.__debounce_timeout);
}
input.__debounce_timeout = setTimeout(() => {
update_from_input(input);
}, 250);
}
input.addEventListener("input", on_updated);
input.addEventListener("change", on_updated);
}
});
</script>
<div class="avatar-container xx-large">
<img
id="user-avatar"
src="/images/default_avatar.gif"
alt="User Avatar"
data-bind-to-user-field="meta.avatar"
data-user-field-target="src"
data-user-field-default="/images/default_avatar.gif"
/>
<input type="file" accept="image/*" name="meta.avatar" />
</div>
<details class="additional-profile">
<summary>
<div class="username-container">
<span class="username" data-bind-to-user-field="username"></span>
</div>
</summary>
<div class="notifications-settings-container">
<button class="mockup" onclick="NOTIFICATIONS.request_permission()">
Enable Notifications
</button>
</div>
<div class="color-settings-container">
<input
type="text"
id="user-color-setting-primary"
name="meta.primary_color"
value=""
data-bind-to-user-field="meta.primary_color"
data-user-field-target="value"
data-user-field-default=""
/>
<label class="placeholder" for="user-color-setting-primary">Primary Color</label>
</div>
</details>
</form>
<button
style="text-transform: uppercase; width: 100%; padding: 1.1rem 0"
onclick="generate_invite(event)"
>
Invite Another Human
</button>
<form
data-smart="true"
data-method="DELETE"
action="/api/auth"
style="position: absolute; left: 1rem; right: 1rem; bottom: 1rem"
>
<script>
{
const form = document.currentScript.closest("form");
form.on_reply = (response) => {
if (!response.deleted) {
alert("error logging out? please reload.");
return;
}
APP.logout();
};
}
</script>
<button class="primary">Log Out</button>
</form>
<div class="topics-container">
<div style="margin-bottom: 1rem">
<span class="title">topics</span>
</div>
<ul id="topic-list" class="topic-list"></ul>
<div id="topic-creation-container" data-requires-permission="topics.create">
<button
id="toggle-topic-creation-form-button"
onclick="((event) => {
event.preventDefault();
const topic_create_form = document.getElementById( 'topic-create' );
topic_create_form.style[ 'height' ] = topic_create_form.style[ 'height' ] === '5rem' ? '0' : '5rem';
})(event)"
>
<div class="icon plus"></div>
</button>
<form
id="topic-create"
data-smart="true"
action="/api/topics"
method="POST"
style="
margin-top: 1rem;
width: 100%;
overflow: hidden;
height: 0;
overflow: hidden;
transition: all 0.5s;
"
>
<input
id="new-topic-name-input"
type="text"
name="name"
value=""
placeholder="new topic"
/>
<input type="submit" hidden />
<script>
{
const form = document.currentScript.closest("form");
const topic_create_form = document.getElementById("topic-create");
const new_topic_name_input =
document.getElementById("new-topic-name-input");
form.on_reply = (new_topic) => {
const topic_list = document.getElementById("topic-list");
topic_list.insertAdjacentHTML(
"beforeend",
`<li id="topic-selector-${new_topic.id}" class="topic"><a href="#/topic/${new_topic.id}">${new_topic.name}</a></li>`,
);
new_topic_name_input.value = "";
window.location.hash = `/topic/${new_topic.id}/chat`;
topic_create_form.style["height"] = "0";
};
}
</script>
</form>
</div>
</div>
</div>
</div>