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