Skip to main content
Glama

Storyden

by Southclaws
Mozilla Public License 2.0
229
library.ts11.7 kB
import slugify from "@sindresorhus/slugify"; import { uniqueId } from "lodash"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { MutatorCallback, useSWRConfig } from "swr"; import { Xid } from "xid-ts"; import { linkCreate } from "@/api/openapi-client/links"; import { nodeCreate, nodeDelete, nodeGenerateContent, nodeGenerateTags, nodeGenerateTitle, nodeUpdate, nodeUpdatePosition, nodeUpdateVisibility, } from "@/api/openapi-client/nodes"; import { Asset, Node, NodeGetOKResponse, NodeListOKResponse, NodeUpdatePositionBody, NodeWithChildren, TagNameList, Visibility, } from "@/api/openapi-schema"; import { useSession } from "@/auth"; import { joinLibraryPath, replaceLibraryPath, } from "@/screens/library/library-path"; import { useLibraryPath } from "@/screens/library/useLibraryPath"; import { useCapability } from "../settings/capabilities"; import { CoverImage } from "./metadata"; import { nodeListMutator, nodeMutator } from "./mutator-functions"; import { buildNodeKey, buildNodeListKey, nodeListPrivateKeyFn, } from "./mutator-keys"; export type CreateNodeArgs = { initialName?: string; parentID?: string; parentSlug?: string; disableRedirect?: boolean; }; export type CoverImageArgs = | { /** * The asset to use as the cover image. */ asset: Asset; /** * The configuration for the cropper, this is used to store the crop coords * for when the user re-enters the edit mode and loads the original image. */ config: CoverImage; /** * Is this cover image a full replacement or a crop of the original? */ isReplacement: false; } | { asset: Asset; isReplacement: true; }; // TODO: Remove slug params from API calls and use the node object instead. export function useLibraryMutation(node?: Node) { const session = useSession(); const genaiAvailable = useCapability("gen_ai"); const { mutate } = useSWRConfig(); const router = useRouter(); const libraryPath = useLibraryPath(); const createNode = async ({ initialName, parentID, parentSlug, disableRedirect, }: CreateNodeArgs) => { if (!session) return; // NOTE: This is a stopgap until the API deals with initial empty states in // a nicer way. For now we simply generate a dumb name which in turn results // in a unique slug. Eventually, the API should handle empty names and slugs // which it will generate a suitable unique mark for, like how Notion works. // // NOTE 2: We use the Xid library to generate a unique ID for the new page // however, the way that marks work is XID-format strings are assumed to be // node IDs not slugs. So we need to prefix the random name to prevent this. // const name = initialName?.trim() || `untitled`; const slug = slugify(`${name}-${new Xid().toString()}`); const initial: NodeWithChildren = { id: "optimistic_node_" + uniqueId(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), name, slug: slug, description: "", owner: session, properties: [], child_property_schema: [], hide_child_tree: false, meta: {}, children: [], assets: [], tags: [], visibility: "draft", recomentations: [], }; const mutator: MutatorCallback<NodeListOKResponse> = (data) => { if (!data) return; return { ...data, nodes: [initial, ...data.nodes], }; }; await mutate(nodeListPrivateKeyFn, mutator, { revalidate: false }); const parent = parentID ?? parentSlug; const created = await nodeCreate({ name, slug, parent, }); if (!disableRedirect) { const newPath = joinLibraryPath(libraryPath, created.slug); router.push(`/l/${newPath}?edit=true`); } }; const suggestTags = async (slug: string, content: string) => { const { tags } = await nodeGenerateTags(slug, { content }); return tags; }; const suggestTitle = async (slug: string, content: string) => { const { title } = await nodeGenerateTitle(slug, { content }); return title; }; const suggestSummary = async (slug: string, currentContent: string) => { const { content } = await nodeGenerateContent(slug, { content: currentContent, }); return content; }; const importFromLink = async (id: string, url: string) => { const { title, description, primary_image } = await linkCreate({ url }); if (genaiAvailable && description) { const [tag_suggestions, title_suggestion, content_suggestion] = await Promise.all([ suggestTags(id, description), suggestTitle(id, description), suggestSummary(id, description), ]); return { title_suggestion, tag_suggestions, content_suggestion, primary_image, }; } return { title_suggestion: title, tag_suggestions: [] as string[], content_suggestion: description, primary_image, }; }; const updateNodeVisibility = async (slug: string, visibility: Visibility) => { const mutator: MutatorCallback<NodeListOKResponse> = (data) => { if (!data) return; const newNodes = data.nodes.map((node) => { if (node.slug === slug) { if ( node.parent && visibility === Visibility.published && node.parent.visibility !== Visibility.published ) { toast.warning( "Page is staged for publishing but has not been published yet because this page's parent is not published. When the parent is published, this page be visible on the site.", { duration: 15000, dismissible: true, closeButton: true, }, ); } const newNode = { ...node, visibility }; return newNode; } return node; }); return { ...data, nodes: newNodes, }; }; const keyFn = buildNodeListKey(); await mutate(keyFn, mutator, { revalidate: false }); await nodeUpdateVisibility(slug, { visibility }); }; const updateNodeChildVisibility = async ( slug: string, hideChildTree: boolean, ) => { const nodeMutator: MutatorCallback<NodeGetOKResponse> = (data) => { if (!data) return; const updated = { ...data, hide_child_tree: hideChildTree, } satisfies NodeWithChildren; return updated; }; const nodeListMutator: MutatorCallback<NodeListOKResponse> = (data) => { if (!data) return; const newNodes = data.nodes.map((node) => { if (node.slug === slug) { const newNode = { ...node, hide_child_tree: hideChildTree }; return newNode; } return node; }); return { ...data, nodes: newNodes, }; }; const listKeyFn = buildNodeListKey(); await mutate(listKeyFn, nodeListMutator, { revalidate: false }); const nodeKeyFn = buildNodeKey(slug); await mutate(nodeKeyFn, nodeMutator, { revalidate: false }); const updated = await nodeUpdate(slug, { hide_child_tree: hideChildTree, }); revalidate(); return updated; }; const deleteNode = async (slug: string, newParent?: string) => { const mutator: MutatorCallback<NodeListOKResponse> = (data) => { if (!data) return; const newNodes = data.nodes.filter((node) => node.slug !== slug); return { ...data, nodes: newNodes, }; }; const listKeyFn = buildNodeListKey(); await mutate(listKeyFn, mutator, { revalidate: false }); await nodeDelete(slug, { target_node: newParent }); // TODO: Ensure redirect only happens if you're viewing this actual page. if (newParent) { const newPath = replaceLibraryPath(libraryPath, slug, newParent); router.push(newPath); } else { router.push("/l"); } }; const moveNode = async ( draggingNodeId: string, dropTargetId: string, dropPosition: "above" | "below" | "inside", newParent: string | null, ) => { const mutator: MutatorCallback<NodeListOKResponse> = (prevData) => { if (!prevData) return prevData; const newNodes = moveNodeInTree({ tree: prevData.nodes, draggingNodeId, dropTargetId, dropPosition, }); return { ...prevData, nodes: newNodes }; }; const listKeyFn = buildNodeListKey(); await mutate(listKeyFn, mutator, { revalidate: false }); const params: NodeUpdatePositionBody = (() => { switch (dropPosition) { case "above": return { before: dropTargetId, parent: newParent, }; case "below": return { after: dropTargetId, parent: newParent, }; case "inside": return { parent: dropTargetId, }; } })(); await nodeUpdatePosition(draggingNodeId, params); }; const revalidate = async (updated?: NodeWithChildren) => { const listKeyFn = buildNodeListKey(); await mutate<NodeListOKResponse>( listKeyFn, updated ? nodeListMutator(updated) : undefined, ); if (node) { const nodeKeyFn = buildNodeKey(updated?.slug ?? node.slug); await mutate<NodeGetOKResponse>( nodeKeyFn, updated ? nodeMutator(updated) : undefined, ); } }; return { createNode, suggestTitle, suggestSummary, suggestTags, importFromLink, updateNodeVisibility, updateNodeChildVisibility, deleteNode, moveNode, revalidate, }; } interface MoveNodeParams { tree: NodeWithChildren[]; draggingNodeId: string; dropTargetId: string; dropPosition: "above" | "below" | "inside"; } function moveNodeInTree({ tree, draggingNodeId, dropTargetId, dropPosition, }: MoveNodeParams): NodeWithChildren[] { let draggedNode: NodeWithChildren | null = null; function removeNode(nodes: NodeWithChildren[]): NodeWithChildren[] { return nodes.reduce<NodeWithChildren[]>((acc, node) => { if (node.id === draggingNodeId) { draggedNode = { ...node }; return acc; } const newChildren = removeNode(node.children || []); acc.push({ ...node, children: newChildren }); return acc; }, []); } function insertNode(nodes: NodeWithChildren[]): NodeWithChildren[] { return nodes.reduce<NodeWithChildren[]>((acc, node) => { if (node.id === dropTargetId) { if (dropPosition === "inside") { // Insert as first child const newChildren = [draggedNode!, ...(node.children || [])]; acc.push({ ...node, children: newChildren }); } else { // Insert above or below at sibling level if (dropPosition === "above") { acc.push(draggedNode!); acc.push(node); } else if (dropPosition === "below") { acc.push(node); acc.push(draggedNode!); } } } else { // Normal node const newChildren = insertNode(node.children || []); acc.push({ ...node, children: newChildren }); } return acc; }, []); } const treeWithoutDragged = removeNode(tree); if (!draggedNode) { console.warn("Dragged node not found"); return tree; } const newTree = insertNode(treeWithoutDragged); return newTree; }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Southclaws/storyden'

If you have feedback or need assistance with the MCP directory API, please join our Discord server