diff --git a/assets/public/widget.html b/assets/public/widget.html
new file mode 100644
index 00000000..a03f4b36
--- /dev/null
+++ b/assets/public/widget.html
@@ -0,0 +1,152 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Spacebar Widget</title>
+
+ <link rel="preconnect" href="https://fonts.googleapis.com">
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+ <link href="https://fonts.googleapis.com/css2?family=Montserrat&display=swap" rel="stylesheet">
+
+ <style>
+ body {
+ font-family: 'Montserrat', sans-serif;
+ background-color: rgb(10, 10, 10);
+ color: white;
+ font-size: 1.1rem;
+ height: 100vh;
+ }
+
+ * {
+ padding: 0;
+ margin: 0;
+ }
+
+ p {
+ margin-top: 10px;
+ }
+
+ .container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ }
+
+ #wordmark {
+ width: min(250px, 50%);
+ }
+
+ .header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 20px;
+ border-bottom: 1px solid #222;
+ }
+
+ .content {
+ padding: 8px;
+ }
+
+ #online-count {
+ font-size: 1.2rem;
+ font-weight: 999;
+ }
+
+ a, a:visited {
+ color: #0185ff;
+ }
+ </style>
+
+ <script>
+ // id param in query
+ const guildId = new URLSearchParams(window.location.search).get("id");
+
+ document.addEventListener("DOMContentLoaded", function() {
+ const vcList = document.getElementById("vc-list");
+ const memberList = document.getElementById("member-list");
+ const onlineCount = document.getElementById("online-count");
+
+ function fetchData() {
+ fetch(`/api/guilds/${guildId}/widget.json`)
+ .then(response => response.json())
+ .then(data => {
+ // Update online count
+ onlineCount.textContent = data.presence_count;
+
+ // Update voice channels
+ vcList.innerHTML = "";
+ data.channels.forEach(channel => {
+ const channelDiv = document.createElement("div");
+ const channelName = document.createElement("h3");
+ channelName.textContent = "🔊 " + channel.name;
+ channelDiv.appendChild(channelName);
+
+ const userList = document.createElement("ul");
+ channel.users?.forEach(user => {
+ const userItem = document.createElement("li");
+ userItem.textContent = user.username;
+ userList.appendChild(userItem);
+ });
+ channelDiv.appendChild(userList);
+ vcList.appendChild(channelDiv);
+ });
+
+ // Update online members
+ memberList.innerHTML = "";
+ data.members.forEach(member => {
+ const memberItem = document.createElement("div");
+ const img = document.createElement("img");
+ img.src = member.avatar_url;
+ img.alt = member.username;
+ img.width = 32;
+ img.height = 32;
+ img.style.borderRadius = "50%";
+ img.style.marginRight = "8px";
+ memberItem.appendChild(img);
+ const nameSpan = document.createElement("span");
+ nameSpan.textContent = member.username;
+ memberItem.appendChild(nameSpan);
+ memberItem.style.display = "flex";
+ memberItem.style.alignItems = "center";
+ memberItem.style.marginBottom = "6px";
+ memberList.appendChild(memberItem);
+ });
+ })
+ .catch(error => {
+ console.error("Error fetching widget data:", error);
+ });
+ }
+
+ // Initial fetch
+ fetchData();
+
+ // Refresh data every 30 seconds
+ setInterval(fetchData, 30000);
+ });
+ </script>
+</head>
+
+<body>
+<div class="container">
+ <div class="header">
+ <img alt="Spacebar Logo"
+ id="wordmark"
+ src="https://raw.githubusercontent.com/spacebarchat/spacebarchat/master/branding/svg/Spacebar__Logo-Blue.svg" />
+ <span>
+ <b id="online-count">Unknown</b>
+ users online
+ </span>
+ </div>
+ <div class="content">
+ <div id="vc-list"></div>
+ <p>Online users</p>
+ <div id="member-list"></div>
+ </div>
+</div>
+</body>
+
+</html>
\ No newline at end of file
diff --git a/src/api/Server.ts b/src/api/Server.ts
index f81898e8..9cc1820f 100644
--- a/src/api/Server.ts
+++ b/src/api/Server.ts
@@ -115,8 +115,8 @@ export class SpacebarServer extends Server {
app.use("/imageproxy/:hash/:size/:url", ImageProxy);
app.get("/", (req, res) => res.sendFile(path.join(PUBLIC_ASSETS_FOLDER, "index.html")));
-
app.get("/verify-email", (req, res) => res.sendFile(path.join(PUBLIC_ASSETS_FOLDER, "verify.html")));
+ app.get("/widget", (req, res) => res.sendFile(path.join(PUBLIC_ASSETS_FOLDER, "widget.html")));
app.get("/_spacebar/api/schemas.json", (req, res) => {
res.sendFile(path.join(ASSETS_FOLDER, "schemas.json"));
diff --git a/src/api/routes/guilds/#guild_id/widget.json.ts b/src/api/routes/guilds/#guild_id/widget.json.ts
index 8f41417a..dea8f29f 100644
--- a/src/api/routes/guilds/#guild_id/widget.json.ts
+++ b/src/api/routes/guilds/#guild_id/widget.json.ts
@@ -17,7 +17,7 @@
*/
import { randomString, route } from "@spacebar/api";
-import { Channel, DiscordApiErrors, Guild, Invite, Member, Permissions } from "@spacebar/util";
+import { Channel, Config, DiscordApiErrors, Guild, Invite, Member, Permissions } from "@spacebar/util";
import { Request, Response, Router } from "express";
const router: Router = Router({ mergeParams: true });
@@ -99,7 +99,17 @@ router.get(
// Fetch members
// TODO: Understand how Discord's max 100 random member sample works, and apply to here (see top of this file)
- const members = await Member.find({ where: { guild_id: guild_id } });
+ const members = await Member.find({ where: { guild_id: guild_id }, relations: { user: { sessions: true } } });
+ const memberData = members.map((x) => {
+ return {
+ id: x.id,
+ username: x.user.username,
+ discriminator: x.user.discriminator,
+ avatar: x.user.avatar,
+ status: "online", // TODO
+ avatar_url: x.user.avatar ? `${Config.get().cdn.endpointPublic}/avatars/${x.id}/${x.user.avatar}.png` : undefined,
+ };
+ });
// Construct object to respond with
const data = {
@@ -107,8 +117,8 @@ router.get(
name: guild.name,
instant_invite: invite?.code,
channels: channels,
- members: members,
- presence_count: guild.presence_count,
+ members: memberData,
+ presence_count: guild.presence_count || members.filter((m) => m.user.sessions.filter((s) => (s.last_seen?.getTime() ?? 0) > Date.now() - 1000 * 60)).length,
};
res.set("Cache-Control", "public, max-age=300");
|