server settings

This commit is contained in:
2026-04-05 22:24:24 -04:00
parent fc7e4487b8
commit a44505cd78
12 changed files with 791 additions and 51 deletions
+1 -1
View File
@@ -3,6 +3,6 @@
"run": "pnpm run dev" "run": "pnpm run dev"
}, },
"server": "maincloud", "server": "maincloud",
"database": "ditchcord", "database": "zep",
"module-path": "./spacetimedb" "module-path": "./spacetimedb"
} }
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"database": "ditchcord" "database": "zep"
} }
+73 -1
View File
@@ -29,6 +29,7 @@ const server = table(
id: t.u64().primaryKey().autoInc(), id: t.u64().primaryKey().autoInc(),
name: t.string(), name: t.string(),
owner: t.identity().optional(), owner: t.identity().optional(),
avatar_id: t.u64().optional(),
}, },
); );
@@ -545,6 +546,76 @@ export const upload_banner = spacetimedb.reducer(
}, },
); );
export const upload_server_avatar = spacetimedb.reducer(
{ serverId: t.u64(), data: t.byteArray(), mimeType: t.string() },
(ctx, { serverId, data, mimeType }) => {
if (data.length > 1024 * 1024) {
throw new SenderError("Avatar exceeds 1MB limit");
}
const s = ctx.db.server.id.find(serverId);
if (!s) throw new SenderError("Server not found");
/* owner check temporarily disabled */
/* if (s.owner?.toHexString() !== ctx.sender.toHexString()) {
throw new SenderError("Only the server owner can change the avatar");
} */
const img = ctx.db.image.insert({
id: 0n,
data,
mime_type: mimeType,
name: "server_avatar",
});
ctx.db.server.id.update({ ...s, avatar_id: img.id });
},
);
export const update_server_name = spacetimedb.reducer(
{ serverId: t.u64(), name: t.string() },
(ctx, { serverId, name }) => {
validateName(name);
const s = ctx.db.server.id.find(serverId);
if (!s) throw new SenderError("Server not found");
/* owner check temporarily disabled */
/* if (s.owner?.toHexString() !== ctx.sender.toHexString()) {
throw new SenderError("Only the server owner can rename the server");
} */
ctx.db.server.id.update({ ...s, name });
},
);
export const delete_server = spacetimedb.reducer(
{ serverId: t.u64() },
(ctx, { serverId }) => {
const s = ctx.db.server.id.find(serverId);
if (!s) throw new SenderError("Server not found");
/* owner check temporarily disabled */
/* if (s.owner?.toHexString() !== ctx.sender.toHexString()) {
throw new SenderError("Only the server owner can delete the server");
} */
// 1. Delete all channels in this server
for (const c of ctx.db.channel.by_server_id.filter(serverId)) {
// Cleanup messages in channel
for (const m of ctx.db.message.by_channel_id.filter(c.id)) {
ctx.db.message.id.delete(m.id);
}
for (const rm of ctx.db.recent_message.by_channel.filter(c.id)) {
ctx.db.recent_message.id.delete(rm.id);
}
ctx.db.channel.id.delete(c.id);
}
// 2. Delete all memberships
for (const m of ctx.db.server_member.by_server_id.filter(serverId)) {
ctx.db.server_member.id.delete(m.id);
}
// 3. Delete the server itself
ctx.db.server.id.delete(serverId);
},
);
export const toggle_reaction = spacetimedb.reducer( export const toggle_reaction = spacetimedb.reducer(
{ {
messageId: t.u64(), messageId: t.u64(),
@@ -651,7 +722,7 @@ export const create_server = spacetimedb.reducer(
"You must be logged in via OIDC to create a server", "You must be logged in via OIDC to create a server",
); );
} }
const s = ctx.db.server.insert({ id: 0n, name, owner: ctx.sender }); const s = ctx.db.server.insert({ id: 0n, name, owner: ctx.sender, avatar_id: undefined });
ctx.db.server_member.insert({ ctx.db.server_member.insert({
id: 0n, id: 0n,
identity: ctx.sender, identity: ctx.sender,
@@ -1227,6 +1298,7 @@ export const init = spacetimedb.init((ctx) => {
id: 0n, id: 0n,
name: "Zep", name: "Zep",
owner: undefined, owner: undefined,
avatar_id: undefined,
}); });
ctx.db.channel.insert({ ctx.db.channel.insert({
id: 0n, id: 0n,
+7
View File
@@ -13,6 +13,7 @@
import VideoGrid from "./components/VideoGrid.svelte"; import VideoGrid from "./components/VideoGrid.svelte";
import Avatar from "./components/Avatar.svelte"; import Avatar from "./components/Avatar.svelte";
import SettingsPanel from "./components/SettingsPanel.svelte"; import SettingsPanel from "./components/SettingsPanel.svelte";
import ServerSettingsPanel from "./components/ServerSettingsPanel.svelte";
import ImageViewer from "./components/ImageViewer.svelte"; import ImageViewer from "./components/ImageViewer.svelte";
import ProfileModal from "./components/ProfileModal.svelte"; import ProfileModal from "./components/ProfileModal.svelte";
import UserContextMenu from "./components/UserContextMenu.svelte"; import UserContextMenu from "./components/UserContextMenu.svelte";
@@ -215,6 +216,12 @@
/> />
{/if} {/if}
{#if chat.showServerSettings}
<ServerSettingsPanel
onClose={() => (chat.showServerSettings = false)}
/>
{/if}
{#if chat.viewingImageId} {#if chat.viewingImageId}
{@const image = chat.images.find(img => img.id === chat.viewingImageId)} {@const image = chat.images.find(img => img.id === chat.viewingImageId)}
{#if image} {#if image}
+11 -9
View File
@@ -63,25 +63,26 @@
const heightDiff = newHeight - lastHeight; const heightDiff = newHeight - lastHeight;
if (heightDiff !== 0) { if (heightDiff !== 0) {
const messages = chat.channelMessages; const messagesList = chat.channelMessages;
const lastMsg = messages[messages.length - 1]; const lastMsg = messagesList[messagesList.length - 1];
const myIdHex = chat.identity?.toHexString(); const myIdHex = chat.identity?.toHexString();
const lastMsgSenderHex = lastMsg?.sender.toHexString(); const lastMsgSenderHex = lastMsg?.sender.toHexString();
const sentByMe = myIdHex && lastMsgSenderHex && myIdHex === lastMsgSenderHex; const sentByMe = myIdHex && lastMsgSenderHex && myIdHex === lastMsgSenderHex;
// If the user is NOT at the bottom, adjust scroll to keep position // If we ARE at the bottom, OR we just sent a message, keep us pinned to the NEW bottom
if (!isAtBottom && !isPrepending && !sentByMe) { if (isAtBottom || sentByMe) {
scrollContainer!.scrollTop += heightDiff;
}
// If we ARE at the bottom, OR we just sent a message, keep us pinned
else if (isAtBottom || sentByMe) {
scrollContainer!.scrollTop = target.scrollHeight; scrollContainer!.scrollTop = target.scrollHeight;
}
// If the user is NOT at the bottom, and they aren't loading more history,
// we only adjust scroll if something above them expanded.
// For simplicity, we assume expansion happens at the point of interaction.
else if (!isPrepending) {
// No-op for expansion at/below viewport to let browser maintain focus on the item
} }
lastHeight = newHeight; lastHeight = newHeight;
} }
} }
}); });
observer.observe(scrollContainer); observer.observe(scrollContainer);
return () => observer.disconnect(); return () => observer.disconnect();
} }
@@ -94,6 +95,7 @@
const lastMsgSenderHex = lastMsg?.sender.toHexString(); const lastMsgSenderHex = lastMsg?.sender.toHexString();
const sentByMe = myIdHex && lastMsgSenderHex && myIdHex === lastMsgSenderHex; const sentByMe = myIdHex && lastMsgSenderHex && myIdHex === lastMsgSenderHex;
// Only force scroll to bottom if we were already anchored or we just sent the message
if (isAtBottom || sentByMe) { if (isAtBottom || sentByMe) {
scrollToBottom(false); scrollToBottom(false);
} }
+2 -8
View File
@@ -137,14 +137,8 @@
} }
function onImageLoad(e: Event) { function onImageLoad(e: Event) {
const img = e.currentTarget as HTMLImageElement; if (onLoad) {
const list = img.closest(".message-list"); onLoad();
if (list) {
if (onLoad) {
onLoad();
} else {
list.scrollTop = list.scrollHeight;
}
} }
} }
+5 -1
View File
@@ -9,12 +9,16 @@
<div class="server-list"> <div class="server-list">
{#each chat.joinedServers as server (server.id.toString())} {#each chat.joinedServers as server (server.id.toString())}
{@const avatarUrl = chat.getServerAvatarUrl(server)}
<button <button
class="server-icon {chat.activeServerId === server.id ? 'active' : ''}" class="server-icon {chat.activeServerId === server.id ? 'active' : ''}"
onclick={() => (chat.activeServerId = server.id)} onclick={() => (chat.activeServerId = server.id)}
title={server.name} title={server.name}
style={avatarUrl ? `background-image: url(${avatarUrl}); background-size: cover; background-position: center; border: none;` : ""}
> >
{server.name.substring(0, 2).toUpperCase()} {#if !avatarUrl}
{server.name.substring(0, 2).toUpperCase()}
{/if}
</button> </button>
{/each} {/each}
@@ -0,0 +1,540 @@
<script lang="ts">
import { getContext } from "svelte";
import type { ChatService } from "../services/chat.svelte";
import { optimizeEmoji } from "../utils";
import { portal } from "../../portal";
let { onClose } = $props<{ onClose: () => void }>();
const chat = getContext<ChatService>("chat");
let activeCategory = $state("overview");
let serverName = $state("");
let avatarPreview = $state<string | null>(null);
let newAvatarFile = $state<File | null>(null);
let isUploading = $state(false);
let errorMessage = $state<string | null>(null);
let showDeleteConfirm = $state(false);
const server = $derived(chat.activeServer);
const isOwner = true; // temporarily enabled for everyone
$effect(() => {
if (server) {
serverName = server.name;
avatarPreview = chat.getServerAvatarUrl(server);
}
});
const hasChanges = $derived(
(serverName.trim() !== (server?.name || "") && serverName.trim() !== "") ||
newAvatarFile !== null
);
async function handleAvatarChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
newAvatarFile = file;
avatarPreview = URL.createObjectURL(file);
}
}
const handleSave = async () => {
if (!server) return;
errorMessage = null;
isUploading = true;
try {
if (serverName.trim() !== server.name) {
chat.handleUpdateServerName(server.id, serverName.trim());
}
if (newAvatarFile) {
const { data, mimeType } = await optimizeEmoji(newAvatarFile);
await chat.handleUploadServerAvatar(server.id, data, mimeType);
}
onClose();
} catch (err) {
console.error("Failed to save server settings:", err);
errorMessage = "Failed to save changes. Please try again.";
} finally {
isUploading = false;
}
};
const handleConfirmDelete = () => {
if (chat.activeServerId) {
chat.handleDeleteServer(chat.activeServerId);
showDeleteConfirm = false;
onClose();
}
};
const categories = [
{ id: "overview", name: "Overview", icon: "fas fa-info-circle" },
];
const handleOverlayClick = (e: MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="settings-overlay" onclick={handleOverlayClick} role="presentation">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="settings-modal" onclick={(e) => e.stopPropagation()} role="presentation">
<div class="settings-sidebar">
<div class="sidebar-header">{server?.name || 'Server'} Settings</div>
{#each categories as cat}
<button
class="sidebar-item {activeCategory === cat.id ? 'active' : ''}"
onclick={() => activeCategory = cat.id}
>
<i class={cat.icon}></i>
{cat.name}
</button>
{/each}
<div class="sidebar-separator"></div>
<button class="sidebar-item danger" onclick={() => {
if (chat.activeServerId) {
chat.handleLeaveServer(chat.activeServerId);
onClose();
}
}}>
<i class="fas fa-sign-out-alt"></i>
Leave Server
</button>
<div class="sidebar-separator"></div>
<button class="sidebar-item danger" onclick={() => showDeleteConfirm = true}>
<i class="fas fa-trash-alt"></i>
Delete Server
</button>
</div>
<div class="settings-main">
<div class="settings-content">
<div class="content-header">
<h2>{categories.find(c => c.id === activeCategory)?.name}</h2>
<button class="close-btn" onclick={onClose} aria-label="Close settings">
<i class="fas fa-times"></i>
</button>
</div>
<div class="category-content">
{#if activeCategory === "overview"}
<div class="section">
<div class="section-header-description">
<h3>Server Overview</h3>
<p>Manage the basic information of your server.</p>
</div>
<div class="overview-grid">
<div class="avatar-edit-section">
<div class="server-avatar-preview-large">
{#if avatarPreview}
<img src={avatarPreview} alt="Server Icon" />
{:else}
<div class="avatar-fallback-large">
{server?.name.substring(0, 2).toUpperCase()}
</div>
{/if}
</div>
{#if isOwner}
<label class="btn-primary" style="margin-top: 12px; font-size: 0.8rem; padding: 6px 12px; cursor: pointer;">
Change Avatar
<input type="file" accept="image/*" onchange={handleAvatarChange} disabled={isUploading} style="display: none;" />
</label>
{/if}
</div>
<div class="name-edit-section">
<div class="form-group">
<label for="server-name">Server Name</label>
<input
id="server-name"
type="text"
bind:value={serverName}
disabled={!isOwner}
class="styled-input"
/>
</div>
</div>
</div>
{#if errorMessage}
<div class="form-error" style="color: var(--status-danger); font-size: 0.85rem; margin-top: 16px;">
<i class="fas fa-exclamation-circle"></i> {errorMessage}
</div>
{/if}
</div>
{/if}
</div>
</div>
{#if hasChanges || isUploading}
<div class="settings-footer">
<div class="footer-notice">
{#if isUploading}
Saving changes...
{:else}
Careful — you have unsaved changes!
{/if}
</div>
<div class="footer-actions">
<button class="btn-ghost" onclick={() => {
if (server) {
serverName = server.name;
avatarPreview = chat.getServerAvatarUrl(server);
newAvatarFile = null;
errorMessage = null;
}
}}>Reset</button>
<button class="btn-success" onclick={handleSave} disabled={isUploading}>
{isUploading ? "Saving..." : "Save Changes"}
</button>
</div>
</div>
{/if}
</div>
</div>
</div>
{#if showDeleteConfirm}
<div class="modal-overlay danger-overlay" use:portal onclick={() => showDeleteConfirm = false} role="presentation">
<div class="modal-content danger-modal" onclick={(e) => e.stopPropagation()} role="presentation">
<h2>Delete '{server?.name}'</h2>
<p>Are you sure you want to delete <strong>{server?.name}</strong>? This action is permanent and will delete all channels, messages, and associated data. This cannot be undone.</p>
<div class="modal-actions">
<button class="btn-ghost" onclick={() => showDeleteConfirm = false}>Cancel</button>
<button class="btn-danger" onclick={handleConfirmDelete}>Delete Server</button>
</div>
</div>
</div>
{/if}
<style>
.settings-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.85);
z-index: 5000;
display: flex;
justify-content: center;
align-items: center;
font-family: var(--font-sans);
padding: 40px;
}
.settings-modal {
width: 100%;
max-width: 1000px;
height: 100%;
max-height: 800px;
display: flex;
background-color: var(--background-primary);
border-radius: 8px;
overflow: hidden;
box-shadow: var(--elevation-high);
border: 1px solid var(--background-modifier-accent);
}
.settings-sidebar {
width: 218px;
background-color: var(--background-secondary);
padding: 60px 6px 20px 20px;
display: flex;
flex-direction: column;
gap: 2px;
}
.sidebar-header {
padding: 6px 10px;
font-size: 0.75rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-item {
padding: 6px 10px;
border-radius: 4px;
background: none;
border: none;
color: var(--interactive-normal);
text-align: left;
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: background-color 0.1s, color 0.1s;
}
.sidebar-item i {
width: 16px;
text-align: center;
font-size: 0.9rem;
}
.sidebar-item:hover {
background-color: var(--background-modifier-hover);
color: var(--interactive-hover);
}
.sidebar-item.active {
background-color: var(--background-modifier-selected);
color: var(--interactive-hover);
}
.sidebar-item.danger {
color: var(--status-danger);
}
.sidebar-item.danger:hover {
background-color: var(--status-danger);
color: white;
}
.sidebar-separator {
height: 1px;
background-color: var(--background-modifier-accent);
margin: 8px 10px;
}
.settings-main {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--background-primary);
position: relative;
}
.settings-content {
flex: 1;
padding: 60px 40px 80px 40px;
overflow-y: auto;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
}
.content-header h2 {
margin: 0;
font-size: 1.25rem;
color: var(--header-primary);
font-weight: 600;
}
.section-header-description h3 {
margin: 0 0 4px 0;
font-size: 1rem;
color: var(--header-primary);
text-transform: uppercase;
}
.section-header-description p {
margin: 0 0 20px 0;
font-size: 0.85rem;
color: var(--text-muted);
}
.overview-grid {
display: flex;
gap: 32px;
align-items: flex-start;
}
.server-avatar-preview-large {
width: 100px;
height: 100px;
border-radius: 50%;
background-color: var(--background-secondary);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 2px solid var(--background-modifier-accent);
}
.server-avatar-preview-large img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-fallback-large {
font-size: 2rem;
font-weight: bold;
color: var(--text-muted);
}
.name-edit-section {
flex: 1;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 0.75rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
}
.styled-input {
background-color: var(--background-tertiary);
border: 1px solid var(--background-modifier-accent);
color: var(--text-normal);
padding: 10px;
border-radius: 4px;
font-size: 1rem;
outline: none;
}
.styled-input:focus {
border-color: var(--brand);
}
.styled-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Footer */
.settings-footer {
position: absolute;
bottom: 20px;
left: 40px;
right: 40px;
background-color: rgba(0, 0, 0, 0.9);
padding: 12px 16px;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
animation: slideInUp 0.2s ease-out;
backdrop-filter: blur(4px);
border: 1px solid var(--background-modifier-accent);
}
.footer-notice {
color: white;
font-size: 0.9rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.footer-actions {
display: flex;
gap: 12px;
}
.btn-success {
background-color: var(--status-positive);
color: white;
border: none;
padding: 8px 20px;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.1s;
}
.btn-success:hover:not(:disabled) {
opacity: 0.9;
}
.btn-success:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-ghost {
background: none;
border: none;
color: white;
padding: 8px 16px;
cursor: pointer;
font-weight: 500;
}
/* Custom Confirmation Modal */
.danger-overlay {
background-color: rgba(0, 0, 0, 0.85);
z-index: 6000; /* Above settings */
}
.danger-modal {
background-color: var(--background-primary);
max-width: 440px;
width: 90%;
padding: 24px;
border-radius: 8px;
box-shadow: var(--elevation-high);
border: 1px solid var(--background-modifier-accent);
animation: modal-pop 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.danger-modal h2 {
color: var(--status-danger);
margin: 0 0 16px 0;
font-size: 1.2rem;
}
.danger-modal p {
color: var(--text-normal);
line-height: 1.5;
margin-bottom: 24px;
font-size: 0.95rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.btn-danger {
background-color: var(--status-danger);
color: white;
border: none;
padding: 10px 24px;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.1s;
}
.btn-danger:hover {
opacity: 0.9;
}
@keyframes slideInUp {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes modal-pop {
from { transform: scale(0.9); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
</style>
@@ -5,39 +5,65 @@
const chat = getContext<ChatService>("chat"); const chat = getContext<ChatService>("chat");
let showServerDropdown = $state(false); let showServerDropdown = $state(false);
let fileInput = $state<HTMLInputElement | null>(null);
const isOwner = true; // temporarily enabled for everyone
function handleOpenSettings() {
chat.showServerSettings = true;
showServerDropdown = false;
}
</script> </script>
<button <div class="server-header-container">
class="server-header clickable" <button
onclick={() => (showServerDropdown = !showServerDropdown)} class="server-header clickable"
> onclick={() => (showServerDropdown = !showServerDropdown)}
<h3
style="margin: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left;"
> >
{chat.activeServer?.name || "Select a Server"} <h3
</h3> style="margin: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left;"
<span style="font-size: 0.8rem; opacity: 0.6; margin-left: 4px;">
<i class="fas {showServerDropdown ? 'fa-chevron-up' : 'fa-chevron-down'}"></i>
</span>
</button>
{#if showServerDropdown}
<div class="server-dropdown">
<button
class="server-dropdown-item danger muted"
onclick={() => {
if (chat.activeServerId) {
chat.handleLeaveServer(chat.activeServerId);
showServerDropdown = false;
}
}}
> >
Leave Server {chat.activeServer?.name || "Select a Server"}
</button> </h3>
</div> <span style="font-size: 0.8rem; opacity: 0.6; margin-left: 4px;">
{/if} <i class="fas {showServerDropdown ? 'fa-chevron-up' : 'fa-chevron-down'}"></i>
</span>
</button>
{#if showServerDropdown}
<div class="server-dropdown shadow-box">
{#if isOwner}
<button
class="server-dropdown-item"
onclick={handleOpenSettings}
>
<i class="fas fa-cog" style="width: 16px; margin-right: 8px;"></i>
Server Settings
</button>
<div class="dropdown-separator"></div>
{/if}
<button
class="server-dropdown-item danger"
onclick={() => {
if (chat.activeServerId) {
chat.handleLeaveServer(chat.activeServerId);
showServerDropdown = false;
}
}}
>
<i class="fas fa-sign-out-alt" style="width: 16px; margin-right: 8px;"></i>
Leave Server
</button>
</div>
{/if}
</div>
<style> <style>
.server-header-container {
position: relative;
width: 100%;
}
.server-header { .server-header {
padding: 12px 16px; padding: 12px 16px;
height: 48px; height: 48px;
@@ -53,6 +79,7 @@
cursor: pointer; cursor: pointer;
font-weight: bold; font-weight: bold;
width: 100%; width: 100%;
transition: background-color 0.1s;
} }
.server-header:hover { .server-header:hover {
@@ -60,17 +87,33 @@
} }
.server-dropdown { .server-dropdown {
/* dropdown styles if needed */ position: absolute;
top: 100%;
left: 8px;
right: 8px;
margin-top: 4px;
background-color: var(--background-floating);
border-radius: 4px;
padding: 6px;
z-index: 1000;
border: 1px solid var(--background-modifier-accent);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
} }
.server-dropdown-item { .server-dropdown-item {
background: none; background: none;
border: none; border: none;
color: var(--text-normal); color: var(--interactive-normal);
width: 100%; width: 100%;
text-align: left; text-align: left;
padding: 8px; padding: 8px 12px;
cursor: pointer; cursor: pointer;
border-radius: 2px;
font-size: 0.85rem;
font-weight: 500;
display: flex;
align-items: center;
transition: all 0.1s;
} }
.server-dropdown-item:hover { .server-dropdown-item:hover {
@@ -78,7 +121,19 @@
color: white; color: white;
} }
.server-dropdown-item.danger {
color: var(--status-danger);
}
.server-dropdown-item.danger:hover { .server-dropdown-item.danger:hover {
background-color: var(--status-danger); background-color: var(--status-danger);
color: white;
}
.dropdown-separator {
height: 1px;
background-color: var(--background-modifier-accent);
margin: 4px 6px;
opacity: 0.5;
} }
</style> </style>
+48
View File
@@ -25,6 +25,7 @@ export class ChatService {
identity = $state<Identity | null>(null); identity = $state<Identity | null>(null);
#avatarUrls = new SvelteMap<string, string>(); #avatarUrls = new SvelteMap<string, string>();
#bannerUrls = new SvelteMap<string, string>(); #bannerUrls = new SvelteMap<string, string>();
#serverAvatarUrls = new SvelteMap<string, string>();
constructor(initialIdentity: Identity | null) { constructor(initialIdentity: Identity | null) {
this.identity = initialIdentity; this.identity = initialIdentity;
@@ -87,6 +88,30 @@ export class ChatService {
}); });
} }
}); });
// Background effect to populate server avatar cache
$effect(() => {
const serversWithAvatars = this.servers.filter((s) => s.avatarId);
for (const server of serversWithAvatars) {
const avatarId = server.avatarId!;
const idStr = avatarId.toString();
if (this.#serverAvatarUrls.has(idStr)) continue;
imageCache.get(avatarId).then((cachedUrl) => {
if (cachedUrl) {
this.#serverAvatarUrls.set(idStr, cachedUrl);
} else {
const img = this.#db.images.find((i) => i.id === avatarId);
if (img) {
imageCache.set(img.id, img.data, img.mimeType).then((url) => {
this.#serverAvatarUrls.set(idStr, url);
});
}
}
});
}
});
} }
get activeServerId() { get activeServerId() {
@@ -194,6 +219,12 @@ export class ChatService {
set showDiscoveryModal(v) { set showDiscoveryModal(v) {
this.#ui.showDiscoveryModal = v; this.#ui.showDiscoveryModal = v;
} }
get showServerSettings() {
return this.#ui.showServerSettings;
}
set showServerSettings(v) {
this.#ui.showServerSettings = v;
}
get authError() { get authError() {
return this.#ui.authError; return this.#ui.authError;
} }
@@ -425,6 +456,11 @@ export class ChatService {
return this.#bannerUrls.get(user.bannerId.toString()) || null; return this.#bannerUrls.get(user.bannerId.toString()) || null;
}; };
getServerAvatarUrl = (server: Types.Server | null | undefined) => {
if (!server || !server.avatarId) return null;
return this.#serverAvatarUrls.get(server.avatarId.toString()) || null;
};
uploadImage = async (data: Uint8Array, mimeType: string, name?: string) => { uploadImage = async (data: Uint8Array, mimeType: string, name?: string) => {
this.#msg.uploadImage(data, mimeType, name); this.#msg.uploadImage(data, mimeType, name);
}; };
@@ -500,6 +536,18 @@ export class ChatService {
this.#server.handleLeaveServer(serverId); this.#server.handleLeaveServer(serverId);
}; };
handleUploadServerAvatar = (serverId: bigint, data: Uint8Array, mimeType: string) => {
this.#server.handleUploadServerAvatar(serverId, data, mimeType);
};
handleUpdateServerName = (serverId: bigint, name: string) => {
this.#server.handleUpdateServerName(serverId, name);
};
handleDeleteServer = (serverId: bigint) => {
this.#server.handleDeleteServer(serverId);
};
// Helper functions // Helper functions
getUsername = (userIdentity: Identity | null) => getUsername = (userIdentity: Identity | null) =>
getUsername(userIdentity, this.users); getUsername(userIdentity, this.users);
@@ -6,6 +6,9 @@ export class ServerManagementService {
#createChannelReducer = useReducer(reducers.createChannel); #createChannelReducer = useReducer(reducers.createChannel);
#joinServerReducer = useReducer(reducers.joinServer); #joinServerReducer = useReducer(reducers.joinServer);
#leaveServerReducer = useReducer(reducers.leaveServer); #leaveServerReducer = useReducer(reducers.leaveServer);
#uploadServerAvatarReducer = useReducer(reducers.uploadServerAvatar);
#updateServerNameReducer = useReducer(reducers.updateServerName);
#deleteServerReducer = useReducer(reducers.deleteServer);
handleCreateServer = (name: string) => { handleCreateServer = (name: string) => {
if (name.trim()) { if (name.trim()) {
@@ -30,4 +33,18 @@ export class ServerManagementService {
handleLeaveServer = (serverId: bigint) => { handleLeaveServer = (serverId: bigint) => {
this.#leaveServerReducer({ serverId }); this.#leaveServerReducer({ serverId });
}; };
handleUploadServerAvatar = (serverId: bigint, data: Uint8Array, mimeType: string) => {
this.#uploadServerAvatarReducer({ serverId, data, mimeType });
};
handleUpdateServerName = (serverId: bigint, name: string) => {
if (name.trim()) {
this.#updateServerNameReducer({ serverId, name });
}
};
handleDeleteServer = (serverId: bigint) => {
this.#deleteServerReducer({ serverId });
};
} }
+1
View File
@@ -11,6 +11,7 @@ export class ThemeService {
messageText = $state(""); messageText = $state("");
threadMessageText = $state(""); threadMessageText = $state("");
showDiscoveryModal = $state(false); showDiscoveryModal = $state(false);
showServerSettings = $state(false);
authError = $state(""); authError = $state("");
viewingImageId = $state<bigint | null>(null); viewingImageId = $state<bigint | null>(null);
viewingProfileUser = $state<any | null>(null); viewingProfileUser = $state<any | null>(null);