feat(web): space avatars with GIF support and live space list sync [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful

This commit is contained in:
justme committed 2026-10-17 13:44:16 +08:00
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;
}
+2 -1
View File
@@ -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>;
}