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 { 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";
import {
createContext,
useContext,
useState,
useMemo,
useCallback,
type ReactNode,
} from "react"
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 {
showMembers: boolean;
setShowMembers: (v: boolean) => void;
showMembers: boolean
setShowMembers: (v: boolean) => void
}
const ChannelContext = createContext<ChannelContextType>({
showMembers: false,
setShowMembers: () => {},
});
})
// 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 }) {
const [showMembers, setShowMembers] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const isMobile = useIsMobile();
const isTablet = useIsTablet();
const canShowMembers = !isMobile && !isTablet;
const [showMembers, setShowMembers] = useState(false)
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
const isMobile = useIsMobile()
const isTablet = useIsTablet()
const canShowMembers = !isMobile && !isTablet
const contextValue = useMemo(
() => ({ showMembers, setShowMembers }),
[showMembers],
);
[showMembers]
)
const toggleSidebar = useCallback(
() => setIsSidebarCollapsed((v) => !v),
[],
);
const toggleSidebar = useCallback(() => setIsSidebarCollapsed((v) => !v), [])
const roomMatch = useMatch("/channel/:roomId");
const mainShouldOwnScroll = !roomMatch;
const roomMatch = useMatch("/channel/:roomId")
const mainShouldOwnScroll = !roomMatch
return (
<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 />}
<div className="relative flex shrink-0">
@ -53,14 +68,16 @@ export function ChannelLayout({ children }: { children?: ReactNode }) {
transition: "width 0.2s ease",
overflow: "hidden",
backgroundColor: "var(--surface-sidebar)",
borderRight: isSidebarCollapsed ? "none" : "0.5px solid var(--border-subtle)",
borderRight: isSidebarCollapsed
? "none"
: "0.5px solid var(--border-subtle)",
}}
>
{!isSidebarCollapsed && <ChannelSidebar />}
</div>
<button
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={{
color: "var(--text-muted)",
backgroundColor: "var(--surface-sidebar)",
@ -75,15 +92,22 @@ export function ChannelLayout({ children }: { children?: ReactNode }) {
title={isSidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
<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>
</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 />
<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)" }}
>
{children ?? <Outlet />}
@ -91,11 +115,13 @@ export function ChannelLayout({ children }: { children?: ReactNode }) {
</div>
{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 />}
</div>
)}
</div>
</ChannelContext.Provider>
);
)
}

View File

@ -1,13 +1,13 @@
import { useNavigate } from "react-router-dom";
import { Plus, Trash2, ArrowLeft } from "lucide-react";
import { BOARD_PAGE } from "@/css/app/board-styles";
import { useNavigate } from "react-router-dom"
import { Plus, Trash2, ArrowLeft } from "lucide-react"
import { Button } from "@/components/ui/button"
interface BoardHeaderProps {
projectName: string;
boardName: string;
boardDescription?: string;
onDeleteBoard: () => void;
onAddColumn: () => void;
projectName: string
boardName: string
boardDescription?: string
onDeleteBoard: () => void
onAddColumn: () => void
}
export function BoardHeader({
@ -17,40 +17,60 @@ export function BoardHeader({
onDeleteBoard,
onAddColumn,
}: BoardHeaderProps) {
const navigate = useNavigate();
const navigate = useNavigate()
return (
<div className={BOARD_PAGE.header}>
<div className={BOARD_PAGE.titleGroup}>
<div className="rounded-2xl border border-border/70 bg-card/80 p-5 shadow-sm backdrop-blur">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-3">
<button
<Button
type="button"
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
onClick={() => navigate(`/${projectName}/board`)}
className={BOARD_PAGE.iconBtn}
title="Back to boards"
>
<ArrowLeft className="w-5 h-5" />
</button>
<h1 className={BOARD_PAGE.title}>{boardName}</h1>
<ArrowLeft />
</Button>
</div>
<p className={BOARD_PAGE.description} style={{ marginLeft: 36 }}>
<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
<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"
variant="outline"
size="sm"
className="rounded-full"
>
<Plus className="w-4 h-4" />
Add Column
</button>
<button
className={BOARD_PAGE.iconBtn}
<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"
title="Delete board"
>
<Trash2 className="w-4 h-4" />
</button>
<Trash2 />
</Button>
</div>
</div>
);
</div>
)
}

View File

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

View File

@ -1,25 +1,25 @@
import { Link, Outlet, useMatch, useParams } from "react-router-dom";
import { createContext, useContext, useState } from "react";
import { Lock, PanelLeftOpen } 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 { RoomProvider } from "@/contexts/room";
import { useIsMobile, useIsTablet } from "@/hooks/use-mobile";
import { useProjectInfo } from "@/hooks/useProjectInfo";
import type { ProjectInfoRelational } from "@/client/model";
import { Button } from "@/components/ui/button";
import { t } from "@/i18n/T";
import { Link, Outlet, useMatch, useParams } from "react-router-dom"
import { createContext, useContext, useState } from "react"
import { Lock, PanelLeftOpen } 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 { RoomProvider } from "@/contexts/room"
import { useIsMobile, useIsTablet } from "@/hooks/use-mobile"
import { useProjectInfo } from "@/hooks/useProjectInfo"
import type { ProjectInfoRelational } from "@/client/model"
import { Button } from "@/components/ui/button"
import { t } from "@/i18n/T"
interface ProjectContextType {
showMembers: boolean;
setShowMembers: (v: boolean) => void;
currentRoomName: string | null;
setCurrentRoomName: (name: string | null) => void;
projectInfo: ProjectInfoRelational | null;
isProjectMember: boolean;
isProjectPreview: boolean;
showMembers: boolean
setShowMembers: (v: boolean) => void
currentRoomName: string | null
setCurrentRoomName: (name: string | null) => void
projectInfo: ProjectInfoRelational | null
isProjectMember: boolean
isProjectPreview: boolean
}
const ProjectContext = createContext<ProjectContextType>({
@ -30,62 +30,67 @@ const ProjectContext = createContext<ProjectContextType>({
projectInfo: null,
isProjectMember: false,
isProjectPreview: false,
});
})
// eslint-disable-next-line react-refresh/only-export-components
export const useProjectLayout = () => useContext(ProjectContext);
export const useProjectLayout = () => useContext(ProjectContext)
export function ProjectJoinBanner({
compact = false,
message,
}: {
compact?: boolean;
message?: string;
compact?: boolean
message?: string
}) {
const { projectInfo } = useProjectLayout();
const projectName = projectInfo?.name;
const defaultMessage = t("project.layout.join_banner.join_to_participate");
const { projectInfo } = useProjectLayout()
const projectName = projectInfo?.name
const defaultMessage = t("project.layout.join_banner.join_to_participate")
return (
<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`}
style={{ borderColor: "var(--border-subtle)" }}
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`}
>
<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">
<Lock className="size-4" />
</div>
<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 text-muted-foreground">{message ?? defaultMessage}</p>
<p className="text-sm font-medium text-foreground">
{t("project.layout.join_banner.preview_mode")}
</p>
<p className="text-sm text-muted-foreground">
{message ?? defaultMessage}
</p>
</div>
</div>
{projectName && (
<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>
)}
</div>
);
)
}
export function ProjectLayout() {
const [showMembers, setShowMembers] = useState(false);
const [currentRoomName, setCurrentRoomName] = useState<string | null>(null);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const { projectName } = useParams<{ projectName: string }>();
const { data: projectInfo = null } = useProjectInfo(projectName);
const channelMatch = useMatch("/:projectName/channel/:roomId");
const chatMatch = useMatch("/:projectName/chat/*");
const roomId = channelMatch?.params.roomId ?? null;
const isMobile = useIsMobile();
const isTablet = useIsTablet();
const [showMembers, setShowMembers] = useState(false)
const [currentRoomName, setCurrentRoomName] = useState<string | null>(null)
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
const { projectName } = useParams<{ projectName: string }>()
const { data: projectInfo = null } = useProjectInfo(projectName)
const channelMatch = useMatch("/:projectName/channel/:roomId")
const chatMatch = useMatch("/:projectName/chat/*")
const roomId = channelMatch?.params.roomId ?? null
const isMobile = useIsMobile()
const isTablet = useIsTablet()
const isProjectMember = !!projectInfo?.role;
const isProjectPreview = !!projectInfo && !projectInfo.role;
const canShowMembers = !isMobile && !isTablet && isProjectMember;
const isProjectMember = !!projectInfo?.role
const isProjectPreview = !!projectInfo && !projectInfo.role
const canShowMembers = !isMobile && !isTablet && isProjectMember
const mainShouldOwnScroll = !channelMatch && !chatMatch;
const mainShouldOwnScroll = !channelMatch && !chatMatch
return (
<ProjectContext.Provider
@ -99,8 +104,19 @@ export function ProjectLayout() {
isProjectPreview,
}}
>
<RoomProvider roomId={isProjectMember ? roomId : null} projectName={projectName}>
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--surface-ground)" }}>
<RoomProvider
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 />}
<div className="relative flex shrink-0">
@ -109,41 +125,34 @@ export function ProjectLayout() {
width: isSidebarCollapsed ? 0 : 220,
transition: "width 0.2s ease",
overflow: "hidden",
backgroundColor: "var(--surface-sidebar)",
borderRight: isSidebarCollapsed ? "none" : "0.5px solid var(--border-subtle)",
backgroundColor:
"color-mix(in oklch, var(--surface-sidebar) 92%, transparent)",
borderRight: isSidebarCollapsed
? "none"
: "0.5px solid var(--border-subtle)",
}}
>
{!isSidebarCollapsed && (
<ChannelSidebar onCollapse={() => setIsSidebarCollapsed(true)} />
<ChannelSidebar
onCollapse={() => setIsSidebarCollapsed(true)}
/>
)}
</div>
{/* Expand button - visible when collapsed */}
{isSidebarCollapsed && (
<button
<Button
onClick={() => setIsSidebarCollapsed(false)}
className="absolute flex items-center justify-center w-5 h-10 cursor-pointer transition-colors"
style={{
color: "var(--text-muted)",
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,
}}
variant="ghost"
size="icon-sm"
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"
title={t("project.layout.expand_sidebar")}
>
<PanelLeftOpen className="w-3.5 h-3.5" />
</button>
<PanelLeftOpen className="size-3.5" />
</Button>
)}
</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 bg-[var(--surface-ground)]">
<Header />
{isProjectPreview && (
<ProjectJoinBanner
@ -156,7 +165,11 @@ export function ProjectLayout() {
/>
)}
<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)" }}
>
<Outlet />
@ -173,5 +186,5 @@ export function ProjectLayout() {
</div>
</RoomProvider>
</ProjectContext.Provider>
);
)
}