578 lines
20 KiB
TypeScript
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;
|