| 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 | */ |
| 7 | import type { Folio } from "@g1t/contracts"; |
| 8 | import { ArrowRightLeft, Copy, Download, Ellipsis, ExternalLink, History, LayoutTemplate, Link2, PencilLine, Share2, Star, Trash2 } from "lucide-react"; |
| 9 | import { type ReactNode, useState } from "react"; |
| 10 | import { Link, useNavigate, useRevalidator } from "react-router"; |
| 11 | |
| 12 | import { canDo } from "../../lib/folios"; |
| 13 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../ui/dropdown-menu"; |
| 14 | import { foliosRequest, useFoliosData } from "./actions"; |
| 15 | import { MoveDialog, RenameDialog, TemplateDialog } from "./dialogs"; |
| 16 | import { ShareDialog } from "./share-dialog"; |
| 17 | |
| 18 | export 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 | } |