Skip to content
148 linesCodeBlameRaw
1/**
2 * An artifact's ⋯ menu, the same on Home's rows and cards and in its own
3 * header: open in a new tab, favorite, share, rename, duplicate, move,
4 * copy the link, export, save as a template, move to the trash. What the
5 * viewer's role doesn't allow isn't offered.
6 */
7import type { Folio } from "@g1t/contracts";
8import { ArrowRightLeft, Copy, Download, Ellipsis, ExternalLink, History, LayoutTemplate, Link2, PencilLine, Share2, Star, Trash2 } from "lucide-react";
9import { type ReactNode, useState } from "react";
10import { Link, useNavigate, useRevalidator } from "react-router";
11
12import { canDo } from "../../lib/folios";
13import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../ui/dropdown-menu";
14import { foliosRequest, useFoliosData } from "./actions";
15import { MoveDialog, RenameDialog, TemplateDialog } from "./dialogs";
16import { ShareDialog } from "./share-dialog";
17
18export function FolioMenu({
19 slug,
20 folio,
21 onError,
22 onTrashed,
23 page = false,
24 trigger,
25}: {
26 slug: string;
27 folio: Folio;
28 onError?: (message: string) => void;
29 /** After it went to the trash (its own page goes back Home). */
30 onTrashed?: () => void;
31 /** On its own page: History is offered, and "Open in new tab" isn't. */
32 page?: boolean;
33 trigger?: ReactNode;
34}) {
35 const layout = useFoliosData();
36 const navigate = useNavigate();
37 const { revalidate } = useRevalidator();
38 const [sharing, setSharing] = useState(false);
39 const [renaming, setRenaming] = useState(false);
40 const [moving, setMoving] = useState(false);
41 const [templating, setTemplating] = useState(false);
42 const editable = canDo(folio.viewer_role, "edit");
43 const fail = (message: string) => onError?.(message);
44 const send = async <T,>(intent: string, body: Record<string, unknown>) => {
45 const done = await foliosRequest<T>(slug, intent, { folio_id: folio.id, ...body });
46 if (done.ok) void revalidate();
47 else fail(done.error.message);
48 return done;
49 };
50 return (
51 <>
52 <DropdownMenu>
53 <DropdownMenuTrigger asChild>
54 {trigger ?? (
55 <button type="button" aria-label={`More for ${folio.title || "Untitled"}`} className="flex size-8 shrink-0 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg max-md:size-10">
56 <Ellipsis size={16} />
57 </button>
58 )}
59 </DropdownMenuTrigger>
60 <DropdownMenuContent align="end" className="w-56">
61 {!page && (
62 <DropdownMenuItem asChild>
63 <a href={folio.path} target="_blank" rel="noopener">
64 <ExternalLink size={14} /> Open in new tab
65 </a>
66 </DropdownMenuItem>
67 )}
68 <DropdownMenuItem onSelect={() => void send("favorite", { on: !folio.favorite })}>
69 <Star size={14} className={folio.favorite ? "fill-current text-warn" : ""} /> {folio.favorite ? "Remove from Favorites" : "Add to Favorites"}
70 </DropdownMenuItem>
71 <DropdownMenuItem onSelect={() => setSharing(true)}>
72 <Share2 size={14} /> Share…
73 </DropdownMenuItem>
74 {page && (
75 <DropdownMenuItem asChild>
76 <Link to={`${folio.path}/history`}>
77 <History size={14} /> History
78 </Link>
79 </DropdownMenuItem>
80 )}
81 <DropdownMenuSeparator />
82 {editable && (
83 <DropdownMenuItem onSelect={() => setRenaming(true)}>
84 <PencilLine size={14} /> Rename
85 </DropdownMenuItem>
86 )}
87 <DropdownMenuItem
88 onSelect={async () => {
89 const made = await send<Folio>("duplicate", {});
90 if (made.ok) navigate(made.value.path);
91 }}
92 >
93 <Copy size={14} /> Duplicate
94 </DropdownMenuItem>
95 {editable && (
96 <DropdownMenuItem onSelect={() => setMoving(true)}>
97 <ArrowRightLeft size={14} /> Move to…
98 </DropdownMenuItem>
99 )}
100 <DropdownMenuItem
101 onSelect={async () => {
102 try {
103 await navigator.clipboard.writeText(`${window.location.origin}${folio.path}`);
104 } catch {
105 fail("Your browser didn't allow copying.");
106 }
107 }}
108 >
109 <Link2 size={14} /> Copy link
110 </DropdownMenuItem>
111 <DropdownMenuItem asChild>
112 <a href={`/${slug}/-/artifacts/export?folio=${encodeURIComponent(folio.id)}`}>
113 <Download size={14} /> Export Markdown
114 </a>
115 </DropdownMenuItem>
116 <DropdownMenuItem onSelect={() => setTemplating(true)}>
117 <LayoutTemplate size={14} /> Save as template
118 </DropdownMenuItem>
119 {editable && (
120 <>
121 <DropdownMenuSeparator />
122 <DropdownMenuItem
123 onSelect={async () => {
124 const done = await send("trash", {});
125 if (done.ok) onTrashed?.();
126 }}
127 className="text-danger focus:text-danger"
128 >
129 <Trash2 size={14} /> Move to trash
130 </DropdownMenuItem>
131 </>
132 )}
133 </DropdownMenuContent>
134 </DropdownMenu>
135 <ShareDialog slug={slug} folio={folio} open={sharing} onOpenChange={setSharing} onChanged={() => void revalidate()} />
136 <RenameDialog open={renaming} onOpenChange={setRenaming} title={folio.title} onSave={(title) => send("update", { change: { title } })} />
137 <MoveDialog
138 sidebar={layout?.sidebar}
139 folio={folio}
140 me={layout?.me.key ?? ""}
141 open={moving}
142 onOpenChange={setMoving}
143 onMove={(spaceId, parentId) => send("move", { move: { space_id: spaceId, parent_id: parentId, before_id: null } })}
144 />
145 <TemplateDialog open={templating} onOpenChange={setTemplating} title={folio.title || "Untitled"} widens={folio.general_access !== "workspace" && !(folio.space?.kind === "workspace" && folio.inherit)} onSave={(name, description) => send("save_template", { name, description })} />
146 </>
147 );
148}