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,330 @@
/**
* MobileEmojisTab — full-screen Fluxer-style mobile variant of
* EmojisTab. Layout:
*
* ┌────────── Upload Emoji ──────────┐ ← brand-primary CTA
* Add up to 50 custom emoji… helper text
* UPLOAD REQUIREMENTS section label
* ┌─────── card ──────────────┐
* │ • File type: PNG, GIF, … │
* │ • Recommended size: 256KB │
* │ • Recommended dimensions… │
* │ • Naming: 2+ chars, … │
* └────────────────────────────┘
* EMOJI — N SLOTS AVAILABLE section label
* ┌────────── card ────────────┐
* │ [img] :hazmat: ⋯ │
* │ [img] :blank: ⋯ │
* │ ... │
* └────────────────────────────┘
*
* The uploader column from the Fluxer reference is intentionally
* dropped — MSC2545's `images` dict only stores `{shortcode → mxc}`
* with no record of who added each entry, so there's nothing to
* surface. Tapping the trash icon prompts a confirm and removes.
*
* The whole tab is gated behind `EmojiPackManager.canManageEmojis`
* (native power-level check) — same gate as desktop. Non-admins
* see a friendly fallback explaining how to get access.
*/
import { observer } from 'mobx-react-lite';
import { useMemo, useRef, useState } from 'react';
import { Trash, UploadSimple } from '@phosphor-icons/react';
import {
EmojiPackManager,
MAX_EMOJIS_PER_PACK,
type CustomEmoji,
} from '@brycord/matrix-client';
import EmojiPackStore from '@app/stores/EmojiPackStore';
import { CustomEmojiImage } from '../channel/CustomEmojiImage';
import styles from './MobileEmojisTab.module.css';
interface MobileEmojisTabProps {
serverId: string;
}
const SHORTCODE_REGEX = /^[a-z0-9_]{2,30}$/;
const MAX_FILE_BYTES = 256 * 1024;
export const MobileEmojisTab = observer(function MobileEmojisTab({
serverId,
}: MobileEmojisTabProps) {
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [pendingPreviewUrl, setPendingPreviewUrl] = useState<string | null>(
null,
);
const [shortcode, setShortcode] = useState('');
const [uploading, setUploading] = useState(false);
const [deletingShortcode, setDeletingShortcode] = useState<string | null>(
null,
);
const [status, setStatus] = useState<
{ type: 'success' | 'error'; message: string } | null
>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const canManage = useMemo(() => {
try {
return EmojiPackManager.getInstance().canManageEmojis(serverId);
} catch {
return false;
}
}, [serverId]);
const pack = EmojiPackStore.getPack(serverId);
const emojis: CustomEmoji[] = pack?.emojis ?? [];
const slotsRemaining = MAX_EMOJIS_PER_PACK - emojis.length;
const isFull = slotsRemaining <= 0;
if (!canManage) {
return (
<div className={styles.gate}>
<h2 className={styles.gateTitle}>Emojis</h2>
<p className={styles.gateBody}>
You need permission to manage emojis in this server. Ask an
admin to grant you a role with a higher power level.
</p>
</div>
);
}
const handleFilePick = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
// Reset input so the same file can be picked again after clearing.
if (fileInputRef.current) fileInputRef.current.value = '';
if (!file) return;
setStatus(null);
if (!file.type.startsWith('image/')) {
setStatus({
type: 'error',
message: 'Emoji must be a PNG, GIF, WebP, or JPEG image.',
});
return;
}
if (file.size > MAX_FILE_BYTES) {
setStatus({
type: 'error',
message: 'File is too large. Maximum is 256 KB.',
});
return;
}
if (pendingPreviewUrl) URL.revokeObjectURL(pendingPreviewUrl);
setPendingFile(file);
setPendingPreviewUrl(URL.createObjectURL(file));
// Auto-suggest a shortcode from the filename, same heuristic
// the desktop tab uses.
if (!shortcode) {
const base = file.name.replace(/\.[^.]+$/, '').toLowerCase();
const cleaned = base.replace(/[^a-z0-9_]/g, '_').slice(0, 30);
if (cleaned.length >= 2) setShortcode(cleaned);
}
};
const handleUpload = async () => {
if (!pendingFile || uploading) return;
const trimmed = shortcode.trim();
if (!SHORTCODE_REGEX.test(trimmed)) {
setStatus({
type: 'error',
message:
'Shortcode must be 230 chars (lowercase letters, digits, underscores).',
});
return;
}
if (emojis.some((e) => e.shortcode === trimmed)) {
setStatus({
type: 'error',
message: `An emoji named "${trimmed}" already exists.`,
});
return;
}
setUploading(true);
setStatus(null);
try {
await EmojiPackManager.getInstance().addEmoji(serverId, trimmed, pendingFile);
if (pendingPreviewUrl) URL.revokeObjectURL(pendingPreviewUrl);
setPendingFile(null);
setPendingPreviewUrl(null);
setShortcode('');
setStatus({ type: 'success', message: `Added :${trimmed}:` });
} catch (err: any) {
setStatus({
type: 'error',
message: err?.message || 'Failed to upload emoji.',
});
} finally {
setUploading(false);
}
};
const handleClearPending = () => {
if (pendingPreviewUrl) URL.revokeObjectURL(pendingPreviewUrl);
setPendingFile(null);
setPendingPreviewUrl(null);
setShortcode('');
};
const handleDelete = async (emoji: CustomEmoji) => {
if (deletingShortcode) return;
if (!window.confirm(`Remove :${emoji.shortcode}: from this server?`)) return;
setDeletingShortcode(emoji.shortcode);
setStatus(null);
try {
await EmojiPackManager.getInstance().removeEmoji(serverId, emoji.shortcode);
setStatus({ type: 'success', message: `Removed :${emoji.shortcode}:` });
} catch (err: any) {
setStatus({
type: 'error',
message: err?.message || 'Failed to remove emoji.',
});
} finally {
setDeletingShortcode(null);
}
};
return (
<div className={styles.root}>
<button
type="button"
className={styles.uploadButton}
onClick={() => fileInputRef.current?.click()}
disabled={isFull || uploading}
>
<UploadSimple size={18} weight="bold" />
Upload Emoji
</button>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/gif,image/webp,image/jpeg"
onChange={handleFilePick}
style={{ display: 'none' }}
/>
<p className={styles.helperText}>
Add up to {MAX_EMOJIS_PER_PACK} custom emoji that anyone can use
in this server. Animated GIF and WebP emoji play automatically.
</p>
<div className={styles.sectionLabel}>Upload Requirements</div>
<div className={styles.requirementsCard}>
<ul className={styles.requirementsList}>
<li>
<strong>File type:</strong> PNG, GIF, WebP, JPEG
</li>
<li>
<strong>Maximum file size:</strong> 256 KB
</li>
<li>
<strong>Recommended dimensions:</strong> 128×128
</li>
<li>
<strong>Naming:</strong> Emoji names must be at least 2
characters long and can only contain lowercase letters,
digits, and underscores.
</li>
</ul>
</div>
{/* Inline upload form — only visible after a file has been
picked. Lets the user name and confirm the upload, or
cancel out without leaving the tab. */}
{pendingFile && (
<div className={styles.uploadForm}>
<div className={styles.uploadFormRow}>
<div className={styles.uploadPreview}>
{pendingPreviewUrl && (
<img
src={pendingPreviewUrl}
alt="pending emoji"
className={styles.uploadPreviewImage}
draggable={false}
/>
)}
</div>
<input
type="text"
className={styles.shortcodeInput}
placeholder="shortcode"
value={shortcode}
onChange={(e) =>
setShortcode(
e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''),
)
}
maxLength={30}
/>
</div>
<div className={styles.uploadFormActions}>
<button
type="button"
className={`${styles.formButton} ${styles.formButtonSecondary}`}
onClick={handleClearPending}
disabled={uploading}
>
Cancel
</button>
<button
type="button"
className={`${styles.formButton} ${styles.formButtonPrimary}`}
onClick={handleUpload}
disabled={uploading || !SHORTCODE_REGEX.test(shortcode)}
>
{uploading ? 'Uploading…' : 'Add Emoji'}
</button>
</div>
</div>
)}
{status && (
<div
className={`${styles.status} ${
status.type === 'error' ? styles.statusError : styles.statusSuccess
}`}
>
{status.message}
</div>
)}
<div className={styles.sectionLabel}>
Emoji {Math.max(0, slotsRemaining)} Slots Available
</div>
{emojis.length === 0 ? (
<div className={styles.empty}>
No custom emojis yet. Tap "Upload Emoji" to add one.
</div>
) : (
<div className={styles.emojiCard}>
{emojis.map((emoji) => (
<div key={emoji.mxcUrl} className={styles.emojiRow}>
<div className={styles.emojiThumb}>
<CustomEmojiImage
mxc={emoji.mxcUrl}
alt={`:${emoji.shortcode}:`}
title={`:${emoji.shortcode}:`}
className={styles.emojiThumbImage}
draggable={false}
/>
</div>
<span className={styles.emojiName}>:{emoji.shortcode}:</span>
<button
type="button"
className={styles.deleteButton}
onClick={() => handleDelete(emoji)}
disabled={deletingShortcode === emoji.shortcode}
aria-label={`Remove :${emoji.shortcode}:`}
>
<Trash size={18} weight="fill" />
</button>
</div>
))}
</div>
)}
</div>
);
});