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:
parent
16865117de
commit
c39ee1ce2a
@ -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,189 +184,194 @@ 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}
|
||||||
{segment.isLast ? (
|
className="flex min-w-0 items-center gap-1"
|
||||||
<span
|
|
||||||
className={`font-medium text-foreground truncate${segment.fullUuid ? " font-mono cursor-pointer hover:text-accent transition-colors" : ""}`}
|
|
||||||
title={segment.fullUuid}
|
|
||||||
{...(segment.fullUuid
|
|
||||||
? { onClick: (e: React.MouseEvent) => handleCopy(e, segment.fullUuid!) }
|
|
||||||
: {})}
|
|
||||||
>
|
|
||||||
{segment.label}
|
|
||||||
</span>
|
|
||||||
) : siblings && siblings.length > 0 ? (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
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="truncate max-w-[180px]">{segment.label}</span>
|
|
||||||
<ChevronDown className="w-3 h-3 shrink-0" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="min-w-36 max-h-64 overflow-y-auto">
|
|
||||||
{siblings.map((s) => (
|
|
||||||
<DropdownMenuItem key={s.path} asChild>
|
|
||||||
<Link to={s.path} className="truncate">
|
|
||||||
{s.label}
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : segment.fullUuid ? (
|
|
||||||
<Link
|
|
||||||
to={segment.path}
|
|
||||||
className="text-muted-foreground hover:text-foreground font-mono cursor-pointer transition-colors"
|
|
||||||
title={segment.fullUuid}
|
|
||||||
onClick={(e) => handleCopy(e, segment.fullUuid!)}
|
|
||||||
>
|
|
||||||
{segment.label}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
to={segment.path}
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
{segment.label}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
|
||||||
{location.pathname.startsWith("/me") ? null : (
|
|
||||||
<>
|
|
||||||
{isProjectMember && roomContext?.currentRoom && location.pathname.includes("/channel/") && (
|
|
||||||
<button
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<Settings className="w-[18px] h-[18px]" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isProjectMember && (
|
|
||||||
<button
|
|
||||||
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
|
<ChevronRight className="size-4 shrink-0 text-muted-foreground/50" />
|
||||||
className="w-[18px] h-[18px]"
|
{segment.isLast ? (
|
||||||
fill="none"
|
<span
|
||||||
stroke="currentColor"
|
className={`truncate font-medium text-foreground${segment.fullUuid ? "cursor-pointer font-mono transition-colors hover:text-accent" : ""}`}
|
||||||
viewBox="0 0 24 24"
|
title={segment.fullUuid}
|
||||||
>
|
{...(segment.fullUuid
|
||||||
<path
|
? {
|
||||||
strokeLinecap="round"
|
onClick: (e: React.MouseEvent) =>
|
||||||
strokeLinejoin="round"
|
handleCopy(e, segment.fullUuid!),
|
||||||
strokeWidth={1.5}
|
}
|
||||||
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>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{TOOLBAR_ICONS.map((icon, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
onClick={
|
|
||||||
icon.label === "Search"
|
|
||||||
? openGlobalSearch
|
|
||||||
: icon.label === "Pinned Messages"
|
|
||||||
? () => setShowFavorites(true)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className="w-8 h-8 flex items-center justify-center rounded-[4px] transition-colors"
|
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
title={
|
|
||||||
icon.label === "Search"
|
|
||||||
? `Search (${modKey()}${altKey()}F)`
|
|
||||||
: icon.label === "Pinned Messages"
|
|
||||||
? "Pinned Messages"
|
|
||||||
: icon.label
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{icon.label === "Pinned Messages" ? (
|
|
||||||
<Bookmark className="w-[18px] h-[18px]" />
|
|
||||||
) : (
|
|
||||||
<svg
|
|
||||||
className="w-[18px] h-[18px]"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
>
|
||||||
|
{segment.label}
|
||||||
|
</span>
|
||||||
|
) : siblings && siblings.length > 0 ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<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">
|
||||||
|
<span className="max-w-[180px] truncate">
|
||||||
|
{segment.label}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="size-3.5 shrink-0" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
className="max-h-64 min-w-36 overflow-y-auto"
|
||||||
|
>
|
||||||
|
{siblings.map((s) => (
|
||||||
|
<DropdownMenuItem key={s.path} asChild>
|
||||||
|
<Link to={s.path} className="truncate">
|
||||||
|
{s.label}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : segment.fullUuid ? (
|
||||||
|
<Link
|
||||||
|
to={segment.path}
|
||||||
|
className="cursor-pointer font-mono text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
title={segment.fullUuid}
|
||||||
|
onClick={(e) => handleCopy(e, segment.fullUuid!)}
|
||||||
|
>
|
||||||
|
{segment.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to={segment.path}
|
||||||
|
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
{segment.label}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{location.pathname.startsWith("/me") ? null : (
|
||||||
|
<>
|
||||||
|
{isProjectMember &&
|
||||||
|
roomContext?.currentRoom &&
|
||||||
|
location.pathname.includes("/channel/") && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="rounded-full text-muted-foreground"
|
||||||
|
onClick={() => setShowSettings(true)}
|
||||||
|
title="Room Settings"
|
||||||
|
>
|
||||||
|
<Settings />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isProjectMember && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={`rounded-full text-muted-foreground ${showMembers ? "bg-muted text-foreground" : ""}`}
|
||||||
|
onClick={() => setShowMembers(!showMembers)}
|
||||||
|
>
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
d={icon.path}
|
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) => (
|
||||||
)}
|
<Button
|
||||||
</div>
|
type="button"
|
||||||
</header>
|
variant="ghost"
|
||||||
<RoomSettingsModal open={showSettings} onOpenChange={setShowSettings} />
|
size="icon-sm"
|
||||||
<ProjectMessageFavoritesDrawer
|
key={i}
|
||||||
open={showFavorites}
|
onClick={
|
||||||
onOpenChange={setShowFavorites}
|
icon.label === "Search"
|
||||||
projectName={params.projectName}
|
? openGlobalSearch
|
||||||
/>
|
: icon.label === "Pinned Messages"
|
||||||
|
? () => setShowFavorites(true)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className="rounded-full text-muted-foreground"
|
||||||
|
title={
|
||||||
|
icon.label === "Search"
|
||||||
|
? `Search (${modKey()}${altKey()}F)`
|
||||||
|
: icon.label === "Pinned Messages"
|
||||||
|
? "Pinned Messages"
|
||||||
|
: icon.label
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{icon.label === "Pinned Messages" ? (
|
||||||
|
<Bookmark />
|
||||||
|
) : (
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d={icon.path}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<RoomSettingsModal open={showSettings} onOpenChange={setShowSettings} />
|
||||||
|
<ProjectMessageFavoritesDrawer
|
||||||
|
open={showFavorites}
|
||||||
|
onOpenChange={setShowFavorites}
|
||||||
|
projectName={params.projectName}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
});
|
})
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/components/ui/ConfirmDialog.tsx
Normal file
55
src/components/ui/ConfirmDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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 className="flex flex-col gap-1.5">
|
||||||
|
<h3 className="text-lg font-semibold tracking-tight text-foreground">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="max-w-sm text-sm leading-6 text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action && <div className="pt-1">{action}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="text-lg font-medium text-foreground">{title}</h3>
|
|
||||||
{description && (
|
|
||||||
<p className="text-sm text-muted-foreground max-w-sm">{description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{action && <div>{action}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 className="flex flex-col gap-1.5">
|
||||||
|
<h3 className="text-lg font-semibold tracking-tight text-foreground">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="max-w-sm text-sm leading-6 text-muted-foreground">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{onRetry && (
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
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="size-4" />
|
||||||
|
{t("common.actions.retry")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="text-lg font-medium text-foreground">{title}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground max-w-sm">{message}</p>
|
|
||||||
</div>
|
|
||||||
{onRetry && (
|
|
||||||
<button
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
{t("common.actions.retry")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
<LoadingSpinner size={32} />
|
<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">
|
||||||
<p className="text-sm text-muted-foreground">{message}</p>
|
<LoadingSpinner size={32} />
|
||||||
|
<p className="text-sm text-muted-foreground">{message}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user