mirror of
https://github.com/willchen96/mike.git
synced 2026-06-24 21:38:06 +02:00
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:
parent
444d1d38e4
commit
1fa0554ea5
49 changed files with 3623 additions and 1587 deletions
|
|
@ -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"}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
69
frontend/src/app/components/shared/HeaderActionsMenu.tsx
Normal file
69
frontend/src/app/components/shared/HeaderActionsMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue