feat(web): role colors with picker, Material 3 draw, effects and inline markdown
This commit is contained in:
1 parent
bef8898cd8
commit
3b062687cd
20 files changed
+688
-83
No files matched your search
@@ -24,7 +24,7 @@
|
||||
|
||||
/* 身份组页内容多:加高,列表在滚动时固定在左边 */
|
||||
.tall {
|
||||
height: min(560px, 80dvh);
|
||||
height: min(760px, 84dvh);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
|
||||
@@ -67,7 +67,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
const invalid = maxMembers < 1 || maxMembers > info.limits.max_members || ttlSeconds < info.limits.min_ttl_seconds || ttlSeconds > info.limits.max_ttl_seconds || Boolean(joinPassword && joinPassword.length < 8);
|
||||
|
||||
return (
|
||||
<Sheet title="空间设置" onClose={onClose} wide>
|
||||
<Sheet title="空间设置" onClose={onClose} wide={tab === 'roles' ? 'xl' : true}>
|
||||
<div className={`${styles.layout} ${tab === 'roles' ? styles.tall : ''}`}>
|
||||
<div className={styles.tabs} role="tablist" aria-orientation="vertical" aria-label="空间设置分类">
|
||||
{TABS.filter((item) => owner || item.id !== 'roles').map((item) => <button key={item.id} type="button" role="tab" aria-selected={tab === item.id} onClick={() => setTab(item.id)}>
|
||||
|
||||
@@ -13,10 +13,6 @@
|
||||
vertical-align: 1px;
|
||||
}
|
||||
|
||||
.owner {
|
||||
--c: var(--text);
|
||||
}
|
||||
|
||||
.gray {
|
||||
--c: var(--text-dim);
|
||||
}
|
||||
@@ -40,3 +36,99 @@
|
||||
.purple {
|
||||
--c: var(--role-purple);
|
||||
}
|
||||
|
||||
.gradient {
|
||||
background: var(--g) text;
|
||||
box-shadow: inset 0 0 0 1px var(--line-strong);
|
||||
color: transparent;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sunset {
|
||||
--g: var(--grad-sunset);
|
||||
}
|
||||
|
||||
.ocean {
|
||||
--g: var(--grad-ocean);
|
||||
}
|
||||
|
||||
.aurora {
|
||||
--g: var(--grad-aurora);
|
||||
}
|
||||
|
||||
.candy {
|
||||
--g: var(--grad-candy);
|
||||
}
|
||||
|
||||
.gold {
|
||||
--g: var(--grad-gold);
|
||||
}
|
||||
|
||||
.rainbow {
|
||||
--g: var(--grad-rainbow);
|
||||
}
|
||||
|
||||
.badge code {
|
||||
font-family: monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.badge a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 动画效果。系统设置了「减少动态效果」时一律不播放 */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.breathe {
|
||||
animation: breathe 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.blink {
|
||||
animation: blink 1.2s steps(1, end) infinite;
|
||||
}
|
||||
|
||||
.cycle {
|
||||
animation: cycle 6s linear infinite;
|
||||
}
|
||||
|
||||
.marquee.gradient {
|
||||
background-repeat: repeat-x;
|
||||
background-size: 200% 100%;
|
||||
animation: marquee 3s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cycle {
|
||||
to {
|
||||
filter: hue-rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
to {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { badgesOf, type Badge } from '@/lib/roles';
|
||||
import { InlineMarkdown } from '@/lib/inlineMarkdown';
|
||||
import { badgesOf, customColorStyle, effectOf, isGradient, type Badge } from '@/lib/roles';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import styles from './RoleBadges.module.css';
|
||||
|
||||
function List({ items }: { items: Badge[] }) {
|
||||
return <>{items.map((badge) => <span key={badge.key} className={`${styles.badge} ${styles[badge.color]}`}>{badge.text}</span>)}</>;
|
||||
return <>{items.map((badge) => <span key={badge.key} style={customColorStyle(badge.color)} className={`${styles.badge} ${styles[badge.color.split('|')[0]!] ?? ''} ${isGradient(badge.color) ? styles.gradient : ''} ${styles[effectOf(badge.color) ?? 'still'] ?? ''}`}><InlineMarkdown text={badge.text} /></span>)}</>;
|
||||
}
|
||||
|
||||
/** 名字前后的身份标签:空间主固定一个,其余是身份组里自定义的前缀、后缀 */
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.head,
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 300px) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
@media (width < 720px) {
|
||||
.body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.modes,
|
||||
.stops {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 28px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button[aria-checked='true'] {
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 20px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.stop {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stop[aria-checked='true'] {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sv,
|
||||
.hue {
|
||||
position: relative;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.sv {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.hue {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 2px solid var(--surface);
|
||||
border-radius: var(--radius-avatar);
|
||||
box-shadow: 0 0 0 1px var(--line-strong);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hexRow,
|
||||
.import {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hexRow input {
|
||||
width: 96px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.draw {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.drawHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--space-1);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-soft);
|
||||
color: var(--text-dim);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chip {
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { extractColors, hexToHsv, hsvToHex, parseHex, type Hsv } from '@/lib/colorMath';
|
||||
import { drawColors, type DrawnColor } from '@/lib/colorDraw';
|
||||
import { EFFECTS, gradientOf, isGradient, joinColor, splitColor, stopsOf } from '@/lib/roles';
|
||||
import styles from './RoleColorPicker.module.css';
|
||||
|
||||
const MAX_STOPS = 5;
|
||||
const HUES = 'linear-gradient(90deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)';
|
||||
|
||||
/** 在一个元素上按住拖动,回调给出 0~1 的相对位置 */
|
||||
function drag(onMove: (x: number, y: number) => void) {
|
||||
return (event: React.PointerEvent<HTMLElement>) => {
|
||||
const box = event.currentTarget.getBoundingClientRect();
|
||||
const move = (e: { clientX: number; clientY: number }) => onMove(Math.min(1, Math.max(0, (e.clientX - box.left) / box.width)), Math.min(1, Math.max(0, (e.clientY - box.top) / box.height)));
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
move(event);
|
||||
const target = event.currentTarget;
|
||||
const onMoveEvent = (e: PointerEvent) => move(e);
|
||||
const stop = () => { target.removeEventListener('pointermove', onMoveEvent); target.removeEventListener('pointerup', stop); target.removeEventListener('pointercancel', stop); };
|
||||
target.addEventListener('pointermove', onMoveEvent); target.addEventListener('pointerup', stop); target.addEventListener('pointercancel', stop);
|
||||
};
|
||||
}
|
||||
|
||||
/** 一个颜色的选择器:饱和度/明度方块 + 色相条 + 色值输入 */
|
||||
function ColorField({ value, onChange }: { value: string; onChange: (hex: string) => void }) {
|
||||
// 拖动时在 HSV 里保存色相,否则拖到灰色/黑色时色相会丢
|
||||
const [hsv, setHsv] = useState<Hsv>(() => hexToHsv(value));
|
||||
const last = useRef(value);
|
||||
const [text, setText] = useState(value);
|
||||
if (last.current !== value) { last.current = value; const next = hexToHsv(value); setHsv((old) => (hsvToHex(old) === value ? old : next)); setText(value); }
|
||||
const apply = (next: Hsv) => { setHsv(next); const hex = hsvToHex(next); last.current = hex; setText(hex); onChange(hex); };
|
||||
|
||||
return <div className={styles.field}>
|
||||
<div className={styles.sv} style={{ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, ${hsvToHex({ h: hsv.h, s: 1, v: 1 })})` }}
|
||||
onPointerDown={drag((x, y) => apply({ ...hsv, s: x, v: 1 - y }))} role="presentation">
|
||||
<span className={styles.thumb} style={{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%`, background: value }} />
|
||||
</div>
|
||||
<div className={styles.hue} style={{ background: HUES }} onPointerDown={drag((x) => apply({ ...hsv, h: x * 360 }))} role="presentation">
|
||||
<span className={styles.thumb} style={{ left: `${(hsv.h / 360) * 100}%`, top: '50%', background: hsvToHex({ h: hsv.h, s: 1, v: 1 }) }} />
|
||||
</div>
|
||||
<label className={styles.hexRow}>色值
|
||||
<input value={text} maxLength={7} spellCheck={false} aria-label="色值"
|
||||
onChange={(event) => { setText(event.target.value); const hex = parseHex(event.target.value); if (hex) { last.current = hex; setHsv(hexToHsv(hex)); onChange(hex); } }} />
|
||||
</label>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 身份组前后缀的颜色:自定义纯色,或 2~5 个色标的渐变(均匀分布,从左到右)。
|
||||
* 也可以把在线渐变生成器(如 cssgradient.io)复制出来的 CSS 粘贴进来,自动取出其中的颜色。
|
||||
*/
|
||||
export function RoleColorPicker({ color: full, onPick: pickFull }: { color: string; onPick: (color: string) => void }) {
|
||||
const { base: color, effect } = splitColor(full);
|
||||
// 选颜色时保留已经选好的动画效果;纯色没有跑马灯
|
||||
const onPick = (next: string) => pickFull(joinColor(next, effect === 'marquee' && !isGradient(next) ? undefined : effect));
|
||||
const [seed, setSeed] = useState(Math.random);
|
||||
const stops = stopsOf(color);
|
||||
const gradient = isGradient(color) && stops.length > 0;
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [paste, setPaste] = useState('');
|
||||
const at = Math.min(selected, Math.max(0, stops.length - 1));
|
||||
const drawn: DrawnColor[] = useMemo(() => drawColors(() => seed, 6, gradient ? 'gradient' : 'solid'), [seed, gradient]);
|
||||
const solid = parseHex(color) ?? '#3b6ea8';
|
||||
const setStop = (index: number, hex: string) => onPick(gradientOf(stops.map((stop, i) => (i === index ? hex : stop))));
|
||||
|
||||
function importText(text: string) {
|
||||
setPaste(text);
|
||||
const colors = extractColors(text, MAX_STOPS);
|
||||
if (colors.length >= 2) { onPick(gradientOf(colors)); setSelected(0); }
|
||||
else if (colors.length === 1) onPick(colors[0]!);
|
||||
}
|
||||
|
||||
return <div className={styles.picker}>
|
||||
<div className={styles.head}>
|
||||
<div className={styles.modes} role="radiogroup" aria-label="颜色类型">
|
||||
<button type="button" role="radio" aria-checked={!gradient} className={styles.button} onClick={() => onPick(solid)}>纯色</button>
|
||||
<button type="button" role="radio" aria-checked={gradient} className={styles.button}
|
||||
onClick={() => onPick(gradientOf(gradient ? stops : [solid, hsvToHex({ ...hexToHsv(solid), h: (hexToHsv(solid).h + 60) % 360 })]))}>渐变</button>
|
||||
</div>
|
||||
{gradient ? <>
|
||||
<div className={styles.bar} style={{ background: `linear-gradient(90deg, ${stops.join(', ')})` }} aria-hidden />
|
||||
<div className={styles.stops} role="radiogroup" aria-label="色标">
|
||||
{stops.map((stop, index) => <button key={index} type="button" role="radio" aria-checked={index === at} aria-label={`色标 ${index + 1}`}
|
||||
className={styles.stop} style={{ background: stop }} onClick={() => setSelected(index)} />)}
|
||||
<button type="button" className={styles.button} disabled={stops.length >= MAX_STOPS}
|
||||
onClick={() => { onPick(gradientOf([...stops, stops[stops.length - 1]!])); setSelected(stops.length); }}>加色标</button>
|
||||
<button type="button" className={styles.button} disabled={stops.length <= 2}
|
||||
onClick={() => { onPick(gradientOf(stops.filter((_, i) => i !== at))); setSelected(0); }}>删除</button>
|
||||
<button type="button" className={styles.button} onClick={() => onPick(gradientOf([...stops].reverse()))}>反转</button>
|
||||
</div>
|
||||
</> : null}
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
{gradient ? <ColorField key={at} value={stops[at]!} onChange={(hex) => setStop(at, hex)} /> : <ColorField value={solid} onChange={onPick} />}
|
||||
<div className={styles.side}>
|
||||
<div className={styles.draw}>
|
||||
<div className={styles.drawHead}>随机{gradient ? '渐变' : '纯色'} · Material 3<button type="button" className={styles.button} onClick={() => setSeed(Math.random())}>换一批</button></div>
|
||||
<div className={styles.cards}>
|
||||
{drawn.map((item, index) => <button key={index} type="button" className={styles.card} title={item.label} onClick={() => { onPick(item.color); setSelected(0); }}>
|
||||
<span className={styles.chip} style={{ background: isGradient(item.color) ? `linear-gradient(90deg, ${stopsOf(item.color).join(', ')})` : item.color }} />
|
||||
<small>{item.label}</small></button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.modes} role="radiogroup" aria-label="动画效果">
|
||||
<button type="button" role="radio" aria-checked={!effect} className={styles.button} onClick={() => pickFull(color)}>无动画</button>
|
||||
{EFFECTS.map((item) => <button key={item.id} type="button" role="radio" aria-checked={effect === item.id} className={styles.button}
|
||||
disabled={item.id === 'marquee' && !gradient} title={item.id === 'marquee' && !gradient ? '跑马灯需要渐变色' : undefined}
|
||||
onClick={() => pickFull(joinColor(color, item.id))}>{item.label}</button>)}
|
||||
</div>
|
||||
<label className={styles.import}>从在线生成器导入
|
||||
<input value={paste} placeholder="粘贴 CSS 渐变或色值,如 linear-gradient(90deg, #ff8a3d, #f0366b)" onChange={(event) => importText(event.target.value)} />
|
||||
</label>
|
||||
<small className={styles.hint}><a href="https://cssgradient.io" target="_blank" rel="noreferrer noopener">cssgradient.io</a></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -90,55 +90,6 @@
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.colors {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
--c: var(--text-dim);
|
||||
|
||||
min-width: 36px;
|
||||
min-height: 28px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-soft);
|
||||
color: var(--c);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.swatch[aria-checked='true'] {
|
||||
border-color: var(--c);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gray {
|
||||
--c: var(--text-dim);
|
||||
}
|
||||
|
||||
.blue {
|
||||
--c: var(--busy);
|
||||
}
|
||||
|
||||
.green {
|
||||
--c: var(--ok);
|
||||
}
|
||||
|
||||
.amber {
|
||||
--c: var(--warn);
|
||||
}
|
||||
|
||||
.red {
|
||||
--c: var(--danger);
|
||||
}
|
||||
|
||||
.purple {
|
||||
--c: var(--role-purple);
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -205,3 +156,19 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.fixed {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
@@ -2,25 +2,27 @@ import { useEffect, useState } from 'react';
|
||||
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess';
|
||||
import { errorMessage } from '@/core-bridge/errors';
|
||||
import type { RoleInfo } from '@/core-bridge/types';
|
||||
import { PERMISSIONS, ROLE_COLORS } from '@/lib/roles';
|
||||
import { customColorStyle, effectOf, isGradient, normalizeColor, OWNER_ROLE_ID, PERMISSIONS, toCustomColor } from '@/lib/roles';
|
||||
import { InlineMarkdown } from '@/lib/inlineMarkdown';
|
||||
import { useToast } from '@/store/toast';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import badgeStyles from './RoleBadges.module.css';
|
||||
import { RoleColorPicker } from './RoleColorPicker';
|
||||
import styles from './RolesEditor.module.css';
|
||||
|
||||
const COLOR_LABEL = { gray: '灰', blue: '蓝', green: '绿', amber: '黄', red: '红', purple: '紫' } as const;
|
||||
|
||||
function RoleForm({ role, members, onDone }: { role?: RoleInfo; members: ChannelMember[]; onDone: (id?: string) => void }) {
|
||||
const people = useWorkspace((s) => s.people);
|
||||
const createRole = useWorkspace((s) => s.createRole);
|
||||
const updateRole = useWorkspace((s) => s.updateRole);
|
||||
const deleteRole = useWorkspace((s) => s.deleteRole);
|
||||
const setRoleMembers = useWorkspace((s) => s.setRoleMembers);
|
||||
const fixed = role?.id === OWNER_ROLE_ID;
|
||||
const [name, setName] = useState(role?.name ?? '');
|
||||
const [prefix, setPrefix] = useState(role?.prefix ?? '');
|
||||
const [suffix, setSuffix] = useState(role?.suffix ?? '');
|
||||
const [color, setColor] = useState<string>(role?.color ?? 'gray');
|
||||
const [color, setColor] = useState<string>(toCustomColor(normalizeColor(role?.color)));
|
||||
const [permissions, setPermissions] = useState<ReadonlySet<string>>(new Set(role?.permissions));
|
||||
const [chosen, setChosen] = useState<ReadonlySet<string>>(new Set(role?.members));
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -37,41 +39,39 @@ function RoleForm({ role, members, onDone }: { role?: RoleInfo; members: Channel
|
||||
const request = { name: name.trim(), prefix: prefix.trim() || null, suffix: suffix.trim() || null, color, permissions: [...permissions] };
|
||||
let id = role?.id;
|
||||
if (id) await updateRole(id, request); else id = await createRole(request);
|
||||
await setRoleMembers(id, [...chosen]);
|
||||
if (!fixed) await setRoleMembers(id, [...chosen]);
|
||||
useToast.getState().push({ kind: 'ok', text: '身份组已保存' });
|
||||
return id;
|
||||
});
|
||||
const remove = () => run(async () => { if (role) await deleteRole(role.id); return undefined; });
|
||||
const sample = `${prefix.trim()}${prefix.trim() ? ' ' : ''}你的名字${suffix.trim() ? ' ' : ''}${suffix.trim()}`;
|
||||
const tag = (text: string, key: string) => text.trim() ? <span key={key} style={customColorStyle(color)} className={`${badgeStyles.badge} ${badgeStyles[color.split('|')[0]!] ?? ''} ${isGradient(color) ? badgeStyles.gradient : ''} ${badgeStyles[effectOf(color) ?? 'still'] ?? ''}`}><InlineMarkdown text={text.trim()} /></span> : null;
|
||||
|
||||
return <form className={styles.form} onSubmit={(event) => { event.preventDefault(); if (name.trim()) void save(); }}>
|
||||
<label className={styles.field}>名称
|
||||
<input maxLength={48} value={name} placeholder="例如:管理组" onChange={(event) => setName(event.target.value)} /></label>
|
||||
<input maxLength={48} disabled={fixed} value={name} placeholder="例如:管理组" onChange={(event) => setName(event.target.value)} /></label>
|
||||
<div className={styles.pair}>
|
||||
<label className={styles.field}>前缀(可选)
|
||||
<input maxLength={12} value={prefix} placeholder="例如:【组长】" onChange={(event) => setPrefix(event.target.value)} /></label>
|
||||
<input value={prefix} placeholder="例如:【组长】 或 **组长**" onChange={(event) => setPrefix(event.target.value)} /></label>
|
||||
<label className={styles.field}>后缀(可选)
|
||||
<input maxLength={12} value={suffix} placeholder="例如:管理员" onChange={(event) => setSuffix(event.target.value)} /></label>
|
||||
<input value={suffix} placeholder="例如:管理员 或 *管理员*" onChange={(event) => setSuffix(event.target.value)} /></label>
|
||||
</div>
|
||||
<div className={styles.field}>颜色 · 聊天里显示为 <b>{sample}</b>
|
||||
<div className={styles.colors} role="radiogroup" aria-label="标签颜色">
|
||||
{ROLE_COLORS.map((item) => <button key={item} type="button" role="radio" aria-checked={color === item} className={`${styles.swatch} ${styles[item]}`}
|
||||
onClick={() => setColor(item)}>{COLOR_LABEL[item]}</button>)}
|
||||
</div>
|
||||
<div className={styles.field}>颜色 · 前后缀可用纯色或渐变
|
||||
<RoleColorPicker color={color} onPick={setColor} />
|
||||
<div className={styles.preview} aria-label="预览">{tag(prefix, 'p')}<span>你的名字</span>{tag(suffix, 's')}</div>
|
||||
</div>
|
||||
<fieldset className={styles.group}><legend>权限</legend>
|
||||
<fieldset className={styles.group} disabled={fixed}><legend>权限{fixed ? ' · 空间主拥有全部权限' : ''}</legend>
|
||||
{PERMISSIONS.map((item) => <label key={item.id} className={styles.check}>
|
||||
<input type="checkbox" checked={permissions.has(item.id)} onChange={() => setPermissions(toggle(permissions, item.id))} />
|
||||
<input type="checkbox" checked={fixed || permissions.has(item.id)} onChange={() => setPermissions(toggle(permissions, item.id))} />
|
||||
<span>{item.label}<small>{item.hint}</small></span></label>)}
|
||||
</fieldset>
|
||||
<fieldset className={styles.group}><legend>成员 · {chosen.size}</legend>
|
||||
{fixed ? <p className={styles.fixed}>这个组固定属于空间主,只能修改前后缀和颜色,不能删除,也不能添加其他成员。</p> : <fieldset className={styles.group}><legend>成员 · {chosen.size}</legend>
|
||||
{members.length ? members.map((member) => <label key={member.username} className={styles.check}>
|
||||
<input type="checkbox" checked={chosen.has(member.username)} onChange={() => setChosen(toggle(chosen, member.username))} />
|
||||
<span>{people[member.username]?.displayName || member.username}<small>{member.username}</small></span></label>)
|
||||
: <p className={styles.empty}>还没有其他成员。成员接受邀请后可以加入身份组。</p>}
|
||||
</fieldset>
|
||||
</fieldset>}
|
||||
<div className={styles.actions}>
|
||||
{role ? (confirmDelete
|
||||
{role && !fixed ? (confirmDelete
|
||||
? <><Button variant="ghost" disabled={busy} onClick={() => setConfirmDelete(false)}>先不删</Button><Button variant="danger" disabled={busy} onClick={() => { void remove(); }}>确认删除</Button></>
|
||||
: <Button variant="ghost" icon="trash" disabled={busy} onClick={() => setConfirmDelete(true)}>删除身份组</Button>) : null}
|
||||
<Button type="submit" variant="primary" disabled={busy || !name.trim()}>{busy ? '保存中…' : role ? '保存' : '创建身份组'}</Button>
|
||||
|
||||
Reference in new issue
Block a user