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 (
<>
-
+
+
>
- );
-});
+ )
+})
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 && (
+
+
+ {t("common.actions.retry")}
+
+ )}
-
- {onRetry && (
-
-
- {t("common.actions.retry")}
-
- )}
- );
+ )
}
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}
+
- );
+ )
}
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 (
)