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,268 @@
/* ── Crop Avatar modal body ──────────────────────────────────
Chrome (backdrop, centring, header) comes from Modal.Root.
This module styles the editor viewport, the zoom row, and
the footer actions. */
.body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 0;
}
.description {
margin: 0;
font-size: 0.875rem;
line-height: 1.4;
color: var(--text-secondary);
}
/* ── Editor viewport ─────────────────────────────────────────
Fixed-size square container. The image is absolutely
positioned inside and transformed via JS state (pan / zoom /
rotate). The circular crop mask is a sibling element that
casts a huge outer box-shadow to dim everything outside the
ring. */
.editor {
position: relative;
align-self: center;
background-color: #000;
border-radius: 8px;
overflow: hidden;
cursor: grab;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.editor:active {
cursor: grabbing;
}
.editorImage {
position: absolute;
left: 50%;
top: 50%;
transform-origin: center center;
pointer-events: none;
max-width: none;
max-height: none;
/* Prevent the browser from smoothing natural-size pixels
into a blur when the user zooms hard — on a 512px output
we'd rather have slightly crunchy pixels than mush. */
image-rendering: auto;
}
.cropMask {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.85);
box-sizing: border-box;
pointer-events: none;
/* A massive outer box-shadow acts as the "outside the
circle is dark" overlay. 9999px is hand-wavy but it only
needs to exceed the max viewport dimension to look clean,
which it always will. */
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.6);
}
/* ── Zoom row ────────────────────────────────────────────── */
.zoomRow {
display: flex;
align-items: center;
gap: 12px;
}
.zoomLabel {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-primary-muted, #a0a3a8);
flex-shrink: 0;
}
.zoomSlider {
flex: 1;
height: 4px;
-webkit-appearance: none;
appearance: none;
background-color: var(--background-modifier-accent);
border-radius: 999px;
cursor: pointer;
outline: none;
}
.zoomSlider:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.zoomSlider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--brand-primary);
cursor: pointer;
border: none;
}
.zoomSlider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--brand-primary);
cursor: pointer;
border: none;
}
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
padding: 0;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-primary-muted, #a0a3a8);
cursor: pointer;
transition: background-color 0.12s, color 0.12s;
}
.iconButton:hover:not(:disabled) {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.iconButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Inline error row ────────────────────────────────────── */
.error {
padding: 10px 14px;
background-color: hsl(0, calc(60% * var(--saturation-factor, 1)), 22%);
color: hsl(0, calc(80% * var(--saturation-factor, 1)), 85%);
border-radius: 6px;
font-size: 0.8125rem;
}
/* ── Footer action row ────────────────────────────────────
Reset sits hard-left, a flex spacer pushes Cancel / Skip /
Save to the right end. Matches the Fluxer reference image
exactly. */
.actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.actionsSpacer {
flex: 1;
}
.resetButton {
height: 40px;
padding: 0 18px;
background: var(--background-secondary-alt);
border: none;
border-radius: 6px;
color: var(--text-primary);
font: inherit;
font-size: 0.875rem;
font-weight: 700;
cursor: pointer;
transition: background-color 0.12s;
}
.resetButton:hover:not(:disabled) {
background-color: var(--background-modifier-hover);
}
.resetButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.secondaryButton {
height: 40px;
padding: 0 18px;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-primary);
font: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.12s;
}
.secondaryButton:hover:not(:disabled) {
background-color: var(--background-modifier-hover);
}
.tertiaryButton {
height: 40px;
padding: 0 18px;
background: var(--background-secondary-alt);
border: none;
border-radius: 6px;
color: var(--text-primary);
font: inherit;
font-size: 0.875rem;
font-weight: 700;
cursor: pointer;
transition: background-color 0.12s;
}
.tertiaryButton:hover:not(:disabled) {
background-color: var(--background-modifier-hover);
}
.primaryButton {
height: 40px;
padding: 0 20px;
background-color: var(--brand-primary);
border: none;
border-radius: 6px;
color: var(--text-on-brand-primary, #fff);
font: inherit;
font-size: 0.875rem;
font-weight: 700;
cursor: pointer;
transition: filter 0.12s;
}
.primaryButton:hover:not(:disabled) {
filter: brightness(1.08);
}
.primaryButton:active:not(:disabled) {
filter: brightness(0.92);
}
.primaryButton:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.secondaryButton:disabled,
.tertiaryButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -0,0 +1,468 @@
/**
* AvatarCropModal — opened after the user picks an avatar file in
* the profile settings. Lets them drag to reposition + zoom the
* image inside a circular crop frame before we upload the result.
*
* Output is a square PNG (default 512×512) of the visible crop
* region. We output PNG instead of re-encoding the source format
* so transparent avatars don't get a white background and so the
* alpha channel (transparent PNG, GIF frame 0) round-trips.
*
* The modal is built on the shared `Modal` primitive, so backdrop
* chrome + Esc handling + portal routing come for free. Drag and
* zoom are handled with pointer events (no external dependency)
* so we can ship this inside the existing Vite bundle without
* adding react-easy-crop or react-image-crop to the dep tree.
*/
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type WheelEvent as ReactWheelEvent,
} from 'react';
import { Modal } from '@discord-clone/ui';
import {
ArrowClockwise,
FrameCorners,
} from '@phosphor-icons/react';
import styles from './AvatarCropModal.module.css';
const EDITOR_SIZE = 360; // CSS pixels of the square editor viewport
const OUTPUT_SIZE = 512; // PNG pixels of the exported crop
const ZOOM_MIN = 1;
const ZOOM_MAX = 4;
const ZOOM_STEP = 0.05;
interface AvatarCropModalProps {
isOpen: boolean;
onClose: () => void;
/** The raw File the user picked — gets decoded into an Image
* and rendered inside the editor. */
file: File | null;
/** Called with the cropped PNG Blob when the user hits Save.
* The caller should treat it as a fresh upload candidate
* (same path as picking a brand new file). */
onCropped: (blob: Blob) => void;
/** Called when the user clicks "Skip Cropping" — the raw file
* is accepted as-is without any client-side processing. Used
* for animated GIFs where cropping would flatten to a single
* frame and the user would rather upload the original. */
onSkipCrop: () => void;
}
interface Offset {
x: number;
y: number;
}
export function AvatarCropModal({
isOpen,
onClose,
file,
onCropped,
onSkipCrop,
}: AvatarCropModalProps) {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [imageEl, setImageEl] = useState<HTMLImageElement | null>(null);
// Crop transform state — `offset` is the image centre's
// position inside the editor viewport in CSS pixels, `zoom` is
// a multiplier on top of the "cover" scale computed once per
// image. Keeping them separate lets the zoom slider and drag
// pan compose cleanly.
const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [rotation, setRotation] = useState(0);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const editorRef = useRef<HTMLDivElement>(null);
// Pointer drag bookkeeping — we track the start pointer + the
// offset at the moment the drag began, and compute the live
// offset as (current - start + startOffset). Kept in a ref so
// mid-drag updates don't re-render 60 times a second.
const dragRef = useRef<{
pointerId: number;
startX: number;
startY: number;
startOffset: Offset;
} | null>(null);
// ── Source image decoding ─────────────────────────────────
// Decode the picked File into an <img> element so we know its
// natural dimensions before laying out the cover scale.
useEffect(() => {
if (!isOpen || !file) {
setImageUrl(null);
setImageEl(null);
return;
}
const url = URL.createObjectURL(file);
setImageUrl(url);
const img = new Image();
img.onload = () => setImageEl(img);
img.onerror = () => setError('Could not read that image.');
img.src = url;
return () => {
URL.revokeObjectURL(url);
};
}, [isOpen, file]);
// Reset transform whenever a new file lands. Otherwise the
// user's last crop would persist across picks which is
// confusing — fresh file should open uncropped.
useEffect(() => {
if (!imageEl) return;
setOffset({ x: 0, y: 0 });
setZoom(1);
setRotation(0);
setError(null);
}, [imageEl]);
// ── Cover scale ───────────────────────────────────────────
//
// The baseline scale that makes the source image exactly cover
// the editor viewport (the `object-fit: cover` equivalent).
// All further zooming is multiplied on top of this so zoom=1
// always means "source fits edges".
const coverScale = useMemo(() => {
if (!imageEl) return 1;
return Math.max(
EDITOR_SIZE / imageEl.naturalWidth,
EDITOR_SIZE / imageEl.naturalHeight,
);
}, [imageEl]);
const effectiveScale = coverScale * zoom;
const displayWidth = imageEl
? imageEl.naturalWidth * effectiveScale
: EDITOR_SIZE;
const displayHeight = imageEl
? imageEl.naturalHeight * effectiveScale
: EDITOR_SIZE;
// Clamp the drag offset so the image edge can never move
// inside the crop circle's bounding box. Matches the Fluxer
// behaviour where you physically can't pan past "image
// touching the edge of the viewport".
const clampOffset = useCallback(
(raw: Offset): Offset => {
const maxX = Math.max(0, (displayWidth - EDITOR_SIZE) / 2);
const maxY = Math.max(0, (displayHeight - EDITOR_SIZE) / 2);
return {
x: Math.max(-maxX, Math.min(maxX, raw.x)),
y: Math.max(-maxY, Math.min(maxY, raw.y)),
};
},
[displayWidth, displayHeight],
);
// Re-clamp whenever zoom or image size changes so a zoom-out
// doesn't leave the image stranded off-centre.
useEffect(() => {
setOffset((prev) => clampOffset(prev));
}, [clampOffset]);
// ── Pointer drag ──────────────────────────────────────────
const handlePointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!imageEl) return;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
dragRef.current = {
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
startOffset: offset,
};
};
const handlePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== e.pointerId) return;
const dx = e.clientX - drag.startX;
const dy = e.clientY - drag.startY;
setOffset(
clampOffset({
x: drag.startOffset.x + dx,
y: drag.startOffset.y + dy,
}),
);
};
const handlePointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== e.pointerId) return;
try {
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
} catch {
// Already released — non-issue.
}
dragRef.current = null;
};
// ── Wheel zoom ────────────────────────────────────────────
const handleWheel = (e: ReactWheelEvent<HTMLDivElement>) => {
if (!imageEl) return;
// Negative deltaY = scroll up = zoom in. Convert to the
// same 0.05 steps the slider uses for a consistent feel.
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
setZoom((prev) => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, prev + delta)));
};
// ── Footer actions ────────────────────────────────────────
const handleReset = () => {
setOffset({ x: 0, y: 0 });
setZoom(1);
setRotation(0);
};
const handleRotate = () => {
// Clockwise 90° increments. Rotation composes on top of
// the drag/zoom transform at render time.
setRotation((prev) => (prev + 90) % 360);
};
const handleFitFrame = () => {
// Snap zoom back to 1 (cover) and recentre.
setZoom(1);
setOffset({ x: 0, y: 0 });
};
const handleSkip = () => {
onSkipCrop();
onClose();
};
const handleSave = async () => {
if (!imageEl) return;
setIsSaving(true);
setError(null);
try {
const blob = await renderCrop({
image: imageEl,
editorSize: EDITOR_SIZE,
outputSize: OUTPUT_SIZE,
offset,
scale: effectiveScale,
rotation,
});
onCropped(blob);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to crop image.');
setIsSaving(false);
}
};
// ── Render ────────────────────────────────────────────────
// Compose CSS transform from the three axes. Order matters —
// we translate THEN rotate THEN scale so the pivot is the
// image centre and the user's pan/zoom feel intuitive.
const imageTransform = imageEl
? `translate(-50%, -50%) translate(${offset.x}px, ${offset.y}px) rotate(${rotation}deg) scale(${effectiveScale})`
: undefined;
return (
<Modal.Root isOpen={isOpen} onClose={onClose} size="medium">
<Modal.Header title="Crop Avatar" onClose={onClose} />
<Modal.Content>
<div className={styles.body}>
<p className={styles.description}>
Drag to reposition your avatar and use the scroll wheel or
pinch to zoom. The recommended minimum size is 256×256 pixels.
</p>
<div
ref={editorRef}
className={styles.editor}
style={{ width: EDITOR_SIZE, height: EDITOR_SIZE }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onWheel={handleWheel}
>
{imageUrl && (
<img
src={imageUrl}
alt=""
className={styles.editorImage}
style={{
transform: imageTransform,
// The image is rendered at its NATURAL size
// and we scale it with `transform: scale()`
// so the pan math stays in source-pixel
// space no matter the zoom.
width: imageEl?.naturalWidth ?? 0,
height: imageEl?.naturalHeight ?? 0,
}}
draggable={false}
/>
)}
{/* Circular crop mask — absolutely positioned ring
on top of the image with a 9999px outer shadow
that dims everything outside the ring. */}
<div className={styles.cropMask} aria-hidden />
</div>
<div className={styles.zoomRow}>
<label className={styles.zoomLabel} htmlFor="avatar-zoom">
Zoom
</label>
<input
id="avatar-zoom"
type="range"
className={styles.zoomSlider}
min={ZOOM_MIN}
max={ZOOM_MAX}
step={ZOOM_STEP}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
disabled={!imageEl}
/>
<button
type="button"
className={styles.iconButton}
onClick={handleFitFrame}
disabled={!imageEl}
aria-label="Fit to frame"
title="Fit to frame"
>
<FrameCorners size={20} weight="regular" />
</button>
<button
type="button"
className={styles.iconButton}
onClick={handleRotate}
disabled={!imageEl}
aria-label="Rotate 90°"
title="Rotate 90°"
>
<ArrowClockwise size={20} weight="regular" />
</button>
</div>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<button
type="button"
className={styles.resetButton}
onClick={handleReset}
disabled={!imageEl}
>
Reset
</button>
<div className={styles.actionsSpacer} />
<button
type="button"
className={styles.secondaryButton}
onClick={onClose}
disabled={isSaving}
>
Cancel
</button>
<button
type="button"
className={styles.tertiaryButton}
onClick={handleSkip}
disabled={isSaving}
title="Upload the original file without cropping"
>
Skip Cropping
</button>
<button
type="button"
className={styles.primaryButton}
onClick={handleSave}
disabled={!imageEl || isSaving}
>
{isSaving ? 'Saving…' : 'Save Avatar'}
</button>
</div>
</div>
</Modal.Content>
</Modal.Root>
);
}
// ─────────────────────────────────────────────────────────────────
// Canvas render helper
// ─────────────────────────────────────────────────────────────────
interface RenderCropArgs {
image: HTMLImageElement;
editorSize: number;
outputSize: number;
offset: Offset;
scale: number;
rotation: number;
}
/**
* Bakes the current editor transform into a square PNG Blob. The
* math mirrors the DOM transform chain exactly: translate to the
* editor centre, pan by the drag offset, rotate, then draw the
* source image scaled by `scale` centred at (0, 0).
*
* Output is `outputSize × outputSize` pixels regardless of how
* much the user zoomed in — we let the browser's native image
* scaling handle super-sampling from the source bitmap.
*/
function renderCrop(args: RenderCropArgs): Promise<Blob> {
return new Promise((resolve, reject) => {
const { image, editorSize, outputSize, offset, scale, rotation } = args;
const canvas = document.createElement('canvas');
canvas.width = outputSize;
canvas.height = outputSize;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Could not get 2D canvas context.'));
return;
}
// Convert from editor-pixel space (360px) to output-pixel
// space (512px) by uniformly scaling every transform.
const ratio = outputSize / editorSize;
ctx.save();
// Move the origin to the centre of the output canvas
// (where the crop circle is centred in the editor).
ctx.translate(outputSize / 2, outputSize / 2);
// Apply the user's pan offset, rescaled to output space.
ctx.translate(offset.x * ratio, offset.y * ratio);
// Rotate around the new origin.
ctx.rotate((rotation * Math.PI) / 180);
// Scale the source image by the effective scale * ratio.
// The image is drawn from its own centre so the origin
// lines up with the editor's crop centre.
const drawScale = scale * ratio;
ctx.drawImage(
image,
(-image.naturalWidth * drawScale) / 2,
(-image.naturalHeight * drawScale) / 2,
image.naturalWidth * drawScale,
image.naturalHeight * drawScale,
);
ctx.restore();
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Canvas export failed.'));
return;
}
resolve(blob);
},
'image/png',
0.95,
);
});
}

View File

