mirror of https://github.com/usememos/memos
feat: add dedicated shortcuts page (#5942)
parent
02096836c3
commit
1df6479443
@ -1,158 +0,0 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { shortcutServiceClient } from "@/connect";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { Shortcut, ShortcutSchema } from "@/types/proto/api/v1/shortcut_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
shortcut?: Shortcut;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, onSuccess }: Props) {
|
||||
const t = useTranslate();
|
||||
const user = useCurrentUser();
|
||||
const { refetchSettings } = useAuth();
|
||||
const [shortcut, setShortcut] = useState<Shortcut>(
|
||||
create(ShortcutSchema, {
|
||||
name: initialShortcut?.name || "",
|
||||
title: initialShortcut?.title || "",
|
||||
filter: initialShortcut?.filter || "",
|
||||
}),
|
||||
);
|
||||
const requestState = useLoading(false);
|
||||
const isCreating = shortcut.name === "";
|
||||
|
||||
useEffect(() => {
|
||||
setShortcut(
|
||||
create(ShortcutSchema, {
|
||||
name: initialShortcut?.name || "",
|
||||
title: initialShortcut?.title || "",
|
||||
filter: initialShortcut?.filter || "",
|
||||
}),
|
||||
);
|
||||
}, [initialShortcut]);
|
||||
|
||||
const onShortcutTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
title: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const onShortcutFilterChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setPartialState({
|
||||
filter: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const setPartialState = (partialState: Partial<Shortcut>) => {
|
||||
setShortcut({
|
||||
...shortcut,
|
||||
...partialState,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!shortcut.title || !shortcut.filter) {
|
||||
toast.error("Title and filter cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
requestState.setLoading();
|
||||
if (isCreating) {
|
||||
await shortcutServiceClient.createShortcut({
|
||||
parent: user?.name,
|
||||
shortcut: {
|
||||
name: "",
|
||||
title: shortcut.title,
|
||||
filter: shortcut.filter,
|
||||
},
|
||||
});
|
||||
toast.success("Create shortcut successfully");
|
||||
} else {
|
||||
await shortcutServiceClient.updateShortcut({
|
||||
shortcut: {
|
||||
...shortcut,
|
||||
name: initialShortcut!.name,
|
||||
},
|
||||
updateMask: create(FieldMaskSchema, { paths: ["title", "filter"] }),
|
||||
});
|
||||
toast.success("Update shortcut successfully");
|
||||
}
|
||||
await refetchSettings();
|
||||
requestState.setFinish();
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
} catch (error: unknown) {
|
||||
await handleError(error, toast.error, {
|
||||
context: isCreating ? "Create shortcut" : "Update shortcut",
|
||||
onError: () => requestState.setError(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`${isCreating ? t("common.create") : t("common.edit")} ${t("common.shortcuts")}`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">{t("common.title")}</Label>
|
||||
<Input id="title" type="text" placeholder="" value={shortcut.title} onChange={onShortcutTitleChange} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="filter">{t("common.filter")}</Label>
|
||||
<Textarea
|
||||
id="filter"
|
||||
rows={3}
|
||||
placeholder={t("common.shortcut-filter")}
|
||||
value={shortcut.filter}
|
||||
onChange={onShortcutFilterChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="mb-2">{t("common.learn-more")}:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>
|
||||
<a
|
||||
className="text-primary hover:underline"
|
||||
href="https://www.usememos.com/docs/usage/shortcuts"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Docs - Shortcuts
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateShortcutDialog;
|
||||
@ -0,0 +1,504 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
ClipboardCheckIcon,
|
||||
Clock3Icon,
|
||||
ExternalLinkIcon,
|
||||
FilterIcon,
|
||||
MoreVerticalIcon,
|
||||
PencilIcon,
|
||||
PinIcon,
|
||||
PlusIcon,
|
||||
SaveIcon,
|
||||
SearchIcon,
|
||||
ShieldIcon,
|
||||
TagsIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { shortcutServiceClient } from "@/connect";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Shortcut, ShortcutSchema } from "@/types/proto/api/v1/shortcut_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
const shortcutExamples = [
|
||||
{
|
||||
title: "Pinned",
|
||||
filter: "pinned",
|
||||
description: "Only pinned memos.",
|
||||
icon: PinIcon,
|
||||
},
|
||||
{
|
||||
title: "Recent notes",
|
||||
filter: "created_ts >= now() - 60 * 60",
|
||||
description: "Memos created in the last hour.",
|
||||
icon: Clock3Icon,
|
||||
},
|
||||
{
|
||||
title: "Public memos",
|
||||
filter: 'visibility == "PUBLIC"',
|
||||
description: "Only public memos.",
|
||||
icon: ShieldIcon,
|
||||
},
|
||||
{
|
||||
title: "Project tags",
|
||||
filter: 'tag in ["work", "personal"]',
|
||||
description: "Match one or more exact tags.",
|
||||
icon: TagsIcon,
|
||||
},
|
||||
{
|
||||
title: "Archive tree",
|
||||
filter: 'tags.exists(t, t.startsWith("archive"))',
|
||||
description: "Match hierarchical tags by prefix.",
|
||||
icon: TagsIcon,
|
||||
},
|
||||
{
|
||||
title: "Open tasks",
|
||||
filter: "has_task_list && has_incomplete_tasks",
|
||||
description: "Memos with unfinished tasks.",
|
||||
icon: ClipboardCheckIcon,
|
||||
},
|
||||
{
|
||||
title: "Links or code",
|
||||
filter: "has_link || has_code",
|
||||
description: "Memos containing links or code blocks.",
|
||||
icon: FilterIcon,
|
||||
},
|
||||
{
|
||||
title: "Content search",
|
||||
filter: 'content.contains("TODO")',
|
||||
description: "Search text inside memo content.",
|
||||
icon: SearchIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const filterFields = [
|
||||
"content.contains(...)",
|
||||
"visibility",
|
||||
"pinned",
|
||||
"tag in [...]",
|
||||
"tags.exists(...)",
|
||||
"has_task_list",
|
||||
"has_incomplete_tasks",
|
||||
"has_link",
|
||||
"has_code",
|
||||
"created_ts",
|
||||
"updated_ts",
|
||||
];
|
||||
|
||||
const getShortcutId = (name: string): string => {
|
||||
const parts = name.split("/");
|
||||
return parts.length === 4 ? parts[3] : name;
|
||||
};
|
||||
|
||||
const createEmptyShortcut = () =>
|
||||
create(ShortcutSchema, {
|
||||
name: "",
|
||||
title: "",
|
||||
filter: "",
|
||||
});
|
||||
|
||||
interface ShortcutGuideProps {
|
||||
onUseExample: (example: (typeof shortcutExamples)[number]) => void;
|
||||
}
|
||||
|
||||
interface ShortcutsRouteState {
|
||||
openCreate?: boolean;
|
||||
shortcut?: Shortcut;
|
||||
}
|
||||
|
||||
const ShortcutGuide = ({ onUseExample }: ShortcutGuideProps) => {
|
||||
return (
|
||||
<aside className="flex flex-col gap-5">
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<h2 className="text-sm font-semibold text-foreground">Expression examples</h2>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{shortcutExamples.map((example) => {
|
||||
const Icon = example.icon;
|
||||
return (
|
||||
<button
|
||||
key={example.filter}
|
||||
type="button"
|
||||
className="group rounded-md border border-transparent p-2 text-left transition-colors hover:border-border hover:bg-muted/50"
|
||||
onClick={() => onUseExample(example)}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<Icon className="h-4 w-4 text-muted-foreground group-hover:text-primary" />
|
||||
{example.title}
|
||||
</span>
|
||||
<span className="mt-1 block font-mono text-xs leading-5 text-muted-foreground">{example.filter}</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-muted-foreground">{example.description}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<h2 className="text-sm font-semibold text-foreground">Supported fields</h2>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{filterFields.map((field) => (
|
||||
<Badge key={field} variant="secondary" className="font-mono">
|
||||
{field}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
const Shortcuts = () => {
|
||||
const t = useTranslate();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const user = useCurrentUser();
|
||||
const { shortcuts, refetchSettings } = useAuth();
|
||||
const [isCreateFormOpen, setIsCreateFormOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<Shortcut>(createEmptyShortcut());
|
||||
const [deleteTarget, setDeleteTarget] = useState<Shortcut | undefined>();
|
||||
const createState = useLoading(false);
|
||||
const validateState = useLoading(false);
|
||||
const updateState = useLoading(false);
|
||||
const isEditing = draft.name !== "";
|
||||
const isSaving = createState.isLoading || updateState.isLoading;
|
||||
|
||||
useEffect(() => {
|
||||
refetchSettings();
|
||||
}, [refetchSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
const state = location.state as ShortcutsRouteState | null;
|
||||
if (!state) return;
|
||||
|
||||
if (state.shortcut) {
|
||||
setDraft(
|
||||
create(ShortcutSchema, {
|
||||
name: state.shortcut.name,
|
||||
title: state.shortcut.title,
|
||||
filter: state.shortcut.filter,
|
||||
}),
|
||||
);
|
||||
setIsCreateFormOpen(true);
|
||||
} else if (state.openCreate) {
|
||||
setDraft(createEmptyShortcut());
|
||||
setIsCreateFormOpen(true);
|
||||
}
|
||||
|
||||
navigate(location.pathname, { replace: true, state: null });
|
||||
}, [location.key, location.pathname, location.state, navigate]);
|
||||
|
||||
const setDraftState = (state: Partial<Shortcut>) => {
|
||||
setDraft((current) => ({ ...current, ...state }));
|
||||
};
|
||||
|
||||
const handleUseExample = (example: (typeof shortcutExamples)[number]) => {
|
||||
setDraft(
|
||||
create(ShortcutSchema, {
|
||||
name: draft.name,
|
||||
title: draft.title || example.title,
|
||||
filter: example.filter,
|
||||
}),
|
||||
);
|
||||
setIsCreateFormOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenCreateForm = () => {
|
||||
setDraft(createEmptyShortcut());
|
||||
setIsCreateFormOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setDraft(createEmptyShortcut());
|
||||
setIsCreateFormOpen(false);
|
||||
};
|
||||
|
||||
const handleEditShortcut = (shortcut: Shortcut) => {
|
||||
setDraft(
|
||||
create(ShortcutSchema, {
|
||||
name: shortcut.name,
|
||||
title: shortcut.title,
|
||||
filter: shortcut.filter,
|
||||
}),
|
||||
);
|
||||
setIsCreateFormOpen(true);
|
||||
};
|
||||
|
||||
const validateDraft = async () => {
|
||||
if (!draft.title || !draft.filter) {
|
||||
toast.error("Title and filter cannot be empty");
|
||||
return false;
|
||||
}
|
||||
if (!user?.name) {
|
||||
toast.error("No current user");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
validateState.setLoading();
|
||||
await shortcutServiceClient.createShortcut({
|
||||
parent: user.name,
|
||||
shortcut: { name: "", title: draft.title, filter: draft.filter },
|
||||
validateOnly: true,
|
||||
});
|
||||
validateState.setFinish();
|
||||
toast.success("Filter expression looks valid");
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
await handleError(error, toast.error, {
|
||||
context: "Validate shortcut filter",
|
||||
onError: () => validateState.setError(),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateShortcut = async () => {
|
||||
if (!draft.title || !draft.filter) {
|
||||
toast.error("Title and filter cannot be empty");
|
||||
return;
|
||||
}
|
||||
if (!user?.name) {
|
||||
toast.error("No current user");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
createState.setLoading();
|
||||
await shortcutServiceClient.createShortcut({
|
||||
parent: user.name,
|
||||
shortcut: { name: "", title: draft.title, filter: draft.filter },
|
||||
});
|
||||
await refetchSettings();
|
||||
createState.setFinish();
|
||||
setDraft(createEmptyShortcut());
|
||||
setIsCreateFormOpen(false);
|
||||
toast.success("Create shortcut successfully");
|
||||
} catch (error: unknown) {
|
||||
await handleError(error, toast.error, {
|
||||
context: "Create shortcut",
|
||||
onError: () => createState.setError(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateShortcut = async () => {
|
||||
if (!draft.title || !draft.filter) {
|
||||
toast.error("Title and filter cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
updateState.setLoading();
|
||||
await shortcutServiceClient.updateShortcut({
|
||||
shortcut: draft,
|
||||
updateMask: create(FieldMaskSchema, { paths: ["title", "filter"] }),
|
||||
});
|
||||
await refetchSettings();
|
||||
updateState.setFinish();
|
||||
setDraft(createEmptyShortcut());
|
||||
setIsCreateFormOpen(false);
|
||||
toast.success("Update shortcut successfully");
|
||||
} catch (error: unknown) {
|
||||
await handleError(error, toast.error, {
|
||||
context: "Update shortcut",
|
||||
onError: () => updateState.setError(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveShortcut = async () => {
|
||||
if (isEditing) {
|
||||
await handleUpdateShortcut();
|
||||
return;
|
||||
}
|
||||
|
||||
await handleCreateShortcut();
|
||||
};
|
||||
|
||||
const confirmDeleteShortcut = async () => {
|
||||
if (!deleteTarget) return;
|
||||
|
||||
try {
|
||||
await shortcutServiceClient.deleteShortcut({ name: deleteTarget.name });
|
||||
await refetchSettings();
|
||||
toast.success(t("setting.shortcut.delete-success", { title: deleteTarget.title }));
|
||||
setDeleteTarget(undefined);
|
||||
} catch (error: unknown) {
|
||||
await handleError(error, toast.error, {
|
||||
context: "Delete shortcut",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mx-auto flex w-full max-w-6xl flex-col gap-6 pb-10">
|
||||
<div className="flex flex-col gap-2 border-b border-border pb-5 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<FilterIcon className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">{t("common.shortcuts")}</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-normal text-foreground">Shortcut filters</h1>
|
||||
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
Create reusable memo filters with fields, operators, time helpers, and tag matching. Use examples as starting points, then
|
||||
validate before saving.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={isCreateFormOpen ? handleCloseForm : handleOpenCreateForm}>
|
||||
{isCreateFormOpen ? <XIcon className="h-4 w-4" /> : <PlusIcon className="h-4 w-4" />}
|
||||
{isCreateFormOpen ? t("common.cancel") : `${t("common.create")} ${t("common.shortcuts")}`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={cn("grid grid-cols-1 gap-6", isCreateFormOpen && "xl:grid-cols-[minmax(0,1fr)_20rem]")}>
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-lg border border-border bg-background transition-[max-height,opacity] duration-200",
|
||||
isCreateFormOpen ? "max-h-[48rem] opacity-100" : "max-h-0 border-transparent opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-5 p-4 sm:p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">{isEditing ? "Edit shortcut" : "Create shortcut"}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Name the shortcut and define the memo filter expression it should apply.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"
|
||||
href="https://www.usememos.com/docs/usage/shortcuts"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Docs
|
||||
<ExternalLinkIcon className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="shortcut-title">{t("common.title")}</Label>
|
||||
<Input
|
||||
id="shortcut-title"
|
||||
value={draft.title}
|
||||
placeholder="Pinned, Recent notes, Work"
|
||||
onChange={(event) => setDraftState({ title: event.target.value })}
|
||||
/>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Prefix the title with an emoji if you want it to appear in the sidebar.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="shortcut-filter">{t("common.filter")}</Label>
|
||||
<Textarea
|
||||
id="shortcut-filter"
|
||||
rows={5}
|
||||
className="font-mono text-sm"
|
||||
value={draft.filter}
|
||||
placeholder='pinned && tag in ["work"]'
|
||||
onChange={(event) => setDraftState({ filter: event.target.value })}
|
||||
/>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Combine expressions with <span className="font-mono">&&</span>, <span className="font-mono">||</span>, and{" "}
|
||||
<span className="font-mono">!</span>. Time fields use Unix seconds and support <span className="font-mono">now()</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" disabled={validateState.isLoading || isSaving} onClick={validateDraft}>
|
||||
<CheckCircle2Icon className="h-4 w-4" />
|
||||
Validate
|
||||
</Button>
|
||||
<Button disabled={isSaving || validateState.isLoading} onClick={handleSaveShortcut}>
|
||||
<SaveIcon className="h-4 w-4" />
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">All shortcuts</h2>
|
||||
<Badge variant="outline">{shortcuts.length}</Badge>
|
||||
</div>
|
||||
|
||||
{shortcuts.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border px-4 py-10 text-center">
|
||||
<p className="text-sm font-medium text-foreground">No shortcuts yet</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Open the create form to choose an example and add your first filter.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border overflow-hidden rounded-lg border border-border">
|
||||
{shortcuts.map((shortcut) => (
|
||||
<div
|
||||
key={shortcut.name}
|
||||
className="grid gap-3 bg-background px-4 py-3 sm:grid-cols-[minmax(10rem,14rem)_minmax(0,1fr)_2rem]"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-foreground">{shortcut.title}</div>
|
||||
<div className="mt-1 font-mono text-xs text-muted-foreground">{getShortcutId(shortcut.name)}</div>
|
||||
</div>
|
||||
<pre className="min-w-0 overflow-x-auto rounded-md bg-muted/50 px-3 py-2 font-mono text-xs leading-5 text-muted-foreground">
|
||||
{shortcut.filter}
|
||||
</pre>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="justify-self-end">
|
||||
<MoreVerticalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEditShortcut(shortcut)}>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDeleteTarget(shortcut)}>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCreateFormOpen ? <ShortcutGuide onUseExample={handleUseExample} /> : null}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
|
||||
title={t("setting.shortcut.delete-confirm", { title: deleteTarget?.title ?? "" })}
|
||||
confirmLabel={t("common.delete")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={confirmDeleteShortcut}
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Shortcuts;
|
||||
Loading…
Reference in New Issue