mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
fix: improve the ui
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { CapacityBadges } from "@/shared/components";
|
||||
import ActionMenu from "@/shared/components/ActionMenu";
|
||||
|
||||
export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onRemove, onTest, isTesting, onDisable, caps, thinkingSuffix }) {
|
||||
const displayModel = thinkingSuffix ? `${fullModel}(${thinkingSuffix})` : fullModel;
|
||||
@@ -16,8 +17,30 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test
|
||||
: undefined;
|
||||
const deleteModel = onRemove || (isCustom ? onDeleteAlias : onDisable);
|
||||
const deletesPermanently = !!deleteModel;
|
||||
|
||||
const actionButtonClass = "inline-flex size-7 items-center justify-center rounded-md text-text-muted transition-[background-color,color,transform,box-shadow] duration-150 hover:bg-background hover:text-text-main focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-95 disabled:cursor-not-allowed disabled:opacity-50";
|
||||
const actionItems = [
|
||||
...(onTest ? [{
|
||||
id: "test",
|
||||
icon: isTesting ? "progress_activity" : "science",
|
||||
label: isTesting ? "Testing model…" : "Test model",
|
||||
onSelect: onTest,
|
||||
disabled: isTesting,
|
||||
spinning: isTesting,
|
||||
}] : []),
|
||||
{
|
||||
id: "copy",
|
||||
icon: copied === `model-${model.id}` ? "check" : "content_copy",
|
||||
label: copied === `model-${model.id}` ? "Model ID copied" : "Copy model ID",
|
||||
onSelect: () => onCopy(displayModel, `model-${model.id}`),
|
||||
},
|
||||
...(deleteModel ? [{
|
||||
id: "delete",
|
||||
icon: "delete",
|
||||
label: deletesPermanently ? "Permanently delete model" : "Hide model",
|
||||
onSelect: deleteModel,
|
||||
danger: true,
|
||||
dividerBefore: true,
|
||||
}] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={`group min-w-0 max-w-full rounded-lg border px-3 py-2 transition-colors ${borderColor} hover:bg-sidebar/50 focus-within:border-primary/50`}>
|
||||
@@ -35,44 +58,8 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test
|
||||
<CapacityBadges caps={caps} colorOverride="text-text-muted/70" size={12} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-0.5 rounded-lg border border-border/80 bg-sidebar/60 p-1 shadow-[0_1px_0_rgb(255_255_255/0.03)]">
|
||||
{onTest && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTest}
|
||||
disabled={isTesting}
|
||||
className={actionButtonClass}
|
||||
title={isTesting ? "Testing model" : "Test model"}
|
||||
aria-label={isTesting ? `Testing ${fullModel}` : `Test ${fullModel}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[17px]" style={isTesting ? { animation: "spin 1s linear infinite" } : undefined}>
|
||||
{isTesting ? "progress_activity" : "science"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(displayModel, `model-${model.id}`)}
|
||||
className={actionButtonClass}
|
||||
title={copied === `model-${model.id}` ? "Copied" : "Copy model ID"}
|
||||
aria-label={copied === `model-${model.id}` ? `${fullModel} copied` : `Copy ${fullModel}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[17px]">
|
||||
{copied === `model-${model.id}` ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
{deleteModel && <span className="mx-0.5 h-4 w-px bg-border" aria-hidden="true" />}
|
||||
{deleteModel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={deleteModel}
|
||||
className="inline-flex size-7 items-center justify-center rounded-md text-text-muted transition-[background-color,color,transform,box-shadow] duration-150 hover:bg-red-500/12 hover:text-red-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 active:scale-95"
|
||||
title={deletesPermanently ? "Permanently remove model from catalogs and saved configurations" : "Hide model from the dashboard catalog"}
|
||||
aria-label={deletesPermanently ? `Permanently delete ${fullModel}` : `Hide ${fullModel}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[17px]">delete</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<ActionMenu ariaLabel={`Actions for ${fullModel}`} items={actionItems} title={`Actions for ${fullModel}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const MENU_WIDTH = 192;
|
||||
const VIEWPORT_GUTTER = 12;
|
||||
|
||||
/**
|
||||
* Compact, reusable overflow menu for contextual row actions.
|
||||
*/
|
||||
export default function ActionMenu({ ariaLabel, items, title = "More options" }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [opensRight, setOpensRight] = useState(true);
|
||||
const menuRef = useRef(null);
|
||||
const triggerRef = useRef(null);
|
||||
const menuId = useId();
|
||||
|
||||
const updateHorizontalPlacement = () => {
|
||||
const triggerRect = triggerRef.current?.getBoundingClientRect();
|
||||
if (!triggerRect) return;
|
||||
|
||||
setOpensRight(triggerRect.left + MENU_WIDTH <= window.innerWidth - VIEWPORT_GUTTER);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
|
||||
const handlePointerDown = (event) => {
|
||||
if (!menuRef.current?.contains(event.target)) setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
menuRef.current?.querySelector("button")?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("resize", updateHorizontalPlacement);
|
||||
updateHorizontalPlacement();
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("resize", updateHorizontalPlacement);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSelect = (item) => {
|
||||
if (item.disabled) return;
|
||||
setIsOpen(false);
|
||||
item.onSelect();
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
ref={triggerRef}
|
||||
onClick={() => {
|
||||
updateHorizontalPlacement();
|
||||
setIsOpen((open) => !open);
|
||||
}}
|
||||
className="inline-flex size-8 items-center justify-center rounded-md border border-border/80 bg-sidebar/60 text-text-muted shadow-[0_1px_0_rgb(255_255_255/0.03)] transition-[background-color,color,transform,box-shadow] duration-150 hover:bg-background hover:text-text-main focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 active:scale-95"
|
||||
title={title}
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={isOpen ? menuId : undefined}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[19px]">more_vert</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
id={menuId}
|
||||
role="menu"
|
||||
aria-label={ariaLabel}
|
||||
className={`absolute top-full z-30 mt-1.5 w-48 min-w-0 max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-lg border border-border bg-surface py-1 shadow-soft ${
|
||||
opensRight ? "left-0" : "right-0"
|
||||
}`}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
onClick={() => handleSelect(item)}
|
||||
className={`flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
item.dividerBefore ? "mt-1 border-t border-border pt-3" : ""
|
||||
} ${
|
||||
item.danger
|
||||
? "text-red-400 hover:bg-red-500/12"
|
||||
: "text-text-main hover:bg-sidebar"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-[17px] text-text-muted"
|
||||
style={item.spinning ? { animation: "spin 1s linear infinite" } : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ActionMenu.propTypes = {
|
||||
ariaLabel: PropTypes.string.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
icon: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
dividerBefore: PropTypes.bool,
|
||||
danger: PropTypes.bool,
|
||||
spinning: PropTypes.bool,
|
||||
})).isRequired,
|
||||
title: PropTypes.string,
|
||||
};
|
||||
@@ -21,6 +21,7 @@ export { default as McpMarketplaceModal } from "./McpMarketplaceModal";
|
||||
export { default as UsageStats } from "./UsageStats";
|
||||
export { default as LanguageSwitcher } from "./LanguageSwitcher";
|
||||
export { default as HeaderMenu } from "./HeaderMenu";
|
||||
export { default as ActionMenu } from "./ActionMenu";
|
||||
export { default as ChangelogModal } from "./ChangelogModal";
|
||||
export { default as RequestLogger } from "./RequestLogger";
|
||||
export { default as KiroAuthModal } from "./KiroAuthModal";
|
||||
|
||||
Reference in New Issue
Block a user