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