支持 app.ini 配置与系统服务安装,并优化前端布局与无障碍体验。

引入类 Gitea 的 app.ini、Windows Service/systemd 控制;前端增加侧栏抽屉、回到顶部、标签输入与浮层 a11y。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-29 14:09:58 +08:00
parent 962bb15298
commit 9487c8ab02
37 changed files with 2025 additions and 403 deletions

View File

@@ -0,0 +1,125 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { ArrowUp } from 'lucide-react';
/** 滚动超过该距离后显示按钮 */
const SHOW_THRESHOLD = 320;
/** 路由切换后等待滚动容器挂载的最大重试次数 */
const BIND_RETRY_MAX = 24;
const BIND_RETRY_MS = 50;
/**
* 定位当前真正滚动的容器。
* 前台:.post-list-scroll / .page-wrap / .main-content--compose
* 后台:.admin-main
*/
function pickScrollEl(scope: ParentNode): HTMLElement | null {
const list = scope.querySelector<HTMLElement>('.post-list-scroll');
if (list) return list;
const page = scope.querySelector<HTMLElement>('.page-wrap');
if (page) return page;
const compose = scope.querySelector<HTMLElement>('.main-content--compose');
if (compose) return compose;
return scope.querySelector<HTMLElement>('.admin-main');
}
function findScrollScope(): ParentNode | null {
return document.querySelector('.main-content')
?? document.querySelector('.admin-shell');
}
export default function BackToTop() {
const loc = useLocation();
const [visible, setVisible] = useState(false);
const scrollElRef = useRef<HTMLElement | null>(null);
const syncVisible = useCallback(() => {
const el = scrollElRef.current;
setVisible(!!el && el.scrollTop > SHOW_THRESHOLD);
}, []);
useEffect(() => {
let cancelled = false;
let attempts = 0;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let bound: HTMLElement | null = null;
const onScroll = () => {
if (!cancelled) syncVisible();
};
const unbind = () => {
if (bound) {
bound.removeEventListener('scroll', onScroll);
bound = null;
}
scrollElRef.current = null;
};
const bind = (el: HTMLElement) => {
if (bound === el) {
onScroll();
return;
}
unbind();
bound = el;
scrollElRef.current = el;
el.addEventListener('scroll', onScroll, { passive: true });
onScroll();
};
const tryBind = () => {
if (cancelled) return;
const scope = findScrollScope();
if (!scope) {
setVisible(false);
return;
}
const next = pickScrollEl(scope);
if (next) {
bind(next);
const waitingList =
!next.classList.contains('post-list-scroll') &&
!!(scope as Element).querySelector?.('.feed-panel');
if (waitingList && attempts < BIND_RETRY_MAX) {
attempts += 1;
retryTimer = setTimeout(tryBind, BIND_RETRY_MS);
}
return;
}
unbind();
setVisible(false);
if (attempts < BIND_RETRY_MAX) {
attempts += 1;
retryTimer = setTimeout(tryBind, BIND_RETRY_MS);
}
};
tryBind();
return () => {
cancelled = true;
clearTimeout(retryTimer);
unbind();
};
}, [loc.pathname, loc.search, syncVisible]);
const scrollToTop = () => {
const el = scrollElRef.current;
if (!el) return;
el.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
<button
type="button"
className={`back-to-top${visible ? ' back-to-top--visible' : ''}`}
onClick={scrollToTop}
aria-label="回到顶部"
title="回到顶部"
tabIndex={visible ? 0 : -1}
>
<ArrowUp size={20} strokeWidth={2.25} />
</button>
);
}

View File

