feat(web): space avatars with GIF support and live space list sync [CI]
This commit is contained in:
1 parent
96f92c2ba9
commit
ba0b6c258a
14 files changed
+153
-33
No files matched your search
@@ -146,3 +146,37 @@
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.avatarRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: grid;
|
||||
flex: 0 0 64px;
|
||||
place-items: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-2);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatarFallback {
|
||||
color: var(--text);
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.avatarSide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.avatarButtons {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import type { ChannelInfo, ChannelSettingsPatch } from '@/core-bridge/types';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon, type IconName } from '@/ui/Icon';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import { AvatarEditor } from '../home/AvatarEditor';
|
||||
import { checkAvatarSource } from '../home/avatarSource';
|
||||
import { errorMessage } from '@/core-bridge/errors';
|
||||
import { useToast } from '@/store/toast';
|
||||
import { RolesEditor } from './RolesEditor';
|
||||
import styles from './ChannelSettingsSheet.module.css';
|
||||
|
||||
@@ -42,6 +46,10 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
const [joinPassword, setJoinPassword] = useState('');
|
||||
const [clearPassword, setClearPassword] = useState(false);
|
||||
const [tab, setTab] = useState<Tab>('basic');
|
||||
const [avatar, setAvatar] = useState(channel.avatar);
|
||||
const [editing, setEditing] = useState<File>();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const spaceName = name.trim() || channel.name || '空间';
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const ttlOptions = withCurrent(info.choices.ttl_seconds, ttlSeconds);
|
||||
@@ -52,7 +60,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
setSaving(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
await onSave({ name: name.trim(), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy,
|
||||
await onSave({ name: name.trim(), ...(avatar !== channel.avatar ? { avatar: avatar ?? '' } : {}), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy,
|
||||
...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) });
|
||||
onClose();
|
||||
} catch {
|
||||
@@ -62,7 +70,13 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
}
|
||||
}
|
||||
|
||||
const dirty = name.trim() !== (channel.name ?? '') || maxMembers !== channel.maxMembers || membersCanUpload !== channel.membersCanUpload
|
||||
async function chooseAvatar(file?: File) {
|
||||
if (!file) return;
|
||||
try { await checkAvatarSource(file); setEditing(file); }
|
||||
catch (cause) { useToast.getState().push({ kind: 'error', text: errorMessage(cause) }); }
|
||||
}
|
||||
|
||||
const dirty = avatar !== channel.avatar || name.trim() !== (channel.name ?? '') || maxMembers !== channel.maxMembers || membersCanUpload !== channel.membersCanUpload
|
||||
|| ttlSeconds !== channel.ttlSeconds || fileMaxDownloads !== channel.maxDownloads || joinPolicy !== channel.joinPolicy || joinPassword !== '' || clearPassword;
|
||||
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);
|
||||
|
||||
@@ -74,8 +88,20 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
<Icon name={item.icon} size={18} /><span>{item.label}</span></button>)}
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<section className={styles.pane} role="tabpanel" aria-label={TABS.find((item) => item.id === tab)?.label}>
|
||||
{editing ? <AvatarEditor kind="space" file={editing} name={spaceName} onCancel={() => setEditing(undefined)} onDone={(url) => { setAvatar(url); setEditing(undefined); }} />
|
||||
: <section className={styles.pane} role="tabpanel" aria-label={TABS.find((item) => item.id === tab)?.label}>
|
||||
{tab === 'basic' ? <>
|
||||
<div className={styles.avatarRow}>
|
||||
{avatar ? <img className={styles.avatar} src={avatar} alt="空间头像" /> : <span className={`${styles.avatar} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(spaceName)[0]?.toLocaleUpperCase()}</span>}
|
||||
<div className={styles.avatarSide}>
|
||||
<input ref={fileInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" aria-label="选择空间头像文件" hidden onChange={(event) => { void chooseAvatar(event.target.files?.[0]); event.target.value = ''; }} />
|
||||
<div className={styles.avatarButtons}>
|
||||
<Button icon="image" disabled={!owner} onClick={() => fileInput.current?.click()}>更换空间头像</Button>
|
||||
{avatar && owner ? <Button variant="ghost" onClick={() => setAvatar(undefined)}>移除</Button> : null}
|
||||
</div>
|
||||
<span className={styles.note}>PNG、JPEG、WebP 或 GIF 动图,所有成员可见</span>
|
||||
</div>
|
||||
</div>
|
||||
<label className={styles.field}><span>空间名称</span>
|
||||
<input type="text" maxLength={48} value={name} disabled={!owner} placeholder="例如:项目组" onChange={(event) => setName(event.target.value)} /></label>
|
||||
<p className={styles.note}>空间名称和设置对加入者及服务端可见。</p>
|
||||
@@ -108,7 +134,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
<p className={styles.note}>空间主及受邀用户无需加入密码。修改规则不会移除已加入成员。</p>
|
||||
</> : null}
|
||||
{tab === 'roles' ? <RolesEditor /> : null}
|
||||
</section>
|
||||
</section>}
|
||||
{tab === 'roles' ? null : owner ? (
|
||||
<div className={styles.footer}>
|
||||
{failed ? <p className={styles.error} role="alert">保存失败,请检查名称和设置后重试。</p> : null}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-2);
|
||||
@@ -92,3 +94,18 @@
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.headerAvatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-md);
|
||||
object-fit: cover;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export function SpaceRail({ id, onCreate, onJoin }: RailProps) {
|
||||
const name = channel.name || channel.id.slice(0, 8);
|
||||
return <button key={channel.id} type="button" className={`${styles.space} ${channel.id === id ? styles.current : ''}`}
|
||||
aria-current={channel.id === id ? 'page' : undefined} title={name} aria-label={name} onClick={() => navigate(channelPath(channel.id))}>
|
||||
{Array.from(name)[0]?.toLocaleUpperCase()}
|
||||
{channel.avatar ? <img className={styles.avatar} src={channel.avatar} alt="" /> : Array.from(name)[0]?.toLocaleUpperCase()}
|
||||
</button>;
|
||||
})}
|
||||
<button type="button" className={`${styles.space} ${styles.action}`} title="新建空间" aria-label="新建空间" disabled={!username} onClick={onCreate}><Icon name="plus" size={20} /></button>
|
||||
@@ -30,6 +30,7 @@ export function SpaceHeader() {
|
||||
const name = channel?.name || `${channel?.id.slice(0, 8) ?? ''}…`;
|
||||
return <div className={styles.header}>
|
||||
{channel?.mode === 'raw' ? <span className={styles.raw}>明文</span> : <Icon name="lock" size={14} />}
|
||||
{channel?.avatar ? <img className={styles.headerAvatar} src={channel.avatar} alt="" /> : null}
|
||||
<strong>{name}</strong>
|
||||
</div>;
|
||||
}
|
||||
Reference in new issue
Block a user