@@ -0,0 +1,323 @@
.container {
display: flex;
flex-direction: column;
gap: 16px;
}
/* ── Search row ──────────────────────────────────────────────── */
.searchWrap {
position: relative;
width: 100%;
}
.searchIcon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
pointer-events: none;
}
.searchInput {
width: 100%;
height: 44px;
padding: 0 14px 0 40px;
border: 1px solid var(--background-header-secondary);
border-radius: 8px;
background: var(--background-tertiary);
color: var(--text-primary);
font: inherit;
font-size: 0.875rem;
outline: none;
transition: border-color 0.12s;
}
.searchInput:focus {
border-color: var(--brand-primary);
}
/* ── Slots card ──────────────────────────────────────────────── */
.slotsCard {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px 20px;
border: 1px solid var(--background-header-secondary);
border-radius: 10px;
background: var(--background-secondary);
}
.slotsHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.slotsHeaderLeft {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.slotsTitle {
font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.slotsCounts {
display: flex;
align-items: center;
gap: 20px;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
}
.slotsCount strong {
color: var(--text-primary);
}
.uploadButton {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border: none;
border-radius: 6px;
background: var(--brand-primary, #5865f2);
color: #ffffff;
font: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
transition: filter 0.12s;
}
.uploadButton:hover:not(:disabled) {
filter: brightness(1.1);
}
.uploadButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.slotsDescription {
font-size: 0.8125rem;
line-height: 1.5;
color: var(--text-tertiary);
margin: 0;
}
/* ── Drag and drop zone ──────────────────────────────────────── */
.dropZone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 40px 20px;
border: 1px dashed var(--background-modifier-accent);
border-radius: 10px;
background: var(--background-secondary);
color: var(--text-tertiary);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.12s, border-color 0.12s, color 0.12s;
}
.dropZone:hover {
background: var(--background-modifier-hover);
color: var(--text-primary);
}
.dropZoneActive {
border-color: var(--brand-primary, #5865f2);
background: rgba(88, 101, 242, 0.08);
color: var(--text-primary);
}
.dropZoneIcon {
color: var(--text-tertiary);
}
.dropZoneActive .dropZoneIcon {
color: var(--brand-primary, #5865f2);
}
/* ── Emoji sections ──────────────────────────────────────────── */
.section {
display: flex;
flex-direction: column;
gap: 8px;
}
.sectionTitle {
font-size: 0.9375rem;
font-weight: 700;
color: var(--text-primary);
margin: 12px 0 4px;
}
.tableHeader {
display: grid;
grid-template-columns: 72px 1fr 1fr 40px;
gap: 12px;
padding: 8px 14px;
font-size: 0.6875rem;
font-weight: 700;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--background-modifier-accent);
}
.tableBody {
display: flex;
flex-direction: column;
}
.emojiRow {
display: grid;
grid-template-columns: 72px 1fr 1fr 40px;
gap: 12px;
align-items: center;
padding: 10px 14px;
border-bottom: 1px solid var(--background-modifier-accent);
transition: background-color 0.1s;
}
.emojiRow:last-child {
border-bottom: none;
}
.emojiRow:hover {
background: var(--background-modifier-hover);
}
.emojiCell {
display: flex;
align-items: center;
}
.emojiImage {
width: 40px;
height: 40px;
object-fit: contain;
border-radius: 4px;
}
.nameCell {
display: flex;
align-items: center;
min-width: 0;
}
.nameInput {
width: 100%;
padding: 6px 10px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--text-primary);
font: inherit;
font-size: 0.875rem;
font-family: ui-monospace, Menlo, Consolas, monospace;
outline: none;
transition: background-color 0.12s, border-color 0.12s;
}
.nameInput:hover,
.nameInput:focus {
background: var(--background-tertiary);
border-color: var(--background-modifier-accent);
}
.nameInput:focus {
border-color: var(--brand-primary);
}
.uploaderCell {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.uploaderName {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deleteCell {
display: flex;
align-items: center;
justify-content: flex-end;
}
.deleteButton {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
transition: background-color 0.12s, color 0.12s;
opacity: 0;
}
.emojiRow:hover .deleteButton {
opacity: 1;
}
.deleteButton:hover {
background: rgba(237, 66, 69, 0.12);
color: var(--status-danger, #ed4245);
}
/* ── Empty / status ──────────────────────────────────────────── */
.emptyState {
padding: 24px 20px;
text-align: center;
font-size: 0.875rem;
color: var(--text-tertiary);
}
.statusBanner {
padding: 10px 12px;
border-radius: 6px;
font-size: 0.8125rem;
}
.statusOk {
background: rgba(59, 165, 93, 0.12);
border: 1px solid rgba(59, 165, 93, 0.4);
color: var(--text-primary);
}
.statusErr {
background: rgba(234, 80, 80, 0.12);
border: 1px solid rgba(234, 80, 80, 0.4);
color: var(--text-primary);
}

View File

@@ -0,0 +1,418 @@
/**
* CustomEmojisTab — server-settings Custom Emoji surface, ported
* from the Fluxer GuildEmojiTab design. Shows the emoji slot card
* (Static/Animated counts + Upload button + help copy), a drag-and-
* drop zone, and a per-group table (Non-Animated / Animated) of the
* server's current uploads with rename-in-place + delete-on-hover.
*
* Backend: `api.customEmojis.list / upload / rename / remove`. The
* component infers the `animated` flag from the uploaded file's
* MIME type (GIF/APNG → animated). MIME-based classification only,
* no frame inspection.
*/
import { useMutation, useQuery } from 'convex/react';
import {
MagnifyingGlass,
Trash,
UploadSimple,
} from '@phosphor-icons/react';
import { useMemo, useRef, useState } from 'react';
import { Avatar } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import styles from './CustomEmojisTab.module.css';
interface CustomEmojiDoc {
_id: Id<'customEmojis'>;
name: string;
src: string;
createdAt: number;
animated: boolean;
uploadedById: Id<'userProfiles'>;
uploadedByUsername: string;
uploadedByDisplayName: string | null;
uploadedByAvatarUrl: string | null;
}
const MAX_STATIC = 50;
const MAX_ANIMATED = 50;
const ACCEPTED_MIME =
'image/png,image/gif,image/webp,image/jpeg,image/apng';
function isAnimatedMime(mime: string): boolean {
const m = mime.toLowerCase();
return m === 'image/gif' || m === 'image/apng';
}
/** Turn a file name like `"cat face!.gif"` into a valid emoji
* shortcode (`"cat_face"`), matching the server's validation. */
function sanitizeEmojiName(filename: string): string {
const stem = filename.replace(/\.[^.]+$/, '');
const clean = stem
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 32);
if (clean.length >= 2) return clean;
return 'emoji';
}
export function CustomEmojisTab() {
const userId =
typeof localStorage !== 'undefined'
? (localStorage.getItem('userId') as Id<'userProfiles'> | null)
: null;
const emojis = (useQuery(api.customEmojis.list, {}) ??
[]) as CustomEmojiDoc[];
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const uploadEmoji = useMutation(api.customEmojis.upload);
const renameEmoji = useMutation(api.customEmojis.rename);
const removeEmoji = useMutation(api.customEmojis.remove);
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [search, setSearch] = useState('');
const [status, setStatus] = useState<{
type: 'ok' | 'err';
message: string;
} | null>(null);
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
// Inline rename draft per emoji id — `undefined` means "no local
// draft, read the server name" so the query reactively updates
// rows that other clients renamed.
const [nameDrafts, setNameDrafts] = useState<Record<string, string>>({});
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return emojis;
return emojis.filter((e) => e.name.toLowerCase().includes(q));
}, [emojis, search]);
const staticEmojis = useMemo(
() => filtered.filter((e) => !e.animated),
[filtered],
);
const animatedEmojis = useMemo(
() => filtered.filter((e) => e.animated),
[filtered],
);
// Counts use the unfiltered lists so the slot card always
// reflects the true server state, not the current search view.
const totalStatic = emojis.filter((e) => !e.animated).length;
const totalAnimated = emojis.filter((e) => e.animated).length;
const uploadFiles = async (files: FileList | File[]) => {
if (!userId) {
setStatus({ type: 'err', message: 'You must be logged in.' });
return;
}
const list = Array.from(files).filter((f) => f.type.startsWith('image/'));
if (list.length === 0) return;
setUploading(true);
setStatus(null);
let okCount = 0;
try {
for (const file of list) {
const animated = isAnimatedMime(file.type);
// Enforce slot limits locally so we don't waste an upload
// round-trip when the server would reject it anyway.
if (
(animated && totalAnimated + okCount >= MAX_ANIMATED) ||
(!animated && totalStatic + okCount >= MAX_STATIC)
) {
setStatus({
type: 'err',
message: `Slot limit reached for ${animated ? 'animated' : 'static'} emoji.`,
});
break;
}
try {
const uploadUrl = await generateUploadUrl({});
const res = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': file.type },
body: file,
});
if (!res.ok) throw new Error(`Upload failed (${res.status})`);
const { storageId } = (await res.json()) as {
storageId: Id<'_storage'>;
};
await uploadEmoji({
userId,
name: sanitizeEmojiName(file.name),
storageId,
animated,
});
okCount++;
} catch (err: any) {
setStatus({
type: 'err',
message: err?.message ?? `Failed to upload ${file.name}`,
});
}
}
if (okCount > 0 && !status) {
setStatus({
type: 'ok',
message: `Uploaded ${okCount} ${okCount === 1 ? 'emoji' : 'emojis'}.`,
});
}
} finally {
setUploading(false);
}
};
const handlePickFile = () => fileInputRef.current?.click();
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (fileInputRef.current) fileInputRef.current.value = '';
if (!files || files.length === 0) return;
await uploadFiles(files);
};
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDraggingFiles(false);
if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
await uploadFiles(e.dataTransfer.files);
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDraggingFiles(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDraggingFiles(false);
};
const handleRename = async (emoji: CustomEmojiDoc, nextName: string) => {
if (!userId) return;
const clean = nextName.trim();
if (clean === emoji.name) return;
try {
await renameEmoji({
userId,
emojiId: emoji._id,
name: clean,
});
setNameDrafts((prev) => {
const next = { ...prev };
delete next[emoji._id];
return next;
});
} catch (err: any) {
setStatus({ type: 'err', message: err?.message ?? 'Rename failed' });
}
};
const handleRemove = async (emoji: CustomEmojiDoc) => {
if (!userId) return;
if (!confirm(`Delete :${emoji.name}:?`)) return;
setStatus(null);
try {
await removeEmoji({ userId, emojiId: emoji._id });
} catch (err: any) {
setStatus({ type: 'err', message: err?.message ?? 'Delete failed' });
}
};
const renderRows = (rows: CustomEmojiDoc[]) => (
<>
<div className={styles.tableHeader}>
<span>Emoji</span>
<span>Name</span>
<span>Uploaded By</span>
<span />
</div>
<div className={styles.tableBody}>
{rows.map((emoji) => {
const draft =
nameDrafts[emoji._id] !== undefined
? nameDrafts[emoji._id]
: emoji.name;
return (
<div key={emoji._id} className={styles.emojiRow}>
<div className={styles.emojiCell}>
<img
src={emoji.src}
alt={emoji.name}
className={styles.emojiImage}
draggable={false}
/>
</div>
<div className={styles.nameCell}>
<input
type="text"
className={styles.nameInput}
value={`:${draft}:`}
onChange={(e) => {
// Strip the surrounding colons so only the raw
// shortcode is stored — users edit what reads as
// `:name:` but the backend gets the bare name.
const stripped = e.target.value.replace(/^:|:$/g, '');
setNameDrafts((prev) => ({
...prev,
[emoji._id]: stripped,
}));
}}
onBlur={() => handleRename(emoji, draft)}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
else if (e.key === 'Escape') {
setNameDrafts((prev) => {
const next = { ...prev };
delete next[emoji._id];
return next;
});
(e.currentTarget as HTMLInputElement).blur();
}
}}
maxLength={32}
/>
</div>
<div className={styles.uploaderCell}>
<Avatar
src={emoji.uploadedByAvatarUrl ?? undefined}
size={24}
fallback={
emoji.uploadedByDisplayName ||
emoji.uploadedByUsername
}
/>
<span className={styles.uploaderName}>
{emoji.uploadedByDisplayName || emoji.uploadedByUsername}
</span>
</div>
<div className={styles.deleteCell}>
<button
type="button"
className={styles.deleteButton}
onClick={() => handleRemove(emoji)}
aria-label={`Delete ${emoji.name}`}
title="Delete"
>
<Trash size={14} weight="bold" />
</button>
</div>
</div>
);
})}
</div>
</>
);
return (
<div className={styles.container}>
<div className={styles.searchWrap}>
<MagnifyingGlass
className={styles.searchIcon}
size={16}
weight="regular"
/>
<input
type="text"
className={styles.searchInput}
placeholder="Search emojis..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className={styles.slotsCard}>
<div className={styles.slotsHeader}>
<div className={styles.slotsHeaderLeft}>
<h3 className={styles.slotsTitle}>Emoji Slots</h3>
<div className={styles.slotsCounts}>
<span className={styles.slotsCount}>
Static: <strong>{totalStatic}</strong> / {MAX_STATIC}
</span>
<span className={styles.slotsCount}>
Animated: <strong>{totalAnimated}</strong> / {MAX_ANIMATED}
</span>
</div>
</div>
<button
type="button"
className={styles.uploadButton}
onClick={handlePickFile}
disabled={uploading}
>
<UploadSimple size={16} weight="bold" />
{uploading ? 'Uploading…' : 'Upload Emoji'}
</button>
</div>
<p className={styles.slotsDescription}>
Emoji names must be at least 2 characters long and can only contain
alphanumeric characters and underscores. Allowed file types: JPEG,
PNG, WebP, GIF. We compress images to 128×128 pixels. Maximum size:
384 KB per emoji.
</p>
</div>
<div
className={`${styles.dropZone} ${isDraggingFiles ? styles.dropZoneActive : ''}`}
onClick={handlePickFile}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<UploadSimple
size={32}
weight="bold"
className={styles.dropZoneIcon}
/>
<span>Drag and drop emoji files here</span>
</div>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_MIME}
multiple
onChange={handleFileChange}
style={{ display: 'none' }}
/>
{status && (
<div
className={`${styles.statusBanner} ${status.type === 'ok' ? styles.statusOk : styles.statusErr}`}
>
{status.message}
</div>
)}
{filtered.length === 0 ? (
<div className={styles.emptyState}>
{search
? 'No emojis match your search.'
: 'No custom emojis yet. Upload one to get started.'}
</div>
) : (
<>
{staticEmojis.length > 0 && (
<div className={styles.section}>
<h3 className={styles.sectionTitle}>
Non-Animated Emoji ({staticEmojis.length})
</h3>
{renderRows(staticEmojis)}
</div>
)}
{animatedEmojis.length > 0 && (
<div className={styles.section}>
<h3 className={styles.sectionTitle}>
Animated Emoji ({animatedEmojis.length})
</h3>
{renderRows(animatedEmojis)}
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,225 @@
.container {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 680px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.heading {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--text-primary, #fff);
}
.count {
font-size: 11px;
color: var(--text-primary-muted, #a0a3a8);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.description {
margin: 0;
font-size: 13px;
line-height: 1.45;
color: var(--text-secondary, #a0a3a8);
}
/* ── Upload row ──────────────────────────────────────────────────── */
.uploadRow {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background-color: var(--background-secondary, rgba(255, 255, 255, 0.03));
border: 1px solid var(--background-modifier-accent, rgba(255, 255, 255, 0.06));
border-radius: 8px;
}
.uploadPreview {
flex: 0 0 auto;
width: 64px;
height: 64px;
border-radius: 8px;
background-color: var(--background-tertiary, rgba(0, 0, 0, 0.3));
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.uploadPreviewImage {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.uploadPreviewEmpty {
color: var(--text-primary-muted, #a0a3a8);
}
.uploadFields {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.shortcodeInput {
width: 100%;
padding: 8px 12px;
background-color: var(--background-tertiary, rgba(0, 0, 0, 0.3));
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-primary, #fff);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
outline: none;
box-sizing: border-box;
transition: border-color 0.1s;
}
.shortcodeInput:focus {
border-color: var(--brand-primary, #5865f2);
}
.uploadActions {
display: flex;
gap: 8px;
}
/* ── Status banners ──────────────────────────────────────────────── */
.statusSuccess {
padding: 8px 12px;
background-color: rgba(46, 204, 113, 0.15);
border: 1px solid rgba(46, 204, 113, 0.4);
border-radius: 6px;
font-size: 13px;
color: var(--text-primary, #fff);
}
.statusError {
padding: 8px 12px;
background-color: rgba(234, 80, 80, 0.15);
border: 1px solid rgba(234, 80, 80, 0.4);
border-radius: 6px;
font-size: 13px;
color: var(--text-primary, #fff);
}
/* ── Existing pack grid ──────────────────────────────────────────── */
.empty {
padding: 32px 20px;
text-align: center;
font-size: 13px;
color: var(--text-primary-muted, #a0a3a8);
background-color: var(--background-secondary, rgba(255, 255, 255, 0.03));
border: 1px dashed var(--background-modifier-accent, rgba(255, 255, 255, 0.08));
border-radius: 8px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px;
}
.gridItem {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 10px 8px;
background-color: var(--background-secondary, rgba(255, 255, 255, 0.03));
border: 1px solid var(--background-modifier-accent, rgba(255, 255, 255, 0.06));
border-radius: 8px;
transition: border-color 0.1s, background-color 0.1s;
}
.gridItem:hover {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.06));
border-color: var(--background-modifier-accent, rgba(255, 255, 255, 0.12));
}
.gridPreview {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
}
.gridImage {
max-width: 56px;
max-height: 56px;
object-fit: contain;
}
.gridLabel {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
color: var(--text-primary-muted, #a0a3a8);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.gridRemove {
position: absolute;
top: 4px;
right: 4px;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
cursor: pointer;
opacity: 0;
transition: opacity 0.1s, background-color 0.1s;
}
.gridItem:hover .gridRemove {
opacity: 1;
}
.gridRemove:hover {
background-color: var(--status-danger, #da373c);
}
/* ── Permission gate fallback ────────────────────────────────────── */
.gate {
padding: 60px 20px;
text-align: center;
}
.gateTitle {
margin: 0 0 8px;
font-size: 18px;
font-weight: 700;
color: var(--text-primary, #fff);
}
.gateBody {
margin: 0 auto;
max-width: 360px;
font-size: 14px;
color: var(--text-secondary, #a0a3a8);
}

View File

@@ -0,0 +1,296 @@
/**
* EmojisTab — admin UI for managing the server's MSC2545 image pack.
* Upload PNG / GIF / WebP images, assign shortcodes, delete existing
* entries. Gated behind `EmojiPackManager.canManageEmojis` (native
* Matrix power level check on `im.ponies.room_emotes` state events).
*
* Pattern mirrors `RolesTab`: optimistic store updates, tolerant
* error surfacing, file + shortcode validation before hitting the
* homeserver.
*/
import { observer } from 'mobx-react-lite';
import { useMemo, useRef, useState } from 'react';
import { Upload, Trash, Plus } from '@phosphor-icons/react';
import { Button } from '@brycord/ui';
import {
EmojiPackManager,
MAX_EMOJIS_PER_PACK,
type CustomEmoji,
} from '@brycord/matrix-client';
import EmojiPackStore from '@app/stores/EmojiPackStore';
import SelectionStore from '@app/stores/SelectionStore';
import { CustomEmojiImage } from '../channel/CustomEmojiImage';
import { MobileEmojisTab } from './MobileEmojisTab';
import styles from './EmojisTab.module.css';
interface EmojisTabProps {
serverId: string;
}
const SHORTCODE_REGEX = /^[a-z0-9_]{2,30}$/;
export const EmojisTab = observer(function EmojisTab({ serverId }: EmojisTabProps) {
// Mobile gets a completely different full-screen layout — see
// MobileEmojisTab. Branch into a thin wrapper component so the
// desktop hooks below don't run on the mobile path.
if (SelectionStore.isMobileViewport) {
return <MobileEmojisTab serverId={serverId} />;
}
return <DesktopEmojisTab serverId={serverId} />;
});
const DesktopEmojisTab = observer(function DesktopEmojisTab({ serverId }: EmojisTabProps) {
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [pendingPreviewUrl, setPendingPreviewUrl] = useState<string | null>(null);
const [shortcode, setShortcode] = useState('');
const [uploading, setUploading] = useState(false);
const [deletingId, setDeletingId] = 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 isFull = emojis.length >= MAX_EMOJIS_PER_PACK;
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 the input so the same file can be re-picked 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, or WebP image.' });
return;
}
if (file.size > 256 * 1024) {
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: strip extension,
// lowercase, replace non-alphanumerics with underscores, clamp.
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) return;
if (uploading) return;
const trimmed = shortcode.trim();
if (!SHORTCODE_REGEX.test(trimmed)) {
setStatus({
type: 'error',
message: 'Shortcode must be 230 characters of lowercase letters, digits, or 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);
// The RoomState.events listener in MatrixActions will push
// the new pack to EmojiPackStore on the next tick; clear
// the local form state immediately for snappy feedback.
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 handleRemove = async (emoji: CustomEmoji) => {
if (deletingId) return;
if (!window.confirm(`Remove :${emoji.shortcode}: from this server?`)) return;
setDeletingId(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 {
setDeletingId(null);
}
};
const handleClearPending = () => {
if (pendingPreviewUrl) URL.revokeObjectURL(pendingPreviewUrl);
setPendingFile(null);
setPendingPreviewUrl(null);
setShortcode('');
};
return (
<div className={styles.container}>
<div className={styles.header}>
<h2 className={styles.heading}>Server Emojis</h2>
<span className={styles.count}>
{emojis.length}/{MAX_EMOJIS_PER_PACK}
</span>
</div>
<p className={styles.description}>
Upload custom emojis that everyone in this server can use in
messages and reactions. PNG, GIF, or WebP up to 256&nbsp;KB.
Animated GIFs play automatically.
</p>
{/* Upload row */}
<div className={styles.uploadRow}>
<div className={styles.uploadPreview}>
{pendingPreviewUrl ? (
<img
src={pendingPreviewUrl}
alt="pending emoji preview"
className={styles.uploadPreviewImage}
draggable={false}
/>
) : (
<div className={styles.uploadPreviewEmpty}>
<Upload size={20} />
</div>
)}
</div>
<div className={styles.uploadFields}>
<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 className={styles.uploadActions}>
{!pendingFile ? (
<Button
variant="secondary"
size="sm"
icon={<Upload size={14} />}
onClick={() => fileInputRef.current?.click()}
disabled={isFull}
>
Choose File
</Button>
) : (
<>
<Button
variant="primary"
size="sm"
icon={<Plus size={14} weight="bold" />}
onClick={handleUpload}
disabled={uploading || !SHORTCODE_REGEX.test(shortcode)}
loading={uploading}
>
{uploading ? 'Uploading…' : 'Add'}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleClearPending}
disabled={uploading}
>
Cancel
</Button>
</>
)}
</div>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/gif,image/webp,image/jpeg"
onChange={handleFilePick}
style={{ display: 'none' }}
/>
{status && (
<div
className={
status.type === 'error' ? styles.statusError : styles.statusSuccess
}
>
{status.message}
</div>
)}
{/* Existing pack grid */}
{emojis.length === 0 ? (
<div className={styles.empty}>
No custom emojis yet. Upload one above to get started.
</div>
) : (
<div className={styles.grid}>
{emojis.map((emoji) => (
<div key={emoji.mxcUrl} className={styles.gridItem}>
<div className={styles.gridPreview}>
<CustomEmojiImage
mxc={emoji.mxcUrl}
alt={`:${emoji.shortcode}:`}
title={`:${emoji.shortcode}:`}
className={styles.gridImage}
draggable={false}
/>
</div>
<div className={styles.gridLabel} title={`:${emoji.shortcode}:`}>
:{emoji.shortcode}:
</div>
<button
type="button"
className={styles.gridRemove}
onClick={() => handleRemove(emoji)}
disabled={deletingId === emoji.shortcode}
aria-label={`Remove :${emoji.shortcode}:`}
>
<Trash size={14} weight="fill" />
</button>
</div>
))}
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,136 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.heading {
font-size: 1.125rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.description {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
color: var(--text-tertiary);
}
.categoryTitle {
margin: 1.5rem 0 0.5rem;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-primary-muted, var(--text-muted));
}
.row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
background-color: var(--background-secondary);
margin-bottom: 0.375rem;
}
.rowLabel {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.rowName {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
.rowDescription {
font-size: 0.75rem;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
}
.comboButton {
flex-shrink: 0;
min-width: 10rem;
min-height: 2.25rem;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-primary);
color: var(--text-primary);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
text-align: center;
cursor: pointer;
transition: border-color 0.12s, background-color 0.12s;
}
.comboButton:hover {
border-color: var(--brand-primary, #4641d9);
}
.comboButtonRecording {
border-color: var(--brand-primary, #4641d9);
background-color: var(--background-modifier-selected);
animation: pulse 1.2s ease-in-out infinite;
}
.comboButtonEmpty {
color: var(--text-tertiary);
font-style: italic;
}
.resetButton {
flex-shrink: 0;
padding: 0.375rem 0.625rem;
border-radius: 0.375rem;
border: none;
background-color: transparent;
color: var(--text-tertiary);
font: inherit;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: color 0.12s, background-color 0.12s;
}
.resetButton:hover {
color: var(--text-primary);
background-color: var(--background-modifier-hover);
}
.resetButton:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.conflict {
margin-top: 0.375rem;
font-size: 0.75rem;
color: var(--status-warning, #f0b232);
}
.recorderHint {
margin-top: 0.375rem;
font-size: 0.75rem;
color: var(--text-tertiary);
}
@keyframes pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(70, 65, 217, 0.35);
}
50% {
box-shadow: 0 0 0 4px rgba(70, 65, 217, 0);
}
}

View File

@@ -0,0 +1,233 @@
/**
* KeybindsTab — the Keybinds page inside User Settings. Lists every
* action from KeybindContext grouped by category. Each row shows the
* current combo as a button; clicking it opens an inline recorder that
* captures the next keydown and saves the result.
*/
import { useEffect, useRef, useState } from 'react';
import { Button } from '@discord-clone/ui';
import {
eventToCombo,
formatCombo,
useKeybinds,
type KeybindAction,
type KeybindCategory,
} from '../../contexts/KeybindContext';
import styles from './KeybindsTab.module.css';
const CATEGORY_ORDER: KeybindCategory[] = [
'voice',
'navigation',
'popouts',
'messaging',
];
const CATEGORY_LABELS: Record<KeybindCategory, string> = {
voice: 'Voice',
navigation: 'Navigation',
popouts: 'Popouts',
messaging: 'Messaging',
};
export function KeybindsTab() {
const keybinds = useKeybinds();
const [recordingId, setRecordingId] = useState<string | null>(null);
const actionsByCategory = new Map<KeybindCategory, KeybindAction[]>();
for (const action of keybinds.actions) {
const bucket = actionsByCategory.get(action.category) ?? [];
bucket.push(action);
actionsByCategory.set(action.category, bucket);
}
return (
<div>
<div className={styles.header}>
<div>
<h3 className={styles.heading}>Keybinds</h3>
<p className={styles.description}>
Customize keyboard shortcuts. Click a combo to rebind, or reset
to defaults.
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => keybinds.resetAll()}
>
Reset All
</Button>
</div>
{CATEGORY_ORDER.map((category) => {
const actions = actionsByCategory.get(category);
if (!actions || actions.length === 0) return null;
return (
<div key={category}>
<div className={styles.categoryTitle}>
{CATEGORY_LABELS[category]}
</div>
{actions.map((action) => (
<KeybindRow
key={action.id}
action={action}
isRecording={recordingId === action.id}
onStartRecording={() => setRecordingId(action.id)}
onStopRecording={() => setRecordingId(null)}
/>
))}
</div>
);
})}
</div>
);
}
interface KeybindRowProps {
action: KeybindAction;
isRecording: boolean;
onStartRecording: () => void;
onStopRecording: () => void;
}
function KeybindRow({
action,
isRecording,
onStartRecording,
onStopRecording,
}: KeybindRowProps) {
const keybinds = useKeybinds();
const currentCombo = keybinds.getCombo(action.id);
const isDefault = currentCombo === action.defaultCombo;
return (
<div className={styles.row}>
<div className={styles.rowLabel}>
<span className={styles.rowName}>{action.label}</span>
<span className={styles.rowDescription}>{action.description}</span>
</div>
{isRecording ? (
<KeybindRecorder
action={action}
onSave={(combo) => {
keybinds.setCombo(action.id, combo);
onStopRecording();
}}
onCancel={onStopRecording}
/>
) : (
<>
<button
type="button"
className={`${styles.comboButton} ${!currentCombo ? styles.comboButtonEmpty : ''}`}
onClick={onStartRecording}
>
{formatCombo(currentCombo)}
</button>
<button
type="button"
className={styles.resetButton}
disabled={isDefault}
onClick={() => keybinds.resetCombo(action.id)}
>
Reset
</button>
</>
)}
</div>
);
}
interface KeybindRecorderProps {
action: KeybindAction;
onSave: (combo: string) => void;
onCancel: () => void;
}
function KeybindRecorder({ action, onSave, onCancel }: KeybindRecorderProps) {
const keybinds = useKeybinds();
const [captured, setCaptured] = useState<string>('');
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
containerRef.current?.focus();
}, []);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onCancel();
return;
}
const combo = eventToCombo(e);
if (!combo) return;
e.preventDefault();
e.stopPropagation();
setCaptured(combo);
};
window.addEventListener('keydown', onKeyDown, { capture: true });
return () => {
window.removeEventListener('keydown', onKeyDown, {
capture: true,
} as EventListenerOptions);
};
}, [onCancel]);
const conflictId = captured ? keybinds.findConflict(captured, action.id) : null;
const conflictAction = conflictId
? keybinds.actions.find((a) => a.id === conflictId)
: null;
return (
<div
ref={containerRef}
tabIndex={-1}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
gap: 4,
flex: 1,
minWidth: 0,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
className={`${styles.comboButton} ${styles.comboButtonRecording}`}
style={{ flex: 1 }}
tabIndex={-1}
>
{captured ? formatCombo(captured) : 'Press a key combination…'}
</button>
<Button
variant="primary"
size="sm"
disabled={!captured}
onClick={() => onSave(captured)}
>
Save
</Button>
<Button variant="secondary" size="sm" onClick={() => onSave('')}>
Clear
</Button>
<Button variant="secondary" size="sm" onClick={onCancel}>
Cancel
</Button>
</div>
{conflictAction && (
<div className={styles.conflict}>
Conflicts with <strong>{conflictAction.label}</strong> saving
will clear the other binding.
</div>
)}
{!captured && (
<div className={styles.recorderHint}>
Press any key or combo. Press Escape to cancel.
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,307 @@
/* ── Mobile Emojis tab ───────────────────────────────────────────────
Full-screen Fluxer-style layout. Uses the same `--background-primary`
page surface and `--background-secondary` rounded cards as the
mobile Roles tab so the two tabs feel like one system. */
.root {
display: flex;
flex-direction: column;
gap: 16px;
}
/* ── Top "Upload Emoji" CTA ─────────────────────────────────────────*/
.uploadButton {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 16px;
background-color: var(--brand-primary);
color: #fff;
border: none;
border-radius: 0.75rem;
font: inherit;
font-size: 16px;
font-weight: 700;
cursor: pointer;
transition: filter 0.15s;
-webkit-tap-highlight-color: transparent;
}
.uploadButton:active:not(:disabled) {
filter: brightness(0.92);
}
.uploadButton:disabled {
opacity: 0.5;
cursor: default;
}
.helperText {
font-size: 13px;
color: var(--text-primary-muted);
line-height: 1.45;
margin: 0 4px;
}
/* ── Upload requirements card ───────────────────────────────────────*/
.sectionLabel {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-primary-muted);
padding: 4px 4px 0;
}
.requirementsCard {
background-color: var(--background-secondary);
border-radius: 0.75rem;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.requirementsList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.requirementsList li {
font-size: 13px;
color: var(--text-primary);
line-height: 1.45;
padding-left: 16px;
position: relative;
}
.requirementsList li::before {
content: '•';
position: absolute;
left: 0;
color: var(--text-primary-muted);
}
.requirementsList li strong {
font-weight: 700;
}
/* ── Inline upload form (shown when a file is staged) ──────────────*/
.uploadForm {
background-color: var(--background-secondary);
border-radius: 0.75rem;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.uploadFormRow {
display: flex;
align-items: center;
gap: 12px;
}
.uploadPreview {
width: 56px;
height: 56px;
border-radius: 0.5rem;
background-color: var(--background-secondary-alt);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.uploadPreviewImage {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.shortcodeInput {
flex: 1;
background-color: var(--background-secondary-alt);
border: none;
outline: none;
border-radius: 0.5rem;
padding: 10px 12px;
font: inherit;
font-size: 15px;
color: var(--text-primary);
min-width: 0;
}
.shortcodeInput:focus,
.shortcodeInput:focus-visible {
outline: none;
box-shadow: none;
}
.shortcodeInput::placeholder {
color: var(--text-tertiary-muted, var(--text-tertiary));
}
.uploadFormActions {
display: flex;
gap: 8px;
}
.formButton {
flex: 1;
padding: 12px 16px;
border: none;
border-radius: 0.5rem;
font: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: filter 0.15s, background-color 0.15s;
-webkit-tap-highlight-color: transparent;
}
.formButtonPrimary {
background-color: var(--brand-primary);
color: #fff;
}
.formButtonPrimary:disabled {
opacity: 0.5;
cursor: default;
}
.formButtonSecondary {
background-color: var(--background-secondary-alt);
color: var(--text-primary);
}
/* ── Emoji list card ────────────────────────────────────────────────*/
.emojiCard {
background-color: var(--background-secondary);
border-radius: 0.75rem;
overflow: hidden;
}
.emojiRow {
position: relative;
display: grid;
grid-template-columns: 48px 1fr 36px;
align-items: center;
gap: 12px;
padding: 12px 16px;
}
.emojiRow:not(:last-child)::after {
content: '';
position: absolute;
left: calc(16px + 48px + 12px);
right: 16px;
bottom: 0;
height: 1px;
background-color: var(--background-header-secondary);
pointer-events: none;
}
.emojiThumb {
width: 48px;
height: 48px;
border-radius: 0.375rem;
background-color: var(--background-secondary-alt);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.emojiThumbImage {
max-width: 36px;
max-height: 36px;
object-fit: contain;
}
.emojiName {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.deleteButton {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: transparent;
color: hsl(350, calc(90% * var(--saturation-factor)), 65%);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background-color 0.15s;
}
.deleteButton:active {
background-color: var(--background-modifier-hover);
}
.deleteButton:disabled {
opacity: 0.4;
cursor: default;
}
/* ── Empty + status states ─────────────────────────────────────────*/
.empty {
padding: 24px 16px;
text-align: center;
color: var(--text-primary-muted);
font-size: 14px;
}
.status {
padding: 10px 14px;
border-radius: 0.5rem;
font-size: 13px;
}
.statusSuccess {
background-color: hsl(139, calc(47.3% * var(--saturation-factor)), 20%);
color: hsl(139, calc(47.3% * var(--saturation-factor)), 85%);
}
.statusError {
background-color: hsl(0, calc(60% * var(--saturation-factor)), 22%);
color: hsl(0, calc(80% * var(--saturation-factor)), 85%);
}
.gate {
padding: 32px 16px;
text-align: center;
}
.gateTitle {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 8px;
}
.gateBody {
font-size: 14px;
color: var(--text-primary-muted);
margin: 0;
}

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

View File

@@ -0,0 +1,541 @@
/* ── Mobile Roles tab ────────────────────────────────────────────────
Full-screen Fluxer-style layout. Both the list view and the role
editor sit inside the existing MobileServerSettings body area and
share the `--background-primary` page surface.
Cards use `--background-secondary` as a distinct raised surface
(same treatment the You page uses), with inset dividers in
`--background-header-secondary`. */
.root {
display: flex;
flex-direction: column;
gap: 16px;
padding: 0;
}
/* ── Search input ───────────────────────────────────────────────────*/
.searchBar {
position: relative;
display: flex;
align-items: center;
min-height: 40px;
padding: 0 2.25rem 0 2.25rem;
border-radius: 8px;
background-color: var(--background-secondary);
flex-shrink: 0;
}
.searchIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
pointer-events: none;
}
.searchInput {
flex: 1;
background: transparent;
border: none;
outline: none;
box-shadow: none;
-webkit-appearance: none;
appearance: none;
padding: 10px 0;
font: inherit;
font-size: 15px;
color: var(--text-primary);
}
.searchInput:focus,
.searchInput:focus-visible {
outline: none;
box-shadow: none;
}
.searchInput::placeholder {
color: var(--text-tertiary-muted, var(--text-tertiary));
}
/* ── Helper text between search and everyone card ──────────────────*/
.helperText {
font-size: 13px;
color: var(--text-primary-muted);
line-height: 1.4;
padding: 0 4px;
}
/* ── @everyone single-item card ────────────────────────────────────*/
.everyoneCard {
background-color: var(--background-secondary);
border-radius: 0.75rem;
overflow: hidden;
}
/* ── "Roles — N" section header row ────────────────────────────────*/
.rolesHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 4px;
}
.rolesHeaderTitle {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
}
.rolesHeaderCreate {
display: flex;
align-items: center;
gap: 4px;
background: transparent;
border: none;
color: var(--brand-primary-light);
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
-webkit-tap-highlight-color: transparent;
}
.rolesHeaderCreate:disabled {
opacity: 0.5;
cursor: default;
}
/* ── Roles list card ───────────────────────────────────────────────*/
.rolesCard {
background-color: var(--background-secondary);
border-radius: 0.75rem;
overflow: hidden;
}
.roleRow {
position: relative;
display: grid;
grid-template-columns: 36px 1fr auto;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 16px;
background: transparent;
border: none;
color: var(--text-primary);
font: inherit;
text-align: left;
cursor: pointer;
transition: background-color 0.1s;
-webkit-tap-highlight-color: transparent;
}
.roleRow:active {
background-color: var(--background-modifier-hover);
}
.roleRow:not(:last-child)::after {
content: '';
position: absolute;
left: calc(16px + 36px + 12px);
right: 16px;
bottom: 0;
height: 1px;
background-color: var(--background-header-secondary);
pointer-events: none;
}
.roleAvatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--background-secondary-alt);
flex-shrink: 0;
}
/* Small colored dot sitting inside the circle avatar — shows the
role color even when no icon is attached. */
.roleAvatarDot {
width: 18px;
height: 18px;
border-radius: 50%;
}
.roleRowText {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.roleRowName {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.roleRowSubtitle {
font-size: 12px;
color: var(--text-primary-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.roleRowCaret {
color: var(--text-primary-muted);
}
/* ── Empty state ───────────────────────────────────────────────────*/
.emptyState {
padding: 24px;
text-align: center;
color: var(--text-primary-muted);
font-size: 14px;
}
.error {
padding: 12px 16px;
background-color: hsl(0, calc(60% * var(--saturation-factor)), 20%);
color: hsl(0, calc(80% * var(--saturation-factor)), 85%);
border-radius: 0.5rem;
font-size: 13px;
}
.gate {
padding: 32px 16px;
text-align: center;
}
.gateTitle {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 8px;
}
.gateBody {
font-size: 14px;
color: var(--text-primary-muted);
margin: 0;
}
/* ── Editor view ───────────────────────────────────────────────────*/
.editorRoot {
display: flex;
flex-direction: column;
gap: 16px;
}
.fieldLabel {
font-size: 13px;
font-weight: 500;
color: var(--text-primary-muted);
padding: 0 4px 6px;
}
/* Single-row input card (role name textbox wrapped in a rounded
--background-secondary surface). */
.inputCard {
background-color: var(--background-secondary);
border-radius: 0.75rem;
padding: 14px 16px;
display: flex;
align-items: center;
}
.inputCard input {
flex: 1;
background: transparent;
border: none;
outline: none;
box-shadow: none;
-webkit-appearance: none;
appearance: none;
font: inherit;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.inputCard input:focus,
.inputCard input:focus-visible {
outline: none;
box-shadow: none;
}
.inputCard input::placeholder {
color: var(--text-tertiary-muted, var(--text-tertiary));
}
/* Grouped card with multiple rows (Role Color, Power Level, etc). */
.groupCard {
background-color: var(--background-secondary);
border-radius: 0.75rem;
overflow: hidden;
}
.groupRow {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
}
.groupRow:not(:last-child)::after {
content: '';
position: absolute;
left: 16px;
right: 16px;
bottom: 0;
height: 1px;
background-color: var(--background-header-secondary);
pointer-events: none;
}
.groupRowLeft {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.groupRowLabel {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.groupRowSublabel {
font-size: 12px;
color: var(--text-primary-muted);
}
.colorPreview {
width: 22px;
height: 22px;
border-radius: 6px;
flex-shrink: 0;
}
/* Stacked row (row + content below it, for swatches / slider) */
.stackedRow {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px 16px;
}
.stackedRow:not(:last-child)::after {
content: '';
position: absolute;
left: 16px;
right: 16px;
bottom: 0;
height: 1px;
background-color: var(--background-header-secondary);
pointer-events: none;
}
.swatchRow {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.swatch {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.1s, border-color 0.1s;
-webkit-tap-highlight-color: transparent;
padding: 0;
}
.swatchActive {
border-color: #fff;
}
.swatchNone {
background-color: var(--background-secondary-alt);
color: var(--text-primary-muted);
display: flex;
align-items: center;
justify-content: center;
}
.hexInput {
margin-top: 4px;
background: var(--background-secondary-alt);
border: none;
outline: none;
padding: 8px 10px;
border-radius: 6px;
color: var(--text-primary);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
}
.powerHeader {
display: flex;
align-items: center;
justify-content: space-between;
}
.powerValue {
font-size: 15px;
font-weight: 700;
color: var(--brand-primary-light);
}
.slider {
width: 100%;
accent-color: var(--brand-primary-light);
}
.powerHint {
font-size: 12px;
color: var(--text-primary-muted);
margin: 0;
line-height: 1.4;
}
/* ── Abilities readout ─────────────────────────────────────────────*/
.abilities {
background-color: var(--background-secondary);
border-radius: 0.75rem;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.abilitiesHeader {
font-size: 13px;
color: var(--text-primary-muted);
}
.abilitiesList {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: 1fr;
gap: 6px;
}
.abilitiesList li {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.abilityOn {
color: var(--text-primary);
}
.abilityOn svg {
color: hsl(139, calc(47.3% * var(--saturation-factor)), 43.9%);
}
.abilityOff {
color: var(--text-primary-muted);
}
.abilityOff svg {
color: var(--text-primary-muted);
opacity: 0.6;
}
/* ── Delete button ─────────────────────────────────────────────────*/
.deleteCard {
background-color: var(--background-secondary);
border-radius: 0.75rem;
overflow: hidden;
}
.deleteButton {
width: 100%;
padding: 16px;
background: transparent;
border: none;
color: hsl(350, calc(90% * var(--saturation-factor)), 65%);
font: inherit;
font-size: 15px;
font-weight: 700;
cursor: pointer;
text-align: center;
-webkit-tap-highlight-color: transparent;
}
.deleteButton:active {
background-color: var(--background-modifier-hover);
}
/* ── Save status strip ─────────────────────────────────────────────*/
.status {
padding: 10px 14px;
border-radius: 0.5rem;
font-size: 13px;
}
.statusSuccess {
background-color: hsl(139, calc(47.3% * var(--saturation-factor)), 20%);
color: hsl(139, calc(47.3% * var(--saturation-factor)), 85%);
}
.statusError {
background-color: hsl(0, calc(60% * var(--saturation-factor)), 22%);
color: hsl(0, calc(80% * var(--saturation-factor)), 85%);
}
.saveButton {
width: 100%;
padding: 14px 16px;
background-color: var(--brand-primary);
border: none;
border-radius: 0.75rem;
color: #fff;
font: inherit;
font-size: 15px;
font-weight: 700;
cursor: pointer;
transition: filter 0.15s;
-webkit-tap-highlight-color: transparent;
}
.saveButton:active:not(:disabled) {
filter: brightness(0.92);
}
.saveButton:disabled {
opacity: 0.5;
cursor: default;
}

View File

@@ -0,0 +1,576 @@
/**
* MobileRolesTab — mobile full-screen variant of RolesTab. Rendered
* by RolesTab when SelectionStore.isMobileViewport is true. Hosted
* inside MobileServerSettings, so the top bar + body chrome come
* from there — this component only renders the tab contents and
* uses the shared `useMobileSettingsNav` context to push the top
* bar title / back handler when the user opens a specific role's
* editor screen.
*
* Layout follows the Fluxer mobile reference (see reference images
* in chat):
* List view — search, helper text, @everyone card, "Roles — N"
* header with Create shortcut, then a rounded card
* of every custom role with a trailing caret.
* Editor view — role name input, color picker card, power level
* slider card, abilities readout, delete button.
*
* Core CRUD + dirty tracking is intentionally inlined here (instead
* of shared with desktop RoleEditor) because the mobile layout
* rearranges controls enough that a mobile=true prop would bloat
* the desktop JSX. The underlying RoleManager calls are identical.
*/
import { observer } from 'mobx-react-lite';
import { useEffect, useMemo, useState } from 'react';
import {
CaretRight,
CheckCircle,
MagnifyingGlass,
Plus,
XCircle,
} from '@phosphor-icons/react';
import { RoleManager } from '@brycord/matrix-client';
import type { Role, PowerLevelAbilities } from '@brycord/matrix-client';
import { MAX_ROLES_PER_SERVER } from '@brycord/constants';
import RoleStore from '@app/stores/RoleStore';
import { useMobileSettingsNav } from './MobileServerSettings';
import styles from './MobileRolesTab.module.css';
interface MobileRolesTabProps {
serverId: string;
}
const COLOR_SWATCHES: Array<{ hex: string; name: string }> = [
{ hex: '#99AAB5', name: 'Default' },
{ hex: '#1ABC9C', name: 'Teal' },
{ hex: '#2ECC71', name: 'Green' },
{ hex: '#3498DB', name: 'Blue' },
{ hex: '#9B59B6', name: 'Purple' },
{ hex: '#E91E63', name: 'Magenta' },
{ hex: '#F1C40F', name: 'Yellow' },
{ hex: '#E67E22', name: 'Orange' },
{ hex: '#E74C3C', name: 'Red' },
{ hex: '#95A5A6', name: 'Gray' },
{ hex: '#607D8B', name: 'Slate' },
];
const HEX_REGEX = /^#[0-9a-fA-F]{6}$/;
/**
* Build a roleId → member-count map for the given server in one pass
* over `memberRolesBySpace`. Used by the list view to show a
* "3 Members" subtitle on each role row. @everyone is excluded
* because it's never stored in the per-member assignment index.
*/
function useMemberCounts(serverId: string): Record<string, number> {
return useMemo(() => {
const assignments = RoleStore.memberRolesBySpace[serverId] ?? {};
const counts: Record<string, number> = {};
for (const roleIds of Object.values(assignments)) {
for (const id of roleIds) {
counts[id] = (counts[id] ?? 0) + 1;
}
}
return counts;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serverId, RoleStore.memberRolesBySpace[serverId]]);
}
export const MobileRolesTab = observer(function MobileRolesTab({
serverId,
}: MobileRolesTabProps) {
const [editingRoleId, setEditingRoleId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [error, setError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const canManage = useMemo(() => {
try {
return RoleManager.getInstance().canManageRoles(serverId);
} catch {
return false;
}
}, [serverId]);
const memberCounts = useMemberCounts(serverId);
const allRoles = RoleStore.getRoles(serverId);
const editableRoles = allRoles
.filter((r) => r.id !== 'everyone')
.sort((a, b) => b.position - a.position);
const everyone = allRoles.find((r) => r.id === 'everyone');
const filteredRoles = useMemo(() => {
if (!search.trim()) return editableRoles;
const q = search.toLowerCase();
return editableRoles.filter((r) => r.name.toLowerCase().includes(q));
}, [editableRoles, search]);
const editingRole: Role | undefined = editingRoleId
? allRoles.find((r) => r.id === editingRoleId)
: undefined;
// Fall back to the list view if the role being edited was deleted
// from another session or just now by this component.
useEffect(() => {
if (editingRoleId && !editingRole) setEditingRoleId(null);
}, [editingRoleId, editingRole]);
// Tell MobileServerSettings to swap the top bar title / back
// button while the editor is open. Cleared on unmount + when the
// editor closes.
const nav = useMobileSettingsNav();
useEffect(() => {
if (!editingRole) {
nav.setSubTitle(null);
nav.setSubSubtitle(null);
nav.setSubBackHandler(null);
return;
}
nav.setSubTitle(editingRole.name);
nav.setSubSubtitle('Role');
nav.setSubBackHandler(() => () => setEditingRoleId(null));
return () => {
nav.setSubTitle(null);
nav.setSubSubtitle(null);
nav.setSubBackHandler(null);
};
}, [editingRole, nav]);
if (!canManage) {
return (
<div className={styles.gate}>
<h2 className={styles.gateTitle}>Manage Roles</h2>
<p className={styles.gateBody}>
You need permission to manage roles in this server. Ask an
admin to grant you a role with a higher power level.
</p>
</div>
);
}
const handleCreate = async () => {
if (creating) return;
if (editableRoles.length >= MAX_ROLES_PER_SERVER) {
setError(`A server can have at most ${MAX_ROLES_PER_SERVER} roles.`);
return;
}
setCreating(true);
setError(null);
try {
const role = await RoleManager.getInstance().createRole(serverId, {
name: 'New Role',
powerLevel: 0,
});
RoleStore.handleRoleCreated(serverId, role);
setEditingRoleId(role.id);
} catch (err: any) {
setError(err?.message || 'Failed to create role.');
} finally {
setCreating(false);
}
};
if (editingRole) {
return (
<MobileRoleEditor
key={editingRole.id}
serverId={serverId}
role={editingRole}
readOnly={editingRole.id === 'everyone'}
onDeleted={() => setEditingRoleId(null)}
/>
);
}
return (
<div className={styles.root}>
<div className={styles.searchBar}>
<MagnifyingGlass
size={18}
weight="regular"
className={styles.searchIcon}
/>
<input
type="text"
className={styles.searchInput}
placeholder="Search"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<p className={styles.helperText}>
Use roles to group your server members and assign permissions.
</p>
{everyone && (
<div className={styles.everyoneCard}>
<button
type="button"
className={styles.roleRow}
onClick={() => setEditingRoleId('everyone')}
>
<div className={styles.roleAvatar}>
<span
className={styles.roleAvatarDot}
style={{ backgroundColor: 'var(--text-primary-muted)' }}
/>
</div>
<div className={styles.roleRowText}>
<span className={styles.roleRowName}>@everyone</span>
<span className={styles.roleRowSubtitle}>
Default permissions for all server members
</span>
</div>
<CaretRight
size={16}
weight="bold"
className={styles.roleRowCaret}
/>
</button>
</div>
)}
<div className={styles.rolesHeader}>
<span className={styles.rolesHeaderTitle}>
Roles {editableRoles.length}
</span>
<button
type="button"
className={styles.rolesHeaderCreate}
onClick={handleCreate}
disabled={creating || editableRoles.length >= MAX_ROLES_PER_SERVER}
>
<Plus size={14} weight="bold" />
Create
</button>
</div>
{filteredRoles.length === 0 ? (
<div className={styles.emptyState}>
{search.trim()
? 'No roles match your search.'
: 'No roles yet. Tap "Create" to add one.'}
</div>
) : (
<div className={styles.rolesCard}>
{filteredRoles.map((role) => {
const count = memberCounts[role.id] ?? 0;
return (
<button
key={role.id}
type="button"
className={styles.roleRow}
onClick={() => setEditingRoleId(role.id)}
>
<div className={styles.roleAvatar}>
<span
className={styles.roleAvatarDot}
style={{
backgroundColor:
role.color || 'var(--text-primary-muted)',
}}
/>
</div>
<div className={styles.roleRowText}>
<span
className={styles.roleRowName}
style={role.color ? { color: role.color } : undefined}
>
{role.name}
</span>
<span className={styles.roleRowSubtitle}>
{count} {count === 1 ? 'Member' : 'Members'} · PL{' '}
{role.powerLevel}
</span>
</div>
<CaretRight
size={16}
weight="bold"
className={styles.roleRowCaret}
/>
</button>
);
})}
</div>
)}
{error && <div className={styles.error}>{error}</div>}
</div>
);
});
// ── Editor ──────────────────────────────────────────────────────────
const ABILITY_LABELS: Array<{ key: keyof PowerLevelAbilities; label: string }> = [
{ key: 'canSendMessages', label: 'Send messages' },
{ key: 'canInvite', label: 'Invite members' },
{ key: 'canRenameNicknames', label: 'Rename other members' },
{ key: 'canRedact', label: "Delete others' messages" },
{ key: 'canKick', label: 'Kick members' },
{ key: 'canBan', label: 'Ban members' },
{ key: 'canSendState', label: 'Send room state events' },
{ key: 'canRenameChannels', label: 'Rename channels' },
{ key: 'canManageRoles', label: 'Manage roles' },
{ key: 'canEditPowerLevels', label: 'Edit server power levels' },
];
interface MobileRoleEditorProps {
serverId: string;
role: Role;
readOnly?: boolean;
onDeleted: () => void;
}
const MobileRoleEditor = observer(function MobileRoleEditor({
serverId,
role,
readOnly,
onDeleted,
}: MobileRoleEditorProps) {
const [name, setName] = useState(role.name);
const [color, setColor] = useState<string | undefined>(role.color);
const [powerLevel, setPowerLevel] = useState(role.powerLevel);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [status, setStatus] = useState<
{ type: 'success' | 'error'; message: string } | null
>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
useEffect(() => {
setName(role.name);
setColor(role.color);
setPowerLevel(role.powerLevel);
setStatus(null);
setConfirmDelete(false);
}, [role.id]);
const abilities: PowerLevelAbilities = useMemo(
() => RoleManager.getInstance().describePowerLevel(serverId, powerLevel),
[serverId, powerLevel],
);
const trimmedName = name.trim();
const isDirty =
trimmedName !== role.name ||
(color ?? undefined) !== (role.color ?? undefined) ||
powerLevel !== role.powerLevel;
const nameValid = trimmedName.length > 0 && trimmedName.length <= 32;
const canSave = isDirty && nameValid && !saving && !readOnly;
const handleSave = async () => {
if (!canSave) return;
setSaving(true);
setStatus(null);
try {
await RoleManager.getInstance().updateRole(serverId, role.id, {
name: trimmedName,
color,
powerLevel,
});
RoleStore.handleRoleUpdated(serverId, {
...role,
name: trimmedName,
color,
powerLevel,
});
setStatus({ type: 'success', message: 'Role saved.' });
} catch (err: any) {
setStatus({
type: 'error',
message: err?.message || 'Failed to save role.',
});
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (readOnly || deleting) return;
if (!confirmDelete) {
setConfirmDelete(true);
return;
}
setDeleting(true);
setStatus(null);
try {
await RoleManager.getInstance().deleteRole(serverId, role.id);
RoleStore.handleRoleDeleted(serverId, role.id);
onDeleted();
} catch (err: any) {
setStatus({
type: 'error',
message: err?.message || 'Failed to delete role.',
});
setDeleting(false);
}
};
if (readOnly) {
return (
<div className={styles.editorRoot}>
<div className={styles.abilities}>
<div className={styles.abilitiesHeader}>
@everyone is the baseline role every member has. Its abilities
come from the server's default power level thresholds, not
from a per-role configuration.
</div>
</div>
<AbilitiesReadout
powerLevel={0}
abilities={RoleManager.getInstance().describePowerLevel(serverId, 0)}
/>
</div>
);
}
return (
<div className={styles.editorRoot}>
<div>
<div className={styles.fieldLabel}>Role name</div>
<div className={styles.inputCard}>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={40}
placeholder="new role"
/>
</div>
</div>
<div className={styles.groupCard}>
<div className={styles.stackedRow}>
<div className={styles.groupRowLeft}>
<span className={styles.groupRowLabel}>Role Color</span>
<span className={styles.groupRowSublabel}>
{color ? color : 'No color'}
</span>
</div>
<div className={styles.swatchRow}>
<button
type="button"
className={`${styles.swatch} ${styles.swatchNone} ${!color ? styles.swatchActive : ''}`}
onClick={() => setColor(undefined)}
aria-label="No color"
>
<XCircle size={14} weight="bold" />
</button>
{COLOR_SWATCHES.map((s) => (
<button
key={s.hex}
type="button"
className={`${styles.swatch} ${color === s.hex ? styles.swatchActive : ''}`}
style={{ backgroundColor: s.hex }}
onClick={() => setColor(s.hex)}
aria-label={s.name}
/>
))}
</div>
<input
type="text"
className={styles.hexInput}
value={color ?? ''}
placeholder="#5865F2"
onChange={(e) => {
const val = e.target.value;
if (val === '' || HEX_REGEX.test(val)) {
setColor(val || undefined);
} else if (val.startsWith('#') && val.length <= 7) {
setColor(val);
}
}}
/>
</div>
<div className={styles.stackedRow}>
<div className={styles.powerHeader}>
<span className={styles.groupRowLabel}>Power Level</span>
<span className={styles.powerValue}>{powerLevel}</span>
</div>
<input
type="range"
min={0}
max={100}
value={powerLevel}
onChange={(e) => setPowerLevel(Number(e.target.value))}
className={styles.slider}
/>
<p className={styles.powerHint}>
Power level determines what members with this role can do
at the Matrix protocol layer. Multiple roles can share the
same power level.
</p>
</div>
</div>
<AbilitiesReadout powerLevel={powerLevel} abilities={abilities} />
{status && (
<div
className={`${styles.status} ${
status.type === 'error' ? styles.statusError : styles.statusSuccess
}`}
>
{status.message}
</div>
)}
<button
type="button"
className={styles.saveButton}
onClick={handleSave}
disabled={!canSave}
>
{saving ? 'Saving' : 'Save Role'}
</button>
<div className={styles.deleteCard}>
<button
type="button"
className={styles.deleteButton}
onClick={handleDelete}
disabled={deleting}
>
{confirmDelete ? 'Tap again to confirm delete' : 'Delete Role'}
</button>
</div>
</div>
);
});
function AbilitiesReadout({
powerLevel,
abilities,
}: {
powerLevel: number;
abilities: PowerLevelAbilities;
}) {
return (
<div className={styles.abilities}>
<div className={styles.abilitiesHeader}>
At power level <strong>{powerLevel}</strong>, members can:
</div>
<ul className={styles.abilitiesList}>
{ABILITY_LABELS.map(({ key, label }) => {
const allowed = abilities[key];
return (
<li
key={key}
className={allowed ? styles.abilityOn : styles.abilityOff}
>
{allowed ? (
<CheckCircle size={14} weight="fill" />
) : (
<XCircle size={14} weight="fill" />
)}
<span>{label}</span>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -0,0 +1,223 @@
/* ── Mobile server settings — full-screen overlay ────────────────────
Stands in for ServerSettingsModal's desktop Modal at ≤768px. Same
Fluxer-mobile layout: top bar with back/close + title, server icon
+ name hero, Settings section heading, and a rounded card stack
with one row per tab. Tapping a row pushes that tab's panel
full-screen with a back arrow header. */
.overlay {
position: fixed;
inset: 0;
z-index: var(--z-index-modal, 10000);
display: flex;
flex-direction: column;
background-color: var(--background-primary);
overflow: hidden;
}
/* ── Top bar (close / back + title) ─────────────────────────────────*/
.topBar {
flex-shrink: 0;
display: grid;
grid-template-columns: 48px 1fr 48px;
align-items: center;
height: 56px;
padding: 0 8px;
border-bottom: 1px solid var(--background-header-secondary);
}
.topBarButton {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--text-primary);
cursor: pointer;
transition: background-color 0.15s;
-webkit-tap-highlight-color: transparent;
margin: 0 auto;
}
.topBarButton:active {
background-color: var(--background-modifier-hover);
}
.topBarTitleBlock {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 0;
}
.topBarTitle {
font-size: 17px;
font-weight: 700;
color: var(--text-primary);
text-align: center;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.topBarSubtitle {
font-size: 12px;
font-weight: 500;
color: var(--text-primary-muted);
line-height: 1.2;
margin-top: 2px;
}
/* ── Body (scrolling content area) ──────────────────────────────────*/
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 16px 16px 32px;
}
/* Category-list body variant gets extra top space for the server
hero block. The tab-panel variant sits flush so custom panels
control their own spacing. */
.bodyList {
padding-top: 24px;
}
.bodyPanel {
padding: 16px;
}
/* ── Server hero (icon + name) ──────────────────────────────────────*/
.hero {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
margin-bottom: 28px;
}
.heroIcon {
width: 72px;
height: 72px;
border-radius: 18px;
object-fit: cover;
background-color: var(--background-modifier-hover);
}
.heroInitials {
width: 72px;
height: 72px;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--background-modifier-hover);
color: var(--text-primary);
font-size: 26px;
font-weight: 700;
}
.heroName {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Section label ("Settings") ─────────────────────────────────────*/
.sectionLabel {
font-size: 13px;
font-weight: 500;
color: var(--text-primary-muted);
padding: 0 4px 8px;
}
/* ── Rounded card holding the list of tabs ──────────────────────────*/
.tabList {
background-color: var(--background-secondary);
border-radius: 0.75rem;
overflow: hidden;
}
.tabItem {
position: relative;
display: grid;
grid-template-columns: 32px 1fr auto;
align-items: center;
gap: 12px;
width: 100%;
padding: 16px;
background: transparent;
border: none;
color: var(--text-primary);
font: inherit;
font-size: 15px;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: background-color 0.1s;
-webkit-tap-highlight-color: transparent;
}
.tabItem:active {
background-color: var(--background-modifier-hover);
}
.tabItemIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background-color: var(--background-secondary-alt);
color: var(--text-primary);
}
.tabItemCaret {
color: var(--text-primary-muted);
}
/* Divider between adjacent rows within the rounded card. */
.tabItem:not(:last-child)::after {
content: '';
position: absolute;
left: calc(16px + 32px + 12px);
right: 16px;
bottom: 0;
height: 1px;
background-color: var(--background-header-secondary);
pointer-events: none;
}
/* ── Tab panel — stub fallback ──────────────────────────────────────*/
.stubTitle {
margin: 0 0 8px;
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.stubBody {
margin: 0;
font-size: 14px;
color: var(--text-secondary);
}

View File

@@ -0,0 +1,235 @@
/**
* MobileServerSettings — full-screen stand-in for ServerSettingsModal
* on mobile. Ported from the new UI: top bar with X / back button +
* title, a server icon + name hero, then a rounded card listing each
* category. Tapping a category replaces the body with that tab's
* panel and swaps the top bar to a back arrow.
*
* Rendered as a portal overlay (not the shared Modal) so the surface
* is true full-screen on mobile. Reuses the existing `OverviewTab`,
* `RolesTab`, and `EmojisTab` components exported from
* ServerSettingsModal — same Convex-backed logic as desktop.
*/
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useQuery } from 'convex/react';
import {
CaretLeft,
CaretRight,
Gear,
ShieldStar,
Smiley,
X,
} from '@phosphor-icons/react';
import { api } from '../../../../../convex/_generated/api';
import {
EmojisTab,
OverviewTab,
type ServerSettingsTab,
} from './ServerSettingsModal';
import { useRolesView } from './RolesView';
import rolesStyles from './RolesView.module.css';
import styles from './MobileServerSettings.module.css';
interface MobileServerSettingsProps {
isOpen: boolean;
onClose: () => void;
initialTab?: ServerSettingsTab;
}
const TABS: Array<{
id: ServerSettingsTab;
label: string;
icon: React.ComponentType<{
size?: number;
weight?: 'regular' | 'fill' | 'bold';
}>;
}> = [
{ id: 'overview', label: 'Overview', icon: Gear },
{ id: 'roles', label: 'Roles & Permissions', icon: ShieldStar },
{ id: 'emojis', label: 'Custom Emoji', icon: Smiley },
];
function getInitials(name: string): string {
return name
.split(/\s+/)
.map((w) => w[0])
.join('')
.slice(0, 2)
.toUpperCase();
}
export function MobileServerSettings({
isOpen,
onClose,
initialTab,
}: MobileServerSettingsProps) {
// `null` means we're on the category list. A non-null tab id
// shows that tab's panel full-screen with a back arrow in the
// top bar.
const [activeTab, setActiveTab] = useState<ServerSettingsTab | null>(
initialTab ?? null,
);
// Roles & Permissions uses a two-screen flow on mobile: the
// role list view, then the editor when a role is tapped. The
// hook owns the selected-id state so desktop and mobile share
// the same drafts map behind the scenes.
const rolesView = useRolesView({
onBack: () => setActiveTab(null),
});
useEffect(() => {
if (isOpen) setActiveTab(initialTab ?? null);
}, [isOpen, initialTab]);
// Lock body scroll while the overlay is open.
useEffect(() => {
if (!isOpen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prev;
};
}, [isOpen]);
// Escape / top-bar back — pop one level at a time.
// roles editor → roles list → category list → close modal
const popOneLevel = () => {
if (activeTab === 'roles' && rolesView.selectedId) {
rolesView.clearSelection();
return;
}
if (activeTab !== null) {
setActiveTab(null);
return;
}
onClose();
};
useEffect(() => {
if (!isOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
popOneLevel();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, activeTab, rolesView.selectedId, onClose]);
const serverSettings = useQuery(api.serverSettings.get, isOpen ? {} : 'skip');
if (!isOpen) return null;
const serverName = serverSettings?.serverName || 'Server';
const serverIcon = serverSettings?.iconUrl || null;
const activeTabMeta = activeTab
? TABS.find((t) => t.id === activeTab)
: undefined;
const backIsClose = activeTab === null;
const barTitle = activeTabMeta ? activeTabMeta.label : 'Server Settings';
return createPortal(
<div className={styles.overlay} role="dialog" aria-modal="true">
{/* Top bar — X at the root, back arrow whenever a tab is pushed. */}
<div className={styles.topBar}>
<button
type="button"
className={styles.topBarButton}
onClick={popOneLevel}
aria-label={backIsClose ? 'Close' : 'Back'}
>
{backIsClose ? (
<X size={22} weight="bold" />
) : (
<CaretLeft size={22} weight="bold" />
)}
</button>
<div className={styles.topBarTitleBlock}>
<h2 className={styles.topBarTitle}>{barTitle}</h2>
</div>
<div />
</div>
{activeTab === null ? (
<div className={`${styles.body} ${styles.bodyList}`}>
<div className={styles.hero}>
{serverIcon ? (
<img
src={serverIcon}
alt={serverName}
className={styles.heroIcon}
draggable={false}
/>
) : (
<div className={styles.heroInitials}>
{getInitials(serverName)}
</div>
)}
<h3 className={styles.heroName}>{serverName}</h3>
</div>
<div className={styles.sectionLabel}>Settings</div>
<div className={styles.tabList}>
{TABS.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
type="button"
className={styles.tabItem}
onClick={() => setActiveTab(tab.id)}
>
<span className={styles.tabItemIcon}>
<Icon size={18} weight="fill" />
</span>
<span>{tab.label}</span>
<CaretRight
size={16}
weight="bold"
className={styles.tabItemCaret}
/>
</button>
);
})}
</div>
</div>
) : activeTab === 'roles' ? (
// Roles & Permissions gets a dedicated two-screen flow:
// - no selection → full-screen role list (Create Role +
// rows with chevrons)
// - role selected → editor content with a "Back to
// Roles" row on top for the in-panel pop-back
<div className={`${styles.body} ${styles.bodyPanel}`}>
{rolesView.selectedId ? (
<>
<div className={rolesStyles.mobileBackRow}>
<button
type="button"
className={rolesStyles.mobileBackButton}
onClick={rolesView.clearSelection}
>
<CaretLeft size={14} weight="bold" />
Back to Roles
</button>
</div>
{rolesView.header}
{rolesView.content}
</>
) : (
rolesView.mobileList
)}
</div>
) : (
<div className={`${styles.body} ${styles.bodyPanel}`}>
{activeTab === 'overview' && <OverviewTab />}
{activeTab === 'emojis' && <EmojisTab />}
</div>
)}
</div>,
document.body,
);
}

View File

@@ -0,0 +1,243 @@
/* ── Mobile user settings — full-screen overlay ──────────────────────
Mirrors `MobileServerSettings.module.css` so the two settings
surfaces feel consistent on mobile. Top bar with X / back arrow,
user-avatar hero on the category list, then a stack of grouped
rounded cards (Your Account / App Settings / Log Out). Tapping a
row pushes the tab content full-screen with a back arrow. */
.overlay {
position: fixed;
inset: 0;
z-index: var(--z-index-modal, 10000);
display: flex;
flex-direction: column;
background-color: var(--background-secondary);
overflow: hidden;
}
/* ── Top bar ────────────────────────────────────────────────────────*/
.topBar {
flex-shrink: 0;
display: grid;
grid-template-columns: 48px 1fr 48px;
align-items: center;
height: 56px;
padding: 0 8px;
border-bottom: 1px solid var(--background-header-secondary);
}
.topBarButton {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--text-primary);
cursor: pointer;
transition: background-color 0.15s;
-webkit-tap-highlight-color: transparent;
margin: 0 auto;
}
.topBarButton:active {
background-color: var(--background-modifier-hover);
}
.topBarTitleBlock {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 0;
}
.topBarTitle {
font-size: 17px;
font-weight: 700;
color: var(--text-primary);
text-align: center;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
/* ── Body ───────────────────────────────────────────────────────────*/
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 16px 16px 32px;
}
.bodyList {
padding-top: 24px;
}
.bodyPanel {
padding: 16px;
}
/* ── User hero ─────────────────────────────────────────────────────*/
.hero {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
margin-bottom: 28px;
}
.heroName {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
text-align: center;
}
.heroHandle {
font-size: 13px;
color: var(--text-primary-muted);
margin: 0;
text-align: center;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 16px;
}
/* ── Section / cards ───────────────────────────────────────────────*/
.section + .section {
margin-top: 18px;
}
.sectionLabel {
font-size: 13px;
font-weight: 500;
color: var(--text-primary-muted);
padding: 0 4px 8px;
}
.tabList {
background-color: var(--background-secondary-alt);
border-radius: 0.75rem;
overflow: hidden;
}
.tabItem {
position: relative;
display: grid;
grid-template-columns: 32px 1fr auto;
align-items: center;
gap: 12px;
width: 100%;
padding: 16px;
background: transparent;
border: none;
color: var(--text-primary);
font: inherit;
font-size: 15px;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: background-color 0.1s;
-webkit-tap-highlight-color: transparent;
}
.tabItem:active {
background-color: var(--background-modifier-hover);
}
.tabItemIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background-color: var(--background-secondary);
color: var(--text-primary);
}
.tabItemCaret {
color: var(--text-primary-muted);
}
.tabItem:not(:last-child)::after {
content: '';
position: absolute;
left: calc(16px + 32px + 12px);
right: 16px;
bottom: 0;
height: 1px;
background-color: var(--background-header-secondary);
pointer-events: none;
}
/* ── Log Out button (danger card) ───────────────────────────────────*/
.dangerCard {
background-color: var(--background-secondary-alt);
border-radius: 0.75rem;
overflow: hidden;
}
.dangerItem {
display: grid;
grid-template-columns: 32px 1fr;
align-items: center;
gap: 12px;
width: 100%;
padding: 16px;
background: transparent;
border: none;
font: inherit;
font-size: 15px;
font-weight: 600;
text-align: left;
cursor: pointer;
color: hsl(350, calc(90% * var(--saturation-factor)), 65%);
-webkit-tap-highlight-color: transparent;
transition: background-color 0.1s;
}
.dangerItem:active {
background-color: var(--background-modifier-hover);
}
.dangerItemIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background-color: var(--background-secondary);
color: hsl(350, calc(90% * var(--saturation-factor)), 65%);
}
/* ── Tab panel stub fallback ────────────────────────────────────────*/
.stubTitle {
margin: 0 0 8px;
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.stubBody {
margin: 0;
font-size: 14px;
color: var(--text-primary-muted);
}

View File

@@ -0,0 +1,289 @@
/**
* MobileUserSettings — full-screen stand-in for UserSettingsModal on
* mobile. Ported from the new UI: top bar with X / back button, a
* user-avatar hero on the category list, then grouped rounded cards
* listing each tab. Tapping a row replaces the body with that tab's
* content and the top bar's X swaps for a back arrow.
*
* Reuses the existing tab components (`AccountTab`, `AppearanceTab`,
* `VoiceTab`, `SecurityTab`, plus `KeybindsTab`) exported from
* UserSettingsModal so the underlying form logic — profile editing,
* avatar upload, accent color, voice test, recovery key — is
* identical to desktop.
*/
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useQuery } from 'convex/react';
import {
Bell,
CaretLeft,
CaretRight,
Microphone,
PaintBrush,
ShieldCheck,
SignOut,
User as UserIcon,
X,
} from '@phosphor-icons/react';
import { Avatar } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import { useOnlineUsers } from '../../contexts/PresenceContext';
import { useLogout } from '../../hooks/useLogout';
import { LogoutConfirmModal } from '../modals/LogoutConfirmModal';
import {
AccountTab,
AppearanceTab,
SecurityTab,
VoiceTab,
} from './UserSettingsModal';
import styles from './MobileUserSettings.module.css';
// Keybinds are deliberately omitted from the mobile settings — mobile
// doesn't have a physical keyboard workflow, so the whole tab would
// be dead weight. Desktop still shows it via UserSettingsModal.
type TabId =
| 'account'
| 'appearance'
| 'voice'
| 'notifications'
| 'security';
interface MobileUserSettingsProps {
isOpen: boolean;
onClose: () => void;
initialTab?: TabId;
}
interface MobileSettingsTabMeta {
id: TabId;
label: string;
icon: React.ComponentType<{
size?: number;
weight?: 'regular' | 'fill' | 'bold';
}>;
}
interface MobileSettingsGroup {
category: string;
tabs: MobileSettingsTabMeta[];
}
const TAB_GROUPS: MobileSettingsGroup[] = [
{
category: 'Your Account',
tabs: [
{ id: 'account', label: 'My Account', icon: UserIcon },
{ id: 'security', label: 'Security & Login', icon: ShieldCheck },
],
},
{
category: 'App Settings',
tabs: [
{ id: 'appearance', label: 'Appearance', icon: PaintBrush },
{ id: 'voice', label: 'Voice & Video', icon: Microphone },
{ id: 'notifications', label: 'Notifications', icon: Bell },
],
},
];
function mapPresence(
status: string | undefined,
): 'online' | 'idle' | 'dnd' | 'offline' {
switch (status) {
case 'online':
return 'online';
case 'idle':
return 'idle';
case 'dnd':
return 'dnd';
default:
return 'offline';
}
}
export function MobileUserSettings({
isOpen,
onClose,
initialTab,
}: MobileUserSettingsProps) {
// `null` means we're on the category list. A non-null tab id
// shows that tab's panel full-screen with a back arrow in the
// top bar.
const [activeTab, setActiveTab] = useState<TabId | null>(
initialTab ?? null,
);
const logout = useLogout();
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
useEffect(() => {
if (isOpen) setActiveTab(initialTab ?? null);
}, [isOpen, initialTab]);
// Lock body scroll while the overlay is open.
useEffect(() => {
if (!isOpen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prev;
};
}, [isOpen]);
// Escape — pop one level (panel → list → close).
useEffect(() => {
if (!isOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
if (activeTab !== null) setActiveTab(null);
else onClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [isOpen, activeTab, onClose]);
const localUserId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const allUsers = useQuery(api.auth.getPublicKeys, isOpen ? {} : 'skip') ?? [];
const me = allUsers.find((u) => u.id === localUserId);
const { resolveStatus } = useOnlineUsers();
if (!isOpen) return null;
const displayName = me?.displayName || me?.username || 'You';
const username = me?.username || '';
const avatarUrl = me?.avatarUrl ?? null;
const presence = mapPresence(
localUserId
? resolveStatus(me?.status ?? 'offline', localUserId)
: 'offline',
);
const allTabs = TAB_GROUPS.flatMap((g) => g.tabs);
const activeMeta = activeTab ? allTabs.find((t) => t.id === activeTab) : undefined;
const goBack = () => setActiveTab(null);
const backIsClose = activeTab === null;
const renderPanel = () => {
switch (activeTab) {
case 'account':
return <AccountTab />;
case 'security':
return <SecurityTab />;
case 'voice':
return <VoiceTab />;
case 'appearance':
return <AppearanceTab />;
case 'notifications':
return (
<div>
<h2 className={styles.stubTitle}>Notifications</h2>
<p className={styles.stubBody}>
Notification settings coming soon.
</p>
</div>
);
default:
return null;
}
};
return createPortal(
<>
<div className={styles.overlay} role="dialog" aria-modal="true">
<div className={styles.topBar}>
<button
type="button"
className={styles.topBarButton}
onClick={backIsClose ? onClose : goBack}
aria-label={backIsClose ? 'Close' : 'Back'}
>
{backIsClose ? (
<X size={22} weight="bold" />
) : (
<CaretLeft size={22} weight="bold" />
)}
</button>
<div className={styles.topBarTitleBlock}>
<h2 className={styles.topBarTitle}>
{activeMeta ? activeMeta.label : 'Settings'}
</h2>
</div>
<div />
</div>
{activeTab === null ? (
<div className={`${styles.body} ${styles.bodyList}`}>
<div className={styles.hero}>
<Avatar
src={avatarUrl}
fallback={displayName}
size={88}
status={presence}
/>
<h3 className={styles.heroName}>{displayName}</h3>
{username && <p className={styles.heroHandle}>@{username}</p>}
</div>
{TAB_GROUPS.map((group) => (
<div key={group.category} className={styles.section}>
<div className={styles.sectionLabel}>{group.category}</div>
<div className={styles.tabList}>
{group.tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
type="button"
className={styles.tabItem}
onClick={() => setActiveTab(tab.id)}
>
<span className={styles.tabItemIcon}>
<Icon size={18} weight="fill" />
</span>
<span>{tab.label}</span>
<CaretRight
size={16}
weight="bold"
className={styles.tabItemCaret}
/>
</button>
);
})}
</div>
</div>
))}
<div className={styles.section}>
<div className={styles.dangerCard}>
<button
type="button"
className={styles.dangerItem}
onClick={() => setShowLogoutConfirm(true)}
>
<span className={styles.dangerItemIcon}>
<SignOut size={18} weight="fill" />
</span>
<span>Log Out</span>
</button>
</div>
</div>
</div>
) : (
<div className={`${styles.body} ${styles.bodyPanel}`}>
{renderPanel()}
</div>
)}
</div>
<LogoutConfirmModal
isOpen={showLogoutConfirm}
onClose={() => setShowLogoutConfirm(false)}
onConfirm={() => {
setShowLogoutConfirm(false);
void logout();
}}
/>
</>,
document.body,
);
}

View File

@@ -0,0 +1,316 @@
/* ── OverviewTab — server name + icon editor ─────────────────────────
Shared between desktop ServerSettingsModal and mobile MobileServerSettings.
The component branches its layout off `mobile` so the same form
logic powers both styles. Mobile variant uses the now-familiar
rounded-card pattern; desktop uses the existing inline form look. */
.root {
display: flex;
flex-direction: column;
gap: 18px;
}
.heading {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
/* ── Icon picker ────────────────────────────────────────────────────*/
.iconBlock {
display: flex;
align-items: center;
gap: 16px;
}
.iconPreviewWrap {
position: relative;
flex-shrink: 0;
}
.iconPreview {
width: 96px;
height: 96px;
border-radius: 24px;
object-fit: cover;
background-color: var(--background-secondary-alt);
display: block;
}
.iconInitials {
width: 96px;
height: 96px;
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--background-secondary-alt);
color: var(--text-primary);
font-size: 32px;
font-weight: 700;
}
.iconBadge {
position: absolute;
right: -2px;
bottom: -2px;
width: 28px;
height: 28px;
border-radius: 50%;
background-color: var(--brand-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
border: 3px solid var(--background-secondary);
pointer-events: none;
}
.iconActions {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.iconActionsHelp {
font-size: 12px;
color: var(--text-primary-muted);
margin: 0;
}
.iconButton {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: 1px solid var(--background-modifier-accent, rgba(255, 255, 255, 0.1));
border-radius: 8px;
background-color: var(--background-secondary-alt);
color: var(--text-primary);
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s;
-webkit-tap-highlight-color: transparent;
}
.iconButton:active {
background-color: var(--background-modifier-hover);
}
.iconButtonDanger {
color: hsl(350, calc(90% * var(--saturation-factor)), 65%);
border-color: hsla(350, calc(90% * var(--saturation-factor)), 65%, 0.3);
}
/* ── Name field ─────────────────────────────────────────────────────*/
.fieldLabel {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-primary-muted);
margin: 0 0 4px;
}
.nameInput {
width: 100%;
padding: 12px 14px;
background-color: var(--background-secondary-alt);
border: 1px solid transparent;
border-radius: 8px;
color: var(--text-primary);
font: inherit;
font-size: 15px;
font-weight: 500;
outline: none;
box-shadow: none;
-webkit-appearance: none;
appearance: none;
}
.nameInput:focus,
.nameInput:focus-visible {
border-color: var(--brand-primary);
outline: none;
}
/* ── Save / status row ──────────────────────────────────────────────*/
.actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 4px;
}
.saveButton {
padding: 10px 18px;
background-color: var(--brand-primary);
border: none;
border-radius: 8px;
color: #fff;
font: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: filter 0.15s;
-webkit-tap-highlight-color: transparent;
}
.saveButton:active:not(:disabled) {
filter: brightness(0.92);
}
.saveButton:disabled {
opacity: 0.5;
cursor: default;
}
.status {
padding: 10px 14px;
border-radius: 0.5rem;
font-size: 13px;
}
.statusSuccess {
background-color: hsl(139, calc(47.3% * var(--saturation-factor)), 20%);
color: hsl(139, calc(47.3% * var(--saturation-factor)), 85%);
}
.statusError {
background-color: hsl(0, calc(60% * var(--saturation-factor)), 22%);
color: hsl(0, calc(80% * var(--saturation-factor)), 85%);
}
.gate {
padding: 32px 16px;
text-align: center;
}
.gateTitle {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 8px;
}
.gateBody {
font-size: 14px;
color: var(--text-primary-muted);
margin: 0;
}
/* ── Mobile variant overrides ───────────────────────────────────────
When `mobile` is true the component renders the same widgets but
with the rounded-card chrome the rest of the mobile settings tabs
use. The inline overrides below tweak spacing / colors so it sits
inside MobileServerSettings comfortably. */
.mobileRoot {
gap: 16px;
}
.mobileIconBlock {
flex-direction: column;
align-items: center;
gap: 12px;
}
.mobileNameCard {
background-color: var(--background-secondary);
border-radius: 0.75rem;
padding: 14px 16px;
}
.mobileNameCard .nameInput {
background: transparent;
padding: 0;
font-size: 16px;
font-weight: 600;
border: none;
}
.mobileNameCard .nameInput:focus,
.mobileNameCard .nameInput:focus-visible {
border: none;
}
.mobileFieldLabel {
padding: 0 4px 6px;
margin: 0;
}
.mobileSaveButton {
width: 100%;
padding: 14px;
font-size: 15px;
border-radius: 0.75rem;
}
.mobileActions {
margin-top: 0;
}
/* ── Idle / AFK settings ─────────────────────────────────────── */
.idleSection {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 12px;
border-top: 1px solid var(--background-modifier-accent);
}
.idleHeading {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.idleDescription {
font-size: 13px;
color: var(--text-secondary);
margin: 0 0 4px 0;
}
.idleRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 768px) {
.idleRow {
grid-template-columns: 1fr;
}
}
.idleField {
display: flex;
flex-direction: column;
gap: 4px;
}
.idleSelect {
height: 40px;
padding: 0 12px;
border-radius: var(--radius-md, 6px);
background-color: var(--background-tertiary);
color: var(--text-primary);
border: none;
font-size: 0.9375rem;
font-family: inherit;
outline: none;
cursor: pointer;
}
.idleSelect:focus {
outline: 2px solid var(--brand-primary);
outline-offset: -2px;
}

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

View File

@@ -0,0 +1,244 @@
.container {
display: flex;
flex-direction: column;
gap: 18px;
max-width: 560px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.heading {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--text-primary, #fff);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
}
.label {
display: block;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-primary-muted, #a0a3a8);
}
.input {
width: 100%;
padding: 10px 12px;
background-color: var(--background-tertiary, rgba(0, 0, 0, 0.3));
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-primary, #fff);
font-size: 14px;
font-family: inherit;
outline: none;
box-sizing: border-box;
transition: border-color 0.1s;
}
.input:focus {
border-color: var(--brand-primary, #5865f2);
}
.counter {
position: absolute;
top: 0;
right: 0;
font-size: 11px;
color: var(--text-primary-muted, #a0a3a8);
}
.counterBad {
color: var(--status-danger, #da373c);
}
.swatchRow {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.swatch {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
transition: border-color 0.1s, transform 0.1s;
}
.swatch:hover {
transform: scale(1.1);
}
.swatchActive {
border-color: var(--text-primary, #fff);
}
.swatchNone {
background-color: var(--background-tertiary, rgba(0, 0, 0, 0.3));
color: var(--text-primary-muted, #a0a3a8);
}
.hexInput {
width: 120px;
padding: 6px 10px;
background-color: var(--background-tertiary, rgba(0, 0, 0, 0.3));
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-primary, #fff);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
outline: none;
box-sizing: border-box;
}
.hexInput:focus {
border-color: var(--brand-primary, #5865f2);
}
.powerHeader {
display: flex;
align-items: center;
justify-content: space-between;
}
.powerValue {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #fff);
}
.slider {
width: 100%;
accent-color: var(--brand-primary, #5865f2);
}
.powerHint {
margin: 0;
font-size: 12px;
line-height: 1.45;
color: var(--text-primary-muted, #a0a3a8);
}
.abilities {
padding: 12px 14px;
background-color: var(--background-secondary, rgba(255, 255, 255, 0.03));
border: 1px solid var(--background-modifier-accent, rgba(255, 255, 255, 0.06));
border-radius: 6px;
}
.abilitiesHeader {
font-size: 13px;
color: var(--text-secondary, #a0a3a8);
margin-bottom: 8px;
}
.abilitiesHeader strong {
color: var(--text-primary, #fff);
}
.abilitiesList {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 14px;
}
.abilitiesList li {
display: flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
}
.abilityOn {
color: var(--text-primary, #fff);
}
.abilityOn svg {
color: #2ecc71;
}
.abilityOff {
color: var(--text-primary-muted, #a0a3a8);
}
.abilityOff svg {
color: var(--text-primary-muted, #a0a3a8);
}
.status {
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
}
.statusSuccess {
background-color: rgba(46, 204, 113, 0.15);
border: 1px solid rgba(46, 204, 113, 0.4);
color: var(--text-primary, #fff);
}
.statusError {
background-color: rgba(234, 80, 80, 0.15);
border: 1px solid rgba(234, 80, 80, 0.4);
color: var(--text-primary, #fff);
}
.actions {
display: flex;
justify-content: space-between;
gap: 12px;
padding-top: 4px;
}
.readOnlyNote {
padding: 14px 16px;
background-color: var(--background-secondary, rgba(255, 255, 255, 0.03));
border: 1px solid var(--background-modifier-accent, rgba(255, 255, 255, 0.06));
border-radius: 6px;
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary, #a0a3a8);
}
.readOnlyNote p {
margin: 0;
}
.readOnlyNote p + p {
margin-top: 10px;
}
.readOnlyNote strong {
color: var(--text-primary, #fff);
}
.readOnlyNote code {
padding: 1px 4px;
background-color: var(--background-tertiary, rgba(0, 0, 0, 0.3));
border-radius: 3px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11.5px;
color: var(--text-primary, #fff);
}

View File

@@ -0,0 +1,304 @@
/**
* RoleEditor — right-hand panel of the Roles tab. Lets the admin
* edit a single role's name, color, and power level. Below the PL
* slider there's a live "abilities readout" driven by
* RoleManager.describePowerLevel, so admins see exactly which
* homeserver-enforced actions the chosen PL grants.
*
* Changes are saved via an explicit Save button — the form is dirty-
* tracked so Save is disabled until the user has made a real change.
* Deletion is its own button at the bottom with a confirm prompt.
*
* The @everyone entry passes `readOnly` and gets a short explanation
* instead of an editable form, since @everyone is synthetic and its
* permissions come from the room's baseline power level defaults.
*/
import { observer } from 'mobx-react-lite';
import { useEffect, useMemo, useState } from 'react';
import { Trash, CheckCircle, XCircle } from '@phosphor-icons/react';
import { Button } from '@brycord/ui';
import { RoleManager } from '@brycord/matrix-client';
import type { Role, PowerLevelAbilities } from '@brycord/matrix-client';
import RoleStore from '@app/stores/RoleStore';
import styles from './RoleEditor.module.css';
interface RoleEditorProps {
serverId: string;
role: Role;
readOnly?: boolean;
}
// A small, curated palette of Discord-like role colors plus a "no
// color" swatch. Admins can always type a custom hex too.
const COLOR_SWATCHES: Array<{ hex: string; name: string }> = [
{ hex: '#99AAB5', name: 'Default' },
{ hex: '#1ABC9C', name: 'Teal' },
{ hex: '#2ECC71', name: 'Green' },
{ hex: '#3498DB', name: 'Blue' },
{ hex: '#9B59B6', name: 'Purple' },
{ hex: '#E91E63', name: 'Magenta' },
{ hex: '#F1C40F', name: 'Yellow' },
{ hex: '#E67E22', name: 'Orange' },
{ hex: '#E74C3C', name: 'Red' },
{ hex: '#95A5A6', name: 'Gray' },
{ hex: '#607D8B', name: 'Slate' },
];
const HEX_REGEX = /^#[0-9a-fA-F]{6}$/;
export const RoleEditor = observer(function RoleEditor({ serverId, role, readOnly }: RoleEditorProps) {
const [name, setName] = useState(role.name);
const [color, setColor] = useState<string | undefined>(role.color);
const [powerLevel, setPowerLevel] = useState(role.powerLevel);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [status, setStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
// Reset local form state whenever the selected role changes.
useEffect(() => {
setName(role.name);
setColor(role.color);
setPowerLevel(role.powerLevel);
setStatus(null);
setConfirmDelete(false);
}, [role.id]);
// Compute what abilities the current slider PL unlocks — reads the
// space's live `m.room.power_levels` via RoleManager so the list
// reflects reality (admins may have customized thresholds).
const abilities: PowerLevelAbilities = useMemo(
() => RoleManager.getInstance().describePowerLevel(serverId, powerLevel),
[serverId, powerLevel],
);
const trimmedName = name.trim();
const isDirty =
trimmedName !== role.name ||
(color ?? undefined) !== (role.color ?? undefined) ||
powerLevel !== role.powerLevel;
const nameValid = trimmedName.length > 0 && trimmedName.length <= 32;
const canSave = isDirty && nameValid && !saving && !readOnly;
const handleSave = async () => {
if (!canSave) return;
setSaving(true);
setStatus(null);
try {
await RoleManager.getInstance().updateRole(serverId, role.id, {
name: trimmedName,
color: color,
powerLevel,
});
// Optimistically update the store so the left-column list
// reflects the new values immediately.
RoleStore.handleRoleUpdated(serverId, {
...role,
name: trimmedName,
color,
powerLevel,
});
setStatus({ type: 'success', message: 'Role saved.' });
} catch (err: any) {
setStatus({ type: 'error', message: err?.message || 'Failed to save role.' });
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (readOnly || deleting) return;
if (!confirmDelete) {
setConfirmDelete(true);
return;
}
setDeleting(true);
setStatus(null);
try {
await RoleManager.getInstance().deleteRole(serverId, role.id);
RoleStore.handleRoleDeleted(serverId, role.id);
} catch (err: any) {
setStatus({ type: 'error', message: err?.message || 'Failed to delete role.' });
setDeleting(false);
}
};
if (readOnly) {
return (
<div className={styles.container}>
<div className={styles.header}>
<h2 className={styles.heading}>{role.name}</h2>
</div>
<div className={styles.readOnlyNote}>
<p>
<strong>@everyone</strong> is the baseline role every member
has. Its abilities come from the server's default power
level thresholds in <code>m.room.power_levels</code>, not
from a per-role configuration.
</p>
<p>
To change what @everyone can do, edit the baseline power
level values directly (channel-level permission editing
will arrive in a future update).
</p>
</div>
<AbilitiesReadout powerLevel={0} abilities={
RoleManager.getInstance().describePowerLevel(serverId, 0)
} />
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.header}>
<h2 className={styles.heading}>Edit Role</h2>
</div>
<div className={styles.field}>
<label className={styles.label}>
Role Name
<input
type="text"
className={styles.input}
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={40}
placeholder="new role"
/>
</label>
<span className={`${styles.counter} ${trimmedName.length > 32 ? styles.counterBad : ''}`}>
{trimmedName.length}/32
</span>
</div>
<div className={styles.field}>
<span className={styles.label}>Role Color</span>
<div className={styles.swatchRow}>
<button
type="button"
className={`${styles.swatch} ${!color ? styles.swatchActive : ''} ${styles.swatchNone}`}
onClick={() => setColor(undefined)}
aria-label="No color"
>
<XCircle size={16} weight="bold" />
</button>
{COLOR_SWATCHES.map((s) => (
<button
key={s.hex}
type="button"
className={`${styles.swatch} ${color === s.hex ? styles.swatchActive : ''}`}
style={{ backgroundColor: s.hex }}
onClick={() => setColor(s.hex)}
aria-label={s.name}
/>
))}
</div>
<input
type="text"
className={styles.hexInput}
value={color ?? ''}
placeholder="#5865F2"
onChange={(e) => {
const val = e.target.value;
if (val === '' || HEX_REGEX.test(val)) {
setColor(val || undefined);
} else if (val.startsWith('#') && val.length <= 7) {
// Let the user type hex chars without immediate validation
setColor(val);
}
}}
/>
</div>
<div className={styles.field}>
<div className={styles.powerHeader}>
<span className={styles.label}>Power Level</span>
<span className={styles.powerValue}>{powerLevel}</span>
</div>
<input
type="range"
min={0}
max={100}
value={powerLevel}
onChange={(e) => setPowerLevel(Number(e.target.value))}
className={styles.slider}
/>
<p className={styles.powerHint}>
Power level determines what members with this role can do
at the Matrix protocol layer. Multiple roles can share the
same power level — they'll have identical abilities but
different names and colors.
</p>
</div>
<AbilitiesReadout powerLevel={powerLevel} abilities={abilities} />
{status && (
<div className={`${styles.status} ${status.type === 'error' ? styles.statusError : styles.statusSuccess}`}>
{status.message}
</div>
)}
<div className={styles.actions}>
<Button variant="primary" onClick={handleSave} disabled={!canSave}>
{saving ? 'Saving…' : 'Save Role'}
</Button>
<Button
variant="danger"
onClick={handleDelete}
disabled={deleting}
>
<Trash size={14} weight="fill" />
{confirmDelete ? 'Click again to confirm' : 'Delete Role'}
</Button>
</div>
</div>
);
});
// ── Abilities readout ──────────────────────────────────────────────
const ABILITY_LABELS: Array<{ key: keyof PowerLevelAbilities; label: string }> = [
{ key: 'canSendMessages', label: 'Send messages' },
{ key: 'canInvite', label: 'Invite members' },
{ key: 'canRenameNicknames', label: 'Rename other members' },
{ key: 'canRedact', label: "Delete others' messages" },
{ key: 'canKick', label: 'Kick members' },
{ key: 'canBan', label: 'Ban members' },
{ key: 'canSendState', label: 'Send room state events' },
{ key: 'canRenameChannels', label: 'Rename channels' },
{ key: 'canManageRoles', label: 'Manage roles' },
{ key: 'canEditPowerLevels', label: 'Edit server power levels' },
];
function AbilitiesReadout({
powerLevel,
abilities,
}: {
powerLevel: number;
abilities: PowerLevelAbilities;
}) {
return (
<div className={styles.abilities}>
<div className={styles.abilitiesHeader}>
At power level <strong>{powerLevel}</strong>, members with this role can:
</div>
<ul className={styles.abilitiesList}>
{ABILITY_LABELS.map(({ key, label }) => {
const allowed = abilities[key];
return (
<li key={key} className={allowed ? styles.abilityOn : styles.abilityOff}>
{allowed ? (
<CheckCircle size={14} weight="fill" />
) : (
<XCircle size={14} weight="fill" />
)}
<span>{label}</span>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -0,0 +1,143 @@
.container {
display: flex;
gap: 20px;
height: 100%;
min-height: 440px;
}
.list {
flex: 0 0 240px;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
.listHeader {
display: flex;
align-items: center;
justify-content: space-between;
}
.listTitle {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-primary-muted, #a0a3a8);
}
.listCount {
font-size: 11px;
color: var(--text-primary-muted, #a0a3a8);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.rolesList {
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
min-height: 0;
flex: 1 1 auto;
}
.roleItem {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background-color: var(--background-secondary, rgba(255, 255, 255, 0.03));
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
user-select: none;
transition: background-color 0.1s, border-color 0.1s;
}
.roleItem:hover {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.06));
}
.roleItemSelected {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.08));
border-color: var(--brand-primary, #5865f2);
}
.roleItemDragOver {
border-color: var(--brand-primary, #5865f2);
background-color: rgba(88, 101, 242, 0.12);
}
.roleItemEveryone {
opacity: 0.7;
margin-top: 8px;
border-top: 1px solid var(--background-modifier-accent, rgba(255, 255, 255, 0.06));
padding-top: 12px;
}
.roleDot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.roleName {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 500;
color: var(--text-primary, #fff);
}
.rolePower {
flex-shrink: 0;
font-size: 11px;
color: var(--text-primary-muted, #a0a3a8);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.editor {
flex: 1 1 auto;
min-width: 0;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-primary-muted, #a0a3a8);
font-size: 14px;
}
.gate {
padding: 60px 20px;
text-align: center;
}
.gateTitle {
margin: 0 0 8px;
font-size: 18px;
font-weight: 700;
color: var(--text-primary, #fff);
}
.gateBody {
margin: 0 auto;
max-width: 360px;
font-size: 14px;
color: var(--text-secondary, #a0a3a8);
}
.error {
padding: 8px 12px;
background-color: rgba(234, 80, 80, 0.15);
border: 1px solid rgba(234, 80, 80, 0.4);
border-radius: 6px;
color: var(--text-primary, #fff);
font-size: 12px;
}

View File

@@ -0,0 +1,218 @@
/**
* RolesTab — left column lists every role in the server (with
* @everyone pinned at the bottom), right column is the RoleEditor for
* the currently selected role. The create button at the top spawns a
* new role with default values and immediately selects it.
*
* Role reordering is via native HTML5 drag-and-drop — no new deps.
* `@everyone` is non-draggable, non-selectable, non-deletable, and
* has its own "read-only" notice in the editor.
*/
import { observer } from 'mobx-react-lite';
import { useEffect, useMemo, useState } from 'react';
import { Plus } from '@phosphor-icons/react';
import { Button } from '@brycord/ui';
import { RoleManager } from '@brycord/matrix-client';
import type { Role } from '@brycord/matrix-client';
import { MAX_ROLES_PER_SERVER } from '@brycord/constants';
import RoleStore from '@app/stores/RoleStore';
import SelectionStore from '@app/stores/SelectionStore';
import { RoleEditor } from './RoleEditor';
import { MobileRolesTab } from './MobileRolesTab';
import styles from './RolesTab.module.css';
interface RolesTabProps {
serverId: string;
}
export const RolesTab = observer(function RolesTab({ serverId }: RolesTabProps) {
// Mobile gets a completely different full-screen layout — a
// Fluxer-style list of rounded cards that pushes the role editor
// as a sub-screen. Delegates to a separate component so the
// desktop hooks below don't run on the mobile path (and vice
// versa), which keeps React's rules-of-hooks happy.
if (SelectionStore.isMobileViewport) {
return <MobileRolesTab serverId={serverId} />;
}
return <DesktopRolesTab serverId={serverId} />;
});
const DesktopRolesTab = observer(function DesktopRolesTab({ serverId }: RolesTabProps) {
const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const canManage = useMemo(() => {
try {
return RoleManager.getInstance().canManageRoles(serverId);
} catch {
return false;
}
}, [serverId]);
// All roles including synthetic @everyone. Everyone is at index 0
// in the store; split it out for rendering.
const allRoles = RoleStore.getRoles(serverId);
const editableRoles = allRoles
.filter((r) => r.id !== 'everyone')
.sort((a, b) => b.position - a.position); // show highest at the top
const everyone = allRoles.find((r) => r.id === 'everyone');
// Auto-select the first editable role when one exists and nothing
// is currently selected.
useEffect(() => {
if (selectedRoleId) return;
if (editableRoles.length > 0) setSelectedRoleId(editableRoles[0].id);
}, [editableRoles, selectedRoleId]);
// If the currently selected role was deleted, fall back.
useEffect(() => {
if (!selectedRoleId) return;
if (!allRoles.some((r) => r.id === selectedRoleId)) {
setSelectedRoleId(editableRoles[0]?.id ?? null);
}
}, [allRoles, editableRoles, selectedRoleId]);
if (!canManage) {
return (
<div className={styles.gate}>
<h2 className={styles.gateTitle}>Manage Roles</h2>
<p className={styles.gateBody}>
You need permission to manage roles in this server. Ask an
admin to grant you a role with a higher power level.
</p>
</div>
);
}
const handleCreate = async () => {
if (creating) return;
if (editableRoles.length >= MAX_ROLES_PER_SERVER) {
setError(`A server can have at most ${MAX_ROLES_PER_SERVER} roles.`);
return;
}
setCreating(true);
setError(null);
try {
const role = await RoleManager.getInstance().createRole(serverId, {
name: 'New Role',
powerLevel: 0,
});
RoleStore.handleRoleCreated(serverId, role);
setSelectedRoleId(role.id);
} catch (err: any) {
setError(err?.message || 'Failed to create role.');
} finally {
setCreating(false);
}
};
const handleDragStart = (e: React.DragEvent, roleId: string) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', roleId);
};
const handleDragOver = (e: React.DragEvent, overId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverId(overId);
};
const handleDrop = async (e: React.DragEvent, dropOnId: string) => {
e.preventDefault();
setDragOverId(null);
const draggedId = e.dataTransfer.getData('text/plain');
if (!draggedId || draggedId === dropOnId) return;
const order = editableRoles.map((r) => r.id);
const fromIdx = order.indexOf(draggedId);
const toIdx = order.indexOf(dropOnId);
if (fromIdx === -1 || toIdx === -1) return;
const next = [...order];
next.splice(fromIdx, 1);
next.splice(toIdx, 0, draggedId);
// Since the list is displayed with highest position first,
// reverse to match the RoleManager's ascending position order.
const orderedIds = [...next].reverse();
try {
await RoleManager.getInstance().reorderRoles(serverId, orderedIds);
} catch (err: any) {
setError(err?.message || 'Failed to reorder roles.');
}
};
const selectedRole: Role | undefined = selectedRoleId
? allRoles.find((r) => r.id === selectedRoleId)
: undefined;
return (
<div className={styles.container}>
<div className={styles.list}>
<div className={styles.listHeader}>
<span className={styles.listTitle}>Roles</span>
<span className={styles.listCount}>
{editableRoles.length}/{MAX_ROLES_PER_SERVER}
</span>
</div>
<Button
variant="primary"
onClick={handleCreate}
disabled={creating || editableRoles.length >= MAX_ROLES_PER_SERVER}
>
<Plus size={14} weight="bold" /> Create Role
</Button>
<div className={styles.rolesList}>
{editableRoles.map((role) => {
const selected = role.id === selectedRoleId;
return (
<div
key={role.id}
draggable
onDragStart={(e) => handleDragStart(e, role.id)}
onDragOver={(e) => handleDragOver(e, role.id)}
onDragLeave={() => setDragOverId((id) => (id === role.id ? null : id))}
onDrop={(e) => handleDrop(e, role.id)}
onClick={() => setSelectedRoleId(role.id)}
className={`${styles.roleItem} ${selected ? styles.roleItemSelected : ''} ${dragOverId === role.id ? styles.roleItemDragOver : ''}`}
>
<span
className={styles.roleDot}
style={{ backgroundColor: role.color || 'var(--text-primary-muted, #a0a3a8)' }}
/>
<span className={styles.roleName}>{role.name}</span>
<span className={styles.rolePower}>PL {role.powerLevel}</span>
</div>
);
})}
{everyone && (
<div
className={`${styles.roleItem} ${styles.roleItemEveryone} ${selectedRoleId === 'everyone' ? styles.roleItemSelected : ''}`}
onClick={() => setSelectedRoleId('everyone')}
>
<span className={styles.roleDot} style={{ backgroundColor: 'var(--text-primary-muted, #a0a3a8)' }} />
<span className={styles.roleName}>@everyone</span>
<span className={styles.rolePower}>PL 0</span>
</div>
)}
</div>
{error && <div className={styles.error}>{error}</div>}
</div>
<div className={styles.editor}>
{selectedRole ? (
<RoleEditor
serverId={serverId}
role={selectedRole}
readOnly={selectedRole.id === 'everyone'}
/>
) : (
<div className={styles.placeholder}>
Select a role on the left to edit, or create a new one.
</div>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,696 @@
/* ── Custom sidebar (replaces the default tab list) ──────────── */
.sidebarInner {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px 12px 24px;
}
.backButton {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.12s, color 0.12s;
}
.backButton:hover {
background: var(--background-modifier-hover);
color: var(--text-primary);
}
.sidebarSectionTitle {
padding: 6px 12px 2px;
font-size: 0.6875rem;
font-weight: 700;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.createButton {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid var(--background-header-secondary);
border-radius: 6px;
background: var(--background-tertiary);
color: var(--text-primary);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.12s, border-color 0.12s;
}
.createButton:hover {
background: var(--background-modifier-hover);
border-color: var(--background-modifier-accent);
}
.createButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.roleList {
display: flex;
flex-direction: column;
gap: 2px;
}
.roleItem {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--text-primary);
font: inherit;
font-size: 0.875rem;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: background-color 0.12s, border-color 0.12s;
}
.roleItem:hover {
background: var(--background-modifier-hover);
}
.roleItemActive,
.roleItemActive:hover {
background: var(--background-modifier-selected, var(--background-modifier-hover));
border-color: var(--brand-primary, #5865f2);
}
.roleDot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.roleName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emptyNote {
padding: 8px 12px;
font-size: 0.8125rem;
color: var(--text-tertiary);
}
/* ── Main editor column ──────────────────────────────────────── */
.editorEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 8px;
color: var(--text-tertiary);
font-size: 0.9375rem;
}
.headerRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.headerText {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.headerTitle {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.headerSubtitle {
font-size: 0.8125rem;
color: var(--text-tertiary);
margin: 0;
}
.deleteButton {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: 1px solid var(--background-header-secondary);
border-radius: 6px;
background: var(--background-tertiary);
color: var(--text-primary);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.12s, border-color 0.12s, color 0.12s;
}
.deleteButton:hover {
background: rgba(237, 66, 69, 0.12);
border-color: rgba(237, 66, 69, 0.4);
color: var(--status-danger, #ed4245);
}
.deleteButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Section headings ────────────────────────────────────────── */
.sectionHeading {
font-size: 0.6875rem;
font-weight: 700;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin: 24px 0 10px;
}
.sectionHeading:first-of-type {
margin-top: 0;
}
/* ── Display row ─────────────────────────────────────────────── */
.displayRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
.fieldGroup {
display: flex;
flex-direction: column;
gap: 6px;
}
.fieldLabel {
font-size: 0.75rem;
font-weight: 700;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.textInput {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--background-header-secondary);
border-radius: 6px;
background: var(--background-tertiary);
color: var(--text-primary);
font: inherit;
font-size: 0.9375rem;
outline: none;
transition: border-color 0.12s;
}
.textInput:focus {
border-color: var(--brand-primary);
}
.textInput:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Color field wraps the hex input + a native color picker button so
the user can either type a colour or pick one. */
.colorWrap {
position: relative;
display: flex;
align-items: center;
}
.colorInput {
padding-right: 42px;
}
.colorSwatchButton {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
width: 30px;
height: 30px;
padding: 0;
border: 1px solid var(--background-header-secondary);
border-radius: 6px;
background: transparent;
cursor: pointer;
overflow: hidden;
}
.colorSwatchInner {
display: block;
width: 100%;
height: 100%;
}
.hiddenColorInput {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
border: none;
padding: 0;
background: none;
}
.fieldHelp {
font-size: 0.75rem;
color: var(--text-tertiary);
margin-top: 2px;
}
/* ── Toggle row (hoist + mentionable) ────────────────────────── */
.toggleRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 0;
border-top: 1px solid var(--background-modifier-accent);
}
.toggleRow:first-of-type {
border-top: none;
}
.toggleText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.toggleTitle {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
.toggleDescription {
font-size: 0.8125rem;
color: var(--text-tertiary);
}
.toggle {
position: relative;
width: 40px;
height: 22px;
border: none;
border-radius: 999px;
background: var(--background-header-secondary);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s;
}
.toggleOn {
background: var(--brand-primary, #5865f2);
}
.toggleThumb {
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #ffffff;
transition: transform 0.15s ease;
}
.toggleOn .toggleThumb {
transform: translateX(18px);
}
.toggle:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Clear permissions row ───────────────────────────────────── */
.clearRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin: 16px 0 8px;
padding: 14px 0 0;
border-top: 1px solid var(--background-modifier-accent);
}
.clearText {
flex: 1;
font-size: 0.8125rem;
color: var(--text-tertiary);
margin: 0;
line-height: 1.4;
}
.clearButton {
padding: 8px 14px;
border: 1px solid var(--background-header-secondary);
border-radius: 6px;
background: var(--background-tertiary);
color: var(--text-primary);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.12s, border-color 0.12s;
}
.clearButton:hover {
background: var(--background-modifier-hover);
}
.clearButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Permission search row ───────────────────────────────────── */
.searchRow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.searchWrap {
position: relative;
flex: 1;
min-width: 0;
}
.searchIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
pointer-events: none;
}
.searchInput {
width: 100%;
padding: 10px 12px 10px 36px;
border: 1px solid var(--background-header-secondary);
border-radius: 6px;
background: var(--background-tertiary);
color: var(--text-primary);
font: inherit;
font-size: 0.875rem;
outline: none;
transition: border-color 0.12s;
}
.searchInput:focus {
border-color: var(--brand-primary);
}
/* ── Permissions list ────────────────────────────────────────── */
.permissionList {
display: flex;
flex-direction: column;
}
.permissionRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 14px 0;
border-top: 1px solid var(--background-modifier-accent);
}
.permissionRow:first-of-type {
border-top: none;
}
.permissionInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.permissionTitle {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary);
}
.permissionDescription {
font-size: 0.8125rem;
color: var(--text-tertiary);
line-height: 1.4;
}
.permissionEmpty {
padding: 24px 0;
text-align: center;
font-size: 0.875rem;
color: var(--text-tertiary);
}
.errorBanner {
padding: 10px 12px;
margin-bottom: 16px;
background: rgba(234, 80, 80, 0.12);
border: 1px solid rgba(234, 80, 80, 0.4);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.8125rem;
}
/* ── Unsaved-changes bar (replaces the modal content header) ── */
.unsavedBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
padding: 0;
}
.unsavedText {
font-size: 0.9375rem;
font-weight: 700;
color: var(--text-primary);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.unsavedActions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.resetButton {
padding: 8px 18px;
border: none;
border-radius: 6px;
background: var(--background-tertiary);
color: var(--text-primary);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.12s;
}
.resetButton:hover:not(:disabled) {
background: var(--background-modifier-hover);
}
.resetButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.saveButton {
padding: 8px 18px;
border: none;
border-radius: 6px;
background: var(--brand-primary, #5865f2);
color: #ffffff;
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: filter 0.12s;
}
.saveButton:hover:not(:disabled) {
filter: brightness(1.1);
}
.saveButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ── Mobile full-screen role list ────────────────────────────── */
.mobileList {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
}
.mobileListHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.mobileListTitle {
font-size: 1.125rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.mobileCreateButton {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 14px;
border: none;
border-radius: 8px;
background: var(--background-tertiary);
color: var(--text-primary);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.12s;
}
.mobileCreateButton:hover {
background: var(--background-modifier-hover);
}
.mobileRoleRow {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 16px;
border: 1px solid var(--background-header-secondary);
border-radius: 10px;
background: var(--background-secondary);
color: var(--text-primary);
font: inherit;
font-size: 0.9375rem;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: background-color 0.12s;
}
.mobileRoleRow:hover,
.mobileRoleRow:active {
background: var(--background-modifier-hover);
}
.mobileRoleRowName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobileRoleRowCaret {
color: var(--text-tertiary);
flex-shrink: 0;
}
/* ── Mobile editor back-to-roles row ─────────────────────────── */
.mobileBackRow {
display: flex;
margin-bottom: 12px;
}
.mobileBackButton {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: 1px solid var(--background-header-secondary);
border-radius: 6px;
background: var(--background-tertiary);
color: var(--text-primary);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.12s;
}
.mobileBackButton:hover {
background: var(--background-modifier-hover);
}

View File

@@ -0,0 +1,735 @@
/**
* RolesView — full "Roles & Permissions" editor used inside
* ServerSettingsModal. Takes over the whole settings surface: the
* sidebar shows a back-to-settings button + create-role + role list
* (not the tab list), and the main column shows the editor for the
* selected role (display + toggles + permissions).
*
* Layout / visual language is ported from the Fluxer GuildRolesTab
* screenshot the user shared, adapted to our Convex role schema:
* - Single server, no mentionable field in our schema (omitted)
* - Owner role is immutable and never shows here (filtered out)
* - Saves are direct `api.roles.update` calls — no explicit Save
* button / unsaved-changes banner; reactive queries update the
* other UI surfaces instantly.
*/
import { useMemo, useState, type ReactNode } from 'react';
import { useMutation, useQuery } from 'convex/react';
import {
DndContext,
type DragEndEvent,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
CaretLeft,
CaretRight,
MagnifyingGlass,
Plus,
Trash,
} from '@phosphor-icons/react';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import styles from './RolesView.module.css';
interface RoleDoc {
_id: Id<'roles'>;
name: string;
color: string;
position?: number;
permissions?: Record<string, boolean>;
isHoist?: boolean;
}
interface UseRolesViewOptions {
onBack: () => void;
}
interface RolesViewSlots {
sidebar: ReactNode;
content: ReactNode;
/**
* Optional override for the modal's content header. When the
* user has uncommitted edits across any role, this renders a
* yellow "unsaved changes" banner with Reset / Save Changes
* buttons (replacing the default title + close button). When
* there are no drafts, it's `null` and the caller falls back
* to its default header row.
*/
header: ReactNode | null;
hasChanges: boolean;
/**
* Full-screen mobile role list — shown by `MobileServerSettings`
* instead of the desktop sidebar when `activeTab === 'roles'`
* and no role is yet selected. Tapping a row flips `selectedId`
* via the hook's own state, so the mobile caller only needs to
* check `selectedId` to decide whether to show this or the
* editor.
*/
mobileList: ReactNode;
/** Currently selected role id (null if nothing selected). */
selectedId: string | null;
/** Clear the current selection — used by the mobile "Back to
* Roles" control to pop back to the list view. */
clearSelection: () => void;
}
/**
* Pending edits for a single role. Only fields the user touched
* are present; a field set to `undefined` (or missing) means "use
* the server value". Saving walks the Map and only sends the
* defined fields to `api.roles.update`.
*/
interface RoleDraft {
name?: string;
color?: string;
isHoist?: boolean;
permissions?: Record<string, boolean>;
}
interface PermissionMeta {
key: string;
label: string;
description: string;
}
// Our backend currently ships a small permission set — labels and
// descriptions are local metadata, not part of the schema, so new
// permissions added server-side must also be added here to be
// editable in the UI.
const PERMISSIONS: PermissionMeta[] = [
{
key: 'manage_channels',
label: 'Manage Channels',
description: 'Create, edit, or delete channels and categories.',
},
{
key: 'manage_roles',
label: 'Manage Roles',
description: 'Create, edit, or delete roles below your highest role.',
},
{
key: 'manage_messages',
label: 'Manage Messages',
description: 'Delete messages by other members and pin any message.',
},
{
key: 'create_invite',
label: 'Create Invite',
description: 'Invite other people to the server.',
},
{
key: 'embed_links',
label: 'Embed Links',
description: 'Render link previews for URLs sent in messages.',
},
{
key: 'attach_files',
label: 'Attach Files',
description: 'Upload images, videos, audio, and other files.',
},
{
key: 'move_members',
label: 'Move Members',
description: 'Move members between voice channels.',
},
{
key: 'mute_members',
label: 'Mute Members',
description: 'Server-mute other members in voice channels.',
},
{
key: 'manage_nicknames',
label: 'Manage Nicknames',
description: 'Change the nicknames of other members.',
},
];
const DEFAULT_COLOR = '#99aab5';
function isValidHex(value: string): boolean {
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value);
}
/**
* Sortable sidebar row — wraps the visual role button with the
* dnd-kit sortable bindings so pointer drags move the row in-place.
* The click handler still fires for normal taps because the sensor
* only activates after a 6px drag (configured on the parent).
*/
function SortableRoleRow({
role,
isActive,
onClick,
}: {
role: RoleDoc;
isActive: boolean;
onClick: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: role._id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : undefined,
};
return (
<button
ref={setNodeRef}
type="button"
className={`${styles.roleItem} ${isActive ? styles.roleItemActive : ''}`}
style={style}
onClick={onClick}
{...attributes}
{...listeners}
>
<span
className={styles.roleDot}
style={{ background: role.color || DEFAULT_COLOR }}
/>
<span className={styles.roleName}>{role.name}</span>
</button>
);
}
/**
* Hook — returns the two JSX slots the caller renders inside the
* settings modal shell. Not a component because it returns a plain
* object, which React components are not allowed to do.
*/
export function useRolesView({ onBack }: UseRolesViewOptions): RolesViewSlots {
const allRoles = (useQuery(api.roles.list, {}) ?? []) as RoleDoc[];
// Owner is permanently frozen — see convex/roles.ts. It's filtered
// out here so the sidebar never shows it and the editor never
// targets it. Backend mutations reject any Owner edit as a second
// line of defence.
//
// The sidebar renders two groups:
// - `draggableRoles` — everything except Owner and @everyone, in
// descending position order. These are the rows the user can
// drag to reorder.
// - `everyoneRole` — pinned to the bottom of the list regardless
// of its stored position so it always reads as the fallback
// bucket.
const { draggableRoles, everyoneRole } = useMemo(() => {
const everyone = allRoles.find((r) => r.name === '@everyone') ?? null;
const others = allRoles
.filter((r) => r.name !== 'Owner' && r.name !== '@everyone')
.sort((a, b) => (b.position ?? 0) - (a.position ?? 0));
return { draggableRoles: others, everyoneRole: everyone };
}, [allRoles]);
// Flat list used by the editor lookup (by selected id). Keeps
// the @everyone row targetable from the editor without dragging.
const roles = useMemo(
() => (everyoneRole ? [...draggableRoles, everyoneRole] : draggableRoles),
[draggableRoles, everyoneRole],
);
const createRole = useMutation(api.roles.create);
const updateRole = useMutation(api.roles.update);
const removeRole = useMutation(api.roles.remove);
const reorderRoles = useMutation(api.roles.reorder);
// dnd-kit sensors — `distance: 6` means a 6px drag is required
// before a drop is triggered, so a normal click still selects a
// role without accidentally starting a drag.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
);
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = draggableRoles.findIndex((r) => r._id === active.id);
const newIndex = draggableRoles.findIndex((r) => r._id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const nextOrder = arrayMove(draggableRoles, oldIndex, newIndex);
// Rewrite positions top-down. Higher array index = lower
// position number, matching the descending-position sort
// everywhere else. Leaves 10 unit gaps between rows so new
// rows can be inserted later without a full renumber.
const updates = nextOrder.map((r, idx) => ({
id: r._id,
position: (nextOrder.length - idx) * 10,
}));
setError(null);
try {
await reorderRoles({ updates });
} catch (err: any) {
setError(err?.message ?? 'Failed to reorder roles');
}
};
const [selectedId, setSelectedId] = useState<Id<'roles'> | null>(null);
const [permissionSearch, setPermissionSearch] = useState('');
const [drafts, setDrafts] = useState<Map<string, RoleDraft>>(new Map());
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const selected = useMemo(
() => roles.find((r) => r._id === selectedId) ?? null,
[roles, selectedId],
);
/** Merge any pending draft for the given role on top of the
* server state so the editor shows the in-progress values. */
const getEffectiveRole = (role: RoleDoc): RoleDoc => {
const draft = drafts.get(role._id);
if (!draft) return role;
return {
...role,
name: draft.name ?? role.name,
color: draft.color ?? role.color,
isHoist: draft.isHoist ?? role.isHoist,
permissions: draft.permissions ?? role.permissions,
};
};
const selectedWithDraft = selected ? getEffectiveRole(selected) : null;
const hasChanges = drafts.size > 0;
const updateDraft = (roleId: string, changes: Partial<RoleDraft>) => {
setError(null);
setDrafts((prev) => {
const next = new Map(prev);
const existing = next.get(roleId) ?? {};
next.set(roleId, { ...existing, ...changes });
return next;
});
};
const handleCreate = async () => {
setError(null);
try {
const created: any = await createRole({
name: 'new role',
color: DEFAULT_COLOR,
permissions: {},
isHoist: false,
position: 0,
});
if (created?._id) setSelectedId(created._id);
} catch (err: any) {
setError(err?.message ?? 'Failed to create role');
}
};
const handleDelete = async () => {
if (!selected) return;
if (!confirm(`Delete role "${selected.name}"?`)) return;
setError(null);
try {
await removeRole({ id: selected._id });
// Drop any pending draft for this role — it no longer exists.
setDrafts((prev) => {
const next = new Map(prev);
next.delete(selected._id);
return next;
});
setSelectedId(null);
} catch (err: any) {
setError(err?.message ?? 'Failed to delete role');
}
};
const handleReset = () => {
setDrafts(new Map());
setError(null);
};
const handleSaveChanges = async () => {
if (drafts.size === 0 || saving) return;
setError(null);
setSaving(true);
try {
// Persist every dirty role in parallel. For each draft we
// only forward the fields that were actually touched — an
// invalid hex colour is silently skipped so the user can
// still save the rest of their edits.
await Promise.all(
Array.from(drafts.entries()).map(([id, draft]) => {
const role = allRoles.find((r) => r._id === id);
if (!role) return undefined;
const updates: Record<string, unknown> = {
id: id as Id<'roles'>,
};
if (draft.name !== undefined) {
const clean = draft.name.trim();
if (clean) updates.name = clean;
}
if (draft.color !== undefined && isValidHex(draft.color)) {
updates.color = draft.color;
}
if (draft.isHoist !== undefined) {
updates.isHoist = draft.isHoist;
}
if (draft.permissions !== undefined) {
updates.permissions = draft.permissions;
}
return updateRole(updates as any);
}),
);
setDrafts(new Map());
} catch (err: any) {
setError(err?.message ?? 'Failed to save changes');
} finally {
setSaving(false);
}
};
const filteredPermissions = useMemo(() => {
const q = permissionSearch.trim().toLowerCase();
if (!q) return PERMISSIONS;
return PERMISSIONS.filter(
(p) =>
p.label.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q),
);
}, [permissionSearch]);
const currentPerms = selectedWithDraft?.permissions ?? {};
// Render the sidebar + main content. The caller is responsible for
// wrapping these in the modal shell — RolesView returns a fragment
// with two children via the `sidebar` / `content` render props.
return {
sidebar: (
<div className={styles.sidebarInner}>
<button type="button" className={styles.backButton} onClick={onBack}>
<CaretLeft size={14} weight="bold" />
Back to Settings
</button>
<div className={styles.sidebarSectionTitle}>Roles</div>
<button
type="button"
className={styles.createButton}
onClick={handleCreate}
>
<Plus size={14} weight="bold" />
Create Role
</button>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={draggableRoles.map((r) => r._id)}
strategy={verticalListSortingStrategy}
>
<div className={styles.roleList}>
{draggableRoles.length === 0 && !everyoneRole && (
<div className={styles.emptyNote}>No custom roles yet.</div>
)}
{draggableRoles.map((role) => (
<SortableRoleRow
key={role._id}
role={role}
isActive={selectedId === role._id}
onClick={() => setSelectedId(role._id)}
/>
))}
{everyoneRole && (
<button
key={everyoneRole._id}
type="button"
className={`${styles.roleItem} ${selectedId === everyoneRole._id ? styles.roleItemActive : ''}`}
onClick={() => setSelectedId(everyoneRole._id)}
>
<span
className={styles.roleDot}
style={{ background: everyoneRole.color || DEFAULT_COLOR }}
/>
<span className={styles.roleName}>{everyoneRole.name}</span>
</button>
)}
</div>
</SortableContext>
</DndContext>
</div>
),
content: !selected || !selectedWithDraft ? (
<div className={styles.editorEmpty}>
Select a role on the left to edit it, or create a new one.
</div>
) : (
<>
{error && <div className={styles.errorBanner}>{error}</div>}
<div className={styles.headerRow}>
<div className={styles.headerText}>
<h2 className={styles.headerTitle}>
Edit "{selectedWithDraft.name}"
</h2>
<p className={styles.headerSubtitle}>
Configure role settings and permissions
</p>
</div>
{selected.name !== '@everyone' && (
<button
type="button"
className={styles.deleteButton}
onClick={handleDelete}
>
<Trash size={14} weight="bold" />
Delete Role
</button>
)}
</div>
<div className={styles.sectionHeading}>Display</div>
<div className={styles.displayRow}>
<div className={styles.fieldGroup}>
<label className={styles.fieldLabel} htmlFor="role-name-input">
Role Name
</label>
<input
id="role-name-input"
className={styles.textInput}
type="text"
value={selectedWithDraft.name}
onChange={(e) =>
updateDraft(selected._id, { name: e.target.value })
}
maxLength={100}
/>
</div>
<div className={styles.fieldGroup}>
<label className={styles.fieldLabel} htmlFor="role-color-input">
Role Color
</label>
<div className={styles.colorWrap}>
<input
id="role-color-input"
className={`${styles.textInput} ${styles.colorInput}`}
type="text"
value={selectedWithDraft.color || ''}
onChange={(e) =>
updateDraft(selected._id, { color: e.target.value })
}
placeholder="#99aab5"
/>
<button
type="button"
className={styles.colorSwatchButton}
aria-label="Pick a color"
>
<span
className={styles.colorSwatchInner}
style={{
background: isValidHex(selectedWithDraft.color || '')
? selectedWithDraft.color
: DEFAULT_COLOR,
}}
/>
<input
className={styles.hiddenColorInput}
type="color"
value={
isValidHex(selectedWithDraft.color || '')
? selectedWithDraft.color
: DEFAULT_COLOR
}
onChange={(e) =>
updateDraft(selected._id, { color: e.target.value })
}
/>
</button>
</div>
<p className={styles.fieldHelp}>
Type a color (hex) or use the picker.
</p>
</div>
</div>
<div className={styles.toggleRow}>
<div className={styles.toggleText}>
<span className={styles.toggleTitle}>
Show this role separately
</span>
<span className={styles.toggleDescription}>
Lists members with this role in their own section in the member
list.
</span>
</div>
<button
type="button"
className={`${styles.toggle} ${selectedWithDraft.isHoist ? styles.toggleOn : ''}`}
onClick={() =>
updateDraft(selected._id, {
isHoist: !selectedWithDraft.isHoist,
})
}
aria-pressed={!!selectedWithDraft.isHoist}
aria-label="Show this role separately"
>
<span className={styles.toggleThumb} />
</button>
</div>
<div className={styles.clearRow}>
<p className={styles.clearText}>
Use this button to quickly clear all permissions.
</p>
<button
type="button"
className={styles.clearButton}
onClick={() => updateDraft(selected._id, { permissions: {} })}
>
Clear Permissions
</button>
</div>
<div className={styles.sectionHeading}>Permissions</div>
<div className={styles.searchRow}>
<div className={styles.searchWrap}>
<MagnifyingGlass
className={styles.searchIcon}
size={16}
weight="regular"
/>
<input
className={styles.searchInput}
type="text"
placeholder="Search Permissions..."
value={permissionSearch}
onChange={(e) => setPermissionSearch(e.target.value)}
/>
</div>
</div>
<div className={styles.permissionList}>
{filteredPermissions.length === 0 ? (
<div className={styles.permissionEmpty}>
No permissions match your search.
</div>
) : (
filteredPermissions.map((perm) => {
const enabled = !!currentPerms[perm.key];
return (
<div key={perm.key} className={styles.permissionRow}>
<div className={styles.permissionInfo}>
<span className={styles.permissionTitle}>
{perm.label}
</span>
<span className={styles.permissionDescription}>
{perm.description}
</span>
</div>
<button
type="button"
className={`${styles.toggle} ${enabled ? styles.toggleOn : ''}`}
onClick={() =>
updateDraft(selected._id, {
permissions: { ...currentPerms, [perm.key]: !enabled },
})
}
aria-pressed={enabled}
aria-label={perm.label}
>
<span className={styles.toggleThumb} />
</button>
</div>
);
})
)}
</div>
</>
),
header: hasChanges ? (
<div className={styles.unsavedBar}>
<span className={styles.unsavedText}>
Careful! You have unsaved changes.
</span>
<div className={styles.unsavedActions}>
<button
type="button"
className={styles.resetButton}
onClick={handleReset}
disabled={saving}
>
Reset
</button>
<button
type="button"
className={styles.saveButton}
onClick={handleSaveChanges}
disabled={saving}
>
{saving ? 'Saving…' : 'Save Changes'}
</button>
</div>
</div>
) : null,
hasChanges,
selectedId,
clearSelection: () => setSelectedId(null),
mobileList: (
<div className={styles.mobileList}>
{error && <div className={styles.errorBanner}>{error}</div>}
<div className={styles.mobileListHeader}>
<h3 className={styles.mobileListTitle}>Roles</h3>
<button
type="button"
className={styles.mobileCreateButton}
onClick={handleCreate}
>
<Plus size={14} weight="bold" />
Create Role
</button>
</div>
{draggableRoles.length === 0 && !everyoneRole && (
<div className={styles.emptyNote}>No custom roles yet.</div>
)}
{draggableRoles.map((role) => (
<button
key={role._id}
type="button"
className={styles.mobileRoleRow}
onClick={() => setSelectedId(role._id)}
>
<span
className={styles.roleDot}
style={{ background: role.color || DEFAULT_COLOR }}
/>
<span className={styles.mobileRoleRowName}>{role.name}</span>
<CaretRight
size={18}
weight="bold"
className={styles.mobileRoleRowCaret}
/>
</button>
))}
{everyoneRole && (
<button
type="button"
className={styles.mobileRoleRow}
onClick={() => setSelectedId(everyoneRole._id)}
>
<span
className={styles.roleDot}
style={{ background: everyoneRole.color || DEFAULT_COLOR }}
/>
<span className={styles.mobileRoleRowName}>
{everyoneRole.name}
</span>
<CaretRight
size={18}
weight="bold"
className={styles.mobileRoleRowCaret}
/>
</button>
)}
</div>
),
};
}

View File

@@ -0,0 +1,84 @@
.content {
padding: 0;
min-height: 520px;
max-height: 80vh;
overflow: hidden;
}
.layout {
display: flex;
min-height: 520px;
max-height: 80vh;
}
.sidebar {
flex: 0 0 180px;
display: flex;
flex-direction: column;
gap: 2px;
padding: 16px 8px;
border-right: 1px solid var(--background-modifier-accent, rgba(255, 255, 255, 0.06));
background-color: var(--background-secondary, rgba(0, 0, 0, 0.15));
}
.tab {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
background-color: transparent;
border: none;
border-radius: 6px;
color: var(--text-secondary, #a0a3a8);
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
text-align: left;
transition: background-color 0.1s, color 0.1s;
}
.tab:hover {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.06));
color: var(--text-primary, #fff);
}
.tabActive {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.08));
color: var(--text-primary, #fff);
}
.tabIcon {
display: inline-flex;
width: 16px;
height: 16px;
}
.panel {
flex: 1 1 auto;
min-width: 0;
padding: 20px 24px;
overflow-y: auto;
}
.stub {
padding: 40px 0;
text-align: center;
}
.stubTitle {
margin: 0 0 8px;
font-size: 18px;
font-weight: 700;
color: var(--text-primary, #fff);
}
.stubBody {
margin: 0;
font-size: 14px;
color: var(--text-secondary, #a0a3a8);
max-width: 360px;
margin-left: auto;
margin-right: auto;
}

View File

@@ -0,0 +1,878 @@
/**
* ServerSettingsModal — full-screen settings overlay (same pattern as
* UserSettingsModal) with Overview / Roles / Emojis tabs, wired to the
* Convex backend.
*
* Opens via the `brycord:open-server-settings` window event dispatched
* from GuildHeaderDropdown / GuildNavbar. All three tabs are inlined
* here so the file is self-contained — the sibling *Tab.tsx files
* still hold the old Matrix-based code and are not imported.
*/
import { useMutation, useQuery } from 'convex/react';
import { Gear, Plus, ShieldStar, Smiley, Trash, UploadSimple, X } from '@phosphor-icons/react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import { useIsMobile } from '../../hooks/useIsMobile';
import { CustomEmojisTab } from './CustomEmojisTab';
import { MobileServerSettings } from './MobileServerSettings';
import { useRolesView } from './RolesView';
import userStyles from './UserSettingsModal.module.css';
export type ServerSettingsTab = 'overview' | 'roles' | 'emojis';
interface ServerSettingsModalProps {
isOpen: boolean;
onClose: () => void;
initialTab?: ServerSettingsTab;
}
const TABS: Array<{ id: ServerSettingsTab; label: string; icon: typeof Gear }> = [
{ id: 'overview', label: 'Overview', icon: Gear },
{ id: 'roles', label: 'Roles', icon: ShieldStar },
{ id: 'emojis', label: 'Custom Emoji', icon: Smiley },
];
export function ServerSettingsModal({ isOpen, onClose, initialTab = 'overview' }: ServerSettingsModalProps) {
const [activeTab, setActiveTab] = useState<ServerSettingsTab>(initialTab);
const isMobile = useIsMobile();
useEffect(() => {
if (isOpen) setActiveTab(initialTab);
}, [isOpen, initialTab]);
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [isOpen, onClose]);
// Mobile gets the full-screen overlay with a category list ↔ panel
// flow, desktop gets the two-column modal below.
if (isMobile) {
return (
<MobileServerSettings
isOpen={isOpen}
onClose={onClose}
initialTab={initialTab}
/>
);
}
// Roles view takes over the whole settings surface — its own
// sidebar (back + create + role list) and its own main column
// (role editor). We still delegate the back button to flipping
// activeTab back to 'overview' so the outer modal stays open.
const rolesView = useRolesView({ onBack: () => setActiveTab('overview') });
if (!isOpen) return null;
const active = TABS.find((t) => t.id === activeTab) ?? TABS[0];
const inRolesView = activeTab === 'roles';
return createPortal(
<div className={userStyles.overlay} onClick={onClose}>
<div className={userStyles.modal} onClick={(e) => e.stopPropagation()}>
<nav className={userStyles.sidebar}>
{inRolesView ? (
rolesView.sidebar
) : (
<div className={userStyles.sidebarInner}>
<div className={userStyles.sidebarCategoryTitle}>Server Settings</div>
<div className={userStyles.sidebarGroup}>
{TABS.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
className={`${userStyles.sidebarItem} ${isActive ? userStyles.sidebarItemActive : ''}`}
onClick={() => setActiveTab(tab.id)}
>
<span className={userStyles.sidebarItemIcon}>
<Icon size={18} />
</span>
<span className={userStyles.sidebarItemLabel}>{tab.label}</span>
</button>
);
})}
</div>
</div>
)}
</nav>
<div className={userStyles.contentColumn}>
<div className={userStyles.contentHeader}>
{inRolesView && rolesView.header ? (
// Dirty roles editor takes over the header with a
// Reset / Save Changes bar. The close X is hidden
// until the user either saves or resets their drafts.
rolesView.header
) : (
<>
<h1 className={userStyles.contentTitle}>
{inRolesView ? 'Roles & Permissions' : active.label}
</h1>
<button
type="button"
className={userStyles.closeButton}
onClick={onClose}
aria-label="Close settings"
>
<X size={22} weight="bold" />
</button>
</>
)}
</div>
<div className={userStyles.contentScroll}>
<div className={userStyles.contentInner}>
{activeTab === 'overview' && <OverviewTab />}
{inRolesView && rolesView.content}
{activeTab === 'emojis' && <CustomEmojisTab />}
</div>
</div>
</div>
</div>
</div>,
document.body,
);
}
/* ------------------------------------------------------------------- */
/* Overview */
/* ------------------------------------------------------------------- */
export function OverviewTab() {
const userId =
typeof localStorage !== 'undefined' ? (localStorage.getItem('userId') as Id<'userProfiles'> | null) : null;
const settings = useQuery(api.serverSettings.get, {}) as
| {
serverName?: string;
iconUrl?: string | null;
afkChannelId?: Id<'channels'> | null;
afkTimeout?: number;
}
| null
| undefined;
const channels = useQuery(api.channels.list, {}) ?? [];
const updateSettings = useMutation(api.serverSettings.update);
const voiceChannels = useMemo(
() => channels.filter((c: any) => c.type === 'voice'),
[channels],
);
const [afkChannelId, setAfkChannelId] = useState<string>('');
const [afkTimeout, setAfkTimeout] = useState<number>(300);
const [status, setStatus] = useState<{ type: 'ok' | 'err'; message: string } | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (settings) {
setAfkChannelId((settings.afkChannelId as string) ?? '');
setAfkTimeout(settings.afkTimeout ?? 300);
}
}, [settings?.afkChannelId, settings?.afkTimeout]);
const handleSave = async () => {
if (!userId) {
setStatus({ type: 'err', message: 'You must be logged in.' });
return;
}
if (afkTimeout < 60 || afkTimeout > 3600) {
setStatus({ type: 'err', message: 'AFK timeout must be between 60 and 3600 seconds.' });
return;
}
setSaving(true);
setStatus(null);
try {
await updateSettings({
userId,
afkChannelId: (afkChannelId || undefined) as Id<'channels'> | undefined,
afkTimeout,
});
setStatus({ type: 'ok', message: 'Saved.' });
} catch (err: any) {
setStatus({ type: 'err', message: err?.message ?? 'Failed to save.' });
} finally {
setSaving(false);
}
};
const serverName = settings?.serverName ?? 'Server';
const iconUrl = settings?.iconUrl ?? null;
const initials = serverName
.split(/\s+/)
.map((w) => w[0])
.join('')
.slice(0, 2)
.toUpperCase();
return (
<>
<div className={userStyles.profileHeader}>
<h2 className={userStyles.profileSubheading}>Overview</h2>
<p className={userStyles.profileDescription}>
Manage your server's display info and idle settings.
</p>
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'center', marginBottom: 24 }}>
{iconUrl ? (
<img
src={iconUrl}
alt={serverName}
style={{
width: 96,
height: 96,
borderRadius: 24,
objectFit: 'cover',
background: 'var(--background-tertiary)',
}}
/>
) : (
<div
style={{
width: 96,
height: 96,
borderRadius: 24,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--background-tertiary)',
color: 'var(--text-primary)',
fontSize: 30,
fontWeight: 700,
}}
>
{initials || 'S'}
</div>
)}
<div style={{ flex: 1 }}>
<Label>Server Name</Label>
<input
type="text"
value={serverName}
readOnly
style={inputStyle}
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
<div>
<Label>AFK / Idle Channel</Label>
<select
value={afkChannelId}
onChange={(e) => setAfkChannelId(e.target.value)}
style={inputStyle}
>
<option value="">No AFK Channel</option>
{voiceChannels.map((ch: any) => (
<option key={ch._id} value={ch._id}>
{ch.name}
</option>
))}
</select>
</div>
<div>
<Label>AFK Timeout (seconds)</Label>
<input
type="number"
min={60}
max={3600}
value={afkTimeout}
onChange={(e) => setAfkTimeout(Number(e.target.value))}
style={inputStyle}
/>
</div>
</div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<button type="button" onClick={handleSave} disabled={saving} style={primaryBtnStyle}>
{saving ? 'Saving' : 'Save Changes'}
</button>
{status && (
<span
style={{
fontSize: 13,
color: status.type === 'err' ? '#f87171' : 'var(--text-secondary)',
}}
>
{status.message}
</span>
)}
</div>
</>
);
}
/* ------------------------------------------------------------------- */
/* Roles */
/* ------------------------------------------------------------------- */
const PERMISSION_KEYS = [
'manage_channels',
'manage_roles',
'manage_messages',
'create_invite',
'embed_links',
'attach_files',
'move_members',
'mute_members',
'manage_nicknames',
] as const;
type PermissionKey = (typeof PERMISSION_KEYS)[number];
interface RoleDoc {
_id: Id<'roles'>;
name: string;
color: string;
position?: number;
permissions?: Record<string, boolean>;
isHoist?: boolean;
}
export function RolesTab() {
// Owner is a bootstrap-only, permanently frozen role — hide it
// from the editable roles list so admins can't delete, rename,
// or strip its permissions. Backend mutations enforce the same
// rule server-side as a second line of defence.
const roles = (
(useQuery(api.roles.list, {}) ?? []) as RoleDoc[]
).filter((r) => r.name !== 'Owner');
const createRole = useMutation(api.roles.create);
const updateRole = useMutation(api.roles.update);
const removeRole = useMutation(api.roles.remove);
const [selectedId, setSelectedId] = useState<Id<'roles'> | null>(null);
const [error, setError] = useState<string | null>(null);
const selected = useMemo(
() => roles.find((r) => r._id === selectedId) ?? null,
[roles, selectedId],
);
// Local draft state for the editor — seeded when selection changes.
const [draftName, setDraftName] = useState('');
const [draftColor, setDraftColor] = useState('#99aab5');
const [draftPerms, setDraftPerms] = useState<Record<string, boolean>>({});
const [draftHoist, setDraftHoist] = useState(false);
useEffect(() => {
if (selected) {
setDraftName(selected.name);
setDraftColor(selected.color || '#99aab5');
setDraftPerms({ ...(selected.permissions ?? {}) });
setDraftHoist(!!selected.isHoist);
}
}, [selectedId]);
const handleCreate = async () => {
setError(null);
try {
const created: any = await createRole({
name: 'new role',
color: '#99aab5',
permissions: {},
isHoist: false,
position: 0,
});
if (created?._id) setSelectedId(created._id);
} catch (err: any) {
setError(err?.message ?? 'Failed to create role');
}
};
const handleSave = async () => {
if (!selected) return;
setError(null);
try {
await updateRole({
id: selected._id,
name: draftName,
color: draftColor,
permissions: draftPerms,
isHoist: draftHoist,
});
} catch (err: any) {
setError(err?.message ?? 'Failed to save role');
}
};
const handleDelete = async () => {
if (!selected) return;
if (!confirm(`Delete role "${selected.name}"?`)) return;
setError(null);
try {
await removeRole({ id: selected._id });
setSelectedId(null);
} catch (err: any) {
setError(err?.message ?? 'Failed to delete role');
}
};
return (
<>
<div className={userStyles.profileHeader}>
<h2 className={userStyles.profileSubheading}>Roles</h2>
<p className={userStyles.profileDescription}>
Use roles to group your members and assign permissions.
</p>
</div>
{error && (
<div
style={{
padding: '8px 12px',
marginBottom: 12,
background: 'rgba(234,80,80,0.15)',
border: '1px solid rgba(234,80,80,0.4)',
borderRadius: 6,
color: 'var(--text-primary)',
fontSize: 13,
}}
>
{error}
</div>
)}
<div style={{ display: 'flex', gap: 20, minHeight: 440 }}>
<div style={{ flex: '0 0 240px', display: 'flex', flexDirection: 'column', gap: 10 }}>
<button type="button" onClick={handleCreate} style={{ ...primaryBtnStyle, width: '100%' }}>
<Plus size={14} weight="bold" style={{ marginRight: 6, verticalAlign: 'middle' }} />
Create Role
</button>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto' }}>
{roles.length === 0 && (
<div style={{ fontSize: 13, color: 'var(--text-secondary)', padding: 8 }}>
No roles yet.
</div>
)}
{roles.map((role) => (
<button
key={role._id}
type="button"
onClick={() => setSelectedId(role._id)}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 12px',
background:
selectedId === role._id
? 'var(--background-modifier-hover)'
: 'var(--background-secondary)',
border:
selectedId === role._id
? '1px solid var(--brand-primary)'
: '1px solid transparent',
borderRadius: 6,
cursor: 'pointer',
textAlign: 'left',
color: 'var(--text-primary)',
font: 'inherit',
fontSize: 14,
}}
>
<span
style={{
width: 12,
height: 12,
borderRadius: '50%',
flexShrink: 0,
background: role.color || '#99aab5',
}}
/>
<span
style={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{role.name}
</span>
</button>
))}
</div>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{!selected ? (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: 'var(--text-secondary)',
fontSize: 14,
}}
>
Select a role to edit, or create a new one.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<Label>Role Name</Label>
<input
type="text"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
style={inputStyle}
/>
</div>
<div>
<Label>Role Color</Label>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<input
type="color"
value={draftColor}
onChange={(e) => setDraftColor(e.target.value)}
style={{
width: 48,
height: 40,
border: 'none',
borderRadius: 6,
background: 'transparent',
cursor: 'pointer',
}}
/>
<input
type="text"
value={draftColor}
onChange={(e) => setDraftColor(e.target.value)}
style={{ ...inputStyle, maxWidth: 140 }}
/>
</div>
</div>
<div>
<Label>Display Options</Label>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
fontSize: 14,
color: 'var(--text-primary)',
}}
>
<input
type="checkbox"
checked={draftHoist}
onChange={(e) => setDraftHoist(e.target.checked)}
/>
Display role members separately from online members
</label>
</div>
<div>
<Label>Permissions</Label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{PERMISSION_KEYS.map((key) => (
<label
key={key}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 13,
color: 'var(--text-primary)',
padding: '6px 8px',
background: 'var(--background-tertiary)',
borderRadius: 6,
}}
>
<input
type="checkbox"
checked={!!draftPerms[key]}
onChange={(e) =>
setDraftPerms((p) => ({ ...p, [key]: e.target.checked }))
}
/>
{key.replace(/_/g, ' ')}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 8 }}>
<button type="button" onClick={handleSave} style={primaryBtnStyle}>
Save
</button>
<button type="button" onClick={handleDelete} style={dangerBtnStyle}>
<Trash size={14} weight="bold" style={{ marginRight: 6, verticalAlign: 'middle' }} />
Delete
</button>
</div>
</div>
)}
</div>
</div>
</>
);
}
/* ------------------------------------------------------------------- */
/* Emojis */
/* ------------------------------------------------------------------- */
/**
* Back-compat shim — the old desktop EmojisTab lived inline in this
* file and is still imported by MobileServerSettings. It now just
* renders the shared `CustomEmojisTab` so both surfaces get the new
* Fluxer-style layout without a second code path.
*/
export function EmojisTab() {
return <CustomEmojisTab />;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function _LegacyEmojisTabDeadCode(): any {
const userId =
typeof localStorage !== 'undefined' ? (localStorage.getItem('userId') as Id<'userProfiles'> | null) : null;
const emojis = (useQuery(api.customEmojis.list, {}) ?? []) as CustomEmojiDoc[];
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const uploadEmoji = useMutation(api.customEmojis.upload);
const removeEmoji = useMutation(api.customEmojis.remove);
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [status, setStatus] = useState<{ type: 'ok' | 'err'; message: string } | null>(null);
const handlePickFile = () => fileInputRef.current?.click();
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (fileInputRef.current) fileInputRef.current.value = '';
if (!file) return;
if (!userId) {
setStatus({ type: 'err', message: 'You must be logged in.' });
return;
}
// Prompt for an emoji name based on the filename.
const defaultName =
file.name
.replace(/\.[^.]+$/, '')
.replace(/[^a-zA-Z0-9_]/g, '_')
.slice(0, 32) || 'emoji';
const name = prompt('Emoji name (letters, numbers, underscores; 2-32 chars):', defaultName);
if (!name) return;
setUploading(true);
setStatus(null);
try {
const uploadUrl = await generateUploadUrl({});
const res = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': file.type },
body: file,
});
if (!res.ok) throw new Error('Upload failed');
const { storageId } = (await res.json()) as { storageId: Id<'_storage'> };
await uploadEmoji({ userId, name, storageId });
setStatus({ type: 'ok', message: `Added :${name}:` });
} catch (err: any) {
setStatus({ type: 'err', message: err?.message ?? 'Upload failed' });
} finally {
setUploading(false);
}
};
const handleRemove = async (emojiId: Id<'customEmojis'>) => {
if (!userId) return;
if (!confirm('Delete this emoji?')) return;
setStatus(null);
try {
await removeEmoji({ userId, emojiId });
} catch (err: any) {
setStatus({ type: 'err', message: err?.message ?? 'Failed to remove' });
}
};
return (
<>
<div className={userStyles.profileHeader}>
<h2 className={userStyles.profileSubheading}>Emojis</h2>
<p className={userStyles.profileDescription}>
Upload custom emojis for this server. PNG, GIF, or WebP up to ~500 KB works best.
</p>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 16 }}>
<button type="button" onClick={handlePickFile} disabled={uploading} style={primaryBtnStyle}>
<UploadSimple size={14} weight="bold" style={{ marginRight: 6, verticalAlign: 'middle' }} />
{uploading ? 'Uploading…' : 'Upload Emoji'}
</button>
<span style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
{emojis.length} {emojis.length === 1 ? 'emoji' : 'emojis'}
</span>
{status && (
<span
style={{
fontSize: 13,
color: status.type === 'err' ? '#f87171' : 'var(--text-secondary)',
}}
>
{status.message}
</span>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/gif,image/webp,image/jpeg"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
{emojis.length === 0 ? (
<div
style={{
padding: '32px 20px',
textAlign: 'center',
fontSize: 13,
color: 'var(--text-secondary)',
background: 'var(--background-secondary)',
border: '1px dashed var(--background-modifier-accent)',
borderRadius: 8,
}}
>
No custom emojis yet. Upload one to get started.
</div>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))',
gap: 10,
}}
>
{emojis.map((emoji) => (
<div
key={emoji._id}
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 6,
padding: '10px 8px',
background: 'var(--background-secondary)',
border: '1px solid var(--background-modifier-accent)',
borderRadius: 8,
}}
>
<img
src={emoji.src}
alt={emoji.name}
style={{ width: 56, height: 56, objectFit: 'contain' }}
/>
<span
style={{
fontFamily: 'ui-monospace, Menlo, Consolas, monospace',
fontSize: 11,
color: 'var(--text-secondary)',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
:{emoji.name}:
</span>
<button
type="button"
onClick={() => handleRemove(emoji._id)}
style={{
position: 'absolute',
top: 4,
right: 4,
width: 22,
height: 22,
border: 'none',
borderRadius: 4,
background: 'rgba(0,0,0,0.55)',
color: '#fff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
aria-label={`Delete ${emoji.name}`}
>
<Trash size={12} weight="bold" />
</button>
</div>
))}
</div>
)}
</>
);
}
/* ------------------------------------------------------------------- */
/* Shared bits */
/* ------------------------------------------------------------------- */
function Label({ children }: { children: React.ReactNode }) {
return (
<label
style={{
display: 'block',
marginBottom: 6,
fontSize: 12,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: 0.5,
color: 'var(--text-secondary)',
}}
>
{children}
</label>
);
}
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 12px',
background: 'var(--background-tertiary)',
border: '1px solid var(--background-modifier-accent)',
borderRadius: 6,
color: 'var(--text-primary)',
fontSize: 14,
fontFamily: 'inherit',
outline: 'none',
boxSizing: 'border-box',
};
const primaryBtnStyle: React.CSSProperties = {
background: 'var(--brand-primary)',
color: '#fff',
border: 'none',
padding: '10px 18px',
borderRadius: 6,
cursor: 'pointer',
fontWeight: 600,
fontSize: 14,
};
const dangerBtnStyle: React.CSSProperties = {
background: 'var(--status-danger, #da373c)',
color: '#fff',
border: 'none',
padding: '10px 18px',
borderRadius: 6,
cursor: 'pointer',
fontWeight: 600,
fontSize: 14,
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export { UserSettingsModal } from './UserSettingsModal';