Refactor ProjectPageParts and ProjectPageHeader components for improved loading states and skeleton UI. Update Modal and PageHeader components to support loading states. Enhance RenameableTitle for better caret positioning. Adjust DisplayWorkflowModal to utilize the new Modal component structure. Update WorkflowList to include loading indicators and improve sticky header behavior.

This commit is contained in:
willchen96 2026-06-11 21:50:58 +08:00
parent 444d1d38e4
commit 1fa0554ea5
49 changed files with 3623 additions and 1587 deletions

View file

@ -157,7 +157,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
onClick={onToggle}
className={cn(
"h-9 w-9 p-2.5 items-center flex transition-colors",
"rounded-xl hover:bg-gray-100",
"rounded-md hover:bg-gray-100",
)}
title={isOpen ? "Close sidebar" : "Open sidebar"}
>

View file

@ -536,8 +536,11 @@ export function DocView({
return (
<DocxView
documentId={doc.document_id}
versionId={doc.version_id ?? null}
quotes={quotes}
quoteFocusKey={quoteFocusKey}
rounded={rounded}
bordered={bordered}
/>
);
}

View file

@ -0,0 +1,69 @@
"use client";
import { MoreHorizontal, type LucideIcon } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
export type HeaderActionsMenuItem = {
label: string;
icon?: LucideIcon;
onSelect: () => void;
disabled?: boolean;
variant?: "default" | "danger";
};
export function HeaderActionsMenu({
items,
title = "Actions",
}: {
items: HeaderActionsMenuItem[];
title?: string;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-full text-gray-600 transition-all",
"hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-300",
)}
aria-label={title}
title={title}
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-[160] w-48 bg-white">
{items.map((item) => {
const Icon = item.icon;
return (
<DropdownMenuItem
key={item.label}
disabled={item.disabled}
variant={
item.variant === "danger"
? "destructive"
: "default"
}
onSelect={item.onSelect}
className={cn(
"cursor-pointer text-xs",
item.variant === "danger" &&
"text-red-600 focus:bg-red-50 focus:text-red-700",
)}
>
{Icon && <Icon className="h-3.5 w-3.5" />}
{item.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -117,13 +117,13 @@ export function Modal({
</button>
</div>
)}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pt-1 pb-2">
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
{children}
</div>
{hasFooter && (
<div
className={cn(
"flex items-center gap-3 p-4",
"flex items-center gap-3 p-3",
secondaryAction || footerInfo
? "justify-between"
: "justify-end",
@ -186,7 +186,7 @@ function ModalActionButton({
"rounded-full border border-gray-700/40 bg-gray-950/88 text-white shadow-[0_3px_9px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl hover:bg-gray-900/90 active:scale-[0.98] disabled:active:scale-100",
variant === "secondary" && "text-gray-600 hover:text-gray-950",
fallbackVariant === "secondary" &&
"rounded-full border border-gray-200/80 bg-gray-100/70 shadow-[0_1px_4px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.78),inset_0_-3px_8px_rgba(148,163,184,0.14)] backdrop-blur-xl hover:bg-gray-100",
"rounded-full border border-blue-500/35 bg-blue-600/90 text-white shadow-[0_3px_9px_rgba(37,99,235,0.16),inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-4px_9px_rgba(29,78,216,0.2)] backdrop-blur-xl hover:bg-blue-600 hover:text-white active:scale-[0.98] disabled:active:scale-100",
variant === "danger" &&
"rounded-full border border-red-700/35 bg-red-600/90 text-white shadow-[0_3px_9px_rgba(127,29,29,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(127,29,29,0.18)] backdrop-blur-xl hover:bg-red-600 active:scale-[0.98] disabled:active:scale-100",
)}

View file

@ -16,6 +16,7 @@ export interface PageHeaderBreadcrumb {
label?: ReactNode;
suffix?: ReactNode;
onClick?: () => void;
cursor?: "text";
loading?: boolean;
skeletonClassName?: string;
title?: string;
@ -30,7 +31,6 @@ type PageHeaderButtonAction = {
title?: string;
variant?: "default" | "danger";
iconOnly?: boolean;
className?: string;
tooltip?: ReactNode;
};
@ -70,18 +70,28 @@ export type PageHeaderAction =
| PageHeaderCustomAction
| ReactNode;
type PageHeaderActionGap = "xs" | "sm" | "md" | "lg";
type PageHeaderActionGroup =
| PageHeaderAction[]
| {
actions: PageHeaderAction[];
gap?: PageHeaderActionGap;
};
interface PageHeaderProps {
children?: ReactNode;
actions?: PageHeaderAction[];
actionGroups?: PageHeaderAction[][];
actionGroups?: PageHeaderActionGroup[];
align?: "center" | "start";
shrink?: boolean;
className?: string;
actionGap?: "sm" | "md" | "lg";
actionGap?: PageHeaderActionGap;
breadcrumbs?: PageHeaderBreadcrumb[];
loading?: boolean;
}
const actionGapClassName = {
xs: "gap-1",
sm: "gap-2.5",
md: "gap-2.5",
lg: "gap-2.5",
@ -96,18 +106,24 @@ export function PageHeader({
className,
actionGap = "sm",
breadcrumbs,
loading = false,
}: PageHeaderProps) {
const headerContent = breadcrumbs?.length ? (
<PageHeaderBreadcrumbs items={breadcrumbs} />
) : (
children
);
const actionsDisabled =
loading || !!breadcrumbs?.some((item) => item.loading);
const actionItems = actions?.filter(Boolean) ?? [];
const groupedActionItems =
const groupedActionItems = (
actionGroups
?.map((group) => group.filter(Boolean))
.filter((group) => group.length > 0) ??
(actionItems.length > 0 ? [actionItems] : []);
?.map((group) => normalizeActionGroup(group, actionGap))
.filter((group) => group.actions.length > 0) ??
(actionItems.length > 0
? [{ actions: actionItems, gap: actionGap }]
: [])
);
return (
<div
@ -128,13 +144,16 @@ export function PageHeader({
key={groupIndex}
className={cn(
"flex shrink-0 items-center",
actionGapClassName[actionGap],
actionGapClassName[group.gap],
"rounded-full border border-white/70 bg-white px-1 py-1 shadow-[0_-1px_3px_rgba(15,23,42,0.03),0_2px_7px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.82),inset_0_-3px_7px_rgba(255,255,255,0.13)] backdrop-blur-2xl",
)}
>
{group.map((action, index) => (
{group.actions.map((action, index) => (
<Fragment key={index}>
<PageHeaderActionRenderer action={action} />
<PageHeaderActionRenderer
action={action}
disabled={actionsDisabled}
/>
</Fragment>
))}
</div>
@ -145,21 +164,80 @@ export function PageHeader({
);
}
function PageHeaderActionRenderer({ action }: { action: PageHeaderAction }) {
if (!isPageHeaderActionObject(action)) return <>{action}</>;
function normalizeActionGroup(
group: PageHeaderActionGroup,
fallbackGap: PageHeaderActionGap,
) {
if (Array.isArray(group)) {
return {
actions: group.filter(Boolean),
gap: fallbackGap,
};
}
return {
actions: group.actions.filter(Boolean),
gap: group.gap ?? fallbackGap,
};
}
function PageHeaderActionRenderer({
action,
disabled,
}: {
action: PageHeaderAction;
disabled: boolean;
}) {
if (!isPageHeaderActionObject(action)) {
return disabled ? (
<span className="inline-flex h-7 items-center opacity-40 pointer-events-none">
{action}
</span>
) : (
<>{action}</>
);
}
switch (action.type) {
case "search":
return <PageHeaderSearchActionControl action={action} />;
return (
<PageHeaderSearchActionControl
action={action}
disabled={disabled}
/>
);
case "delete":
return <PageHeaderDeleteActionControl action={action} />;
return (
<PageHeaderDeleteActionControl
action={action}
disabled={disabled}
/>
);
case "new":
return <PageHeaderNewActionControl action={action} />;
return (
<PageHeaderNewActionControl
action={action}
disabled={disabled}
/>
);
case "custom":
return <>{action.render}</>;
return (
<span
className={cn(
"inline-flex h-7 items-center",
disabled && "pointer-events-none opacity-40",
)}
>
{action.render}
</span>
);
case "button":
default:
return <PageHeaderButtonActionControl action={action} />;
return (
<PageHeaderButtonActionControl
action={action}
disabled={disabled}
/>
);
}
}
@ -171,20 +249,21 @@ function isPageHeaderActionObject(
function PageHeaderButtonActionControl({
action,
disabled,
}: {
action: PageHeaderButtonAction;
disabled: boolean;
}) {
const iconOnly = action.iconOnly ?? !action.label;
return (
<div className={action.tooltip ? "relative group" : undefined}>
<PageHeaderActionButton
onClick={action.onClick}
disabled={action.disabled}
disabled={disabled || action.disabled}
title={action.title}
aria-label={action.title}
variant={action.variant}
iconOnly={iconOnly}
className={action.className}
>
{action.icon}
{action.label}
@ -200,14 +279,16 @@ function PageHeaderButtonActionControl({
function PageHeaderNewActionControl({
action,
disabled,
}: {
action: PageHeaderNewAction;
disabled: boolean;
}) {
const title = action.title ?? "New";
return (
<PageHeaderActionButton
onClick={action.onClick}
disabled={action.disabled || action.loading}
disabled={disabled || action.disabled || action.loading}
title={title}
aria-label={title}
iconOnly
@ -223,14 +304,16 @@ function PageHeaderNewActionControl({
function PageHeaderDeleteActionControl({
action,
disabled,
}: {
action: PageHeaderDeleteAction;
disabled: boolean;
}) {
const title = action.title ?? "Delete";
return (
<PageHeaderActionButton
onClick={action.onClick}
disabled={action.disabled || action.loading}
disabled={disabled || action.disabled || action.loading}
title={title}
aria-label={title}
iconOnly
@ -247,8 +330,10 @@ function PageHeaderDeleteActionControl({
function PageHeaderSearchActionControl({
action,
disabled,
}: {
action: PageHeaderSearchAction;
disabled: boolean;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@ -280,6 +365,7 @@ function PageHeaderSearchActionControl({
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
<input
autoFocus
disabled={disabled}
type="text"
placeholder={placeholder}
value={action.value}
@ -290,6 +376,7 @@ function PageHeaderSearchActionControl({
) : (
<PageHeaderActionButton
onClick={() => setOpen(true)}
disabled={disabled}
iconOnly
title={placeholder}
aria-label={placeholder}
@ -301,7 +388,10 @@ function PageHeaderSearchActionControl({
);
}
type PageHeaderActionButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
type PageHeaderActionButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"className"
> & {
variant?: "default" | "danger";
iconOnly?: boolean;
};
@ -333,7 +423,6 @@ function pageHeaderActionControlClassName({
function PageHeaderActionButton({
children,
className,
variant = "default",
iconOnly = false,
disabled,
@ -346,7 +435,6 @@ function PageHeaderActionButton({
variant,
iconOnly,
disabled,
className,
})}
{...props}
>
@ -411,13 +499,21 @@ function BreadcrumbItem({
/>
) : (
<>
<span className="truncate">{item.label}</span>
<span
className={cn(
"truncate",
item.cursor === "text" && "cursor-text",
)}
>
{item.label}
</span>
{showSuffix && item.suffix}
</>
);
const className = cn(
"min-w-0 truncate transition-colors",
item.cursor === "text" && "cursor-text",
current
? "text-gray-900"
: item.onClick

View file

@ -8,6 +8,14 @@ interface Props {
suffix?: React.ReactNode;
}
type CaretDocument = Document & {
caretPositionFromPoint?: (
x: number,
y: number,
) => { offset: number } | null;
caretRangeFromPoint?: (x: number, y: number) => Range | null;
};
export function RenameableTitle({ value, onCommit, suffix }: Props) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
@ -15,10 +23,14 @@ export function RenameableTitle({ value, onCommit, suffix }: Props) {
const escaped = useRef(false);
function startEditing(e: React.MouseEvent) {
const doc = document as any;
const doc = document as CaretDocument;
const caret = doc.caretPositionFromPoint?.(e.clientX, e.clientY);
const range = !caret && doc.caretRangeFromPoint?.(e.clientX, e.clientY);
caretPos.current = caret ? caret.offset : range ? range.startOffset : null;
caretPos.current = caret
? caret.offset
: range
? range.startOffset
: null;
escaped.current = false;
setDraft(value);
setEditing(true);
@ -61,7 +73,7 @@ export function RenameableTitle({ value, onCommit, suffix }: Props) {
return (
<span
className="text-gray-900 cursor-text hover:text-gray-600 transition-colors"
className="inline-block cursor-text text-gray-900 transition-colors hover:text-gray-600"
onClick={startEditing}
>
{value}

View file

@ -30,6 +30,7 @@ interface Props {
onUploadNewVersion?: () => void;
onNewSubfolder?: () => void;
deleting?: boolean;
deleteDisabled?: boolean;
onRename?: () => void;
onUpdateCmNumber?: () => void;
newSubfolderLabel?: string;
@ -47,6 +48,7 @@ export function RowActionMenuItems({
onUploadNewVersion,
onNewSubfolder,
deleting,
deleteDisabled = false,
onRename,
onUpdateCmNumber,
newSubfolderLabel = "New subfolder",
@ -141,7 +143,12 @@ export function RowActionMenuItems({
<button
onClick={() => { onClose(); onDelete(); }}
disabled={deleting}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-red-500 hover:bg-red-50 transition-colors disabled:opacity-40"
aria-disabled={deleteDisabled}
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-red-500 transition-colors disabled:opacity-40 ${
deleteDisabled
? "cursor-not-allowed opacity-40 hover:bg-transparent"
: "hover:bg-red-50"
}`}
>
<Trash2 className="h-3.5 w-3.5" />
{deleteLabel}

View file

@ -52,7 +52,7 @@ export function UploadNewVersionModal({ open, onClose, doc, onSubmit }: Props) {
if (!open || !doc) return null;
const accept = doc.file_type === "pdf" ? ".pdf" : ".docx,.doc";
const accept = ".pdf,.docx,.doc";
function handleFilePick(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0] ?? null;

View file

@ -32,6 +32,8 @@ export interface Document {
project_id: string | null;
folder_id?: string | null;
filename: string;
owner_email?: string | null;
owner_display_name?: string | null;
file_type: string | null; // pdf | docx | doc
storage_path: string | null;
pdf_storage_path: string | null;