fix(channel): update channel components and article draft

This commit is contained in:
zhenyi 2026-05-31 13:11:44 +08:00
parent 82475e95d5
commit 29e6f6214d
18 changed files with 158 additions and 103 deletions

View File

@ -23,7 +23,7 @@ export default function ArticleCard({ article, onClick, className }: Props) {
return (
<article
className={cn(
"group relative flex cursor-pointer flex-col overflow-hidden rounded-2xl border border-border/30 bg-card shadow-sm transition-all duration-300 hover:shadow-lg hover:-translate-y-0.5",
"group relative flex cursor-pointer flex-col overflow-hidden rounded-2xl border border-border/30 bg-card shadow-sm transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-300 hover:shadow-lg hover:-translate-y-0.5",
article.is_pinned && "ring-1 ring-amber-500/20",
className,
)}
@ -53,7 +53,7 @@ export default function ArticleCard({ article, onClick, className }: Props) {
{/* Pinned badge */}
{article.is_pinned && (
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-amber-500/90 px-2 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-warning/90 px-2 py-0.5 text-[10px] font-medium text-warning-foreground backdrop-blur-sm">
<Pin className="size-2.5" />
Pinned
</span>

View File

@ -43,7 +43,7 @@ export default function ArticleComposer({
);
// Auto-save indicator
const autoSaveTimer = useRef<ReturnType<typeof setTimeout>>();
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
if (!draft) return;
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);

View File

@ -162,7 +162,7 @@ export default function ArticleDetail({
<h2 className="truncate text-sm font-semibold">Article Detail</h2>
</div>
{article.is_pinned && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-600/70">
<span className="inline-flex items-center gap-1 rounded-full bg-warning/10 px-2 py-0.5 text-[11px] font-medium text-warning/70">
<Pin className="size-3" />
Pinned
</span>
@ -249,7 +249,7 @@ export default function ArticleDetail({
<div className="mt-4 flex justify-center">
<button
className={cn(
"inline-flex cursor-pointer items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-200",
"inline-flex cursor-pointer items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-200",
liked
? "bg-rose-500/10 text-rose-500 ring-1 ring-rose-500/20"
: "bg-muted/50 text-muted-foreground/60 hover:bg-rose-500/[0.04] hover:text-rose-500/70",
@ -260,7 +260,7 @@ export default function ArticleDetail({
>
<Heart
className={cn(
"size-[18px] transition-all",
"size-[18px] transition-[fill,color,transform]",
liked && "fill-rose-500",
)}
/>

View File

@ -143,7 +143,7 @@ export default function ArticleFeed({ roomId, roomName, currentUserId, onCompose
const { onEvent } = useChannelSocket();
useEffect(() => {
return onEvent((event) => {
if (!event.room || event.room.id !== roomId) return;
if (!('room' in event) || !event.room || event.room.id !== roomId) return;
if (event.type === "article.liked" || event.type === "article.unliked") {
const d = event.data as { article_id: string; like_count: number };

View File

@ -56,7 +56,7 @@ export function ChannelHeader({
<Tooltip>
<TooltipTrigger
aria-label="Channel settings"
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-lg text-muted-foreground/40 transition-all duration-150 hover:bg-accent/40 hover:text-foreground"
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-lg text-muted-foreground/40 transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-150 hover:bg-accent/40 hover:text-foreground"
onClick={onToggleSettings}
>
<Settings className="size-[18px]" />

View File

@ -57,9 +57,9 @@ function RoomLink({
return (
<Link
className={cn(
"group flex h-8 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-all duration-150",
"group flex h-9 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-[background-color,color,box-shadow] duration-150",
active
? "bg-primary/[0.08] font-semibold text-foreground shadow-[inset_0_0_0_1px_rgba(var(--color-primary),0.08)]"
? "bg-primary/[0.08] font-semibold text-foreground ring-1 ring-primary/10"
: "text-muted-foreground/70 hover:bg-accent/50 hover:text-foreground",
)}
to={`/${projectName}/channel/${room.id}`}
@ -203,7 +203,7 @@ export default function ChannelSidebar({
{onCreateChannel && (
<button
aria-label="Create channel"
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground/40 transition-all duration-150 hover:bg-accent hover:text-foreground"
className="inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground/40 transition-[background-color,color] duration-150 hover:bg-accent hover:text-foreground"
onClick={onCreateChannel}
type="button"
>
@ -250,7 +250,7 @@ export default function ChannelSidebar({
</p>
{onCreateChannel && (
<button
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-primary transition-all duration-150 hover:bg-primary/[0.08]"
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-primary transition-[background-color,color] duration-150 hover:bg-primary/10"
onClick={onCreateChannel}
type="button"
>

View File

@ -96,7 +96,8 @@ export default function MessageComposer({
</p>
</div>
<Button
className="size-6 shrink-0 cursor-pointer text-muted-foreground/30 hover:text-foreground"
aria-label="Cancel reply"
className="size-8 shrink-0 cursor-pointer text-muted-foreground/30 hover:text-foreground"
onClick={onCancelReply}
size="icon"
variant="ghost"
@ -108,7 +109,7 @@ export default function MessageComposer({
<div
className={cn(
"relative rounded-2xl border bg-card/80 shadow-sm transition-all duration-200",
"relative rounded-2xl border bg-card/80 shadow-sm transition-[background-color,border-color,box-shadow] duration-200",
"focus-within:border-primary/20 focus-within:shadow-md focus-within:ring-1 focus-within:ring-primary/[0.06] focus-within:bg-card",
replyTarget ? "border-primary/[0.12]" : "border-border/40",
)}
@ -133,8 +134,9 @@ export default function MessageComposer({
<div className="absolute right-2 bottom-2 flex items-center gap-1">
<FileUploadButton disabled={disabled || sending} />
<Button
aria-label="Send message"
className={cn(
"size-8 rounded-lg transition-all duration-200",
"size-9 rounded-lg transition-[opacity,transform,background-color,color] duration-200",
hasContent
? "scale-100 opacity-100"
: "scale-90 opacity-40",

View File

@ -157,7 +157,8 @@ export default function FileUploadButton({
)}
</div>
<Button
className="size-6 shrink-0 cursor-pointer rounded-md text-muted-foreground/40 hover:bg-accent/50 hover:text-foreground"
aria-label="Remove file"
className="size-8 shrink-0 cursor-pointer rounded-lg text-muted-foreground/40 hover:bg-accent/50 hover:text-foreground"
onClick={() => removeFile(file.id)}
size="icon"
variant="ghost"

View File

@ -1,6 +1,8 @@
import { useMemo } from "react";
import RepoEmbedCard from "./repo-embed-card";
import { parseRepoLinks, type RepoLinkMatch } from "./repo-link-parser";
import XEmbedCard from "./x-embed-card";
import { parseRepoLinks } from "./repo-link-parser";
import { parseXLinks } from "./x-link-parser";
type Props = {
content: string;
@ -9,60 +11,86 @@ type Props = {
/**
* Renders message content, detecting same-origin repo links and
* replacing them with RepoEmbedCards.
* X/Twitter links, replacing them with embed cards.
*/
export default function MessageContent({ content, contentType }: Props) {
const { textParts, embeds } = useMemo(() => {
const links = parseRepoLinks(content);
if (links.length === 0) return { textParts: [content], embeds: [] };
const embeds: RepoLinkMatch[] = [];
const textParts: string[] = [];
let lastIndex = 0;
for (const link of links) {
const idx = content.indexOf(link.url, lastIndex);
if (idx === -1) continue;
// Text before this link
if (idx > lastIndex) {
textParts.push(content.slice(lastIndex, idx));
}
embeds.push(link);
lastIndex = idx + link.url.length;
}
// Remaining text after last link
if (lastIndex < content.length) {
textParts.push(content.slice(lastIndex));
}
return { textParts, embeds };
}, [content]);
const isPlainText = contentType === "text" || !contentType;
return (
<div>
{textParts.map((part, i) => {
const trimmed = part.trim();
if (!trimmed && embeds.length > 0) return null;
return (
const elements = useMemo(() => {
const repoLinks = parseRepoLinks(content);
const xLinks = parseXLinks(content);
const allLinks = [
...repoLinks.map((l) => ({ kind: "repo" as const, url: l.url, data: l, index: content.indexOf(l.url) })),
...xLinks.map((l) => ({ kind: "x" as const, url: l.url, data: l, index: content.indexOf(l.url) })),
].sort((a, b) => a.index - b.index);
if (allLinks.length === 0) {
return [
<p
className={
isPlainText
? "whitespace-pre-wrap break-words text-[13px] leading-[1.55] text-foreground/85"
: "whitespace-pre-wrap break-words text-[13px] text-foreground/85"
}
key={`t-${i}`}
key="only"
>
{isPlainText ? part : part}
</p>
);
})}
{embeds.map((link) => (
<RepoEmbedCard key={link.url} link={link} />
))}
</div>
{content}
</p>,
];
}
const result: React.ReactNode[] = [];
let cursor = 0;
for (const link of allLinks) {
if (link.index > cursor) {
const text = content.slice(cursor, link.index);
if (text.trim()) {
result.push(
<p
className={
isPlainText
? "whitespace-pre-wrap break-words text-[13px] leading-[1.55] text-foreground/85"
: "whitespace-pre-wrap break-words text-[13px] text-foreground/85"
}
key={`t-${cursor}`}
>
{text}
</p>,
);
}
}
if (link.kind === "repo") {
result.push(<RepoEmbedCard key={`repo-${link.url}`} link={link.data} />);
} else {
result.push(<XEmbedCard key={`x-${link.url}`} link={link.data} />);
}
cursor = link.index + link.url.length;
}
if (cursor < content.length) {
const text = content.slice(cursor);
if (text.trim()) {
result.push(
<p
className={
isPlainText
? "whitespace-pre-wrap break-words text-[13px] leading-[1.55] text-foreground/85"
: "whitespace-pre-wrap break-words text-[13px] text-foreground/85"
}
key={`t-${cursor}`}
>
{text}
</p>,
);
}
}
return result;
}, [content, isPlainText]);
return <div>{elements}</div>;
}

View File

@ -184,8 +184,8 @@ export default function MessageItem({
className={cn(
"group relative flex gap-3 transition-colors duration-150",
isCompact
? "items-start px-4 py-0 hover:bg-black/[0.025]"
: "items-start px-4 py-1 hover:bg-black/[0.03]",
? "items-start px-4 py-0 hover:bg-muted/30"
: "items-start px-4 py-1 hover:bg-muted/40",
)}
>
{isCompact ? (
@ -228,7 +228,7 @@ export default function MessageItem({
</span>
)}
{isPinned && (
<span className="text-amber-500/60" title="Pinned">
<span className="text-warning/60" title="Pinned">
<Pin className="size-3" />
</span>
)}
@ -237,16 +237,18 @@ export default function MessageItem({
{/* Reply context indicator */}
{message.in_reply_to && (
<div
<button
className={cn(
"mb-1 flex items-center gap-1.5 rounded-md border border-border/20 bg-muted/[0.08] px-2.5 py-1 text-left",
repliedMessage && "cursor-pointer transition-colors hover:bg-muted/[0.15] hover:border-border/30",
"mb-1 flex w-full items-center gap-1.5 rounded-lg border border-border/30 bg-muted/20 px-2.5 py-1 text-left",
repliedMessage && "cursor-pointer transition-colors hover:border-border/50 hover:bg-muted/40",
!repliedMessage && "cursor-default",
)}
disabled={!repliedMessage}
onClick={() => {
if (repliedMessage) onReply?.(repliedMessage);
}}
title={replyPreview ? `Replying to: ${replyPreview}` : "Replying to a message"}
role={repliedMessage ? "button" : undefined}
type="button"
>
<CornerDownRight className="size-3 shrink-0 text-primary/50" />
{replyAuthor ? (
@ -263,7 +265,7 @@ export default function MessageItem({
{replyPreview}
</span>
)}
</div>
</button>
)}
{editing ? (
@ -290,7 +292,7 @@ export default function MessageItem({
{threadForMessage && !message.thread && (
<button
className="mt-1.5 flex items-center gap-1.5 rounded-lg border border-border/20 bg-muted/[0.05] px-2.5 py-1 text-[12px] font-medium text-primary/60 transition-all duration-150 hover:bg-primary/[0.05] hover:text-primary hover:border-primary/15 hover:shadow-sm"
className="mt-1.5 flex items-center gap-1.5 rounded-lg border border-border/30 bg-muted/20 px-2.5 py-1 text-[12px] font-medium text-primary/70 transition-[background-color,border-color,color,box-shadow] duration-150 hover:border-primary/20 hover:bg-primary/10 hover:text-primary hover:shadow-sm"
onClick={() => onViewThread?.(threadForMessage.id, message.seq)}
type="button"
>
@ -304,7 +306,7 @@ export default function MessageItem({
{message.reactions.map((r) => (
<button
className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-lg border px-2 py-[2px] text-xs transition-all duration-150",
"inline-flex cursor-pointer items-center gap-1 rounded-lg border px-2 py-[2px] text-xs transition-[background-color,border-color,color] duration-150",
r.reacted_by_me
? "border-primary/15 bg-primary/[0.06] text-primary hover:bg-primary/[0.1]"
: "border-border/30 bg-muted/20 text-muted-foreground/60 hover:border-border/50 hover:bg-muted/40",
@ -320,7 +322,7 @@ export default function MessageItem({
<ReactionPicker
onSelect={(emoji) => handleReaction(emoji)}
trigger={
<button className="inline-flex size-6 cursor-pointer items-center justify-center rounded-lg border border-dashed border-border/30 text-muted-foreground/25 opacity-0 transition-all duration-150 hover:border-primary/25 hover:text-primary/50 group-hover:opacity-100">
<button aria-label="Add reaction" className="inline-flex size-8 cursor-pointer items-center justify-center rounded-lg border border-dashed border-border/30 text-muted-foreground/30 opacity-60 transition-[border-color,color,opacity] duration-150 hover:border-primary/25 hover:text-primary/60 group-hover:opacity-100" type="button">
<SmilePlus className="size-3" />
</button>
}
@ -329,12 +331,13 @@ export default function MessageItem({
)}
</div>
<div className="absolute right-2 top-0 z-10 flex items-center gap-[1px] rounded-lg border border-border/20 bg-card/95 px-1 py-1 shadow-md backdrop-blur-sm opacity-0 transition-all duration-150 group-hover:opacity-100">
<div className="absolute right-2 top-0 z-10 flex items-center gap-[1px] rounded-xl border border-border/30 bg-card/95 px-1 py-1 opacity-0 shadow-md backdrop-blur-sm transition-opacity duration-150 group-focus-within:opacity-100 group-hover:opacity-100">
<ReactionPicker
onSelect={(emoji) => handleReaction(emoji)}
trigger={
<Button
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
aria-label="Add reaction"
className="size-8 cursor-pointer rounded-lg text-muted-foreground/50 hover:bg-accent/50 hover:text-foreground"
size="icon"
title="Add reaction"
variant="ghost"
@ -345,7 +348,8 @@ export default function MessageItem({
/>
<Button
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
aria-label="Reply"
className="size-8 cursor-pointer rounded-lg text-muted-foreground/50 hover:bg-accent/50 hover:text-foreground"
onClick={() => onReply?.(message)}
size="icon"
title="Reply"
@ -355,7 +359,8 @@ export default function MessageItem({
</Button>
<Button
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
aria-label={threadForMessage ? "View thread" : "Start thread"}
className="size-8 cursor-pointer rounded-lg text-muted-foreground/50 hover:bg-accent/50 hover:text-foreground"
onClick={() =>
threadForMessage
? onViewThread?.(threadForMessage.id, message.seq)
@ -371,7 +376,8 @@ export default function MessageItem({
<DropdownMenu>
<DropdownMenuTrigger>
<Button
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
aria-label="More message actions"
className="size-8 cursor-pointer rounded-lg text-muted-foreground/50 hover:bg-accent/50 hover:text-foreground"
size="icon"
title="More"
variant="ghost"
@ -482,7 +488,7 @@ function ReactionPicker({
<div className="grid grid-cols-8 gap-0.5">
{REACTIONS_PALETTE.map((emoji) => (
<button
className="grid size-8 cursor-pointer place-items-center rounded-md text-lg transition-all duration-150 hover:scale-110 hover:bg-accent/50"
className="grid size-9 cursor-pointer place-items-center rounded-lg text-lg transition-[background-color,transform] duration-150 hover:scale-105 hover:bg-accent/50"
key={emoji}
onClick={() => onSelect(emoji)}
title={emoji}

View File

@ -91,6 +91,7 @@ export default function MessageView({
const bottomRef = useRef<HTMLDivElement>(null);
const [atBottom, setAtBottom] = useState(true);
const [newMsgCount, setNewMsgCount] = useState(0);
const [initialLoaded, setInitialLoaded] = useState(false);
const prevMsgCountRef = useRef(0);
const [replyTarget, setReplyTarget] = useState<MessageNewService | null>(null);
@ -141,26 +142,45 @@ export default function MessageView({
[renderedItems],
);
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
prevMsgCountRef.current = 0;
setNewMsgCount(0);
bottomRef.current?.scrollIntoView();
// eslint-disable-next-line react-hooks/exhaustive-deps
setInitialLoaded(false);
}, [roomId]);
/* eslint-enable react-hooks/set-state-in-effect */
// Scroll to bottom after initial messages load
useEffect(() => {
if (!initialLoaded && !loading && messageCount > 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time scroll after load
setInitialLoaded(true);
requestAnimationFrame(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
});
}
}, [initialLoaded, loading, messageCount]);
// Auto-scroll on new messages (after initial load)
useEffect(() => {
const prev = prevMsgCountRef.current;
prevMsgCountRef.current = messageCount;
if (!initialLoaded) return;
if (messageCount > prev && prev > 0 && !loading) {
if (atBottom) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
/* eslint-disable react-hooks/set-state-in-effect */
setNewMsgCount(0);
/* eslint-enable react-hooks/set-state-in-effect */
} else {
setNewMsgCount((c) => c + (messageCount - prev));
}
}
}, [messageCount, loading, atBottom]);
}, [messageCount, loading, atBottom, initialLoaded]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
@ -205,12 +225,12 @@ export default function MessageView({
<div className="flex min-h-0 flex-1 flex-col">
{pinnedMessages.length > 0 && (
<button
className="flex shrink-0 w-full cursor-pointer items-center gap-2 border-b border-amber-500/10 bg-amber-500/[0.03] px-4 py-2 text-left transition-colors hover:bg-amber-500/[0.06]"
className="flex shrink-0 w-full cursor-pointer items-center gap-2 border-b border-warning/10 bg-warning/[0.03] px-4 py-2 text-left transition-colors hover:bg-warning/[0.06]"
type="button"
title={`${pinnedMessages.length} pinned message${pinnedMessages.length > 1 ? "s" : ""}`}
>
<Pin className="size-3.5 text-amber-500/50" />
<span className="truncate text-[12px] font-medium text-amber-600/60">
<Pin className="size-3.5 text-warning/50" />
<span className="truncate text-[12px] font-medium text-warning/60">
{pinnedMessages.length} pinned message
{pinnedMessages.length > 1 ? "s" : ""}
</span>
@ -320,12 +340,12 @@ export default function MessageView({
</div>
))}
<div ref={bottomRef} />
<div ref={bottomRef} className="mb-4" />
</div>
{newMsgCount > 0 && (
<button
className="absolute bottom-3 left-1/2 z-20 flex -translate-x-1/2 cursor-pointer items-center gap-1.5 rounded-full bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground shadow-lg transition-all duration-200 hover:bg-primary/90 hover:shadow-xl"
className="absolute bottom-3 left-1/2 z-20 flex -translate-x-1/2 cursor-pointer items-center gap-1.5 rounded-full bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground shadow-lg transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-200 hover:bg-primary/90 hover:shadow-xl"
onClick={handleScrollToBottom}
>
<ChevronDown className="size-3.5" />

View File

@ -1,6 +1,7 @@
import { ExternalLink } from "lucide-react";
import RightDrawer from "@/components/right-drawer";
import { useDrawer } from "@/hooks/use-drawer";
import RepoView from "@/components/repo/repo-view";
type Props = {
workspace: string;
@ -33,12 +34,9 @@ export default function RepoDrawer({ workspace, repo, children }: Props) {
onClose={closeDrawer}
open={open}
title={`${workspace}/${repo}`}
width="max-w-[90vw]"
>
<iframe
className="min-h-0 flex-1 w-full border-0"
src={repoUrl}
title={`${workspace}/${repo}`}
/>
<RepoView repo={repo} workspace={workspace} />
</RightDrawer>
</>
);

View File

@ -105,7 +105,7 @@ export default function RepoEmbedCard({ link }: { link: RepoLinkMatch }) {
return (
<RepoDrawer repo={link.repo} workspace={link.workspace}>
<div className="mt-2 block max-w-[420px] rounded-xl border border-border/30 bg-muted/[0.03] p-4 transition-all duration-200 hover:border-primary/20 hover:bg-muted/[0.08] hover:shadow-sm">
<div className="mt-2 block max-w-[420px] rounded-xl border border-border/30 bg-muted/[0.03] p-4 transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-200 hover:border-primary/20 hover:bg-muted/[0.08] hover:shadow-sm">
{loading ? (
<div className="flex items-center gap-2 py-2 text-[13px] text-muted-foreground/50">
<Loader2 className="size-4 animate-spin" />

View File

@ -116,7 +116,7 @@ export default function RoomCreateDialog({
<Label className="text-[13px]">Channel type</Label>
<div className="flex gap-2">
<button
className={`flex flex-1 cursor-pointer items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-all ${
className={`flex flex-1 cursor-pointer items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-[background-color,border-color,color,box-shadow] ${
channelType === "channel"
? "border-primary/40 bg-primary/[0.06] text-foreground"
: "border-border/30 text-muted-foreground/60 hover:border-border/50"
@ -128,7 +128,7 @@ export default function RoomCreateDialog({
Chat
</button>
<button
className={`flex flex-1 cursor-pointer items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-all ${
className={`flex flex-1 cursor-pointer items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-[background-color,border-color,color,box-shadow] ${
channelType === "article"
? "border-primary/40 bg-primary/[0.06] text-foreground"
: "border-border/30 text-muted-foreground/60 hover:border-border/50"

View File

@ -50,9 +50,9 @@ export default function RoomDndToggle({ roomId }: Props) {
<PopoverTrigger>
<Button
className={cn(
"size-8 cursor-pointer rounded-lg transition-all duration-150",
"size-8 cursor-pointer rounded-lg transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-150",
dnd.do_not_disturb
? "text-amber-500 hover:bg-accent/40"
? "text-warning hover:bg-accent/40"
: "text-muted-foreground/40 hover:bg-accent/40 hover:text-muted-foreground",
)}
size="icon"

View File

@ -128,7 +128,7 @@ export default function ThreadPane({ thread, roomId, onClose }: Props) {
{/* Header */}
<div className="flex h-[52px] shrink-0 items-center gap-3 border-b border-border/40 px-4">
<Button
className="size-7 shrink-0 cursor-pointer rounded-lg text-muted-foreground/40 hover:bg-accent/50 hover:text-foreground"
className="size-8 shrink-0 cursor-pointer rounded-lg text-muted-foreground/40 hover:bg-accent/50 hover:text-foreground"
onClick={onClose}
size="icon"
variant="ghost"
@ -229,7 +229,7 @@ export default function ThreadPane({ thread, roomId, onClose }: Props) {
<div className="shrink-0 border-t border-border/40 p-3">
<div
className={cn(
"flex items-end gap-2 rounded-xl border bg-card px-3 py-2 transition-all duration-200",
"flex items-end gap-2 rounded-xl border bg-card px-3 py-2 transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-200",
"focus-within:border-primary/20 focus-within:ring-1 focus-within:ring-primary/[0.06]",
"border-border/50",
)}

View File

@ -109,7 +109,7 @@ export default function ThreadSidebar({
</div>
{onClose && (
<Button
className="size-7 cursor-pointer rounded-lg text-muted-foreground/30 hover:bg-accent/50 hover:text-foreground"
className="size-8 cursor-pointer rounded-lg text-muted-foreground/30 hover:bg-accent/50 hover:text-foreground"
onClick={onClose}
size="icon"
variant="ghost"
@ -136,7 +136,7 @@ export default function ThreadSidebar({
return (
<button
className={cn(
"relative flex-1 cursor-pointer rounded-md px-2 py-1.5 text-[11px] font-medium transition-all duration-150",
"relative flex-1 cursor-pointer rounded-md px-2 py-1.5 text-[11px] font-medium transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-150",
isActive
? "text-primary"
: "text-muted-foreground/40 hover:text-muted-foreground/70",
@ -193,7 +193,7 @@ export default function ThreadSidebar({
return (
<button
className="group flex w-full cursor-pointer gap-3 border-b border-border/[0.06] px-4 py-3 text-left transition-all duration-150 hover:bg-accent/[0.03] active:bg-accent/[0.06]"
className="group flex w-full cursor-pointer gap-3 border-b border-border/[0.06] px-4 py-3 text-left transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-150 hover:bg-accent/[0.03] active:bg-accent/[0.06]"
key={thread.id}
onClick={() => onSelect?.(thread)}
type="button"

View File

@ -36,8 +36,8 @@ function saveDraft(draft: ArticleDraft | null) {
}
export function useArticleDraft(defaultChannel?: string) {
const [draft, setDraftState] = useState<ArticleDraft | null>(loadDraft);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const [draft, setDraftState] = useState<ArticleDraft | null>(() => loadDraft());
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const draftRef = useRef(draft);
// Debounced persist