{"_id":"@arevo/payload-pagetree-plugin","name":"@arevo/payload-pagetree-plugin","dist-tags":{"latest":"1.0.0"},"versions":{"1.0.0":{"name":"@arevo/payload-pagetree-plugin","version":"1.0.0","description":"Hierarchical page tree plugin for Payload CMS with drag-and-drop reordering and nested pages","main":"dist/index.js","types":"dist/index.d.ts","scripts":{"build":"tsc","prepublishOnly":"npm run build"},"keywords":["payload","payloadcms","plugin","tree","hierarchy","pages","nested","drag-and-drop","cms"],"author":{"name":"Arevo"},"license":"MIT","publishConfig":{"access":"public"},"peerDependencies":{"payload":"^3.0.0","react":"^18.0.0 || ^19.0.0","next":"^14.0.0 || ^15.0.0"},"devDependencies":{"typescript":"^5.0.0"},"repository":{"type":"git","url":"git+https://github.com/yourusername/payload-pagetree.git"},"bugs":{"url":"https://github.com/yourusername/payload-pagetree/issues"},"homepage":"https://github.com/yourusername/payload-pagetree#readme","_id":"@arevo/payload-pagetree-plugin@1.0.0","gitHead":"2523a2135e0ea784a4832f1496a38c05e596e7b5","_nodeVersion":"18.20.6","_npmVersion":"10.9.3","dist":{"integrity":"sha512-O1Ze5RMaHK8Y/5Za7Jg57ClZ6qdHa8CZq6z8+ASiaSElMrpZUAizjkDW4yJ9aIFcKNNSxU3P45ZTc4UrGvkDFw==","shasum":"96e0d5928e7a0ca236de5cb8019563d41c506911","tarball":"https://registry.npmjs.org/@arevo/payload-pagetree-plugin/-/payload-pagetree-plugin-1.0.0.tgz","fileCount":25,"unpackedSize":82224,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQCKb6+30hgQpZVXrRXEQsSjjGqB/bNuT0+KFUfozMLuQQIgCK9WoqIQlw38UCq8GLworttUD6phRuiiUEXyRLQPPjQ="}]},"_npmUser":{"name":"skxv","email":"skov@skxv.dev"},"directories":{},"maintainers":[{"name":"skxv","email":"skov@skxv.dev"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/payload-pagetree-plugin_1.0.0_1761932173385_0.2128992238868126"},"_hasShrinkwrap":false}},"time":{"created":"2025-10-31T17:36:13.263Z","1.0.0":"2025-10-31T17:36:13.599Z","modified":"2025-10-31T17:36:14.120Z"},"maintainers":[{"name":"skxv","email":"skov@skxv.dev"}],"description":"Hierarchical page tree plugin for Payload CMS with drag-and-drop reordering and nested pages","homepage":"https://github.com/yourusername/payload-pagetree#readme","keywords":["payload","payloadcms","plugin","tree","hierarchy","pages","nested","drag-and-drop","cms"],"repository":{"type":"git","url":"git+https://github.com/yourusername/payload-pagetree.git"},"author":{"name":"Arevo"},"bugs":{"url":"https://github.com/yourusername/payload-pagetree/issues"},"license":"MIT","readme":"# Payload PageTree Plugin\n\nA powerful plugin for Payload CMS that adds hierarchical page tree functionality with drag-and-drop reordering, nested pages, and automatic path computation.\n\n## Features\n\n- **Hierarchical Page Structure**: Create parent-child relationships between pages\n- **Auto-computed Full Paths**: Automatically generates full URL paths based on page hierarchy (e.g., `/parent/child/grandchild`)\n- **Tree View Interface**: Beautiful drag-and-drop tree view in the admin panel\n- **Page Groups**: Support for folder-like group pages that organize content\n- **Automatic Slug Generation**: Slugs are automatically generated from page titles\n- **Move & Reorder**: Drag pages to reorder or move them between parents\n- **Cascade Updates**: Moving a parent automatically updates all descendant paths\n- **Delete Protection**: Prevents deletion of pages with children\n- **REST API Endpoints**: Query and manipulate the page tree via API\n\n## Installation\n\nSince this is a local plugin, it's already included in your project at `src/payload-pagetree/`.\n\nIf you want to use it in another project, copy the entire `payload-pagetree` folder to your new project's `src/` directory.\n\n## Quick Start\n\n### 1. Add the Plugin to Your Payload Config\n\n```typescript\n// payload.config.ts\nimport { PageTree } from './payload-pagetree'\n\nexport default buildConfig({\n  // ... other config\n  plugins: [\n    PageTree({\n      collections: ['pages'], // Add any collections you want to be hierarchical\n    }),\n  ],\n})\n```\n\n### 2. Update Your Collection (if needed)\n\nThe plugin automatically adds these fields to your collection:\n- `slug` - URL-friendly identifier\n- `fullPath` - Auto-computed full path from parent + slug\n- `parent` - Relationship to parent page\n- `isGroup` - Checkbox to mark as folder/group\n- `sort` - Number for ordering among siblings\n\n**Important**: If your collection already has a `slug` field (e.g., using `slugField()`), remove it to avoid conflicts:\n\n```typescript\n// Before\nimport { slugField } from 'payload'\n\nexport const Pages: CollectionConfig = {\n  fields: [\n    // ... other fields\n    slugField(), // Remove this line\n  ],\n}\n\n// After\nexport const Pages: CollectionConfig = {\n  fields: [\n    // ... other fields\n    // Plugin provides slug field automatically\n  ],\n}\n```\n\n### 3. Set Up Next.js Routing (Required)\n\nTo support nested pages in your Next.js app, follow the [Integration Guide](#integration-with-nextjs) below.\n\n## Configuration Options\n\n```typescript\nPageTree({\n  collections: ['pages', 'docs'], // Collections to enable tree functionality\n  treeLabel?: 'Page Tree', // Optional: Customize the tree view label\n})\n```\n\n### Options\n\n| Option | Type | Required | Description |\n|--------|------|----------|-------------|\n| `collections` | `string[]` | Yes | Array of collection slugs to enable tree functionality |\n| `treeLabel` | `string` | No | Custom label for the tree view interface |\n\n## Integration with Next.js\n\nTo make your Next.js App Router work with nested pages, you need to update your routing structure.\n\n### Step 1: Convert to Catch-All Route\n\nReplace your single-segment route with an optional catch-all route:\n\n**Before:**\n```\nsrc/app/(frontend)/\n  ├── [slug]/\n  │   ├── page.tsx\n  │   └── page.client.tsx\n  └── page.tsx (root page)\n```\n\n**After:**\n```\nsrc/app/(frontend)/\n  └── [[...slug]]/\n      ├── page.tsx\n      └── page.client.tsx\n```\n\n### Step 2: Update the Page Component\n\nCreate or update `src/app/(frontend)/[[...slug]]/page.tsx`:\n\n```typescript\nimport type { Metadata } from 'next'\nimport { getPayload } from 'payload'\nimport { draftMode } from 'next/headers'\nimport { cache } from 'react'\nimport configPromise from '@payload-config'\n\nexport async function generateStaticParams() {\n  const payload = await getPayload({ config: configPromise })\n  const pages = await payload.find({\n    collection: 'pages',\n    draft: false,\n    limit: 1000,\n    overrideAccess: false,\n    pagination: false,\n    select: {\n      slug: true,\n      fullPath: true,\n    },\n  })\n\n  return pages.docs\n    ?.filter((doc) => doc.slug !== 'home')\n    .map((doc) => {\n      // Use fullPath and split into segments for catch-all route\n      const path = (doc as any).fullPath || doc.slug\n      const pathWithoutSlash = path.startsWith('/') ? path.slice(1) : path\n      const slug = pathWithoutSlash.split('/').filter(Boolean)\n      return { slug }\n    }) || []\n}\n\ntype Args = {\n  params: Promise<{\n    slug?: string[]\n  }>\n}\n\nexport default async function Page({ params: paramsPromise }: Args) {\n  const { isEnabled: draft } = await draftMode()\n  const { slug: slugArray } = await paramsPromise\n  \n  // Convert slug array to string path (or 'home' if undefined/empty)\n  const slug = slugArray && slugArray.length > 0 ? slugArray.join('/') : 'home'\n  const decodedSlug = decodeURIComponent(slug)\n  \n  const page = await queryPageBySlug({ slug: decodedSlug })\n  \n  if (!page) {\n    notFound()\n  }\n\n  // Render your page...\n}\n\nconst queryPageBySlug = cache(async ({ slug }: { slug: string }) => {\n  const { isEnabled: draft } = await draftMode()\n  const payload = await getPayload({ config: configPromise })\n  \n  // Construct the full path for querying (add leading slash)\n  const fullPath = slug.startsWith('/') ? slug : '/' + slug\n\n  // Query by fullPath (supports nested pages)\n  const result = await payload.find({\n    collection: 'pages',\n    draft,\n    limit: 1,\n    pagination: false,\n    overrideAccess: true,\n    where: {\n      fullPath: {\n        equals: fullPath,\n      },\n    },\n  })\n\n  return result.docs?.[0] || null\n})\n```\n\n### Step 3: Update Preview Path Generation\n\nUpdate your preview path utility to use `fullPath`:\n\n```typescript\n// utilities/generatePreviewPath.ts\nexport const generatePreviewPath = ({ collection, slug }: Props) => {\n  if (slug === undefined || slug === null) {\n    return null\n  }\n\n  const encodedSlug = encodeURIComponent(slug)\n  const pathPrefix = collectionPrefixMap[collection] || ''\n  \n  // For pages, slug might already be a fullPath (e.g., \"parent/child\")\n  const fullPath = slug.startsWith('/') ? slug : `${pathPrefix}/${slug}`\n\n  const encodedParams = new URLSearchParams({\n    slug: encodedSlug,\n    collection,\n    path: fullPath,\n    previewSecret: process.env.PREVIEW_SECRET || '',\n  })\n\n  return `/next/preview?${encodedParams.toString()}`\n}\n```\n\n### Step 4: Update Collection Config\n\nUpdate your Pages collection to use `fullPath` in preview functions:\n\n```typescript\n// collections/Pages/index.ts\nexport const Pages: CollectionConfig = {\n  admin: {\n    livePreview: {\n      url: ({ data, req }) =>\n        generatePreviewPath({\n          slug: (data as any)?.fullPath || data?.slug,\n          collection: 'pages',\n          req,\n        }),\n    },\n    preview: (data, { req }) =>\n      generatePreviewPath({\n        slug: ((data as any)?.fullPath || data?.slug) as string,\n        collection: 'pages',\n        req,\n      }),\n  },\n  // ... rest of config\n}\n```\n\n### Step 5: Update Revalidation Hooks\n\nUpdate your revalidation hooks to use `fullPath`:\n\n```typescript\n// collections/Pages/hooks/revalidatePage.ts\nexport const revalidatePage: CollectionAfterChangeHook<Page> = ({\n  doc,\n  previousDoc,\n  req: { payload, context },\n}) => {\n  if (!context.disableRevalidate) {\n    if (doc._status === 'published') {\n      const fullPath = (doc as any).fullPath || doc.slug\n      const path = doc.slug === 'home' ? '/' : fullPath.startsWith('/') ? fullPath : `/${fullPath}`\n      \n      payload.logger.info(`Revalidating page at path: ${path}`)\n      revalidatePath(path)\n      revalidateTag('pages-sitemap')\n    }\n\n    // Handle previously published pages\n    if (previousDoc?._status === 'published' && doc._status !== 'published') {\n      const prevFullPath = (previousDoc as any).fullPath || previousDoc.slug\n      const oldPath = previousDoc.slug === 'home' ? '/' : prevFullPath.startsWith('/') ? prevFullPath : `/${prevFullPath}`\n      \n      payload.logger.info(`Revalidating old page at path: ${oldPath}`)\n      revalidatePath(oldPath)\n      revalidateTag('pages-sitemap')\n    }\n  }\n  return doc\n}\n\nexport const revalidateDelete: CollectionAfterDeleteHook<Page> = ({ doc, req: { context } }) => {\n  if (!context.disableRevalidate) {\n    const fullPath = (doc as any)?.fullPath || doc?.slug\n    const path = doc?.slug === 'home' ? '/' : fullPath?.startsWith('/') ? fullPath : `/${fullPath}`\n    revalidatePath(path)\n    revalidateTag('pages-sitemap')\n  }\n  return doc\n}\n```\n\n### Step 6: Update Sitemap Generation\n\nUpdate your sitemap to use `fullPath`:\n\n```typescript\n// app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts\nconst results = await payload.find({\n  collection: 'pages',\n  overrideAccess: false,\n  draft: false,\n  depth: 0,\n  limit: 1000,\n  pagination: false,\n  where: {\n    _status: {\n      equals: 'published',\n    },\n  },\n  select: {\n    slug: true,\n    fullPath: true, // Add fullPath to select\n    updatedAt: true,\n  },\n})\n\nconst sitemap = results.docs\n  ?.filter((page) => Boolean(page?.slug))\n  .map((page) => {\n    // Use fullPath if available, fallback to slug\n    const path = (page as any)?.fullPath || page?.slug\n    const url = page?.slug === 'home' ? `${SITE_URL}/` : `${SITE_URL}${path.startsWith('/') ? path : `/${path}`}`\n    return {\n      loc: url,\n      lastmod: page.updatedAt || dateFallback,\n    }\n  })\n```\n\n## API Endpoints\n\nThe plugin automatically adds two REST endpoints:\n\n### Get Tree Structure\n\n```\nGET /api/pagetree/:collection/tree\n```\n\nReturns the complete hierarchical tree for a collection.\n\n**Response:**\n```json\n{\n  \"tree\": [\n    {\n      \"id\": \"123\",\n      \"title\": \"About\",\n      \"slug\": \"about\",\n      \"fullPath\": \"/about\",\n      \"isGroup\": false,\n      \"sort\": 0,\n      \"children\": [\n        {\n          \"id\": \"456\",\n          \"title\": \"Team\",\n          \"slug\": \"team\",\n          \"fullPath\": \"/about/team\",\n          \"isGroup\": false,\n          \"sort\": 0,\n          \"children\": []\n        }\n      ]\n    }\n  ]\n}\n```\n\n### Move Node\n\n```\nPOST /api/pagetree/:collection/move\n```\n\nMove a page to a new parent or update its sort order.\n\n**Request Body:**\n```json\n{\n  \"id\": \"456\",\n  \"newParent\": \"789\",\n  \"newSort\": 10\n}\n```\n\n**Response:**\n```json\n{\n  \"ok\": true,\n  \"node\": { ... }\n}\n```\n\n## Admin Interface\n\nThe plugin replaces the default list view with a tree view that provides:\n\n- **Drag-and-drop**: Reorder pages or move them between parents\n- **Visual hierarchy**: Clear parent-child relationships\n- **Expand/collapse**: Navigate large page trees easily\n- **Quick actions**: Edit, view, or delete pages from the tree\n- **Full-path display**: See the complete URL path for each page\n\n## How It Works\n\n### Field Management\n\nThe plugin intelligently adds fields only if they don't already exist in your collection:\n\n- `slug`: Auto-generated from title if not provided\n- `fullPath`: Computed from parent hierarchy + slug\n- `parent`: Relationship field to self\n- `isGroup`: Flag for folder-like pages\n- `sort`: Numeric value for sibling order\n\n### Hooks\n\nThe plugin adds several hooks to maintain data integrity:\n\n1. **beforeValidate**: Auto-generates slug from title if missing\n2. **beforeChange**: Computes fullPath and assigns default sort order\n3. **afterChange**: Cascades path updates to all descendants when parent or slug changes\n4. **beforeDelete**: Prevents deletion if page has children\n\n### Path Computation\n\nWhen a page is saved, its `fullPath` is computed by:\n1. Getting the parent's `fullPath`\n2. Appending the current page's `slug`\n3. Example: parent `/about` + slug `team` = `/about/team`\n\nIf a parent is moved or its slug changes, all descendants are automatically updated in a breadth-first cascade.\n\n## Best Practices\n\n### Page Structure\n\n```\nRoot Pages (parent: null)\n├── About (slug: about, fullPath: /about)\n│   ├── Team (slug: team, fullPath: /about/team)\n│   └── History (slug: history, fullPath: /about/history)\n├── Products (slug: products, fullPath: /products, isGroup: true)\n│   ├── Category A (slug: category-a, fullPath: /products/category-a)\n│   └── Category B (slug: category-b, fullPath: /products/category-b)\n└── Contact (slug: contact, fullPath: /contact)\n```\n\n### Using Groups\n\nMark pages as groups (folders) when they:\n- Organize other pages but don't have their own content\n- Should be displayed differently in navigation\n- Need special handling in your frontend\n\n### Slug Naming\n\n- Keep slugs short and descriptive\n- Use lowercase letters and hyphens\n- Avoid special characters\n- The plugin auto-slugifies titles, but you can override\n\n### Performance\n\n- The plugin uses efficient BFS algorithms for cascade updates\n- Tree queries are optimized with proper indexing\n- For very large trees (1000+ pages), consider pagination in the admin view\n\n## Troubleshooting\n\n### 404 Errors on Nested Pages\n\n**Problem**: Pages show in admin but return 404 on the frontend.\n\n**Solution**: \n1. Ensure you've converted to `[[...slug]]` catch-all route\n2. Verify you removed the duplicate root `page.tsx`\n3. Restart your dev server\n4. Check that pages are published (not drafts)\n\n### Slug Field Conflicts\n\n**Problem**: Error about duplicate slug fields.\n\n**Solution**: Remove `slugField()` from your collection config - the plugin provides its own slug field with additional functionality.\n\n### Pages Not Showing in Tree View\n\n**Problem**: Tree view is empty or doesn't show.\n\n**Solution**:\n1. Verify the collection is listed in the plugin config\n2. Clear your browser cache\n3. Check that the custom component path is correct\n4. Ensure the collection has at least one page\n\n### FullPath Not Updating\n\n**Problem**: Moving a page doesn't update child paths.\n\n**Solution**: This should happen automatically. If it doesn't:\n1. Check that the `afterChange` hook is registered\n2. Look for errors in the server logs\n3. Try saving the parent page again to trigger cascade\n\n## Examples\n\n### Creating a Documentation Site Structure\n\n```typescript\n// docs/\n//   getting-started/\n//     installation\n//     quick-start\n//   guides/\n//     authentication\n//     deployment\n//   api-reference/\n//     collections\n//     fields\n```\n\n1. Create root page: \"Docs\" (isGroup: true)\n2. Create sections: \"Getting Started\", \"Guides\", \"API Reference\" (all isGroup: true)\n3. Create content pages under each section\n4. Drag to reorder as needed\n\n### Building a Multi-level Navigation\n\nQuery the tree structure from your frontend:\n\n```typescript\nconst response = await fetch('/api/pagetree/pages/tree')\nconst { tree } = await response.json()\n\n// Render navigation\nfunction renderNav(items) {\n  return (\n    <ul>\n      {items.map(item => (\n        <li key={item.id}>\n          <a href={item.fullPath}>{item.title}</a>\n          {item.children.length > 0 && renderNav(item.children)}\n        </li>\n      ))}\n    </ul>\n  )\n}\n```\n\n## TypeScript Support\n\nThe plugin is fully typed. TypeScript will infer the correct types when you query pages:\n\n```typescript\nconst page = await payload.findByID({\n  collection: 'pages',\n  id: '123',\n})\n\n// TypeScript knows about these fields:\npage.slug // string\npage.fullPath // string\npage.parent // string | Page | null\npage.isGroup // boolean\npage.sort // number\n```\n\n## Contributing\n\nSince this is a local plugin, feel free to modify it for your needs:\n\n- Plugin core: `src/payload-pagetree/index.ts`\n- Admin components: `src/payload-pagetree/admin/`\n- Shared utilities: `src/payload-pagetree/shared/`\n\n## License\n\nMIT\n\n## Credits\n\nBuilt for Payload CMS v3+ with Next.js App Router support.\n\n","readmeFilename":"README.md","_rev":"1-8799c4be973f76a993ce3336dadbee31"}