init
This commit is contained in:
commit
c7a592933e
32 changed files with 7871 additions and 0 deletions
862
static/app.js
Normal file
862
static/app.js
Normal file
|
|
@ -0,0 +1,862 @@
|
|||
const state = {
|
||||
me: null,
|
||||
guilds: [],
|
||||
channels: [],
|
||||
dmConversations: [],
|
||||
members: [],
|
||||
voicePresence: new Map(),
|
||||
selectedGuildId: null,
|
||||
selectedTextChannelId: null,
|
||||
selectedDmUserId: null,
|
||||
selectedDmDisplayName: null,
|
||||
selectedVoiceChannelId: null,
|
||||
voice: {
|
||||
ws: null,
|
||||
joinedChannelId: null,
|
||||
localStream: null,
|
||||
rawStream: null,
|
||||
audioContext: null,
|
||||
denoiserNode: null,
|
||||
deepFilterCore: null,
|
||||
peerConnections: new Map(),
|
||||
muted: false,
|
||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||
},
|
||||
voicePresencePollId: null,
|
||||
};
|
||||
|
||||
const el = {
|
||||
authScreen: document.getElementById("auth-screen"),
|
||||
loginBtn: document.getElementById("login-btn"),
|
||||
main: document.getElementById("main"),
|
||||
status: document.getElementById("status"),
|
||||
|
||||
// Guilds
|
||||
guildList: document.getElementById("guild-list"),
|
||||
addGuildBtn: document.getElementById("add-guild-btn"),
|
||||
guildTitle: document.getElementById("guild-title"),
|
||||
createInviteBtn: document.getElementById("create-invite-btn"),
|
||||
|
||||
// Channels
|
||||
channelList: document.getElementById("channel-list"),
|
||||
voiceChannelList: document.getElementById("voice-channel-list"),
|
||||
addTextBtn: document.getElementById("add-text-btn"),
|
||||
addVoiceBtn: document.getElementById("add-voice-btn"),
|
||||
dmList: document.getElementById("dm-list"),
|
||||
channelTitle: document.getElementById("channel-title"),
|
||||
|
||||
// Messages
|
||||
messageList: document.getElementById("message-list"),
|
||||
messageForm: document.getElementById("message-form"),
|
||||
messageBody: document.getElementById("message-body"),
|
||||
|
||||
// User Panel
|
||||
userName: document.getElementById("user-name"),
|
||||
userAvatar: document.getElementById("user-avatar"),
|
||||
logoutBtn: document.getElementById("logout-btn"),
|
||||
|
||||
// Voice Connection
|
||||
voiceConnection: document.getElementById("voice-connection"),
|
||||
vcChannelName: document.getElementById("vc-channel-name"),
|
||||
voiceMuteBtn: document.getElementById("voice-mute-btn"),
|
||||
voiceLeaveBtn: document.getElementById("voice-leave-btn"),
|
||||
|
||||
// Members
|
||||
memberList: document.getElementById("member-list"),
|
||||
|
||||
// Modals
|
||||
modalContainer: document.getElementById("modal-container"),
|
||||
guildForm: document.getElementById("guild-form"),
|
||||
guildName: document.getElementById("guild-name"),
|
||||
modalCancel: document.getElementById("modal-cancel"),
|
||||
|
||||
channelModal: document.getElementById("channel-modal"),
|
||||
channelForm: document.getElementById("channel-form"),
|
||||
channelName: document.getElementById("channel-name"),
|
||||
channelModalCancel: document.getElementById("channel-modal-cancel"),
|
||||
};
|
||||
|
||||
// --- API Helpers ---
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const res = await fetch(path, {
|
||||
...options,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = "request failed";
|
||||
try {
|
||||
const body = await res.json();
|
||||
detail = body.error || detail;
|
||||
} catch {}
|
||||
throw new Error(`${res.status}: ${detail}`);
|
||||
}
|
||||
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// --- Utils ---
|
||||
|
||||
function shortName(name) {
|
||||
if (!name || typeof name !== 'string') return "?";
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return "?";
|
||||
const words = trimmed.split(/\s+/).filter(w => w.length > 0).slice(0, 2);
|
||||
if (words.length === 0) return "?";
|
||||
if (words.length === 1) return words[0].substring(0, 2).toUpperCase();
|
||||
return words.map((w) => w[0]?.toUpperCase() || "").join("");
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (s === null || s === undefined) return "";
|
||||
return String(s)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function formatDate(isoString) {
|
||||
const d = new Date(isoString);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
|
||||
if (isToday) {
|
||||
return `Today at ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||
}
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
const DEEPFILTERNET_LIB_PATH = "/static/vendor/deepfilternet3/index.esm.js";
|
||||
const NSNET2_COMPAT_SUPPRESSION = 56;
|
||||
|
||||
let deepFilterLibPromise = null;
|
||||
const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId";
|
||||
|
||||
// --- Renderers ---
|
||||
|
||||
function renderGuilds() {
|
||||
el.guildList.innerHTML = "";
|
||||
|
||||
for (const guild of state.guilds) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = `guild-pill ${state.selectedGuildId === guild.id ? "active" : ""}`;
|
||||
btn.title = guild.name;
|
||||
btn.textContent = shortName(guild.name);
|
||||
btn.onclick = async () => {
|
||||
state.selectedGuildId = guild.id;
|
||||
state.selectedTextChannelId = null;
|
||||
state.selectedVoiceChannelId = null;
|
||||
state.selectedDmUserId = null;
|
||||
state.selectedDmDisplayName = null;
|
||||
localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id);
|
||||
renderGuilds();
|
||||
renderDMs();
|
||||
await loadChannels();
|
||||
await loadGuildMembers();
|
||||
await refreshVoicePresence();
|
||||
startVoicePresencePolling();
|
||||
renderMessages([]);
|
||||
updateHeaderLabels();
|
||||
};
|
||||
el.guildList.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChannels() {
|
||||
el.channelList.innerHTML = "";
|
||||
el.voiceChannelList.innerHTML = "";
|
||||
|
||||
for (const channel of state.channels) {
|
||||
const row = document.createElement("button");
|
||||
row.className = `channel-row ${
|
||||
(channel.kind === 'text' && state.selectedTextChannelId === channel.id) ||
|
||||
(channel.kind === 'voice' && state.selectedVoiceChannelId === channel.id) ? "active" : ""
|
||||
}`;
|
||||
|
||||
const iconName = channel.kind === 'text' ? 'hash' : 'volume-2';
|
||||
row.innerHTML = `<i data-lucide="${iconName}"></i> <span>${escapeHtml(channel.name)}</span>`;
|
||||
|
||||
row.onclick = async () => {
|
||||
if (channel.kind === 'text') {
|
||||
state.selectedDmUserId = null;
|
||||
state.selectedDmDisplayName = null;
|
||||
state.selectedTextChannelId = channel.id;
|
||||
renderChannels();
|
||||
renderDMs();
|
||||
updateHeaderLabels();
|
||||
const messages = await api(`/channels/${channel.id}/messages?limit=100`);
|
||||
renderMessages(messages);
|
||||
} else {
|
||||
state.selectedDmUserId = null;
|
||||
state.selectedDmDisplayName = null;
|
||||
state.selectedVoiceChannelId = channel.id;
|
||||
renderChannels();
|
||||
renderDMs();
|
||||
joinVoice();
|
||||
}
|
||||
};
|
||||
|
||||
if (channel.kind === 'text') {
|
||||
el.channelList.appendChild(row);
|
||||
} else {
|
||||
el.voiceChannelList.appendChild(row);
|
||||
|
||||
const participants = state.voicePresence.get(channel.id) || [];
|
||||
if (participants.length > 0) {
|
||||
const pList = document.createElement("div");
|
||||
pList.className = "voice-row-members channel-list";
|
||||
pList.style.paddingLeft = "24px";
|
||||
for (const p of participants) {
|
||||
const pRow = document.createElement("div");
|
||||
pRow.className = "channel-row";
|
||||
pRow.style.padding = "2px 8px";
|
||||
pRow.innerHTML = `<div class="avatar" style="width:20px;height:20px;font-size:10px">${shortName(p.display_name)}</div> <span>${escapeHtml(p.display_name)}</span>`;
|
||||
pList.appendChild(pRow);
|
||||
}
|
||||
el.voiceChannelList.appendChild(pList);
|
||||
}
|
||||
}
|
||||
}
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function renderDMs() {
|
||||
el.dmList.innerHTML = "";
|
||||
for (const dm of state.dmConversations) {
|
||||
const row = document.createElement("button");
|
||||
row.className = `channel-row ${state.selectedDmUserId === dm.user_id ? "active" : ""}`;
|
||||
row.innerHTML = `<i data-lucide="message-circle"></i> <span>${escapeHtml(dm.display_name)}</span>`;
|
||||
row.onclick = async () => {
|
||||
state.selectedDmUserId = dm.user_id;
|
||||
state.selectedDmDisplayName = dm.display_name;
|
||||
state.selectedTextChannelId = null;
|
||||
state.selectedVoiceChannelId = null;
|
||||
renderChannels();
|
||||
renderDMs();
|
||||
updateHeaderLabels();
|
||||
const messages = await api(`/dms/${dm.user_id}/messages?limit=100`);
|
||||
renderMessages(messages);
|
||||
};
|
||||
el.dmList.appendChild(row);
|
||||
}
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function renderMessages(messages) {
|
||||
el.messageList.innerHTML = "";
|
||||
let lastAuthorId = null;
|
||||
let lastTime = null;
|
||||
|
||||
for (const m of messages.slice().reverse()) {
|
||||
const row = document.createElement("div");
|
||||
const mDate = new Date(m.created_at);
|
||||
const isGrouped = lastAuthorId === m.author_user_id &&
|
||||
lastTime && (mDate - lastTime < 300000); // 5 minutes
|
||||
|
||||
row.className = `msg ${isGrouped ? "msg-grouped" : ""}`;
|
||||
|
||||
const displayName = m.author_display_name || "Unknown User";
|
||||
|
||||
if (isGrouped) {
|
||||
row.innerHTML = `<div class="msg-content"><div class="msg-body">${escapeHtml(m.body)}</div></div>`;
|
||||
} else {
|
||||
row.innerHTML = `
|
||||
<div class="msg-avatar">${shortName(displayName)}</div>
|
||||
<div class="msg-content">
|
||||
<div class="msg-header">
|
||||
<span class="msg-author">${escapeHtml(displayName)}</span>
|
||||
<span class="msg-time">${formatDate(m.created_at)}</span>
|
||||
</div>
|
||||
<div class="msg-body">${escapeHtml(m.body)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
el.messageList.appendChild(row);
|
||||
lastAuthorId = m.author_user_id;
|
||||
lastTime = mDate;
|
||||
}
|
||||
|
||||
el.messageList.scrollTop = el.messageList.scrollHeight;
|
||||
}
|
||||
|
||||
function renderMembers() {
|
||||
el.memberList.innerHTML = "";
|
||||
for (const m of state.members) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "member-row";
|
||||
row.innerHTML = `
|
||||
<div class="member-avatar">${shortName(m.display_name)}</div>
|
||||
<div class="member-name">${escapeHtml(m.display_name)}</div>
|
||||
`;
|
||||
row.style.cursor = "pointer";
|
||||
row.onclick = async () => {
|
||||
if (state.me && m.id === state.me.id) return;
|
||||
state.selectedDmUserId = m.id;
|
||||
state.selectedDmDisplayName = m.display_name;
|
||||
state.selectedTextChannelId = null;
|
||||
state.selectedVoiceChannelId = null;
|
||||
renderChannels();
|
||||
renderDMs();
|
||||
updateHeaderLabels();
|
||||
const messages = await api(`/dms/${m.id}/messages?limit=100`);
|
||||
renderMessages(messages);
|
||||
};
|
||||
el.memberList.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function updateHeaderLabels() {
|
||||
const guild = state.guilds.find((g) => g.id === state.selectedGuildId);
|
||||
el.guildTitle.textContent = guild ? guild.name : "No server selected";
|
||||
|
||||
if (state.selectedDmUserId) {
|
||||
const dmFromConversations = state.dmConversations.find((u) => u.user_id === state.selectedDmUserId);
|
||||
const dmFromMembers = state.members.find((m) => m.id === state.selectedDmUserId);
|
||||
const displayName = dmFromConversations?.display_name || dmFromMembers?.display_name || state.selectedDmDisplayName;
|
||||
const dmName = displayName ? `@${displayName}` : "Direct Message";
|
||||
el.channelTitle.textContent = dmName;
|
||||
el.messageBody.placeholder = displayName
|
||||
? `Message @${displayName}`
|
||||
: "Message";
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = state.channels.find((c) => c.id === state.selectedTextChannelId);
|
||||
el.channelTitle.textContent = channel ? channel.name : "Select a channel";
|
||||
el.messageBody.placeholder = channel ? `Message #${channel.name}` : "Select a channel";
|
||||
}
|
||||
|
||||
async function createInviteLink() {
|
||||
if (!state.selectedGuildId) {
|
||||
alert("Select a server first.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const invite = await api(`/guilds/${state.selectedGuildId}/invites`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ max_uses: 50, expires_in_hours: 24 }),
|
||||
});
|
||||
const link = `${location.origin}/?invite=${encodeURIComponent(invite.code)}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
alert(`Invite link copied:\n${link}`);
|
||||
} catch {
|
||||
prompt("Copy invite link:", link);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Logic ---
|
||||
|
||||
async function loadGuilds() {
|
||||
state.guilds = await api("/guilds");
|
||||
renderGuilds();
|
||||
}
|
||||
|
||||
async function loadDMConversations() {
|
||||
state.dmConversations = await api("/dms");
|
||||
renderDMs();
|
||||
}
|
||||
|
||||
async function loadGuildMembers() {
|
||||
if (!state.selectedGuildId) {
|
||||
state.members = [];
|
||||
renderMembers();
|
||||
return;
|
||||
}
|
||||
state.members = await api(`/guilds/${state.selectedGuildId}/members`);
|
||||
renderMembers();
|
||||
}
|
||||
|
||||
async function loadChannels() {
|
||||
if (!state.selectedGuildId) {
|
||||
state.channels = [];
|
||||
renderChannels();
|
||||
return;
|
||||
}
|
||||
|
||||
state.channels = await api(`/guilds/${state.selectedGuildId}/channels`);
|
||||
renderChannels();
|
||||
}
|
||||
|
||||
async function refreshVoicePresence() {
|
||||
if (!state.selectedGuildId) {
|
||||
state.voicePresence.clear();
|
||||
renderChannels();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api(`/guilds/${state.selectedGuildId}/voice-presence`);
|
||||
state.voicePresence.clear();
|
||||
for (const entry of res.channels || []) {
|
||||
state.voicePresence.set(entry.channel_id, entry.participants || []);
|
||||
}
|
||||
renderChannels();
|
||||
} catch (err) {
|
||||
console.warn("voice presence failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
function startVoicePresencePolling() {
|
||||
if (state.voicePresencePollId) clearInterval(state.voicePresencePollId);
|
||||
state.voicePresencePollId = setInterval(() => {
|
||||
refreshVoicePresence().catch(() => {});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// --- Voice ---
|
||||
|
||||
function getVoiceWsUrl(channelId) {
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
return `${proto}://${location.host}/channels/${channelId}/voice/ws`;
|
||||
}
|
||||
|
||||
function shouldInitiateOffer(peerId) {
|
||||
if (!state.me || !state.me.id) return false;
|
||||
return state.me.id > peerId;
|
||||
}
|
||||
|
||||
async function loadDeepFilterLib() {
|
||||
if (!deepFilterLibPromise) {
|
||||
deepFilterLibPromise = import(DEEPFILTERNET_LIB_PATH);
|
||||
}
|
||||
return deepFilterLibPromise;
|
||||
}
|
||||
|
||||
async function buildDenoiserNode(audioContext) {
|
||||
if (!audioContext.audioWorklet) {
|
||||
throw new Error("AudioWorklet is not supported in this browser");
|
||||
}
|
||||
|
||||
const df = await loadDeepFilterLib();
|
||||
const core = new df.DeepFilterNet3Core({
|
||||
sampleRate: 48000,
|
||||
noiseReductionLevel: NSNET2_COMPAT_SUPPRESSION,
|
||||
assetConfig: {
|
||||
cdnUrl: "/static/vendor/deepfilternet3",
|
||||
},
|
||||
});
|
||||
await core.initialize();
|
||||
const workletNode = await core.createAudioWorkletNode(audioContext);
|
||||
state.voice.deepFilterCore = core;
|
||||
return workletNode;
|
||||
}
|
||||
|
||||
function stopAndClearAudioPipeline() {
|
||||
if (state.voice.deepFilterCore) {
|
||||
state.voice.deepFilterCore.destroy();
|
||||
state.voice.deepFilterCore = null;
|
||||
}
|
||||
if (state.voice.denoiserNode && typeof state.voice.denoiserNode.destroy === "function") {
|
||||
state.voice.denoiserNode.destroy();
|
||||
}
|
||||
if (state.voice.localStream) {
|
||||
for (const track of state.voice.localStream.getTracks()) track.stop();
|
||||
}
|
||||
if (state.voice.rawStream && state.voice.rawStream !== state.voice.localStream) {
|
||||
for (const track of state.voice.rawStream.getTracks()) track.stop();
|
||||
}
|
||||
if (state.voice.audioContext) {
|
||||
state.voice.audioContext.close().catch(() => {});
|
||||
}
|
||||
state.voice.localStream = null;
|
||||
state.voice.rawStream = null;
|
||||
state.voice.audioContext = null;
|
||||
state.voice.denoiserNode = null;
|
||||
state.voice.deepFilterCore = null;
|
||||
}
|
||||
|
||||
async function buildAudioPipeline(rawStream) {
|
||||
const audioContext = new AudioContext();
|
||||
const source = audioContext.createMediaStreamSource(rawStream);
|
||||
const destination = audioContext.createMediaStreamDestination();
|
||||
state.voice.audioContext = audioContext;
|
||||
|
||||
let head = source;
|
||||
|
||||
const denoiserNode = await buildDenoiserNode(audioContext);
|
||||
if (denoiserNode) {
|
||||
head.connect(denoiserNode);
|
||||
head = denoiserNode;
|
||||
}
|
||||
head.connect(destination);
|
||||
|
||||
state.voice.denoiserNode = denoiserNode;
|
||||
return destination.stream;
|
||||
}
|
||||
|
||||
async function createLocalVoiceStream() {
|
||||
const constraints = {
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
sampleRate: 48000,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
},
|
||||
video: false,
|
||||
};
|
||||
const rawStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
const localStream = await buildAudioPipeline(rawStream);
|
||||
state.voice.rawStream = rawStream;
|
||||
state.voice.localStream = localStream;
|
||||
if (state.voice.muted) {
|
||||
state.voice.localStream.getAudioTracks().forEach((t) => {
|
||||
t.enabled = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePeerConnection(peerId) {
|
||||
if (state.voice.peerConnections.has(peerId)) {
|
||||
return state.voice.peerConnections.get(peerId);
|
||||
}
|
||||
|
||||
const pc = new RTCPeerConnection({ iceServers: state.voice.iceServers });
|
||||
|
||||
if (state.voice.localStream) {
|
||||
for (const track of state.voice.localStream.getTracks()) {
|
||||
pc.addTrack(track, state.voice.localStream);
|
||||
}
|
||||
}
|
||||
|
||||
pc.onicecandidate = (event) => {
|
||||
if (!event.candidate || !state.voice.ws) return;
|
||||
state.voice.ws.send(JSON.stringify({
|
||||
type: "signal",
|
||||
to_user_id: peerId,
|
||||
kind: "ice",
|
||||
data: event.candidate,
|
||||
}));
|
||||
};
|
||||
|
||||
pc.ontrack = (event) => {
|
||||
let audio = document.getElementById(`audio-${peerId}`);
|
||||
if (!audio) {
|
||||
audio = document.createElement("audio");
|
||||
audio.id = `audio-${peerId}`;
|
||||
audio.autoplay = true;
|
||||
audio.playsInline = true;
|
||||
document.body.appendChild(audio);
|
||||
}
|
||||
audio.srcObject = event.streams[0];
|
||||
};
|
||||
|
||||
state.voice.peerConnections.set(peerId, pc);
|
||||
return pc;
|
||||
}
|
||||
|
||||
async function sendOffer(peerId) {
|
||||
const pc = ensurePeerConnection(peerId);
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
state.voice.ws.send(JSON.stringify({
|
||||
type: "signal",
|
||||
to_user_id: peerId,
|
||||
kind: "offer",
|
||||
data: offer,
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSignal(fromPeerId, kind, data) {
|
||||
const pc = ensurePeerConnection(fromPeerId);
|
||||
|
||||
if (kind === "offer") {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(data));
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
state.voice.ws.send(JSON.stringify({
|
||||
type: "signal",
|
||||
to_user_id: fromPeerId,
|
||||
kind: "answer",
|
||||
data: answer,
|
||||
}));
|
||||
} else if (kind === "answer") {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(data));
|
||||
} else if (kind === "ice") {
|
||||
try {
|
||||
await pc.addIceCandidate(data ? new RTCIceCandidate(data) : null);
|
||||
} catch (err) {
|
||||
console.warn("failed to add ice candidate", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function joinVoice() {
|
||||
if (!state.selectedVoiceChannelId) return;
|
||||
if (state.voice.joinedChannelId === state.selectedVoiceChannelId) return;
|
||||
|
||||
await leaveVoice();
|
||||
|
||||
try {
|
||||
await createLocalVoiceStream();
|
||||
} catch (err) {
|
||||
console.error("microphone denied", err);
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(getVoiceWsUrl(state.selectedVoiceChannelId));
|
||||
state.voice.ws = ws;
|
||||
state.voice.joinedChannelId = state.selectedVoiceChannelId;
|
||||
|
||||
ws.onopen = () => {
|
||||
const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId);
|
||||
el.vcChannelName.textContent = channel ? channel.name : "Voice";
|
||||
el.voiceConnection.classList.remove("hidden");
|
||||
refreshVoicePresence().catch(() => {});
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "peers") {
|
||||
for (const peer of msg.peers) {
|
||||
if (shouldInitiateOffer(peer.user_id)) await sendOffer(peer.user_id);
|
||||
}
|
||||
} else if (msg.type === "peer_joined") {
|
||||
if (shouldInitiateOffer(msg.user_id)) await sendOffer(msg.user_id);
|
||||
} else if (msg.type === "peer_left") {
|
||||
const pc = state.voice.peerConnections.get(msg.user_id);
|
||||
if (pc) {
|
||||
pc.close();
|
||||
state.voice.peerConnections.delete(msg.user_id);
|
||||
}
|
||||
document.getElementById(`audio-${msg.user_id}`)?.remove();
|
||||
} else if (msg.type === "signal") {
|
||||
await handleSignal(msg.from_user_id, msg.kind, msg.data);
|
||||
}
|
||||
refreshVoicePresence().catch(() => {});
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
el.voiceConnection.classList.add("hidden");
|
||||
for (const pc of state.voice.peerConnections.values()) pc.close();
|
||||
state.voice.peerConnections.clear();
|
||||
stopAndClearAudioPipeline();
|
||||
state.voice.joinedChannelId = null;
|
||||
state.voice.ws = null;
|
||||
refreshVoicePresence().catch(() => {});
|
||||
};
|
||||
}
|
||||
|
||||
async function leaveVoice() {
|
||||
if (state.voice.ws) state.voice.ws.close();
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (!state.voice.localStream) return;
|
||||
state.voice.muted = !state.voice.muted;
|
||||
state.voice.localStream.getAudioTracks().forEach((t) => {
|
||||
t.enabled = !state.voice.muted;
|
||||
});
|
||||
el.voiceMuteBtn.innerHTML = state.voice.muted ? '<i data-lucide="mic-off"></i>' : '<i data-lucide="mic"></i>';
|
||||
el.voiceMuteBtn.style.color = state.voice.muted ? 'var(--danger)' : 'var(--text-muted)';
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
// --- Initialization ---
|
||||
|
||||
async function init() {
|
||||
lucide.createIcons();
|
||||
|
||||
el.loginBtn.onclick = () => { location.href = "/auth/login"; };
|
||||
|
||||
el.logoutBtn.onclick = async () => {
|
||||
await leaveVoice();
|
||||
await api("/auth/logout", { method: "POST" });
|
||||
location.reload();
|
||||
};
|
||||
|
||||
el.addGuildBtn.onclick = () => { el.modalContainer.classList.remove("hidden"); };
|
||||
el.createInviteBtn.onclick = createInviteLink;
|
||||
el.modalCancel.onclick = () => { el.modalContainer.classList.add("hidden"); };
|
||||
|
||||
el.addTextBtn.onclick = () => {
|
||||
if (!state.selectedGuildId) {
|
||||
alert("Create or select a server first.");
|
||||
return;
|
||||
}
|
||||
document.querySelector('input[name="channel-kind"][value="text"]').checked = true;
|
||||
el.channelModal.classList.remove("hidden");
|
||||
};
|
||||
el.addVoiceBtn.onclick = () => {
|
||||
if (!state.selectedGuildId) {
|
||||
alert("Create or select a server first.");
|
||||
return;
|
||||
}
|
||||
document.querySelector('input[name="channel-kind"][value="voice"]').checked = true;
|
||||
el.channelModal.classList.remove("hidden");
|
||||
};
|
||||
el.channelModalCancel.onclick = () => { el.channelModal.classList.add("hidden"); };
|
||||
|
||||
el.guildForm.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const guild = await api("/guilds", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: el.guildName.value }),
|
||||
});
|
||||
el.guildName.value = "";
|
||||
el.modalContainer.classList.add("hidden");
|
||||
state.guilds.push(guild);
|
||||
state.selectedGuildId = guild.id;
|
||||
state.selectedTextChannelId = null;
|
||||
state.selectedVoiceChannelId = null;
|
||||
state.selectedDmUserId = null;
|
||||
state.selectedDmDisplayName = null;
|
||||
localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id);
|
||||
renderGuilds();
|
||||
await loadChannels();
|
||||
await loadGuildMembers();
|
||||
await refreshVoicePresence();
|
||||
renderMessages([]);
|
||||
updateHeaderLabels();
|
||||
} catch (err) { alert(err.message); }
|
||||
};
|
||||
|
||||
el.channelForm.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!state.selectedGuildId) {
|
||||
alert("Select a server first.");
|
||||
return;
|
||||
}
|
||||
const kindInput = document.querySelector('input[name="channel-kind"]:checked');
|
||||
const kind = kindInput ? kindInput.value : "text";
|
||||
const channelName = el.channelName.value.trim();
|
||||
if (!channelName) {
|
||||
alert("Channel name is required.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const created = await api("/channels", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
guild_id: state.selectedGuildId,
|
||||
name: channelName,
|
||||
kind: kind,
|
||||
}),
|
||||
});
|
||||
el.channelName.value = "";
|
||||
el.channelModal.classList.add("hidden");
|
||||
await loadChannels();
|
||||
if (created.kind === "text") {
|
||||
state.selectedTextChannelId = created.id;
|
||||
state.selectedVoiceChannelId = null;
|
||||
state.selectedDmUserId = null;
|
||||
state.selectedDmDisplayName = null;
|
||||
renderChannels();
|
||||
renderDMs();
|
||||
updateHeaderLabels();
|
||||
const messages = await api(`/channels/${created.id}/messages?limit=100`);
|
||||
renderMessages(messages);
|
||||
} else {
|
||||
state.selectedVoiceChannelId = created.id;
|
||||
state.selectedTextChannelId = null;
|
||||
state.selectedDmUserId = null;
|
||||
state.selectedDmDisplayName = null;
|
||||
renderChannels();
|
||||
renderDMs();
|
||||
updateHeaderLabels();
|
||||
}
|
||||
} catch (err) { alert(err.message); }
|
||||
};
|
||||
|
||||
el.messageForm.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const body = el.messageBody.value.trim();
|
||||
if (!body) return;
|
||||
|
||||
try {
|
||||
if (state.selectedTextChannelId) {
|
||||
await api(`/channels/${state.selectedTextChannelId}/messages`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
} else if (state.selectedDmUserId) {
|
||||
await api(`/dms/${state.selectedDmUserId}/messages`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
el.messageBody.value = "";
|
||||
const messages = state.selectedTextChannelId
|
||||
? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`)
|
||||
: await api(`/dms/${state.selectedDmUserId}/messages?limit=100`);
|
||||
renderMessages(messages);
|
||||
await loadDMConversations();
|
||||
renderDMs();
|
||||
} catch (err) { alert(err.message); }
|
||||
};
|
||||
|
||||
el.voiceMuteBtn.onclick = toggleMute;
|
||||
el.voiceLeaveBtn.onclick = leaveVoice;
|
||||
|
||||
try {
|
||||
state.me = await api("/me");
|
||||
if (state.me && state.me.display_name) {
|
||||
el.userName.textContent = state.me.display_name;
|
||||
el.userAvatar.textContent = shortName(state.me.display_name);
|
||||
}
|
||||
el.authScreen.classList.add("hidden");
|
||||
el.main.classList.remove("hidden");
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const inviteCode = params.get("invite");
|
||||
if (inviteCode) {
|
||||
try {
|
||||
const joinedGuild = await api(`/invites/${encodeURIComponent(inviteCode)}/join`, {
|
||||
method: "POST",
|
||||
});
|
||||
localStorage.setItem(LAST_GUILD_STORAGE_KEY, joinedGuild.id);
|
||||
} catch (err) {
|
||||
alert(`Failed to join invite: ${err.message}`);
|
||||
} finally {
|
||||
params.delete("invite");
|
||||
const nextQuery = params.toString();
|
||||
const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`;
|
||||
history.replaceState(null, "", nextUrl);
|
||||
}
|
||||
}
|
||||
|
||||
await loadGuilds();
|
||||
await loadDMConversations();
|
||||
|
||||
if (state.guilds.length > 0) {
|
||||
const lastGuildId = localStorage.getItem(LAST_GUILD_STORAGE_KEY);
|
||||
const guild = state.guilds.find((g) => g.id === lastGuildId) || state.guilds[0];
|
||||
state.selectedGuildId = guild.id;
|
||||
localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id);
|
||||
renderGuilds();
|
||||
await loadChannels();
|
||||
await loadGuildMembers();
|
||||
await refreshVoicePresence();
|
||||
updateHeaderLabels();
|
||||
} else {
|
||||
state.members = [];
|
||||
renderMembers();
|
||||
updateHeaderLabels();
|
||||
}
|
||||
|
||||
startVoicePresencePolling();
|
||||
lucide.createIcons();
|
||||
} catch (err) {
|
||||
console.error("init failed", err);
|
||||
el.authScreen.classList.remove("hidden");
|
||||
el.main.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
195
static/index.html
Normal file
195
static/index.html
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Chattz</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<!-- Lucide Icons -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-screen" id="auth-screen">
|
||||
<div class="auth-card">
|
||||
<div class="brand-large">C</div>
|
||||
<h1>Chattz</h1>
|
||||
<p>Sign in to open your servers.</p>
|
||||
<button id="login-btn">Login with Authentik</button>
|
||||
<p id="status" class="status-line"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shell hidden" id="main">
|
||||
<aside class="server-rail">
|
||||
<div class="brand" title="Home">
|
||||
<i data-lucide="message-square"></i>
|
||||
</div>
|
||||
<div class="separator"></div>
|
||||
<div id="guild-list" class="guild-list"></div>
|
||||
<button id="add-guild-btn" class="guild-pill action-pill" title="Add a Server">
|
||||
<i data-lucide="plus"></i>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<aside class="channel-sidebar">
|
||||
<header class="sidebar-header clickable" id="guild-header">
|
||||
<h2 id="guild-title">No server selected</h2>
|
||||
<div class="sidebar-header-actions">
|
||||
<button id="create-invite-btn" class="header-action-btn" title="Create Invite Link">
|
||||
<i data-lucide="link-2"></i>
|
||||
</button>
|
||||
<i data-lucide="chevron-down"></i>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="sidebar-scroll">
|
||||
<section class="sidebar-group">
|
||||
<div class="group-title">
|
||||
<i data-lucide="chevron-down" class="group-toggle"></i>
|
||||
<span>Text Channels</span>
|
||||
<button id="add-text-btn" class="add-btn" type="button" title="Create Text Channel">
|
||||
<i data-lucide="plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="channel-list" class="channel-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="sidebar-group">
|
||||
<div class="group-title">
|
||||
<i data-lucide="chevron-down" class="group-toggle"></i>
|
||||
<span>Voice Channels</span>
|
||||
<button id="add-voice-btn" class="add-btn" type="button" title="Create Voice Channel">
|
||||
<i data-lucide="plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="voice-channel-list" class="channel-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="sidebar-group">
|
||||
<div class="group-title">
|
||||
<i data-lucide="chevron-down" class="group-toggle"></i>
|
||||
<span>Direct Messages</span>
|
||||
</div>
|
||||
<div id="dm-list" class="channel-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div id="voice-connection" class="voice-connection hidden">
|
||||
<div class="vc-info">
|
||||
<i data-lucide="signal-high" class="vc-icon"></i>
|
||||
<div class="vc-text">
|
||||
<span class="vc-status">Voice Connected</span>
|
||||
<span id="vc-channel-name" class="vc-name">General</span>
|
||||
</div>
|
||||
<div class="vc-actions">
|
||||
<button id="voice-mute-btn" title="Mute"><i data-lucide="mic"></i></button>
|
||||
<button id="voice-leave-btn" title="Disconnect"><i data-lucide="phone-off"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-panel">
|
||||
<div class="avatar-wrapper">
|
||||
<div id="user-avatar" class="avatar">U</div>
|
||||
<div class="status-dot online"></div>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div id="user-name" class="display-name">Username</div>
|
||||
<div class="user-status">Online</div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<button id="user-settings-btn" title="Settings"><i data-lucide="settings"></i></button>
|
||||
<button id="logout-btn" title="Logout"><i data-lucide="log-out"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="chat-pane">
|
||||
<header class="chat-header">
|
||||
<i data-lucide="hash" class="header-icon"></i>
|
||||
<h3 id="channel-title">Select a channel</h3>
|
||||
</header>
|
||||
|
||||
<div id="message-list" class="message-list"></div>
|
||||
|
||||
<div class="chat-input-wrapper">
|
||||
<form id="message-form" class="message-form">
|
||||
<input id="message-body" autocomplete="off" placeholder="Message #channel" maxlength="4000" required />
|
||||
<button type="submit" class="hidden"></button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="utility-sidebar">
|
||||
<header class="sidebar-header">
|
||||
<h2>Members</h2>
|
||||
</header>
|
||||
<div class="member-list-wrapper">
|
||||
<div id="member-list" class="member-list"></div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<div id="modal-container" class="modal-container hidden">
|
||||
<div class="modal">
|
||||
<h2 id="modal-title">Create Server</h2>
|
||||
<form id="guild-form" class="modal-form">
|
||||
<div class="form-item">
|
||||
<label for="guild-name">SERVER NAME</label>
|
||||
<input id="guild-name" placeholder="My Awesome Server" maxlength="64" required />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="cancel-btn" id="modal-cancel">Cancel</button>
|
||||
<button type="submit" class="submit-btn">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="channel-modal" class="modal-container hidden">
|
||||
<div class="modal">
|
||||
<h2>Create Channel</h2>
|
||||
<form id="channel-form" class="modal-form">
|
||||
<div class="form-item">
|
||||
<label>CHANNEL TYPE</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-item">
|
||||
<input type="radio" name="channel-kind" value="text" checked>
|
||||
<div class="radio-box">
|
||||
<i data-lucide="hash"></i>
|
||||
<div class="radio-text">
|
||||
<strong>Text</strong>
|
||||
<span>Send messages, images, and GIFs.</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="radio-item">
|
||||
<input type="radio" name="channel-kind" value="voice">
|
||||
<div class="radio-box">
|
||||
<i data-lucide="volume-2"></i>
|
||||
<div class="radio-text">
|
||||
<strong>Voice</strong>
|
||||
<span>Hang out together with voice and video.</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label for="channel-name">CHANNEL NAME</label>
|
||||
<input id="channel-name" placeholder="new-channel" maxlength="64" required />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="cancel-btn" id="channel-modal-cancel">Cancel</button>
|
||||
<button type="submit" class="submit-btn">Create Channel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
526
static/styles.css
Normal file
526
static/styles.css
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
:root {
|
||||
--bg-darker: #1e1f22;
|
||||
--bg-sidebar: #2b2d31;
|
||||
--bg-main: #313338;
|
||||
--bg-secondary: #232428;
|
||||
--bg-tertiary: #111214;
|
||||
--bg-modifier-selected: rgba(78, 80, 88, 0.6);
|
||||
--bg-modifier-hover: rgba(78, 80, 88, 0.3);
|
||||
--bg-input: #383a40;
|
||||
|
||||
--text-normal: #dbdee1;
|
||||
--text-muted: #949ba4;
|
||||
--text-strong: #f2f3f5;
|
||||
--text-link: #00a8fc;
|
||||
|
||||
--brand: #5865f2;
|
||||
--brand-hover: #4752c4;
|
||||
--green: #23a559;
|
||||
--danger: #f23f43;
|
||||
--yellow: #f0b232;
|
||||
|
||||
--font-main: "gg sans", "Inter", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
background: var(--bg-darker);
|
||||
color: var(--text-normal);
|
||||
font-family: var(--font-main);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--bg-tertiary); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #242529; }
|
||||
|
||||
button { border: 0; cursor: pointer; transition: all 150ms ease; background: none; color: inherit; font: inherit; padding: 0; }
|
||||
input, select { border: 0; outline: none; background: var(--bg-input); color: var(--text-normal); font: inherit; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* Auth Screen */
|
||||
.auth-screen {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100vh;
|
||||
background-image: url("https://discord.com/assets/f9a15998e94589d343f7.png");
|
||||
background-size: cover;
|
||||
}
|
||||
.auth-card {
|
||||
width: min(480px, 92vw);
|
||||
background: var(--bg-sidebar);
|
||||
border-radius: 8px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||
text-align: center;
|
||||
}
|
||||
.brand-large {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
border-radius: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 40px;
|
||||
font-weight: 800;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
.auth-card h1 { margin: 0 0 8px; color: var(--text-strong); }
|
||||
.auth-card p { margin: 0 0 24px; color: var(--text-muted); }
|
||||
#login-btn {
|
||||
width: 100%;
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
padding: 12px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
#login-btn:hover { background: var(--brand-hover); }
|
||||
|
||||
/* Main Layout */
|
||||
.shell {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 72px 240px 1fr 240px;
|
||||
background: var(--bg-main);
|
||||
}
|
||||
|
||||
/* Server Rail */
|
||||
.server-rail {
|
||||
background: var(--bg-darker);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.server-rail::-webkit-scrollbar { display: none; }
|
||||
.guild-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.brand, .guild-pill {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
background: var(--bg-sidebar);
|
||||
}
|
||||
|
||||
.brand { background: var(--brand); color: #fff; border-radius: 16px; margin-bottom: 2px; }
|
||||
.brand:hover { border-radius: 16px; }
|
||||
|
||||
.separator {
|
||||
width: 32px;
|
||||
height: 2px;
|
||||
background: var(--bg-modifier-selected);
|
||||
margin-bottom: 2px;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.guild-pill:hover, .guild-pill.active {
|
||||
border-radius: 16px;
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.guild-pill::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -12px;
|
||||
width: 4px;
|
||||
height: 0;
|
||||
background: #fff;
|
||||
border-radius: 0 4px 4px 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.guild-pill:hover::before { height: 20px; }
|
||||
.guild-pill.active::before { height: 40px; }
|
||||
|
||||
.action-pill { color: var(--green); }
|
||||
.action-pill:hover { background: var(--green); color: #fff; }
|
||||
|
||||
/* Channel Sidebar */
|
||||
.channel-sidebar {
|
||||
background: var(--bg-sidebar);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--bg-darker);
|
||||
box-shadow: 0 1px 0 rgba(0,0,0,0.1);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
.sidebar-header:hover { background: var(--bg-modifier-hover); }
|
||||
.sidebar-header h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-strong);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.sidebar-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.header-action-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.header-action-btn:hover {
|
||||
background: var(--bg-modifier-hover);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
.header-action-btn i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.sidebar-scroll { flex: 1; overflow-y: auto; padding-top: 12px; }
|
||||
|
||||
.sidebar-group { margin-bottom: 20px; }
|
||||
.group-title {
|
||||
padding: 0 8px 0 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.group-title:hover { color: var(--text-normal); }
|
||||
.group-toggle { width: 12px; height: 12px; margin-right: 2px; }
|
||||
.group-title span { flex: 1; }
|
||||
.add-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s, background-color 0.2s;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.add-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--bg-modifier-hover);
|
||||
}
|
||||
.add-btn i { width: 16px; height: 16px; }
|
||||
|
||||
.channel-list { padding: 0 8px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.channel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
gap: 6px;
|
||||
}
|
||||
.channel-row:hover { background: var(--bg-modifier-hover); color: var(--text-normal); }
|
||||
.channel-row.active { background: var(--bg-modifier-selected); color: var(--text-strong); }
|
||||
.channel-row i { width: 20px; height: 20px; opacity: 0.6; }
|
||||
|
||||
/* Sidebar Footer */
|
||||
.sidebar-footer { background: var(--bg-secondary); padding: 0; }
|
||||
|
||||
.user-panel {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 52px;
|
||||
}
|
||||
.user-panel:hover { background: var(--bg-modifier-hover); }
|
||||
|
||||
.avatar-wrapper { position: relative; }
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
.status-dot {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--bg-secondary);
|
||||
}
|
||||
.status-dot.online { background: var(--green); }
|
||||
|
||||
.user-info { flex: 1; min-width: 0; }
|
||||
.display-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.user-status { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
.user-actions { display: flex; gap: 2px; }
|
||||
.user-actions button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.user-actions button:hover { background: var(--bg-modifier-hover); color: var(--text-normal); }
|
||||
.user-actions i { width: 20px; height: 20px; }
|
||||
|
||||
/* Voice Connection */
|
||||
.voice-connection {
|
||||
padding: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.vc-info { display: flex; align-items: center; gap: 8px; }
|
||||
.vc-icon { color: var(--green); width: 20px; }
|
||||
.vc-text { flex: 1; display: flex; flex-direction: column; }
|
||||
.vc-status { color: var(--green); font-size: 14px; font-weight: 700; }
|
||||
.vc-name { color: var(--text-muted); font-size: 12px; }
|
||||
.vc-actions { display: flex; gap: 4px; }
|
||||
.vc-actions button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.vc-actions button:hover { background: var(--bg-modifier-hover); color: var(--text-normal); }
|
||||
|
||||
/* Chat Pane */
|
||||
.chat-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-main);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.2);
|
||||
box-shadow: 0 1px 0 rgba(0,0,0,0.1);
|
||||
}
|
||||
.header-icon { color: var(--text-muted); width: 24px; }
|
||||
.chat-header h3 { margin: 0; font-size: 16px; font-weight: 700; color: var(--text-strong); }
|
||||
|
||||
.message-list {
|
||||
flex: 1;
|
||||
overflow-y: scroll;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.msg {
|
||||
padding: 2px 16px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 1.0625rem;
|
||||
}
|
||||
.msg:hover { background: rgba(0,0,0,0.05); }
|
||||
|
||||
.msg-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand);
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.msg-content { flex: 1; min-width: 0; }
|
||||
.msg-header { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
|
||||
.msg-author {
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.msg-author:hover { text-decoration: underline; }
|
||||
.msg-time { font-size: 12px; color: var(--text-muted); }
|
||||
.msg-body { color: var(--text-normal); line-height: 1.375rem; white-space: pre-wrap; word-wrap: break-word; }
|
||||
|
||||
.msg-grouped { margin-top: 0; padding-top: 0; padding-bottom: 0; }
|
||||
.msg-grouped .msg-avatar, .msg-grouped .msg-header { display: none; }
|
||||
.msg-grouped .msg-content { padding-left: 56px; }
|
||||
|
||||
/* Chat Input */
|
||||
.chat-input-wrapper { padding: 0 16px 24px; }
|
||||
.message-form {
|
||||
background: var(--bg-input);
|
||||
border-radius: 8px;
|
||||
padding: 11px 16px;
|
||||
}
|
||||
.message-form input {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
color: var(--text-normal);
|
||||
font-size: 16px;
|
||||
}
|
||||
.message-form input::placeholder { color: var(--text-muted); }
|
||||
|
||||
/* Member List */
|
||||
.utility-sidebar {
|
||||
background: var(--bg-sidebar);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.member-list-wrapper { flex: 1; overflow-y: auto; padding: 12px 8px; }
|
||||
.member-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.member-row:hover { background: var(--bg-modifier-hover); color: var(--text-normal); }
|
||||
.member-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.member-name {
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Modals */
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.85);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg-sidebar);
|
||||
width: min(440px, 95vw);
|
||||
border-radius: 5px;
|
||||
padding: 24px;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
.modal h2 { margin: 0 0 16px; text-align: center; color: var(--text-strong); }
|
||||
.form-item { margin-bottom: 20px; }
|
||||
.form-item label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.form-item input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-darker);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
}
|
||||
.cancel-btn { padding: 10px 20px; color: var(--text-strong); font-weight: 500; }
|
||||
.cancel-btn:hover { text-decoration: underline; }
|
||||
.submit-btn {
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
padding: 10px 24px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.submit-btn:hover { background: var(--brand-hover); }
|
||||
|
||||
/* Radio Group for Channel Type */
|
||||
.radio-group { display: flex; flex-direction: column; gap: 8px; }
|
||||
.radio-item { cursor: pointer; position: relative; }
|
||||
.radio-item input { position: absolute; opacity: 0; }
|
||||
.radio-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
background: var(--bg-modifier-hover);
|
||||
border-radius: 4px;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.radio-item input:checked + .radio-box { background: var(--bg-modifier-selected); color: var(--text-strong); }
|
||||
.radio-box i { width: 24px; height: 24px; color: var(--text-muted); }
|
||||
.radio-text { display: flex; flex-direction: column; }
|
||||
.radio-text strong { font-size: 16px; }
|
||||
.radio-text span { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.shell { grid-template-columns: 72px 240px 1fr; }
|
||||
.utility-sidebar { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shell { grid-template-columns: 72px 1fr; }
|
||||
.channel-sidebar { display: none; }
|
||||
}
|
||||
249
static/vendor/deepfilternet3/index.esm.js
vendored
Normal file
249
static/vendor/deepfilternet3/index.esm.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
static/vendor/deepfilternet3/v2/models/DeepFilterNet3_onnx.tar.gz
vendored
Normal file
BIN
static/vendor/deepfilternet3/v2/models/DeepFilterNet3_onnx.tar.gz
vendored
Normal file
Binary file not shown.
BIN
static/vendor/deepfilternet3/v2/pkg/df_bg.wasm
vendored
Normal file
BIN
static/vendor/deepfilternet3/v2/pkg/df_bg.wasm
vendored
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue