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:
parent
86ab2d2f85
commit
16865117de
@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user