glo/static/js/03-admin.js
2026-09-24 22:33:36 +00:00

690 lines
18 KiB
JavaScript

// static/js/03-admin.js
"use strict";
window.Glow = window.Glow || {};
Glow.closeAdminPanel = () => {
Glow.els.adminPanel.classList.remove("open");
Glow.els.adminPanel.setAttribute("aria-hidden", "true");
Glow.els.adminFab.focus();
};
Glow.renderAdminUsers = users => {
Glow.els.adminUsers.replaceChildren();
const heading = document.createElement("h3");
heading.textContent = "Users";
Glow.els.adminUsers.append(heading);
if (!users.length) {
const empty = document.createElement("p");
empty.className = "admin-empty";
empty.textContent = "No users found.";
Glow.els.adminUsers.append(empty);
return;
}
for (const user of users) {
const row = document.createElement("div");
row.className = "admin-user";
const details = document.createElement("div");
const name = document.createElement("div");
name.className = "admin-user-name";
name.textContent = `${user.username} · ${user.role}`;
const info = document.createElement("div");
info.className = "admin-user-info";
info.textContent =
`${user.status} · ${user.message_count} message(s)` +
(user.restricted_until
? ` · until ${new Date(user.restricted_until).toLocaleString()}`
: "");
details.append(name, info);
const actions = document.createElement("div");
actions.className = "admin-user-actions";
const current =
Glow.state.currentUser &&
Number(Glow.state.currentUser.id) === Number(user.id);
if (!current && Glow.isAdmin()) {
const role = document.createElement("button");
role.className = "small-button";
role.type = "button";
role.textContent =
user.role === "moderator" ? "Make user" : "Make moderator";
role.addEventListener("click", () => {
Glow.updateAdminUser(user.id, {
role: user.role === "moderator" ? "user" : "moderator"
});
});
const restrict = document.createElement("button");
restrict.className = "small-button";
restrict.type = "button";
restrict.textContent =
user.status === "restricted" ? "Unrestrict" : "Restrict";
restrict.addEventListener("click", () => {
Glow.toggleRestriction(user);
});
const blacklist = document.createElement("button");
blacklist.className = "small-button danger";
blacklist.type = "button";
blacklist.textContent =
user.status === "blacklisted" ? "Unblacklist" : "Blacklist";
blacklist.addEventListener("click", () => {
Glow.toggleBlacklist(user);
});
actions.append(role, restrict, blacklist);
}
row.append(details, actions);
Glow.els.adminUsers.append(row);
}
};
Glow.renderAdminRooms = rooms => {
Glow.els.adminRooms.replaceChildren();
const heading = document.createElement("h3");
heading.textContent = "Rooms";
Glow.els.adminRooms.append(heading);
if (!rooms.length) {
const empty = document.createElement("p");
empty.className = "admin-empty";
empty.textContent = "No rooms found.";
Glow.els.adminRooms.append(empty);
return;
}
for (const room of rooms) {
const row = document.createElement("div");
row.className = "admin-room";
const details = document.createElement("div");
const name = document.createElement("div");
name.className = "admin-room-name";
name.textContent = `${room.name} · ${room.visibility}`;
const info = document.createElement("div");
info.className = "admin-room-info";
info.textContent =
`/${room.slug}` +
(room.description ? ` · ${room.description}` : "");
details.append(name, info);
const actions = document.createElement("div");
actions.className = "admin-room-actions";
if (Glow.isAdmin()) {
const edit = document.createElement("button");
edit.className = "small-button";
edit.type = "button";
edit.textContent = "Edit";
edit.addEventListener("click", () => Glow.editRoom(room));
actions.append(edit);
if (room.visibility === "private") {
const members = document.createElement("button");
members.className = "small-button";
members.type = "button";
members.textContent = "Members";
members.addEventListener("click", () => {
Glow.manageRoomMembers(room);
});
const invite = document.createElement("button");
invite.className = "small-button";
invite.type = "button";
invite.textContent = "Invite link";
invite.addEventListener("click", () => {
Glow.createInviteLink(room);
});
actions.append(members, invite);
}
if (room.slug !== "general") {
const remove = document.createElement("button");
remove.className = "small-button danger";
remove.type = "button";
remove.textContent = "Delete";
remove.addEventListener("click", () => Glow.deleteRoom(room));
actions.append(remove);
}
}
row.append(details, actions);
Glow.els.adminRooms.append(row);
}
};
Glow.openAdminPanel = async () => {
if (!Glow.canModerate()) return;
Glow.els.adminPanel.classList.add("open");
Glow.els.adminPanel.setAttribute("aria-hidden", "false");
Glow.els.adminUsers.textContent = "Loading users…";
Glow.els.adminRooms.textContent = "Loading rooms…";
try {
const [usersData, roomsData] = await Promise.all([
Glow.apiRequest("/api/admin/users"),
Glow.apiRequest("/api/admin/rooms")
]);
Glow.renderAdminUsers(usersData.users || []);
Glow.renderAdminRooms(roomsData.rooms || []);
Glow.updateAdminPreferenceButtons();
setTimeout(() => Glow.els.closeAdminButton.focus(), 0);
} catch (error) {
Glow.showToast(error.message, "error");
}
};
Glow.updateThemeButton = () => {
if (!Glow.els.themeButton) return;
Glow.els.themeButton.textContent = "Accent color";
};
Glow.updateAdminPreferenceButtons = () => {
Glow.els.typingToggleButton.textContent =
`Typing indicators: ${Glow.state.typingIndicatorsEnabled ? "on" : "off"}`;
Glow.els.notificationsToggleButton.textContent =
`Notifications: ${Glow.state.notificationsEnabled ? "on" : "off"}`;
Glow.updateThemeButton();
};
Glow.openAccentColorPicker = async () => {
const container = document.createElement("div");
container.className = "accent-picker";
const description = document.createElement("p");
description.className = "accent-picker-description";
description.textContent =
"Choose an accent color for focus rings, buttons, selected controls, and highlighted mentions. It is stored only in this browser unless saved in account settings.";
const label = document.createElement("label");
label.className = "accent-picker-label";
label.textContent = "Accent color";
const row = document.createElement("div");
row.className = "accent-picker-row";
const colorInput = document.createElement("input");
colorInput.className = "accent-color-input";
colorInput.type = "color";
colorInput.value =
Glow.normalizeAccentColor(Glow.state.accentColor) ||
Glow.DEFAULT_ACCENT_COLOR;
colorInput.setAttribute("aria-label", "Choose accent color");
const hexInput = document.createElement("input");
hexInput.className = "field accent-hex-input";
hexInput.type = "text";
hexInput.value = colorInput.value;
hexInput.maxLength = 7;
hexInput.autocomplete = "off";
hexInput.autocapitalize = "off";
hexInput.spellcheck = false;
hexInput.placeholder = "#f7dce8";
hexInput.setAttribute(
"aria-label",
"Accent color hexadecimal value"
);
const preview = document.createElement("span");
preview.className = "accent-preview";
preview.style.background = colorInput.value;
preview.setAttribute("aria-hidden", "true");
const error = document.createElement("div");
error.className = "accent-picker-error";
error.setAttribute("role", "status");
const updatePreview = value => {
const normalized = Glow.normalizeAccentColor(value);
if (!normalized) {
preview.style.background = "transparent";
error.textContent =
"Use a six-digit hex color, such as #7dd3fc.";
return false;
}
preview.style.background = normalized;
error.textContent = "";
if (colorInput.value !== normalized) {
colorInput.value = normalized;
}
return true;
};
colorInput.addEventListener("input", () => {
const color = Glow.normalizeAccentColor(colorInput.value);
if (!color) return;
hexInput.value = color;
updatePreview(color);
});
hexInput.addEventListener("input", () => {
updatePreview(hexInput.value.trim());
});
row.append(colorInput, hexInput, preview);
label.append(row);
container.append(description, label, error);
const result = await Glow.openDialog({
title: "Accent color",
body: container,
confirmText: "Save color",
cancelText: "Cancel"
});
if (!result) return;
const accent = Glow.normalizeAccentColor(hexInput.value);
if (!accent) {
Glow.showToast(
"Choose a valid six-digit hexadecimal color.",
"error"
);
return;
}
Glow.applyAccentColor(accent);
Glow.showToast("Accent color updated.");
};
Glow.toggleRestriction = async user => {
if (user.status === "restricted") {
return Glow.updateAdminUser(user.id, {
status: "active",
restricted_until: null
});
}
const values = await Glow.openDialog({
title: `Restrict ${user.username}`,
body: "Enter the restriction duration in minutes.",
confirmText: "Restrict",
danger: true,
fields: [
{
name: "minutes",
label: "Duration in minutes",
type: "number",
value: "60",
maxLength: 5
}
]
});
if (!values) return;
const minutes = Number(values.minutes);
if (!Number.isFinite(minutes) || minutes < 1 || minutes > 43200) {
Glow.showToast(
"Enter a duration between 1 minute and 30 days.",
"error"
);
return;
}
await Glow.updateAdminUser(user.id, {
status: "restricted",
restricted_until: new Date(
Date.now() + minutes * 60 * 1000
).toISOString()
});
};
Glow.toggleBlacklist = async user => {
const blacklisting = user.status !== "blacklisted";
const result = await Glow.openDialog({
title: blacklisting
? `Blacklist ${user.username}?`
: `Unblacklist ${user.username}?`,
body: blacklisting
? "The account will be denied access immediately."
: "The account will be restored to active status.",
confirmText: blacklisting ? "Blacklist" : "Unblacklist",
danger: blacklisting
});
if (!result) return;
await Glow.updateAdminUser(user.id, {
status: blacklisting ? "blacklisted" : "active"
});
};
Glow.updateAdminUser = async (userId, changes) => {
try {
await Glow.apiRequest(`/api/admin/users/${encodeURIComponent(userId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes)
});
Glow.showToast("User updated.");
await Glow.openAdminPanel();
} catch (error) {
Glow.showToast(error.message, "error");
}
};
Glow.createRoom = async () => {
const values = await Glow.openDialog({
title: "Create room",
body: "Choose a room name, description, and visibility.",
confirmText: "Create",
fields: [
{
name: "name",
label: "Room name",
placeholder: "Project chat",
maxLength: 60
},
{
name: "description",
label: "Description",
placeholder: "Optional room description",
multiline: true,
rows: 3,
maxLength: 300
},
{
name: "visibility",
label: "Visibility: public or private",
value: "public",
maxLength: 7
}
]
});
if (!values) return;
const visibility = values.visibility.toLowerCase();
if (!["public", "private"].includes(visibility)) {
Glow.showToast("Visibility must be public or private.", "error");
return;
}
try {
await Glow.apiRequest("/api/rooms", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: values.name,
description: values.description,
visibility
})
});
Glow.showToast("Room created.");
await Glow.loadRooms();
await Glow.openAdminPanel();
} catch (error) {
Glow.showToast(error.message, "error");
}
};
Glow.editRoom = async room => {
const values = await Glow.openDialog({
title: `Edit ${room.name}`,
confirmText: "Save",
fields: [
{
name: "name",
label: "Room name",
value: room.name,
maxLength: 60
},
{
name: "description",
label: "Description",
value: room.description || "",
multiline: true,
rows: 3,
maxLength: 300
},
{
name: "visibility",
label: "Visibility: public or private",
value: room.visibility,
maxLength: 7
}
]
});
if (!values) return;
const visibility = values.visibility.toLowerCase();
if (!["public", "private"].includes(visibility)) {
Glow.showToast("Visibility must be public or private.", "error");
return;
}
await Glow.updateRoom(room.slug, {
name: values.name,
description: values.description,
visibility
});
};
Glow.updateRoom = async (slug, changes) => {
try {
await Glow.apiRequest(`/api/rooms/${encodeURIComponent(slug)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes)
});
Glow.showToast("Room updated.");
await Glow.loadRooms();
if (!Glow.state.activeDm) {
await Glow.loadMessages();
}
await Glow.openAdminPanel();
} catch (error) {
Glow.showToast(error.message, "error");
}
};
Glow.deleteRoom = async room => {
const result = await Glow.openDialog({
title: `Delete ${room.name}?`,
body: "This permanently deletes the room and every message in it.",
confirmText: "Delete room",
danger: true
});
if (!result) return;
try {
await Glow.apiRequest(`/api/rooms/${encodeURIComponent(room.slug)}`, {
method: "DELETE"
});
Glow.showToast("Room deleted.");
await Glow.loadRooms();
if (!Glow.state.activeDm) {
await Glow.loadMessages();
}
await Glow.openAdminPanel();
} catch (error) {
Glow.showToast(error.message, "error");
}
};
Glow.clearActiveRoom = async () => {
const room = Glow.state.activeRoom;
if (!room || Glow.state.activeDm) return;
const result = await Glow.openDialog({
title: `Clear ${room.name}?`,
body: "All messages in this room will be permanently deleted.",
confirmText: "Clear room",
danger: true
});
if (!result) return;
try {
await Glow.apiRequest(
`/api/rooms/${encodeURIComponent(room.slug)}/messages`,
{ method: "DELETE" }
);
Glow.clearMessages();
Glow.renderEmptyState();
Glow.showToast("Room messages cleared.");
} catch (error) {
Glow.showToast(error.message, "error");
}
};
Glow.manageRoomMembers = async room => {
try {
const data = await Glow.apiRequest(
`/api/rooms/${encodeURIComponent(room.slug)}/members`
);
const container = document.createElement("div");
container.className = "member-list";
for (const member of data.members || []) {
const row = document.createElement("div");
row.className = "member-row";
const name = document.createElement("span");
name.textContent = `${member.username} · ${member.membership_role}`;
const remove = document.createElement("button");
remove.className = "small-button danger";
remove.type = "button";
remove.textContent = "Remove";
remove.disabled = member.membership_role === "owner";
remove.addEventListener("click", async () => {
try {
await Glow.apiRequest(
`/api/rooms/${encodeURIComponent(room.slug)}/members/${member.id}`,
{ method: "DELETE" }
);
Glow.showToast("Member removed.");
Glow.closeDialog({});
Glow.manageRoomMembers(room);
} catch (error) {
Glow.showToast(error.message, "error");
}
});
row.append(name, remove);
container.append(row);
}
const values = await Glow.openDialog({
title: `${room.name} members`,
body: container,
confirmText: "Add member",
fields: [
{
name: "username",
label: "Username to add",
placeholder: "username",
maxLength: 30
}
]
});
if (!values?.username) return;
await Glow.apiRequest(
`/api/rooms/${encodeURIComponent(room.slug)}/members`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: values.username })
}
);
Glow.showToast("Member added.");
await Glow.manageRoomMembers(room);
} catch (error) {
Glow.showToast(error.message, "error");
}
};
Glow.createInviteLink = async room => {
try {
const data = await Glow.apiRequest(
`/api/rooms/${encodeURIComponent(room.slug)}/invites`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
}
);
const url = new URL(
data.invite.url,
window.location.origin
).toString();
const values = await Glow.openDialog({
title: "Private-room invite link",
body: "Copy and share this link. It can be used once before it expires.",
confirmText: "Copy link",
fields: [
{
name: "invite",
label: "Invite link",
value: url,
maxLength: 500
}
]
});
if (values) {
await navigator.clipboard.writeText(url);
Glow.showToast("Invite link copied.");
}
} catch (error) {
Glow.showToast(error.message, "error");
}
};