diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 79f5d15..546c7fc 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,37 +1,45 @@ -import { memo, useCallback, useState } from "react"; -import { Link, useLocation, useParams } from "react-router-dom"; -import { Bookmark, ChevronRight, ChevronDown, Home, Settings } from "lucide-react"; -import { useProjectLayout } from "@/app/project/layout"; -import { useProjectsQuery } from "@/hooks/useProjectsQuery"; -import { useOptionalRoom } from "@/contexts/room"; -import { RoomSettingsModal } from "@/app/project/channel/RoomSettingsModal"; -import { 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 { memo, useCallback, useState } from "react" +import { Link, useLocation, useParams } from "react-router-dom" +import { + Bookmark, + ChevronRight, + ChevronDown, + Home, + Settings, +} from "lucide-react" +import { useProjectLayout } from "@/app/project/layout" +import { useProjectsQuery } from "@/hooks/useProjectsQuery" +import { useOptionalRoom } from "@/contexts/room" +import { RoomSettingsModal } from "@/app/project/channel/RoomSettingsModal" +import { 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 { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, 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 { - return s.slice(0, 8); + return s.slice(0, 8) } interface BreadcrumbSegment { - label: string; - path: string; - isLast: boolean; - fullUuid?: string; + label: string + path: string + isLast: boolean + fullUuid?: string } interface BreadcrumbSibling { - label: string; - path: string; + label: string + path: string } const ME_NAV_SIBLINGS: BreadcrumbSibling[] = [ @@ -44,20 +52,23 @@ const ME_NAV_SIBLINGS: BreadcrumbSibling[] = [ { label: "Following", path: "/me/following" }, { label: "Followers", path: "/me/followers" }, { label: "Invitations", path: "/me/invitations" }, -]; +] -function getProjectNavSiblings(projectName: string, showSettings: boolean): BreadcrumbSibling[] { +function getProjectNavSiblings( + projectName: string, + showSettings: boolean +): BreadcrumbSibling[] { const items = [ { label: "Repository", path: `/${projectName}/repos` }, { label: "Issues", path: `/${projectName}/issues` }, { label: "Skills", path: `/${projectName}/skills` }, { label: "Board", path: `/${projectName}/board` }, { label: "Chat", path: `/${projectName}/chat` }, - ]; + ] 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[] { @@ -68,92 +79,92 @@ function getRepoTabSiblings(repoBasePath: string): BreadcrumbSibling[] { { label: "Branches", path: `${repoBasePath}/branches` }, { label: "Tags", path: `${repoBasePath}/tags` }, { label: "Settings", path: `${repoBasePath}/settings` }, - ]; + ] } function getSegmentSiblings( segment: BreadcrumbSegment, projects: Array<{ name: string; display_name: string }>, - canManageProject = false, + canManageProject = false ): BreadcrumbSibling[] | null { - if (segment.isLast) return null; + if (segment.isLast) return null - const parts = segment.path.split("/").filter(Boolean); - const depth = parts.length; + const parts = segment.path.split("/").filter(Boolean) + const depth = parts.length if (depth === 1) { - if (parts[0] === "me") return ME_NAV_SIBLINGS; + if (parts[0] === "me") return ME_NAV_SIBLINGS if (projects.length > 1) { return projects.map((p) => ({ label: p.display_name || p.name, path: `/${p.name}`, - })); + })) } - return null; + return null } if (depth === 2) { - if (parts[0] === "me") return ME_NAV_SIBLINGS; - return getProjectNavSiblings(parts[0], canManageProject); + if (parts[0] === "me") return ME_NAV_SIBLINGS + return getProjectNavSiblings(parts[0], canManageProject) } if (depth >= 3 && parts[1] === "repo") { - const repoBase = `/${parts[0]}/repo/${parts[2]}`; - return getRepoTabSiblings(repoBase); + const repoBase = `/${parts[0]}/repo/${parts[2]}` + return getRepoTabSiblings(repoBase) } - return null; + return null } function useBreadcrumbs() { - const location = useLocation(); + const location = useLocation() const params = useParams<{ - projectName?: string; - repoName?: string; - issueNumber?: string; - skillSlug?: string; - roomId?: string; - }>(); - const roomContext = useOptionalRoom(); - const currentRoom = roomContext?.currentRoom; + projectName?: string + repoName?: string + issueNumber?: string + skillSlug?: string + roomId?: string + }>() + const roomContext = useOptionalRoom() + const currentRoom = roomContext?.currentRoom - const { data: projects = [] } = useProjectsQuery(); - const activeProject = projects.find((p) => p.name === params.projectName); + const { data: projects = [] } = useProjectsQuery() + const activeProject = projects.find((p) => p.name === params.projectName) - const segments: BreadcrumbSegment[] = []; - const pathParts = location.pathname.split("/").filter(Boolean); + const segments: BreadcrumbSegment[] = [] + const pathParts = location.pathname.split("/").filter(Boolean) for (let i = 0; i < pathParts.length; i++) { - const part = pathParts[i]; - const path = "/" + pathParts.slice(0, i + 1).join("/"); - const isLast = i === pathParts.length - 1; + const part = pathParts[i] + const path = "/" + pathParts.slice(0, i + 1).join("/") + const isLast = i === pathParts.length - 1 - let label = part; - let fullUuid: string | undefined; + let label = part + let fullUuid: string | undefined if (part === params.roomId && currentRoom) { - label = `#${currentRoom.room_name}`; + label = `#${currentRoom.room_name}` } else if (UUID_RE.test(part)) { - fullUuid = part; - label = truncateUuid(part); + fullUuid = part + label = truncateUuid(part) } else if (part === params.projectName && activeProject) { - label = activeProject.display_name; + label = activeProject.display_name } else if (part === params.repoName) { - label = part; + label = part } else if (part === params.issueNumber) { - label = `#${part}`; + label = `#${part}` } else if (part === params.skillSlug) { - label = part; + label = part } else if (part === "channel" && params.roomId && currentRoom) { - label = currentRoom.room_name; + label = currentRoom.room_name } 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 = [ @@ -173,189 +184,194 @@ const TOOLBAR_ICONS = [ 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", }, -]; +] export const Header = memo(function Header() { - const location = useLocation(); - const params = useParams<{ projectName?: string }>(); - const { segments, projects } = useBreadcrumbs(); - const { isProjectMember, projectInfo, showMembers, setShowMembers } = useProjectLayout(); - const [showSettings, setShowSettings] = useState(false); - const [showFavorites, setShowFavorites] = useState(false); - const roomContext = useOptionalRoom(); - const canManageProject = isProjectAdminRole(projectInfo?.role); + const location = useLocation() + const params = useParams<{ projectName?: string }>() + const { segments, projects } = useBreadcrumbs() + const { isProjectMember, projectInfo, showMembers, setShowMembers } = + useProjectLayout() + const [showSettings, setShowSettings] = useState(false) + const [showFavorites, setShowFavorites] = useState(false) + const roomContext = useOptionalRoom() + const canManageProject = isProjectAdminRole(projectInfo?.role) const handleCopy = useCallback((e: React.MouseEvent, text: string) => { - e.preventDefault(); - navigator.clipboard.writeText(text); - }, []); + e.preventDefault() + navigator.clipboard.writeText(text) + }, []) return ( <> -
-
- - - +
+
+ + + - {segments.map((segment, idx) => { - const siblings = getSegmentSiblings(segment, projects, canManageProject); + {segments.map((segment, idx) => { + const siblings = getSegmentSiblings( + segment, + projects, + canManageProject + ) - return ( - - - {segment.isLast ? ( - handleCopy(e, segment.fullUuid!) } - : {})} - > - {segment.label} - - ) : siblings && siblings.length > 0 ? ( - - - - - - {siblings.map((s) => ( - - - {s.label} - - - ))} - - - ) : segment.fullUuid ? ( - handleCopy(e, segment.fullUuid!)} - > - {segment.label} - - ) : ( - - {segment.label} - - )} - - ); - })} -
- -
- {location.pathname.startsWith("/me") ? null : ( - <> - {isProjectMember && roomContext?.currentRoom && location.pathname.includes("/channel/") && ( - - )} - {isProjectMember && ( - - )} - - {TOOLBAR_ICONS.map((icon, i) => ( - + + + {siblings.map((s) => ( + + + {s.label} + + + ))} + + + ) : segment.fullUuid ? ( + handleCopy(e, segment.fullUuid!)} + > + {segment.label} + + ) : ( + + {segment.label} + + )} + + ) + })} +
+ +
+ {location.pathname.startsWith("/me") ? null : ( + <> + {isProjectMember && + roomContext?.currentRoom && + location.pathname.includes("/channel/") && ( + + )} + {isProjectMember && ( + - ))} - - )} -
-
- - + + )} + + {TOOLBAR_ICONS.map((icon, i) => ( + + ))} + + )} +
+
+ + - ); -}); + ) +}) diff --git a/src/components/layout/ProjectMessageFavoritesDrawer.tsx b/src/components/layout/ProjectMessageFavoritesDrawer.tsx index 949facd..1e2aecc 100644 --- a/src/components/layout/ProjectMessageFavoritesDrawer.tsx +++ b/src/components/layout/ProjectMessageFavoritesDrawer.tsx @@ -1,77 +1,105 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Bookmark, ExternalLink, Loader2, Trash2 } from "lucide-react"; -import { useNavigate } from "react-router-dom"; -import { projectMessageFavoriteRemove, projectMessageFavorites } from "@/client/api"; -import type { ProjectMessageFavoriteItem } from "@/client/model"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { Bookmark, ExternalLink, Loader2, Trash2 } from "lucide-react" +import { useNavigate } from "react-router-dom" +import { + projectMessageFavoriteRemove, + projectMessageFavorites, +} from "@/client/api" +import type { ProjectMessageFavoriteItem } from "@/client/model" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, -} from "@/components/ui/sheet"; -import { extractIrNodes } from "@/lib/ir/parser"; +} from "@/components/ui/sheet" +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty" +import { extractIrNodes } from "@/lib/ir/parser" interface Props { - open: boolean; - onOpenChange: (open: boolean) => void; - projectName?: string; + open: boolean + onOpenChange: (open: boolean) => void + projectName?: string } function plainMessageText(item: ProjectMessageFavoriteItem) { - const nodes = extractIrNodes(item.content); + const nodes = extractIrNodes(item.content) const text = nodes .map((node) => { - if ("text" in node && typeof node.text === "string") return node.text; - if ("content" in node && typeof node.content === "string") return node.content; - return ""; + if ("text" in node && typeof node.text === "string") return node.text + if ("content" in node && typeof node.content === "string") + return node.content + return "" }) .join(" ") .replace(/\s+/g, " ") - .trim(); - return text || item.content.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); + .trim() + return ( + text || + item.content + .replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " ") + .trim() + ) } function formatDate(value: string) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return ""; + const date = new Date(value) + if (Number.isNaN(date.getTime())) return "" return date.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", - }); + }) } -export function ProjectMessageFavoritesDrawer({ open, onOpenChange, projectName }: Props) { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const queryKey = ["project-message-favorites", projectName]; +export function ProjectMessageFavoritesDrawer({ + open, + onOpenChange, + projectName, +}: Props) { + const navigate = useNavigate() + const queryClient = useQueryClient() + const queryKey = ["project-message-favorites", projectName] const favoritesQuery = useQuery({ queryKey, queryFn: async () => { - const res = await projectMessageFavorites(projectName!, { page: 1, per_page: 50 }); - return res.data.data; + const res = await projectMessageFavorites(projectName!, { + page: 1, + per_page: 50, + }) + return res.data.data }, enabled: open && !!projectName, staleTime: 15_000, - }); + }) const removeMutation = useMutation({ - mutationFn: (messageId: string) => projectMessageFavoriteRemove(projectName!, messageId), + mutationFn: (messageId: string) => + projectMessageFavoriteRemove(projectName!, messageId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey }); + queryClient.invalidateQueries({ queryKey }) }, - }); + }) const goToMessage = (item: ProjectMessageFavoriteItem) => { - if (!projectName) return; - onOpenChange(false); - navigate(`/${projectName}/channel/${item.room_id}?message=${item.message_id}`); - }; + if (!projectName) return + onOpenChange(false) + navigate( + `/${projectName}/channel/${item.room_id}?message=${item.message_id}` + ) + } - const list = favoritesQuery.data?.list ?? []; + const list = favoritesQuery.data?.list ?? [] return ( @@ -90,26 +118,38 @@ export function ProjectMessageFavoritesDrawer({ open, onOpenChange, projectName ) : list.length === 0 ? ( -
- - No favorite messages yet. -
+ + + + + + + No favorite messages yet. + + Pin a message from a room and it will appear here for quick + access. + + + + ) : (
{list.map((item) => { - const preview = plainMessageText(item); + const preview = plainMessageText(item) return (
- #{item.room_name} + + #{item.room_name} + {formatDate(item.send_at)}
- ); + ) })} )}
- ); + ) } diff --git a/src/components/repo/RepoHeader.tsx b/src/components/repo/RepoHeader.tsx index 2b24b20..35a525c 100644 --- a/src/components/repo/RepoHeader.tsx +++ b/src/components/repo/RepoHeader.tsx @@ -1,77 +1,116 @@ -import { Lock, Globe, GitBranch, GitCommit, GitPullRequest, Tag } from "lucide-react"; -import type { UserRepoInfo } from "@/client/model"; -import { REPO_HEADER } from "@/css/repo/styles"; +import { + Lock, + Globe, + GitBranch, + GitCommit, + GitPullRequest, + Tag, +} from "lucide-react" +import type { UserRepoInfo } from "@/client/model" +import { Badge } from "@/components/ui/badge" interface RepoHeaderProps { - repo: UserRepoInfo; + repo: UserRepoInfo stats?: { - openPulls?: number; - commitsCount?: number; - tagsCount?: number; - branchesCount?: number; - }; + openPulls?: number + commitsCount?: number + tagsCount?: number + branchesCount?: number + } } export function RepoHeader({ repo, stats }: RepoHeaderProps) { return ( -
-
-
-
-

+
+
+
+
+

{repo.repo_name}

- {repo.is_private ? ( <> - Private + + Private ) : ( <> - Public + + Public )} - +
{repo.description && ( -

+

{repo.description}

)} -
- - +
+ + {repo.default_branch} - + {stats?.openPulls != null && ( - - + + {stats.openPulls} open - + )} {stats?.commitsCount != null && ( - - + + {stats.commitsCount} commits - + + )} + {stats?.branchesCount != null && ( + + + {stats.branchesCount} branches + )} {stats?.tagsCount != null && ( - - + + {stats.tagsCount} tags - + )}
+ +
+ + {repo.project_name} + + + {repo.storage_path || repo.repo_name} + +
- ); + ) } diff --git a/src/components/ui/ConfirmDialog.tsx b/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..d432916 --- /dev/null +++ b/src/components/ui/ConfirmDialog.tsx @@ -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 ( + + + + {title} + {description ? ( + {description} + ) : null} + + + {cancelLabel} + + {confirmLabel} + + + + + ) +} diff --git a/src/components/ui/EmptyState.tsx b/src/components/ui/EmptyState.tsx index 9baf8d7..556991c 100644 --- a/src/components/ui/EmptyState.tsx +++ b/src/components/ui/EmptyState.tsx @@ -1,30 +1,36 @@ -import { FileQuestion } from "lucide-react"; +import { FileQuestion } from "lucide-react" interface EmptyStateProps { - icon?: React.ReactNode; - title: string; - description?: string; - action?: React.ReactNode; + icon?: React.ReactNode + title: string + description?: string + action?: React.ReactNode } export function EmptyState({ icon, title, description, - action + action, }: EmptyStateProps) { return ( -
-
- {icon || } +
+
+
+ {icon || } +
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ {action &&
{action}
}
-
-

{title}

- {description && ( -

{description}

- )} -
- {action &&
{action}
}
- ); + ) } diff --git a/src/components/ui/ErrorState.tsx b/src/components/ui/ErrorState.tsx index ab11101..ccfb63c 100644 --- a/src/components/ui/ErrorState.tsx +++ b/src/components/ui/ErrorState.tsx @@ -1,35 +1,41 @@ -import { AlertCircle, RefreshCw } from "lucide-react"; -import { t } from "@/i18n/T"; +import { AlertCircle, RefreshCw } from "lucide-react" +import { t } from "@/i18n/T" interface ErrorStateProps { - title?: string; - message?: string; - onRetry?: () => void; + title?: string + message?: string + onRetry?: () => void } export function ErrorState({ title = t("common.states.error_title"), message = t("common.states.error_message"), - onRetry + onRetry, }: ErrorStateProps) { return ( -
-
- +
+
+
+ +
+
+

+ {title} +

+

+ {message} +

+
+ {onRetry && ( + + )}
-
-

{title}

-

{message}

-
- {onRetry && ( - - )}
- ); + ) } diff --git a/src/components/ui/LoadingState.tsx b/src/components/ui/LoadingState.tsx index 148382f..84b7a00 100644 --- a/src/components/ui/LoadingState.tsx +++ b/src/components/ui/LoadingState.tsx @@ -1,15 +1,19 @@ -import { LoadingSpinner } from "./LoadingSpinner"; -import { t } from "@/i18n/T"; +import { LoadingSpinner } from "./LoadingSpinner" +import { t } from "@/i18n/T" 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 ( -
- -

{message}

+
+
+ +

{message}

+
- ); + ) } diff --git a/src/components/ui/PageHeader.tsx b/src/components/ui/PageHeader.tsx index 5763c2f..5f9e481 100644 --- a/src/components/ui/PageHeader.tsx +++ b/src/components/ui/PageHeader.tsx @@ -1,19 +1,25 @@ interface PageHeaderProps { - title: string; - description?: string; - actions?: React.ReactNode; + title: string + description?: string + actions?: React.ReactNode } export function PageHeader({ title, description, actions }: PageHeaderProps) { return ( -
-
-

{title}

+
+
+

+ {title} +

{description && ( -

{description}

+

+ {description} +

)}
- {actions &&
{actions}
} + {actions && ( +
{actions}
+ )}
- ); + ) } diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 49a6af2..c78a3f5 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -35,7 +35,7 @@ function SheetOverlay({ - + Close @@ -88,7 +87,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { return (
)