feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- 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.
This commit is contained in:
369
packages/shared/src/components/settings/OverviewTab.tsx
Normal file
369
packages/shared/src/components/settings/OverviewTab.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user