Initial commit
This commit is contained in:
commit
a1e3727c3f
33
Dockerfile
Normal file
33
Dockerfile
Normal file
@ -0,0 +1,33 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PORT=5000
|
||||
|
||||
COPY requirements.txt ./
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /app/data /app/data/uploads /tmp /run/gunicorn \
|
||||
&& chown -R 1000:1000 /app /tmp /run/gunicorn
|
||||
|
||||
USER 1000:1000
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["gunicorn", \
|
||||
"--bind", "0.0.0.0:5000", \
|
||||
"--workers", "1", \
|
||||
"--threads", "4", \
|
||||
"--timeout", "60", \
|
||||
"--graceful-timeout", "20", \
|
||||
"--keep-alive", "5", \
|
||||
"--worker-tmp-dir", "/tmp", \
|
||||
"--access-logfile", "-", \
|
||||
"--error-logfile", "-", \
|
||||
"app:app"]
|
||||
61
docker-compose.yml
Normal file
61
docker-compose.yml
Normal file
@ -0,0 +1,61 @@
|
||||
services:
|
||||
monet4:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: monet4
|
||||
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
environment:
|
||||
PORT: 5000
|
||||
FLASK_DEBUG: 0
|
||||
|
||||
CHAT_DB_PATH: /app/data/chat.db
|
||||
CHAT_UPLOAD_DIR: /app/data/uploads
|
||||
CHAT_SECRET_KEY_PATH: /app/data/flask_secret.key
|
||||
CHAT_CLIENT_HASH_KEY_PATH: /app/data/client_hash.key
|
||||
|
||||
SESSION_COOKIE_SECURE: 1
|
||||
TRUSTED_PROXY_HOPS: 1
|
||||
|
||||
GUNICORN_CMD_ARGS: "--worker-tmp-dir /tmp"
|
||||
|
||||
FLASK_SECRET_KEY: ${FLASK_SECRET_KEY:-}
|
||||
CHAT_CLIENT_HASH_KEY: ${CHAT_CLIENT_HASH_KEY:-}
|
||||
|
||||
volumes:
|
||||
- chat_data:/app/data
|
||||
|
||||
networks:
|
||||
- privatenet
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
read_only: true
|
||||
|
||||
tmpfs:
|
||||
- /tmp:size=64m,mode=1777
|
||||
- /run/gunicorn:size=16m,mode=1777
|
||||
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/api/csrf', timeout=5).read()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
labels:
|
||||
caddy: money.backend-3.com
|
||||
caddy.reverse_proxy: "{{upstreams 5000}}"
|
||||
|
||||
volumes:
|
||||
chat_data:
|
||||
|
||||
networks:
|
||||
privatenet:
|
||||
external: true
|
||||
1031
features.py
Normal file
1031
features.py
Normal file
File diff suppressed because it is too large
Load Diff
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@ -0,0 +1,4 @@
|
||||
flask
|
||||
werkzeug
|
||||
gunicorn
|
||||
markdown
|
||||
97
sanitizer.py
Normal file
97
sanitizer.py
Normal file
@ -0,0 +1,97 @@
|
||||
import html
|
||||
from html.parser import HTMLParser
|
||||
|
||||
|
||||
_ALLOWED_TAGS = {
|
||||
"b",
|
||||
"strong",
|
||||
"i",
|
||||
"em",
|
||||
"u",
|
||||
"p",
|
||||
"br",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"blockquote",
|
||||
"code",
|
||||
"pre",
|
||||
"a",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
}
|
||||
|
||||
_ALLOWED_ATTRS = {
|
||||
"a": {"href", "title", "target", "rel"},
|
||||
}
|
||||
|
||||
_ALLOWED_SCHEMES = ("http://", "https://", "mailto:")
|
||||
|
||||
|
||||
def _escape_attr(value: str) -> str:
|
||||
return html.escape(value, quote=True)
|
||||
|
||||
|
||||
def sanitize_html_fragment(raw: str) -> str:
|
||||
class SafeHtmlParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.parts = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
tag = tag.lower()
|
||||
if tag not in _ALLOWED_TAGS:
|
||||
return
|
||||
|
||||
allowed = _ALLOWED_ATTRS.get(tag, set())
|
||||
clean_attrs = []
|
||||
|
||||
for key, value in attrs:
|
||||
key = (key or "").lower()
|
||||
value = value or ""
|
||||
|
||||
if key not in allowed:
|
||||
continue
|
||||
|
||||
if tag == "a" and key == "href":
|
||||
lowered = value.strip().lower()
|
||||
if not lowered.startswith(_ALLOWED_SCHEMES):
|
||||
continue
|
||||
|
||||
clean_attrs.append((key, value))
|
||||
|
||||
if tag == "a":
|
||||
attr_keys = {key for key, _ in clean_attrs}
|
||||
if "target" not in attr_keys:
|
||||
clean_attrs.append(("target", "_blank"))
|
||||
if "rel" not in attr_keys:
|
||||
clean_attrs.append(("rel", "noopener noreferrer"))
|
||||
|
||||
attr_text = "".join(
|
||||
f' {key}="{_escape_attr(value)}"'
|
||||
for key, value in clean_attrs
|
||||
)
|
||||
self.parts.append(f"<{tag}{attr_text}>")
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
tag = tag.lower()
|
||||
if tag in _ALLOWED_TAGS and tag not in {"br"}:
|
||||
self.parts.append(f"</{tag}>")
|
||||
|
||||
def handle_data(self, data):
|
||||
self.parts.append(html.escape(data))
|
||||
|
||||
def handle_entityref(self, name):
|
||||
self.parts.append(f"&{name};")
|
||||
|
||||
def handle_charref(self, name):
|
||||
self.parts.append(f"&#{name};")
|
||||
|
||||
parser = SafeHtmlParser()
|
||||
parser.feed(raw or "")
|
||||
parser.close()
|
||||
return "".join(parser.parts)
|
||||
2
static/js/.env
Normal file
2
static/js/.env
Normal file
@ -0,0 +1,2 @@
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=StrongPass123!
|
||||
621
static/js/00-config.js
Normal file
621
static/js/00-config.js
Normal file
@ -0,0 +1,621 @@
|
||||
// static/js/00-config.js
|
||||
"use strict";
|
||||
|
||||
window.Glow = window.Glow || {};
|
||||
|
||||
Glow.els = {
|
||||
chatBox: document.getElementById("chatBox"),
|
||||
dmBox: document.getElementById("dmBox"),
|
||||
feedBox: document.getElementById("feedBox"),
|
||||
feedButton: document.getElementById("feedButton"),
|
||||
dmsButton: document.getElementById("dmsButton"),
|
||||
composerWrap: document.getElementById("composerWrap"),
|
||||
chatForm: document.getElementById("chatForm"),
|
||||
messageInput: document.getElementById("messageInput"),
|
||||
sendButton: document.getElementById("sendButton"),
|
||||
messageCount: document.getElementById("messageCount"),
|
||||
attachmentInput: document.getElementById("attachmentInput"),
|
||||
attachmentButton: document.getElementById("attachmentButton"),
|
||||
attachmentName: document.getElementById("attachmentName"),
|
||||
authOverlay: document.getElementById("authOverlay"),
|
||||
authNotice: document.getElementById("authNotice"),
|
||||
usernameInput: document.getElementById("usernameInput"),
|
||||
passwordInput: document.getElementById("passwordInput"),
|
||||
loginButton: document.getElementById("loginButton"),
|
||||
registerButton: document.getElementById("registerButton"),
|
||||
roomSelect: document.getElementById("roomSelect"),
|
||||
headerTitle: document.getElementById("headerTitle"),
|
||||
headerSubtitle: document.getElementById("headerSubtitle"),
|
||||
headerAccount: document.getElementById("headerAccount"),
|
||||
presenceDot: document.getElementById("presenceDot"),
|
||||
presenceLabel: document.getElementById("presenceLabel"),
|
||||
logoutButton: document.getElementById("logoutButton"),
|
||||
adminFab: document.getElementById("adminFab"),
|
||||
adminPanel: document.getElementById("adminPanel"),
|
||||
closeAdminButton: document.getElementById("closeAdminButton"),
|
||||
clearMessagesButton: document.getElementById("clearMessagesButton"),
|
||||
createRoomButton: document.getElementById("createRoomButton"),
|
||||
typingToggleButton: document.getElementById("typingToggleButton"),
|
||||
notificationsToggleButton: document.getElementById(
|
||||
"notificationsToggleButton"
|
||||
),
|
||||
themeButton: document.getElementById("themeButton"),
|
||||
adminUsers: document.getElementById("adminUsers"),
|
||||
adminRooms: document.getElementById("adminRooms"),
|
||||
statusToast: document.getElementById("statusToast"),
|
||||
blockedOverlay: document.getElementById("blockedOverlay"),
|
||||
blockedMessage: document.getElementById("blockedMessage"),
|
||||
newMessagesBar: document.getElementById("newMessagesBar"),
|
||||
jumpToLatestButton: document.getElementById("jumpToLatestButton"),
|
||||
typingBar: document.getElementById("typingBar"),
|
||||
dialogOverlay: document.getElementById("dialogOverlay"),
|
||||
dialogTitle: document.getElementById("dialogTitle"),
|
||||
dialogBody: document.getElementById("dialogBody"),
|
||||
dialogActions: document.getElementById("dialogActions"),
|
||||
|
||||
teamSelect: document.getElementById("teamSelect"),
|
||||
pagesButton: document.getElementById("pagesButton"),
|
||||
backButton: document.getElementById("backButton"),
|
||||
threadBox: document.getElementById("threadBox"),
|
||||
threadComposerWrap: document.getElementById("threadComposerWrap"),
|
||||
threadForm: document.getElementById("threadForm"),
|
||||
threadInput: document.getElementById("threadInput"),
|
||||
threadSendButton: document.getElementById("threadSendButton"),
|
||||
threadMessageCount: document.getElementById("threadMessageCount"),
|
||||
threadAttachmentInput: document.getElementById("threadAttachmentInput"),
|
||||
threadAttachmentButton: document.getElementById("threadAttachmentButton"),
|
||||
threadAttachmentName: document.getElementById("threadAttachmentName"),
|
||||
pagesBox: document.getElementById("pagesBox"),
|
||||
pageDetailBox: document.getElementById("pageDetailBox"),
|
||||
|
||||
settingsButton: document.getElementById("settingsButton"),
|
||||
profileBox: document.getElementById("profileBox"),
|
||||
|
||||
headerMenuOverlay: document.getElementById("headerMenuOverlay"),
|
||||
headerMenuList: document.getElementById("headerMenuList"),
|
||||
|
||||
reactionPopupOverlay: document.getElementById("reactionPopupOverlay"),
|
||||
reactionPopupGrid: document.getElementById("reactionPopupGrid"),
|
||||
reactionPopupList: document.getElementById("reactionPopupList"),
|
||||
reactionCustomInput: document.getElementById("reactionCustomInput"),
|
||||
reactionCustomButton: document.getElementById("reactionCustomButton"),
|
||||
closeReactionPopupButton: document.getElementById(
|
||||
"closeReactionPopupButton"
|
||||
),
|
||||
|
||||
mediaOverlay: document.getElementById("mediaOverlay"),
|
||||
mediaTitle: document.getElementById("mediaTitle"),
|
||||
mediaImage: document.getElementById("mediaImage"),
|
||||
mediaAudio: document.getElementById("mediaAudio"),
|
||||
mediaVideo: document.getElementById("mediaVideo"),
|
||||
mediaFileFallback: document.getElementById("mediaFileFallback"),
|
||||
mediaFallbackLink: document.getElementById("mediaFallbackLink"),
|
||||
mediaDownloadLink: document.getElementById("mediaDownloadLink"),
|
||||
closeMediaButton: document.getElementById("closeMediaButton"),
|
||||
|
||||
callOverlay: document.getElementById("callOverlay"),
|
||||
callStatus: document.getElementById("callStatus"),
|
||||
callPeerLabel: document.getElementById("callPeerLabel"),
|
||||
startCallButton: document.getElementById("startCallButton"),
|
||||
answerCallButton: document.getElementById("answerCallButton"),
|
||||
muteCallButton: document.getElementById("muteCallButton"),
|
||||
hangupCallButton: document.getElementById("hangupCallButton"),
|
||||
closeCallPanelButton: document.getElementById("closeCallPanelButton"),
|
||||
remoteAudio: document.getElementById("remoteAudio")
|
||||
};
|
||||
|
||||
Glow.state = {
|
||||
currentUser: null,
|
||||
csrfToken: "",
|
||||
|
||||
rooms: [],
|
||||
activeRoom: null,
|
||||
|
||||
teams: [],
|
||||
activeTeam: null,
|
||||
channels: [],
|
||||
activeChannel: null,
|
||||
|
||||
dms: [],
|
||||
activeDm: null,
|
||||
|
||||
activeView: "feed",
|
||||
|
||||
lastSeenId: 0,
|
||||
knownMessageIds: new Set(),
|
||||
renderedMessages: [],
|
||||
messageRequestId: 0,
|
||||
channelRequestId: 0,
|
||||
dmRequestId: 0,
|
||||
pollInFlight: false,
|
||||
channelPollInFlight: false,
|
||||
dmPollInFlight: false,
|
||||
|
||||
feedLastSeenId: 0,
|
||||
feedKnownMessageIds: new Set(),
|
||||
feedEntries: [],
|
||||
feedRequestId: 0,
|
||||
feedPollInFlight: false,
|
||||
|
||||
activeThread: null,
|
||||
threadReplies: [],
|
||||
threadRequestId: 0,
|
||||
threadPollInFlight: false,
|
||||
|
||||
pages: [],
|
||||
activePage: null,
|
||||
pageTypeFilter: "",
|
||||
|
||||
activeProfile: null,
|
||||
settings: null,
|
||||
|
||||
pollTimer: null,
|
||||
initialized: false,
|
||||
|
||||
toastTimer: null,
|
||||
|
||||
selectedAttachment: [],
|
||||
selectedThreadAttachment: [],
|
||||
|
||||
typingTimer: null,
|
||||
typingLastSent: 0,
|
||||
typingUsers: [],
|
||||
dmTypingUsers: [],
|
||||
|
||||
typingIndicatorsEnabled:
|
||||
localStorage.getItem("glow_typing_enabled") !== "false",
|
||||
|
||||
notificationsEnabled:
|
||||
localStorage.getItem("glow_notifications_enabled") === "true",
|
||||
|
||||
accentColor:
|
||||
localStorage.getItem("glow_accent_color") || "#f7dce8",
|
||||
|
||||
unreadMessages: 0,
|
||||
originalTitle: document.title,
|
||||
|
||||
dialogResolver: null,
|
||||
lastFocusedElement: null,
|
||||
|
||||
presence: {
|
||||
roomOnlineCount: 0,
|
||||
channelOnlineCount: 0,
|
||||
dmOnlineCount: 0
|
||||
},
|
||||
|
||||
calls: {
|
||||
activeCall: null,
|
||||
peerConnection: null,
|
||||
localStream: null,
|
||||
muted: false,
|
||||
signalLastSeenId: 0,
|
||||
pollTimer: null,
|
||||
incomingCall: null,
|
||||
pendingOffer: null
|
||||
}
|
||||
};
|
||||
|
||||
Glow.MAX_MESSAGE_LENGTH = 1000;
|
||||
Glow.MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
|
||||
Glow.DEFAULT_ACCENT_COLOR = "#f7dce8";
|
||||
|
||||
Glow.normalizeAccentColor = value => {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const color = value.trim();
|
||||
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(color)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return color.toLowerCase();
|
||||
};
|
||||
|
||||
Glow.normalizeNameColor = value => {
|
||||
if (value == null || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const color = value.trim();
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(color)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return color.toLowerCase();
|
||||
};
|
||||
|
||||
Glow.hexToRgb = hex => {
|
||||
const normalized = Glow.normalizeAccentColor(hex);
|
||||
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
r: parseInt(normalized.slice(1, 3), 16),
|
||||
g: parseInt(normalized.slice(3, 5), 16),
|
||||
b: parseInt(normalized.slice(5, 7), 16)
|
||||
};
|
||||
};
|
||||
|
||||
Glow.relativeLuminance = hex => {
|
||||
const rgb = Glow.hexToRgb(hex);
|
||||
|
||||
if (!rgb) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const channel = value => {
|
||||
const normalized = value / 255;
|
||||
|
||||
return normalized <= 0.03928
|
||||
? normalized / 12.92
|
||||
: Math.pow((normalized + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
|
||||
return (
|
||||
0.2126 * channel(rgb.r) +
|
||||
0.7152 * channel(rgb.g) +
|
||||
0.0722 * channel(rgb.b)
|
||||
);
|
||||
};
|
||||
|
||||
Glow.accentTextColor = hex => {
|
||||
return Glow.relativeLuminance(hex) >= 0.18 ? hex : "#f2f2f4";
|
||||
};
|
||||
|
||||
Glow.applyAccentColor = value => {
|
||||
const accent =
|
||||
Glow.normalizeAccentColor(value) || Glow.DEFAULT_ACCENT_COLOR;
|
||||
|
||||
const rgb = Glow.hexToRgb(accent);
|
||||
const root = document.documentElement;
|
||||
|
||||
Glow.state.accentColor = accent;
|
||||
|
||||
root.style.setProperty("--accent", accent);
|
||||
root.style.setProperty("--accent-active", Glow.accentTextColor(accent));
|
||||
root.style.setProperty(
|
||||
"--accent-soft",
|
||||
`rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.16)`
|
||||
);
|
||||
root.style.setProperty(
|
||||
"--accent-hover",
|
||||
`rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.28)`
|
||||
);
|
||||
|
||||
localStorage.setItem("glow_accent_color", accent);
|
||||
|
||||
Glow.updateThemeButton?.();
|
||||
};
|
||||
|
||||
Glow.resetAccentColor = () => {
|
||||
localStorage.removeItem("glow_accent_color");
|
||||
Glow.applyAccentColor(Glow.DEFAULT_ACCENT_COLOR);
|
||||
};
|
||||
|
||||
Glow.roleDefaultNameColor = role => {
|
||||
switch (String(role || "").toLowerCase()) {
|
||||
case "admin":
|
||||
return "#ff6b6b";
|
||||
case "moderator":
|
||||
return "#7dd3fc";
|
||||
default:
|
||||
return "#f5f5f7";
|
||||
}
|
||||
};
|
||||
|
||||
Glow.resolveUserNameColor = userish => {
|
||||
const explicit =
|
||||
Glow.normalizeNameColor(userish?.name_color) ||
|
||||
Glow.normalizeNameColor(userish?.user_name_color);
|
||||
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
return Glow.roleDefaultNameColor(userish?.role || userish?.user_role);
|
||||
};
|
||||
|
||||
Glow.applyUserColor = (element, userish) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.style.color = Glow.resolveUserNameColor(userish);
|
||||
};
|
||||
|
||||
Glow.canModerate = () => {
|
||||
const user = Glow.state.currentUser;
|
||||
return Boolean(user && ["moderator", "admin"].includes(user.role));
|
||||
};
|
||||
|
||||
Glow.isAdmin = () => {
|
||||
const user = Glow.state.currentUser;
|
||||
return Boolean(user && user.role === "admin");
|
||||
};
|
||||
|
||||
Glow.showToast = (message, type = "") => {
|
||||
clearTimeout(Glow.state.toastTimer);
|
||||
|
||||
Glow.els.statusToast.textContent = String(message);
|
||||
Glow.els.statusToast.className = `status-toast show ${type}`.trim();
|
||||
|
||||
Glow.state.toastTimer = setTimeout(() => {
|
||||
Glow.els.statusToast.className = "status-toast";
|
||||
}, 3500);
|
||||
};
|
||||
|
||||
Glow.showAuthNotice = (message, type = "error") => {
|
||||
Glow.els.authNotice.textContent = String(message);
|
||||
Glow.els.authNotice.className = `notice show ${type}`;
|
||||
};
|
||||
|
||||
Glow.clearAuthNotice = () => {
|
||||
Glow.els.authNotice.textContent = "";
|
||||
Glow.els.authNotice.className = "notice";
|
||||
};
|
||||
|
||||
Glow.showBlocked = message => {
|
||||
Glow.els.blockedMessage.textContent = message || "Access has been blocked.";
|
||||
|
||||
Glow.els.blockedOverlay.classList.remove("hidden");
|
||||
Glow.els.authOverlay.classList.add("hidden");
|
||||
|
||||
Glow.els.messageInput.disabled = true;
|
||||
Glow.els.sendButton.disabled = true;
|
||||
Glow.els.roomSelect.disabled = true;
|
||||
Glow.els.attachmentInput.disabled = true;
|
||||
|
||||
Glow.els.attachmentButton.classList.add("disabled");
|
||||
|
||||
clearTimeout(Glow.state.pollTimer);
|
||||
};
|
||||
|
||||
Glow.relativeTime = iso => {
|
||||
const seconds = Math.max(
|
||||
0,
|
||||
Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
|
||||
);
|
||||
|
||||
if (seconds < 5) {
|
||||
return "now";
|
||||
}
|
||||
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours < 24) {
|
||||
return `${hours}h`;
|
||||
}
|
||||
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
};
|
||||
|
||||
Glow.formatDateLabel = iso => {
|
||||
const date = new Date(iso);
|
||||
const now = new Date();
|
||||
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
const target = new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate()
|
||||
);
|
||||
|
||||
const diff = Math.round((today - target) / 86400000);
|
||||
|
||||
if (diff === 0) {
|
||||
return "Today";
|
||||
}
|
||||
|
||||
if (diff === 1) {
|
||||
return "Yesterday";
|
||||
}
|
||||
|
||||
return date.toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric"
|
||||
});
|
||||
};
|
||||
|
||||
Glow.dateKey = iso => {
|
||||
const date = new Date(iso);
|
||||
|
||||
return [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, "0"),
|
||||
String(date.getDate()).padStart(2, "0")
|
||||
].join("-");
|
||||
};
|
||||
|
||||
Glow.isNearBottom = () => {
|
||||
const activeBox =
|
||||
Glow.state.activeView === "feed"
|
||||
? Glow.els.feedBox
|
||||
: Glow.state.activeView === "thread"
|
||||
? Glow.els.threadBox
|
||||
: Glow.state.activeView === "dms"
|
||||
? Glow.els.dmBox
|
||||
: Glow.els.chatBox;
|
||||
|
||||
if (!activeBox) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return activeBox.scrollHeight - activeBox.scrollTop - activeBox.clientHeight < 100;
|
||||
};
|
||||
|
||||
Glow.autoResizeComposer = () => {
|
||||
const input = Glow.els.messageInput;
|
||||
input.style.height = "auto";
|
||||
input.style.height = `${Math.min(input.scrollHeight, 150)}px`;
|
||||
};
|
||||
|
||||
Glow.updateMessageCount = () => {
|
||||
const count = Glow.els.messageInput.value.length;
|
||||
Glow.els.messageCount.textContent = `${count} / ${Glow.MAX_MESSAGE_LENGTH}`;
|
||||
};
|
||||
|
||||
Glow.updateDocumentTitle = () => {
|
||||
if (!Glow.state.unreadMessages) {
|
||||
document.title = Glow.state.originalTitle;
|
||||
return;
|
||||
}
|
||||
|
||||
document.title = `(${Glow.state.unreadMessages}) ` + Glow.state.originalTitle;
|
||||
};
|
||||
|
||||
Glow.resetUnreadMessages = () => {
|
||||
Glow.state.unreadMessages = 0;
|
||||
Glow.updateDocumentTitle();
|
||||
Glow.els.newMessagesBar.classList.add("hidden");
|
||||
};
|
||||
|
||||
Glow.showNewMessages = () => {
|
||||
Glow.els.newMessagesBar.classList.remove("hidden");
|
||||
};
|
||||
|
||||
Glow.normalizeSelectedFiles = fileList => {
|
||||
if (!fileList) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...fileList].filter(Boolean);
|
||||
};
|
||||
|
||||
Glow.openDialog = ({
|
||||
title,
|
||||
body,
|
||||
confirmText = "Confirm",
|
||||
cancelText = "Cancel",
|
||||
danger = false,
|
||||
fields = []
|
||||
}) => {
|
||||
if (Glow.state.dialogResolver) {
|
||||
Glow.closeDialog(null);
|
||||
}
|
||||
|
||||
Glow.state.lastFocusedElement = document.activeElement;
|
||||
|
||||
Glow.els.dialogTitle.textContent = title;
|
||||
Glow.els.dialogBody.replaceChildren();
|
||||
Glow.els.dialogActions.replaceChildren();
|
||||
|
||||
if (typeof body === "string") {
|
||||
const paragraph = document.createElement("p");
|
||||
paragraph.textContent = body;
|
||||
Glow.els.dialogBody.append(paragraph);
|
||||
} else if (body instanceof Node) {
|
||||
Glow.els.dialogBody.append(body);
|
||||
}
|
||||
|
||||
const fieldElements = {};
|
||||
|
||||
for (const field of fields) {
|
||||
const label = document.createElement("label");
|
||||
label.className = "dialog-label";
|
||||
label.textContent = field.label;
|
||||
|
||||
const isMultiline = Boolean(field.multiline);
|
||||
const input = document.createElement(isMultiline ? "textarea" : "input");
|
||||
|
||||
input.className = "field";
|
||||
input.name = field.name;
|
||||
input.value = field.value || "";
|
||||
input.placeholder = field.placeholder || "";
|
||||
|
||||
if (isMultiline) {
|
||||
input.rows = field.rows || 3;
|
||||
input.maxLength = field.maxLength || 300;
|
||||
} else {
|
||||
input.type = field.type || "text";
|
||||
input.maxLength = field.maxLength || 300;
|
||||
|
||||
if (field.type === "number") {
|
||||
input.min = field.min || "1";
|
||||
input.max = field.max || "43200";
|
||||
input.step = field.step || "1";
|
||||
}
|
||||
}
|
||||
|
||||
label.append(input);
|
||||
Glow.els.dialogBody.append(label);
|
||||
fieldElements[field.name] = input;
|
||||
}
|
||||
|
||||
const cancel = document.createElement("button");
|
||||
cancel.className = "button secondary";
|
||||
cancel.type = "button";
|
||||
cancel.textContent = cancelText;
|
||||
|
||||
const confirmButton = document.createElement("button");
|
||||
confirmButton.className = danger ? "danger-button" : "button";
|
||||
confirmButton.type = "button";
|
||||
confirmButton.textContent = confirmText;
|
||||
|
||||
Glow.els.dialogActions.append(cancel, confirmButton);
|
||||
Glow.els.dialogOverlay.classList.remove("hidden");
|
||||
|
||||
return new Promise(resolve => {
|
||||
Glow.state.dialogResolver = resolve;
|
||||
|
||||
cancel.addEventListener(
|
||||
"click",
|
||||
() => Glow.closeDialog(null),
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
confirmButton.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
const values = {};
|
||||
|
||||
for (const [name, input] of Object.entries(fieldElements)) {
|
||||
values[name] = input.value.trim();
|
||||
}
|
||||
|
||||
Glow.closeDialog(values);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
const first = Object.values(fieldElements)[0] || confirmButton;
|
||||
first.focus();
|
||||
}, 0);
|
||||
});
|
||||
};
|
||||
|
||||
Glow.closeDialog = (result = null) => {
|
||||
Glow.els.dialogOverlay.classList.add("hidden");
|
||||
|
||||
const resolve = Glow.state.dialogResolver;
|
||||
Glow.state.dialogResolver = null;
|
||||
|
||||
if (resolve) {
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
if (Glow.state.lastFocusedElement instanceof HTMLElement) {
|
||||
Glow.state.lastFocusedElement.focus();
|
||||
}
|
||||
};
|
||||
405
static/js/01-api.js
Normal file
405
static/js/01-api.js
Normal file
@ -0,0 +1,405 @@
|
||||
// static/js/01-api.js
|
||||
"use strict";
|
||||
|
||||
window.Glow = window.Glow || {};
|
||||
|
||||
Glow.refreshCsrfToken = async () => {
|
||||
const response = await fetch("/api/csrf", {
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Accept: "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Could not refresh security token.");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
Glow.state.csrfToken = data.csrf_token || "";
|
||||
};
|
||||
|
||||
Glow.handleUnauthorized = () => {
|
||||
Glow.state.currentUser = null;
|
||||
Glow.state.csrfToken = "";
|
||||
Glow.state.rooms = [];
|
||||
Glow.state.activeRoom = null;
|
||||
Glow.state.teams = [];
|
||||
Glow.state.activeTeam = null;
|
||||
Glow.state.channels = [];
|
||||
Glow.state.activeChannel = null;
|
||||
Glow.state.dms = [];
|
||||
Glow.state.activeDm = null;
|
||||
Glow.state.activeThread = null;
|
||||
Glow.state.threadReplies = [];
|
||||
Glow.state.pages = [];
|
||||
Glow.state.activePage = null;
|
||||
Glow.state.pageTypeFilter = "";
|
||||
Glow.state.activeProfile = null;
|
||||
Glow.state.settings = null;
|
||||
Glow.state.selectedAttachment = [];
|
||||
Glow.state.selectedThreadAttachment = [];
|
||||
Glow.state.typingUsers = [];
|
||||
Glow.state.dmTypingUsers = [];
|
||||
Glow.state.pollInFlight = false;
|
||||
Glow.state.channelPollInFlight = false;
|
||||
Glow.state.dmPollInFlight = false;
|
||||
Glow.state.feedPollInFlight = false;
|
||||
Glow.state.threadPollInFlight = false;
|
||||
Glow.state.messageRequestId += 1;
|
||||
Glow.state.channelRequestId += 1;
|
||||
Glow.state.dmRequestId += 1;
|
||||
Glow.state.feedRequestId += 1;
|
||||
Glow.state.threadRequestId += 1;
|
||||
Glow.state.calls.activeCall = null;
|
||||
Glow.state.calls.signalLastSeenId = 0;
|
||||
Glow.state.calls.incomingCall = null;
|
||||
Glow.state.calls.pendingOffer = null;
|
||||
|
||||
clearTimeout(Glow.state.pollTimer);
|
||||
clearTimeout(Glow.state.typingTimer);
|
||||
clearTimeout(Glow.state.calls.pollTimer);
|
||||
|
||||
Glow.clearMessages?.();
|
||||
Glow.clearFeed?.();
|
||||
Glow.clearDms?.();
|
||||
Glow.closeHeaderMenu?.();
|
||||
Glow.closeReactionPopup?.();
|
||||
Glow.closeMediaModal?.();
|
||||
Glow.endLocalCallUi?.();
|
||||
|
||||
Glow.setSelectedAttachment?.([]);
|
||||
Glow.setSelectedThreadAttachment?.([]);
|
||||
|
||||
Glow.els.threadBox?.replaceChildren();
|
||||
Glow.els.pagesBox?.replaceChildren();
|
||||
Glow.els.pageDetailBox?.replaceChildren();
|
||||
Glow.els.profileBox?.replaceChildren();
|
||||
|
||||
if (Glow.els.teamSelect) {
|
||||
Glow.els.teamSelect.replaceChildren();
|
||||
Glow.els.teamSelect.disabled = true;
|
||||
}
|
||||
|
||||
if (Glow.els.roomSelect) {
|
||||
Glow.els.roomSelect.replaceChildren();
|
||||
Glow.els.roomSelect.disabled = true;
|
||||
}
|
||||
|
||||
if (Glow.els.typingBar) {
|
||||
Glow.els.typingBar.textContent = "";
|
||||
Glow.els.typingBar.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (Glow.els.presenceLabel) {
|
||||
Glow.els.presenceLabel.textContent = "";
|
||||
Glow.els.presenceLabel.classList.add("hidden");
|
||||
}
|
||||
|
||||
Glow.state.activeView = "feed";
|
||||
|
||||
Glow.updateAuthUI?.();
|
||||
Glow.showAuthNotice?.("Your session has expired. Please sign in again.");
|
||||
Glow.els.authOverlay.classList.remove("hidden");
|
||||
};
|
||||
|
||||
Glow.apiRequest = async (url, options = {}, retryCsrf = true) => {
|
||||
const method = (options.method || "GET").toUpperCase();
|
||||
const headers = new Headers(options.headers || {});
|
||||
headers.set("Accept", "application/json");
|
||||
|
||||
if (!["GET", "HEAD"].includes(method)) {
|
||||
headers.set("X-CSRF-Token", Glow.state.csrfToken || "");
|
||||
}
|
||||
|
||||
let response;
|
||||
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
credentials: "same-origin",
|
||||
...options,
|
||||
method,
|
||||
headers
|
||||
});
|
||||
} catch (_) {
|
||||
const error = new Error("Network error. Check your connection and try again.");
|
||||
error.code = "network";
|
||||
throw error;
|
||||
}
|
||||
|
||||
let data = {};
|
||||
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (_) {
|
||||
data = {};
|
||||
}
|
||||
|
||||
if (response.status === 403 && data.code === "blacklisted") {
|
||||
Glow.showBlocked(data.error);
|
||||
throw new Error(data.error || "Access blocked.");
|
||||
}
|
||||
|
||||
if (response.status === 403 && data.code === "csrf" && retryCsrf) {
|
||||
await Glow.refreshCsrfToken();
|
||||
return Glow.apiRequest(url, options, false);
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
Glow.handleUnauthorized();
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(data.error || "Request failed.");
|
||||
error.status = response.status;
|
||||
error.code = data.code;
|
||||
error.retryAfter = data.retry_after;
|
||||
error.restrictedUntil = data.restricted_until;
|
||||
error.data = data;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (data.csrf_token) {
|
||||
Glow.state.csrfToken = data.csrf_token;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/* profile/settings */
|
||||
Glow.apiGetMyProfile = () => Glow.apiRequest("/api/profile");
|
||||
Glow.apiUpdateMyProfile = payload =>
|
||||
Glow.apiRequest("/api/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
Glow.apiGetPublicProfile = username =>
|
||||
Glow.apiRequest(`/api/profiles/${encodeURIComponent(username)}`);
|
||||
|
||||
Glow.apiGetMySettings = () => Glow.apiRequest("/api/settings");
|
||||
Glow.apiUpdateMySettings = payload =>
|
||||
Glow.apiRequest("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
/* teams/channels */
|
||||
Glow.apiCreateTeam = payload =>
|
||||
Glow.apiRequest("/api/teams", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
Glow.apiUpdateTeam = (teamSlug, payload) =>
|
||||
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
Glow.apiCreateChannel = (teamSlug, payload) =>
|
||||
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/channels`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
Glow.apiUpdateChannel = (teamSlug, channelSlug, payload) =>
|
||||
Glow.apiRequest(
|
||||
`/api/teams/${encodeURIComponent(teamSlug)}/channels/${encodeURIComponent(channelSlug)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
|
||||
Glow.apiDeleteChannel = (teamSlug, channelSlug) =>
|
||||
Glow.apiRequest(
|
||||
`/api/teams/${encodeURIComponent(teamSlug)}/channels/${encodeURIComponent(channelSlug)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
|
||||
Glow.apiListTeamMembers = teamSlug =>
|
||||
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/members`);
|
||||
|
||||
Glow.apiAddTeamMember = (teamSlug, payload) =>
|
||||
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/members`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
Glow.apiUpdateTeamMember = (teamSlug, userId, payload) =>
|
||||
Glow.apiRequest(
|
||||
`/api/teams/${encodeURIComponent(teamSlug)}/members/${encodeURIComponent(userId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
|
||||
Glow.apiRemoveTeamMember = (teamSlug, userId) =>
|
||||
Glow.apiRequest(
|
||||
`/api/teams/${encodeURIComponent(teamSlug)}/members/${encodeURIComponent(userId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
|
||||
Glow.apiCreateTeamInvite = (teamSlug, payload = {}) =>
|
||||
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/invites`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
Glow.apiAcceptTeamInvite = token =>
|
||||
Glow.apiRequest(`/api/team-invites/${encodeURIComponent(token)}/accept`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
/* rooms */
|
||||
Glow.apiCreateRoom = payload =>
|
||||
Glow.apiRequest("/api/rooms", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
Glow.apiUpdateRoom = (roomSlug, payload) =>
|
||||
Glow.apiRequest(`/api/rooms/${encodeURIComponent(roomSlug)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
/* reactions */
|
||||
Glow.apiGetMessageReactions = messageId =>
|
||||
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/reactions`);
|
||||
|
||||
Glow.apiAddReaction = (messageId, emoji) =>
|
||||
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/reactions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ emoji })
|
||||
});
|
||||
|
||||
Glow.apiRemoveReaction = (messageId, emoji) =>
|
||||
Glow.apiRequest(
|
||||
`/api/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(emoji)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
|
||||
/* message editing / receipts / search / presence */
|
||||
Glow.apiEditMessage = (messageId, payload) =>
|
||||
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
Glow.apiSearch = query =>
|
||||
Glow.apiRequest(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
|
||||
Glow.apiMarkRead = messageIds =>
|
||||
Glow.apiRequest("/api/read-receipts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message_ids: messageIds })
|
||||
});
|
||||
|
||||
Glow.apiGetMessageReceipts = messageId =>
|
||||
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/receipts`);
|
||||
|
||||
Glow.apiPingPresence = payload =>
|
||||
Glow.apiRequest("/api/presence", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
Glow.apiGetRoomPresence = roomSlug =>
|
||||
Glow.apiRequest(`/api/rooms/${encodeURIComponent(roomSlug)}/presence`);
|
||||
|
||||
Glow.apiGetChannelPresence = (teamSlug, channelSlug) =>
|
||||
Glow.apiRequest(
|
||||
`/api/teams/${encodeURIComponent(teamSlug)}/channels/${encodeURIComponent(channelSlug)}/presence`
|
||||
);
|
||||
|
||||
Glow.apiGetDmPresence = dmId =>
|
||||
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/presence`);
|
||||
|
||||
Glow.apiLoadHistory = params => {
|
||||
const qs = new URLSearchParams(params);
|
||||
return Glow.apiRequest(`/api/messages/history?${qs.toString()}`);
|
||||
};
|
||||
|
||||
/* DMs */
|
||||
Glow.apiListDms = () => Glow.apiRequest("/api/dms");
|
||||
|
||||
Glow.apiCreateDm = payload =>
|
||||
Glow.apiRequest("/api/dms", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
Glow.apiGetDmMessages = (dmId, afterId = null) => {
|
||||
const base = `/api/dms/${encodeURIComponent(dmId)}/messages`;
|
||||
return Glow.apiRequest(
|
||||
afterId == null ? base : `${base}?after_id=${encodeURIComponent(afterId)}`
|
||||
);
|
||||
};
|
||||
|
||||
Glow.apiSendDmMessage = (dmId, payload) =>
|
||||
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/messages`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
Glow.apiGetDmTyping = dmId =>
|
||||
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/typing`);
|
||||
|
||||
Glow.apiSetDmTyping = (dmId, typing) =>
|
||||
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/typing`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ typing })
|
||||
});
|
||||
|
||||
/* calls */
|
||||
Glow.apiCreateCall = dmId =>
|
||||
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/calls`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
Glow.apiGetCall = callId =>
|
||||
Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}`);
|
||||
|
||||
Glow.apiPostCallSignal = (callId, payload) =>
|
||||
Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}/signal`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
Glow.apiGetCallSignals = (callId, afterId = 0) =>
|
||||
Glow.apiRequest(
|
||||
`/api/calls/${encodeURIComponent(callId)}/signals?after_id=${encodeURIComponent(afterId)}`
|
||||
);
|
||||
|
||||
Glow.apiEndCall = callId =>
|
||||
Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}/end`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
1779
static/js/02-chat.js
Normal file
1779
static/js/02-chat.js
Normal file
File diff suppressed because it is too large
Load Diff
689
static/js/03-admin.js
Normal file
689
static/js/03-admin.js
Normal file
@ -0,0 +1,689 @@
|
||||
// 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");
|
||||
}
|
||||
};
|
||||
859
static/js/04-main.js
Normal file
859
static/js/04-main.js
Normal file
@ -0,0 +1,859 @@
|
||||
// static/js/04-main.js
|
||||
"use strict";
|
||||
|
||||
window.Glow = window.Glow || {};
|
||||
|
||||
Glow.loadRooms = async () => {
|
||||
const previousSlug = Glow.state.activeRoom?.slug;
|
||||
const data = await Glow.apiRequest("/api/rooms");
|
||||
|
||||
Glow.state.rooms = data.rooms || [];
|
||||
Glow.els.roomSelect.replaceChildren();
|
||||
|
||||
for (const room of Glow.state.rooms) {
|
||||
const option = document.createElement("option");
|
||||
option.value = room.slug;
|
||||
option.textContent =
|
||||
`${room.name}${room.visibility === "private" ? " (private)" : ""}`;
|
||||
Glow.els.roomSelect.append(option);
|
||||
}
|
||||
|
||||
Glow.els.roomSelect.disabled = !Glow.state.rooms.length;
|
||||
|
||||
const selected =
|
||||
Glow.state.rooms.find(room => room.slug === previousSlug) ||
|
||||
Glow.state.rooms.find(room => room.slug === "general") ||
|
||||
Glow.state.rooms[0] ||
|
||||
null;
|
||||
|
||||
Glow.state.activeRoom = selected;
|
||||
|
||||
Glow.updateViewUI();
|
||||
Glow.updateChatControls();
|
||||
};
|
||||
|
||||
Glow.updateViewUI = () => {
|
||||
const view = Glow.state.activeView || "feed";
|
||||
const room = Glow.state.activeRoom;
|
||||
const dm = Glow.state.activeDm;
|
||||
|
||||
const feedActive = view === "feed";
|
||||
const dmListActive = view === "dms";
|
||||
const chatActive = view === "room" || view === "channel" || view === "dm";
|
||||
const threadActive = view === "thread";
|
||||
const pagesActive = view === "pages";
|
||||
const pageActive = view === "page";
|
||||
const profileActive = view === "profile";
|
||||
const settingsActive = view === "settings";
|
||||
|
||||
Glow.els.feedBox.classList.toggle("hidden", !feedActive);
|
||||
Glow.els.dmBox?.classList.toggle("hidden", !dmListActive);
|
||||
Glow.els.chatBox.classList.toggle("hidden", !chatActive);
|
||||
Glow.els.threadBox?.classList.toggle("hidden", !threadActive);
|
||||
Glow.els.pagesBox?.classList.toggle("hidden", !pagesActive);
|
||||
Glow.els.pageDetailBox?.classList.toggle("hidden", !pageActive);
|
||||
Glow.els.profileBox?.classList.toggle("hidden", !(profileActive || settingsActive));
|
||||
|
||||
Glow.els.composerWrap.classList.toggle("hidden", !chatActive);
|
||||
Glow.els.threadComposerWrap?.classList.toggle("hidden", !threadActive);
|
||||
|
||||
if (!chatActive) {
|
||||
Glow.state.typingUsers = [];
|
||||
Glow.state.dmTypingUsers = [];
|
||||
|
||||
if (Glow.els.typingBar) {
|
||||
Glow.els.typingBar.textContent = "";
|
||||
Glow.els.typingBar.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
Glow.els.feedButton.classList.toggle("active", feedActive);
|
||||
Glow.els.feedButton.setAttribute("aria-pressed", String(feedActive));
|
||||
|
||||
Glow.els.pagesButton?.classList.toggle("active", pagesActive || pageActive);
|
||||
Glow.els.pagesButton?.setAttribute("aria-pressed", String(pagesActive || pageActive));
|
||||
|
||||
Glow.els.dmsButton?.classList.toggle("active", dmListActive || view === "dm");
|
||||
Glow.els.dmsButton?.setAttribute("aria-pressed", String(dmListActive || view === "dm"));
|
||||
|
||||
Glow.els.backButton?.classList.toggle(
|
||||
"hidden",
|
||||
!(threadActive || pageActive || profileActive || settingsActive || view === "dm")
|
||||
);
|
||||
|
||||
Glow.els.roomSelect.classList.toggle("active", chatActive && !dm);
|
||||
Glow.els.teamSelect?.classList.toggle("hidden", dmListActive || view === "dm");
|
||||
Glow.els.roomSelect?.classList.toggle("hidden", dmListActive || view === "dm");
|
||||
|
||||
if (feedActive) {
|
||||
Glow.els.headerTitle.textContent = "Feed";
|
||||
Glow.els.headerSubtitle.textContent = Glow.state.currentUser
|
||||
? "Recent activity across your accessible rooms"
|
||||
: "Sign in or create an account to personalize your feed";
|
||||
|
||||
Glow.els.roomSelect.disabled = !Glow.state.rooms.length;
|
||||
Glow.els.presenceLabel?.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (dmListActive) {
|
||||
Glow.els.headerTitle.textContent = "Direct messages";
|
||||
Glow.els.headerSubtitle.textContent = Glow.state.currentUser
|
||||
? "Private conversations"
|
||||
: "Sign in to open private conversations";
|
||||
Glow.els.presenceLabel?.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (threadActive) {
|
||||
Glow.els.headerTitle.textContent = "Thread";
|
||||
Glow.els.headerSubtitle.textContent =
|
||||
dm
|
||||
? "Direct message replies"
|
||||
: room?.name || "Conversation replies";
|
||||
Glow.updateThreadControls?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pagesActive) {
|
||||
Glow.els.headerTitle.textContent = "Pages";
|
||||
Glow.els.headerSubtitle.textContent = "Shared knowledge and resources";
|
||||
Glow.els.presenceLabel?.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (pageActive) {
|
||||
Glow.els.headerTitle.textContent = Glow.state.activePage?.title || "Page";
|
||||
Glow.els.headerSubtitle.textContent = "Shared knowledge and resources";
|
||||
Glow.els.presenceLabel?.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (profileActive) {
|
||||
Glow.els.headerTitle.textContent =
|
||||
Glow.state.activeProfile?.display_name ||
|
||||
Glow.state.activeProfile?.username ||
|
||||
"Profile";
|
||||
Glow.els.headerSubtitle.textContent = "User profile";
|
||||
Glow.els.presenceLabel?.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (settingsActive) {
|
||||
Glow.els.headerTitle.textContent = "Settings";
|
||||
Glow.els.headerSubtitle.textContent = "Appearance, profile, and preferences";
|
||||
Glow.els.presenceLabel?.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (view === "dm" && dm) {
|
||||
const peer =
|
||||
(dm.members || []).find(
|
||||
member => Number(member.id) !== Number(Glow.state.currentUser?.id)
|
||||
) || dm.members?.[0];
|
||||
|
||||
Glow.els.headerTitle.textContent =
|
||||
peer?.display_name || peer?.username || "Direct message";
|
||||
Glow.els.headerSubtitle.textContent =
|
||||
peer?.username ? `@${peer.username}` : "Private conversation";
|
||||
Glow.refreshPresence?.();
|
||||
Glow.updateChatControls();
|
||||
return;
|
||||
}
|
||||
|
||||
if (room) {
|
||||
Glow.els.headerTitle.textContent = room.name;
|
||||
Glow.els.headerSubtitle.textContent =
|
||||
room.description ||
|
||||
(room.visibility === "private" ? "private room" : "public room");
|
||||
|
||||
Glow.els.roomSelect.value = room.slug;
|
||||
} else {
|
||||
Glow.els.headerTitle.textContent = "No rooms";
|
||||
Glow.els.headerSubtitle.textContent = "No accessible chat rooms";
|
||||
}
|
||||
|
||||
Glow.updateChatControls();
|
||||
};
|
||||
|
||||
Glow.updateAuthUI = () => {
|
||||
const user = Glow.state.currentUser;
|
||||
const authenticated = Boolean(user);
|
||||
|
||||
Glow.els.authOverlay.classList.toggle("hidden", authenticated);
|
||||
Glow.els.logoutButton.classList.toggle("hidden", !authenticated);
|
||||
|
||||
Glow.els.headerAccount.textContent =
|
||||
user ? `${user.username} · ${user.role}` : "not signed in";
|
||||
|
||||
document.body.classList.toggle("is-moderator", Glow.canModerate());
|
||||
document.body.classList.toggle("is-admin", Glow.isAdmin());
|
||||
|
||||
Glow.els.adminFab.classList.toggle("visible", Glow.canModerate());
|
||||
|
||||
Glow.els.presenceDot.classList.toggle("online", authenticated);
|
||||
Glow.els.presenceDot.classList.toggle("new", Boolean(user?.status === "restricted"));
|
||||
|
||||
if (user?.status === "restricted") {
|
||||
Glow.showToast("Your account is temporarily restricted.", "error");
|
||||
}
|
||||
|
||||
Glow.updateViewUI();
|
||||
Glow.updateChatControls();
|
||||
Glow.updateThreadControls?.();
|
||||
Glow.renderHeaderMenu?.();
|
||||
};
|
||||
|
||||
Glow.checkInvite = async () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const roomToken = params.get("invite");
|
||||
const teamToken = params.get("team_invite");
|
||||
|
||||
if (roomToken) {
|
||||
try {
|
||||
const data = await Glow.apiRequest(
|
||||
`/api/invites/${encodeURIComponent(roomToken)}/accept`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
}
|
||||
);
|
||||
|
||||
Glow.showToast(`Joined private room: ${data.room.name}`);
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
} catch (error) {
|
||||
if (error.status !== 401) {
|
||||
Glow.showToast(error.message, "error");
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (teamToken) {
|
||||
try {
|
||||
const data = await Glow.apiAcceptTeamInvite(teamToken);
|
||||
Glow.showToast(`Joined team: ${data.team.name}`);
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
} catch (error) {
|
||||
if (error.status !== 401) {
|
||||
Glow.showToast(error.message, "error");
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Glow.checkAuth = async () => {
|
||||
try {
|
||||
const data = await Glow.apiRequest("/api/me");
|
||||
|
||||
Glow.state.currentUser = data.authenticated ? data.user : null;
|
||||
Glow.state.csrfToken = data.csrf_token || "";
|
||||
|
||||
Glow.updateAuthUI();
|
||||
await Glow.checkInvite();
|
||||
await Glow.loadRooms();
|
||||
await Glow.loadDms?.().catch(() => {});
|
||||
|
||||
Glow.state.activeView = "feed";
|
||||
Glow.updateViewUI();
|
||||
await Glow.loadFeed();
|
||||
await Glow.loadMySettings?.().catch(() => {});
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not connect.", "error");
|
||||
} finally {
|
||||
Glow.schedulePoll();
|
||||
}
|
||||
};
|
||||
|
||||
Glow.authenticate = async endpoint => {
|
||||
Glow.clearAuthNotice();
|
||||
|
||||
const username = Glow.els.usernameInput.value.trim().toLowerCase();
|
||||
const password = Glow.els.passwordInput.value;
|
||||
|
||||
if (!username || !password) {
|
||||
Glow.showAuthNotice("Enter a username and password.");
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.els.loginButton.disabled = true;
|
||||
Glow.els.registerButton.disabled = true;
|
||||
|
||||
try {
|
||||
const data = await Glow.apiRequest(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
Glow.state.currentUser = data.user;
|
||||
Glow.state.csrfToken = data.csrf_token || "";
|
||||
Glow.els.passwordInput.value = "";
|
||||
|
||||
Glow.updateAuthUI();
|
||||
await Glow.checkInvite();
|
||||
await Glow.loadRooms();
|
||||
await Glow.loadDms?.().catch(() => {});
|
||||
|
||||
Glow.state.activeView = "feed";
|
||||
Glow.updateViewUI();
|
||||
await Glow.loadFeed();
|
||||
await Glow.loadMySettings?.().catch(() => {});
|
||||
|
||||
Glow.showToast(
|
||||
endpoint.endsWith("register") ? "Account created." : "Signed in."
|
||||
);
|
||||
} catch (error) {
|
||||
Glow.showAuthNotice(
|
||||
error.status === 429 && error.retryAfter
|
||||
? `Please wait ${error.retryAfter} seconds before trying again.`
|
||||
: error.message
|
||||
);
|
||||
} finally {
|
||||
Glow.els.loginButton.disabled = false;
|
||||
Glow.els.registerButton.disabled = false;
|
||||
Glow.schedulePoll();
|
||||
}
|
||||
};
|
||||
|
||||
Glow.logout = async () => {
|
||||
try {
|
||||
await Glow.apiRequest("/api/logout", { method: "POST" });
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
Glow.state.currentUser = null;
|
||||
Glow.state.csrfToken = "";
|
||||
Glow.state.rooms = [];
|
||||
Glow.state.activeRoom = null;
|
||||
Glow.state.teams = [];
|
||||
Glow.state.activeTeam = null;
|
||||
Glow.state.channels = [];
|
||||
Glow.state.activeChannel = null;
|
||||
Glow.state.dms = [];
|
||||
Glow.state.activeDm = null;
|
||||
Glow.state.activeView = "feed";
|
||||
Glow.state.activeThread = null;
|
||||
Glow.state.threadReplies = [];
|
||||
Glow.state.activePage = null;
|
||||
Glow.state.activeProfile = null;
|
||||
Glow.state.settings = null;
|
||||
|
||||
Glow.clearMessages();
|
||||
Glow.clearFeed();
|
||||
Glow.clearDms();
|
||||
Glow.closeHeaderMenu?.();
|
||||
Glow.closeReactionPopup?.();
|
||||
Glow.closeMediaModal?.();
|
||||
Glow.endLocalCallUi?.();
|
||||
|
||||
Glow.updateAuthUI();
|
||||
|
||||
Glow.els.authOverlay.classList.remove("hidden");
|
||||
Glow.els.usernameInput.focus();
|
||||
|
||||
await Glow.refreshCsrfToken().catch(() => {});
|
||||
await Glow.loadFeed().catch(() => {});
|
||||
|
||||
Glow.showToast("Signed out.");
|
||||
};
|
||||
|
||||
Glow.toggleNotifications = async () => {
|
||||
if (!("Notification" in window)) {
|
||||
Glow.showToast("Browser notifications are not supported.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Glow.state.notificationsEnabled) {
|
||||
const permission = await Notification.requestPermission();
|
||||
|
||||
if (permission !== "granted") {
|
||||
Glow.showToast("Notification permission was not granted.", "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Glow.state.notificationsEnabled = !Glow.state.notificationsEnabled;
|
||||
|
||||
localStorage.setItem(
|
||||
"glow_notifications_enabled",
|
||||
String(Glow.state.notificationsEnabled)
|
||||
);
|
||||
|
||||
Glow.updateAdminPreferenceButtons?.();
|
||||
Glow.showToast(
|
||||
Glow.state.notificationsEnabled
|
||||
? "Browser notifications enabled."
|
||||
: "Browser notifications disabled."
|
||||
);
|
||||
};
|
||||
|
||||
Glow.toggleTypingIndicators = () => {
|
||||
Glow.state.typingIndicatorsEnabled = !Glow.state.typingIndicatorsEnabled;
|
||||
|
||||
localStorage.setItem(
|
||||
"glow_typing_enabled",
|
||||
String(Glow.state.typingIndicatorsEnabled)
|
||||
);
|
||||
|
||||
if (!Glow.state.typingIndicatorsEnabled) {
|
||||
Glow.state.typingUsers = [];
|
||||
Glow.state.dmTypingUsers = [];
|
||||
Glow.renderTyping();
|
||||
}
|
||||
|
||||
Glow.updateAdminPreferenceButtons?.();
|
||||
|
||||
Glow.showToast(
|
||||
Glow.state.typingIndicatorsEnabled
|
||||
? "Typing indicators enabled."
|
||||
: "Typing indicators disabled."
|
||||
);
|
||||
};
|
||||
|
||||
Glow.pingPresence = async () => {
|
||||
if (!Glow.state.currentUser) return;
|
||||
|
||||
const payload = {
|
||||
view: Glow.state.activeView
|
||||
};
|
||||
|
||||
if (Glow.state.activeDm?.id) {
|
||||
payload.direct_conversation_id = Glow.state.activeDm.id;
|
||||
} else if (Glow.state.activeChannel?.id) {
|
||||
payload.channel_id = Glow.state.activeChannel.id;
|
||||
} else if (Glow.state.activeRoom?.id) {
|
||||
payload.room_id = Glow.state.activeRoom.id;
|
||||
}
|
||||
|
||||
try {
|
||||
await Glow.apiPingPresence(payload);
|
||||
} catch (_) {
|
||||
// non-blocking
|
||||
}
|
||||
};
|
||||
|
||||
Glow.refreshPresence = async () => {
|
||||
try {
|
||||
if (Glow.state.activeDm?.id) {
|
||||
const data = await Glow.apiGetDmPresence(Glow.state.activeDm.id);
|
||||
Glow.state.presence.dmOnlineCount = Number(data.online_count) || 0;
|
||||
Glow.els.presenceLabel.textContent =
|
||||
`${Glow.state.presence.dmOnlineCount} online`;
|
||||
Glow.els.presenceLabel.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Glow.state.activeTeam?.slug && Glow.state.activeChannel?.slug) {
|
||||
const data = await Glow.apiGetChannelPresence(
|
||||
Glow.state.activeTeam.slug,
|
||||
Glow.state.activeChannel.slug
|
||||
);
|
||||
Glow.state.presence.channelOnlineCount = Number(data.online_count) || 0;
|
||||
Glow.els.presenceLabel.textContent =
|
||||
`${Glow.state.presence.channelOnlineCount} online`;
|
||||
Glow.els.presenceLabel.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Glow.state.activeRoom?.slug) {
|
||||
const data = await Glow.apiGetRoomPresence(Glow.state.activeRoom.slug);
|
||||
Glow.state.presence.roomOnlineCount = Number(data.online_count) || 0;
|
||||
Glow.els.presenceLabel.textContent =
|
||||
`${Glow.state.presence.roomOnlineCount} online`;
|
||||
Glow.els.presenceLabel.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.els.presenceLabel.classList.add("hidden");
|
||||
} catch (_) {
|
||||
Glow.els.presenceLabel?.classList.add("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.schedulePoll = () => {
|
||||
clearTimeout(Glow.state.pollTimer);
|
||||
|
||||
if (Glow.els.blockedOverlay.classList.contains("hidden") === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.state.pollTimer = setTimeout(async () => {
|
||||
if (Glow.state.activeView === "feed") {
|
||||
await Glow.pollFeed();
|
||||
} else if (
|
||||
Glow.state.activeView === "room" ||
|
||||
Glow.state.activeView === "channel" ||
|
||||
Glow.state.activeView === "dm"
|
||||
) {
|
||||
await Glow.pollMessages();
|
||||
await Glow.pollCallSignals?.();
|
||||
} else if (Glow.state.activeView === "thread") {
|
||||
await Glow.pollThread?.();
|
||||
await Glow.pollCallSignals?.();
|
||||
} else if (Glow.state.activeView === "dms") {
|
||||
await Glow.loadDms?.().catch(() => {});
|
||||
}
|
||||
|
||||
await Glow.pingPresence?.();
|
||||
await Glow.refreshPresence?.();
|
||||
|
||||
Glow.schedulePoll();
|
||||
}, document.hidden ? 12000 : 5000);
|
||||
};
|
||||
|
||||
Glow.els.chatForm.addEventListener("submit", Glow.sendMessage);
|
||||
|
||||
Glow.els.threadForm?.addEventListener("submit", event =>
|
||||
Glow.sendThreadReply?.(event)
|
||||
);
|
||||
|
||||
Glow.els.messageInput.addEventListener("input", () => {
|
||||
Glow.autoResizeComposer();
|
||||
Glow.updateMessageCount();
|
||||
|
||||
clearTimeout(Glow.state.typingTimer);
|
||||
|
||||
if (Glow.els.messageInput.value.trim()) {
|
||||
Glow.sendTyping(true);
|
||||
|
||||
Glow.state.typingTimer = setTimeout(() => {
|
||||
Glow.sendTyping(false);
|
||||
}, 2500);
|
||||
} else {
|
||||
Glow.sendTyping(false);
|
||||
}
|
||||
});
|
||||
|
||||
Glow.els.threadInput?.addEventListener("input", () => {
|
||||
Glow.autoResizeThreadComposer?.();
|
||||
Glow.updateThreadMessageCount?.();
|
||||
});
|
||||
|
||||
Glow.els.messageInput.addEventListener("keydown", event => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
Glow.sendMessage(event);
|
||||
}
|
||||
});
|
||||
|
||||
Glow.els.threadInput?.addEventListener("keydown", event => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
Glow.sendThreadReply?.(event);
|
||||
}
|
||||
});
|
||||
|
||||
Glow.els.attachmentInput.addEventListener("change", () => {
|
||||
const files = Glow.normalizeSelectedFiles(Glow.els.attachmentInput.files);
|
||||
|
||||
if (!files.length) {
|
||||
Glow.setSelectedAttachment([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const invalid = files.find(file => file.size > Glow.MAX_ATTACHMENT_BYTES);
|
||||
if (invalid) {
|
||||
Glow.showToast("Attachments must be 5 MB or smaller.", "error");
|
||||
Glow.els.attachmentInput.value = "";
|
||||
Glow.setSelectedAttachment([]);
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.setSelectedAttachment(files);
|
||||
});
|
||||
|
||||
Glow.els.threadAttachmentInput?.addEventListener("change", () => {
|
||||
const files = Glow.normalizeSelectedFiles(Glow.els.threadAttachmentInput.files);
|
||||
|
||||
if (!files.length) {
|
||||
Glow.setSelectedThreadAttachment?.([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const invalid = files.find(file => file.size > Glow.MAX_ATTACHMENT_BYTES);
|
||||
if (invalid) {
|
||||
Glow.showToast("Attachments must be 5 MB or smaller.", "error");
|
||||
Glow.els.threadAttachmentInput.value = "";
|
||||
Glow.setSelectedThreadAttachment?.([]);
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.setSelectedThreadAttachment?.(files);
|
||||
});
|
||||
|
||||
Glow.els.feedButton.addEventListener("click", Glow.openFeed);
|
||||
|
||||
Glow.els.dmsButton?.addEventListener("click", async () => {
|
||||
Glow.state.activeView = "dms";
|
||||
Glow.updateViewUI();
|
||||
await Glow.loadDms?.();
|
||||
Glow.schedulePoll();
|
||||
});
|
||||
|
||||
Glow.els.pagesButton?.addEventListener("click", () => {
|
||||
Glow.openPages?.();
|
||||
});
|
||||
|
||||
Glow.els.backButton?.addEventListener("click", async () => {
|
||||
if (Glow.state.activeView === "thread") {
|
||||
await Glow.returnToChannel?.();
|
||||
} else if (Glow.state.activeView === "page") {
|
||||
await Glow.openPages?.();
|
||||
} else if (Glow.state.activeView === "profile" || Glow.state.activeView === "settings") {
|
||||
await Glow.openFeed?.();
|
||||
} else if (Glow.state.activeView === "dm") {
|
||||
Glow.state.activeView = "dms";
|
||||
Glow.updateViewUI();
|
||||
await Glow.loadDms?.();
|
||||
}
|
||||
});
|
||||
|
||||
Glow.els.loginButton.addEventListener("click", () => {
|
||||
Glow.authenticate("/api/login");
|
||||
});
|
||||
|
||||
Glow.els.registerButton.addEventListener("click", () => {
|
||||
Glow.authenticate("/api/register");
|
||||
});
|
||||
|
||||
Glow.els.passwordInput.addEventListener("keydown", event => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
Glow.authenticate("/api/login");
|
||||
}
|
||||
});
|
||||
|
||||
Glow.els.logoutButton.addEventListener("click", Glow.logout);
|
||||
|
||||
Glow.els.roomSelect.addEventListener("change", async () => {
|
||||
const selected = Glow.state.rooms.find(
|
||||
room => room.slug === Glow.els.roomSelect.value
|
||||
);
|
||||
|
||||
if (!selected) return;
|
||||
|
||||
Glow.state.activeDm = null;
|
||||
Glow.state.activeRoom = selected;
|
||||
Glow.state.activeView = "room";
|
||||
|
||||
Glow.state.typingUsers = [];
|
||||
Glow.state.dmTypingUsers = [];
|
||||
Glow.renderTyping();
|
||||
|
||||
Glow.updateViewUI();
|
||||
await Glow.loadMessages();
|
||||
Glow.pollTyping();
|
||||
Glow.pingPresence?.();
|
||||
Glow.refreshPresence?.();
|
||||
Glow.schedulePoll();
|
||||
});
|
||||
|
||||
Glow.els.teamSelect?.addEventListener("change", () => {
|
||||
const teams = Glow.state.teams || [];
|
||||
|
||||
Glow.state.activeTeam =
|
||||
teams.find(team => team.slug === Glow.els.teamSelect.value) || null;
|
||||
});
|
||||
|
||||
Glow.els.adminFab.addEventListener("click", Glow.openAdminPanel);
|
||||
Glow.els.closeAdminButton.addEventListener("click", Glow.closeAdminPanel);
|
||||
Glow.els.createRoomButton.addEventListener("click", Glow.createRoom);
|
||||
Glow.els.clearMessagesButton.addEventListener("click", Glow.clearActiveRoom);
|
||||
Glow.els.typingToggleButton.addEventListener("click", Glow.toggleTypingIndicators);
|
||||
Glow.els.notificationsToggleButton.addEventListener("click", Glow.toggleNotifications);
|
||||
Glow.els.themeButton?.addEventListener("click", Glow.openAccentColorPicker);
|
||||
|
||||
Glow.els.settingsButton?.addEventListener("click", () => {
|
||||
Glow.toggleHeaderMenu?.();
|
||||
});
|
||||
|
||||
Glow.els.jumpToLatestButton.addEventListener("click", () => {
|
||||
Glow.els.chatBox.scrollTop = Glow.els.chatBox.scrollHeight;
|
||||
Glow.resetUnreadMessages();
|
||||
});
|
||||
|
||||
Glow.els.chatBox.addEventListener("scroll", () => {
|
||||
if (Glow.isNearBottom()) {
|
||||
Glow.resetUnreadMessages();
|
||||
}
|
||||
});
|
||||
|
||||
Glow.els.adminPanel.addEventListener("click", event => {
|
||||
if (event.target === Glow.els.adminPanel) {
|
||||
Glow.closeAdminPanel();
|
||||
}
|
||||
});
|
||||
|
||||
Glow.els.dialogOverlay.addEventListener("click", event => {
|
||||
if (event.target === Glow.els.dialogOverlay) {
|
||||
Glow.closeDialog(null);
|
||||
}
|
||||
});
|
||||
|
||||
Glow.els.headerMenuOverlay?.addEventListener("click", event => {
|
||||
if (event.target === Glow.els.headerMenuOverlay) {
|
||||
Glow.closeHeaderMenu?.();
|
||||
}
|
||||
});
|
||||
|
||||
Glow.els.closeReactionPopupButton?.addEventListener("click", () => {
|
||||
Glow.closeReactionPopup?.();
|
||||
});
|
||||
|
||||
Glow.els.reactionPopupOverlay?.addEventListener("click", event => {
|
||||
if (event.target === Glow.els.reactionPopupOverlay) {
|
||||
Glow.closeReactionPopup?.();
|
||||
}
|
||||
});
|
||||
|
||||
Glow.els.closeMediaButton?.addEventListener("click", () => {
|
||||
Glow.closeMediaModal?.();
|
||||
});
|
||||
|
||||
Glow.els.mediaOverlay?.addEventListener("click", event => {
|
||||
if (event.target === Glow.els.mediaOverlay) {
|
||||
Glow.closeMediaModal?.();
|
||||
}
|
||||
});
|
||||
|
||||
Glow.els.closeCallPanelButton?.addEventListener("click", () => {
|
||||
Glow.els.callOverlay?.classList.add("hidden");
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", event => {
|
||||
if (event.key !== "Escape") return;
|
||||
|
||||
if (!Glow.els.dialogOverlay.classList.contains("hidden")) {
|
||||
Glow.closeDialog(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Glow.els.mediaOverlay?.classList.contains("hidden")) {
|
||||
Glow.closeMediaModal?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Glow.els.reactionPopupOverlay?.classList.contains("hidden")) {
|
||||
Glow.closeReactionPopup?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Glow.els.headerMenuOverlay?.classList.contains("hidden")) {
|
||||
Glow.closeHeaderMenu?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Glow.els.callOverlay?.classList.contains("hidden")) {
|
||||
Glow.els.callOverlay.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Glow.state.activeView === "thread") {
|
||||
Glow.returnToChannel?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Glow.state.activeView === "page") {
|
||||
Glow.openPages?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Glow.state.activeView === "dm") {
|
||||
Glow.state.activeView = "dms";
|
||||
Glow.updateViewUI();
|
||||
Glow.loadDms?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Glow.els.adminPanel.classList.contains("open")) {
|
||||
Glow.closeAdminPanel();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
Glow.schedulePoll();
|
||||
|
||||
if (!document.hidden) {
|
||||
Glow.resetUnreadMessages();
|
||||
|
||||
if (Glow.state.activeView === "feed") {
|
||||
Glow.pollFeed();
|
||||
} else if (
|
||||
Glow.state.activeView === "room" ||
|
||||
Glow.state.activeView === "channel" ||
|
||||
Glow.state.activeView === "dm"
|
||||
) {
|
||||
Glow.pollMessages();
|
||||
Glow.pollTyping();
|
||||
Glow.pollCallSignals?.();
|
||||
} else if (Glow.state.activeView === "thread") {
|
||||
Glow.pollThread?.();
|
||||
Glow.pollCallSignals?.();
|
||||
} else if (Glow.state.activeView === "dms") {
|
||||
Glow.loadDms?.();
|
||||
}
|
||||
|
||||
Glow.pingPresence?.();
|
||||
Glow.refreshPresence?.();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("online", () => {
|
||||
Glow.showToast("Back online.");
|
||||
|
||||
if (Glow.state.activeView === "feed") {
|
||||
Glow.pollFeed();
|
||||
} else if (
|
||||
Glow.state.activeView === "room" ||
|
||||
Glow.state.activeView === "channel" ||
|
||||
Glow.state.activeView === "dm"
|
||||
) {
|
||||
Glow.pollMessages();
|
||||
Glow.pollTyping();
|
||||
Glow.pollCallSignals?.();
|
||||
} else if (Glow.state.activeView === "thread") {
|
||||
Glow.pollThread?.();
|
||||
Glow.pollCallSignals?.();
|
||||
} else if (Glow.state.activeView === "dms") {
|
||||
Glow.loadDms?.();
|
||||
}
|
||||
|
||||
Glow.pingPresence?.();
|
||||
Glow.refreshPresence?.();
|
||||
});
|
||||
|
||||
window.addEventListener("offline", () => {
|
||||
Glow.showToast("You are offline.", "error");
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
if (
|
||||
Glow.state.activeView === "room" ||
|
||||
Glow.state.activeView === "channel" ||
|
||||
Glow.state.activeView === "dm"
|
||||
) {
|
||||
Glow.pollTyping();
|
||||
}
|
||||
}, 2500);
|
||||
|
||||
setInterval(() => {
|
||||
if (
|
||||
Glow.state.activeView === "room" ||
|
||||
Glow.state.activeView === "channel" ||
|
||||
Glow.state.activeView === "dm"
|
||||
) {
|
||||
Glow.pingPresence?.();
|
||||
Glow.refreshPresence?.();
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
Glow.applyAccentColor(Glow.state.accentColor);
|
||||
Glow.autoResizeComposer();
|
||||
Glow.updateMessageCount();
|
||||
Glow.autoResizeThreadComposer?.();
|
||||
Glow.updateThreadMessageCount?.();
|
||||
Glow.checkAuth();
|
||||
430
static/js/05-feed.js
Normal file
430
static/js/05-feed.js
Normal file
@ -0,0 +1,430 @@
|
||||
// static/js/05-feed.js
|
||||
"use strict";
|
||||
|
||||
window.Glow = window.Glow || {};
|
||||
|
||||
Glow.clearFeed = () => {
|
||||
Glow.state.feedLastSeenId = 0;
|
||||
Glow.state.feedKnownMessageIds.clear();
|
||||
Glow.state.feedEntries = [];
|
||||
Glow.els.feedBox.replaceChildren();
|
||||
};
|
||||
|
||||
Glow.renderFeedEmptyState = message => {
|
||||
if (Glow.els.feedBox.children.length) return;
|
||||
|
||||
const state = document.createElement("div");
|
||||
state.className = "empty-state";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "empty-card";
|
||||
card.textContent = message;
|
||||
|
||||
state.append(card);
|
||||
Glow.els.feedBox.append(state);
|
||||
};
|
||||
|
||||
Glow.openFeedRoom = async slug => {
|
||||
const room = Glow.state.rooms.find(item => item.slug === slug);
|
||||
|
||||
if (!room) {
|
||||
Glow.showToast("This room is no longer available.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.state.activeDm = null;
|
||||
Glow.state.activeChannel = null;
|
||||
Glow.state.activeRoom = room;
|
||||
Glow.state.activeView = "room";
|
||||
|
||||
Glow.els.roomSelect.value = room.slug;
|
||||
|
||||
Glow.updateViewUI();
|
||||
await Glow.loadMessages();
|
||||
Glow.pingPresence?.();
|
||||
Glow.refreshPresence?.();
|
||||
Glow.schedulePoll();
|
||||
};
|
||||
|
||||
Glow.openFeedEntry = async entry => {
|
||||
if (
|
||||
entry.team_slug &&
|
||||
entry.channel_slug &&
|
||||
typeof Glow.loadChannelsForTeam === "function"
|
||||
) {
|
||||
let team = Glow.state.teams?.find(item => item.slug === entry.team_slug);
|
||||
|
||||
if (!team && typeof Glow.loadTeamsForAdapter === "function") {
|
||||
try {
|
||||
await Glow.loadTeamsForAdapter();
|
||||
team = Glow.state.teams?.find(item => item.slug === entry.team_slug);
|
||||
} catch (_) {
|
||||
team = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (team) {
|
||||
Glow.state.activeTeam = team;
|
||||
Glow.state.activeDm = null;
|
||||
|
||||
if (Glow.els.teamSelect) {
|
||||
Glow.els.teamSelect.value = team.slug;
|
||||
}
|
||||
|
||||
try {
|
||||
await Glow.loadChannelsForTeam(team.slug, entry.channel_slug);
|
||||
|
||||
if (Glow.state.activeChannel) {
|
||||
Glow.state.activeView = "channel";
|
||||
Glow.updateViewUI();
|
||||
await Glow.loadChannelMessages?.();
|
||||
Glow.pollTyping?.();
|
||||
Glow.pingPresence?.();
|
||||
Glow.refreshPresence?.();
|
||||
Glow.schedulePoll();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not open this channel.", "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.room_slug) {
|
||||
await Glow.openFeedRoom(entry.room_slug);
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.showToast("This conversation is no longer available.", "error");
|
||||
};
|
||||
|
||||
Glow.buildFeedReactions = entry => {
|
||||
const reactions = Array.isArray(entry.reactions) ? entry.reactions : [];
|
||||
if (!reactions.length) return null;
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "feed-entry-reactions";
|
||||
|
||||
for (const reaction of reactions) {
|
||||
const chip = document.createElement("button");
|
||||
chip.className = "reaction-chip";
|
||||
chip.type = "button";
|
||||
chip.textContent = `${reaction.emoji} ${reaction.count}`;
|
||||
|
||||
if (reaction.reacted) {
|
||||
chip.classList.add("active");
|
||||
}
|
||||
|
||||
chip.addEventListener("click", async () => {
|
||||
try {
|
||||
if (reaction.reacted) {
|
||||
await Glow.apiRemoveReaction(entry.id, reaction.emoji);
|
||||
} else {
|
||||
await Glow.apiAddReaction(entry.id, reaction.emoji);
|
||||
}
|
||||
|
||||
await Glow.loadFeed();
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not update reaction.", "error");
|
||||
}
|
||||
});
|
||||
|
||||
row.append(chip);
|
||||
}
|
||||
|
||||
return row;
|
||||
};
|
||||
|
||||
Glow.buildFeedAttachmentBlock = entry => {
|
||||
const attachments = Array.isArray(entry.attachments)
|
||||
? entry.attachments
|
||||
: entry.attachment
|
||||
? [entry.attachment]
|
||||
: [];
|
||||
|
||||
if (!attachments.length) return null;
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "message-attachments-list";
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (String(attachment.mime_type || "").startsWith("image/")) {
|
||||
const imageButton = document.createElement("button");
|
||||
imageButton.className = "message-image-link";
|
||||
imageButton.type = "button";
|
||||
|
||||
const image = document.createElement("img");
|
||||
image.className = "message-image-preview";
|
||||
image.src = attachment.url;
|
||||
image.alt = attachment.original_name || "Image attachment";
|
||||
image.loading = "lazy";
|
||||
|
||||
imageButton.append(image);
|
||||
imageButton.addEventListener("click", () => {
|
||||
Glow.openMediaModal?.({
|
||||
url: attachment.url,
|
||||
name: attachment.original_name,
|
||||
mimeType: attachment.mime_type
|
||||
});
|
||||
});
|
||||
|
||||
wrap.append(imageButton);
|
||||
} else if (String(attachment.mime_type || "").startsWith("audio/")) {
|
||||
const audio = document.createElement("audio");
|
||||
audio.controls = true;
|
||||
audio.preload = "metadata";
|
||||
audio.src = attachment.url;
|
||||
wrap.append(audio);
|
||||
} else if (String(attachment.mime_type || "").startsWith("video/")) {
|
||||
const video = document.createElement("video");
|
||||
video.controls = true;
|
||||
video.preload = "metadata";
|
||||
video.src = attachment.url;
|
||||
video.className = "message-video-preview";
|
||||
wrap.append(video);
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.className = "message-attachment";
|
||||
link.href = attachment.url;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
link.download = attachment.original_name;
|
||||
link.textContent = `Attachment: ${attachment.original_name}`;
|
||||
wrap.append(link);
|
||||
}
|
||||
|
||||
return wrap;
|
||||
};
|
||||
|
||||
Glow.buildFeedEntry = entry => {
|
||||
const card = document.createElement("article");
|
||||
card.className = "feed-entry";
|
||||
|
||||
if (entry.is_mention) {
|
||||
card.classList.add("mention");
|
||||
}
|
||||
|
||||
card.dataset.messageId = String(entry.id);
|
||||
|
||||
if (entry.room_slug) card.dataset.roomSlug = entry.room_slug;
|
||||
if (entry.team_slug) card.dataset.teamSlug = entry.team_slug;
|
||||
if (entry.channel_slug) card.dataset.channelSlug = entry.channel_slug;
|
||||
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "feed-entry-heading";
|
||||
|
||||
const room = document.createElement("button");
|
||||
room.className = "feed-room-link";
|
||||
room.type = "button";
|
||||
|
||||
if (entry.team_name && entry.channel_name) {
|
||||
room.textContent = `${entry.team_name} · # ${entry.channel_name}`;
|
||||
} else {
|
||||
room.textContent =
|
||||
`${entry.room_visibility === "private" ? "(private) " : "# "}${entry.room_name || "channel"}`;
|
||||
}
|
||||
|
||||
room.addEventListener("click", () => {
|
||||
Glow.openFeedEntry(entry);
|
||||
});
|
||||
|
||||
const time = document.createElement("time");
|
||||
time.className = "feed-entry-time";
|
||||
time.dateTime = entry.created_at;
|
||||
time.textContent = Glow.relativeTime(entry.created_at);
|
||||
time.title = new Date(entry.created_at).toLocaleString();
|
||||
|
||||
heading.append(room, time);
|
||||
|
||||
const author = document.createElement("button");
|
||||
author.className = "feed-entry-author profile-link";
|
||||
author.type = "button";
|
||||
author.textContent = entry.username;
|
||||
Glow.applyUserColor(author, entry);
|
||||
author.addEventListener("click", () => {
|
||||
Glow.openProfile?.(entry.username);
|
||||
});
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "feed-entry-body";
|
||||
body.textContent = entry.text;
|
||||
|
||||
const attachmentBlock = Glow.buildFeedAttachmentBlock(entry);
|
||||
const reactions = Glow.buildFeedReactions(entry);
|
||||
|
||||
const openButton = document.createElement("button");
|
||||
openButton.className = "small-button feed-open-room";
|
||||
openButton.type = "button";
|
||||
openButton.textContent = "Open channel";
|
||||
openButton.addEventListener("click", () => {
|
||||
Glow.openFeedEntry(entry);
|
||||
});
|
||||
|
||||
card.addEventListener("dblclick", () => {
|
||||
Glow.openFeedEntry(entry);
|
||||
});
|
||||
|
||||
card.append(heading, author, body);
|
||||
|
||||
if (attachmentBlock) {
|
||||
card.append(attachmentBlock);
|
||||
}
|
||||
|
||||
if (reactions) {
|
||||
card.append(reactions);
|
||||
}
|
||||
|
||||
card.append(openButton);
|
||||
|
||||
return card;
|
||||
};
|
||||
|
||||
Glow.appendFeedEntries = (entries, forceScroll = false) => {
|
||||
if (!Array.isArray(entries) || !entries.length) {
|
||||
if (!Glow.state.feedKnownMessageIds.size) {
|
||||
Glow.renderFeedEmptyState(
|
||||
Glow.state.currentUser
|
||||
? "No activity in your accessible rooms yet."
|
||||
: "Sign in or create an account to see your personalized activity feed."
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nearBottom =
|
||||
Glow.els.feedBox.scrollHeight -
|
||||
Glow.els.feedBox.scrollTop -
|
||||
Glow.els.feedBox.clientHeight < 100;
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
for (const entry of entries) {
|
||||
const id = Number(entry.id);
|
||||
|
||||
if (!id || Glow.state.feedKnownMessageIds.has(id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const previous = Glow.state.feedEntries.at(-1);
|
||||
|
||||
const dateChanged =
|
||||
!previous ||
|
||||
Glow.dateKey(previous.created_at) !== Glow.dateKey(entry.created_at);
|
||||
|
||||
if (dateChanged) {
|
||||
const separator = document.createElement("div");
|
||||
separator.className = "date-separator";
|
||||
separator.dataset.dateKey = Glow.dateKey(entry.created_at);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.textContent = Glow.formatDateLabel(entry.created_at);
|
||||
|
||||
separator.append(label);
|
||||
fragment.append(separator);
|
||||
}
|
||||
|
||||
Glow.state.feedKnownMessageIds.add(id);
|
||||
Glow.state.feedLastSeenId = Math.max(Glow.state.feedLastSeenId, id);
|
||||
Glow.state.feedEntries.push(entry);
|
||||
fragment.append(Glow.buildFeedEntry(entry));
|
||||
}
|
||||
|
||||
if (!fragment.childNodes.length) return;
|
||||
|
||||
Glow.els.feedBox.querySelector(".empty-state")?.remove();
|
||||
Glow.els.feedBox.append(fragment);
|
||||
|
||||
if (forceScroll || nearBottom) {
|
||||
Glow.els.feedBox.scrollTop = Glow.els.feedBox.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
Glow.loadFeed = async () => {
|
||||
const requestId = ++Glow.state.feedRequestId;
|
||||
Glow.els.feedBox.setAttribute("aria-busy", "true");
|
||||
|
||||
try {
|
||||
const data = await Glow.apiRequest("/api/feed");
|
||||
|
||||
if (requestId !== Glow.state.feedRequestId || Glow.state.activeView !== "feed") {
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.clearFeed();
|
||||
Glow.appendFeedEntries(data.entries || [], true);
|
||||
Glow.state.feedLastSeenId = Number(data.latest_id) || 0;
|
||||
|
||||
if (!(data.entries || []).length) {
|
||||
Glow.renderFeedEmptyState(
|
||||
Glow.state.currentUser
|
||||
? "No activity in your accessible rooms yet."
|
||||
: "Sign in or create an account to see your personalized activity feed."
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId !== Glow.state.feedRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.clearFeed();
|
||||
Glow.renderFeedEmptyState(error.message || "Could not load activity feed.");
|
||||
Glow.showToast(error.message || "Could not load activity feed.", "error");
|
||||
} finally {
|
||||
if (requestId === Glow.state.feedRequestId) {
|
||||
Glow.els.feedBox.setAttribute("aria-busy", "false");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Glow.pollFeed = async () => {
|
||||
if (
|
||||
Glow.state.feedPollInFlight ||
|
||||
Glow.state.activeView !== "feed" ||
|
||||
document.hidden
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.state.feedPollInFlight = true;
|
||||
|
||||
try {
|
||||
const data = await Glow.apiRequest(
|
||||
`/api/feed?after_id=${encodeURIComponent(Glow.state.feedLastSeenId)}`
|
||||
);
|
||||
|
||||
if (Glow.state.activeView !== "feed") {
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.appendFeedEntries(data.entries || [], false);
|
||||
Glow.state.feedLastSeenId = Math.max(
|
||||
Glow.state.feedLastSeenId,
|
||||
Number(data.latest_id) || 0
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
console.warn("Feed API is unavailable. Deploy the /api/feed backend route.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
error.status !== 401 &&
|
||||
error.code !== "blacklisted" &&
|
||||
error.code !== "network"
|
||||
) {
|
||||
console.error("Feed polling failed:", error);
|
||||
}
|
||||
} finally {
|
||||
Glow.state.feedPollInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
Glow.openFeed = async () => {
|
||||
Glow.state.activeDm = null;
|
||||
Glow.state.activeView = "feed";
|
||||
Glow.updateViewUI();
|
||||
await Glow.loadFeed();
|
||||
Glow.schedulePoll();
|
||||
};
|
||||
465
static/js/06-pages.js
Normal file
465
static/js/06-pages.js
Normal file
@ -0,0 +1,465 @@
|
||||
// static/js/06-pages.js
|
||||
"use strict";
|
||||
|
||||
window.Glow = window.Glow || {};
|
||||
|
||||
Glow.renderPagesEmptyState = message => {
|
||||
Glow.els.pagesBox.replaceChildren();
|
||||
|
||||
const state = document.createElement("div");
|
||||
state.className = "empty-state";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "empty-card";
|
||||
card.textContent = message;
|
||||
|
||||
state.append(card);
|
||||
Glow.els.pagesBox.append(state);
|
||||
};
|
||||
|
||||
Glow.pageTypeLabel = type => {
|
||||
const labels = {
|
||||
project: "Project",
|
||||
document: "Document",
|
||||
update: "Update",
|
||||
resource: "Resource"
|
||||
};
|
||||
|
||||
return labels[type] || "Page";
|
||||
};
|
||||
|
||||
Glow.loadPages = async (pageType = Glow.state.pageTypeFilter) => {
|
||||
const team = Glow.state.activeTeam;
|
||||
|
||||
if (!team) {
|
||||
Glow.renderPagesEmptyState("Select a team to view pages.");
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.els.pagesBox.setAttribute("aria-busy", "true");
|
||||
|
||||
try {
|
||||
const query = pageType ? `?type=${encodeURIComponent(pageType)}` : "";
|
||||
|
||||
const data = await Glow.apiRequest(
|
||||
`/api/teams/${encodeURIComponent(team.slug)}/pages${query}`
|
||||
);
|
||||
|
||||
if (Glow.state.activeTeam?.slug !== team.slug) return;
|
||||
|
||||
Glow.state.pages = data.pages || [];
|
||||
Glow.state.pageTypeFilter = pageType || "";
|
||||
Glow.renderPages();
|
||||
} catch (error) {
|
||||
Glow.renderPagesEmptyState(error.message || "Could not load pages.");
|
||||
Glow.showToast(error.message || "Could not load pages.", "error");
|
||||
} finally {
|
||||
Glow.els.pagesBox.setAttribute("aria-busy", "false");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.renderPages = () => {
|
||||
const box = Glow.els.pagesBox;
|
||||
const team = Glow.state.activeTeam;
|
||||
const pages = Glow.state.pages || [];
|
||||
|
||||
box.replaceChildren();
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "pages-header";
|
||||
|
||||
const copy = document.createElement("div");
|
||||
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = `${team?.name || "Team"} pages`;
|
||||
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.textContent = "Shared team knowledge and public resources.";
|
||||
|
||||
copy.append(title, subtitle);
|
||||
header.append(copy);
|
||||
|
||||
if (team?.can_manage) {
|
||||
const create = document.createElement("button");
|
||||
create.className = "button";
|
||||
create.type = "button";
|
||||
create.textContent = "New page";
|
||||
create.addEventListener("click", Glow.createPage);
|
||||
|
||||
header.append(create);
|
||||
}
|
||||
|
||||
box.append(header);
|
||||
|
||||
const filters = document.createElement("div");
|
||||
filters.className = "page-filters";
|
||||
|
||||
const filterOptions = [
|
||||
["", "All"],
|
||||
["project", "Projects"],
|
||||
["document", "Documents"],
|
||||
["update", "Updates"],
|
||||
["resource", "Resources"]
|
||||
];
|
||||
|
||||
for (const [value, label] of filterOptions) {
|
||||
const button = document.createElement("button");
|
||||
button.className = "page-filter";
|
||||
button.type = "button";
|
||||
button.textContent = label;
|
||||
button.classList.toggle("active", Glow.state.pageTypeFilter === value);
|
||||
|
||||
button.addEventListener("click", () => Glow.loadPages(value));
|
||||
filters.append(button);
|
||||
}
|
||||
|
||||
box.append(filters);
|
||||
|
||||
if (!pages.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "empty-state page-empty-state";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "empty-card";
|
||||
card.textContent = "No pages match this filter.";
|
||||
|
||||
empty.append(card);
|
||||
box.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "pages-list";
|
||||
|
||||
for (const page of pages) {
|
||||
list.append(Glow.buildPageCard(page));
|
||||
}
|
||||
|
||||
box.append(list);
|
||||
};
|
||||
|
||||
Glow.buildPageCard = page => {
|
||||
const article = document.createElement("article");
|
||||
article.className = "page-card";
|
||||
article.tabIndex = 0;
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "page-card-meta";
|
||||
|
||||
const type = document.createElement("span");
|
||||
type.className = "page-badge type";
|
||||
type.textContent = Glow.pageTypeLabel(page.page_type);
|
||||
|
||||
const visibility = document.createElement("span");
|
||||
visibility.className = `page-badge visibility ${page.visibility}`;
|
||||
visibility.textContent = page.visibility;
|
||||
|
||||
meta.append(type, visibility);
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = page.title;
|
||||
|
||||
const excerpt = document.createElement("p");
|
||||
excerpt.className = "page-card-excerpt";
|
||||
|
||||
const compactBody = (page.body || "").replace(/\s+/g, " ").trim();
|
||||
|
||||
excerpt.textContent =
|
||||
compactBody.length > 180
|
||||
? `${compactBody.slice(0, 180)}…`
|
||||
: compactBody || "No content yet.";
|
||||
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "page-card-footer";
|
||||
|
||||
const context = document.createElement("span");
|
||||
context.textContent = page.channel_name ? `#${page.channel_name}` : "Team page";
|
||||
|
||||
const updated = document.createElement("time");
|
||||
updated.dateTime = page.updated_at;
|
||||
updated.title = new Date(page.updated_at).toLocaleString();
|
||||
updated.textContent = `Updated ${Glow.relativeTime(page.updated_at)}`;
|
||||
|
||||
footer.append(context, updated);
|
||||
|
||||
const open = () => Glow.openPage(page.slug);
|
||||
|
||||
article.addEventListener("click", open);
|
||||
article.addEventListener("keydown", event => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
|
||||
article.append(meta, title, excerpt, footer);
|
||||
return article;
|
||||
};
|
||||
|
||||
Glow.openPages = async () => {
|
||||
Glow.state.activeDm = null;
|
||||
Glow.state.activeView = "pages";
|
||||
Glow.state.activePage = null;
|
||||
|
||||
Glow.updateViewUI();
|
||||
await Glow.loadPages();
|
||||
};
|
||||
|
||||
Glow.openPage = async pageSlug => {
|
||||
const team = Glow.state.activeTeam;
|
||||
if (!team) return;
|
||||
|
||||
Glow.state.activeView = "page";
|
||||
Glow.state.activePage = null;
|
||||
Glow.updateViewUI();
|
||||
|
||||
Glow.els.pageDetailBox.setAttribute("aria-busy", "true");
|
||||
|
||||
try {
|
||||
const data = await Glow.apiRequest(
|
||||
`/api/teams/${encodeURIComponent(team.slug)}/pages/${encodeURIComponent(pageSlug)}`
|
||||
);
|
||||
|
||||
Glow.state.activePage = data.page;
|
||||
Glow.renderPageDetail();
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not load page.", "error");
|
||||
Glow.openPages();
|
||||
} finally {
|
||||
Glow.els.pageDetailBox.setAttribute("aria-busy", "false");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.renderPageDetail = () => {
|
||||
const box = Glow.els.pageDetailBox;
|
||||
const page = Glow.state.activePage;
|
||||
|
||||
box.replaceChildren();
|
||||
|
||||
if (!page) {
|
||||
const state = document.createElement("div");
|
||||
state.className = "empty-state";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "empty-card";
|
||||
card.textContent = "Page not found.";
|
||||
|
||||
state.append(card);
|
||||
box.append(state);
|
||||
return;
|
||||
}
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "page-detail-header";
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "page-detail-meta";
|
||||
|
||||
const type = document.createElement("span");
|
||||
type.className = "page-badge type";
|
||||
type.textContent = Glow.pageTypeLabel(page.page_type);
|
||||
|
||||
const visibility = document.createElement("span");
|
||||
visibility.className = `page-badge visibility ${page.visibility}`;
|
||||
visibility.textContent = page.visibility;
|
||||
|
||||
const updated = document.createElement("time");
|
||||
updated.className = "page-updated";
|
||||
updated.dateTime = page.updated_at;
|
||||
updated.title = new Date(page.updated_at).toLocaleString();
|
||||
updated.textContent = `Updated ${Glow.relativeTime(page.updated_at)}`;
|
||||
|
||||
meta.append(type, visibility, updated);
|
||||
|
||||
const title = document.createElement("h1");
|
||||
title.textContent = page.title;
|
||||
|
||||
const context = document.createElement("p");
|
||||
context.className = "page-detail-context";
|
||||
context.textContent = page.channel_name
|
||||
? `${page.team_name} · #${page.channel_name}`
|
||||
: page.team_name;
|
||||
|
||||
header.append(meta, title, context);
|
||||
box.append(header);
|
||||
|
||||
const body = document.createElement("article");
|
||||
body.className = "page-body markdown-body";
|
||||
body.innerHTML = page.body_html || "<p>No content yet.</p>";
|
||||
|
||||
box.append(body);
|
||||
|
||||
if (page.can_edit) {
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "page-detail-actions";
|
||||
|
||||
const edit = document.createElement("button");
|
||||
edit.className = "button";
|
||||
edit.type = "button";
|
||||
edit.textContent = "Edit";
|
||||
edit.addEventListener("click", () => Glow.editPage(page));
|
||||
|
||||
const remove = document.createElement("button");
|
||||
remove.className = "danger-button";
|
||||
remove.type = "button";
|
||||
remove.textContent = "Delete";
|
||||
remove.addEventListener("click", () => Glow.deletePage(page));
|
||||
|
||||
actions.append(edit, remove);
|
||||
box.append(actions);
|
||||
}
|
||||
};
|
||||
|
||||
Glow.pageDialogFields = (page = null) => [
|
||||
{
|
||||
name: "title",
|
||||
label: "Title",
|
||||
value: page?.title || "",
|
||||
placeholder: "Page title",
|
||||
maxLength: 160
|
||||
},
|
||||
{
|
||||
name: "page_type",
|
||||
label: "Type: project, document, update, or resource",
|
||||
value: page?.page_type || "document",
|
||||
maxLength: 12
|
||||
},
|
||||
{
|
||||
name: "visibility",
|
||||
label: "Visibility: team or public",
|
||||
value: page?.visibility || "team",
|
||||
maxLength: 6
|
||||
},
|
||||
{
|
||||
name: "channel_slug",
|
||||
label: "Channel slug (optional)",
|
||||
value: page?.channel_slug || "",
|
||||
placeholder: "general",
|
||||
maxLength: 48
|
||||
},
|
||||
{
|
||||
name: "body",
|
||||
label: "Markdown content",
|
||||
value: page?.body || "",
|
||||
multiline: true,
|
||||
rows: 14,
|
||||
maxLength: 100000
|
||||
}
|
||||
];
|
||||
|
||||
Glow.validatePageValues = values => {
|
||||
if (!values.title) {
|
||||
Glow.showToast("Page title is required.", "error");
|
||||
return null;
|
||||
}
|
||||
|
||||
const pageType = values.page_type.toLowerCase();
|
||||
if (!["project", "document", "update", "resource"].includes(pageType)) {
|
||||
Glow.showToast("Choose a valid page type.", "error");
|
||||
return null;
|
||||
}
|
||||
|
||||
const visibility = values.visibility.toLowerCase();
|
||||
if (!["team", "public"].includes(visibility)) {
|
||||
Glow.showToast("Visibility must be team or public.", "error");
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title: values.title,
|
||||
body: values.body,
|
||||
page_type: pageType,
|
||||
visibility,
|
||||
channel_slug: values.channel_slug.toLowerCase()
|
||||
};
|
||||
};
|
||||
|
||||
Glow.createPage = async () => {
|
||||
const team = Glow.state.activeTeam;
|
||||
if (!team?.can_manage) return;
|
||||
|
||||
const values = await Glow.openDialog({
|
||||
title: "Create page",
|
||||
body: "Create a shared team page or a public resource. Markdown is supported.",
|
||||
confirmText: "Create page",
|
||||
fields: Glow.pageDialogFields()
|
||||
});
|
||||
|
||||
if (!values) return;
|
||||
|
||||
const payload = Glow.validatePageValues(values);
|
||||
if (!payload) return;
|
||||
|
||||
try {
|
||||
const data = await Glow.apiRequest(
|
||||
`/api/teams/${encodeURIComponent(team.slug)}/pages`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
|
||||
Glow.showToast("Page created.");
|
||||
await Glow.openPage(data.page.slug);
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not create page.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.editPage = async page => {
|
||||
if (!page.can_edit) return;
|
||||
|
||||
const values = await Glow.openDialog({
|
||||
title: `Edit ${page.title}`,
|
||||
confirmText: "Save",
|
||||
fields: Glow.pageDialogFields(page)
|
||||
});
|
||||
|
||||
if (!values) return;
|
||||
|
||||
const payload = Glow.validatePageValues(values);
|
||||
if (!payload) return;
|
||||
|
||||
try {
|
||||
const data = await Glow.apiRequest(
|
||||
`/api/teams/${encodeURIComponent(page.team_slug)}/pages/${encodeURIComponent(page.slug)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
|
||||
Glow.state.activePage = data.page;
|
||||
Glow.renderPageDetail();
|
||||
Glow.showToast("Page updated.");
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not update page.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.deletePage = async page => {
|
||||
if (!page.can_edit) return;
|
||||
|
||||
const result = await Glow.openDialog({
|
||||
title: `Delete ${page.title}?`,
|
||||
body: "This page will no longer be available.",
|
||||
confirmText: "Delete page",
|
||||
danger: true
|
||||
});
|
||||
|
||||
if (!result) return;
|
||||
|
||||
try {
|
||||
await Glow.apiRequest(
|
||||
`/api/teams/${encodeURIComponent(page.team_slug)}/pages/${encodeURIComponent(page.slug)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
|
||||
Glow.showToast("Page deleted.");
|
||||
await Glow.openPages();
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not delete page.", "error");
|
||||
}
|
||||
};
|
||||
681
static/js/07-team-channel-adapter.js
Normal file
681
static/js/07-team-channel-adapter.js
Normal file
@ -0,0 +1,681 @@
|
||||
// static/js/07-team-channel-adapter.js
|
||||
"use strict";
|
||||
|
||||
window.Glow = window.Glow || {};
|
||||
|
||||
Glow.teamAdapter = Glow.teamAdapter || {
|
||||
initialized: false,
|
||||
originalLoadRooms: null,
|
||||
originalUpdateViewUI: null,
|
||||
chatBarVisibilityInstalled: false
|
||||
};
|
||||
|
||||
Glow.loadTeamsForAdapter = async () => {
|
||||
if (!Glow.els.teamSelect) return;
|
||||
|
||||
try {
|
||||
const data = await Glow.apiRequest("/api/teams");
|
||||
const teams = data.teams || [];
|
||||
|
||||
const selectedSlug =
|
||||
Glow.state.activeTeam?.slug ||
|
||||
Glow.els.teamSelect.value;
|
||||
|
||||
Glow.state.teams = teams;
|
||||
Glow.els.teamSelect.replaceChildren();
|
||||
|
||||
for (const team of teams) {
|
||||
const option = document.createElement("option");
|
||||
option.value = team.slug;
|
||||
option.textContent = team.name;
|
||||
Glow.els.teamSelect.append(option);
|
||||
}
|
||||
|
||||
Glow.state.activeTeam =
|
||||
teams.find(team => team.slug === selectedSlug) ||
|
||||
teams.find(team => team.slug === "general") ||
|
||||
teams[0] ||
|
||||
null;
|
||||
|
||||
Glow.els.teamSelect.disabled = !Glow.state.activeTeam;
|
||||
|
||||
if (Glow.state.activeTeam) {
|
||||
Glow.els.teamSelect.value = Glow.state.activeTeam.slug;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Team adapter could not load teams:", error);
|
||||
Glow.state.teams = [];
|
||||
Glow.state.activeTeam = null;
|
||||
Glow.els.teamSelect.disabled = true;
|
||||
}
|
||||
};
|
||||
|
||||
Glow.loadChannelsForTeam = async (teamSlug, preferredChannelSlug = null) => {
|
||||
if (!Glow.els.roomSelect) return;
|
||||
|
||||
if (!teamSlug) {
|
||||
Glow.state.channels = [];
|
||||
Glow.state.activeChannel = null;
|
||||
Glow.els.roomSelect.replaceChildren();
|
||||
Glow.els.roomSelect.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await Glow.apiRequest(
|
||||
`/api/teams/${encodeURIComponent(teamSlug)}/channels`
|
||||
);
|
||||
|
||||
const channels = data.channels || [];
|
||||
Glow.state.channels = channels;
|
||||
|
||||
const matchingTeam = Glow.state.teams.find(team => team.slug === teamSlug);
|
||||
|
||||
if (matchingTeam || data.team) {
|
||||
Glow.state.activeTeam = {
|
||||
...(matchingTeam || {}),
|
||||
...(data.team || {})
|
||||
};
|
||||
}
|
||||
|
||||
Glow.els.roomSelect.replaceChildren();
|
||||
|
||||
for (const channel of channels) {
|
||||
const option = document.createElement("option");
|
||||
option.value = channel.slug;
|
||||
option.textContent = `# ${channel.name}`;
|
||||
Glow.els.roomSelect.append(option);
|
||||
}
|
||||
|
||||
const selected =
|
||||
channels.find(channel => channel.slug === preferredChannelSlug) ||
|
||||
channels.find(channel => channel.slug === Glow.state.activeChannel?.slug) ||
|
||||
channels.find(channel => channel.slug === Glow.state.activeRoom?.slug) ||
|
||||
channels.find(channel => channel.slug === "general") ||
|
||||
channels[0] ||
|
||||
null;
|
||||
|
||||
Glow.state.activeChannel = selected;
|
||||
Glow.state.activeRoom = selected;
|
||||
|
||||
Glow.els.roomSelect.disabled = !selected;
|
||||
|
||||
if (selected) {
|
||||
Glow.els.roomSelect.value = selected.slug;
|
||||
}
|
||||
};
|
||||
|
||||
Glow.decorateChannelSelector = () => {
|
||||
if (!Glow.els.roomSelect) return;
|
||||
|
||||
Glow.els.roomSelect.classList.add("channel-select");
|
||||
Glow.els.roomSelect.setAttribute("aria-label", "Select channel");
|
||||
};
|
||||
|
||||
Glow.updateTeamChannelHeader = () => {
|
||||
const team = Glow.state.activeTeam;
|
||||
const channel = Glow.state.activeChannel || Glow.state.activeRoom;
|
||||
|
||||
const isChannelView =
|
||||
Glow.state.activeView === "channel" ||
|
||||
Glow.state.activeView === "room";
|
||||
|
||||
if (
|
||||
Glow.state.activeView === "dm" ||
|
||||
Glow.state.activeView === "dms"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isChannelView || !team || !channel || !Glow.els.headerTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.els.headerTitle.textContent = `# ${channel.name}`;
|
||||
Glow.els.headerSubtitle.textContent =
|
||||
`${team.name} · ${channel.description || "Channel conversation"}`;
|
||||
};
|
||||
|
||||
Glow.applyComposerVisibility = () => {
|
||||
const view = Glow.state.activeView || "feed";
|
||||
|
||||
const showChannelComposer =
|
||||
view === "room" || view === "channel" || view === "dm";
|
||||
const showThreadComposer = view === "thread";
|
||||
|
||||
Glow.els.composerWrap?.classList.toggle("hidden", !showChannelComposer);
|
||||
Glow.els.threadComposerWrap?.classList.toggle("hidden", !showThreadComposer);
|
||||
|
||||
if (!showChannelComposer) {
|
||||
Glow.state.typingUsers = [];
|
||||
Glow.state.dmTypingUsers = [];
|
||||
Glow.renderTyping?.();
|
||||
}
|
||||
};
|
||||
|
||||
Glow.openCreateTeamDialog = async () => {
|
||||
const values = await Glow.openDialog({
|
||||
title: "Create team",
|
||||
body: "Create a team. Each user can own up to 3 teams.",
|
||||
confirmText: "Create team",
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
label: "Team name",
|
||||
placeholder: "Design",
|
||||
maxLength: 60
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
label: "Description",
|
||||
placeholder: "Optional team description",
|
||||
multiline: true,
|
||||
rows: 3,
|
||||
maxLength: 300
|
||||
},
|
||||
{
|
||||
name: "slug",
|
||||
label: "Slug (optional)",
|
||||
placeholder: "design",
|
||||
maxLength: 48
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!values) return;
|
||||
|
||||
try {
|
||||
const data = await Glow.apiCreateTeam({
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
slug: values.slug
|
||||
});
|
||||
|
||||
Glow.showToast("Team created.");
|
||||
await Glow.refreshTeamAndChannelData(data.team?.slug || null);
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not create team.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.openEditTeamDialog = async team => {
|
||||
if (!team?.can_manage) {
|
||||
Glow.showToast("You cannot edit this team.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const values = await Glow.openDialog({
|
||||
title: `Edit ${team.name}`,
|
||||
confirmText: "Save",
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
label: "Team name",
|
||||
value: team.name || "",
|
||||
maxLength: 60
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
label: "Description",
|
||||
value: team.description || "",
|
||||
multiline: true,
|
||||
rows: 3,
|
||||
maxLength: 300
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!values) return;
|
||||
|
||||
try {
|
||||
await Glow.apiUpdateTeam(team.slug, {
|
||||
name: values.name,
|
||||
description: values.description
|
||||
});
|
||||
|
||||
Glow.showToast("Team updated.");
|
||||
await Glow.refreshTeamAndChannelData(team.slug);
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not update team.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.openCreateChannelDialog = async team => {
|
||||
if (!team?.can_manage) {
|
||||
Glow.showToast("You cannot add channels to this team.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const values = await Glow.openDialog({
|
||||
title: `Add channel to ${team.name}`,
|
||||
confirmText: "Create channel",
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
label: "Channel name",
|
||||
placeholder: "announcements",
|
||||
maxLength: 60
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
label: "Description",
|
||||
placeholder: "Optional channel description",
|
||||
multiline: true,
|
||||
rows: 3,
|
||||
maxLength: 300
|
||||
},
|
||||
{
|
||||
name: "slug",
|
||||
label: "Slug (optional)",
|
||||
placeholder: "announcements",
|
||||
maxLength: 48
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!values) return;
|
||||
|
||||
try {
|
||||
const data = await Glow.apiCreateChannel(team.slug, {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
slug: values.slug
|
||||
});
|
||||
|
||||
Glow.showToast("Channel created.");
|
||||
await Glow.refreshTeamAndChannelData(team.slug, data.channel?.slug || null);
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not create channel.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.openEditChannelDialog = async (team, channel) => {
|
||||
if (!team?.can_manage || !channel) {
|
||||
Glow.showToast("You cannot edit this channel.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const values = await Glow.openDialog({
|
||||
title: `Edit # ${channel.name}`,
|
||||
confirmText: "Save",
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
label: "Channel name",
|
||||
value: channel.name || "",
|
||||
maxLength: 60
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
label: "Description",
|
||||
value: channel.description || "",
|
||||
multiline: true,
|
||||
rows: 3,
|
||||
maxLength: 300
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!values) return;
|
||||
|
||||
try {
|
||||
await Glow.apiUpdateChannel(team.slug, channel.slug, {
|
||||
name: values.name,
|
||||
description: values.description
|
||||
});
|
||||
|
||||
Glow.showToast("Channel updated.");
|
||||
await Glow.refreshTeamAndChannelData(team.slug, channel.slug);
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not update channel.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.manageTeamMembers = async team => {
|
||||
if (!team?.can_manage) {
|
||||
Glow.showToast("You cannot manage this team.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await Glow.apiListTeamMembers(team.slug);
|
||||
|
||||
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 actions = document.createElement("div");
|
||||
actions.className = "settings-actions";
|
||||
|
||||
const promote = document.createElement("button");
|
||||
promote.className = "small-button";
|
||||
promote.type = "button";
|
||||
promote.textContent =
|
||||
member.membership_role === "member" ? "Make admin" : "Make member";
|
||||
promote.disabled = member.membership_role === "owner";
|
||||
promote.addEventListener("click", async () => {
|
||||
try {
|
||||
await Glow.apiUpdateTeamMember(team.slug, member.id, {
|
||||
membership_role:
|
||||
member.membership_role === "member" ? "admin" : "member"
|
||||
});
|
||||
Glow.closeDialog({});
|
||||
Glow.manageTeamMembers(team);
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not update member.", "error");
|
||||
}
|
||||
});
|
||||
|
||||
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.apiRemoveTeamMember(team.slug, member.id);
|
||||
Glow.closeDialog({});
|
||||
Glow.manageTeamMembers(team);
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not remove member.", "error");
|
||||
}
|
||||
});
|
||||
|
||||
actions.append(promote, remove);
|
||||
row.append(name, actions);
|
||||
container.append(row);
|
||||
}
|
||||
|
||||
const values = await Glow.openDialog({
|
||||
title: `${team.name} members`,
|
||||
body: container,
|
||||
confirmText: "Add member",
|
||||
fields: [
|
||||
{
|
||||
name: "username",
|
||||
label: "Username to add",
|
||||
placeholder: "username",
|
||||
maxLength: 30
|
||||
},
|
||||
{
|
||||
name: "membership_role",
|
||||
label: "Role: member or admin",
|
||||
value: "member",
|
||||
maxLength: 6
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!values?.username) return;
|
||||
|
||||
await Glow.apiAddTeamMember(team.slug, {
|
||||
username: values.username,
|
||||
membership_role: values.membership_role || "member"
|
||||
});
|
||||
|
||||
Glow.showToast("Member added.");
|
||||
await Glow.manageTeamMembers(team);
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not manage team members.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.createTeamInviteLink = async team => {
|
||||
if (!team?.can_manage) return;
|
||||
|
||||
try {
|
||||
const values = await Glow.openDialog({
|
||||
title: `Create invite for ${team.name}`,
|
||||
confirmText: "Create link",
|
||||
fields: [
|
||||
{
|
||||
name: "membership_role",
|
||||
label: "Invite role: member or admin",
|
||||
value: "member",
|
||||
maxLength: 6
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!values) return;
|
||||
|
||||
const data = await Glow.apiCreateTeamInvite(team.slug, {
|
||||
membership_role: values.membership_role || "member"
|
||||
});
|
||||
|
||||
const url = new URL(data.invite.url, window.location.origin).toString();
|
||||
|
||||
const result = await Glow.openDialog({
|
||||
title: "Team 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 (result) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
Glow.showToast("Invite link copied.");
|
||||
}
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not create team invite.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.deleteCurrentChannel = async team => {
|
||||
const channel = Glow.state.activeChannel;
|
||||
if (!team?.can_manage || !channel) return;
|
||||
|
||||
const result = await Glow.openDialog({
|
||||
title: `Delete # ${channel.name}?`,
|
||||
body: "This channel will no longer be available.",
|
||||
confirmText: "Delete channel",
|
||||
danger: true
|
||||
});
|
||||
|
||||
if (!result) return;
|
||||
|
||||
try {
|
||||
await Glow.apiDeleteChannel(team.slug, channel.slug);
|
||||
Glow.showToast("Channel deleted.");
|
||||
await Glow.refreshTeamAndChannelData(team.slug);
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not delete channel.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.refreshTeamAndChannelData = async (teamSlug = null, channelSlug = null) => {
|
||||
await Glow.loadTeamsForAdapter();
|
||||
|
||||
const targetTeamSlug =
|
||||
teamSlug ||
|
||||
Glow.state.activeTeam?.slug ||
|
||||
Glow.state.teams?.[0]?.slug ||
|
||||
null;
|
||||
|
||||
if (!targetTeamSlug) {
|
||||
Glow.updateViewUI();
|
||||
return;
|
||||
}
|
||||
|
||||
await Glow.loadChannelsForTeam(targetTeamSlug, channelSlug);
|
||||
|
||||
Glow.state.activeDm = null;
|
||||
Glow.state.activeView = "channel";
|
||||
Glow.updateViewUI();
|
||||
|
||||
if (Glow.state.activeChannel) {
|
||||
await Glow.loadChannelMessages?.();
|
||||
Glow.pollTyping?.();
|
||||
Glow.pingPresence?.();
|
||||
Glow.refreshPresence?.();
|
||||
} else {
|
||||
Glow.clearMessages?.();
|
||||
Glow.renderEmptyState?.("No channels are available in this team.");
|
||||
}
|
||||
|
||||
Glow.schedulePoll?.();
|
||||
};
|
||||
|
||||
Glow.installTeamAdapter = () => {
|
||||
if (Glow.teamAdapter.initialized) return;
|
||||
|
||||
if (!Glow.els.teamSelect || !Glow.els.roomSelect) {
|
||||
console.warn("Team adapter skipped: expected team/channel elements are missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.teamAdapter.initialized = true;
|
||||
|
||||
Glow.state.teams ||= [];
|
||||
Glow.state.channels ||= [];
|
||||
Glow.state.activeTeam ||= null;
|
||||
Glow.state.activeChannel ||= null;
|
||||
|
||||
Glow.decorateChannelSelector();
|
||||
|
||||
Glow.teamAdapter.originalLoadRooms = Glow.loadRooms;
|
||||
|
||||
Glow.loadRooms = async () => {
|
||||
await Glow.teamAdapter.originalLoadRooms();
|
||||
await Glow.loadTeamsForAdapter();
|
||||
Glow.decorateChannelSelector();
|
||||
|
||||
if (Glow.state.activeTeam) {
|
||||
try {
|
||||
await Glow.loadChannelsForTeam(
|
||||
Glow.state.activeTeam.slug,
|
||||
Glow.state.activeRoom?.slug
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("Team adapter could not load channels after rooms:", error);
|
||||
}
|
||||
}
|
||||
|
||||
Glow.updateTeamChannelHeader();
|
||||
};
|
||||
|
||||
Glow.teamAdapter.originalUpdateViewUI = Glow.updateViewUI;
|
||||
|
||||
Glow.updateViewUI = () => {
|
||||
Glow.teamAdapter.originalUpdateViewUI?.();
|
||||
Glow.updateTeamChannelHeader();
|
||||
Glow.applyComposerVisibility();
|
||||
};
|
||||
|
||||
const initializeAdapter = async () => {
|
||||
await Glow.loadTeamsForAdapter();
|
||||
Glow.decorateChannelSelector();
|
||||
|
||||
if (!Glow.state.activeTeam) {
|
||||
Glow.applyComposerVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Glow.loadChannelsForTeam(
|
||||
Glow.state.activeTeam.slug,
|
||||
Glow.state.activeRoom?.slug
|
||||
);
|
||||
|
||||
Glow.updateTeamChannelHeader();
|
||||
} catch (error) {
|
||||
console.warn("Team adapter could not load initial channels:", error);
|
||||
}
|
||||
|
||||
Glow.applyComposerVisibility();
|
||||
};
|
||||
|
||||
initializeAdapter();
|
||||
|
||||
Glow.els.teamSelect.addEventListener("change", async () => {
|
||||
const selected = Glow.state.teams.find(
|
||||
team => team.slug === Glow.els.teamSelect.value
|
||||
);
|
||||
|
||||
if (!selected) return;
|
||||
|
||||
Glow.state.activeDm = null;
|
||||
Glow.state.activeTeam = selected;
|
||||
Glow.state.activeChannel = null;
|
||||
Glow.state.activeRoom = null;
|
||||
|
||||
Glow.state.typingUsers = [];
|
||||
Glow.state.dmTypingUsers = [];
|
||||
Glow.renderTyping?.();
|
||||
Glow.clearMessages?.();
|
||||
|
||||
try {
|
||||
await Glow.loadChannelsForTeam(selected.slug);
|
||||
|
||||
Glow.state.activeView = "channel";
|
||||
Glow.updateViewUI();
|
||||
|
||||
if (Glow.state.activeChannel) {
|
||||
await Glow.loadChannelMessages?.();
|
||||
Glow.pollTyping?.();
|
||||
Glow.pingPresence?.();
|
||||
Glow.refreshPresence?.();
|
||||
} else {
|
||||
Glow.renderEmptyState?.("No channels are available in this team.");
|
||||
}
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not load channels for this team.", "error");
|
||||
}
|
||||
|
||||
Glow.schedulePoll?.();
|
||||
});
|
||||
|
||||
Glow.els.roomSelect.addEventListener("change", () => {
|
||||
window.setTimeout(() => {
|
||||
const selected = Glow.state.channels.find(
|
||||
channel => channel.slug === Glow.els.roomSelect.value
|
||||
);
|
||||
|
||||
if (!selected) return;
|
||||
|
||||
Glow.state.activeDm = null;
|
||||
Glow.state.activeChannel = selected;
|
||||
Glow.state.activeRoom = selected;
|
||||
|
||||
if (
|
||||
Glow.state.activeView === "room" ||
|
||||
Glow.state.activeView === "channel"
|
||||
) {
|
||||
Glow.state.activeView = "channel";
|
||||
}
|
||||
|
||||
Glow.updateViewUI();
|
||||
Glow.pingPresence?.();
|
||||
Glow.refreshPresence?.();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
console.info("Team/channel adapter enabled.");
|
||||
};
|
||||
|
||||
Glow.installTeamAdapter();
|
||||
|
||||
if (!Glow.teamAdapter.chatBarVisibilityInstalled) {
|
||||
Glow.teamAdapter.chatBarVisibilityInstalled = true;
|
||||
|
||||
const previousUpdateViewUI = Glow.updateViewUI;
|
||||
|
||||
Glow.updateViewUI = () => {
|
||||
previousUpdateViewUI?.();
|
||||
Glow.applyComposerVisibility();
|
||||
};
|
||||
}
|
||||
|
||||
Glow.applyComposerVisibility();
|
||||
451
static/js/08-settings.js
Normal file
451
static/js/08-settings.js
Normal file
@ -0,0 +1,451 @@
|
||||
// static/js/08-settings.js
|
||||
"use strict";
|
||||
|
||||
window.Glow = window.Glow || {};
|
||||
|
||||
Glow.state = Glow.state || {};
|
||||
Glow.state.settings = Glow.state.settings || null;
|
||||
|
||||
Glow.applyThemeMode = mode => {
|
||||
const normalized = mode === "light" ? "light" : "dark";
|
||||
document.body.classList.toggle("theme-light", normalized === "light");
|
||||
localStorage.setItem("glow_theme_mode", normalized);
|
||||
};
|
||||
|
||||
Glow.applyCustomCss = cssText => {
|
||||
let style = document.getElementById("glowCustomCss");
|
||||
|
||||
if (!style) {
|
||||
style = document.createElement("style");
|
||||
style.id = "glowCustomCss";
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
style.textContent = typeof cssText === "string" ? cssText : "";
|
||||
};
|
||||
|
||||
Glow.loadMySettings = async () => {
|
||||
try {
|
||||
const data = await Glow.apiGetMySettings();
|
||||
Glow.state.settings = data.settings || {};
|
||||
|
||||
const accent = Glow.state.settings.accent_color || Glow.state.accentColor;
|
||||
Glow.applyAccentColor(accent);
|
||||
|
||||
const mode =
|
||||
Glow.state.settings.theme_mode ||
|
||||
localStorage.getItem("glow_theme_mode") ||
|
||||
"dark";
|
||||
|
||||
Glow.applyThemeMode(mode);
|
||||
Glow.applyCustomCss(Glow.state.settings.custom_css || "");
|
||||
} catch (_) {
|
||||
const mode = localStorage.getItem("glow_theme_mode") || "dark";
|
||||
Glow.applyThemeMode(mode);
|
||||
}
|
||||
};
|
||||
|
||||
Glow.renderHeaderMenu = () => {
|
||||
const list = Glow.els.headerMenuList;
|
||||
if (!list) return;
|
||||
|
||||
list.replaceChildren();
|
||||
|
||||
const addItem = ({ label, hint = "", onClick, hidden = false }) => {
|
||||
if (hidden) return;
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.className = "header-menu-item";
|
||||
button.type = "button";
|
||||
|
||||
const left = document.createElement("span");
|
||||
left.textContent = label;
|
||||
|
||||
const right = document.createElement("small");
|
||||
right.textContent = hint;
|
||||
|
||||
button.append(left, right);
|
||||
button.addEventListener("click", () => {
|
||||
Glow.closeHeaderMenu?.();
|
||||
onClick?.();
|
||||
});
|
||||
|
||||
list.append(button);
|
||||
};
|
||||
|
||||
addItem({
|
||||
label: "My profile",
|
||||
hint: Glow.state.currentUser ? `@${Glow.state.currentUser.username}` : "",
|
||||
onClick: () => Glow.openMyProfile?.(),
|
||||
hidden: !Glow.state.currentUser
|
||||
});
|
||||
|
||||
addItem({
|
||||
label: "Settings",
|
||||
hint: "Theme, CSS, profile",
|
||||
onClick: () => Glow.openSettings?.()
|
||||
});
|
||||
|
||||
addItem({
|
||||
label: "Direct messages",
|
||||
hint: Glow.state.currentUser ? "Private chat" : "",
|
||||
onClick: async () => {
|
||||
Glow.state.activeView = "dms";
|
||||
Glow.updateViewUI();
|
||||
await Glow.loadDms?.();
|
||||
Glow.schedulePoll?.();
|
||||
},
|
||||
hidden: !Glow.state.currentUser
|
||||
});
|
||||
|
||||
addItem({
|
||||
label: "Create team",
|
||||
hint: "Up to 3 max",
|
||||
onClick: () => Glow.openCreateTeamDialog?.(),
|
||||
hidden: !Glow.state.currentUser
|
||||
});
|
||||
|
||||
addItem({
|
||||
label: "Edit current team",
|
||||
hint: Glow.state.activeTeam?.can_manage ? "Manage" : "",
|
||||
onClick: () => Glow.openEditTeamDialog?.(Glow.state.activeTeam),
|
||||
hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage
|
||||
});
|
||||
|
||||
addItem({
|
||||
label: "Add channel",
|
||||
hint: Glow.state.activeTeam?.name || "",
|
||||
onClick: () => Glow.openCreateChannelDialog?.(Glow.state.activeTeam),
|
||||
hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage
|
||||
});
|
||||
|
||||
addItem({
|
||||
label: "Edit current channel",
|
||||
hint: Glow.state.activeChannel?.name || Glow.state.activeRoom?.name || "",
|
||||
onClick: () => Glow.openEditChannelDialog?.(
|
||||
Glow.state.activeTeam,
|
||||
Glow.state.activeChannel || Glow.state.activeRoom
|
||||
),
|
||||
hidden:
|
||||
!Glow.state.activeTeam ||
|
||||
!Glow.state.activeTeam.can_manage ||
|
||||
!(Glow.state.activeChannel || Glow.state.activeRoom) ||
|
||||
Glow.state.activeView === "dm"
|
||||
});
|
||||
|
||||
addItem({
|
||||
label: "Manage team members",
|
||||
hint: Glow.state.activeTeam?.can_manage ? "Members" : "",
|
||||
onClick: () => Glow.manageTeamMembers?.(Glow.state.activeTeam),
|
||||
hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage
|
||||
});
|
||||
|
||||
addItem({
|
||||
label: "Create team invite",
|
||||
hint: Glow.state.activeTeam?.can_manage ? "Share" : "",
|
||||
onClick: () => Glow.createTeamInviteLink?.(Glow.state.activeTeam),
|
||||
hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage
|
||||
});
|
||||
|
||||
addItem({
|
||||
label: "Delete current channel",
|
||||
hint: Glow.state.activeChannel?.name || "",
|
||||
onClick: () => Glow.deleteCurrentChannel?.(Glow.state.activeTeam),
|
||||
hidden:
|
||||
!Glow.state.activeTeam ||
|
||||
!Glow.state.activeTeam.can_manage ||
|
||||
!Glow.state.activeChannel ||
|
||||
Boolean(Glow.state.activeChannel?.legacy_room_id)
|
||||
});
|
||||
};
|
||||
|
||||
Glow.openHeaderMenu = () => {
|
||||
Glow.renderHeaderMenu();
|
||||
Glow.els.headerMenuOverlay?.classList.remove("hidden");
|
||||
};
|
||||
|
||||
Glow.closeHeaderMenu = () => {
|
||||
Glow.els.headerMenuOverlay?.classList.add("hidden");
|
||||
};
|
||||
|
||||
Glow.toggleHeaderMenu = () => {
|
||||
if (Glow.els.headerMenuOverlay?.classList.contains("hidden")) {
|
||||
Glow.openHeaderMenu();
|
||||
} else {
|
||||
Glow.closeHeaderMenu();
|
||||
}
|
||||
};
|
||||
|
||||
Glow.openSettings = async () => {
|
||||
Glow.state.activeView = "settings";
|
||||
Glow.updateViewUI();
|
||||
|
||||
if (!Glow.state.settings && Glow.state.currentUser) {
|
||||
await Glow.loadMySettings();
|
||||
}
|
||||
|
||||
Glow.renderSettingsView();
|
||||
};
|
||||
|
||||
Glow.renderNameColorPreview = value => {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "settings-row";
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.className = "header-subtitle";
|
||||
label.textContent = "Preview";
|
||||
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "name-color-preview";
|
||||
preview.textContent =
|
||||
Glow.state.currentUser?.username || "username";
|
||||
preview.style.color =
|
||||
Glow.normalizeNameColor(value) ||
|
||||
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
|
||||
|
||||
wrap.append(label, preview);
|
||||
return wrap;
|
||||
};
|
||||
|
||||
Glow.renderSettingsView = () => {
|
||||
const box = Glow.els.profileBox;
|
||||
box.replaceChildren();
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "settings-card";
|
||||
|
||||
const appearance = document.createElement("section");
|
||||
appearance.className = "settings-section";
|
||||
|
||||
const aTitle = document.createElement("h2");
|
||||
aTitle.textContent = "Appearance";
|
||||
|
||||
const aText = document.createElement("p");
|
||||
aText.textContent = "Set your theme mode, accent color, username color, and custom CSS.";
|
||||
|
||||
const themeRow = document.createElement("div");
|
||||
themeRow.className = "settings-theme-row";
|
||||
|
||||
const currentTheme =
|
||||
Glow.state.settings?.theme_mode ||
|
||||
localStorage.getItem("glow_theme_mode") ||
|
||||
"dark";
|
||||
|
||||
const darkButton = document.createElement("button");
|
||||
darkButton.className = `theme-pill${currentTheme !== "light" ? " active" : ""}`;
|
||||
darkButton.type = "button";
|
||||
darkButton.textContent = "Dark";
|
||||
|
||||
const lightButton = document.createElement("button");
|
||||
lightButton.className = `theme-pill${currentTheme === "light" ? " active" : ""}`;
|
||||
lightButton.type = "button";
|
||||
lightButton.textContent = "Light";
|
||||
|
||||
darkButton.addEventListener("click", () => {
|
||||
Glow.applyThemeMode("dark");
|
||||
Glow.state.settings ||= {};
|
||||
Glow.state.settings.theme_mode = "dark";
|
||||
Glow.renderSettingsView();
|
||||
});
|
||||
|
||||
lightButton.addEventListener("click", () => {
|
||||
Glow.applyThemeMode("light");
|
||||
Glow.state.settings ||= {};
|
||||
Glow.state.settings.theme_mode = "light";
|
||||
Glow.renderSettingsView();
|
||||
});
|
||||
|
||||
themeRow.append(darkButton, lightButton);
|
||||
|
||||
const accentWrap = document.createElement("div");
|
||||
accentWrap.className = "settings-row";
|
||||
|
||||
const accentLabel = document.createElement("label");
|
||||
accentLabel.className = "dialog-label";
|
||||
accentLabel.textContent = "Accent color";
|
||||
|
||||
const accentInput = document.createElement("input");
|
||||
accentInput.type = "color";
|
||||
accentInput.className = "accent-color-input";
|
||||
accentInput.value =
|
||||
Glow.normalizeAccentColor(
|
||||
Glow.state.settings?.accent_color || Glow.state.accentColor
|
||||
) || Glow.DEFAULT_ACCENT_COLOR;
|
||||
|
||||
accentInput.addEventListener("input", () => {
|
||||
Glow.applyAccentColor(accentInput.value);
|
||||
Glow.state.settings ||= {};
|
||||
Glow.state.settings.accent_color = accentInput.value;
|
||||
});
|
||||
|
||||
accentLabel.append(accentInput);
|
||||
accentWrap.append(accentLabel);
|
||||
|
||||
const nameColorWrap = document.createElement("div");
|
||||
nameColorWrap.className = "settings-row";
|
||||
|
||||
const nameColorLabel = document.createElement("label");
|
||||
nameColorLabel.className = "dialog-label";
|
||||
nameColorLabel.textContent = "Username color";
|
||||
|
||||
const nameColorInput = document.createElement("input");
|
||||
nameColorInput.type = "color";
|
||||
nameColorInput.className = "accent-color-input";
|
||||
nameColorInput.value =
|
||||
Glow.normalizeNameColor(Glow.state.settings?.name_color) ||
|
||||
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
|
||||
|
||||
const nameColorText = document.createElement("input");
|
||||
nameColorText.className = "field accent-hex-input";
|
||||
nameColorText.type = "text";
|
||||
nameColorText.maxLength = 7;
|
||||
nameColorText.value =
|
||||
Glow.normalizeNameColor(Glow.state.settings?.name_color) ||
|
||||
"";
|
||||
nameColorText.placeholder = "#7dd3fc";
|
||||
|
||||
const nameRow = document.createElement("div");
|
||||
nameRow.className = "accent-picker-row";
|
||||
|
||||
const previewSwatch = document.createElement("span");
|
||||
previewSwatch.className = "accent-preview";
|
||||
previewSwatch.style.background =
|
||||
Glow.normalizeNameColor(Glow.state.settings?.name_color) ||
|
||||
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
|
||||
|
||||
const help = document.createElement("div");
|
||||
help.className = "header-subtitle";
|
||||
help.textContent =
|
||||
`Leave empty to use your role default (${Glow.roleDefaultNameColor(Glow.state.currentUser?.role)}).`;
|
||||
|
||||
const updatePreview = value => {
|
||||
const normalized = Glow.normalizeNameColor(value);
|
||||
const finalColor =
|
||||
normalized || Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
|
||||
|
||||
previewSwatch.style.background = finalColor;
|
||||
return finalColor;
|
||||
};
|
||||
|
||||
nameColorInput.addEventListener("input", () => {
|
||||
nameColorText.value = nameColorInput.value;
|
||||
Glow.state.settings ||= {};
|
||||
Glow.state.settings.name_color = nameColorInput.value;
|
||||
updatePreview(nameColorInput.value);
|
||||
Glow.renderSettingsView();
|
||||
});
|
||||
|
||||
nameColorText.addEventListener("input", () => {
|
||||
const normalized = Glow.normalizeNameColor(nameColorText.value.trim());
|
||||
Glow.state.settings ||= {};
|
||||
Glow.state.settings.name_color = normalized || null;
|
||||
updatePreview(nameColorText.value.trim());
|
||||
});
|
||||
|
||||
const resetNameColor = document.createElement("button");
|
||||
resetNameColor.className = "button secondary";
|
||||
resetNameColor.type = "button";
|
||||
resetNameColor.textContent = "Use role default";
|
||||
resetNameColor.addEventListener("click", () => {
|
||||
Glow.state.settings ||= {};
|
||||
Glow.state.settings.name_color = null;
|
||||
Glow.renderSettingsView();
|
||||
});
|
||||
|
||||
nameRow.append(nameColorInput, nameColorText, previewSwatch);
|
||||
nameColorLabel.append(nameRow);
|
||||
nameColorWrap.append(nameColorLabel, help, Glow.renderNameColorPreview(
|
||||
Glow.state.settings?.name_color
|
||||
), resetNameColor);
|
||||
|
||||
appearance.append(aTitle, aText, themeRow, accentWrap, nameColorWrap);
|
||||
|
||||
const cssSection = document.createElement("section");
|
||||
cssSection.className = "settings-section";
|
||||
|
||||
const cTitle = document.createElement("h2");
|
||||
cTitle.textContent = "Custom CSS";
|
||||
|
||||
const cText = document.createElement("p");
|
||||
cText.textContent =
|
||||
"Optional CSS applied only for your account in this browser session and saved to your settings. \n ex: *,html,body{font-size:95%;text-transform:lowercase;}";
|
||||
|
||||
const cssInput = document.createElement("textarea");
|
||||
cssInput.className = "field settings-custom-css";
|
||||
cssInput.placeholder = "/* Custom CSS */";
|
||||
cssInput.value = Glow.state.settings?.custom_css || "";
|
||||
|
||||
cssInput.addEventListener("input", () => {
|
||||
Glow.applyCustomCss(cssInput.value);
|
||||
Glow.state.settings ||= {};
|
||||
Glow.state.settings.custom_css = cssInput.value;
|
||||
});
|
||||
|
||||
cssSection.append(cTitle, cText, cssInput);
|
||||
|
||||
const accountSection = document.createElement("section");
|
||||
accountSection.className = "settings-section";
|
||||
|
||||
const accountTitle = document.createElement("h2");
|
||||
accountTitle.textContent = "Account";
|
||||
|
||||
const accountText = document.createElement("p");
|
||||
accountText.textContent = "Update your public profile and application preferences.";
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "settings-actions";
|
||||
|
||||
const editProfile = document.createElement("button");
|
||||
editProfile.className = "button";
|
||||
editProfile.type = "button";
|
||||
editProfile.textContent = "Edit profile";
|
||||
editProfile.addEventListener("click", () => {
|
||||
Glow.openMyProfile?.(true);
|
||||
});
|
||||
|
||||
const saveSettings = document.createElement("button");
|
||||
saveSettings.className = "button";
|
||||
saveSettings.type = "button";
|
||||
saveSettings.textContent = "Save settings";
|
||||
saveSettings.addEventListener("click", async () => {
|
||||
try {
|
||||
const payload = {
|
||||
accent_color:
|
||||
Glow.normalizeAccentColor(
|
||||
Glow.state.settings?.accent_color || Glow.state.accentColor
|
||||
) || Glow.DEFAULT_ACCENT_COLOR,
|
||||
name_color:
|
||||
Glow.normalizeNameColor(Glow.state.settings?.name_color) || null,
|
||||
custom_css: Glow.state.settings?.custom_css || "",
|
||||
theme_mode:
|
||||
Glow.state.settings?.theme_mode ||
|
||||
localStorage.getItem("glow_theme_mode") ||
|
||||
"dark"
|
||||
};
|
||||
|
||||
const data = await Glow.apiUpdateMySettings(payload);
|
||||
Glow.state.settings = data.settings || payload;
|
||||
Glow.showToast("Settings saved.");
|
||||
Glow.renderSettingsView();
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not save settings.", "error");
|
||||
}
|
||||
});
|
||||
|
||||
const resetAccent = document.createElement("button");
|
||||
resetAccent.className = "button secondary";
|
||||
resetAccent.type = "button";
|
||||
resetAccent.textContent = "Reset accent";
|
||||
resetAccent.addEventListener("click", () => {
|
||||
Glow.resetAccentColor();
|
||||
Glow.state.settings ||= {};
|
||||
Glow.state.settings.accent_color = Glow.DEFAULT_ACCENT_COLOR;
|
||||
Glow.renderSettingsView();
|
||||
});
|
||||
|
||||
actions.append(editProfile, saveSettings, resetAccent);
|
||||
accountSection.append(accountTitle, accountText, actions);
|
||||
|
||||
card.append(appearance, cssSection, accountSection);
|
||||
box.append(card);
|
||||
};
|
||||
349
static/js/09-profile.js
Normal file
349
static/js/09-profile.js
Normal file
@ -0,0 +1,349 @@
|
||||
// static/js/09-profile.js
|
||||
"use strict";
|
||||
|
||||
window.Glow = window.Glow || {};
|
||||
|
||||
Glow.escapeHtml = value =>
|
||||
String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
|
||||
Glow.renderProfileBody = profile => {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "profile-rendered";
|
||||
|
||||
const content = String(profile?.bio_html || "").trim();
|
||||
|
||||
if (!content) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "profile-empty";
|
||||
empty.textContent = "No profile bio yet.";
|
||||
return empty;
|
||||
}
|
||||
|
||||
wrap.innerHTML = content;
|
||||
return wrap;
|
||||
};
|
||||
|
||||
Glow.openProfile = async username => {
|
||||
Glow.state.activeView = "profile";
|
||||
Glow.state.activeProfile = null;
|
||||
Glow.updateViewUI();
|
||||
|
||||
const box = Glow.els.profileBox;
|
||||
box.setAttribute("aria-busy", "true");
|
||||
box.replaceChildren();
|
||||
|
||||
try {
|
||||
const data = await Glow.apiGetPublicProfile(username);
|
||||
Glow.state.activeProfile = data.profile;
|
||||
Glow.renderProfileView(false);
|
||||
} catch (error) {
|
||||
box.replaceChildren();
|
||||
const state = document.createElement("div");
|
||||
state.className = "empty-state";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "empty-card";
|
||||
card.textContent = error.message || "Could not load profile.";
|
||||
|
||||
state.append(card);
|
||||
box.append(state);
|
||||
|
||||
Glow.showToast(error.message || "Could not load profile.", "error");
|
||||
} finally {
|
||||
box.setAttribute("aria-busy", "false");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.openMyProfile = async (edit = false) => {
|
||||
if (!Glow.state.currentUser) {
|
||||
Glow.showToast("Sign in to view your profile.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.state.activeView = "profile";
|
||||
Glow.state.activeProfile = null;
|
||||
Glow.updateViewUI();
|
||||
|
||||
const box = Glow.els.profileBox;
|
||||
box.setAttribute("aria-busy", "true");
|
||||
box.replaceChildren();
|
||||
|
||||
try {
|
||||
const data = await Glow.apiGetMyProfile();
|
||||
Glow.state.activeProfile = data.profile;
|
||||
Glow.renderProfileView(edit);
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not load your profile.", "error");
|
||||
} finally {
|
||||
box.setAttribute("aria-busy", "false");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.renderProfileView = (editing = false) => {
|
||||
const box = Glow.els.profileBox;
|
||||
const profile = Glow.state.activeProfile;
|
||||
|
||||
box.replaceChildren();
|
||||
|
||||
if (!profile) {
|
||||
const state = document.createElement("div");
|
||||
state.className = "empty-state";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "empty-card";
|
||||
card.textContent = "Profile not found.";
|
||||
|
||||
state.append(card);
|
||||
box.append(state);
|
||||
return;
|
||||
}
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "profile-card";
|
||||
|
||||
const banner = document.createElement("div");
|
||||
banner.className = "profile-banner";
|
||||
|
||||
const content = document.createElement("div");
|
||||
content.className = "profile-content";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "profile-header";
|
||||
|
||||
const avatar = document.createElement("div");
|
||||
avatar.className = "profile-avatar";
|
||||
avatar.textContent = (profile.display_name || profile.username || "?")
|
||||
.slice(0, 1)
|
||||
.toUpperCase();
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "profile-actions";
|
||||
|
||||
if (profile.is_self) {
|
||||
const editButton = document.createElement("button");
|
||||
editButton.className = "button";
|
||||
editButton.type = "button";
|
||||
editButton.textContent = editing ? "View profile" : "Edit profile";
|
||||
editButton.addEventListener("click", () => {
|
||||
Glow.renderProfileView(!editing);
|
||||
});
|
||||
|
||||
const settingsButton = document.createElement("button");
|
||||
settingsButton.className = "button secondary";
|
||||
settingsButton.type = "button";
|
||||
settingsButton.textContent = "Settings";
|
||||
settingsButton.addEventListener("click", () => {
|
||||
Glow.openSettings?.();
|
||||
});
|
||||
|
||||
actions.append(editButton, settingsButton);
|
||||
}
|
||||
|
||||
header.append(avatar, actions);
|
||||
|
||||
const name = document.createElement("div");
|
||||
name.className = "profile-name";
|
||||
name.textContent = profile.display_name || profile.username;
|
||||
name.style.color = Glow.resolveUserNameColor({
|
||||
role: profile.role,
|
||||
name_color: profile.name_color
|
||||
});
|
||||
|
||||
const handle = document.createElement("div");
|
||||
handle.className = "profile-handle";
|
||||
handle.textContent = `@${profile.username}`;
|
||||
handle.style.color = Glow.resolveUserNameColor({
|
||||
role: profile.role,
|
||||
name_color: profile.name_color
|
||||
});
|
||||
|
||||
const badges = document.createElement("div");
|
||||
badges.className = "profile-badges";
|
||||
|
||||
const roleBadge = document.createElement("span");
|
||||
roleBadge.className = "profile-badge";
|
||||
roleBadge.textContent = profile.role;
|
||||
|
||||
const statusBadge = document.createElement("span");
|
||||
statusBadge.className = "profile-badge";
|
||||
statusBadge.textContent = profile.status;
|
||||
|
||||
badges.append(roleBadge, statusBadge);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "profile-grid";
|
||||
|
||||
if (editing && profile.is_self) {
|
||||
grid.append(Glow.buildProfileEditor(profile));
|
||||
} else {
|
||||
const bioSection = document.createElement("section");
|
||||
bioSection.className = "profile-section";
|
||||
|
||||
const bioTitle = document.createElement("h3");
|
||||
bioTitle.textContent = "Bio";
|
||||
|
||||
bioSection.append(bioTitle, Glow.renderProfileBody(profile));
|
||||
|
||||
const metaSection = document.createElement("section");
|
||||
metaSection.className = "profile-section";
|
||||
|
||||
const metaTitle = document.createElement("h3");
|
||||
metaTitle.textContent = "Profile";
|
||||
|
||||
const metaList = document.createElement("dl");
|
||||
metaList.className = "profile-meta-list";
|
||||
|
||||
const addMeta = (label, value) => {
|
||||
const dt = document.createElement("dt");
|
||||
dt.textContent = label;
|
||||
|
||||
const dd = document.createElement("dd");
|
||||
dd.textContent = value || "—";
|
||||
|
||||
metaList.append(dt, dd);
|
||||
};
|
||||
|
||||
addMeta("Display name", profile.display_name || profile.username);
|
||||
addMeta("Website", profile.website || "");
|
||||
addMeta("Location", profile.location || "");
|
||||
addMeta(
|
||||
"Joined",
|
||||
profile.created_at ? new Date(profile.created_at).toLocaleDateString() : ""
|
||||
);
|
||||
addMeta("Bio mode", profile.bio_mode || "txt");
|
||||
addMeta("Name color", profile.name_color || "role default");
|
||||
|
||||
metaSection.append(metaTitle, metaList);
|
||||
grid.append(bioSection, metaSection);
|
||||
}
|
||||
|
||||
content.append(header, name, handle, badges, grid);
|
||||
card.append(banner, content);
|
||||
box.append(card);
|
||||
};
|
||||
|
||||
Glow.buildProfileEditor = profile => {
|
||||
const section = document.createElement("section");
|
||||
section.className = "profile-section";
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = "Edit profile";
|
||||
|
||||
const editor = document.createElement("div");
|
||||
editor.className = "profile-editor";
|
||||
|
||||
const displayName = document.createElement("input");
|
||||
displayName.className = "field";
|
||||
displayName.type = "text";
|
||||
displayName.maxLength = 80;
|
||||
displayName.placeholder = "Display name";
|
||||
displayName.value = profile.display_name || "";
|
||||
|
||||
const website = document.createElement("input");
|
||||
website.className = "field";
|
||||
website.type = "url";
|
||||
website.maxLength = 300;
|
||||
website.placeholder = "Website URL";
|
||||
website.value = profile.website || "";
|
||||
|
||||
const location = document.createElement("input");
|
||||
location.className = "field";
|
||||
location.type = "text";
|
||||
location.maxLength = 120;
|
||||
location.placeholder = "Location";
|
||||
location.value = profile.location || "";
|
||||
|
||||
const modeRow = document.createElement("div");
|
||||
modeRow.className = "profile-mode-row";
|
||||
|
||||
let selectedMode = profile.bio_mode || "txt";
|
||||
|
||||
const body = document.createElement("textarea");
|
||||
body.className = "field";
|
||||
body.maxLength = 5000;
|
||||
body.placeholder = "Tell people about yourself";
|
||||
body.value = profile.bio || "";
|
||||
|
||||
for (const mode of ["txt", "md", "html"]) {
|
||||
const button = document.createElement("button");
|
||||
button.className = `mode-pill${selectedMode === mode ? " active" : ""}`;
|
||||
button.type = "button";
|
||||
button.textContent = mode.toUpperCase();
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
selectedMode = mode;
|
||||
for (const el of modeRow.querySelectorAll(".mode-pill")) {
|
||||
el.classList.toggle("active", el.textContent.toLowerCase() === mode);
|
||||
}
|
||||
});
|
||||
|
||||
modeRow.append(button);
|
||||
}
|
||||
|
||||
const help = document.createElement("p");
|
||||
help.className = "header-subtitle";
|
||||
help.textContent =
|
||||
"HTML is sanitized on save/display. Scripts, event handlers, and unsafe URLs are removed.";
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "settings-actions";
|
||||
|
||||
const save = document.createElement("button");
|
||||
save.className = "button";
|
||||
save.type = "button";
|
||||
save.textContent = "Save profile";
|
||||
save.addEventListener("click", async () => {
|
||||
try {
|
||||
const data = await Glow.apiUpdateMyProfile({
|
||||
display_name: displayName.value,
|
||||
website: website.value,
|
||||
location: location.value,
|
||||
bio: body.value,
|
||||
bio_mode: selectedMode
|
||||
});
|
||||
|
||||
Glow.state.activeProfile = {
|
||||
...data.profile,
|
||||
name_color: Glow.state.settings?.name_color || profile.name_color || null
|
||||
};
|
||||
Glow.showToast("Profile updated.");
|
||||
Glow.renderProfileView(false);
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not update profile.", "error");
|
||||
}
|
||||
});
|
||||
|
||||
const preview = document.createElement("button");
|
||||
preview.className = "button secondary";
|
||||
preview.type = "button";
|
||||
preview.textContent = "Preview";
|
||||
preview.addEventListener("click", async () => {
|
||||
try {
|
||||
const data = await Glow.apiUpdateMyProfile({
|
||||
display_name: displayName.value,
|
||||
website: website.value,
|
||||
location: location.value,
|
||||
bio: body.value,
|
||||
bio_mode: selectedMode
|
||||
});
|
||||
|
||||
Glow.state.activeProfile = {
|
||||
...data.profile,
|
||||
name_color: Glow.state.settings?.name_color || profile.name_color || null
|
||||
};
|
||||
Glow.renderProfileView(false);
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not preview profile.", "error");
|
||||
}
|
||||
});
|
||||
|
||||
actions.append(save, preview);
|
||||
editor.append(displayName, website, location, modeRow, help, body, actions);
|
||||
section.append(title, editor);
|
||||
|
||||
return section;
|
||||
};
|
||||
166
static/js/10-reactions.js
Normal file
166
static/js/10-reactions.js
Normal file
@ -0,0 +1,166 @@
|
||||
// static/js/10-reactions.js
|
||||
"use strict";
|
||||
|
||||
window.Glow = window.Glow || {};
|
||||
|
||||
Glow.reactions = Glow.reactions || {
|
||||
enabled: true,
|
||||
emojis: ["👍", "❤️", "🔥", "😂", "🎉", "👏", "😮", "😢", "👀", "✅", "🚀", "💡"],
|
||||
activeMessage: null
|
||||
};
|
||||
|
||||
Glow.submitCustomReaction = async () => {
|
||||
const message = Glow.reactions.activeMessage;
|
||||
const input = Glow.els.reactionCustomInput;
|
||||
if (!message || !input) return;
|
||||
|
||||
const emoji = input.value.trim();
|
||||
if (!emoji) return;
|
||||
|
||||
try {
|
||||
const existing = (message.reactions || []).find(item => item.emoji === emoji);
|
||||
|
||||
if (existing?.reacted) {
|
||||
await Glow.apiRemoveReaction(message.id, emoji);
|
||||
} else {
|
||||
await Glow.apiAddReaction(message.id, emoji);
|
||||
}
|
||||
|
||||
const data = await Glow.apiGetMessageReactions(message.id);
|
||||
message.reactions = data.reactions || [];
|
||||
Glow.updateMessageReactionsDom?.(message.id, message.reactions);
|
||||
|
||||
Glow.renderReactionPopupUsers(message.reactions);
|
||||
input.value = "";
|
||||
|
||||
if (Glow.state.activeView === "feed") {
|
||||
Glow.loadFeed?.();
|
||||
}
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not update reaction.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.openReactionPopup = async message => {
|
||||
Glow.reactions.activeMessage = message;
|
||||
|
||||
const grid = Glow.els.reactionPopupGrid;
|
||||
const list = Glow.els.reactionPopupList;
|
||||
const customInput = Glow.els.reactionCustomInput;
|
||||
|
||||
if (!grid || !list) return;
|
||||
|
||||
grid.replaceChildren();
|
||||
list.replaceChildren();
|
||||
|
||||
for (const emoji of Glow.reactions.emojis) {
|
||||
const button = document.createElement("button");
|
||||
button.className = "reaction-popup-button";
|
||||
button.type = "button";
|
||||
button.textContent = emoji;
|
||||
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
const existing = (message.reactions || []).find(item => item.emoji === emoji);
|
||||
|
||||
if (existing?.reacted) {
|
||||
await Glow.apiRemoveReaction(message.id, emoji);
|
||||
} else {
|
||||
await Glow.apiAddReaction(message.id, emoji);
|
||||
}
|
||||
|
||||
const data = await Glow.apiGetMessageReactions(message.id);
|
||||
message.reactions = data.reactions || [];
|
||||
Glow.updateMessageReactionsDom?.(message.id, message.reactions);
|
||||
|
||||
Glow.renderReactionPopupUsers(message.reactions);
|
||||
|
||||
if (Glow.state.activeView === "feed") {
|
||||
Glow.loadFeed?.();
|
||||
}
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not update reaction.", "error");
|
||||
}
|
||||
});
|
||||
|
||||
grid.append(button);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await Glow.apiGetMessageReactions(message.id);
|
||||
message.reactions = data.reactions || [];
|
||||
} catch (_) {
|
||||
message.reactions = [];
|
||||
}
|
||||
|
||||
if (customInput) {
|
||||
customInput.value = "";
|
||||
}
|
||||
|
||||
Glow.renderReactionPopupUsers(message.reactions || []);
|
||||
Glow.els.reactionPopupOverlay?.classList.remove("hidden");
|
||||
|
||||
setTimeout(() => {
|
||||
Glow.els.reactionCustomInput?.focus();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
Glow.renderReactionPopupUsers = reactions => {
|
||||
const list = Glow.els.reactionPopupList;
|
||||
if (!list) return;
|
||||
|
||||
list.replaceChildren();
|
||||
|
||||
if (!reactions.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "reaction-popup-user";
|
||||
empty.textContent = "No reactions yet.";
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const reaction of reactions) {
|
||||
const section = document.createElement("div");
|
||||
section.className = "reaction-popup-section";
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.className = "reaction-popup-user";
|
||||
title.innerHTML = `<strong>${reaction.emoji} ${reaction.count}</strong>`;
|
||||
section.append(title);
|
||||
|
||||
for (const user of reaction.users || []) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "reaction-popup-user";
|
||||
|
||||
const left = document.createElement("span");
|
||||
left.textContent = user.username;
|
||||
|
||||
const right = document.createElement("span");
|
||||
right.textContent = new Date(user.created_at).toLocaleString();
|
||||
|
||||
row.append(left, right);
|
||||
section.append(row);
|
||||
}
|
||||
|
||||
list.append(section);
|
||||
}
|
||||
};
|
||||
|
||||
Glow.closeReactionPopup = () => {
|
||||
Glow.reactions.activeMessage = null;
|
||||
if (Glow.els.reactionCustomInput) {
|
||||
Glow.els.reactionCustomInput.value = "";
|
||||
}
|
||||
Glow.els.reactionPopupOverlay?.classList.add("hidden");
|
||||
};
|
||||
|
||||
Glow.els.reactionCustomButton?.addEventListener("click", () => {
|
||||
Glow.submitCustomReaction?.();
|
||||
});
|
||||
|
||||
Glow.els.reactionCustomInput?.addEventListener("keydown", event => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
Glow.submitCustomReaction?.();
|
||||
}
|
||||
});
|
||||
65
static/js/11-media.js
Normal file
65
static/js/11-media.js
Normal file
@ -0,0 +1,65 @@
|
||||
// static/js/11-media.js
|
||||
"use strict";
|
||||
|
||||
window.Glow = window.Glow || {};
|
||||
|
||||
Glow.resetMediaModal = () => {
|
||||
if (!Glow.els.mediaOverlay) return;
|
||||
|
||||
Glow.els.mediaImage.classList.add("hidden");
|
||||
Glow.els.mediaAudio.classList.add("hidden");
|
||||
Glow.els.mediaVideo.classList.add("hidden");
|
||||
Glow.els.mediaFileFallback.classList.add("hidden");
|
||||
|
||||
Glow.els.mediaImage.removeAttribute("src");
|
||||
Glow.els.mediaImage.alt = "";
|
||||
|
||||
Glow.els.mediaAudio.pause();
|
||||
Glow.els.mediaAudio.removeAttribute("src");
|
||||
Glow.els.mediaAudio.load();
|
||||
|
||||
Glow.els.mediaVideo.pause();
|
||||
Glow.els.mediaVideo.removeAttribute("src");
|
||||
Glow.els.mediaVideo.load();
|
||||
|
||||
Glow.els.mediaTitle.textContent = "Media";
|
||||
Glow.els.mediaDownloadLink.href = "#";
|
||||
Glow.els.mediaDownloadLink.removeAttribute("download");
|
||||
Glow.els.mediaFallbackLink.href = "#";
|
||||
};
|
||||
|
||||
Glow.openMediaModal = ({ url, name = "Media", mimeType = "" }) => {
|
||||
if (!url || !Glow.els.mediaOverlay) return;
|
||||
|
||||
Glow.resetMediaModal();
|
||||
|
||||
const mime = String(mimeType || "").toLowerCase();
|
||||
|
||||
Glow.els.mediaTitle.textContent = name;
|
||||
Glow.els.mediaDownloadLink.href = url;
|
||||
Glow.els.mediaDownloadLink.download = name;
|
||||
Glow.els.mediaFallbackLink.href = url;
|
||||
|
||||
if (mime.startsWith("image/")) {
|
||||
Glow.els.mediaImage.src = url;
|
||||
Glow.els.mediaImage.alt = name;
|
||||
Glow.els.mediaImage.classList.remove("hidden");
|
||||
} else if (mime.startsWith("audio/")) {
|
||||
Glow.els.mediaAudio.src = url;
|
||||
Glow.els.mediaAudio.classList.remove("hidden");
|
||||
} else if (mime.startsWith("video/")) {
|
||||
Glow.els.mediaVideo.src = url;
|
||||
Glow.els.mediaVideo.classList.remove("hidden");
|
||||
} else {
|
||||
Glow.els.mediaFileFallback.classList.remove("hidden");
|
||||
}
|
||||
|
||||
Glow.els.mediaOverlay.classList.remove("hidden");
|
||||
};
|
||||
|
||||
Glow.closeMediaModal = () => {
|
||||
if (!Glow.els.mediaOverlay) return;
|
||||
|
||||
Glow.els.mediaOverlay.classList.add("hidden");
|
||||
Glow.resetMediaModal();
|
||||
};
|
||||
389
static/js/12-calls.js
Normal file
389
static/js/12-calls.js
Normal file
@ -0,0 +1,389 @@
|
||||
// static/js/12-calls.js
|
||||
"use strict";
|
||||
|
||||
window.Glow = window.Glow || {};
|
||||
|
||||
Glow.calls = Glow.calls || {};
|
||||
|
||||
Glow.calls.iceServers = [
|
||||
{ urls: "stun:stun.l.google.com:19302" },
|
||||
{ urls: "stun:stun1.l.google.com:19302" }
|
||||
];
|
||||
|
||||
Glow.getActiveDmPeer = () => {
|
||||
if (!Glow.state.activeDm || !Glow.state.currentUser) return null;
|
||||
|
||||
return (Glow.state.activeDm.members || []).find(
|
||||
member => Number(member.id) !== Number(Glow.state.currentUser.id)
|
||||
) || null;
|
||||
};
|
||||
|
||||
Glow.renderCallUi = () => {
|
||||
const call = Glow.state.calls.activeCall;
|
||||
const hasCall = Boolean(call);
|
||||
const incoming = Glow.state.calls.incomingCall;
|
||||
const peer = Glow.getActiveDmPeer();
|
||||
|
||||
if (!Glow.els.callStatus) return;
|
||||
|
||||
Glow.els.callPeerLabel.textContent =
|
||||
peer?.display_name || peer?.username || "No active call";
|
||||
|
||||
Glow.els.callStatus.textContent = incoming
|
||||
? "Incoming call"
|
||||
: hasCall
|
||||
? call.active
|
||||
? "In call"
|
||||
: "Ended"
|
||||
: "Idle";
|
||||
|
||||
Glow.els.startCallButton?.classList.toggle(
|
||||
"hidden",
|
||||
!Glow.state.activeDm || hasCall || Boolean(incoming)
|
||||
);
|
||||
|
||||
Glow.els.answerCallButton?.classList.toggle("hidden", !incoming);
|
||||
|
||||
Glow.els.muteCallButton?.classList.toggle("hidden", !hasCall);
|
||||
Glow.els.hangupCallButton?.classList.toggle("hidden", !hasCall && !incoming);
|
||||
|
||||
Glow.els.muteCallButton.textContent =
|
||||
Glow.state.calls.muted ? "Unmute" : "Mute";
|
||||
};
|
||||
|
||||
Glow.openCallPanel = () => {
|
||||
Glow.els.callOverlay?.classList.remove("hidden");
|
||||
Glow.renderCallUi();
|
||||
};
|
||||
|
||||
Glow.endLocalCallUi = async () => {
|
||||
try {
|
||||
if (Glow.state.calls.peerConnection) {
|
||||
Glow.state.calls.peerConnection.onicecandidate = null;
|
||||
Glow.state.calls.peerConnection.ontrack = null;
|
||||
Glow.state.calls.peerConnection.close();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
if (Glow.state.calls.localStream) {
|
||||
for (const track of Glow.state.calls.localStream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
Glow.state.calls.peerConnection = null;
|
||||
Glow.state.calls.localStream = null;
|
||||
Glow.state.calls.activeCall = null;
|
||||
Glow.state.calls.incomingCall = null;
|
||||
Glow.state.calls.pendingOffer = null;
|
||||
Glow.state.calls.signalLastSeenId = 0;
|
||||
Glow.state.calls.muted = false;
|
||||
|
||||
clearTimeout(Glow.state.calls.pollTimer);
|
||||
|
||||
if (Glow.els.remoteAudio) {
|
||||
Glow.els.remoteAudio.srcObject = null;
|
||||
}
|
||||
|
||||
Glow.renderCallUi();
|
||||
};
|
||||
|
||||
Glow.ensurePeerConnection = async () => {
|
||||
if (Glow.state.calls.peerConnection) {
|
||||
return Glow.state.calls.peerConnection;
|
||||
}
|
||||
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: Glow.calls.iceServers
|
||||
});
|
||||
|
||||
pc.onicecandidate = async event => {
|
||||
if (!event.candidate || !Glow.state.calls.activeCall) return;
|
||||
|
||||
try {
|
||||
await Glow.apiPostCallSignal(Glow.state.calls.activeCall.id, {
|
||||
signal_type: "ice",
|
||||
payload: { candidate: event.candidate }
|
||||
});
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
pc.ontrack = event => {
|
||||
const stream = event.streams?.[0];
|
||||
if (stream && Glow.els.remoteAudio) {
|
||||
Glow.els.remoteAudio.srcObject = stream;
|
||||
}
|
||||
};
|
||||
|
||||
Glow.state.calls.peerConnection = pc;
|
||||
return pc;
|
||||
};
|
||||
|
||||
Glow.ensureLocalAudio = async () => {
|
||||
if (Glow.state.calls.localStream) {
|
||||
return Glow.state.calls.localStream;
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
video: false
|
||||
});
|
||||
|
||||
Glow.state.calls.localStream = stream;
|
||||
return stream;
|
||||
};
|
||||
|
||||
Glow.attachLocalTracks = async () => {
|
||||
const pc = await Glow.ensurePeerConnection();
|
||||
const stream = await Glow.ensureLocalAudio();
|
||||
|
||||
const existingSenders = pc.getSenders().map(sender => sender.track).filter(Boolean);
|
||||
|
||||
for (const track of stream.getTracks()) {
|
||||
if (!existingSenders.includes(track)) {
|
||||
pc.addTrack(track, stream);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Glow.startCall = async () => {
|
||||
if (!Glow.state.activeDm) {
|
||||
Glow.showToast("Open a DM to start a call.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia || !window.RTCPeerConnection) {
|
||||
Glow.showToast("Voice calls are not supported in this browser.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await Glow.apiCreateCall(Glow.state.activeDm.id);
|
||||
Glow.state.calls.activeCall = data.call;
|
||||
Glow.state.calls.incomingCall = null;
|
||||
Glow.state.calls.pendingOffer = null;
|
||||
Glow.state.calls.signalLastSeenId = 0;
|
||||
|
||||
Glow.openCallPanel();
|
||||
await Glow.attachLocalTracks();
|
||||
|
||||
const pc = await Glow.ensurePeerConnection();
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
await Glow.apiPostCallSignal(data.call.id, {
|
||||
signal_type: "offer",
|
||||
payload: { sdp: offer }
|
||||
});
|
||||
|
||||
await Glow.apiPostCallSignal(data.call.id, {
|
||||
signal_type: "ring",
|
||||
payload: {}
|
||||
});
|
||||
|
||||
Glow.renderCallUi();
|
||||
Glow.scheduleCallPolling();
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not start call.", "error");
|
||||
await Glow.endLocalCallUi();
|
||||
}
|
||||
};
|
||||
|
||||
Glow.answerIncomingCall = async () => {
|
||||
const incoming = Glow.state.calls.incomingCall;
|
||||
const pendingOffer = Glow.state.calls.pendingOffer;
|
||||
|
||||
if (!incoming || !pendingOffer) return;
|
||||
|
||||
try {
|
||||
Glow.state.calls.activeCall = incoming;
|
||||
Glow.state.calls.incomingCall = null;
|
||||
Glow.openCallPanel();
|
||||
|
||||
await Glow.attachLocalTracks();
|
||||
|
||||
const pc = await Glow.ensurePeerConnection();
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(pendingOffer));
|
||||
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
|
||||
await Glow.apiPostCallSignal(incoming.id, {
|
||||
signal_type: "answer",
|
||||
payload: { sdp: answer }
|
||||
});
|
||||
|
||||
Glow.state.calls.pendingOffer = null;
|
||||
Glow.renderCallUi();
|
||||
Glow.scheduleCallPolling();
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not answer call.", "error");
|
||||
await Glow.endLocalCallUi();
|
||||
}
|
||||
};
|
||||
|
||||
Glow.handleCallSignal = async signal => {
|
||||
const activeCall = Glow.state.calls.activeCall;
|
||||
const incomingCall = Glow.state.calls.incomingCall;
|
||||
|
||||
if (signal.signal_type === "ring") {
|
||||
if (!activeCall && !incomingCall) {
|
||||
try {
|
||||
const data = await Glow.apiGetCall(signal.call_session_id);
|
||||
Glow.state.calls.incomingCall = data.call;
|
||||
Glow.openCallPanel();
|
||||
Glow.renderCallUi();
|
||||
} catch (_) {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal.signal_type === "offer") {
|
||||
try {
|
||||
const data = await Glow.apiGetCall(signal.call_session_id);
|
||||
Glow.state.calls.incomingCall = data.call;
|
||||
Glow.state.calls.pendingOffer = signal.payload?.sdp || signal.payload;
|
||||
Glow.openCallPanel();
|
||||
Glow.renderCallUi();
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Glow.state.calls.activeCall || Number(Glow.state.calls.activeCall.id) !== Number(signal.call_session_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pc = await Glow.ensurePeerConnection();
|
||||
|
||||
if (signal.signal_type === "answer") {
|
||||
const sdp = signal.payload?.sdp || signal.payload;
|
||||
if (sdp) {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(sdp));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal.signal_type === "ice") {
|
||||
const candidate = signal.payload?.candidate || signal.payload;
|
||||
if (candidate) {
|
||||
try {
|
||||
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
} catch (_) {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal.signal_type === "hangup") {
|
||||
Glow.showToast("Call ended.");
|
||||
await Glow.endLocalCallUi();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
Glow.pollCallSignals = async () => {
|
||||
const activeCall = Glow.state.calls.activeCall;
|
||||
const incomingCall = Glow.state.calls.incomingCall;
|
||||
const call = activeCall || incomingCall;
|
||||
|
||||
if (!call || document.hidden) return;
|
||||
|
||||
try {
|
||||
const data = await Glow.apiGetCallSignals(
|
||||
call.id,
|
||||
Glow.state.calls.signalLastSeenId || 0
|
||||
);
|
||||
|
||||
const signals = data.signals || [];
|
||||
|
||||
for (const signal of signals) {
|
||||
Glow.state.calls.signalLastSeenId = Math.max(
|
||||
Glow.state.calls.signalLastSeenId || 0,
|
||||
Number(signal.id) || 0
|
||||
);
|
||||
|
||||
if (
|
||||
Glow.state.currentUser &&
|
||||
Number(signal.user_id) === Number(Glow.state.currentUser.id)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await Glow.handleCallSignal(signal);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
Glow.scheduleCallPolling = () => {
|
||||
clearTimeout(Glow.state.calls.pollTimer);
|
||||
|
||||
if (!Glow.state.calls.activeCall && !Glow.state.calls.incomingCall) {
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.state.calls.pollTimer = setTimeout(async () => {
|
||||
await Glow.pollCallSignals();
|
||||
Glow.scheduleCallPolling();
|
||||
}, document.hidden ? 5000 : 2000);
|
||||
};
|
||||
|
||||
Glow.toggleMuteCall = () => {
|
||||
const stream = Glow.state.calls.localStream;
|
||||
if (!stream) return;
|
||||
|
||||
Glow.state.calls.muted = !Glow.state.calls.muted;
|
||||
|
||||
for (const track of stream.getAudioTracks()) {
|
||||
track.enabled = !Glow.state.calls.muted;
|
||||
}
|
||||
|
||||
Glow.renderCallUi();
|
||||
};
|
||||
|
||||
Glow.hangupCall = async () => {
|
||||
const call = Glow.state.calls.activeCall || Glow.state.calls.incomingCall;
|
||||
if (!call) return;
|
||||
|
||||
try {
|
||||
await Glow.apiEndCall(call.id);
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
Glow.showToast("Call ended.");
|
||||
await Glow.endLocalCallUi();
|
||||
};
|
||||
|
||||
Glow.syncActiveCallForDm = async () => {
|
||||
if (!Glow.state.activeDm) return;
|
||||
|
||||
try {
|
||||
const createResult = await Glow.apiCreateCall(Glow.state.activeDm.id);
|
||||
const call = createResult.call;
|
||||
|
||||
if (call?.active) {
|
||||
Glow.state.calls.activeCall = call;
|
||||
Glow.renderCallUi();
|
||||
Glow.scheduleCallPolling();
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore if no active call semantics vary
|
||||
}
|
||||
};
|
||||
|
||||
Glow.els.startCallButton?.addEventListener("click", () => {
|
||||
Glow.startCall?.();
|
||||
});
|
||||
|
||||
Glow.els.answerCallButton?.addEventListener("click", () => {
|
||||
Glow.answerIncomingCall?.();
|
||||
});
|
||||
|
||||
Glow.els.muteCallButton?.addEventListener("click", () => {
|
||||
Glow.toggleMuteCall?.();
|
||||
});
|
||||
|
||||
Glow.els.hangupCallButton?.addEventListener("click", () => {
|
||||
Glow.hangupCall?.();
|
||||
});
|
||||
2582
static/style.css
Normal file
2582
static/style.css
Normal file
File diff suppressed because it is too large
Load Diff
539
templates/index.html
Normal file
539
templates/index.html
Normal file
@ -0,0 +1,539 @@
|
||||
<!-- templates/index.html -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,viewport-fit=cover"
|
||||
>
|
||||
<meta name="theme-color" content="#0f1012">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="referrer" content="same-origin">
|
||||
<title>pre.v1</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('static', filename='style.css', v=asset_version) }}"
|
||||
>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="chat-header">
|
||||
<div class="header-inner">
|
||||
<div class="header-brand">
|
||||
<span
|
||||
id="presenceDot"
|
||||
class="header-dot"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
|
||||
<div class="header-room-copy">
|
||||
<div id="headerTitle" class="header-title">Feed</div>
|
||||
<div id="headerSubtitle" class="header-subtitle">Recent activity</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-navigation">
|
||||
<button
|
||||
id="feedButton"
|
||||
class="header-action feed-button active"
|
||||
type="button"
|
||||
aria-label="Open activity feed"
|
||||
aria-pressed="true"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="pagesButton"
|
||||
class="header-action pages-button"
|
||||
type="button"
|
||||
aria-label="Open team pages"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
||||
<g fill="none">
|
||||
<path d="M3.5 2.5h17v19h-17z"/>
|
||||
<path stroke="currentColor" stroke-linecap="square" stroke-width="2" d="M3.5 2.5h17v19h-17zM8 8h8m-8 4h8m-8 4h5"/>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="dmsButton"
|
||||
class="header-action"
|
||||
type="button"
|
||||
aria-label="Open direct messages"
|
||||
aria-pressed="false"
|
||||
title="Direct messages"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="backButton"
|
||||
class="header-action hidden"
|
||||
type="button"
|
||||
aria-label="Go back"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
|
||||
<select
|
||||
id="teamSelect"
|
||||
class="room-select team-select"
|
||||
aria-label="Select team"
|
||||
disabled
|
||||
>
|
||||
<option value="">Loading teams…</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
id="roomSelect"
|
||||
class="room-select channel-select"
|
||||
aria-label="Select channel"
|
||||
disabled
|
||||
>
|
||||
<option value="">Loading channels…</option>
|
||||
</select>
|
||||
|
||||
<div id="presenceLabel" class="presence-label hidden" aria-live="polite"></div>
|
||||
|
||||
<button
|
||||
id="settingsButton"
|
||||
class="header-action"
|
||||
type="button"
|
||||
aria-label="Open settings and profile menu"
|
||||
title="Settings and profile"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3l2.3.8l2.4-.5l1.3 2.1l2.2 1l-.3 2.4l1.1 2.1l-1.6 1.8l-.3 2.4l-2.4.5L14 21l-2-.8l-2 .8l-1.4-2.1l-2.4-.5l-.3-2.4L4.3 14l1.1-2.1l-.3-2.4l2.2-1l1.3-2.1l2.4.5zM12 15.5A3.5 3.5 0 1 0 12 8.5a3.5 3.5 0 0 0 0 7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="header-account-wrap">
|
||||
<div id="headerAccount" class="header-account">not signed in</div>
|
||||
|
||||
<button
|
||||
id="logoutButton"
|
||||
class="header-action hidden"
|
||||
type="button"
|
||||
aria-label="Sign out"
|
||||
title="Sign out"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="square" stroke-width="2" d="M15.5 16.5L20 12l-4.5-4.5m3.25 4.5H9m0 8.5H4v-17h5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main
|
||||
id="feedBox"
|
||||
class="feed-box"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
aria-label="Activity feed"
|
||||
>
|
||||
<div class="empty-state">
|
||||
<div class="empty-card">Loading activity…</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<main
|
||||
id="dmBox"
|
||||
class="dm-box hidden"
|
||||
aria-live="polite"
|
||||
aria-busy="false"
|
||||
aria-label="Direct messages"
|
||||
>
|
||||
<div class="empty-state">
|
||||
<div class="empty-card">Sign in to view direct messages.</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<main
|
||||
id="chatBox"
|
||||
class="chat-box hidden"
|
||||
aria-live="off"
|
||||
aria-busy="true"
|
||||
aria-label="Channel messages"
|
||||
></main>
|
||||
|
||||
<main
|
||||
id="threadBox"
|
||||
class="thread-box hidden"
|
||||
aria-live="off"
|
||||
aria-busy="false"
|
||||
aria-label="Message thread"
|
||||
></main>
|
||||
|
||||
<main
|
||||
id="pagesBox"
|
||||
class="pages-box hidden"
|
||||
aria-live="polite"
|
||||
aria-busy="false"
|
||||
aria-label="Team pages"
|
||||
></main>
|
||||
|
||||
<main
|
||||
id="pageDetailBox"
|
||||
class="page-detail-box hidden"
|
||||
aria-live="polite"
|
||||
aria-busy="false"
|
||||
aria-label="Page details"
|
||||
></main>
|
||||
|
||||
<main
|
||||
id="profileBox"
|
||||
class="profile-box hidden"
|
||||
aria-live="polite"
|
||||
aria-busy="false"
|
||||
aria-label="Profile"
|
||||
></main>
|
||||
|
||||
<div id="newMessagesBar" class="new-messages-bar hidden">
|
||||
<button id="jumpToLatestButton" class="small-button" type="button">
|
||||
New messages · Jump to latest
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="typingBar" class="typing-bar hidden" aria-live="polite"></div>
|
||||
|
||||
<div id="composerWrap" class="composer-wrap hidden">
|
||||
<form id="chatForm" class="composer">
|
||||
<label class="sr-only" for="messageInput">Message</label>
|
||||
|
||||
<div class="composer-field-wrap">
|
||||
<textarea
|
||||
id="messageInput"
|
||||
class="composer-input"
|
||||
rows="1"
|
||||
maxlength="1000"
|
||||
autocomplete="off"
|
||||
placeholder="sign in to send"
|
||||
aria-label="Message"
|
||||
disabled
|
||||
></textarea>
|
||||
|
||||
<div class="composer-meta">
|
||||
<span id="attachmentName" class="attachment-name"></span>
|
||||
<span id="messageCount" class="message-count">0 / 1000</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
id="attachmentButton"
|
||||
class="attachment-button disabled"
|
||||
for="attachmentInput"
|
||||
title="Attach file(s)"
|
||||
aria-label="Attach file(s)"
|
||||
>
|
||||
+
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="attachmentInput"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept=".txt,.pdf,.png,.jpg,.jpeg,.gif,.webp"
|
||||
multiple
|
||||
disabled
|
||||
>
|
||||
|
||||
<button id="sendButton" class="send-button" type="submit" disabled>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="threadComposerWrap" class="composer-wrap hidden">
|
||||
<form id="threadForm" class="composer">
|
||||
<label class="sr-only" for="threadInput">Reply in thread</label>
|
||||
|
||||
<div class="composer-field-wrap">
|
||||
<textarea
|
||||
id="threadInput"
|
||||
class="composer-input"
|
||||
rows="1"
|
||||
maxlength="1000"
|
||||
autocomplete="off"
|
||||
placeholder="reply in thread"
|
||||
aria-label="Reply in thread"
|
||||
disabled
|
||||
></textarea>
|
||||
|
||||
<div class="composer-meta">
|
||||
<span id="threadAttachmentName" class="attachment-name"></span>
|
||||
<span id="threadMessageCount" class="message-count">0 / 1000</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
id="threadAttachmentButton"
|
||||
class="attachment-button disabled"
|
||||
for="threadAttachmentInput"
|
||||
title="Attach file(s)"
|
||||
aria-label="Attach file(s)"
|
||||
>
|
||||
+
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="threadAttachmentInput"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept=".txt,.pdf,.png,.jpg,.jpeg,.gif,.webp"
|
||||
multiple
|
||||
disabled
|
||||
>
|
||||
|
||||
<button id="threadSendButton" class="send-button" type="submit" disabled>
|
||||
Reply
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section
|
||||
id="authOverlay"
|
||||
class="auth-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Account access"
|
||||
>
|
||||
<div class="auth-card">
|
||||
<p></p>
|
||||
|
||||
<div id="authNotice" class="notice" role="status"></div>
|
||||
|
||||
<label class="sr-only" for="usernameInput">Username</label>
|
||||
<input
|
||||
id="usernameInput"
|
||||
class="field"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
maxlength="30"
|
||||
placeholder="username"
|
||||
>
|
||||
|
||||
<label class="sr-only" for="passwordInput">Password</label>
|
||||
<input
|
||||
id="passwordInput"
|
||||
class="field"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
maxlength="256"
|
||||
placeholder="password"
|
||||
>
|
||||
|
||||
<div class="auth-actions">
|
||||
<button id="loginButton" class="button" type="button">sign in</button>
|
||||
<button id="registerButton" class="button secondary" type="button">create account</button>
|
||||
</div>
|
||||
|
||||
<p class="auth-help"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="blockedOverlay"
|
||||
class="blocked-overlay hidden"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-label="Access blocked"
|
||||
>
|
||||
<div class="auth-card">
|
||||
<h1>Access blocked</h1>
|
||||
<p id="blockedMessage">Access to this chat service is unavailable.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="headerMenuOverlay"
|
||||
class="header-menu-overlay hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Header menu"
|
||||
>
|
||||
<div class="header-menu-card">
|
||||
<div class="header-menu-title">Quick actions</div>
|
||||
<div id="headerMenuList" class="header-menu-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="reactionPopupOverlay"
|
||||
class="reaction-popup-overlay hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Reactions"
|
||||
>
|
||||
<div class="reaction-popup-card">
|
||||
<h2 class="reaction-popup-title">Reactions</h2>
|
||||
<div id="reactionPopupGrid" class="reaction-popup-grid"></div>
|
||||
|
||||
<div class="reaction-popup-custom">
|
||||
<input
|
||||
id="reactionCustomInput"
|
||||
class="field reaction-custom-input"
|
||||
type="text"
|
||||
maxlength="16"
|
||||
placeholder="Custom emoji"
|
||||
aria-label="Custom reaction"
|
||||
>
|
||||
<button
|
||||
id="reactionCustomButton"
|
||||
class="button secondary"
|
||||
type="button"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="reactionPopupList" class="reaction-popup-list"></div>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button id="closeReactionPopupButton" class="button secondary" type="button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="mediaOverlay"
|
||||
class="media-overlay hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Media preview"
|
||||
>
|
||||
<div class="media-card">
|
||||
<div class="media-header">
|
||||
<div id="mediaTitle" class="media-title">Media</div>
|
||||
<div class="settings-actions">
|
||||
<a id="mediaDownloadLink" class="button secondary" href="#" download>Download</a>
|
||||
<button id="closeMediaButton" class="button secondary" type="button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-body">
|
||||
<img id="mediaImage" class="hidden" alt="">
|
||||
<audio id="mediaAudio" class="hidden" controls preload="metadata"></audio>
|
||||
<video id="mediaVideo" class="hidden" controls preload="metadata"></video>
|
||||
<div id="mediaFileFallback" class="media-file-fallback hidden">
|
||||
<p>This file cannot be previewed here.</p>
|
||||
<a id="mediaFallbackLink" class="button" href="#" target="_blank" rel="noopener noreferrer">Open file</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="callOverlay"
|
||||
class="call-overlay hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Voice call"
|
||||
>
|
||||
<div class="call-card">
|
||||
<div class="call-title-row">
|
||||
<div>
|
||||
<div class="call-title">Voice call</div>
|
||||
<div id="callStatus" class="call-status">Idle</div>
|
||||
</div>
|
||||
<button id="closeCallPanelButton" class="button secondary" type="button">Hide</button>
|
||||
</div>
|
||||
|
||||
<div id="callPeerLabel" class="call-peer-label">No active call</div>
|
||||
|
||||
<audio id="remoteAudio" autoplay playsinline></audio>
|
||||
|
||||
<div class="call-actions">
|
||||
<button id="startCallButton" class="button" type="button">Start call</button>
|
||||
<button id="answerCallButton" class="button hidden" type="button">Answer</button>
|
||||
<button id="muteCallButton" class="button secondary hidden" type="button">Mute</button>
|
||||
<button id="hangupCallButton" class="danger-button hidden" type="button">Hang up</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button
|
||||
id="adminFab"
|
||||
class="admin-fab"
|
||||
type="button"
|
||||
aria-label="Open moderation controls"
|
||||
title="Moderation controls"
|
||||
>
|
||||
#
|
||||
</button>
|
||||
|
||||
<section
|
||||
id="adminPanel"
|
||||
class="admin-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Moderation panel"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="admin-sheet">
|
||||
<div class="admin-sheet-header">
|
||||
<div>
|
||||
<strong>Moderation</strong>
|
||||
<div class="header-subtitle"></div>
|
||||
</div>
|
||||
|
||||
<button id="closeAdminButton" class="button secondary" type="button">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-actions">
|
||||
<button id="createRoomButton" class="button" type="button">Add chat</button>
|
||||
<button id="clearMessagesButton" class="danger-button" type="button">Clear selected room</button>
|
||||
<button id="typingToggleButton" class="button secondary" type="button">Typing indicator: on</button>
|
||||
<button id="notificationsToggleButton" class="button secondary" type="button">Notifications: off</button>
|
||||
<button id="themeButton" class="button secondary" type="button" aria-label="Choose accent color">Accent color</button>
|
||||
</div>
|
||||
|
||||
<div id="adminRooms" class="admin-rooms"></div>
|
||||
<div id="adminUsers" class="admin-users"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="dialogOverlay"
|
||||
class="dialog-overlay hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="dialogTitle"
|
||||
>
|
||||
<div class="dialog-card">
|
||||
<h2 id="dialogTitle">Confirm</h2>
|
||||
<div id="dialogBody" class="dialog-body"></div>
|
||||
<div id="dialogActions" class="dialog-actions"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="statusToast" class="status-toast" role="status" aria-live="polite"></div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/00-config.js', v=asset_version) }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/01-api.js', v=asset_version) }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/02-chat.js', v=asset_version) }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/03-admin.js', v=asset_version) }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/05-feed.js', v=asset_version) }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/06-pages.js', v=asset_version) }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/08-settings.js', v=asset_version) }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/09-profile.js', v=asset_version) }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/10-reactions.js', v=asset_version) }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/11-media.js', v=asset_version) }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/12-calls.js', v=asset_version) }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/04-main.js', v=asset_version) }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/07-team-channel-adapter.js', v=asset_version) }}" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user