refactor(ui): update shared layout and UI components for new theme system

Update Header, ProjectMessageFavoritesDrawer, RepoHeader, sheet,
EmptyState, ErrorState, LoadingState, PageHeader. Add ConfirmDialog
component.
This commit is contained in:
ZhenYi 2026-05-18 20:45:00 +08:00
parent 16865117de
commit c39ee1ce2a
9 changed files with 552 additions and 381 deletions

View File

@ -1,37 +1,45 @@
import { memo, useCallback, useState } from "react"; import { memo, useCallback, useState } from "react"
import { Link, useLocation, useParams } from "react-router-dom"; import { Link, useLocation, useParams } from "react-router-dom"
import { Bookmark, ChevronRight, ChevronDown, Home, Settings } from "lucide-react"; import {
import { useProjectLayout } from "@/app/project/layout"; Bookmark,
import { useProjectsQuery } from "@/hooks/useProjectsQuery"; ChevronRight,
import { useOptionalRoom } from "@/contexts/room"; ChevronDown,
import { RoomSettingsModal } from "@/app/project/channel/RoomSettingsModal"; Home,
import { ProjectMessageFavoritesDrawer } from "@/components/layout/ProjectMessageFavoritesDrawer"; Settings,
import { isProjectAdminRole } from "@/lib/project-permissions"; } from "lucide-react"
import { modKey, altKey } from "@/lib/utils"; import { useProjectLayout } from "@/app/project/layout"
import { openGlobalSearch } from "@/components/search/global-search-events"; import { useProjectsQuery } from "@/hooks/useProjectsQuery"
import { useOptionalRoom } from "@/contexts/room"
import { RoomSettingsModal } from "@/app/project/channel/RoomSettingsModal"
import { ProjectMessageFavoritesDrawer } from "@/components/layout/ProjectMessageFavoritesDrawer"
import { isProjectAdminRole } from "@/lib/project-permissions"
import { modKey, altKey } from "@/lib/utils"
import { openGlobalSearch } from "@/components/search/global-search-events"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
function truncateUuid(s: string): string { function truncateUuid(s: string): string {
return s.slice(0, 8); return s.slice(0, 8)
} }
interface BreadcrumbSegment { interface BreadcrumbSegment {
label: string; label: string
path: string; path: string
isLast: boolean; isLast: boolean
fullUuid?: string; fullUuid?: string
} }
interface BreadcrumbSibling { interface BreadcrumbSibling {
label: string; label: string
path: string; path: string
} }
const ME_NAV_SIBLINGS: BreadcrumbSibling[] = [ const ME_NAV_SIBLINGS: BreadcrumbSibling[] = [
@ -44,20 +52,23 @@ const ME_NAV_SIBLINGS: BreadcrumbSibling[] = [
{ label: "Following", path: "/me/following" }, { label: "Following", path: "/me/following" },
{ label: "Followers", path: "/me/followers" }, { label: "Followers", path: "/me/followers" },
{ label: "Invitations", path: "/me/invitations" }, { label: "Invitations", path: "/me/invitations" },
]; ]
function getProjectNavSiblings(projectName: string, showSettings: boolean): BreadcrumbSibling[] { function getProjectNavSiblings(
projectName: string,
showSettings: boolean
): BreadcrumbSibling[] {
const items = [ const items = [
{ label: "Repository", path: `/${projectName}/repos` }, { label: "Repository", path: `/${projectName}/repos` },
{ label: "Issues", path: `/${projectName}/issues` }, { label: "Issues", path: `/${projectName}/issues` },
{ label: "Skills", path: `/${projectName}/skills` }, { label: "Skills", path: `/${projectName}/skills` },
{ label: "Board", path: `/${projectName}/board` }, { label: "Board", path: `/${projectName}/board` },
{ label: "Chat", path: `/${projectName}/chat` }, { label: "Chat", path: `/${projectName}/chat` },
]; ]
if (showSettings) { if (showSettings) {
items.push({ label: "Settings", path: `/${projectName}/settings` }); items.push({ label: "Settings", path: `/${projectName}/settings` })
} }
return items; return items
} }
function getRepoTabSiblings(repoBasePath: string): BreadcrumbSibling[] { function getRepoTabSiblings(repoBasePath: string): BreadcrumbSibling[] {
@ -68,92 +79,92 @@ function getRepoTabSiblings(repoBasePath: string): BreadcrumbSibling[] {
{ label: "Branches", path: `${repoBasePath}/branches` }, { label: "Branches", path: `${repoBasePath}/branches` },
{ label: "Tags", path: `${repoBasePath}/tags` }, { label: "Tags", path: `${repoBasePath}/tags` },
{ label: "Settings", path: `${repoBasePath}/settings` }, { label: "Settings", path: `${repoBasePath}/settings` },
]; ]
} }
function getSegmentSiblings( function getSegmentSiblings(
segment: BreadcrumbSegment, segment: BreadcrumbSegment,
projects: Array<{ name: string; display_name: string }>, projects: Array<{ name: string; display_name: string }>,
canManageProject = false, canManageProject = false
): BreadcrumbSibling[] | null { ): BreadcrumbSibling[] | null {
if (segment.isLast) return null; if (segment.isLast) return null
const parts = segment.path.split("/").filter(Boolean); const parts = segment.path.split("/").filter(Boolean)
const depth = parts.length; const depth = parts.length
if (depth === 1) { if (depth === 1) {
if (parts[0] === "me") return ME_NAV_SIBLINGS; if (parts[0] === "me") return ME_NAV_SIBLINGS
if (projects.length > 1) { if (projects.length > 1) {
return projects.map((p) => ({ return projects.map((p) => ({
label: p.display_name || p.name, label: p.display_name || p.name,
path: `/${p.name}`, path: `/${p.name}`,
})); }))
} }
return null; return null
} }
if (depth === 2) { if (depth === 2) {
if (parts[0] === "me") return ME_NAV_SIBLINGS; if (parts[0] === "me") return ME_NAV_SIBLINGS
return getProjectNavSiblings(parts[0], canManageProject); return getProjectNavSiblings(parts[0], canManageProject)
} }
if (depth >= 3 && parts[1] === "repo") { if (depth >= 3 && parts[1] === "repo") {
const repoBase = `/${parts[0]}/repo/${parts[2]}`; const repoBase = `/${parts[0]}/repo/${parts[2]}`
return getRepoTabSiblings(repoBase); return getRepoTabSiblings(repoBase)
} }
return null; return null
} }
function useBreadcrumbs() { function useBreadcrumbs() {
const location = useLocation(); const location = useLocation()
const params = useParams<{ const params = useParams<{
projectName?: string; projectName?: string
repoName?: string; repoName?: string
issueNumber?: string; issueNumber?: string
skillSlug?: string; skillSlug?: string
roomId?: string; roomId?: string
}>(); }>()
const roomContext = useOptionalRoom(); const roomContext = useOptionalRoom()
const currentRoom = roomContext?.currentRoom; const currentRoom = roomContext?.currentRoom
const { data: projects = [] } = useProjectsQuery(); const { data: projects = [] } = useProjectsQuery()
const activeProject = projects.find((p) => p.name === params.projectName); const activeProject = projects.find((p) => p.name === params.projectName)
const segments: BreadcrumbSegment[] = []; const segments: BreadcrumbSegment[] = []
const pathParts = location.pathname.split("/").filter(Boolean); const pathParts = location.pathname.split("/").filter(Boolean)
for (let i = 0; i < pathParts.length; i++) { for (let i = 0; i < pathParts.length; i++) {
const part = pathParts[i]; const part = pathParts[i]
const path = "/" + pathParts.slice(0, i + 1).join("/"); const path = "/" + pathParts.slice(0, i + 1).join("/")
const isLast = i === pathParts.length - 1; const isLast = i === pathParts.length - 1
let label = part; let label = part
let fullUuid: string | undefined; let fullUuid: string | undefined
if (part === params.roomId && currentRoom) { if (part === params.roomId && currentRoom) {
label = `#${currentRoom.room_name}`; label = `#${currentRoom.room_name}`
} else if (UUID_RE.test(part)) { } else if (UUID_RE.test(part)) {
fullUuid = part; fullUuid = part
label = truncateUuid(part); label = truncateUuid(part)
} else if (part === params.projectName && activeProject) { } else if (part === params.projectName && activeProject) {
label = activeProject.display_name; label = activeProject.display_name
} else if (part === params.repoName) { } else if (part === params.repoName) {
label = part; label = part
} else if (part === params.issueNumber) { } else if (part === params.issueNumber) {
label = `#${part}`; label = `#${part}`
} else if (part === params.skillSlug) { } else if (part === params.skillSlug) {
label = part; label = part
} else if (part === "channel" && params.roomId && currentRoom) { } else if (part === "channel" && params.roomId && currentRoom) {
label = currentRoom.room_name; label = currentRoom.room_name
} else { } else {
label = part.charAt(0).toUpperCase() + part.slice(1); label = part.charAt(0).toUpperCase() + part.slice(1)
} }
segments.push({ label, path, isLast, fullUuid }); segments.push({ label, path, isLast, fullUuid })
} }
return { segments, projects }; return { segments, projects }
} }
const TOOLBAR_ICONS = [ const TOOLBAR_ICONS = [
@ -173,52 +184,63 @@ const TOOLBAR_ICONS = [
label: "Help", label: "Help",
path: "M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z", path: "M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
}, },
]; ]
export const Header = memo(function Header() { export const Header = memo(function Header() {
const location = useLocation(); const location = useLocation()
const params = useParams<{ projectName?: string }>(); const params = useParams<{ projectName?: string }>()
const { segments, projects } = useBreadcrumbs(); const { segments, projects } = useBreadcrumbs()
const { isProjectMember, projectInfo, showMembers, setShowMembers } = useProjectLayout(); const { isProjectMember, projectInfo, showMembers, setShowMembers } =
const [showSettings, setShowSettings] = useState(false); useProjectLayout()
const [showFavorites, setShowFavorites] = useState(false); const [showSettings, setShowSettings] = useState(false)
const roomContext = useOptionalRoom(); const [showFavorites, setShowFavorites] = useState(false)
const canManageProject = isProjectAdminRole(projectInfo?.role); const roomContext = useOptionalRoom()
const canManageProject = isProjectAdminRole(projectInfo?.role)
const handleCopy = useCallback((e: React.MouseEvent, text: string) => { const handleCopy = useCallback((e: React.MouseEvent, text: string) => {
e.preventDefault(); e.preventDefault()
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text)
}, []); }, [])
return ( return (
<> <>
<header <header
className="h-12 flex items-center justify-between px-4 shrink-0" className="sticky top-0 z-20 flex h-12 shrink-0 items-center justify-between border-b border-border/60 bg-background/85 px-4 backdrop-blur-xl supports-[backdrop-filter]:bg-background/70"
style={{ style={{
borderBottom: "1px solid var(--border-subtle)", boxShadow:
backgroundColor: "var(--surface-ground)", "0 1px 0 color-mix(in oklch, var(--border) 60%, transparent)",
}} }}
> >
<div className="flex items-center gap-1 min-w-0 text-sm"> <div className="flex min-w-0 items-center gap-1 text-sm">
<Link <Link
to="/me" to="/me"
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors shrink-0" className="inline-flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
> >
<Home className="w-3.5 h-3.5" /> <Home className="size-4" />
</Link> </Link>
{segments.map((segment, idx) => { {segments.map((segment, idx) => {
const siblings = getSegmentSiblings(segment, projects, canManageProject); const siblings = getSegmentSiblings(
segment,
projects,
canManageProject
)
return ( return (
<span key={segment.path + idx} className="flex items-center gap-1 min-w-0"> <span
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" /> key={segment.path + idx}
className="flex min-w-0 items-center gap-1"
>
<ChevronRight className="size-4 shrink-0 text-muted-foreground/50" />
{segment.isLast ? ( {segment.isLast ? (
<span <span
className={`font-medium text-foreground truncate${segment.fullUuid ? " font-mono cursor-pointer hover:text-accent transition-colors" : ""}`} className={`truncate font-medium text-foreground${segment.fullUuid ? "cursor-pointer font-mono transition-colors hover:text-accent" : ""}`}
title={segment.fullUuid} title={segment.fullUuid}
{...(segment.fullUuid {...(segment.fullUuid
? { onClick: (e: React.MouseEvent) => handleCopy(e, segment.fullUuid!) } ? {
onClick: (e: React.MouseEvent) =>
handleCopy(e, segment.fullUuid!),
}
: {})} : {})}
> >
{segment.label} {segment.label}
@ -226,14 +248,17 @@ export const Header = memo(function Header() {
) : siblings && siblings.length > 0 ? ( ) : siblings && siblings.length > 0 ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button className="flex min-w-0 cursor-pointer items-center gap-0.5 rounded-full px-2 py-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
className="flex items-center gap-0.5 text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded px-0.5 -mx-0.5" <span className="max-w-[180px] truncate">
> {segment.label}
<span className="truncate max-w-[180px]">{segment.label}</span> </span>
<ChevronDown className="w-3 h-3 shrink-0" /> <ChevronDown className="size-3.5 shrink-0" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-36 max-h-64 overflow-y-auto"> <DropdownMenuContent
align="start"
className="max-h-64 min-w-36 overflow-y-auto"
>
{siblings.map((s) => ( {siblings.map((s) => (
<DropdownMenuItem key={s.path} asChild> <DropdownMenuItem key={s.path} asChild>
<Link to={s.path} className="truncate"> <Link to={s.path} className="truncate">
@ -246,7 +271,7 @@ export const Header = memo(function Header() {
) : segment.fullUuid ? ( ) : segment.fullUuid ? (
<Link <Link
to={segment.path} to={segment.path}
className="text-muted-foreground hover:text-foreground font-mono cursor-pointer transition-colors" className="cursor-pointer font-mono text-muted-foreground transition-colors hover:text-foreground"
title={segment.fullUuid} title={segment.fullUuid}
onClick={(e) => handleCopy(e, segment.fullUuid!)} onClick={(e) => handleCopy(e, segment.fullUuid!)}
> >
@ -255,48 +280,42 @@ export const Header = memo(function Header() {
) : ( ) : (
<Link <Link
to={segment.path} to={segment.path}
className="text-muted-foreground hover:text-foreground transition-colors" className="text-muted-foreground transition-colors hover:text-foreground"
> >
{segment.label} {segment.label}
</Link> </Link>
)} )}
</span> </span>
); )
})} })}
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex shrink-0 items-center gap-1">
{location.pathname.startsWith("/me") ? null : ( {location.pathname.startsWith("/me") ? null : (
<> <>
{isProjectMember && roomContext?.currentRoom && location.pathname.includes("/channel/") && ( {isProjectMember &&
<button roomContext?.currentRoom &&
location.pathname.includes("/channel/") && (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
onClick={() => setShowSettings(true)} onClick={() => setShowSettings(true)}
className="w-8 h-8 flex items-center justify-center rounded-[4px] transition-colors hover:bg-hover-bg"
style={{ color: "var(--text-secondary)" }}
title="Room Settings" title="Room Settings"
> >
<Settings className="w-[18px] h-[18px]" /> <Settings />
</button> </Button>
)} )}
{isProjectMember && ( {isProjectMember && (
<button <Button
type="button"
variant="ghost"
size="icon-sm"
className={`rounded-full text-muted-foreground ${showMembers ? "bg-muted text-foreground" : ""}`}
onClick={() => setShowMembers(!showMembers)} onClick={() => setShowMembers(!showMembers)}
className="w-8 h-8 flex items-center justify-center rounded-[4px] transition-colors"
style={
showMembers
? {
color: "var(--text-primary)",
backgroundColor: "var(--hover-bg-strong)",
}
: { color: "var(--text-secondary)" }
}
>
<svg
className="w-[18px] h-[18px]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
> >
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@ -304,11 +323,14 @@ export const Header = memo(function Header() {
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
/> />
</svg> </svg>
</button> </Button>
)} )}
{TOOLBAR_ICONS.map((icon, i) => ( {TOOLBAR_ICONS.map((icon, i) => (
<button <Button
type="button"
variant="ghost"
size="icon-sm"
key={i} key={i}
onClick={ onClick={
icon.label === "Search" icon.label === "Search"
@ -317,8 +339,7 @@ export const Header = memo(function Header() {
? () => setShowFavorites(true) ? () => setShowFavorites(true)
: undefined : undefined
} }
className="w-8 h-8 flex items-center justify-center rounded-[4px] transition-colors" className="rounded-full text-muted-foreground"
style={{ color: "var(--text-secondary)" }}
title={ title={
icon.label === "Search" icon.label === "Search"
? `Search (${modKey()}${altKey()}F)` ? `Search (${modKey()}${altKey()}F)`
@ -328,14 +349,9 @@ export const Header = memo(function Header() {
} }
> >
{icon.label === "Pinned Messages" ? ( {icon.label === "Pinned Messages" ? (
<Bookmark className="w-[18px] h-[18px]" /> <Bookmark />
) : ( ) : (
<svg <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="w-[18px] h-[18px]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@ -344,7 +360,7 @@ export const Header = memo(function Header() {
/> />
</svg> </svg>
)} )}
</button> </Button>
))} ))}
</> </>
)} )}
@ -357,5 +373,5 @@ export const Header = memo(function Header() {
projectName={params.projectName} projectName={params.projectName}
/> />
</> </>
); )
}); })

View File

@ -1,77 +1,105 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Bookmark, ExternalLink, Loader2, Trash2 } from "lucide-react"; import { Bookmark, ExternalLink, Loader2, Trash2 } from "lucide-react"
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom"
import { projectMessageFavoriteRemove, projectMessageFavorites } from "@/client/api"; import {
import type { ProjectMessageFavoriteItem } from "@/client/model"; projectMessageFavoriteRemove,
projectMessageFavorites,
} from "@/client/api"
import type { ProjectMessageFavoriteItem } from "@/client/model"
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetDescription, SheetDescription,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet"
import { extractIrNodes } from "@/lib/ir/parser"; import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import { extractIrNodes } from "@/lib/ir/parser"
interface Props { interface Props {
open: boolean; open: boolean
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void
projectName?: string; projectName?: string
} }
function plainMessageText(item: ProjectMessageFavoriteItem) { function plainMessageText(item: ProjectMessageFavoriteItem) {
const nodes = extractIrNodes(item.content); const nodes = extractIrNodes(item.content)
const text = nodes const text = nodes
.map((node) => { .map((node) => {
if ("text" in node && typeof node.text === "string") return node.text; if ("text" in node && typeof node.text === "string") return node.text
if ("content" in node && typeof node.content === "string") return node.content; if ("content" in node && typeof node.content === "string")
return ""; return node.content
return ""
}) })
.join(" ") .join(" ")
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim(); .trim()
return text || item.content.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); return (
text ||
item.content
.replace(/<[^>]*>/g, " ")
.replace(/\s+/g, " ")
.trim()
)
} }
function formatDate(value: string) { function formatDate(value: string) {
const date = new Date(value); const date = new Date(value)
if (Number.isNaN(date.getTime())) return ""; if (Number.isNaN(date.getTime())) return ""
return date.toLocaleString([], { return date.toLocaleString([], {
month: "short", month: "short",
day: "numeric", day: "numeric",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}); })
} }
export function ProjectMessageFavoritesDrawer({ open, onOpenChange, projectName }: Props) { export function ProjectMessageFavoritesDrawer({
const navigate = useNavigate(); open,
const queryClient = useQueryClient(); onOpenChange,
const queryKey = ["project-message-favorites", projectName]; projectName,
}: Props) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const queryKey = ["project-message-favorites", projectName]
const favoritesQuery = useQuery({ const favoritesQuery = useQuery({
queryKey, queryKey,
queryFn: async () => { queryFn: async () => {
const res = await projectMessageFavorites(projectName!, { page: 1, per_page: 50 }); const res = await projectMessageFavorites(projectName!, {
return res.data.data; page: 1,
per_page: 50,
})
return res.data.data
}, },
enabled: open && !!projectName, enabled: open && !!projectName,
staleTime: 15_000, staleTime: 15_000,
}); })
const removeMutation = useMutation({ const removeMutation = useMutation({
mutationFn: (messageId: string) => projectMessageFavoriteRemove(projectName!, messageId), mutationFn: (messageId: string) =>
projectMessageFavoriteRemove(projectName!, messageId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey }); queryClient.invalidateQueries({ queryKey })
}, },
}); })
const goToMessage = (item: ProjectMessageFavoriteItem) => { const goToMessage = (item: ProjectMessageFavoriteItem) => {
if (!projectName) return; if (!projectName) return
onOpenChange(false); onOpenChange(false)
navigate(`/${projectName}/channel/${item.room_id}?message=${item.message_id}`); navigate(
}; `/${projectName}/channel/${item.room_id}?message=${item.message_id}`
)
}
const list = favoritesQuery.data?.list ?? []; const list = favoritesQuery.data?.list ?? []
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
@ -90,26 +118,38 @@ export function ProjectMessageFavoritesDrawer({ open, onOpenChange, projectName
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
</div> </div>
) : list.length === 0 ? ( ) : list.length === 0 ? (
<div className="flex h-32 flex-col items-center justify-center gap-2 text-sm text-muted-foreground"> <Empty className="h-44 border-0 bg-transparent p-0">
<Bookmark className="size-5" /> <EmptyContent className="max-w-xs">
<span>No favorite messages yet.</span> <EmptyMedia variant="icon">
</div> <Bookmark />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>No favorite messages yet.</EmptyTitle>
<EmptyDescription>
Pin a message from a room and it will appear here for quick
access.
</EmptyDescription>
</EmptyHeader>
</EmptyContent>
</Empty>
) : ( ) : (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{list.map((item) => { {list.map((item) => {
const preview = plainMessageText(item); const preview = plainMessageText(item)
return ( return (
<div <div
key={item.uid} key={item.uid}
className="rounded-md border border-border/60 bg-popover p-3" className="group rounded-xl border border-border/70 bg-card/80 p-3 shadow-sm transition-colors hover:border-border hover:bg-card"
> >
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground"> <div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
<span className="truncate font-medium text-foreground">#{item.room_name}</span> <span className="truncate font-medium text-foreground">
#{item.room_name}
</span>
<span>{formatDate(item.send_at)}</span> <span>{formatDate(item.send_at)}</span>
</div> </div>
<button <button
type="button" type="button"
className="block w-full text-left text-sm leading-relaxed text-foreground" className="block w-full text-left text-sm leading-relaxed text-foreground transition-colors group-hover:text-foreground/90"
onClick={() => goToMessage(item)} onClick={() => goToMessage(item)}
> >
<span className="line-clamp-3">{preview}</span> <span className="line-clamp-3">{preview}</span>
@ -117,7 +157,7 @@ export function ProjectMessageFavoritesDrawer({ open, onOpenChange, projectName
<div className="mt-3 flex items-center justify-end gap-1"> <div className="mt-3 flex items-center justify-end gap-1">
<button <button
type="button" type="button"
className="inline-flex size-7 items-center justify-center rounded hover:bg-muted" className="inline-flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="Open message" title="Open message"
onClick={() => goToMessage(item)} onClick={() => goToMessage(item)}
> >
@ -125,7 +165,7 @@ export function ProjectMessageFavoritesDrawer({ open, onOpenChange, projectName
</button> </button>
<button <button
type="button" type="button"
className="inline-flex size-7 items-center justify-center rounded text-destructive hover:bg-muted" className="inline-flex size-7 items-center justify-center rounded-full text-destructive transition-colors hover:bg-destructive/10"
title="Remove favorite" title="Remove favorite"
onClick={() => removeMutation.mutate(item.message_id)} onClick={() => removeMutation.mutate(item.message_id)}
> >
@ -133,12 +173,12 @@ export function ProjectMessageFavoritesDrawer({ open, onOpenChange, projectName
</button> </button>
</div> </div>
</div> </div>
); )
})} })}
</div> </div>
)} )}
</div> </div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
); )
} }

View File

@ -1,77 +1,116 @@
import { Lock, Globe, GitBranch, GitCommit, GitPullRequest, Tag } from "lucide-react"; import {
import type { UserRepoInfo } from "@/client/model"; Lock,
import { REPO_HEADER } from "@/css/repo/styles"; Globe,
GitBranch,
GitCommit,
GitPullRequest,
Tag,
} from "lucide-react"
import type { UserRepoInfo } from "@/client/model"
import { Badge } from "@/components/ui/badge"
interface RepoHeaderProps { interface RepoHeaderProps {
repo: UserRepoInfo; repo: UserRepoInfo
stats?: { stats?: {
openPulls?: number; openPulls?: number
commitsCount?: number; commitsCount?: number
tagsCount?: number; tagsCount?: number
branchesCount?: number; branchesCount?: number
}; }
} }
export function RepoHeader({ repo, stats }: RepoHeaderProps) { export function RepoHeader({ repo, stats }: RepoHeaderProps) {
return ( return (
<div className={REPO_HEADER.container}> <div className="rounded-2xl border border-border/70 bg-card/80 p-5 shadow-sm backdrop-blur">
<div className={REPO_HEADER.titleRow}> <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className={REPO_HEADER.nameRow}> <div className="flex flex-wrap items-center gap-3">
<h1 className={REPO_HEADER.title}> <h1 className="text-2xl font-semibold tracking-tight text-foreground">
{repo.repo_name} {repo.repo_name}
</h1> </h1>
<span <Badge
className={`${REPO_HEADER.badge} ${ variant={repo.is_private ? "secondary" : "outline"}
repo.is_private className="gap-1.5 rounded-full px-2.5 py-1 text-xs"
? REPO_HEADER.badgePrivate
: REPO_HEADER.badgePublic
}`}
> >
{repo.is_private ? ( {repo.is_private ? (
<> <>
<Lock className="w-3 h-3" /> Private <Lock className="size-3.5" />
Private
</> </>
) : ( ) : (
<> <>
<Globe className="w-3 h-3" /> Public <Globe className="size-3.5" />
Public
</> </>
)} )}
</span> </Badge>
</div> </div>
{repo.description && ( {repo.description && (
<p className={REPO_HEADER.description}> <p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
{repo.description} {repo.description}
</p> </p>
)} )}
<div className="flex items-center gap-4 text-xs text-muted-foreground"> <div className="mt-4 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1"> <Badge
<GitBranch className="w-3.5 h-3.5" /> variant="outline"
className="gap-1.5 rounded-full px-2.5 py-1"
>
<GitBranch className="size-3.5" />
{repo.default_branch} {repo.default_branch}
</span> </Badge>
{stats?.openPulls != null && ( {stats?.openPulls != null && (
<span className="flex items-center gap-1"> <Badge
<GitPullRequest className="w-3.5 h-3.5" /> variant="outline"
className="gap-1.5 rounded-full px-2.5 py-1"
>
<GitPullRequest className="size-3.5" />
{stats.openPulls} open {stats.openPulls} open
</span> </Badge>
)} )}
{stats?.commitsCount != null && ( {stats?.commitsCount != null && (
<span className="flex items-center gap-1"> <Badge
<GitCommit className="w-3.5 h-3.5" /> variant="outline"
className="gap-1.5 rounded-full px-2.5 py-1"
>
<GitCommit className="size-3.5" />
{stats.commitsCount} commits {stats.commitsCount} commits
</span> </Badge>
)}
{stats?.branchesCount != null && (
<Badge
variant="outline"
className="gap-1.5 rounded-full px-2.5 py-1"
>
<GitBranch className="size-3.5" />
{stats.branchesCount} branches
</Badge>
)} )}
{stats?.tagsCount != null && ( {stats?.tagsCount != null && (
<span className="flex items-center gap-1"> <Badge
<Tag className="w-3.5 h-3.5" /> variant="outline"
className="gap-1.5 rounded-full px-2.5 py-1"
>
<Tag className="size-3.5" />
{stats.tagsCount} tags {stats.tagsCount} tags
</span> </Badge>
)} )}
</div> </div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<Badge
variant="secondary"
className="rounded-full px-2.5 py-1 text-xs"
>
{repo.project_name}
</Badge>
<span className="text-xs text-muted-foreground">
{repo.storage_path || repo.repo_name}
</span>
</div> </div>
</div> </div>
</div> </div>
); </div>
)
} }

View File

@ -0,0 +1,55 @@
import type { ReactNode } from "react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "./alert-dialog"
interface ConfirmDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: ReactNode
confirmLabel?: string
cancelLabel?: string
destructive?: boolean
onConfirm: () => void
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
destructive = true,
onConfirm,
}: ConfirmDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description ? (
<AlertDialogDescription>{description}</AlertDialogDescription>
) : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction
variant={destructive ? "destructive" : "default"}
onClick={onConfirm}
>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@ -1,30 +1,36 @@
import { FileQuestion } from "lucide-react"; import { FileQuestion } from "lucide-react"
interface EmptyStateProps { interface EmptyStateProps {
icon?: React.ReactNode; icon?: React.ReactNode
title: string; title: string
description?: string; description?: string
action?: React.ReactNode; action?: React.ReactNode
} }
export function EmptyState({ export function EmptyState({
icon, icon,
title, title,
description, description,
action action,
}: EmptyStateProps) { }: EmptyStateProps) {
return ( return (
<div className="flex flex-col items-center justify-center h-full gap-4 p-8 text-center"> <div className="flex h-full flex-col items-center justify-center p-8 text-center">
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center"> <div className="flex max-w-md flex-col items-center gap-4 rounded-2xl border border-dashed border-border/70 bg-card/70 px-8 py-10 shadow-sm backdrop-blur">
{icon || <FileQuestion className="w-6 h-6 text-muted-foreground" />} <div className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
{icon || <FileQuestion className="size-6" />}
</div> </div>
<div className="space-y-1"> <div className="flex flex-col gap-1.5">
<h3 className="text-lg font-medium text-foreground">{title}</h3> <h3 className="text-lg font-semibold tracking-tight text-foreground">
{title}
</h3>
{description && ( {description && (
<p className="text-sm text-muted-foreground max-w-sm">{description}</p> <p className="max-w-sm text-sm leading-6 text-muted-foreground">
{description}
</p>
)} )}
</div> </div>
{action && <div>{action}</div>} {action && <div className="pt-1">{action}</div>}
</div> </div>
); </div>
)
} }

View File

@ -1,35 +1,41 @@
import { AlertCircle, RefreshCw } from "lucide-react"; import { AlertCircle, RefreshCw } from "lucide-react"
import { t } from "@/i18n/T"; import { t } from "@/i18n/T"
interface ErrorStateProps { interface ErrorStateProps {
title?: string; title?: string
message?: string; message?: string
onRetry?: () => void; onRetry?: () => void
} }
export function ErrorState({ export function ErrorState({
title = t("common.states.error_title"), title = t("common.states.error_title"),
message = t("common.states.error_message"), message = t("common.states.error_message"),
onRetry onRetry,
}: ErrorStateProps) { }: ErrorStateProps) {
return ( return (
<div className="flex flex-col items-center justify-center h-full gap-4 p-8 text-center"> <div className="flex h-full flex-col items-center justify-center p-8 text-center">
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center"> <div className="flex max-w-md flex-col items-center gap-4 rounded-2xl border border-destructive/20 bg-card/70 px-8 py-10 shadow-sm backdrop-blur">
<AlertCircle className="w-6 h-6 text-destructive" /> <div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<AlertCircle className="size-6" />
</div> </div>
<div className="space-y-1"> <div className="flex flex-col gap-1.5">
<h3 className="text-lg font-medium text-foreground">{title}</h3> <h3 className="text-lg font-semibold tracking-tight text-foreground">
<p className="text-sm text-muted-foreground max-w-sm">{message}</p> {title}
</h3>
<p className="max-w-sm text-sm leading-6 text-muted-foreground">
{message}
</p>
</div> </div>
{onRetry && ( {onRetry && (
<button <button
onClick={onRetry} onClick={onRetry}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90" className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
> >
<RefreshCw className="w-4 h-4" /> <RefreshCw className="size-4" />
{t("common.actions.retry")} {t("common.actions.retry")}
</button> </button>
)} )}
</div> </div>
); </div>
)
} }

View File

@ -1,15 +1,19 @@
import { LoadingSpinner } from "./LoadingSpinner"; import { LoadingSpinner } from "./LoadingSpinner"
import { t } from "@/i18n/T"; import { t } from "@/i18n/T"
interface LoadingStateProps { interface LoadingStateProps {
message?: string; message?: string
} }
export function LoadingState({ message = t("common.states.loading") }: LoadingStateProps) { export function LoadingState({
message = t("common.states.loading"),
}: LoadingStateProps) {
return ( return (
<div className="flex flex-col items-center justify-center h-full gap-4 p-8"> <div className="flex h-full flex-col items-center justify-center p-8">
<div className="flex flex-col items-center gap-4 rounded-2xl border border-border/60 bg-card/70 px-8 py-10 shadow-sm backdrop-blur">
<LoadingSpinner size={32} /> <LoadingSpinner size={32} />
<p className="text-sm text-muted-foreground">{message}</p> <p className="text-sm text-muted-foreground">{message}</p>
</div> </div>
); </div>
)
} }

View File

@ -1,19 +1,25 @@
interface PageHeaderProps { interface PageHeaderProps {
title: string; title: string
description?: string; description?: string
actions?: React.ReactNode; actions?: React.ReactNode
} }
export function PageHeader({ title, description, actions }: PageHeaderProps) { export function PageHeader({ title, description, actions }: PageHeaderProps) {
return ( return (
<div className="flex items-center justify-between mb-6"> <div className="mb-6 flex flex-col gap-4 border-b border-border/60 pb-4 sm:flex-row sm:items-end sm:justify-between">
<div> <div className="min-w-0">
<h1 className="text-2xl font-bold text-foreground">{title}</h1> <h1 className="text-2xl font-semibold tracking-tight text-foreground sm:text-[1.75rem]">
{title}
</h1>
{description && ( {description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p> <p className="mt-1.5 max-w-2xl text-sm leading-6 text-muted-foreground">
{description}
</p>
)} )}
</div> </div>
{actions && <div className="flex items-center gap-2">{actions}</div>} {actions && (
<div className="flex flex-wrap items-center gap-2">{actions}</div>
)}
</div> </div>
); )
} }

View File

@ -35,7 +35,7 @@ function SheetOverlay({
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", "fixed inset-0 z-50 bg-black/25 duration-150 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className className
)} )}
{...props} {...props}
@ -60,7 +60,7 @@ function SheetContent({
data-slot="sheet-content" data-slot="sheet-content"
data-side={side} data-side={side}
className={cn( className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10", "fixed z-50 flex flex-col gap-4 border-border/70 bg-popover/95 bg-clip-padding text-sm text-popover-foreground shadow-2xl shadow-black/10 backdrop-blur-xl transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className className
)} )}
{...props} {...props}
@ -73,8 +73,7 @@ function SheetContent({
className="absolute top-3 right-3" className="absolute top-3 right-3"
size="icon-sm" size="icon-sm"
> >
<XIcon <XIcon />
/>
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</Button> </Button>
</SheetPrimitive.Close> </SheetPrimitive.Close>
@ -88,7 +87,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sheet-header" data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)} className={cn("flex flex-col gap-1 p-4", className)}
{...props} {...props}
/> />
) )