Files
zep/src/chat/components/SettingsPanel.svelte
T

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>