Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Artifacts is a mode in the rail, the shell and the phone's tab bar in place of Docs: its home lists everything you can open by day or as cards under All, Yours and Shared with you, with search, filters and tiles to start a doc (slides, designs and dashboards say they are coming soon), and its sidebar holds favorites, spaces, Private, Shared, projects' docs and the trash. | 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 | } |