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,
} from "@/app/project";
import { ChatPage } from "@/app/chat";
import { ExplorePage } from "@/app/explore/ExplorePage";
import CodePage from "@/app/project/repo/code";
import CommitsPage from "@/app/project/repo/commits";
import PullsPage from "@/app/project/repo/pulls";
@ -84,6 +85,7 @@ export default function App() {
<Route path="/me/following" element={<MePage />} />
<Route path="/me/chat" element={<ChatPage scope="personal" />} />
<Route path="/me/chat/:conversationId" element={<ChatPage scope="personal" />} />
<Route path="/explore" element={<ExplorePage />} />
</Route>
<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 { PanelLeftOpen } from "lucide-react";
import { ServerIconRail } from "@/components/layout/ServerIconRail";
@ -8,11 +8,14 @@ import { useIsMobile } from "@/hooks/use-mobile";
export function MeLayout() {
const isMobile = useIsMobile();
const location = useLocation();
const isExplore = location.pathname === "/explore";
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
return (
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--surface-ground)" }}>
{!isMobile && <ServerIconRail />}
{!isExplore && (
<div className="relative flex shrink-0">
<div
style={{
@ -48,6 +51,7 @@ export function MeLayout() {
</button>
)}
</div>
)}
<div className="flex-1 flex flex-col overflow-hidden min-w-0" style={{ backgroundColor: "var(--surface-ground)" }}>
<Header />

View File

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

View File

@ -42,6 +42,7 @@ export function MessageList({
const virtuosoRef = useRef<VirtuosoHandle>(null);
const initialScrollDoneRef = useRef(false);
const prevLengthRef = useRef(0);
const scrollerRef = useRef<HTMLElement | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const [newMsgCount, setNewMsgCount] = useState(0);
const isMobile = useIsMobile();
@ -85,22 +86,30 @@ export function MessageList({
return result;
}, [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(() => {
initialScrollDoneRef.current = false;
prevLengthRef.current = 0;
setIsAtBottom(true);
setNewMsgCount(0);
if (renderedItems.length > 0) {
setTimeout(() => {
virtuosoRef.current?.scrollToIndex({
index: renderedItems.length - 1,
behavior: 'auto',
});
initialScrollDoneRef.current = true;
}, 50);
const el = scrollerRef.current;
if (el) {
// immediate: estimated heights
el.scrollTop = el.scrollHeight;
// delayed: after Virtuoso finishes measuring actual heights
const timer = setTimeout(() => {
el.scrollTop = el.scrollHeight;
initialScrollDoneRef.current = true;
}, 300);
return () => clearTimeout(timer);
}
}
}, [roomId]);
}, [roomId, renderedItems.length, isLoadingHistory]);
// Handle new messages: auto-scroll if at bottom, else show count
useEffect(() => {
@ -109,10 +118,8 @@ export function MessageList({
if (renderedItems.length > prevLen && prevLen > 0 && initialScrollDoneRef.current && !isLoadingHistory) {
if (isAtBottom) {
virtuosoRef.current?.scrollToIndex({
index: renderedItems.length - 1,
behavior: 'smooth',
});
const el = scrollerRef.current;
if (el) el.scrollTop = el.scrollHeight;
setNewMsgCount(0);
} else {
setNewMsgCount(prev => prev + (renderedItems.length - prevLen));
@ -121,10 +128,8 @@ export function MessageList({
}, [renderedItems.length, isLoadingHistory, isAtBottom]);
const handleScrollToBottom = useCallback(() => {
virtuosoRef.current?.scrollToIndex({
index: renderedItems.length - 1,
behavior: 'smooth',
});
const el = scrollerRef.current;
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
setNewMsgCount(0);
setIsAtBottom(true);
}, [renderedItems.length]);
@ -138,15 +143,20 @@ export function MessageList({
}
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
key={roomId}
ref={virtuosoRef}
style={{ flex: 1 }}
data={renderedItems}
initialTopMostItemIndex={renderedItems.length > 0 ? renderedItems.length - 1 : 0}
initialTopMostItemIndex={
renderedItems.length > 0
? { index: renderedItems.length - 1, align: 'end' }
: 0
}
startReached={onStartReached}
atBottomStateChange={(atBottom) => setIsAtBottom(atBottom)}
scrollerRef={(el) => { scrollerRef.current = el; }}
overscan={isMobile ? 50 : 200}
itemContent={(_, item) => {
if (item.type === 'notice') {

View File

@ -1,10 +1,16 @@
import { memo, useCallback, useState } from "react";
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 { useProjectsQuery } from "@/hooks/useProjectsQuery";
import { useOptionalRoom } from "@/contexts/room";
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;
@ -19,7 +25,78 @@ interface BreadcrumbSegment {
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 params = useParams<{
projectName?: string;
@ -65,7 +142,7 @@ function useBreadcrumbs(): BreadcrumbSegment[] {
segments.push({ label, path, isLast, fullUuid });
}
return segments;
return { segments, projects };
}
const TOOLBAR_ICONS = [
@ -89,7 +166,7 @@ const TOOLBAR_ICONS = [
export const Header = memo(function Header() {
const location = useLocation();
const segments = useBreadcrumbs();
const { segments, projects } = useBreadcrumbs();
const { showMembers, setShowMembers } = useProjectLayout();
const [showSettings, setShowSettings] = useState(false);
const roomContext = useOptionalRoom();
@ -116,38 +193,62 @@ export const Header = memo(function Header() {
<Home className="w-3.5 h-3.5" />
</Link>
{segments.map((segment, idx) => (
<span key={segment.path + idx} className="flex items-center gap-1">
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
{segment.isLast ? (
<span
className={`font-medium text-foreground truncate${segment.fullUuid ? " font-mono cursor-pointer hover:text-accent transition-colors" : ""}`}
title={segment.fullUuid}
{...(segment.fullUuid
? { onClick: (e: React.MouseEvent) => handleCopy(e, segment.fullUuid!) }
: {})}
>
{segment.label}
</span>
) : segment.fullUuid ? (
<Link
to={segment.path}
className="text-muted-foreground hover:text-foreground font-mono cursor-pointer transition-colors"
title={segment.fullUuid}
onClick={(e) => handleCopy(e, segment.fullUuid!)}
>
{segment.label}
</Link>
) : (
<Link
to={segment.path}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{segment.label}
</Link>
)}
</span>
))}
{segments.map((segment, idx) => {
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" />
{segment.isLast ? (
<span
className={`font-medium text-foreground truncate${segment.fullUuid ? " font-mono cursor-pointer hover:text-accent transition-colors" : ""}`}
title={segment.fullUuid}
{...(segment.fullUuid
? { onClick: (e: React.MouseEvent) => handleCopy(e, segment.fullUuid!) }
: {})}
>
{segment.label}
</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 ? (
<Link
to={segment.path}
className="text-muted-foreground hover:text-foreground font-mono cursor-pointer transition-colors"
title={segment.fullUuid}
onClick={(e) => handleCopy(e, segment.fullUuid!)}
>
{segment.label}
</Link>
) : (
<Link
to={segment.path}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{segment.label}
</Link>
)}
</span>
);
})}
</div>
<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 {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
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";
const AVATAR_COLORS = [
@ -33,6 +33,7 @@ export const ServerIconRail = memo(function ServerIconRail() {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const isHome = location.pathname.startsWith("/me");
const isExplore = location.pathname.startsWith("/explore");
const pathParts = location.pathname.split("/").filter(Boolean);
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 */}
<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)" }}