@@ -35,6 +35,7 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
const [showEmoji, setShowEmoji] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
const owoRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (inline && replyTo) {
@@ -51,13 +52,24 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
useEffect(() => {
if (!showEmoji) return;
const handler = (e: MouseEvent) => {
const onPointer = (e: MouseEvent) => {
if (boxRef.current && !boxRef.current.contains(e.target as Node)) {
setShowEmoji(false);
owoRef.current?.focus();
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setShowEmoji(false);
owoRef.current?.focus();
}
};
document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onPointer);
document.removeEventListener('keydown', onKey);
};
}, [showEmoji]);
const insertEmoji = (emoji: string) => {
@@ -108,7 +120,7 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
<div className="comment-box" ref={boxRef}>
<div className="comment-box-avatar">
{user?.avatar ? (
<img src={user.avatar} alt="" className="comment-box-avatar-img" />
<img src={user.avatar} alt="" className="comment-box-avatar-img" loading="lazy" decoding="async" />
) : (
<div className={`comment-box-avatar-placeholder ${user ? '' : 'guest'}`}>
{user ? avatarInitial : (
@@ -145,6 +157,7 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
className="comment-box-send"
disabled={submitting || !content.trim() || (!user && !guestNick.trim())}
onClick={handleSubmit}
aria-label="发送评论"
title="发送"
>
<Send size={16} />
@@ -200,9 +213,13 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
<div className="comment-box-toolbar">
<button
ref={owoRef}
type="button"
className={`comment-box-owo ${showEmoji ? 'active' : ''}`}
onClick={() => setShowEmoji((v) => !v)}
aria-label="插入表情"
aria-expanded={showEmoji}
aria-controls="comment-emoji-picker"
>
OwO
</button>
@@ -212,7 +229,7 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
</label>
</div>
{showEmoji && <EmojiPicker onSelect={insertEmoji} />}
{showEmoji && <EmojiPicker id="comment-emoji-picker" onSelect={insertEmoji} />}
</div>
</div>
);

View File

@@ -45,7 +45,7 @@ function CommentItem({
>
<div className={`waline-comment-avatar ${guest && !c.user?.avatar ? 'guest' : ''}`}>
{c.user?.avatar ? (
<img src={c.user.avatar} alt="" />
<img src={c.user.avatar} alt="" loading="lazy" decoding="async" />
) : (
commentInitial(c)
)}

View File

@@ -1,19 +1,64 @@
import { useEffect, useId, useRef, useState } from 'react';
import { EMOJI_LIST } from '../utils/emojis';
interface Props {
onSelect: (emoji: string) => void;
id?: string;
}
/** OwO 表情选择面板 */
export default function EmojiPicker({ onSelect }: Props) {
/** OwO 表情选择面板方向键浏览Enter 选中) */
export default function EmojiPicker({ onSelect, id }: Props) {
const autoId = useId();
const listId = id ?? autoId;
const [active, setActive] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
listRef.current?.querySelectorAll<HTMLElement>('[role="option"]')[active]?.focus();
}, [active]);
const onKeyDown = (e: React.KeyboardEvent) => {
const cols = 8;
let next = active;
if (e.key === 'ArrowRight') next = Math.min(EMOJI_LIST.length - 1, active + 1);
else if (e.key === 'ArrowLeft') next = Math.max(0, active - 1);
else if (e.key === 'ArrowDown') next = Math.min(EMOJI_LIST.length - 1, active + cols);
else if (e.key === 'ArrowUp') next = Math.max(0, active - cols);
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = EMOJI_LIST.length - 1;
else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(EMOJI_LIST[active]);
return;
} else {
return;
}
e.preventDefault();
setActive(next);
};
return (
<div className="emoji-picker">
{EMOJI_LIST.map((e) => (
<div
id={listId}
ref={listRef}
className="emoji-picker"
role="listbox"
aria-label="表情列表"
aria-activedescendant={`${listId}-opt-${active}`}
onKeyDown={onKeyDown}
>
{EMOJI_LIST.map((e, i) => (
<button
key={e}
id={`${listId}-opt-${i}`}
type="button"
className="emoji-picker-item"
role="option"
tabIndex={active === i ? 0 : -1}
aria-selected={active === i}
className={`emoji-picker-item${active === i ? ' emoji-picker-item--active' : ''}`}
aria-label={e}
onClick={() => onSelect(e)}
onFocus={() => setActive(i)}
>
{e}
</button>

View File

@@ -19,9 +19,9 @@ export default class ErrorBoundary extends Component<Props, State> {
render() {
if (this.state.error) {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<div className="error-boundary">
<h3></h3>
<p style={{ color: 'var(--color-text-3)', fontSize: 13 }}>{this.state.error.message}</p>
<p className="error-boundary-msg">{this.state.error.message}</p>
<Button size="sm" onClick={() => { this.setState({ error: null }); window.location.reload(); }}>
</Button>

View File

@@ -1,5 +1,7 @@
import { useRef } from 'react';
import { Clock, MessageCircle, Flame } from 'lucide-react';
import { cn } from '@/lib/utils';
import { moveTabIndex } from '../hooks/useOverlayA11y';
export type FeedSort = 'latest' | 'reply' | 'hot';
@@ -38,14 +40,35 @@ export function feedSortLabel(sort: FeedSort): string {
}
export default function FeedSortBar({ value, onChange, postTotal }: Props) {
const listRef = useRef<HTMLDivElement>(null);
const activeIndex = Math.max(0, SORT_OPTIONS.findIndex(o => o.key === value));
const onKeyDown = (e: React.KeyboardEvent) => {
const next = moveTabIndex(e.key, activeIndex, SORT_OPTIONS.length);
if (next == null) return;
e.preventDefault();
onChange(SORT_OPTIONS[next].key);
requestAnimationFrame(() => {
const tabs = listRef.current?.querySelectorAll<HTMLElement>('[role="tab"]');
tabs?.[next]?.focus();
});
};
return (
<div className="feed-toolbar">
<div className="feed-sort-bar" role="tablist" aria-label="帖子排序">
{SORT_OPTIONS.map(({ key, label, hint, icon: Icon }) => (
<div
ref={listRef}
className="feed-sort-bar"
role="tablist"
aria-label="帖子排序"
onKeyDown={onKeyDown}
>
{SORT_OPTIONS.map(({ key, label, hint, icon: Icon }, i) => (
<button
key={key}
type="button"
role="tab"
tabIndex={activeIndex === i ? 0 : -1}
aria-selected={value === key}
title={`${label} · ${hint}`}
className={cn('feed-sort-tab', value === key && 'active')}

View File

@@ -22,9 +22,11 @@ export default function PostListItem({ post, sort = 'latest', onClick }: Props)
const likeCount = post.like_count ?? 0;
return (
<div className="post-row" onClick={onClick}>
<button type="button" className="post-row" onClick={onClick}>
<div className="post-avatar">
{post.user?.avatar ? <img src={post.user.avatar} alt="" /> : initial}
{post.user?.avatar
? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
: initial}
</div>
<div className="post-body">
<div className="post-title">
@@ -47,6 +49,6 @@ export default function PostListItem({ post, sort = 'latest', onClick }: Props)
{likeCount}
</span>
</div>
</div>
</button>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import {
History, X, Maximize2, Minimize2, GitCompare, FileText, ArrowRight,
} from 'lucide-react';
@@ -8,6 +8,7 @@ import { api } from '../api/client';
import type { PostRevision } from '../api/types';
import PostContent from './PostContent';
import { formatDateTime } from '../utils/content';
import { moveTabIndex, useOverlayA11y } from '../hooks/useOverlayA11y';
import {
type PostSnapshot,
htmlToDiffText,
@@ -27,6 +28,12 @@ interface Props {
type ViewMode = 'diff' | 'before' | 'after';
const VIEW_MODES = [
['diff', GitCompare, '变更对比'],
['before', FileText, '编辑前'],
['after', ArrowRight, '编辑后'],
] as const;
interface RevisionEntry {
rev: PostRevision;
after: PostSnapshot;
@@ -105,6 +112,12 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
const [loading, setLoading] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('diff');
const [fullscreen, setFullscreen] = useState(false);
const viewTabsRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const handleClose = useCallback(() => onClose(), [onClose]);
useOverlayA11y(open, handleClose, panelRef, { initialFocusRef: closeRef });
useEffect(() => {
if (!open) {
@@ -124,7 +137,6 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
.finally(() => setLoading(false));
}, [open, postId]);
// 阻止背景滚动
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
@@ -166,9 +178,10 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
return (
<div
className={`post-revision-overlay${fullscreen ? ' post-revision-overlay--fullscreen' : ''}`}
onClick={onClose}
onClick={handleClose}
>
<div
ref={panelRef}
className={`post-revision-panel${fullscreen ? ' post-revision-panel--fullscreen' : ''}`}
onClick={e => e.stopPropagation()}
role="dialog"
@@ -177,7 +190,7 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
>
<header className="post-revision-head">
<div className="post-revision-head-left">
<History size={18} />
<History size={18} aria-hidden />
<h3></h3>
{selected && (
<span className="post-revision-head-sub">
@@ -186,21 +199,33 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
)}
</div>
<div className="post-revision-head-actions">
<div className="post-revision-view-tabs" role="tablist">
{([
['diff', GitCompare, '变更对比'],
['before', FileText, '编辑前'],
['after', ArrowRight, '编辑后'],
] as const).map(([mode, Icon, label]) => (
<div
ref={viewTabsRef}
className="post-revision-view-tabs"
role="tablist"
aria-label="视图模式"
onKeyDown={(e) => {
const idx = VIEW_MODES.findIndex(([m]) => m === viewMode);
const next = moveTabIndex(e.key, Math.max(0, idx), VIEW_MODES.length);
if (next == null) return;
e.preventDefault();
setViewMode(VIEW_MODES[next][0]);
requestAnimationFrame(() => {
viewTabsRef.current?.querySelectorAll<HTMLElement>('[role="tab"]')[next]?.focus();
});
}}
>
{VIEW_MODES.map(([mode, Icon, label]) => (
<button
key={mode}
type="button"
role="tab"
tabIndex={viewMode === mode ? 0 : -1}
aria-selected={viewMode === mode}
className={`post-revision-tab${viewMode === mode ? ' active' : ''}`}
onClick={() => setViewMode(mode)}
>
<Icon size={14} />
<Icon size={14} aria-hidden />
<span className="post-revision-tab-label">{label}</span>
</button>
))}
@@ -212,10 +237,16 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
title={fullscreen ? '退出全屏' : '全屏显示'}
aria-label={fullscreen ? '退出全屏' : '全屏显示'}
>
{fullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
{fullscreen ? <Minimize2 size={18} aria-hidden /> : <Maximize2 size={18} aria-hidden />}
</button>
<button type="button" className="post-revision-icon-btn" onClick={onClose} aria-label="关闭">
<X size={18} />
<button
ref={closeRef}
type="button"
className="post-revision-icon-btn"
onClick={handleClose}
aria-label="关闭"
>
<X size={18} aria-hidden />
</button>
</div>
</header>

View File

@@ -6,6 +6,8 @@ interface Props {
notifications: Notification[];
online: OnlineStats | null;
onPostClick: (id: number) => void;
/** 首次拉取中,避免空态闪烁 */
loading?: boolean;
}
function hotRankClass(index: number): string {
@@ -15,7 +17,13 @@ function hotRankClass(index: number): string {
return 'widget-rank';
}
export default function RightPanel({ hot, notifications, online, onPostClick }: Props) {
export default function RightPanel({
hot,
notifications,
online,
onPostClick,
loading = false,
}: Props) {
const hotList = hot?.slice(0, 8) ?? [];
const noticeList = notifications?.slice(0, 6) ?? [];
const members = online?.users ?? [];
@@ -28,13 +36,20 @@ export default function RightPanel({ hot, notifications, online, onPostClick }:
</div>
<div className="widget-card-body">
{hotList.length === 0 ? (
{loading && hotList.length === 0 ? (
<div className="widget-empty"></div>
) : hotList.length === 0 ? (
<div className="widget-empty"></div>
) : hotList.map((item, i) => (
<div key={item.id} className="widget-item" onClick={() => onPostClick(item.id)}>
<button
key={item.id}
type="button"
className="widget-item"
onClick={() => onPostClick(item.id)}
>
<span className={hotRankClass(i)}>{i + 1}</span>
<span className="widget-item-title">{item.title}</span>
</div>
</button>
))}
</div>
</div>
@@ -45,13 +60,20 @@ export default function RightPanel({ hot, notifications, online, onPostClick }:
</div>
<div className="widget-card-body">
{noticeList.length === 0 ? (
{loading && noticeList.length === 0 ? (
<div className="widget-empty"></div>
) : noticeList.length === 0 ? (
<div className="widget-empty"></div>
) : noticeList.map(item => (
<div key={item.id} className="widget-item widget-item--notice" onClick={() => onPostClick(item.id)}>
<button
key={item.id}
type="button"
className="widget-item widget-item--notice"
onClick={() => onPostClick(item.id)}
>
<span className="widget-item-title">{item.title}</span>
<span className="widget-item-time">{item.created_at}</span>
</div>
</button>
))}
</div>
</div>
@@ -66,15 +88,21 @@ export default function RightPanel({ hot, notifications, online, onPostClick }:
{online?.members ?? 0} · {online?.guests ?? 0}
</div>
<div className="widget-online-list">
{members.map(u => (
<span key={u.id} className="widget-online-avatar" title={u.nickname}>
{u.avatar
? <img src={u.avatar} alt="" />
: (u.nickname?.[0] || '?')}
</span>
))}
{members.length === 0 && (
<span className="widget-empty widget-empty--inline">线</span>
{loading && online == null ? (
<span className="widget-empty widget-empty--inline"></span>
) : (
<>
{members.map(u => (
<span key={u.id} className="widget-online-avatar" title={u.nickname}>
{u.avatar
? <img src={u.avatar} alt="" loading="lazy" decoding="async" />
: (u.nickname?.[0] || '?')}
</span>
))}
{members.length === 0 && (
<span className="widget-empty widget-empty--inline">线</span>
)}
</>
)}
</div>
</div>

View File

@@ -56,8 +56,8 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
<aside className="sidebar">
<div className="sidebar-section"></div>
<nav className="sidebar-nav">
{navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); navigateFeed(nav, buildHomeUrl(0, sort)); })}
{user && navItem('favorites', '我的收藏', <Star />, () => nav('/favorites'))}
{navItem('all', '全部帖子', <Home aria-hidden />, () => { onSelectBoard(0); navigateFeed(nav, buildHomeUrl(0, sort)); })}
{user && navItem('favorites', '我的收藏', <Star aria-hidden />, () => nav('/favorites'))}
</nav>
{boards.length > 0 && (
@@ -96,9 +96,9 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
{isAdmin && (
<>
<div className="sidebar-section" style={{ marginTop: 8 }}></div>
<div className="sidebar-section sidebar-section--spaced"></div>
<nav className="sidebar-nav">
{navItem('admin', '管理后台', <LayoutDashboard />, () => nav('/admin/dashboard'))}
{navItem('admin', '管理后台', <LayoutDashboard aria-hidden />, () => nav('/admin/dashboard'))}
</nav>
</>
)}

View File

@@ -0,0 +1,133 @@
import { useRef, useState, type KeyboardEvent } from 'react';
import { Tag, X } from 'lucide-react';
import { notify } from '@/lib/notify';
export function parseTags(raw: string): string[] {
return raw.split(/[,]/).map((t) => t.trim()).filter(Boolean);
}
export function serializeTags(list: string[]): string {
return list.join(',');
}
interface Props {
value: string;
onChange: (value: string) => void;
placeholder?: string;
/** 序列化后的总长度上限0/undefined 表示不限 */
maxLength?: number;
disabled?: boolean;
}
/** 回车 / 逗号确认标签块,悬停显示删除 */
export default function TagInput({
value,
onChange,
placeholder = '输入标签后回车',
maxLength,
disabled,
}: Props) {
const [draft, setDraft] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const tags = parseTags(value);
const commit = (raw: string) => {
const next = raw.trim();
if (!next) return false;
if (tags.some((t) => t.toLowerCase() === next.toLowerCase())) {
setDraft('');
return false;
}
const merged = serializeTags([...tags, next]);
if (maxLength && maxLength > 0 && [...merged].length > maxLength) {
notify.warning(`标签总长不能超过 ${maxLength}`);
return false;
}
onChange(merged);
setDraft('');
return true;
};
const removeAt = (index: number) => {
onChange(serializeTags(tags.filter((_, i) => i !== index)));
inputRef.current?.focus();
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',' || e.key === '') {
e.preventDefault();
commit(draft);
return;
}
if (e.key === 'Backspace' && !draft && tags.length > 0) {
e.preventDefault();
removeAt(tags.length - 1);
}
};
const onDraftChange = (text: string) => {
// 粘贴或输入含分隔符时立即拆成多个标签
if (/[,]/.test(text)) {
const parts = parseTags(text);
const lastSep = Math.max(text.lastIndexOf(','), text.lastIndexOf(''));
const trailing = lastSep >= 0 && lastSep === text.length - 1 ? '' : text.slice(lastSep + 1);
let list = [...tags];
for (const p of parts) {
if (list.some((t) => t.toLowerCase() === p.toLowerCase())) continue;
const merged = serializeTags([...list, p]);
if (maxLength && maxLength > 0 && [...merged].length > maxLength) {
notify.warning(`标签总长不能超过 ${maxLength}`);
break;
}
list = [...list, p];
}
onChange(serializeTags(list));
setDraft(trailing.replace(/^[,]+/, '').trimStart());
return;
}
setDraft(text);
};
return (
<div
className={`compose-tags-field${disabled ? ' compose-tags-field--disabled' : ''}`}
onClick={() => inputRef.current?.focus()}
>
<Tag className="compose-tags-icon" size={16} aria-hidden />
<div className="compose-tags-chips">
{tags.map((tag, i) => (
<span key={`${tag}-${i}`} className="compose-tag-chip">
<span className="compose-tag-chip-label">{tag}</span>
<button
type="button"
className="compose-tag-chip-remove"
aria-label={`删除标签 ${tag}`}
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
removeAt(i);
}}
>
<X size={12} strokeWidth={2.5} aria-hidden />
</button>
</span>
))}
<input
ref={inputRef}
type="text"
className="compose-tags-input"
placeholder={tags.length === 0 ? placeholder : '继续添加…'}
value={draft}
disabled={disabled}
onChange={(e) => onDraftChange(e.target.value)}
onKeyDown={onKeyDown}
onBlur={() => {
if (draft.trim()) commit(draft);
}}
/>
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useRef, useEffect, useLayoutEffect } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Inbox } from 'lucide-react';
import { Button } from '@/components/ui/button';
import PostListItem from './PostListItem';
import PostListSkeleton from './PostListSkeleton';
@@ -46,12 +47,17 @@ export default function VirtualPostList({
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
overscan: 8,
measureElement:
typeof window !== 'undefined' && !navigator.userAgent.includes('Firefox')
? (el) => el.getBoundingClientRect().height
: undefined,
});
const showHistoryPrompt = hasMore && !canAutoLoad && !loading;
const showEnd = !hasMore && posts.length > 0 && !loading;
const isInitialLoad = loading && posts.length === 0;
const isLoadingMore = loading && posts.length > 0;
const isEmpty = !loading && posts.length === 0;
useLayoutEffect(() => {
if (resetScrollKey <= 0) return;
@@ -92,6 +98,12 @@ export default function VirtualPostList({
<div className="post-list-scroll" ref={parentRef}>
{isInitialLoad ? (
<PostListSkeleton />
) : isEmpty ? (
<div className="empty-feed">
<Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
<p></p>
<p className="empty-feed-hint"></p>
</div>
) : (
<>
<div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
@@ -100,6 +112,8 @@ export default function VirtualPostList({
return (
<div
key={post.id}
data-index={vi.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,