Files
DiscordClone/packages/shared/src/components/settings/OverviewTab.tsx
Bryan1029384756 b7a4cf4ce8
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
- Implemented Button component with various props for customization.
- Created Modal component with header, content, and footer subcomponents.
- Added Spinner component for loading indicators.
- Developed Toast component for displaying notifications.
- Introduced Tooltip component for contextual hints with keyboard shortcuts.
- Added corresponding CSS modules for styling each component.
- Updated index file to export new components.
- Configured TypeScript settings for the UI package.
2026-04-14 09:02:14 -05:00

370 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import ChannelStore from '@app/stores/ChannelStore';
import ServerStore from '@app/stores/ServerStore';
import { MatrixClientManager, SpaceManager } from '@brycord/matrix-client';
import { PencilSimple, Trash, UploadSimple } from '@phosphor-icons/react';
/**
* OverviewTab — Server Settings → Overview. Lets an admin rename
* the space and change its avatar (icon). Both writes go through
* `SpaceManager`, which sends the canonical Matrix `m.room.name`
* and `m.room.avatar` state events on the space room — so the
* change is interoperable with Element / Cinny / any other Matrix
* client viewing the same space.
*
* Renders the same form on desktop and mobile; the `mobile` prop
* just swaps the chrome to the rounded-card style the rest of the
* mobile settings tabs use. Logic is identical across both.
*
* Permission gate uses `SpaceManager.canManageSpaceProfile`, which
* checks the local user's power level against the room's
* `state_default` (and any per-event overrides) for both
* `m.room.name` and `m.room.avatar`.
*/
import { observer } from 'mobx-react-lite';
import { useEffect, useMemo, useRef, useState } from 'react';
import styles from './OverviewTab.module.css';
const AFK_TIMEOUT_OPTIONS: { label: string; value: number }[] = [
{ label: '1 minute', value: 60 * 1000 },
{ label: '5 minutes', value: 5 * 60 * 1000 },
{ label: '15 minutes', value: 15 * 60 * 1000 },
{ label: '30 minutes', value: 30 * 60 * 1000 },
{ label: '1 hour', value: 60 * 60 * 1000 },
];
interface OverviewTabProps {
serverId: string;
mobile?: boolean;
}
function getInitials(name: string): string {
return name
.split(/\s+/)
.map((w) => w[0])
.join('')
.slice(0, 2)
.toUpperCase();
}
export const OverviewTab = observer(function OverviewTab({ serverId, mobile = false }: OverviewTabProps) {
const server = ServerStore.getServer(serverId);
const canManage = useMemo(() => {
try {
return SpaceManager.getInstance().canManageSpaceProfile(serverId);
} catch {
return false;
}
}, [serverId]);
// Form state — seeded from the live server snapshot. We track a
// "pending" file separately from the committed avatar so the user
// can preview their pick before tapping Save.
const [name, setName] = useState(server?.name ?? '');
const [pendingIconFile, setPendingIconFile] = useState<File | null>(null);
const [pendingPreviewUrl, setPendingPreviewUrl] = useState<string | null>(null);
// `removeIcon` is a flag set when the user clicks "Remove Icon" so
// we can send an empty `m.room.avatar` content on save without
// also wiping a freshly-picked file.
const [removeIcon, setRemoveIcon] = useState(false);
// AFK settings. `null` means "no AFK channel configured".
// `initialAfk*` tracks the committed values so we can diff on save.
const [afkChannelId, setAfkChannelId] = useState<string | null>(null);
const [afkTimeoutMs, setAfkTimeoutMs] = useState<number>(5 * 60 * 1000);
const [initialAfkChannelId, setInitialAfkChannelId] = useState<string | null>(null);
const [initialAfkTimeoutMs, setInitialAfkTimeoutMs] = useState<number>(5 * 60 * 1000);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Reset state whenever the server name in the store changes
// underneath us (e.g. another client renamed it).
useEffect(() => {
setName(server?.name ?? '');
}, [server?.name]);
// Load the currently-configured AFK settings when the server
// switches. Reads synchronously from the space's in-memory
// state event — no network round-trip.
useEffect(() => {
const current = SpaceManager.getInstance().getAfkSettings(serverId);
const channelId = current?.channelId ?? null;
const timeoutMs = current?.timeoutMs ?? 5 * 60 * 1000;
setAfkChannelId(channelId);
setAfkTimeoutMs(timeoutMs);
setInitialAfkChannelId(channelId);
setInitialAfkTimeoutMs(timeoutMs);
}, [serverId]);
// Cleanup the temporary preview URL when the component unmounts
// or the user picks a different file.
useEffect(() => {
return () => {
if (pendingPreviewUrl) URL.revokeObjectURL(pendingPreviewUrl);
};
}, [pendingPreviewUrl]);
if (!server) {
return (
<div className={styles.gate}>
<h2 className={styles.gateTitle}>Server not found</h2>
<p className={styles.gateBody}>This server is no longer available.</p>
</div>
);
}
if (!canManage) {
return (
<div className={styles.gate}>
<h2 className={styles.gateTitle}>Overview</h2>
<p className={styles.gateBody}>
You need permission to manage this server. Ask an admin to grant you a role with a higher power level.
</p>
</div>
);
}
const trimmedName = name.trim();
const nameDirty = trimmedName !== (server.name ?? '');
const iconDirty = pendingIconFile !== null || removeIcon;
const afkDirty = afkChannelId !== initialAfkChannelId || afkTimeoutMs !== initialAfkTimeoutMs;
const isDirty = nameDirty || iconDirty || afkDirty;
const nameValid = trimmedName.length > 0 && trimmedName.length <= 100;
const canSave = isDirty && nameValid && !saving;
// List of voice channels in this server for the AFK dropdown.
// Filtered live from ChannelStore so newly-created voice channels
// appear without needing a remount.
const voiceChannels = ChannelStore.getVoiceChannels(serverId);
const handlePickFile = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (fileInputRef.current) fileInputRef.current.value = '';
if (!file) return;
setStatus(null);
if (!file.type.startsWith('image/')) {
setStatus({
type: 'error',
message: 'Server icon must be an image file.',
});
return;
}
if (file.size > 10 * 1024 * 1024) {
setStatus({
type: 'error',
message: 'Server icon must be smaller than 10 MB.',
});
return;
}
if (pendingPreviewUrl) URL.revokeObjectURL(pendingPreviewUrl);
setPendingIconFile(file);
setPendingPreviewUrl(URL.createObjectURL(file));
// Picking a new file overrides any pending "remove" flag.
setRemoveIcon(false);
};
const handleRemoveIcon = () => {
if (pendingPreviewUrl) URL.revokeObjectURL(pendingPreviewUrl);
setPendingIconFile(null);
setPendingPreviewUrl(null);
setRemoveIcon(true);
setStatus(null);
};
const handleSave = async () => {
if (!canSave) return;
setSaving(true);
setStatus(null);
try {
const sm = SpaceManager.getInstance();
// Avatar mutation first so the optimistic store update at
// the end has both fields in their final state.
let newIconHttp: string | undefined = server.icon;
if (pendingIconFile) {
const mxc = await sm.setSpaceAvatar(serverId, pendingIconFile);
if (mxc) {
// Match the same dimensioned thumbnail SpaceManager
// produces so the local server entry stays in sync
// with what a fresh sync would compute.
const client = MatrixClientManager.getInstance().getClient();
newIconHttp = client.mxcUrlToHttp(mxc, 128, 128, 'crop') ?? undefined;
}
} else if (removeIcon) {
await sm.setSpaceAvatar(serverId, null);
newIconHttp = undefined;
}
if (nameDirty) {
await sm.renameSpace(serverId, trimmedName);
}
if (afkDirty) {
await sm.setAfkSettings(serverId, afkChannelId, afkTimeoutMs);
setInitialAfkChannelId(afkChannelId);
setInitialAfkTimeoutMs(afkTimeoutMs);
}
// Optimistic local update so the sidebar / hero refresh
// instantly instead of waiting for the next sync tick.
ServerStore.handleServerUpdate({
...server,
name: nameDirty ? trimmedName : server.name,
icon: newIconHttp,
});
if (pendingPreviewUrl) URL.revokeObjectURL(pendingPreviewUrl);
setPendingIconFile(null);
setPendingPreviewUrl(null);
setRemoveIcon(false);
setStatus({ type: 'success', message: 'Saved.' });
} catch (err: any) {
setStatus({
type: 'error',
message: err?.message || 'Failed to save changes.',
});
} finally {
setSaving(false);
}
};
// Decide which preview src to render: pending file > current
// server icon > nothing (initials fallback). When the user has
// queued an icon removal, force the initials path.
const showInitials = removeIcon || (!pendingPreviewUrl && !server.icon);
const previewSrc = pendingPreviewUrl ?? server.icon;
const rootClass = mobile ? `${styles.root} ${styles.mobileRoot}` : styles.root;
const iconBlockClass = mobile ? `${styles.iconBlock} ${styles.mobileIconBlock}` : styles.iconBlock;
const fieldLabelClass = mobile ? `${styles.fieldLabel} ${styles.mobileFieldLabel}` : styles.fieldLabel;
const saveButtonClass = mobile ? `${styles.saveButton} ${styles.mobileSaveButton}` : styles.saveButton;
const actionsClass = mobile ? `${styles.actions} ${styles.mobileActions}` : styles.actions;
return (
<div className={rootClass}>
{!mobile && <h2 className={styles.heading}>Overview</h2>}
<div className={iconBlockClass}>
<div className={styles.iconPreviewWrap}>
{showInitials ? (
<div className={styles.iconInitials}>{getInitials(server.name)}</div>
) : (
<img src={previewSrc} alt={server.name} className={styles.iconPreview} draggable={false} />
)}
<div className={styles.iconBadge}>
<PencilSimple size={12} weight="bold" />
</div>
</div>
<div className={styles.iconActions}>
<p className={styles.iconActionsHelp}>
We recommend an image of at least 512×512 JPEG, PNG, GIF, or WebP, up to 10 MB.
</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button type="button" className={styles.iconButton} onClick={handlePickFile} disabled={saving}>
<UploadSimple size={14} weight="bold" />
{server.icon || pendingPreviewUrl ? 'Change Icon' : 'Upload Icon'}
</button>
{(server.icon || pendingPreviewUrl) && (
<button
type="button"
className={`${styles.iconButton} ${styles.iconButtonDanger}`}
onClick={handleRemoveIcon}
disabled={saving}
>
<Trash size={14} weight="bold" />
Remove
</button>
)}
</div>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<div>
<div className={fieldLabelClass}>Server Name</div>
{mobile ? (
<div className={styles.mobileNameCard}>
<input
type="text"
className={styles.nameInput}
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={100}
placeholder="My Server"
/>
</div>
) : (
<input
type="text"
className={styles.nameInput}
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={100}
placeholder="My Server"
/>
)}
</div>
<div className={styles.idleSection}>
<div className={styles.idleHeading}>Idle Settings</div>
<p className={styles.idleDescription}>
Move members to an AFK voice channel when they go idle. They'll be automatically muted on move.
</p>
<div className={styles.idleRow}>
<div className={styles.idleField}>
<div className={fieldLabelClass}>AFK / Idle Channel</div>
<select
className={styles.idleSelect}
value={afkChannelId ?? ''}
onChange={(e) => setAfkChannelId(e.target.value || null)}
>
<option value="">No AFK Channel</option>
{voiceChannels.map((ch) => (
<option key={ch.id} value={ch.id}>
{ch.name}
</option>
))}
</select>
</div>
<div className={styles.idleField}>
<div className={fieldLabelClass}>AFK Timeout</div>
<select
className={styles.idleSelect}
value={afkTimeoutMs}
onChange={(e) => setAfkTimeoutMs(Number(e.target.value))}
>
{AFK_TIMEOUT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
</div>
{status && (
<div className={`${styles.status} ${status.type === 'error' ? styles.statusError : styles.statusSuccess}`}>
{status.message}
</div>
)}
<div className={actionsClass}>
<button type="button" className={saveButtonClass} onClick={handleSave} disabled={!canSave}>
{saving ? 'Saving' : 'Save Changes'}
</button>
</div>
</div>
);
});