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

- 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:
Bryan1029384756
2026-04-14 09:02:14 -05:00
parent 9ef839938e
commit b7a4cf4ce8
376 changed files with 52619 additions and 167641 deletions

View 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>
);
});