486 lines
13 KiB
Svelte
486 lines
13 KiB
Svelte
<script lang="ts">
|
|
import { getContext, onDestroy, onMount } from "svelte";
|
|
import { auth } from "../../auth/auth.svelte";
|
|
import type { ChatService } from "../services/chat.svelte";
|
|
import type { WebRTCService } from "../services/webrtc/webrtc.svelte";
|
|
import type * as Types from "../../module_bindings/types";
|
|
import { optimizeImage } from "../utils";
|
|
|
|
import AccountSettings from "./settings/AccountSettings.svelte";
|
|
import CustomizationSettings from "./settings/CustomizationSettings.svelte";
|
|
import AudioSettings from "./settings/AudioSettings.svelte";
|
|
import ScreenSharingSettings from "./settings/ScreenSharingSettings.svelte";
|
|
import SecuritySettings from "./settings/SecuritySettings.svelte";
|
|
import Button from "./ui/Button.svelte";
|
|
|
|
let { onClose, currentUser }: { onClose: () => void, currentUser: Types.User | undefined } = $props();
|
|
|
|
const chat = getContext<ChatService>("chat");
|
|
const webrtc = getContext<WebRTCService>("webrtc");
|
|
|
|
let activeCategory = $state("account");
|
|
let localName = $state("");
|
|
let localStatus = $state("");
|
|
let biography = $state("");
|
|
let avatarPreview = $state<string | null>(null);
|
|
let newAvatarFile = $state<File | null>(null);
|
|
let bannerPreview = $state<string | null>(null);
|
|
let newBannerFile = $state<File | null>(null);
|
|
let errorMessage = $state<string | null>(null);
|
|
|
|
onMount(() => {
|
|
if (currentUser?.name) {
|
|
localName = currentUser.name;
|
|
}
|
|
if (currentUser?.status) {
|
|
localStatus = currentUser.status;
|
|
}
|
|
if (currentUser?.biography) {
|
|
biography = currentUser.biography;
|
|
}
|
|
avatarPreview = chat.getAvatarUrl(currentUser);
|
|
bannerPreview = chat.getBannerUrl(currentUser);
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (webrtc.isTestingMic) {
|
|
webrtc.toggleMicTest();
|
|
}
|
|
});
|
|
|
|
const handleSave = async () => {
|
|
errorMessage = null;
|
|
|
|
if (localName.trim() && localName !== currentUser?.name) {
|
|
chat.account.handleSetName(localName.trim());
|
|
}
|
|
|
|
if (localStatus.trim() !== (currentUser?.status || "")) {
|
|
chat.handleSetStatus(localStatus.trim() || undefined);
|
|
}
|
|
|
|
if (biography !== (currentUser?.biography || "")) {
|
|
chat.handleSetBiography(biography.trim() || undefined);
|
|
}
|
|
|
|
if (newAvatarFile) {
|
|
try {
|
|
let data: Uint8Array;
|
|
let mimeType: string;
|
|
|
|
if (newAvatarFile.type === 'image/gif') {
|
|
const buffer = await newAvatarFile.arrayBuffer();
|
|
data = new Uint8Array(buffer);
|
|
mimeType = newAvatarFile.type;
|
|
} else {
|
|
const optimized = await optimizeImage(newAvatarFile);
|
|
data = optimized.data;
|
|
mimeType = optimized.mimeType;
|
|
}
|
|
|
|
if (data.length > 2 * 1024 * 1024) { // Increased limit for non-optimized or large avatars
|
|
errorMessage = "Avatar exceeds 2MB limit.";
|
|
return;
|
|
}
|
|
await chat.handleUploadAvatar(data, mimeType);
|
|
} catch (err) {
|
|
console.error("Avatar upload failed:", err);
|
|
errorMessage = "Failed to process avatar image.";
|
|
return;
|
|
}
|
|
} else if (!avatarPreview && currentUser?.avatarId) {
|
|
// User removed their avatar
|
|
chat.handleSetAvatar(undefined);
|
|
}
|
|
|
|
if (newBannerFile) {
|
|
try {
|
|
let data: Uint8Array;
|
|
let mimeType: string;
|
|
|
|
if (newBannerFile.type === 'image/gif') {
|
|
const buffer = await newBannerFile.arrayBuffer();
|
|
data = new Uint8Array(buffer);
|
|
mimeType = newBannerFile.type;
|
|
} else {
|
|
const optimized = await optimizeImage(newBannerFile);
|
|
data = optimized.data;
|
|
mimeType = optimized.mimeType;
|
|
}
|
|
|
|
if (data.length > 4 * 1024 * 1024) { // Increased limit for non-optimized or large banners
|
|
errorMessage = "Banner exceeds 4MB limit.";
|
|
return;
|
|
}
|
|
await chat.handleUploadBanner(data, mimeType);
|
|
} catch (err) {
|
|
console.error("Banner upload failed:", err);
|
|
errorMessage = "Failed to process banner image.";
|
|
return;
|
|
}
|
|
} else if (!bannerPreview && currentUser?.bannerId) {
|
|
// User removed their banner
|
|
chat.handleSetBanner(undefined);
|
|
}
|
|
|
|
onClose();
|
|
};
|
|
|
|
const handleOverlayClick = (e: MouseEvent) => {
|
|
if (e.target === e.currentTarget) {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
const categories = [
|
|
{ id: "account", name: "My Account", icon: "fas fa-user" },
|
|
{ id: "customization", name: "Customization", icon: "fas fa-palette" },
|
|
{ id: "security", name: "Security & Privacy", icon: "fas fa-shield-alt" },
|
|
{ id: "voice", name: "Voice & Video", icon: "fas fa-microphone" },
|
|
{ id: "screen", name: "Screen Sharing", icon: "fas fa-desktop" },
|
|
];
|
|
|
|
const hasChanges = $derived(
|
|
(localName.trim() !== (currentUser?.name || "") && localName.trim() !== "") ||
|
|
localStatus.trim() !== (currentUser?.status || "") ||
|
|
biography !== (currentUser?.biography || "") ||
|
|
newAvatarFile !== null ||
|
|
(avatarPreview === null && currentUser?.avatarId) ||
|
|
newBannerFile !== null ||
|
|
(bannerPreview === null && currentUser?.bannerId)
|
|
);
|
|
</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">
|
|
<button class="close-btn-fixed" onclick={onClose} aria-label="Close settings">
|
|
<div class="close-icon-circle">
|
|
<i class="fas fa-times"></i>
|
|
</div>
|
|
<span class="close-text">ESC</span>
|
|
</button>
|
|
|
|
<div class="settings-sidebar">
|
|
<div class="sidebar-header">User 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
|
|
variant="danger"
|
|
size="medium"
|
|
class="sidebar-item-btn"
|
|
onclick={() => {
|
|
chat.confirmModal = {
|
|
title: "Log Out",
|
|
message: "Are you sure you want to log out? You will need to sign in again to access your account.",
|
|
confirmText: "Log Out",
|
|
cancelText: "Cancel",
|
|
isDanger: true,
|
|
onConfirm: () => auth.logout()
|
|
};
|
|
}}
|
|
>
|
|
<i class="fas fa-sign-out-alt"></i>
|
|
Logout
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="settings-main">
|
|
<div class="settings-content">
|
|
<div class="content-header">
|
|
<h2>{categories.find(c => c.id === activeCategory)?.name}</h2>
|
|
</div>
|
|
|
|
<div class="category-content">
|
|
{#if activeCategory === "account"}
|
|
<AccountSettings
|
|
bind:localName
|
|
bind:localStatus
|
|
bind:biography
|
|
bind:avatarPreview
|
|
bind:newAvatarFile
|
|
bind:bannerPreview
|
|
bind:newBannerFile
|
|
bind:errorMessage
|
|
{currentUser}
|
|
/>
|
|
{:else if activeCategory === "customization"}
|
|
<CustomizationSettings />
|
|
{:else if activeCategory === "security"}
|
|
<SecuritySettings />
|
|
{:else if activeCategory === "voice"}
|
|
<AudioSettings />
|
|
{:else if activeCategory === "screen"}
|
|
<ScreenSharingSettings />
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
{#if hasChanges || errorMessage}
|
|
<div class="settings-footer">
|
|
<div class="footer-notice {errorMessage ? 'error' : ''}">
|
|
{#if errorMessage}
|
|
<i class="fas fa-exclamation-circle"></i> {errorMessage}
|
|
{:else}
|
|
Careful — you have unsaved changes!
|
|
{/if}
|
|
</div>
|
|
<div class="footer-actions">
|
|
<Button variant="ghost" onclick={() => {
|
|
localName = currentUser?.name || "";
|
|
localStatus = currentUser?.status || "";
|
|
biography = currentUser?.biography || "";
|
|
avatarPreview = chat.getAvatarUrl(currentUser);
|
|
bannerPreview = chat.getBannerUrl(currentUser);
|
|
newAvatarFile = null;
|
|
newBannerFile = null;
|
|
errorMessage = null;
|
|
}}>Reset</Button>
|
|
<Button variant="success" onclick={handleSave}>Save Changes</Button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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);
|
|
position: relative;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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-active);
|
|
}
|
|
|
|
.sidebar-separator {
|
|
height: 1px;
|
|
background-color: var(--background-modifier-accent);
|
|
margin: 8px 10px;
|
|
}
|
|
|
|
:global(.sidebar-item-btn) {
|
|
margin: 2px 0;
|
|
justify-content: flex-start !important;
|
|
text-align: left;
|
|
width: 100% !important;
|
|
padding: 6px 10px !important;
|
|
background: none !important;
|
|
border: none !important;
|
|
font-size: 1rem !important;
|
|
color: var(--interactive-normal) !important;
|
|
transition: all 0.1s !important;
|
|
}
|
|
|
|
:global(.sidebar-item-btn.danger) {
|
|
color: var(--status-danger) !important;
|
|
}
|
|
|
|
:global(.sidebar-item-btn:hover) {
|
|
background-color: var(--background-modifier-hover) !important;
|
|
color: var(--interactive-hover) !important;
|
|
}
|
|
|
|
:global(.sidebar-item-btn.danger:hover) {
|
|
background-color: var(--status-danger) !important;
|
|
color: white !important;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.category-content {
|
|
max-width: 660px;
|
|
}
|
|
|
|
.content-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
margin-bottom: 24px;
|
|
max-width: 660px;
|
|
}
|
|
|
|
.content-header h2 {
|
|
margin: 0;
|
|
font-size: 1.25rem;
|
|
color: var(--header-primary);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.close-btn-fixed {
|
|
position: absolute;
|
|
top: 20px;
|
|
right: 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 8px;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
z-index: 100;
|
|
color: var(--interactive-normal);
|
|
transition: color 0.1s;
|
|
}
|
|
|
|
.close-icon-circle {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 50%;
|
|
border: 2px solid var(--interactive-normal);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 1.1rem;
|
|
transition: all 0.1s;
|
|
}
|
|
|
|
.close-text {
|
|
font-size: 0.75rem;
|
|
font-weight: 700;
|
|
color: var(--interactive-normal);
|
|
transition: color 0.1s;
|
|
}
|
|
|
|
.close-btn-fixed:hover .close-icon-circle {
|
|
background-color: var(--background-modifier-hover);
|
|
color: var(--interactive-hover);
|
|
border-color: var(--interactive-hover);
|
|
}
|
|
|
|
.close-btn-fixed:hover .close-text {
|
|
color: var(--interactive-hover);
|
|
}
|
|
|
|
/* 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-notice.error {
|
|
color: var(--status-danger);
|
|
}
|
|
|
|
.footer-actions {
|
|
display: flex;
|
|
gap: 12px;
|
|
}
|
|
|
|
@keyframes slideInUp {
|
|
from { transform: translateY(100%); opacity: 0; }
|
|
to { transform: translateY(0); opacity: 1; }
|
|
}
|
|
</style>
|