common components

This commit is contained in:
2026-04-21 02:01:04 -04:00
parent dfea32cf23
commit 3a8c70fe12
18 changed files with 815 additions and 593 deletions
-1
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { untrack } from "svelte";
import { auth } from "./auth/auth.svelte";
import { connectionState } from "./connection.svelte";
import { connectionBuilder, getStdbHost, getStdbDbName } from "./config";
import InnerSpacetimeDBProvider from "./InnerSpacetimeDBProvider.svelte";
+7 -7
View File
@@ -5,6 +5,7 @@
import { TokenStore, getStdbHost, getStdbDbName } from "../config";
import SpacetimeProvider from "../SpacetimeProvider.svelte";
import ComboBoxInput from "../chat/components/ComboBoxInput.svelte";
import Button from "../chat/components/ui/Button.svelte";
let { children, showServerSettings, onToggleServerSettings: _onToggleServerSettings } = $props<{
children: any,
@@ -157,19 +158,19 @@
{/if}
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%">
<button
<Button
onclick={() => {
userWantsToConnect = true;
auth.signinRedirect();
}}
disabled={auth.isLoading}
class="btn-primary"
style="width: 100%"
>
{auth.isLoading ? "Loading..." : "Login with OIDC"}
</button>
</Button>
<button
<Button
variant="secondary"
onclick={() => {
console.log("AuthGate: Reconnect/Guest clicked. hasStoredToken:", hasStoredToken);
userWantsToConnect = true;
@@ -178,16 +179,15 @@
}
_onToggleServerSettings(false);
}}
class="btn-secondary"
style="width: 100%"
>
{hasStoredToken ? "Reconnect as last user" : "Connect as guest"}
</button>
</Button>
</div>
<div class="server-settings-section" style="margin-top: 24px; width: 100%; border-top: 1px solid var(--background-modifier-accent);">
<div style="padding-top: 16px; display: flex; flex-direction: column; gap: 16px;">
<div class="form-group" style="display: flex; flex-direction: column; gap: 4px;">
<div style="display: flex; flex-direction: column; gap: 4px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<label for="stdb-connection" style="font-size: 0.7rem; font-weight: bold; color: var(--text-muted); text-transform: uppercase; text-align: left; width: 100%;">
Instance hostname:database
+19 -94
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import Modal from "./Modal.svelte";
import ModalLayout from "./ui/ModalLayout.svelte";
import Button from "./ui/Button.svelte";
let {
title = "Confirm",
@@ -18,110 +20,33 @@
onConfirm: () => void;
onCancel: () => void;
} = $props();
function handleCancel() {
onCancel();
}
function handleConfirm() {
onConfirm();
}
</script>
<Modal onClose={handleCancel}>
<div class="confirm-modal-content">
<div class="modal-header">
<h2>{title}</h2>
</div>
<Modal onClose={onCancel}>
<ModalLayout {title} onClose={onCancel}>
<p class="confirm-message">{message}</p>
<div class="modal-body">
<p>{message}</p>
</div>
<div class="modal-footer">
<button class="btn-ghost" onclick={handleCancel}>
{#snippet footer()}
<Button variant="ghost" onclick={onCancel}>
{cancelText}
</button>
<button
class="btn-primary {isDanger ? 'danger' : ''}"
onclick={handleConfirm}
</Button>
<Button
variant={isDanger ? "danger" : "primary"}
onclick={onConfirm}
>
{confirmText}
</button>
</div>
</div>
</Button>
{/snippet}
</ModalLayout>
</Modal>
<style>
.confirm-modal-content {
width: 100%;
}
.modal-header {
padding: 24px 16px 16px;
text-align: center;
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
color: var(--header-primary);
font-weight: 700;
}
.modal-body {
padding: 0 16px 24px;
text-align: center;
}
.modal-body p {
.confirm-message {
margin: 0;
color: var(--text-normal);
font-size: 0.95rem;
line-height: 1.4;
}
.modal-footer {
background-color: var(--background-secondary);
padding: 16px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
button {
padding: 10px 24px;
border-radius: 4px;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
border: none;
transition: background-color 0.2s;
}
.btn-ghost {
background: transparent;
color: var(--text-normal);
}
.btn-ghost:hover {
text-decoration: underline;
}
.btn-primary {
background-color: var(--brand);
color: white;
}
.btn-primary:hover {
background-color: var(--brand-hover);
}
.btn-primary.danger {
background-color: var(--status-danger);
}
.btn-primary.danger:hover {
background-color: #d83a3d;
font-size: 1rem;
line-height: 1.5;
text-align: center;
padding-top: 8px;
}
</style>
-3
View File
@@ -1,7 +1,4 @@
<script lang="ts">
import { portal } from "../../portal";
import { onMount } from "svelte";
let {
onClose,
children,
+1 -1
View File
@@ -151,7 +151,7 @@
<div class="rich-text">
<div class="text-content">
{#each parts as part, index}
{#each parts as part, _index}
{#if part.match(urlRegex)}
<a href={part} target="_blank" rel="noopener noreferrer" class="url-link">
{part}
+13 -94
View File
@@ -2,6 +2,9 @@
import { getContext } from "svelte";
import type { ChatService } from "../services/chat.svelte";
import Modal from "./Modal.svelte";
import ModalLayout from "./ui/ModalLayout.svelte";
import Input from "./ui/Input.svelte";
import Button from "./ui/Button.svelte";
const chat = getContext<ChatService>("chat");
@@ -15,28 +18,14 @@
</script>
<Modal onClose={() => (chat.showDiscoveryModal = false)} maxWidth="500px">
<div class="discovery-modal">
<div class="modal-header">
<h2>Discover Servers</h2>
<button
type="button"
class="close-btn"
onclick={() => (chat.showDiscoveryModal = false)}
aria-label="Close"
>
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<input
<ModalLayout title="Discover Servers" onClose={() => (chat.showDiscoveryModal = false)}>
<div class="discovery-content">
<Input
id="server-search"
name="server-search"
type="text"
autofocus
placeholder="Search for servers..."
bind:value={searchTerm}
class="search-input"
style="margin-bottom: 16px;"
/>
<div class="server-list-container">
@@ -53,75 +42,26 @@
</div>
<span class="server-name">{server.name}</span>
</div>
<button
class="btn-primary"
<Button
onclick={() => chat.handleJoinServer(server.id)}
disabled={!chat.isFullyAuthenticated}
size="small"
>
Join
</button>
</Button>
</div>
{/each}
{/if}
</div>
</div>
</div>
</ModalLayout>
</Modal>
<style>
.discovery-modal {
.discovery-content {
display: flex;
flex-direction: column;
max-height: 80vh;
}
.modal-header {
padding: 24px 16px 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
color: var(--header-primary);
font-weight: 700;
}
.close-btn {
background: none;
border: none;
color: var(--interactive-normal);
cursor: pointer;
font-size: 1.25rem;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.close-btn:hover {
color: var(--interactive-hover);
}
.modal-body {
padding: 0 16px 24px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.search-input {
margin-bottom: 16px;
width: 100%;
box-sizing: border-box;
padding: 10px;
background-color: var(--background-tertiary);
color: var(--text-normal);
border: 1px solid var(--background-modifier-accent);
border-radius: 4px;
max-height: 60vh;
}
.server-list-container {
@@ -170,25 +110,4 @@
font-weight: bold;
color: var(--header-primary);
}
.btn-primary {
background-color: var(--brand);
color: white;
padding: 8px 16px;
border-radius: 4px;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
border: none;
transition: background-color 0.2s;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--brand-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
+95 -193
View File
@@ -2,7 +2,9 @@
import { getContext } from "svelte";
import type { ChatService } from "../services/chat.svelte";
import { optimizeEmoji } from "../utils";
import { portal } from "../../portal";
import Button from "./ui/Button.svelte";
import Input from "./ui/Input.svelte";
import Switch from "./ui/Switch.svelte";
let { onClose } = $props<{ onClose: () => void }>();
@@ -111,45 +113,53 @@
</button>
{/each}
<div class="sidebar-separator"></div>
<button class="sidebar-item danger" onclick={() => {
chat.confirmModal = {
title: "Leave Server",
message: `Are you sure you want to leave '${server?.name}'? You will need an invite to join again.`,
confirmText: "Leave Server",
cancelText: "Cancel",
isDanger: true,
onConfirm: () => {
if (chat.activeServerId) {
chat.handleLeaveServer(chat.activeServerId);
onClose();
<Button
variant="danger"
size="medium"
class="sidebar-item-btn"
onclick={() => {
chat.confirmModal = {
title: "Leave Server",
message: `Are you sure you want to leave '${server?.name}'? You will need an invite to join again.`,
confirmText: "Leave Server",
cancelText: "Cancel",
isDanger: true,
onConfirm: () => {
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={() => {
chat.confirmModal = {
title: `Delete '${server?.name}'`,
message: `Are you sure you want to delete ${server?.name}? This action is permanent and will delete all channels, messages, and associated data. This cannot be undone.`,
confirmText: "Delete Server",
cancelText: "Cancel",
isDanger: true,
onConfirm: async () => {
if (server) {
chat.handleDeleteServer(server.id);
onClose();
</Button>
<Button
variant="danger"
size="medium"
class="sidebar-item-btn"
onclick={() => {
chat.confirmModal = {
title: `Delete '${server?.name}'`,
message: `Are you sure you want to delete ${server?.name}? This action is permanent and will delete all channels, messages, and associated data. This cannot be undone.`,
confirmText: "Delete Server",
cancelText: "Cancel",
isDanger: true,
onConfirm: async () => {
if (server) {
chat.handleDeleteServer(server.id);
onClose();
}
}
}
};
}}>
};
}}
>
<i class="fas fa-trash-alt"></i>
Delete Server
</button>
</div>
</Button>
</div>
<div class="settings-main">
<div class="settings-content">
<div class="content-header">
@@ -179,7 +189,7 @@
{/if}
</div>
{#if isOwner}
<label class="btn-primary" style="margin-top: 12px; font-size: 0.8rem; padding: 6px 12px; cursor: pointer;">
<label class="btn primary small" style="margin-top: 12px; cursor: pointer;">
Change Avatar
<input type="file" accept="image/*" onchange={handleAvatarChange} disabled={isUploading} style="display: none;" />
</label>
@@ -187,33 +197,20 @@
</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>
<Input
id="server-name"
label="Server Name"
bind:value={serverName}
disabled={!isOwner || isUploading}
/>
<div class="form-group" style="margin-top: 24px;">
<label style="display: flex; align-items: center; justify-content: space-between; cursor: pointer;">
<div style="display: flex; flex-direction: column; gap: 4px;">
<span>Public Server</span>
<span style="font-size: 0.75rem; color: var(--text-muted); text-transform: none;">Whether this server is visible in the Discovery tab.</span>
</div>
<div class="toggle-switch">
<input
type="checkbox"
bind:checked={isPublic}
disabled={!isOwner}
/>
<span class="slider"></span>
</div>
</label>
</div>
<Switch
label="Public Server"
description="Whether this server is visible in the Discovery tab."
bind:checked={isPublic}
disabled={!isOwner || isUploading}
style="margin-top: 24px;"
/>
</div>
</div>
@@ -237,7 +234,7 @@
{/if}
</div>
<div class="footer-actions">
<button class="btn-ghost" onclick={() => {
<Button variant="ghost" onclick={() => {
if (server) {
serverName = server.name;
isPublic = server.public;
@@ -245,10 +242,15 @@
newAvatarFile = null;
errorMessage = null;
}
}}>Reset</button>
<button class="btn-success" onclick={handleSave} disabled={isUploading}>
{isUploading ? "Saving..." : "Save Changes"}
</button>
}}>Reset</Button>
<Button
variant="success"
onclick={handleSave}
loading={isUploading}
disabled={isUploading}
>
Save Changes
</Button>
</div>
</div>
{/if}
@@ -352,6 +354,33 @@
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;
@@ -427,89 +456,6 @@
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;
}
/* Toggle Switch */
.toggle-switch {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #72767d;
transition: .2s;
border-radius: 22px;
}
.slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .2s;
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--status-positive);
}
input:focus + .slider {
box-shadow: 0 0 1px var(--status-positive);
}
input:checked + .slider:before {
transform: translateX(18px);
}
/* Footer */
.settings-footer {
position: absolute;
@@ -541,35 +487,6 @@
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);
@@ -606,21 +523,6 @@
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; }
+47 -47
View File
@@ -11,6 +11,7 @@
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();
@@ -166,19 +167,24 @@
</button>
{/each}
<div class="sidebar-separator"></div>
<button class="sidebar-item danger" 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()
};
}}>
<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>
</Button>
</div>
<div class="settings-main">
@@ -225,7 +231,7 @@
{/if}
</div>
<div class="footer-actions">
<button class="btn-ghost" onclick={() => {
<Button variant="ghost" onclick={() => {
localName = currentUser?.name || "";
localStatus = currentUser?.status || "";
biography = currentUser?.biography || "";
@@ -234,8 +240,8 @@
newAvatarFile = null;
newBannerFile = null;
errorMessage = null;
}}>Reset</button>
<button class="btn-success" onclick={handleSave}>Save Changes</button>
}}>Reset</Button>
<Button variant="success" onclick={handleSave}>Save Changes</Button>
</div>
</div>
{/if}
@@ -321,21 +327,39 @@
color: var(--interactive-active);
}
.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;
}
: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;
@@ -404,30 +428,6 @@
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 {
opacity: 0.9;
}
.btn-ghost {
background: none;
border: none;
color: white;
padding: 8px 16px;
cursor: pointer;
font-weight: 500;
}
@keyframes slideInUp {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
@@ -2,6 +2,8 @@
import { getContext } from "svelte";
import type { ChatService } from "../../services/chat.svelte";
import type * as Types from "../../../module_bindings/types";
import Input from "../ui/Input.svelte";
import Button from "../ui/Button.svelte";
let {
localName = $bindable(),
@@ -86,58 +88,54 @@
</div>
<div class="account-edit-box">
<div class="form-group">
<label for="display-name">Display Name</label>
<div class="input-wrapper">
<input
id="display-name"
type="text"
bind:value={localName}
placeholder="Enter your display name"
/>
<button class="btn-clear" onclick={() => localName = ""} aria-label="Clear name">
<div class="edit-group">
<Input
id="display-name"
label="Display Name"
bind:value={localName}
placeholder="Enter your display name"
/>
{#if localName}
<Button variant="clear" class="clear-btn" onclick={() => localName = ""} title="Clear name">
<i class="fas fa-times-circle"></i>
</button>
</div>
</Button>
{/if}
</div>
<div class="form-group">
<label for="user-status">Status</label>
<div class="input-wrapper">
<input
id="user-status"
type="text"
bind:value={localStatus}
placeholder="Set a status"
maxlength="128"
/>
<button class="btn-clear" onclick={() => localStatus = ""} aria-label="Clear status">
<div class="edit-group">
<Input
id="user-status"
label="Status"
bind:value={localStatus}
placeholder="Set a status"
maxlength="128"
/>
{#if localStatus}
<Button variant="clear" class="clear-btn" onclick={() => localStatus = ""} title="Clear status">
<i class="fas fa-times-circle"></i>
</button>
</div>
</Button>
{/if}
</div>
<div class="form-group">
<label for="biography">Biography</label>
<textarea
id="biography"
bind:value={biography}
placeholder="Tell us about yourself..."
rows="4"
maxlength="200"
></textarea>
<div class="textarea-footer">{biography.length}/200</div>
</div>
<Input
id="biography"
type="textarea"
label="Biography"
bind:value={biography}
placeholder="Tell us about yourself..."
description={`${biography.length}/200`}
class="bio-input"
/>
<div class="avatar-actions">
<button class="btn-secondary small" onclick={() => {
<Button variant="secondary" size="small" onclick={() => {
avatarPreview = null;
newAvatarFile = null;
}}>Remove Avatar</button>
<button class="btn-secondary small" onclick={() => {
}}>Remove Avatar</Button>
<Button variant="secondary" size="small" onclick={() => {
bannerPreview = null;
newBannerFile = null;
}}>Remove Banner</button>
}}>Remove Banner</Button>
</div>
</div>
</div>
@@ -269,58 +267,34 @@
margin: 0 16px 16px 16px;
padding: 16px;
border-radius: 8px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
gap: 16px;
}
.form-group label {
font-size: 0.75rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
}
.input-wrapper {
.edit-group {
position: relative;
display: flex;
align-items: center;
background-color: var(--background-primary);
border-radius: 4px;
padding-right: 8px;
width: 100%;
box-sizing: border-box;
}
.form-group input[type="text"], .form-group textarea {
padding: 10px;
background-color: var(--background-primary);
color: var(--text-normal);
border: none;
font-size: 1rem;
outline: none;
border-radius: 4px;
font-family: inherit;
:global(.clear-btn) {
position: absolute;
right: 8px;
top: 32px; /* Position it correctly relative to the label and input */
padding: 4px !important;
font-size: 1.1rem !important;
}
:global(.edit-group .form-group) {
width: 100%;
box-sizing: border-box;
margin-bottom: 0 !important;
}
.input-wrapper input[type="text"] {
flex: 1;
background-color: transparent;
}
.form-group textarea {
resize: none;
}
.textarea-footer {
text-align: right;
font-size: 0.7rem;
color: var(--text-muted);
:global(.bio-input textarea) {
height: 100px;
resize: none !important;
}
.avatar-actions {
@@ -2,6 +2,7 @@
import { getContext } from "svelte";
import type { WebRTCService } from "../../services/webrtc/webrtc.svelte";
import Dropdown from "../Dropdown.svelte";
import Button from "../ui/Button.svelte";
const webrtc = getContext<WebRTCService>("webrtc");
@@ -35,13 +36,14 @@
<div class="voice-sensitivity-box">
<div class="label-with-action">
<label for="voice-threshold-slider">Input Sensitivity</label>
<button
class="test-mic-btn {webrtc.isTestingMic ? 'active' : ''}"
<Button
variant={webrtc.isTestingMic ? "danger" : "primary"}
size="small"
onclick={() => webrtc.toggleMicTest()}
>
<i class="fas {webrtc.isTestingMic ? 'fa-stop' : 'fa-microphone'}"></i>
{webrtc.isTestingMic ? "Stop Testing" : "Test Mic"}
</button>
</Button>
</div>
<div class="voice-meter-container">
@@ -144,22 +146,6 @@
z-index: 2;
}
.test-mic-btn {
background-color: var(--brand);
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.1s;
}
.test-mic-btn.active {
background-color: var(--status-danger);
}
.threshold-slider {
width: 100%;
cursor: pointer;
@@ -2,6 +2,8 @@
import { getContext } from "svelte";
import type { ChatService } from "../../services/chat.svelte";
import { optimizeEmoji, getCustomEmojiUrl } from "../../utils";
import Button from "../ui/Button.svelte";
import Input from "../ui/Input.svelte";
const chat = getContext<ChatService>("chat");
@@ -138,20 +140,22 @@
<div class="emoji-upload-form-box">
<form class="emoji-upload-form" onsubmit={handleEmojiSubmit}>
<div class="form-row">
<div class="form-group" style="flex: 1; margin-bottom: 0;">
<label for="emoji-name">Emoji Name</label>
<div class="input-wrapper">
<span class="emoji-prefix">:</span>
<input
id="emoji-name"
type="text"
bind:value={newEmojiName}
placeholder="emoji-name"
/>
<span class="emoji-suffix">:</span>
</div>
<div style="flex: 1;">
<Input
id="emoji-name"
label="Emoji Name"
bind:value={newEmojiName}
placeholder="emoji-name"
>
{#snippet prefix()}
<span class="emoji-prefix">:</span>
{/snippet}
{#snippet suffix()}
<span class="emoji-suffix">:</span>
{/snippet}
</Input>
</div>
<div class="form-group" style="margin-bottom: 0;">
<div class="form-group">
<label>Emoji Image</label>
<label class="custom-file-upload">
<i class="fas fa-image"></i>
@@ -159,14 +163,15 @@
<input type="file" accept="image/*" onchange={onEmojiFileChange} style="display: none;" />
</label>
</div>
<button
<Button
type="submit"
class="btn-success"
variant="success"
style="align-self: flex-end; height: 38px; padding: 0 20px;"
disabled={!newEmojiFile || !newEmojiName.trim() || isEmojiUploading}
loading={isEmojiUploading}
>
{isEmojiUploading ? "Uploading..." : "Upload"}
</button>
Upload
</Button>
</div>
{#if emojiError}
<div class="form-error" style="color: var(--status-danger); font-size: 0.75rem; margin-top: 8px;">
@@ -1,6 +1,8 @@
<script lang="ts">
import { getContext } from "svelte";
import type { ChatService } from "../../services/chat.svelte";
import Button from "../ui/Button.svelte";
import Input from "../ui/Input.svelte";
const chat = getContext<ChatService>("chat");
@@ -51,9 +53,9 @@
<label>Your Public Key</label>
<div class="key-display">
<pre>{chat.myPublicKey}</pre>
<button class="btn-secondary btn-small" onclick={copyPublicKey}>
<Button variant="secondary" size="small" class="copy-btn" onclick={copyPublicKey}>
<i class="far fa-copy"></i> Copy
</button>
</Button>
</div>
<p class="help-text">Share this key with others so they can send you encrypted messages (automatically handled by Zep).</p>
</div>
@@ -61,9 +63,9 @@
<div class="danger-zone">
<h4>Danger Zone</h4>
<p>Generating a new key will prevent you from reading old encrypted messages.</p>
<button class="btn-danger" onclick={() => chat.generateEncryptionKey(name, email)} disabled={isGenerating}>
<Button variant="danger" onclick={() => chat.generateEncryptionKey(name, email)} loading={isGenerating}>
Regenerate Keys
</button>
</Button>
</div>
{:else}
<div class="key-status-card missing">
@@ -78,21 +80,29 @@
<div class="setup-form shadow-box">
<h4>Generate New Keypair</h4>
<div class="form-group">
<label for="key-name">Full Name</label>
<input id="key-name" type="text" bind:value={name} placeholder="e.g. Alice Smith" />
</div>
<div class="form-group">
<label for="key-email">Email Address</label>
<input id="key-email" type="email" bind:value={email} placeholder="alice@example.com" />
</div>
<button class="btn-primary" onclick={handleGenerate} disabled={isGenerating || !name || !email}>
{#if isGenerating}
<i class="fas fa-spinner fa-spin"></i> Generating...
{:else}
Generate Keys
{/if}
</button>
<Input
id="key-name"
label="Full Name"
bind:value={name}
placeholder="e.g. Alice Smith"
style="margin-bottom: 16px;"
/>
<Input
id="key-email"
label="Email Address"
type="email"
bind:value={email}
placeholder="alice@example.com"
style="margin-bottom: 24px;"
/>
<Button
onclick={handleGenerate}
disabled={isGenerating || !name || !email}
loading={isGenerating}
style="width: 100%;"
>
Generate Keys
</Button>
</div>
{/if}
</div>
@@ -178,16 +188,6 @@
text-transform: uppercase;
}
.form-group input {
width: 100%;
padding: 10px;
background-color: var(--background-tertiary);
border: none;
border-radius: 4px;
color: var(--text-normal);
outline: none;
}
.key-display {
position: relative;
background-color: var(--background-secondary-alt);
@@ -207,12 +207,10 @@
word-break: break-all;
}
.key-display .btn-small {
:global(.copy-btn) {
position: absolute;
top: 8px;
right: 8px;
padding: 4px 8px;
font-size: 0.7rem;
}
.help-text {
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
let {
children,
onclick,
type = "button",
variant = "primary",
size = "medium",
disabled = false,
loading = false,
class: className = "",
style = "",
title = ""
}: {
children?: any,
onclick?: (e: MouseEvent) => void,
type?: "button" | "submit" | "reset",
variant?: "primary" | "secondary" | "ghost" | "danger" | "success" | "clear",
size?: "small" | "medium" | "large",
disabled?: boolean,
loading?: boolean,
class?: string,
style?: string,
title?: string
} = $props();
</script>
<button
{type}
{onclick}
{disabled}
{title}
class="btn {variant} {size} {className}"
{style}
>
{#if loading}
<i class="fas fa-spinner fa-spin"></i>
{:else if children}
{@render children()}
{/if}
</button>
<style>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: none;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
white-space: nowrap;
user-select: none;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Variants */
.primary {
background-color: var(--brand);
color: white;
}
.primary:hover:not(:disabled) {
background-color: var(--brand-hover);
}
.secondary {
background-color: var(--background-secondary);
color: var(--text-normal);
border: 1px solid var(--background-modifier-accent);
}
.secondary:hover:not(:disabled) {
background-color: var(--background-modifier-hover);
}
.ghost {
background: transparent;
color: var(--text-normal);
}
.ghost:hover:not(:disabled) {
background-color: var(--background-modifier-hover);
text-decoration: underline;
}
.danger {
background-color: var(--status-danger);
color: white;
}
.danger:hover:not(:disabled) {
filter: brightness(0.9);
}
.success {
background-color: var(--status-positive);
color: white;
}
.success:hover:not(:disabled) {
filter: brightness(0.9);
}
.clear {
background: transparent;
color: var(--text-muted);
padding: 0;
}
.clear:hover:not(:disabled) {
color: var(--text-normal);
}
/* Sizes */
.small {
padding: 4px 8px;
font-size: 0.8rem;
}
.medium {
padding: 10px 24px;
font-size: 0.9rem;
}
.large {
padding: 12px 32px;
font-size: 1rem;
}
</style>
+173
View File
@@ -0,0 +1,173 @@
<script lang="ts">
let {
value = $bindable(),
label,
id,
type = "text",
placeholder = "",
disabled = false,
description,
error,
autofocus = false,
prefix,
suffix,
class: className = "",
style = ""
}: {
value: string,
label?: string,
id?: string,
type?: string,
placeholder?: string,
disabled?: boolean,
description?: string,
error?: string | null,
autofocus?: boolean,
prefix?: any,
suffix?: any,
class?: string,
style?: string
} = $props();
function handleAutofocus(node: HTMLInputElement | HTMLTextAreaElement) {
if (autofocus) {
node.focus();
}
}
</script>
<div class="form-group {className}" {style}>
{#if label}
<label for={id}>
{label}
</label>
{/if}
<div class="input-container {error ? 'error' : ''} {disabled ? 'disabled' : ''}">
{#if prefix}
<div class="prefix">
{@render prefix()}
</div>
{/if}
{#if type === "textarea"}
<textarea
{id}
bind:value
{placeholder}
{disabled}
use:handleAutofocus
class="styled-input"
></textarea>
{:else}
<input
{id}
{type}
bind:value
{placeholder}
{disabled}
use:handleAutofocus
class="styled-input"
/>
{/if}
{#if suffix}
<div class="suffix">
{@render suffix()}
</div>
{/if}
</div>
{#if description && !error}
<p class="description">{description}</p>
{/if}
{#if error}
<p class="error-message">{error}</p>
{/if}
</div>
<style>
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
label {
font-size: 0.75rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
}
.input-container {
display: flex;
align-items: center;
background-color: var(--background-tertiary);
border: 1px solid var(--background-modifier-accent);
border-radius: 4px;
transition: border-color 0.2s;
overflow: hidden;
}
.input-container:focus-within {
border-color: var(--brand);
}
.input-container.error {
border-color: var(--status-danger);
}
.input-container.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.styled-input {
flex: 1;
background: none;
border: none;
color: var(--text-normal);
padding: 10px;
font-size: 1rem;
outline: none;
font-family: inherit;
width: 100%;
box-sizing: border-box;
}
.prefix, .suffix {
display: flex;
align-items: center;
padding: 0 10px;
color: var(--text-muted);
font-weight: bold;
user-select: none;
}
.prefix {
padding-right: 0;
}
.suffix {
padding-left: 0;
}
textarea.styled-input {
resize: none;
}
.description {
font-size: 0.75rem;
color: var(--text-muted);
margin: 0;
}
.error-message {
font-size: 0.75rem;
color: var(--status-danger);
margin: 0;
}
</style>
+96
View File
@@ -0,0 +1,96 @@
<script lang="ts">
let {
title,
onClose,
children,
footer,
maxWidth = "440px",
class: className = ""
}: {
title?: string,
onClose?: () => void,
children: any,
footer?: any,
maxWidth?: string,
class?: string
} = $props();
</script>
<div class="modal-layout {className}" style="max-width: {maxWidth};">
{#if title || onClose}
<div class="modal-header">
{#if title}
<h2>{title}</h2>
{/if}
{#if onClose}
<button class="close-btn" onclick={onClose} aria-label="Close modal">
<i class="fas fa-times"></i>
</button>
{/if}
</div>
{/if}
<div class="modal-body">
{@render children()}
</div>
{#if footer}
<div class="modal-footer">
{@render footer()}
</div>
{/if}
</div>
<style>
.modal-layout {
width: 100%;
background-color: var(--background-primary);
border-radius: 8px;
overflow: hidden;
box-shadow: var(--elevation-high);
display: flex;
flex-direction: column;
}
.modal-header {
padding: 24px 16px 16px;
text-align: center;
position: relative;
}
.modal-header h2 {
margin: 0;
font-size: 1.5rem;
color: var(--header-primary);
font-weight: 700;
}
.close-btn {
position: absolute;
top: 16px;
right: 16px;
background: none;
border: none;
color: var(--interactive-normal);
cursor: pointer;
font-size: 1.2rem;
padding: 4px;
}
.close-btn:hover {
color: var(--interactive-hover);
}
.modal-body {
padding: 0 16px 24px;
overflow-y: auto;
}
.modal-footer {
background-color: var(--background-secondary);
padding: 16px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>
+123
View File
@@ -0,0 +1,123 @@
<script lang="ts">
let {
checked = $bindable(),
label,
description,
disabled = false,
class: className = "",
style = ""
}: {
checked: boolean,
label?: string,
description?: string,
disabled?: boolean,
class?: string,
style?: string
} = $props();
</script>
<div class="switch-container {className}" {style}>
<label class="switch-label-wrapper">
{#if label || description}
<div class="label-text">
{#if label}
<span class="label">{label}</span>
{/if}
{#if description}
<span class="description">{description}</span>
{/if}
</div>
{/if}
<div class="toggle-switch">
<input
type="checkbox"
bind:checked
{disabled}
/>
<span class="slider"></span>
</div>
</label>
</div>
<style>
.switch-container {
width: 100%;
}
.switch-label-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
gap: 16px;
}
.label-text {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.label {
font-size: 1rem;
color: var(--header-primary);
}
.description {
font-size: 0.75rem;
color: var(--text-muted);
}
.toggle-switch {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #72767d;
transition: .2s;
border-radius: 22px;
}
.slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .2s;
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--status-positive);
}
input:disabled + .slider {
opacity: 0.5;
cursor: not-allowed;
}
input:checked + .slider:before {
transform: translateX(18px);
}
</style>
+1 -2
View File
@@ -1,8 +1,7 @@
import { Identity } from "spacetimedb";
import { SvelteMap, SvelteSet } from "svelte/reactivity";
import { SvelteSet } from "svelte/reactivity";
import * as Types from "../../module_bindings/types";
import { getUsername, formatTime } from "../utils";
import { getConnection } from "../../config";
import { DatabaseService } from "./database.svelte";
import { NavigationService } from "./navigation.svelte";
import { ThemeService, themeService } from "./theme.svelte";
+1 -3
View File
@@ -1,8 +1,6 @@
import { tables } from "../../module_bindings";
import { useTable } from "spacetimedb/svelte";
import * as Types from "../../module_bindings/types";
import { getConnection } from "../../config";
import { untrack } from "svelte";
import type { Identity } from "spacetimedb";
export class DatabaseService {
@@ -75,7 +73,7 @@ export class DatabaseService {
return map;
});
constructor(identity: () => Identity | null) {
constructor(_identity: () => Identity | null) {
const [serversStore, serversReadyStore] = useTable(tables.visible_servers);
const [channelsStore, channelsReadyStore] = useTable(tables.visible_channels);
const [directMessagesStore] = useTable(tables.visible_direct_messages);