memos/web/src/pages/Shortcuts.tsx

578 lines
20 KiB
TypeScript

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 - duration("1h")',
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: "Untagged",
filter: "size(tags) == 0",
description: "Memos without any 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,
},
{
title: "Starts with",
filter: 'content.startsWith("TODO")',
description: "Memos whose content begins with text (also endsWith).",
icon: SearchIcon,
},
{
title: "Regex match",
filter: 'content.matches("v[0-9]+")',
description: "Match content with a regular expression.",
icon: FilterIcon,
},
{
title: "All tags match",
filter: 'tags.all(t, t.startsWith("work/"))',
description: "Every tag must satisfy the predicate (tagged memos only).",
icon: TagsIcon,
},
{
title: "One project tag",
filter: 'tags.exists_one(t, t.startsWith("project/"))',
description: "Exactly one tag matches the predicate.",
icon: TagsIcon,
},
{
title: "Any of these tags",
filter: 'sets.intersects(tags, ["work", "urgent"])',
description: "Tags intersect the given set.",
icon: TagsIcon,
},
{
title: "Exactly these tags",
filter: 'sets.equivalent(tags, ["inbox"])',
description: "Tagged with exactly this set, nothing more.",
icon: TagsIcon,
},
{
title: "Memos from 2024",
filter: "created_ts.getFullYear() == 2024",
description: "Filter by calendar year.",
icon: Clock3Icon,
},
{
title: "Weekend notes",
filter: "created_ts.getDayOfWeek() == 0 || created_ts.getDayOfWeek() == 6",
description: "Created on a Sunday or Saturday (0 = Sunday).",
icon: Clock3Icon,
},
{
title: "Long notes",
filter: "size(content) > 280",
description: "Memos longer than 280 characters.",
icon: FilterIcon,
},
];
const filterFields = [
"content.contains(...)",
"content.startsWith(...)",
"content.endsWith(...)",
"content.matches(...)",
"visibility",
"pinned",
"tag in [...]",
"tags.exists(...)",
"tags.all(...)",
"tags.exists_one(...)",
"sets.contains(tags, [...])",
"sets.intersects(tags, [...])",
"sets.equivalent(tags, [...])",
"size(tags) == ...",
"size(content) > ...",
"has_task_list",
"has_incomplete_tasks",
"has_link",
"has_code",
'created_ts >= now - duration("24h")',
"created_ts.getFullYear() == ...",
"created_ts.getMonth() == ... (0 = Jan)",
"created_ts.getDayOfWeek() == ... (0 = Sun)",
"updated_ts",
"now",
'timestamp("2025-01-01T00:00:00Z")',
];
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 (
<div
key={example.filter}
className="group cursor-pointer 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>
</div>
);
})}
</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(() => {
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 are timestamps — use <span className="font-mono">now</span>,{" "}
<span className="font-mono">duration("24h")</span>, <span className="font-mono">timestamp(...)</span>, and accessors
like <span className="font-mono">created_ts.getFullYear()</span>. Tags support{" "}
<span className="font-mono">sets.contains/intersects/equivalent</span> and{" "}
<span className="font-mono">size(content)</span> measures length.
</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;