refactor(ui): update project and channel layouts for new theme system

Update channel layout, project layout, BoardHeader, and
IssueSidebar to use CSS variable-based theme tokens.
This commit is contained in:
ZhenYi 2026-05-18 20:44:52 +08:00
parent 86ab2d2f85
commit 16865117de
4 changed files with 563 additions and 355 deletions

View File

@ -1,49 +1,64 @@
import { createContext, useContext, useState, useMemo, useCallback, type ReactNode } from "react"; import {
import { Outlet } from "react-router-dom"; createContext,
import { useMatch } from "react-router-dom"; useContext,
import { ChevronRight } from "lucide-react"; useState,
import { ServerIconRail } from "@/components/layout/ServerIconRail"; useMemo,
import { ChannelSidebar } from "@/components/layout/ChannelSidebar"; useCallback,
import { Header } from "@/components/layout/Header"; type ReactNode,
import { MemberList } from "@/components/layout/MemberList"; } from "react"
import { useIsMobile, useIsTablet } from "@/hooks/use-mobile"; import { Outlet } from "react-router-dom"
import { useMatch } from "react-router-dom"
import { ChevronRight } from "lucide-react"
import { ServerIconRail } from "@/components/layout/ServerIconRail"
import { ChannelSidebar } from "@/components/layout/ChannelSidebar"
import { Header } from "@/components/layout/Header"
import { MemberList } from "@/components/layout/MemberList"
import { useIsMobile, useIsTablet } from "@/hooks/use-mobile"
interface ChannelContextType { interface ChannelContextType {
showMembers: boolean; showMembers: boolean
setShowMembers: (v: boolean) => void; setShowMembers: (v: boolean) => void
} }
const ChannelContext = createContext<ChannelContextType>({ const ChannelContext = createContext<ChannelContextType>({
showMembers: false, showMembers: false,
setShowMembers: () => {}, setShowMembers: () => {},
}); })
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export const useChannel = () => useContext(ChannelContext); export const useChannel = () => useContext(ChannelContext)
export function ChannelLayout({ children }: { children?: ReactNode }) { export function ChannelLayout({ children }: { children?: ReactNode }) {
const [showMembers, setShowMembers] = useState(false); const [showMembers, setShowMembers] = useState(false)
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
const isMobile = useIsMobile(); const isMobile = useIsMobile()
const isTablet = useIsTablet(); const isTablet = useIsTablet()
const canShowMembers = !isMobile && !isTablet; const canShowMembers = !isMobile && !isTablet
const contextValue = useMemo( const contextValue = useMemo(
() => ({ showMembers, setShowMembers }), () => ({ showMembers, setShowMembers }),
[showMembers], [showMembers]
); )
const toggleSidebar = useCallback( const toggleSidebar = useCallback(() => setIsSidebarCollapsed((v) => !v), [])
() => setIsSidebarCollapsed((v) => !v),
[],
);
const roomMatch = useMatch("/channel/:roomId"); const roomMatch = useMatch("/channel/:roomId")
const mainShouldOwnScroll = !roomMatch; const mainShouldOwnScroll = !roomMatch
return ( return (
<ChannelContext.Provider value={contextValue}> <ChannelContext.Provider value={contextValue}>
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--surface-ground)" }}> <div
className="relative flex h-screen overflow-hidden"
style={{ backgroundColor: "var(--surface-ground)" }}
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-70"
style={{
backgroundImage:
"radial-gradient(circle at top left, color-mix(in oklch, var(--accent) 9%, transparent) 0, transparent 28%), radial-gradient(circle at bottom right, color-mix(in oklch, var(--accent) 4%, transparent) 0, transparent 24%)",
}}
/>
{!isMobile && <ServerIconRail />} {!isMobile && <ServerIconRail />}
<div className="relative flex shrink-0"> <div className="relative flex shrink-0">
@ -53,14 +68,16 @@ export function ChannelLayout({ children }: { children?: ReactNode }) {
transition: "width 0.2s ease", transition: "width 0.2s ease",
overflow: "hidden", overflow: "hidden",
backgroundColor: "var(--surface-sidebar)", backgroundColor: "var(--surface-sidebar)",
borderRight: isSidebarCollapsed ? "none" : "0.5px solid var(--border-subtle)", borderRight: isSidebarCollapsed
? "none"
: "0.5px solid var(--border-subtle)",
}} }}
> >
{!isSidebarCollapsed && <ChannelSidebar />} {!isSidebarCollapsed && <ChannelSidebar />}
</div> </div>
<button <button
onClick={toggleSidebar} onClick={toggleSidebar}
className="absolute flex items-center justify-center w-5 h-10 cursor-pointer transition-colors" className="absolute flex h-10 w-5 cursor-pointer items-center justify-center transition-colors"
style={{ style={{
color: "var(--text-muted)", color: "var(--text-muted)",
backgroundColor: "var(--surface-sidebar)", backgroundColor: "var(--surface-sidebar)",
@ -75,15 +92,22 @@ export function ChannelLayout({ children }: { children?: ReactNode }) {
title={isSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} title={isSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
> >
<ChevronRight <ChevronRight
className={`w-3.5 h-3.5 transition-transform duration-200 ${isSidebarCollapsed ? "" : "rotate-180"}`} className={`h-3.5 w-3.5 transition-transform duration-200 ${isSidebarCollapsed ? "" : "rotate-180"}`}
/> />
</button> </button>
</div> </div>
<div className="flex-1 flex flex-col overflow-hidden min-w-0" style={{ backgroundColor: "var(--surface-ground)" }}> <div
className="relative z-10 flex min-w-0 flex-1 flex-col overflow-hidden"
style={{ backgroundColor: "var(--surface-ground)" }}
>
<Header /> <Header />
<main <main
className={mainShouldOwnScroll ? "flex-1 overflow-y-auto" : "flex-1 overflow-hidden min-h-0"} className={
mainShouldOwnScroll
? "flex-1 overflow-y-auto"
: "min-h-0 flex-1 overflow-hidden"
}
style={{ backgroundColor: "var(--surface-ground)" }} style={{ backgroundColor: "var(--surface-ground)" }}
> >
{children ?? <Outlet />} {children ?? <Outlet />}
@ -91,11 +115,13 @@ export function ChannelLayout({ children }: { children?: ReactNode }) {
</div> </div>
{canShowMembers && ( {canShowMembers && (
<div className={`transition-all duration-300 ${showMembers ? "w-[240px]" : "w-0"}`}> <div
className={`transition-all duration-300 ${showMembers ? "w-[240px]" : "w-0"}`}
>
{showMembers && <MemberList />} {showMembers && <MemberList />}
</div> </div>
)} )}
</div> </div>
</ChannelContext.Provider> </ChannelContext.Provider>
); )
} }

View File

@ -1,13 +1,13 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom"
import { Plus, Trash2, ArrowLeft } from "lucide-react"; import { Plus, Trash2, ArrowLeft } from "lucide-react"
import { BOARD_PAGE } from "@/css/app/board-styles"; import { Button } from "@/components/ui/button"
interface BoardHeaderProps { interface BoardHeaderProps {
projectName: string; projectName: string
boardName: string; boardName: string
boardDescription?: string; boardDescription?: string
onDeleteBoard: () => void; onDeleteBoard: () => void
onAddColumn: () => void; onAddColumn: () => void
} }
export function BoardHeader({ export function BoardHeader({
@ -17,40 +17,60 @@ export function BoardHeader({
onDeleteBoard, onDeleteBoard,
onAddColumn, onAddColumn,
}: BoardHeaderProps) { }: BoardHeaderProps) {
const navigate = useNavigate(); const navigate = useNavigate()
return ( return (
<div className={BOARD_PAGE.header}> <div className="rounded-2xl border border-border/70 bg-card/80 p-5 shadow-sm backdrop-blur">
<div className={BOARD_PAGE.titleGroup}> <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-center gap-3"> <div className="min-w-0">
<button <div className="flex items-center gap-3">
onClick={() => navigate(`/${projectName}/board`)} <Button
className={BOARD_PAGE.iconBtn} type="button"
> variant="ghost"
<ArrowLeft className="w-5 h-5" /> size="icon-sm"
</button> className="rounded-full text-muted-foreground"
<h1 className={BOARD_PAGE.title}>{boardName}</h1> onClick={() => navigate(`/${projectName}/board`)}
title="Back to boards"
>
<ArrowLeft />
</Button>
</div>
<div className="mt-3 flex flex-wrap items-center gap-3">
<h1 className="truncate text-2xl font-semibold tracking-tight text-foreground">
{boardName}
</h1>
<span className="rounded-full bg-muted px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
/{projectName}
</span>
</div>
{boardDescription && (
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
{boardDescription}
</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
onClick={onAddColumn}
variant="outline"
size="sm"
className="rounded-full"
>
<Plus />
Add column
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="rounded-full text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={onDeleteBoard}
title="Delete board"
>
<Trash2 />
</Button>
</div> </div>
<p className={BOARD_PAGE.description} style={{ marginLeft: 36 }}>
{boardDescription}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={onAddColumn}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors text-[12px] font-medium"
>
<Plus className="w-4 h-4" />
Add Column
</button>
<button
className={BOARD_PAGE.iconBtn}
onClick={onDeleteBoard}
title="Delete Board"
>
<Trash2 className="w-4 h-4" />
</button>
</div> </div>
</div> </div>
); )
} }

View File

@ -15,9 +15,10 @@ import {
useUnlinkPRMutation, useUnlinkPRMutation,
useLinkRepoMutation, useLinkRepoMutation,
useUnlinkRepoMutation, useUnlinkRepoMutation,
} from "@/hooks/useIssueExtraQuery"; } from "@/hooks/useIssueExtraQuery"
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { import {
Tag, Tag,
Bell, Bell,
@ -27,97 +28,135 @@ import {
Plus, Plus,
X, X,
Users, Users,
Settings Settings,
} from "lucide-react"; } from "lucide-react"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuSeparator DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu"
import { useState } from "react"; import { useState } from "react"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input"
import { t } from "@/i18n/T"; import { t } from "@/i18n/T"
interface IssueSidebarProps { interface IssueSidebarProps {
projectName: string; projectName: string
issueNumber: number; issueNumber: number
} }
export function IssueSidebar({ projectName, issueNumber }: IssueSidebarProps) { export function IssueSidebar({ projectName, issueNumber }: IssueSidebarProps) {
const { data: assignees = [] } = useIssueAssigneesQuery({ projectName, issueNumber }); const { data: assignees = [] } = useIssueAssigneesQuery({
const { data: issueLabels = [] } = useIssueLabelsQuery({ projectName, issueNumber }); projectName,
const { data: projectLabels = [] } = useProjectLabelsQuery(projectName); issueNumber,
const { data: subscribers = [] } = useIssueSubscribersQuery({ projectName, issueNumber }); })
const { data: prs = [] } = useIssuePRsQuery({ projectName, issueNumber }); const { data: issueLabels = [] } = useIssueLabelsQuery({
const { data: repos = [] } = useIssueReposQuery({ projectName, issueNumber }); projectName,
issueNumber,
})
const { data: projectLabels = [] } = useProjectLabelsQuery(projectName)
const { data: subscribers = [] } = useIssueSubscribersQuery({
projectName,
issueNumber,
})
const { data: prs = [] } = useIssuePRsQuery({ projectName, issueNumber })
const { data: repos = [] } = useIssueReposQuery({ projectName, issueNumber })
const addAssignee = useAddAssigneeMutation(); const addAssignee = useAddAssigneeMutation()
const removeAssignee = useRemoveAssigneeMutation(); const removeAssignee = useRemoveAssigneeMutation()
const addLabel = useAddIssueLabelMutation(); const addLabel = useAddIssueLabelMutation()
const removeLabel = useRemoveIssueLabelMutation(); const removeLabel = useRemoveIssueLabelMutation()
const subscribe = useIssueSubscribeMutation(); const subscribe = useIssueSubscribeMutation()
const unsubscribe = useIssueUnsubscribeMutation(); const unsubscribe = useIssueUnsubscribeMutation()
const linkPR = useLinkPRMutation(); const linkPR = useLinkPRMutation()
const unlinkPR = useUnlinkPRMutation(); const unlinkPR = useUnlinkPRMutation()
const linkRepo = useLinkRepoMutation(); const linkRepo = useLinkRepoMutation()
const unlinkRepo = useUnlinkRepoMutation(); const unlinkRepo = useUnlinkRepoMutation()
const isSubscribed = subscribers.some(s => s.user_id === "me"); const isSubscribed = subscribers.some((s) => s.user_id === "me")
const [showLinkPR, setShowLinkPR] = useState(false); const [showLinkPR, setShowLinkPR] = useState(false)
const [prRepo, setPrRepo] = useState(""); const [prRepo, setPrRepo] = useState("")
const [prNum, setPrNum] = useState(""); const [prNum, setPrNum] = useState("")
const [showLinkRepo, setShowLinkRepo] = useState(false); const [showLinkRepo, setShowLinkRepo] = useState(false)
const [repoName, setRepoName] = useState(""); const [repoName, setRepoName] = useState("")
return ( return (
<div className="space-y-6"> <div className="space-y-5 rounded-2xl border border-border/70 bg-card/80 p-4 shadow-sm backdrop-blur">
{/* Assignees */} {/* Assignees */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between group"> <div className="group flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}> <h3 className="flex items-center gap-1.5 text-xs font-semibold tracking-wider text-muted-foreground uppercase">
<Users className="w-3.5 h-3.5" /> {t("project.issue_detail.assignees")} <Users className="size-3.5" /> {t("project.issue_detail.assignees")}
<Badge
variant="secondary"
className="ml-1 rounded-full px-2 py-0.5 text-[10px]"
>
{assignees.length}
</Badge>
</h3> </h3>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button className="hover:underline text-[10px] font-medium opacity-0 group-hover:opacity-100 transition-opacity" style={{ color: "var(--accent)" }}> <button className="text-[10px] font-medium text-accent opacity-0 transition-opacity group-hover:opacity-100 hover:underline">
{t("project.issue_detail.edit")} {t("project.issue_detail.edit")}
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48" style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }}> <DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem className="text-xs">{t("project.issue_detail.assign_to_me")}</DropdownMenuItem> <DropdownMenuItem className="text-xs">
<DropdownMenuSeparator /> {t("project.issue_detail.assign_to_me")}
<p className="px-2 py-1.5 text-[10px] text-muted-foreground uppercase font-bold">{t("project.issue_detail.suggestions")}</p> </DropdownMenuItem>
<DropdownMenuItem onClick={() => addAssignee.mutate({ projectName, issueNumber, userId: "me" })}> <DropdownMenuSeparator />
admin <p className="px-2 py-1.5 text-[10px] font-bold text-muted-foreground uppercase">
</DropdownMenuItem> {t("project.issue_detail.suggestions")}
</p>
<DropdownMenuItem
onClick={() =>
addAssignee.mutate({ projectName, issueNumber, userId: "me" })
}
>
admin
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5 rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-ground)]/60 p-3">
{assignees.length === 0 ? ( {assignees.length === 0 ? (
<p className="text-xs" style={{ color: "var(--text-muted)" }}>{t("project.issue_detail.no_assigned")}</p> <p className="text-xs text-muted-foreground">
{t("project.issue_detail.no_assigned")}
</p>
) : ( ) : (
assignees.map(a => ( assignees.map((a) => (
<div key={a.username} className="flex items-center gap-2 text-sm group"> <div
<Avatar className="w-5 h-5 rounded-full"> key={a.username}
<AvatarFallback className="text-[10px]">{a.username[0].toUpperCase()}</AvatarFallback> className="group flex items-center gap-2 text-sm"
>
<Avatar className="size-5 rounded-full">
<AvatarFallback className="text-[10px]">
{a.username[0].toUpperCase()}
</AvatarFallback>
</Avatar> </Avatar>
<span className="flex-1 truncate" style={{ color: "var(--text-primary)" }}>{a.username}</span> <span className="flex-1 truncate text-foreground">
{a.username}
</span>
<button <button
onClick={() => removeAssignee.mutate({ projectName, issueNumber, userId: a.user_id })} onClick={() =>
className="opacity-0 group-hover:opacity-100 text-destructive" removeAssignee.mutate({
projectName,
issueNumber,
userId: a.user_id,
})
}
className="text-destructive opacity-0 transition-opacity group-hover:opacity-100"
> >
<X className="w-3 h-3" /> <X className="size-3" />
</button> </button>
</div> </div>
)) ))
@ -125,57 +164,82 @@ export function IssueSidebar({ projectName, issueNumber }: IssueSidebarProps) {
</div> </div>
</div> </div>
<hr className="border-[var(--border-default)]" /> <Separator />
{/* Labels */} {/* Labels */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between group"> <div className="group flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}> <h3 className="flex items-center gap-1.5 text-xs font-semibold tracking-wider text-muted-foreground uppercase">
<Tag className="w-3.5 h-3.5" /> {t("project.issue_detail.labels")} <Tag className="size-3.5" /> {t("project.issue_detail.labels")}
<Badge
variant="secondary"
className="ml-1 rounded-full px-2 py-0.5 text-[10px]"
>
{issueLabels.length}
</Badge>
</h3> </h3>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button className="hover:underline text-[10px] font-medium opacity-0 group-hover:opacity-100 transition-opacity" style={{ color: "var(--accent)" }}> <button className="text-[10px] font-medium text-accent opacity-0 transition-opacity group-hover:opacity-100 hover:underline">
{t("project.issue_detail.edit")} {t("project.issue_detail.edit")}
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56" style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }}> <DropdownMenuContent align="end" className="w-56">
<p className="px-2 py-1.5 text-[10px] text-muted-foreground uppercase font-bold">{t("project.issue_detail.apply_labels")}</p> <p className="px-2 py-1.5 text-[10px] font-bold text-muted-foreground uppercase">
{projectLabels.map(l => ( {t("project.issue_detail.apply_labels")}
<DropdownMenuItem </p>
key={l.id} {projectLabels.map((l) => (
className="flex items-center gap-2" <DropdownMenuItem
onClick={() => addLabel.mutate({ projectName, issueNumber, labelId: l.id })} key={l.id}
> className="flex items-center gap-2"
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: l.color || "var(--accent)" }} /> onClick={() =>
<span className="flex-1">{l.name}</span> addLabel.mutate({ projectName, issueNumber, labelId: l.id })
{issueLabels.some(il => il.label_name === l.name) && <Loader2 className="w-3 h-3 animate-spin" />} }
</DropdownMenuItem> >
))} <div
<DropdownMenuSeparator /> className="size-2.5 rounded-full"
<DropdownMenuItem style={{ color: "var(--accent)" }}> style={{ backgroundColor: l.color || "var(--accent)" }}
<Settings className="w-3.5 h-3.5 mr-2" /> Manage labels />
<span className="flex-1">{l.name}</span>
{issueLabels.some((il) => il.label_name === l.name) && (
<Loader2 className="size-3 animate-spin" />
)}
</DropdownMenuItem> </DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-accent">
<Settings className="mr-2" /> Manage labels
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1 rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-ground)]/60 p-3">
{issueLabels.length === 0 ? ( {issueLabels.length === 0 ? (
<p className="text-xs" style={{ color: "var(--text-muted)" }}>{t("project.issue_detail.none_yet")}</p> <p className="text-xs text-muted-foreground">
{t("project.issue_detail.none_yet")}
</p>
) : ( ) : (
issueLabels.map(l => ( issueLabels.map((l) => (
<Badge <Badge
key={l.label_name} key={l.label_name}
variant="outline" variant="outline"
className="text-[10px] px-1.5 py-0 h-5 border-none" className="h-5 border-border/70 bg-muted/40 px-1.5 text-[10px] text-foreground"
style={{ backgroundColor: "var(--border-default)", color: "var(--text-primary)", borderLeft: `3px solid ${l.label_color ?? "var(--accent)"}` }} style={{
borderLeft: `3px solid ${l.label_color ?? "var(--accent)"}`,
}}
> >
{l.label_name} {l.label_name}
<button <button
onClick={() => removeLabel.mutate({ projectName, issueNumber, labelId: l.label_id })} onClick={() =>
className="ml-1 hover:text-destructive" removeLabel.mutate({
projectName,
issueNumber,
labelId: l.label_id,
})
}
className="ml-1 transition-colors hover:text-destructive"
> >
<X className="w-2.5 h-2.5" /> <X className="size-2.5" />
</button> </button>
</Badge> </Badge>
)) ))
@ -183,151 +247,236 @@ export function IssueSidebar({ projectName, issueNumber }: IssueSidebarProps) {
</div> </div>
</div> </div>
<hr className="border-[var(--border-default)]" /> <Separator />
{/* Notifications */} {/* Notifications */}
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}> <h3 className="flex items-center gap-1.5 text-xs font-semibold tracking-wider text-muted-foreground uppercase">
<Bell className="w-3.5 h-3.5" /> {t("project.issue_detail.notifications")} <Bell className="size-3.5" />{" "}
{t("project.issue_detail.notifications")}
<Badge
variant="secondary"
className="ml-1 rounded-full px-2 py-0.5 text-[10px]"
>
{subscribers.length}
</Badge>
</h3> </h3>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full h-8 text-xs" className="h-8 w-full justify-start rounded-full text-xs"
style={{ backgroundColor: "var(--surface-elevated)" }} onClick={() => {
onClick={() => { if (isSubscribed) {
if (isSubscribed) { unsubscribe.mutate({ projectName, issueNumber })
unsubscribe.mutate({ projectName, issueNumber }); } else {
} else { subscribe.mutate({ projectName, issueNumber })
subscribe.mutate({ projectName, issueNumber }); }
} }}
}}
> >
{isSubscribed ? t("project.issue_detail.unsubscribe") : t("project.issue_detail.subscribe")} {isSubscribed
? t("project.issue_detail.unsubscribe")
: t("project.issue_detail.subscribe")}
</Button> </Button>
<p className="text-[10px]" style={{ color: "var(--text-muted)" }}> <p className="text-[10px] text-muted-foreground">
{t("project.issue_detail.subscribers_count", { count: String(subscribers.length) })} {t("project.issue_detail.subscribers_count", {
count: String(subscribers.length),
})}
</p> </p>
</div> </div>
<hr className="border-[var(--border-default)]" /> <Separator />
{/* Development */} {/* Development */}
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}> <h3 className="flex items-center gap-1.5 text-xs font-semibold tracking-wider text-muted-foreground uppercase">
{t("project.issue_detail.development")} {t("project.issue_detail.development")}
<Badge
variant="secondary"
className="ml-1 rounded-full px-2 py-0.5 text-[10px]"
>
{prs.length + repos.length}
</Badge>
</h3> </h3>
<div className="space-y-2"> <div className="space-y-2">
{prs.length > 0 && ( {prs.length > 0 && (
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[10px] font-bold uppercase" style={{ color: "var(--text-muted)" }}>{t("project.issue_detail.pull_requests")}</p> <p className="text-[10px] font-bold text-muted-foreground uppercase">
{prs.map(pr => ( {t("project.issue_detail.pull_requests")}
<div key={`${pr.repo}-${pr.number}`} className="flex items-center gap-1.5 text-xs group"> </p>
<div className="flex items-center gap-1.5 hover:underline cursor-pointer" style={{ color: "var(--accent)" }}> {prs.map((pr) => (
<GitPullRequest className="w-3 h-3" /> <div
<span>#{pr.number} in {pr.repo}</span> key={`${pr.repo}-${pr.number}`}
</div> className="group flex items-center gap-1.5 text-xs"
<button >
onClick={() => unlinkPR.mutate({ projectName, issueNumber, repo: pr.repo!, prNumber: pr.number! })} <div className="flex cursor-pointer items-center gap-1.5 text-accent hover:underline">
className="opacity-0 group-hover:opacity-100 text-destructive" <GitPullRequest className="size-3" />
> <span>
<X className="w-3 h-3" /> #{pr.number} in {pr.repo}
</button> </span>
</div> </div>
))} <button
onClick={() =>
unlinkPR.mutate({
projectName,
issueNumber,
repo: pr.repo!,
prNumber: pr.number!,
})
}
className="text-destructive opacity-0 transition-opacity group-hover:opacity-100"
>
<X className="size-3" />
</button>
</div> </div>
)} ))}
{repos.length > 0 && ( </div>
<div className="space-y-1"> )}
<p className="text-[10px] font-bold uppercase" style={{ color: "var(--text-muted)" }}>{t("project.issue_detail.linked_repos")}</p> {repos.length > 0 && (
{repos.map(r => ( <div className="space-y-1">
<div key={r.repo} className="flex items-center gap-1.5 text-xs group"> <p className="text-[10px] font-bold text-muted-foreground uppercase">
<div className="flex items-center gap-1.5 hover:underline cursor-pointer" style={{ color: "var(--accent)" }}> {t("project.issue_detail.linked_repos")}
<LinkIcon className="w-3 h-3" /> </p>
<span>{r.repo}</span> {repos.map((r) => (
</div> <div
<button key={r.repo}
onClick={() => unlinkRepo.mutate({ projectName, issueNumber, repoId: r.repo! })} className="group flex items-center gap-1.5 text-xs"
className="opacity-0 group-hover:opacity-100 text-destructive" >
> <div className="flex cursor-pointer items-center gap-1.5 text-accent hover:underline">
<X className="w-3 h-3" /> <LinkIcon className="size-3" />
</button> <span>{r.repo}</span>
</div> </div>
))} <button
onClick={() =>
unlinkRepo.mutate({
projectName,
issueNumber,
repoId: r.repo!,
})
}
className="text-destructive opacity-0 transition-opacity group-hover:opacity-100"
>
<X className="size-3" />
</button>
</div> </div>
)} ))}
<button className="flex items-center gap-1 text-[10px] hover:underline font-medium" style={{ color: "var(--accent)" }} onClick={() => setShowLinkPR(true)}> </div>
<Plus className="w-3 h-3" /> {t("project.issue_detail.link_pr")} )}
<div className="flex flex-col gap-2">
<button
className="flex items-center gap-1 rounded-full border border-[var(--border-subtle)] bg-[var(--surface-ground)]/60 px-3 py-1.5 text-[10px] font-medium text-accent transition-colors hover:bg-[var(--hover-bg)]"
onClick={() => setShowLinkPR(true)}
>
<Plus className="size-3" /> {t("project.issue_detail.link_pr")}
</button> </button>
<button className="flex items-center gap-1 text-[10px] hover:underline font-medium" style={{ color: "var(--accent)" }} onClick={() => setShowLinkRepo(true)}> <button
<Plus className="w-3 h-3" /> {t("project.issue_detail.link_repo")} className="flex items-center gap-1 rounded-full border border-[var(--border-subtle)] bg-[var(--surface-ground)]/60 px-3 py-1.5 text-[10px] font-medium text-accent transition-colors hover:bg-[var(--hover-bg)]"
onClick={() => setShowLinkRepo(true)}
>
<Plus className="size-3" /> {t("project.issue_detail.link_repo")}
</button> </button>
</div>
</div> </div>
</div> </div>
{/* Link PR Dialog */} {/* Link PR Dialog */}
<Dialog open={showLinkPR} onOpenChange={setShowLinkPR}> <Dialog open={showLinkPR} onOpenChange={setShowLinkPR}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t("project.issue_detail.link_pr_title")}</DialogTitle> <DialogTitle>{t("project.issue_detail.link_pr_title")}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="text-xs font-medium" style={{ color: "var(--text-muted)" }}>Repo</label> <label
<Input value={prRepo} onChange={(e) => setPrRepo(e.target.value)} placeholder="owner/repo" /> className="text-xs font-medium"
</div> style={{ color: "var(--text-muted)" }}
<div> >
<label className="text-xs font-medium" style={{ color: "var(--text-muted)" }}>PR Number</label> Repo
<Input value={prNum} onChange={(e) => setPrNum(e.target.value)} placeholder="123" /> </label>
</div> <Input
<Button value={prRepo}
size="sm" onChange={(e) => setPrRepo(e.target.value)}
style={{ backgroundColor: "var(--accent)" }} placeholder="owner/repo"
onClick={() => { />
const parts = prRepo.split("/");
if (parts.length === 2 && prNum) {
linkPR.mutate({ projectName, issueNumber, repo: prRepo, prNumber: Number(prNum) });
setShowLinkPR(false);
setPrRepo("");
setPrNum("");
}
}}
disabled={linkPR.isPending}
>
Link
</Button>
</div> </div>
<div>
<label
className="text-xs font-medium"
style={{ color: "var(--text-muted)" }}
>
PR Number
</label>
<Input
value={prNum}
onChange={(e) => setPrNum(e.target.value)}
placeholder="123"
/>
</div>
<Button
size="sm"
className="rounded-full"
style={{ backgroundColor: "var(--accent)" }}
onClick={() => {
const parts = prRepo.split("/")
if (parts.length === 2 && prNum) {
linkPR.mutate({
projectName,
issueNumber,
repo: prRepo,
prNumber: Number(prNum),
})
setShowLinkPR(false)
setPrRepo("")
setPrNum("")
}
}}
disabled={linkPR.isPending}
>
Link
</Button>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Link Repo Dialog */} {/* Link Repo Dialog */}
<Dialog open={showLinkRepo} onOpenChange={setShowLinkRepo}> <Dialog open={showLinkRepo} onOpenChange={setShowLinkRepo}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t("project.issue_detail.link_repo_title")}</DialogTitle> <DialogTitle>
</DialogHeader> {t("project.issue_detail.link_repo_title")}
<div className="space-y-3"> </DialogTitle>
<div> </DialogHeader>
<label className="text-xs font-medium" style={{ color: "var(--text-muted)" }}>Repo</label> <div className="space-y-3">
<Input value={repoName} onChange={(e) => setRepoName(e.target.value)} placeholder="owner/repo" /> <div>
</div> <label
<Button className="text-xs font-medium"
size="sm" style={{ color: "var(--text-muted)" }}
style={{ backgroundColor: "var(--accent)" }} >
onClick={() => { Repo
if (repoName) { </label>
linkRepo.mutate({ projectName, issueNumber, repo: repoName }); <Input
setShowLinkRepo(false); value={repoName}
setRepoName(""); onChange={(e) => setRepoName(e.target.value)}
} placeholder="owner/repo"
}} />
disabled={linkRepo.isPending}
>
Link
</Button>
</div> </div>
<Button
size="sm"
className="rounded-full"
style={{ backgroundColor: "var(--accent)" }}
onClick={() => {
if (repoName) {
linkRepo.mutate({ projectName, issueNumber, repo: repoName })
setShowLinkRepo(false)
setRepoName("")
}
}}
disabled={linkRepo.isPending}
>
Link
</Button>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
); )
} }

View File

@ -1,25 +1,25 @@
import { Link, Outlet, useMatch, useParams } from "react-router-dom"; import { Link, Outlet, useMatch, useParams } from "react-router-dom"
import { createContext, useContext, useState } from "react"; import { createContext, useContext, useState } from "react"
import { Lock, PanelLeftOpen } from "lucide-react"; import { Lock, PanelLeftOpen } from "lucide-react"
import { ServerIconRail } from "@/components/layout/ServerIconRail"; import { ServerIconRail } from "@/components/layout/ServerIconRail"
import { ChannelSidebar } from "@/components/layout/ChannelSidebar"; import { ChannelSidebar } from "@/components/layout/ChannelSidebar"
import { Header } from "@/components/layout/Header"; import { Header } from "@/components/layout/Header"
import { MemberList } from "@/components/layout/MemberList"; import { MemberList } from "@/components/layout/MemberList"
import { RoomProvider } from "@/contexts/room"; import { RoomProvider } from "@/contexts/room"
import { useIsMobile, useIsTablet } from "@/hooks/use-mobile"; import { useIsMobile, useIsTablet } from "@/hooks/use-mobile"
import { useProjectInfo } from "@/hooks/useProjectInfo"; import { useProjectInfo } from "@/hooks/useProjectInfo"
import type { ProjectInfoRelational } from "@/client/model"; import type { ProjectInfoRelational } from "@/client/model"
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button"
import { t } from "@/i18n/T"; import { t } from "@/i18n/T"
interface ProjectContextType { interface ProjectContextType {
showMembers: boolean; showMembers: boolean
setShowMembers: (v: boolean) => void; setShowMembers: (v: boolean) => void
currentRoomName: string | null; currentRoomName: string | null
setCurrentRoomName: (name: string | null) => void; setCurrentRoomName: (name: string | null) => void
projectInfo: ProjectInfoRelational | null; projectInfo: ProjectInfoRelational | null
isProjectMember: boolean; isProjectMember: boolean
isProjectPreview: boolean; isProjectPreview: boolean
} }
const ProjectContext = createContext<ProjectContextType>({ const ProjectContext = createContext<ProjectContextType>({
@ -30,62 +30,67 @@ const ProjectContext = createContext<ProjectContextType>({
projectInfo: null, projectInfo: null,
isProjectMember: false, isProjectMember: false,
isProjectPreview: false, isProjectPreview: false,
}); })
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export const useProjectLayout = () => useContext(ProjectContext); export const useProjectLayout = () => useContext(ProjectContext)
export function ProjectJoinBanner({ export function ProjectJoinBanner({
compact = false, compact = false,
message, message,
}: { }: {
compact?: boolean; compact?: boolean
message?: string; message?: string
}) { }) {
const { projectInfo } = useProjectLayout(); const { projectInfo } = useProjectLayout()
const projectName = projectInfo?.name; const projectName = projectInfo?.name
const defaultMessage = t("project.layout.join_banner.join_to_participate"); const defaultMessage = t("project.layout.join_banner.join_to_participate")
return ( return (
<div <div
className={`flex ${compact ? "items-center justify-between gap-3 rounded-lg px-4 py-3" : "items-start justify-between gap-4 px-6 py-4"} border bg-muted/30`} className={`flex ${compact ? "items-center justify-between gap-3 rounded-xl px-4 py-3" : "items-start justify-between gap-4 px-6 py-4"} border border-border/70 bg-card/80 shadow-sm backdrop-blur`}
style={{ borderColor: "var(--border-subtle)" }}
> >
<div className="flex min-w-0 items-start gap-3"> <div className="flex min-w-0 items-start gap-3">
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground"> <div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Lock className="size-4" /> <Lock className="size-4" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-foreground">{t("project.layout.join_banner.preview_mode")}</p> <p className="text-sm font-medium text-foreground">
<p className="text-sm text-muted-foreground">{message ?? defaultMessage}</p> {t("project.layout.join_banner.preview_mode")}
</p>
<p className="text-sm text-muted-foreground">
{message ?? defaultMessage}
</p>
</div> </div>
</div> </div>
{projectName && ( {projectName && (
<Button asChild size={compact ? "sm" : "default"} className="shrink-0"> <Button asChild size={compact ? "sm" : "default"} className="shrink-0">
<Link to={`/${projectName}/join`}>{t("project.layout.join_banner.apply_to_join")}</Link> <Link to={`/${projectName}/join`}>
{t("project.layout.join_banner.apply_to_join")}
</Link>
</Button> </Button>
)} )}
</div> </div>
); )
} }
export function ProjectLayout() { export function ProjectLayout() {
const [showMembers, setShowMembers] = useState(false); const [showMembers, setShowMembers] = useState(false)
const [currentRoomName, setCurrentRoomName] = useState<string | null>(null); const [currentRoomName, setCurrentRoomName] = useState<string | null>(null)
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
const { projectName } = useParams<{ projectName: string }>(); const { projectName } = useParams<{ projectName: string }>()
const { data: projectInfo = null } = useProjectInfo(projectName); const { data: projectInfo = null } = useProjectInfo(projectName)
const channelMatch = useMatch("/:projectName/channel/:roomId"); const channelMatch = useMatch("/:projectName/channel/:roomId")
const chatMatch = useMatch("/:projectName/chat/*"); const chatMatch = useMatch("/:projectName/chat/*")
const roomId = channelMatch?.params.roomId ?? null; const roomId = channelMatch?.params.roomId ?? null
const isMobile = useIsMobile(); const isMobile = useIsMobile()
const isTablet = useIsTablet(); const isTablet = useIsTablet()
const isProjectMember = !!projectInfo?.role; const isProjectMember = !!projectInfo?.role
const isProjectPreview = !!projectInfo && !projectInfo.role; const isProjectPreview = !!projectInfo && !projectInfo.role
const canShowMembers = !isMobile && !isTablet && isProjectMember; const canShowMembers = !isMobile && !isTablet && isProjectMember
const mainShouldOwnScroll = !channelMatch && !chatMatch; const mainShouldOwnScroll = !channelMatch && !chatMatch
return ( return (
<ProjectContext.Provider <ProjectContext.Provider
@ -99,8 +104,19 @@ export function ProjectLayout() {
isProjectPreview, isProjectPreview,
}} }}
> >
<RoomProvider roomId={isProjectMember ? roomId : null} projectName={projectName}> <RoomProvider
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--surface-ground)" }}> roomId={isProjectMember ? roomId : null}
projectName={projectName}
>
<div className="relative flex h-screen overflow-hidden bg-[var(--surface-ground)]">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-80"
style={{
backgroundImage:
"radial-gradient(circle at top left, color-mix(in oklch, var(--accent) 9%, transparent) 0, transparent 28%), radial-gradient(circle at bottom right, color-mix(in oklch, var(--accent) 4%, transparent) 0, transparent 24%)",
}}
/>
{!isMobile && <ServerIconRail />} {!isMobile && <ServerIconRail />}
<div className="relative flex shrink-0"> <div className="relative flex shrink-0">
@ -109,41 +125,34 @@ export function ProjectLayout() {
width: isSidebarCollapsed ? 0 : 220, width: isSidebarCollapsed ? 0 : 220,
transition: "width 0.2s ease", transition: "width 0.2s ease",
overflow: "hidden", overflow: "hidden",
backgroundColor: "var(--surface-sidebar)", backgroundColor:
borderRight: isSidebarCollapsed ? "none" : "0.5px solid var(--border-subtle)", "color-mix(in oklch, var(--surface-sidebar) 92%, transparent)",
borderRight: isSidebarCollapsed
? "none"
: "0.5px solid var(--border-subtle)",
}} }}
> >
{!isSidebarCollapsed && ( {!isSidebarCollapsed && (
<ChannelSidebar onCollapse={() => setIsSidebarCollapsed(true)} /> <ChannelSidebar
onCollapse={() => setIsSidebarCollapsed(true)}
/>
)} )}
</div> </div>
{/* Expand button - visible when collapsed */} {/* Expand button - visible when collapsed */}
{isSidebarCollapsed && ( {isSidebarCollapsed && (
<button <Button
onClick={() => setIsSidebarCollapsed(false)} onClick={() => setIsSidebarCollapsed(false)}
className="absolute flex items-center justify-center w-5 h-10 cursor-pointer transition-colors" variant="ghost"
style={{ size="icon-sm"
color: "var(--text-muted)", className="absolute top-1/2 right-[-12px] z-10 rounded-l-none rounded-r-xl border border-l-0 border-[var(--border-subtle)] bg-[var(--surface-sidebar)] text-[var(--text-muted)] shadow-sm"
backgroundColor: "var(--surface-sidebar)",
right: -10,
top: "50%",
transform: "translateY(-50%)",
borderRadius: "0 4px 4px 0",
border: "0.5px solid var(--border-subtle)",
borderLeft: "none",
zIndex: 10,
}}
title={t("project.layout.expand_sidebar")} title={t("project.layout.expand_sidebar")}
> >
<PanelLeftOpen className="w-3.5 h-3.5" /> <PanelLeftOpen className="size-3.5" />
</button> </Button>
)} )}
</div> </div>
<div <div className="relative z-10 flex min-w-0 flex-1 flex-col overflow-hidden bg-[var(--surface-ground)]">
className="flex-1 flex flex-col overflow-hidden min-w-0"
style={{ backgroundColor: "var(--surface-ground)" }}
>
<Header /> <Header />
{isProjectPreview && ( {isProjectPreview && (
<ProjectJoinBanner <ProjectJoinBanner
@ -156,7 +165,11 @@ export function ProjectLayout() {
/> />
)} )}
<main <main
className={mainShouldOwnScroll ? "flex-1 overflow-y-auto" : "flex-1 overflow-hidden min-h-0"} className={
mainShouldOwnScroll
? "flex-1 overflow-y-auto"
: "min-h-0 flex-1 overflow-hidden"
}
style={{ backgroundColor: "var(--surface-ground)" }} style={{ backgroundColor: "var(--surface-ground)" }}
> >
<Outlet /> <Outlet />
@ -173,5 +186,5 @@ export function ProjectLayout() {
</div> </div>
</RoomProvider> </RoomProvider>
</ProjectContext.Provider> </ProjectContext.Provider>
); )
} }