refactor(layout): update layout components, header, navigation and API client

This commit is contained in:
ZhenYi 2026-05-12 13:06:19 +08:00
parent e86803d235
commit d63ca39ca4
6 changed files with 188 additions and 57 deletions

View File

@ -34,6 +34,7 @@ import {
BillingSettings, BillingSettings,
} from "@/app/project"; } from "@/app/project";
import { ChatPage } from "@/app/chat"; import { ChatPage } from "@/app/chat";
import { ExplorePage } from "@/app/explore/ExplorePage";
import CodePage from "@/app/project/repo/code"; import CodePage from "@/app/project/repo/code";
import CommitsPage from "@/app/project/repo/commits"; import CommitsPage from "@/app/project/repo/commits";
import PullsPage from "@/app/project/repo/pulls"; import PullsPage from "@/app/project/repo/pulls";
@ -84,6 +85,7 @@ export default function App() {
<Route path="/me/following" element={<MePage />} /> <Route path="/me/following" element={<MePage />} />
<Route path="/me/chat" element={<ChatPage scope="personal" />} /> <Route path="/me/chat" element={<ChatPage scope="personal" />} />
<Route path="/me/chat/:conversationId" element={<ChatPage scope="personal" />} /> <Route path="/me/chat/:conversationId" element={<ChatPage scope="personal" />} />
<Route path="/explore" element={<ExplorePage />} />
</Route> </Route>
<Route path="/me/settings" element={<SettingsLayout />}> <Route path="/me/settings" element={<SettingsLayout />}>

View File

@ -1,4 +1,4 @@
import { Outlet } from "react-router-dom"; import { Outlet, useLocation } from "react-router-dom";
import { useState } from "react"; import { useState } from "react";
import { PanelLeftOpen } from "lucide-react"; import { PanelLeftOpen } from "lucide-react";
import { ServerIconRail } from "@/components/layout/ServerIconRail"; import { ServerIconRail } from "@/components/layout/ServerIconRail";
@ -8,11 +8,14 @@ import { useIsMobile } from "@/hooks/use-mobile";
export function MeLayout() { export function MeLayout() {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const location = useLocation();
const isExplore = location.pathname === "/explore";
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
return ( return (
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--surface-ground)" }}> <div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--surface-ground)" }}>
{!isMobile && <ServerIconRail />} {!isMobile && <ServerIconRail />}
{!isExplore && (
<div className="relative flex shrink-0"> <div className="relative flex shrink-0">
<div <div
style={{ style={{
@ -48,6 +51,7 @@ export function MeLayout() {
</button> </button>
)} )}
</div> </div>
)}
<div className="flex-1 flex flex-col overflow-hidden min-w-0" style={{ backgroundColor: "var(--surface-ground)" }}> <div className="flex-1 flex flex-col overflow-hidden min-w-0" style={{ backgroundColor: "var(--surface-ground)" }}>
<Header /> <Header />

View File

@ -287,6 +287,9 @@ export const {
gitDescriptionSet, gitDescriptionSet,
gitDescriptionReset, gitDescriptionReset,
gitUpdateRepo, gitUpdateRepo,
// Search
search,
} = api; } = api;
// Manual avatar upload (not in generated API) // Manual avatar upload (not in generated API)

View File

@ -42,6 +42,7 @@ export function MessageList({
const virtuosoRef = useRef<VirtuosoHandle>(null); const virtuosoRef = useRef<VirtuosoHandle>(null);
const initialScrollDoneRef = useRef(false); const initialScrollDoneRef = useRef(false);
const prevLengthRef = useRef(0); const prevLengthRef = useRef(0);
const scrollerRef = useRef<HTMLElement | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true); const [isAtBottom, setIsAtBottom] = useState(true);
const [newMsgCount, setNewMsgCount] = useState(0); const [newMsgCount, setNewMsgCount] = useState(0);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -85,22 +86,30 @@ export function MessageList({
return result; return result;
}, [displayMessages, isLoadingHistory, hasMoreMessages]); }, [displayMessages, isLoadingHistory, hasMoreMessages]);
// Scroll to bottom on room change (initial load) // Scroll to bottom on room change and after messages finish loading.
// Uses native scrollTop = scrollHeight instead of Virtuoso's
// scrollToIndex to avoid alignment estimation errors from virtual
// item height measurement.
useEffect(() => { useEffect(() => {
initialScrollDoneRef.current = false; initialScrollDoneRef.current = false;
prevLengthRef.current = 0; prevLengthRef.current = 0;
setIsAtBottom(true); setIsAtBottom(true);
setNewMsgCount(0); setNewMsgCount(0);
if (renderedItems.length > 0) { if (renderedItems.length > 0) {
setTimeout(() => { const el = scrollerRef.current;
virtuosoRef.current?.scrollToIndex({ if (el) {
index: renderedItems.length - 1, // immediate: estimated heights
behavior: 'auto', el.scrollTop = el.scrollHeight;
}); // delayed: after Virtuoso finishes measuring actual heights
const timer = setTimeout(() => {
el.scrollTop = el.scrollHeight;
initialScrollDoneRef.current = true; initialScrollDoneRef.current = true;
}, 50); }, 300);
return () => clearTimeout(timer);
} }
}, [roomId]); }
}, [roomId, renderedItems.length, isLoadingHistory]);
// Handle new messages: auto-scroll if at bottom, else show count // Handle new messages: auto-scroll if at bottom, else show count
useEffect(() => { useEffect(() => {
@ -109,10 +118,8 @@ export function MessageList({
if (renderedItems.length > prevLen && prevLen > 0 && initialScrollDoneRef.current && !isLoadingHistory) { if (renderedItems.length > prevLen && prevLen > 0 && initialScrollDoneRef.current && !isLoadingHistory) {
if (isAtBottom) { if (isAtBottom) {
virtuosoRef.current?.scrollToIndex({ const el = scrollerRef.current;
index: renderedItems.length - 1, if (el) el.scrollTop = el.scrollHeight;
behavior: 'smooth',
});
setNewMsgCount(0); setNewMsgCount(0);
} else { } else {
setNewMsgCount(prev => prev + (renderedItems.length - prevLen)); setNewMsgCount(prev => prev + (renderedItems.length - prevLen));
@ -121,10 +128,8 @@ export function MessageList({
}, [renderedItems.length, isLoadingHistory, isAtBottom]); }, [renderedItems.length, isLoadingHistory, isAtBottom]);
const handleScrollToBottom = useCallback(() => { const handleScrollToBottom = useCallback(() => {
virtuosoRef.current?.scrollToIndex({ const el = scrollerRef.current;
index: renderedItems.length - 1, if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
behavior: 'smooth',
});
setNewMsgCount(0); setNewMsgCount(0);
setIsAtBottom(true); setIsAtBottom(true);
}, [renderedItems.length]); }, [renderedItems.length]);
@ -138,15 +143,20 @@ export function MessageList({
} }
return ( return (
<div className={MESSAGE_LIST.container} style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', position: 'relative' }}> <div className={MESSAGE_LIST.container} style={{ flex: 1, display: 'flex', flexDirection: 'column', position: 'relative' }}>
<Virtuoso <Virtuoso
key={roomId} key={roomId}
ref={virtuosoRef} ref={virtuosoRef}
style={{ flex: 1 }} style={{ flex: 1 }}
data={renderedItems} data={renderedItems}
initialTopMostItemIndex={renderedItems.length > 0 ? renderedItems.length - 1 : 0} initialTopMostItemIndex={
renderedItems.length > 0
? { index: renderedItems.length - 1, align: 'end' }
: 0
}
startReached={onStartReached} startReached={onStartReached}
atBottomStateChange={(atBottom) => setIsAtBottom(atBottom)} atBottomStateChange={(atBottom) => setIsAtBottom(atBottom)}
scrollerRef={(el) => { scrollerRef.current = el; }}
overscan={isMobile ? 50 : 200} overscan={isMobile ? 50 : 200}
itemContent={(_, item) => { itemContent={(_, item) => {
if (item.type === 'notice') { if (item.type === 'notice') {

View File

@ -1,10 +1,16 @@
import { memo, useCallback, useState } from "react"; import { memo, useCallback, useState } from "react";
import { Link, useLocation, useParams } from "react-router-dom"; import { Link, useLocation, useParams } from "react-router-dom";
import { ChevronRight, Home, Settings } from "lucide-react"; import { ChevronRight, ChevronDown, Home, Settings } from "lucide-react";
import { useProjectLayout } from "@/app/project/layout"; import { useProjectLayout } from "@/app/project/layout";
import { useProjectsQuery } from "@/hooks/useProjectsQuery"; import { useProjectsQuery } from "@/hooks/useProjectsQuery";
import { useOptionalRoom } from "@/contexts/room"; import { useOptionalRoom } from "@/contexts/room";
import { RoomSettingsModal } from "@/app/project/channel/RoomSettingsModal"; import { RoomSettingsModal } from "@/app/project/channel/RoomSettingsModal";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@ -19,7 +25,78 @@ interface BreadcrumbSegment {
fullUuid?: string; fullUuid?: string;
} }
function useBreadcrumbs(): BreadcrumbSegment[] { interface BreadcrumbSibling {
label: string;
path: string;
}
const ME_NAV_SIBLINGS: BreadcrumbSibling[] = [
{ label: "Overview", path: "/me" },
{ label: "Repositories", path: "/me/repositories" },
{ label: "Projects", path: "/me/projects" },
{ label: "Activity", path: "/me/activity" },
{ label: "Chat", path: "/me/chat" },
{ label: "Stars", path: "/me/stars" },
{ label: "Following", path: "/me/following" },
{ label: "Followers", path: "/me/followers" },
];
function getProjectNavSiblings(projectName: string): BreadcrumbSibling[] {
return [
{ label: "Repository", path: `/${projectName}/repos` },
{ label: "Issues", path: `/${projectName}/issues` },
{ label: "Skills", path: `/${projectName}/skills` },
{ label: "Board", path: `/${projectName}/board` },
{ label: "Chat", path: `/${projectName}/chat` },
{ label: "Settings", path: `/${projectName}/settings` },
];
}
function getRepoTabSiblings(repoBasePath: string): BreadcrumbSibling[] {
return [
{ label: "Code", path: repoBasePath },
{ label: "Commits", path: `${repoBasePath}/commits` },
{ label: "Pull Requests", path: `${repoBasePath}/pulls` },
{ label: "Branches", path: `${repoBasePath}/branches` },
{ label: "Tags", path: `${repoBasePath}/tags` },
{ label: "Settings", path: `${repoBasePath}/settings` },
];
}
function getSegmentSiblings(
segment: BreadcrumbSegment,
projects: Array<{ name: string; display_name: string }>,
): BreadcrumbSibling[] | null {
if (segment.isLast) return null;
const parts = segment.path.split("/").filter(Boolean);
const depth = parts.length;
if (depth === 1) {
if (parts[0] === "me") return ME_NAV_SIBLINGS;
if (projects.length > 1) {
return projects.map((p) => ({
label: p.display_name || p.name,
path: `/${p.name}`,
}));
}
return null;
}
if (depth === 2) {
if (parts[0] === "me") return ME_NAV_SIBLINGS;
return getProjectNavSiblings(parts[0]);
}
if (depth >= 3 && parts[1] === "repo") {
const repoBase = `/${parts[0]}/repo/${parts[2]}`;
return getRepoTabSiblings(repoBase);
}
return null;
}
function useBreadcrumbs() {
const location = useLocation(); const location = useLocation();
const params = useParams<{ const params = useParams<{
projectName?: string; projectName?: string;
@ -65,7 +142,7 @@ function useBreadcrumbs(): BreadcrumbSegment[] {
segments.push({ label, path, isLast, fullUuid }); segments.push({ label, path, isLast, fullUuid });
} }
return segments; return { segments, projects };
} }
const TOOLBAR_ICONS = [ const TOOLBAR_ICONS = [
@ -89,7 +166,7 @@ const TOOLBAR_ICONS = [
export const Header = memo(function Header() { export const Header = memo(function Header() {
const location = useLocation(); const location = useLocation();
const segments = useBreadcrumbs(); const { segments, projects } = useBreadcrumbs();
const { showMembers, setShowMembers } = useProjectLayout(); const { showMembers, setShowMembers } = useProjectLayout();
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const roomContext = useOptionalRoom(); const roomContext = useOptionalRoom();
@ -116,8 +193,11 @@ export const Header = memo(function Header() {
<Home className="w-3.5 h-3.5" /> <Home className="w-3.5 h-3.5" />
</Link> </Link>
{segments.map((segment, idx) => ( {segments.map((segment, idx) => {
<span key={segment.path + idx} className="flex items-center gap-1"> const siblings = getSegmentSiblings(segment, projects);
return (
<span key={segment.path + idx} className="flex items-center gap-1 min-w-0">
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" /> <ChevronRight className="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
{segment.isLast ? ( {segment.isLast ? (
<span <span
@ -129,6 +209,26 @@ export const Header = memo(function Header() {
> >
{segment.label} {segment.label}
</span> </span>
) : siblings && siblings.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex items-center gap-0.5 text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded px-0.5 -mx-0.5"
>
<span className="truncate max-w-[180px]">{segment.label}</span>
<ChevronDown className="w-3 h-3 shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-36 max-h-64 overflow-y-auto">
{siblings.map((s) => (
<DropdownMenuItem key={s.path} asChild>
<Link to={s.path} className="truncate">
{s.label}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : segment.fullUuid ? ( ) : segment.fullUuid ? (
<Link <Link
to={segment.path} to={segment.path}
@ -147,7 +247,8 @@ export const Header = memo(function Header() {
</Link> </Link>
)} )}
</span> </span>
))} );
})}
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">

View File

@ -5,7 +5,7 @@ import {useProjectsQuery} from "@/hooks/useProjectsQuery";
import {Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"; import {Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover";
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"; import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
import {useSettingsModal} from "@/components/settings/SettingsModalContext"; import {useSettingsModal} from "@/components/settings/SettingsModalContext";
import {LogOut, Settings, Home, Plus} from "lucide-react"; import {LogOut, Settings, Home, Plus, Compass} from "lucide-react";
import { CreateProjectModal } from "@/app/me/components/CreateProjectModal"; import { CreateProjectModal } from "@/app/me/components/CreateProjectModal";
const AVATAR_COLORS = [ const AVATAR_COLORS = [
@ -33,6 +33,7 @@ export const ServerIconRail = memo(function ServerIconRail() {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const isHome = location.pathname.startsWith("/me"); const isHome = location.pathname.startsWith("/me");
const isExplore = location.pathname.startsWith("/explore");
const pathParts = location.pathname.split("/").filter(Boolean); const pathParts = location.pathname.split("/").filter(Boolean);
const currentProjectName = pathParts.length > 0 ? pathParts[0] : ""; const currentProjectName = pathParts.length > 0 ? pathParts[0] : "";
@ -93,6 +94,16 @@ export const ServerIconRail = memo(function ServerIconRail() {
}) })
)} )}
{/* Explore */}
<div
className="relative group flex items-center justify-center w-[34px] h-[34px] rounded-2xl cursor-pointer transition-all hover:opacity-80"
style={{ backgroundColor: isExplore ? "var(--accent)" : "transparent" }}
onClick={() => navigate("/explore")}
title="Explore Projects"
>
<Compass className="w-[18px] h-[18px]" style={{ color: isExplore ? "var(--accent-fg)" : "var(--text-secondary)" }} />
</div>
{/* Add project */} {/* Add project */}
<div className="relative group flex items-center justify-center w-[34px] h-[34px] rounded-2xl cursor-pointer transition-all hover:opacity-80" <div className="relative group flex items-center justify-center w-[34px] h-[34px] rounded-2xl cursor-pointer transition-all hover:opacity-80"
style={{ border: "0.5px solid var(--border-subtle)" }} style={{ border: "0.5px solid var(--border-subtle)" }}