mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-23 03:26:37 +00:00
feat(frontend): mobile-friendly headers across detail and list pages
Roll the truncate-chain + mobile-navigation pattern introduced for the /flows/:id page out to every other detail and list page so a long title never pushes the action buttons off-screen on a ~390px viewport. Detail pages - `templates/template.tsx` and `features/knowledges/knowledge-header.tsx` now mirror `pages/flows/flow.tsx`: left container becomes `flex min-w-0 flex-1` with `shrink-0` on the SidebarTrigger/Separator, Breadcrumb/BreadcrumbList/BreadcrumbItem get the `min-w-0` / `flex-nowrap` chain, BreadcrumbPage gets `truncate`, the right action area becomes `flex shrink-0`. InlineEditInput in both pages now uses `w-64 min-w-0 max-w-full flex-1` so the rename input shrinks with the parent instead of forcing it to ≥256px. - On `isMobile`, the DetailNavigationToolbar is hidden and re-surfaced inside the existing actions dropdown as a single row matching `pages/flows/flow.tsx` (icon + label + Prev/Position/Next button group with shared borders; middle button doubles as the sheet trigger). DetailNavigationSheet is mounted separately, controlled by `isMobileNavSheetOpen` state. List pages - `pages/flows/flows.tsx`, `pages/knowledges/knowledges.tsx`, `pages/templates/templates.tsx`, `pages/resources/resources.tsx`, and `pages/flows/new-flow.tsx` get the same header truncate chain preventively — most page titles are short, but the structure stays consistent across pages and protects against future longer labels. - `pages/templates/templates.tsx` Title cell gains `max-w-[380px] truncate` (matched to the existing Text-cell cap) so a long template title can't blow up the row. - Settings pages (`settings-providers.tsx`, `settings-api-tokens.tsx`) don't render a breadcrumb header — their `SettingsLayout` already shows the sidebar trigger — but their inline header rows still leaked on narrow screens. Add `min-w-0 flex-1 truncate` to the description text and `shrink-0` to the Create button so the right-edge CTA stays visible while the left text degrades gracefully. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
7a2f882cea
commit
c196e55d5d
@@ -1,14 +1,14 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Ellipsis, LibraryBig, Loader2, Pencil, Trash } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft, ChevronRight, Ellipsis, LibraryBig, Loader2, Pencil, Trash } from 'lucide-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { KnowledgeDocumentFragmentFragment } from '@/graphql/types';
|
||||
|
||||
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
|
||||
import { DetailNavigationToolbar } from '@/components/shared/detail-navigation';
|
||||
import { DetailNavigationSheet, DetailNavigationToolbar, useNavigation } from '@/components/shared/detail-navigation';
|
||||
import { InlineEditInput, useInlineEdit } from '@/components/shared/inline-edit';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useBreakpoint } from '@/hooks/use-breakpoint';
|
||||
import { mergeHrefWithSearchParams } from '@/lib/url-params';
|
||||
import { type Knowledge, useKnowledges } from '@/providers/knowledges-provider';
|
||||
|
||||
import { useKnowledgeDetailNavigation } from './use-knowledge-detail-navigation';
|
||||
@@ -42,6 +44,8 @@ interface KnowledgeHeaderProps {
|
||||
|
||||
export const KnowledgeHeader = ({ isNew, knowledge, onBeforeNavigateAway, saveButton }: KnowledgeHeaderProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { isMobile } = useBreakpoint();
|
||||
const { deleteKnowledge, updateKnowledge } = useKnowledges();
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
@@ -51,6 +55,56 @@ export const KnowledgeHeader = ({ isNew, knowledge, onBeforeNavigateAway, saveBu
|
||||
|
||||
const { toolbarProps: knowledgeToolbarProps } = useKnowledgeDetailNavigation(knowledgeId);
|
||||
|
||||
// Mirror what `<DetailNavigationToolbar>` computes internally so the
|
||||
// mobile menu items share the same filtered subset as the desktop toolbar.
|
||||
const mobileNav = useNavigation<Knowledge>({
|
||||
currentId: knowledgeToolbarProps.currentId,
|
||||
getId: knowledgeToolbarProps.getId,
|
||||
getSearchableText: knowledgeToolbarProps.getSearchableText ?? knowledgeToolbarProps.getLabel,
|
||||
items: knowledgeToolbarProps.items,
|
||||
query: knowledgeToolbarProps.filter,
|
||||
});
|
||||
const [isMobileNavSheetOpen, setIsMobileNavSheetOpen] = useState(false);
|
||||
|
||||
const mobileNavGoTo = useCallback(
|
||||
(id: null | string) => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = mobileNav.filteredItems.find(
|
||||
(item) => String(knowledgeToolbarProps.getId(item)) === id,
|
||||
);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(mergeHrefWithSearchParams(knowledgeToolbarProps.getHref(target), searchParams), {
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
[knowledgeToolbarProps, mobileNav.filteredItems, navigate, searchParams],
|
||||
);
|
||||
|
||||
const mobileNavSelectItem = useCallback(
|
||||
(item: Knowledge) => {
|
||||
setIsMobileNavSheetOpen(false);
|
||||
navigate(mergeHrefWithSearchParams(knowledgeToolbarProps.getHref(item), searchParams), {
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
[knowledgeToolbarProps, navigate, searchParams],
|
||||
);
|
||||
|
||||
const mobilePositionLabel = useMemo(
|
||||
() =>
|
||||
mobileNav.total === 0 || mobileNav.currentIndex === -1
|
||||
? `–/${mobileNav.total}`
|
||||
: `${mobileNav.currentIndex + 1}/${mobileNav.total}`,
|
||||
[mobileNav.currentIndex, mobileNav.total],
|
||||
);
|
||||
|
||||
// Title source-of-truth is the server-side `question`. We intentionally do
|
||||
// not read it from the form draft below — the inline rename flow in this
|
||||
// header writes through `updateKnowledge`, which refreshes `knowledge` via
|
||||
@@ -121,47 +175,49 @@ export const KnowledgeHeader = ({ isNew, knowledge, onBeforeNavigateAway, saveBu
|
||||
return (
|
||||
<>
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
className="mr-2 h-4"
|
||||
orientation="vertical"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem className="gap-2">
|
||||
<LibraryBig className="size-4 shrink-0" />
|
||||
{isEditingTitle && canShowActions ? (
|
||||
<InlineEditInput
|
||||
busy={isRenaming}
|
||||
className="w-64 max-w-full"
|
||||
defaultValue={knowledgeName ?? ''}
|
||||
inputRef={editingInputRef}
|
||||
onCancel={handleRenameCancel}
|
||||
onSave={handleRenameSave}
|
||||
placeholder="Knowledge question"
|
||||
/>
|
||||
) : canShowActions ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<BreadcrumbPage
|
||||
className="max-w-[240px] cursor-text truncate select-none"
|
||||
onDoubleClick={handleRenameStart}
|
||||
>
|
||||
{knowledgeName ?? 'Knowledge'}
|
||||
</BreadcrumbPage>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Double-click to rename</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<BreadcrumbPage className="max-w-[240px] truncate">
|
||||
{isNew ? 'New knowledge' : (knowledgeName ?? 'Knowledge')}
|
||||
</BreadcrumbPage>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{canShowActions && (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1 shrink-0" />
|
||||
<Separator
|
||||
className="mr-2 h-4 shrink-0"
|
||||
orientation="vertical"
|
||||
/>
|
||||
<Breadcrumb className="min-w-0 flex-1">
|
||||
<BreadcrumbList className="min-w-0 flex-nowrap">
|
||||
<BreadcrumbItem className="min-w-0 gap-2">
|
||||
<LibraryBig className="size-4 shrink-0" />
|
||||
{isEditingTitle && canShowActions ? (
|
||||
<InlineEditInput
|
||||
busy={isRenaming}
|
||||
className="w-64 min-w-0 max-w-full flex-1"
|
||||
defaultValue={knowledgeName ?? ''}
|
||||
inputRef={editingInputRef}
|
||||
onCancel={handleRenameCancel}
|
||||
onSave={handleRenameSave}
|
||||
placeholder="Knowledge question"
|
||||
/>
|
||||
) : canShowActions ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<BreadcrumbPage
|
||||
className="min-w-0 cursor-text select-none truncate"
|
||||
onDoubleClick={handleRenameStart}
|
||||
>
|
||||
{knowledgeName ?? 'Knowledge'}
|
||||
</BreadcrumbPage>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Double-click to rename</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<BreadcrumbPage className="min-w-0 truncate">
|
||||
{isNew ? 'New knowledge' : (knowledgeName ?? 'Knowledge')}
|
||||
</BreadcrumbPage>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{canShowActions && !isMobile && (
|
||||
<DetailNavigationToolbar<Knowledge>
|
||||
{...knowledgeToolbarProps}
|
||||
renderItem={(item, isCurrent) => (
|
||||
@@ -199,6 +255,49 @@ export const KnowledgeHeader = ({ isNew, knowledge, onBeforeNavigateAway, saveBu
|
||||
className="min-w-24"
|
||||
onCloseAutoFocus={handleDropdownCloseAutoFocus}
|
||||
>
|
||||
{isMobile && mobileNav.total > 0 && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
className="cursor-default hover:bg-transparent focus:bg-transparent"
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
<LibraryBig className="size-4" />
|
||||
Knowledges
|
||||
<div className="-my-1.5 -mr-2 ml-auto flex items-center">
|
||||
<Button
|
||||
aria-label="Previous"
|
||||
className="size-7 rounded-r-none border-r-0 p-0"
|
||||
disabled={!mobileNav.prevId}
|
||||
onClick={() => mobileNavGoTo(mobileNav.prevId)}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Open knowledges list"
|
||||
className="h-7 min-w-12 rounded-none border-x px-2 font-mono text-xs tabular-nums"
|
||||
disabled={mobileNav.currentIndex === -1}
|
||||
onClick={() => setIsMobileNavSheetOpen(true)}
|
||||
variant="outline"
|
||||
>
|
||||
{mobilePositionLabel}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Next"
|
||||
className="size-7 rounded-l-none border-l-0 p-0"
|
||||
disabled={!mobileNav.nextId}
|
||||
onClick={() => mobileNavGoTo(mobileNav.nextId)}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleRenameStart}>
|
||||
<Pencil className="size-3" />
|
||||
Rename
|
||||
@@ -225,6 +324,32 @@ export const KnowledgeHeader = ({ isNew, knowledge, onBeforeNavigateAway, saveBu
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{isMobile && canShowActions && (
|
||||
<DetailNavigationSheet<Knowledge>
|
||||
currentId={knowledgeToolbarProps.currentId}
|
||||
currentIndex={mobileNav.currentIndex}
|
||||
getId={knowledgeToolbarProps.getId}
|
||||
getLabel={knowledgeToolbarProps.getLabel}
|
||||
items={mobileNav.filteredItems}
|
||||
onItemSelect={mobileNavSelectItem}
|
||||
onOpenChange={setIsMobileNavSheetOpen}
|
||||
open={isMobileNavSheetOpen}
|
||||
renderItem={(item, isCurrent) => (
|
||||
<>
|
||||
<Badge
|
||||
className="shrink-0 text-[10px] whitespace-nowrap"
|
||||
variant="outline"
|
||||
>
|
||||
{item.docType}
|
||||
</Badge>
|
||||
<span className={isCurrent ? 'truncate font-medium' : 'truncate'}>{item.question}</span>
|
||||
</>
|
||||
)}
|
||||
sheetIcon={<LibraryBig className="size-4" />}
|
||||
sheetTitle="Knowledges"
|
||||
total={mobileNav.total}
|
||||
/>
|
||||
)}
|
||||
<ConfirmationDialog
|
||||
cancelText="Cancel"
|
||||
confirmText="Delete"
|
||||
|
||||
@@ -585,22 +585,22 @@ const Flows = () => {
|
||||
|
||||
const pageHeader = (
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1 shrink-0" />
|
||||
<Separator
|
||||
className="h-4"
|
||||
className="h-4 shrink-0"
|
||||
orientation="vertical"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<GitFork className="size-4" />
|
||||
<BreadcrumbPage>Flows</BreadcrumbPage>
|
||||
<Breadcrumb className="min-w-0 flex-1">
|
||||
<BreadcrumbList className="min-w-0 flex-nowrap">
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<GitFork className="size-4 shrink-0" />
|
||||
<BreadcrumbPage className="min-w-0 truncate">Flows</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 px-4">
|
||||
<div className="flex shrink-0 items-center gap-2 px-4">
|
||||
<HeaderButton
|
||||
icon={<Plus />}
|
||||
label="New Flow"
|
||||
|
||||
@@ -48,15 +48,15 @@ const NewFlow = () => {
|
||||
return (
|
||||
<>
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<SidebarTrigger className="-ml-1 shrink-0" />
|
||||
<Separator
|
||||
className="mr-2 h-4"
|
||||
className="mr-2 h-4 shrink-0"
|
||||
orientation="vertical"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>New flow</BreadcrumbPage>
|
||||
<Breadcrumb className="min-w-0 flex-1">
|
||||
<BreadcrumbList className="min-w-0 flex-nowrap">
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<BreadcrumbPage className="min-w-0 truncate">New flow</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
@@ -365,22 +365,22 @@ const Knowledges = () => {
|
||||
|
||||
const pageHeader = (
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1 shrink-0" />
|
||||
<Separator
|
||||
className="h-4"
|
||||
className="h-4 shrink-0"
|
||||
orientation="vertical"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<LibraryBig className="size-4" />
|
||||
<BreadcrumbPage>Knowledges</BreadcrumbPage>
|
||||
<Breadcrumb className="min-w-0 flex-1">
|
||||
<BreadcrumbList className="min-w-0 flex-nowrap">
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<LibraryBig className="size-4 shrink-0" />
|
||||
<BreadcrumbPage className="min-w-0 truncate">Knowledges</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 px-4">
|
||||
<div className="flex shrink-0 items-center gap-2 px-4">
|
||||
<HeaderButton
|
||||
icon={<Plus />}
|
||||
label="New Knowledge"
|
||||
|
||||
@@ -383,22 +383,22 @@ const Resources = () => {
|
||||
|
||||
const pageHeader = (
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1 shrink-0" />
|
||||
<Separator
|
||||
className="h-4"
|
||||
className="h-4 shrink-0"
|
||||
orientation="vertical"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<Folder className="size-4" />
|
||||
<BreadcrumbPage>Resources</BreadcrumbPage>
|
||||
<Breadcrumb className="min-w-0 flex-1">
|
||||
<BreadcrumbList className="min-w-0 flex-nowrap">
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<Folder className="size-4 shrink-0" />
|
||||
<BreadcrumbPage className="min-w-0 truncate">Resources</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 px-4">
|
||||
<div className="flex shrink-0 items-center gap-2 px-4">
|
||||
<HeaderButton
|
||||
disabled={upload.isUploading}
|
||||
icon={<FolderPlus />}
|
||||
|
||||
@@ -142,8 +142,8 @@ const copyToClipboard = async (text: string): Promise<boolean> => {
|
||||
const SettingsAPITokensHeader = ({ onCreateClick }: { onCreateClick: () => void }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-muted-foreground">Manage API tokens for programmatic access</p>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<p className="text-muted-foreground truncate">Manage API tokens for programmatic access</p>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<a
|
||||
className="text-primary inline-flex items-center gap-1 underline hover:no-underline"
|
||||
@@ -167,6 +167,7 @@ const SettingsAPITokensHeader = ({ onCreateClick }: { onCreateClick: () => void
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="shrink-0"
|
||||
onClick={onCreateClick}
|
||||
variant="secondary"
|
||||
>
|
||||
|
||||
@@ -89,11 +89,14 @@ const SettingsProvidersHeader = () => {
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-muted-foreground">Manage language model providers</p>
|
||||
<p className="text-muted-foreground min-w-0 flex-1 truncate">Manage language model providers</p>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary">
|
||||
<Button
|
||||
className="shrink-0"
|
||||
variant="secondary"
|
||||
>
|
||||
Create Provider
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Ellipsis,
|
||||
FileSymlink,
|
||||
FileText,
|
||||
@@ -13,12 +15,12 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
|
||||
import { DetailNavigationToolbar } from '@/components/shared/detail-navigation';
|
||||
import { DetailNavigationSheet, DetailNavigationToolbar, useNavigation } from '@/components/shared/detail-navigation';
|
||||
import { InlineEditInput, useInlineEdit } from '@/components/shared/inline-edit';
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -42,6 +44,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { useTemplateDetailNavigation } from '@/features/templates/use-template-detail-navigation';
|
||||
import { useFlowTemplateQuery } from '@/graphql/types';
|
||||
import { useBreakpoint } from '@/hooks/use-breakpoint';
|
||||
import { mergeHrefWithSearchParams } from '@/lib/url-params';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type Template, useTemplates } from '@/providers/templates-provider';
|
||||
|
||||
@@ -231,6 +234,7 @@ Action plan:
|
||||
|
||||
const Template = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { templateId } = useParams<{ templateId?: string }>();
|
||||
const { createTemplate, deleteTemplate, updateTemplate } = useTemplates();
|
||||
|
||||
@@ -242,6 +246,56 @@ const Template = () => {
|
||||
// below by `canShowActions`).
|
||||
const { toolbarProps: templateToolbarProps } = useTemplateDetailNavigation(isNew ? null : templateId);
|
||||
|
||||
// Mirror what `<DetailNavigationToolbar>` computes internally so the
|
||||
// mobile menu items share the same filtered subset as the desktop toolbar.
|
||||
const mobileNav = useNavigation<Template>({
|
||||
currentId: templateToolbarProps.currentId,
|
||||
getId: templateToolbarProps.getId,
|
||||
getSearchableText: templateToolbarProps.getSearchableText ?? templateToolbarProps.getLabel,
|
||||
items: templateToolbarProps.items,
|
||||
query: templateToolbarProps.filter,
|
||||
});
|
||||
const [isMobileNavSheetOpen, setIsMobileNavSheetOpen] = useState(false);
|
||||
|
||||
const mobileNavGoTo = useCallback(
|
||||
(id: null | string) => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = mobileNav.filteredItems.find(
|
||||
(item) => String(templateToolbarProps.getId(item)) === id,
|
||||
);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(mergeHrefWithSearchParams(templateToolbarProps.getHref(target), searchParams), {
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
[mobileNav.filteredItems, navigate, searchParams, templateToolbarProps],
|
||||
);
|
||||
|
||||
const mobileNavSelectItem = useCallback(
|
||||
(item: Template) => {
|
||||
setIsMobileNavSheetOpen(false);
|
||||
navigate(mergeHrefWithSearchParams(templateToolbarProps.getHref(item), searchParams), {
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
[navigate, searchParams, templateToolbarProps],
|
||||
);
|
||||
|
||||
const mobilePositionLabel = useMemo(
|
||||
() =>
|
||||
mobileNav.total === 0 || mobileNav.currentIndex === -1
|
||||
? `–/${mobileNav.total}`
|
||||
: `${mobileNav.currentIndex + 1}/${mobileNav.total}`,
|
||||
[mobileNav.currentIndex, mobileNav.total],
|
||||
);
|
||||
|
||||
const [isAsideOpen, setIsAsideOpen] = useState(false);
|
||||
const [expandedPresetIndex, setExpandedPresetIndex] = useState<null | number>(null);
|
||||
const [isReplaceConfirmOpen, setIsReplaceConfirmOpen] = useState(false);
|
||||
@@ -393,100 +447,164 @@ const Template = () => {
|
||||
const canShowActions = !isNew && !!templateData?.flowTemplate;
|
||||
|
||||
const pageHeader = (
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
className="mr-2 h-4"
|
||||
orientation="vertical"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem className="gap-2">
|
||||
{isEditingTitle && canShowActions ? (
|
||||
<InlineEditInput
|
||||
busy={isRenaming}
|
||||
className="w-64 max-w-full"
|
||||
defaultValue={templateName ?? ''}
|
||||
inputRef={editingInputRef}
|
||||
onCancel={handleTemplateRenameCancel}
|
||||
onSave={handleTemplateRenameSave}
|
||||
placeholder="Template title"
|
||||
/>
|
||||
) : canShowActions ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<BreadcrumbPage
|
||||
className="cursor-text select-none"
|
||||
onDoubleClick={handleTemplateRenameStart}
|
||||
>
|
||||
{templateName ?? 'Template'}
|
||||
</BreadcrumbPage>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Double-click to rename</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<BreadcrumbPage>{isNew ? 'New template' : (templateName ?? 'Template')}</BreadcrumbPage>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{canShowActions && (
|
||||
<DetailNavigationToolbar<Template>
|
||||
{...templateToolbarProps}
|
||||
sheetIcon={<FileText className="size-4" />}
|
||||
sheetTitle="Templates"
|
||||
<>
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1 shrink-0" />
|
||||
<Separator
|
||||
className="mr-2 h-4 shrink-0"
|
||||
orientation="vertical"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setIsAsideOpen((open) => !open)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
{isAsideOpen ? <PanelRightClose /> : <PanelRightOpen />}
|
||||
</Button>
|
||||
{canShowActions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Template actions"
|
||||
className="size-8 p-0"
|
||||
variant="ghost"
|
||||
>
|
||||
<Ellipsis />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="min-w-24"
|
||||
onCloseAutoFocus={handleDropdownCloseAutoFocus}
|
||||
>
|
||||
<DropdownMenuItem onClick={handleTemplateRenameStart}>
|
||||
<Pencil className="size-3" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={isDeleting}
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
<Breadcrumb className="min-w-0 flex-1">
|
||||
<BreadcrumbList className="min-w-0 flex-nowrap">
|
||||
<BreadcrumbItem className="min-w-0 gap-2">
|
||||
{isEditingTitle && canShowActions ? (
|
||||
<InlineEditInput
|
||||
busy={isRenaming}
|
||||
className="w-64 min-w-0 max-w-full flex-1"
|
||||
defaultValue={templateName ?? ''}
|
||||
inputRef={editingInputRef}
|
||||
onCancel={handleTemplateRenameCancel}
|
||||
onSave={handleTemplateRenameSave}
|
||||
placeholder="Template title"
|
||||
/>
|
||||
) : canShowActions ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<BreadcrumbPage
|
||||
className="min-w-0 cursor-text select-none truncate"
|
||||
onDoubleClick={handleTemplateRenameStart}
|
||||
>
|
||||
{templateName ?? 'Template'}
|
||||
</BreadcrumbPage>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Double-click to rename</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<BreadcrumbPage className="min-w-0 truncate">
|
||||
{isNew ? 'New template' : (templateName ?? 'Template')}
|
||||
</BreadcrumbPage>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{canShowActions && !isMobile && (
|
||||
<DetailNavigationToolbar<Template>
|
||||
{...templateToolbarProps}
|
||||
sheetIcon={<FileText className="size-4" />}
|
||||
sheetTitle="Templates"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setIsAsideOpen((open) => !open)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
{isAsideOpen ? <PanelRightClose /> : <PanelRightOpen />}
|
||||
</Button>
|
||||
{canShowActions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Template actions"
|
||||
className="size-8 p-0"
|
||||
variant="ghost"
|
||||
>
|
||||
<Ellipsis />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="min-w-24"
|
||||
onCloseAutoFocus={handleDropdownCloseAutoFocus}
|
||||
>
|
||||
{isMobile && mobileNav.total > 0 && (
|
||||
<>
|
||||
<Trash className="size-4" />
|
||||
Delete
|
||||
<DropdownMenuItem
|
||||
className="cursor-default hover:bg-transparent focus:bg-transparent"
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
Templates
|
||||
<div className="-my-1.5 -mr-2 ml-auto flex items-center">
|
||||
<Button
|
||||
aria-label="Previous"
|
||||
className="size-7 rounded-r-none border-r-0 p-0"
|
||||
disabled={!mobileNav.prevId}
|
||||
onClick={() => mobileNavGoTo(mobileNav.prevId)}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Open templates list"
|
||||
className="h-7 min-w-12 rounded-none border-x px-2 font-mono text-xs tabular-nums"
|
||||
disabled={mobileNav.currentIndex === -1}
|
||||
onClick={() => setIsMobileNavSheetOpen(true)}
|
||||
variant="outline"
|
||||
>
|
||||
{mobilePositionLabel}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Next"
|
||||
className="size-7 rounded-l-none border-l-0 p-0"
|
||||
disabled={!mobileNav.nextId}
|
||||
onClick={() => mobileNavGoTo(mobileNav.nextId)}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<DropdownMenuItem onClick={handleTemplateRenameStart}>
|
||||
<Pencil className="size-3" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={isDeleting}
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash className="size-4" />
|
||||
Delete
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{isMobile && canShowActions && (
|
||||
<DetailNavigationSheet<Template>
|
||||
currentId={templateToolbarProps.currentId}
|
||||
currentIndex={mobileNav.currentIndex}
|
||||
getId={templateToolbarProps.getId}
|
||||
getLabel={templateToolbarProps.getLabel}
|
||||
items={mobileNav.filteredItems}
|
||||
onItemSelect={mobileNavSelectItem}
|
||||
onOpenChange={setIsMobileNavSheetOpen}
|
||||
open={isMobileNavSheetOpen}
|
||||
sheetIcon={<FileText className="size-4" />}
|
||||
sheetTitle="Templates"
|
||||
total={mobileNav.total}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const asideContent = useMemo(
|
||||
|
||||
@@ -137,7 +137,7 @@ const Templates = () => {
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="font-medium">{title}</div>;
|
||||
return <div className="max-w-[380px] truncate font-medium">{title}</div>;
|
||||
},
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
@@ -242,22 +242,22 @@ const Templates = () => {
|
||||
|
||||
const pageHeader = (
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1 shrink-0" />
|
||||
<Separator
|
||||
className="h-4"
|
||||
className="h-4 shrink-0"
|
||||
orientation="vertical"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<FileText className="size-4" />
|
||||
<BreadcrumbPage>Templates</BreadcrumbPage>
|
||||
<Breadcrumb className="min-w-0 flex-1">
|
||||
<BreadcrumbList className="min-w-0 flex-nowrap">
|
||||
<BreadcrumbItem className="min-w-0">
|
||||
<FileText className="size-4 shrink-0" />
|
||||
<BreadcrumbPage className="min-w-0 truncate">Templates</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 px-4">
|
||||
<div className="flex shrink-0 items-center gap-2 px-4">
|
||||
<HeaderButton
|
||||
icon={<Plus />}
|
||||
label="New Template"
|
||||
|
||||
Reference in New Issue
Block a user