Chapter 1: Okay, chapter here we go!?
Chapter 1: Okay, chapter here we go!?
# Ghost Navigation Dropdowns & Auto-Add Plan
## Summary
Three interconnected features: (1) a JS snippet users inject into Ghost to turn `[subitem]` labels into dropdown menus, (2) automatic addition of new books/series to Ghost navigation as subitems under their schema group, and (3) a setting per schema controlling whether items auto-add to nav.
---
## Part 1: Dropdown JS Snippet + Setup Step 5
Ghost's navigation is a flat list of `{ label, url }` items. It has no native dropdown support. The workaround: use a `[subitem]` prefix convention in nav labels, and inject a JS snippet into Ghost's Code Injection that restructures the DOM into real dropdown menus.
### The JS snippet (to be copy-pasted into Ghost → Settings → Code Injection → Site Header)
```javascript
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.gh-head-menu .nav').forEach(function(nav) {
var items = Array.from(nav.children);
var i = 0;
while (i < items.length) {
var item = items[i];
var link = item.querySelector('a');
if (!link) { i++; continue; }
// Collect subsequent [subitem] entries
var children = [];
var j = i + 1;
while (j < items.length) {
var childLink = items[j].querySelector('a');
if (!childLink || !childLink.textContent.trim().startsWith('[subitem]')) break;
childLink.textContent = childLink.textContent.replace(/^\[subitem\]\s*/, '');
children.push(items[j]);
j++;
}
if (children.length > 0) {
var wrapper = document.createElement('li');
wrapper.className = 'nav-dropdown';
wrapper.style.position = 'relative';
var trigger = item.cloneNode(true);
trigger.querySelector('a').style.cursor = 'pointer';
wrapper.appendChild(trigger);
var submenu = document.createElement('ul');
submenu.className = 'nav-dropdown-menu';
submenu.style.cssText = 'display:none;position:absolute;top:100%;left:0;background:var(--ghost-accent-color,#fff);border:1px solid rgba(0,0,0,0.1);border-radius:4px;min-width:180px;padding:4px 0;z-index:999;box-shadow:0 4px 12px rgba(0,0,0,0.15);';
children.forEach(function(c) {
c.querySelector('a').style.cssText = 'display:block;padding:6px 16px;white-space:nowrap;';
submenu.appendChild(c);
});
wrapper.appendChild(submenu);
wrapper.addEventListener('mouseenter', function() { submenu.style.display = 'block'; });
wrapper.addEventListener('mouseleave', function() { submenu.style.display = 'none'; });
item.replaceWith(wrapper);
i = j;
} else {
i++;
}
}
});
});
</script>
```
### Step 5 in Setup Guide (`TabSettingsDialog.tsx`)
Add a new collapsible step after Step 4, labeled **"Navigation Dropdowns (Optional)"**:
- Explanation: Ghost doesn't support dropdown menus natively. This snippet reads `[subitem]` prefixed labels and converts them into hover-dropdown menus.
- Instructions:
1. Open Ghost Admin → **Settings** → **Code Injection**
2. Paste the code into **Site Header**
3. Save
- "Copy Dropdown Code" button
- Note: "Items labeled `[subitem] My Book` will appear as children of the preceding nav item."
---
## Part 2: Auto-Add to Ghost Navigation on Creation
When a book or series (or any schema with nav enabled) is created via `UniversalCreationModal`, automatically:
1. Fetch current Ghost navigation via `fetchGhostNavigation()`
2. Find the parent group label (e.g., "Books" or "Series") in the nav list
3. Insert a new `[subitem]` entry immediately after the group label (and any existing subitems)
4. Call `updateGhostNavigation()` to push the change
### Logic in `ghostIntegration.ts` — new helper:
```typescript
export async function addNavSubitem(
groupLabel: string, // e.g. "Books"
itemLabel: string, // e.g. "The Beginning"
itemUrl: string, // e.g. "/library/the-beginning/"
supabaseUrl: string,
serviceRoleKey: string,
): Promise<void> {
const { navigation, secondary_navigation } = await fetchGhostNavigation(supabaseUrl, serviceRoleKey);
// Find group in primary nav
const groupIdx = navigation.findIndex(n => n.label.toLowerCase() === groupLabel.toLowerCase());
if (groupIdx === -1) {
// Group doesn't exist — create it with # url, then add subitem
navigation.push({ label: groupLabel, url: "#" });
navigation.push({ label: `[subitem] ${itemLabel}`, url: itemUrl });
} else {
// Find insertion point: after group + existing subitems
let insertIdx = groupIdx + 1;
while (insertIdx < navigation.length && navigation[insertIdx].label.startsWith("[subitem]")) {
insertIdx++;
}
navigation.splice(insertIdx, 0, { label: `[subitem] ${itemLabel}`, url: itemUrl });
}
await updateGhostNavigation(navigation, secondary_navigation, supabaseUrl, serviceRoleKey);
}
```
### Wire into `UniversalCreationModal.executeCreationFormula()`
After the YAML route is created (step 6 of the formula), if the schema has `addToNav: true`, call `addNavSubitem()`. The group label comes from the schema type name (e.g., "Books", "Series").
If creating both a series AND a book simultaneously (recursive creation), batch the nav updates: fetch once, insert both subitems, save once.
---
## Part 3: Schema Nav Settings
### New setting per schema type
Add to `GhostSpecialType` interface in `ghostRoutesStorage.ts`:
```typescript
addToNav?: boolean; // whether items of this type auto-add to Ghost navigation
navGroupLabel?: string; // custom group label (defaults to type name plural)
```
### Default values
- **Series**: `addToNav: true`, `navGroupLabel: "Series"`
- **Book**: `addToNav: true`, `navGroupLabel: "Books"`
- **Custom types**: `addToNav: false` by default (user can enable)
### UI in Settings Cog (`TabSettingsDialog.tsx`)
Add a new section inside the setup guide or as a separate collapsible panel: **"Navigation Auto-Add Settings"**
For each schema type (built-in + custom):
- Toggle: "Auto-add to navigation"
- Text input: "Nav group label" (e.g., "Books")
- Description: "When enabled, new items of this type will be added as dropdown subitems under the group label in Ghost's primary navigation."
### Storage
These settings are part of the `GhostSpecialType` object, already stored in `freeday-ghost-special-types`. Built-in types (Book, Series) need their defaults set in the `BUILTIN_BOOK_TYPE` and `BUILTIN_SERIES_TYPE` constants.
---
## Files Changed
| File | Changes |
|------|---------|
| `src/components/dialogs/TabSettingsDialog.tsx` | Add Step 5 collapsible with dropdown JS snippet, copy button, and Ghost Code Injection instructions. Add "Navigation Auto-Add Settings" section with per-schema toggles. |
| `src/lib/ghostIntegration.ts` | Add `addNavSubitem()` and `addNavSubitems()` (batch) helpers. |
| `src/lib/ghostRoutesStorage.ts` | Add `addToNav` and `navGroupLabel` fields to `GhostSpecialType`. Set defaults on built-in types. |
| `src/components/edits/UniversalCreationModal.tsx` | Call `addNavSubitem()` after creation when schema has `addToNav: true`. |
| `src/components/tabs/Edits.tsx` | Update `GhostNavigationManager` to visually distinguish `[subitem]` entries (indented, with a badge). |
## Implementation Order
1. Add `addToNav`/`navGroupLabel` to `GhostSpecialType` interface and built-in defaults
2. Add `addNavSubitem()`/`addNavSubitems()` to `ghostIntegration.ts`
3. Wire auto-nav into `UniversalCreationModal`'s creation formula
4. Add Step 5 (dropdown JS snippet) to setup guide
5. Add per-schema nav toggle to settings
6. Update `GhostNavigationManager` to show `[subitem]` entries with indent styling
Also, the current error for updating the navigation to website is: Save failed: Ghost API error (403): {"errors":[{"message":"API tokens do not have permission to access this endpoint","context":null,"type":"NoPermissionError","details":null,"property":null,"help":null,"code":null,"id":"c84fbc20-33b2-11f1-9a16-d9aceaa8f61a","ghostErrorCode":null}]}
This is a permissions issue with the type of API key you're using. Ghost has two types of API keys and they have very different access levels:
The Problem
Navigation is a write operation (it's part of Ghost's settings), so it requires an Admin API key. You're almost certainly hitting this endpoint with a Content API key.
How to fix it
1. Get an Admin API key
In your Ghost Admin panel:
Go to Settings → Integrations
Either click an existing custom integration, or create a new one
Copy the Admin API Key (it looks like id:secret — two parts separated by a colon)
2. Admin API auth is different
This is the tricky part — the Admin API doesn't just take the key as a header directly. It requires a JWT token generated from the key. The format is:
js
// Split the Admin API key
const [id, secret] = adminApiKey.split(':');
// Create a JWT
const token = jwt.sign({}, Buffer.from(secret, 'hex'), {
keyid: id,
algorithm: 'HS256',
expiresIn: '5m',
audience: '/admin/'
});
// Use it in your request
headers: {
'Authorization': `Ghost ${token}`
}
3. The navigation endpoint
Make sure you're hitting the right endpoint too:
PUT https://your-site.com/ghost/api/admin/settings/
With a body like:
json
{
"settings": [
{ "key": "navigation", "value": "[{\"label\":\"Home\",\"url\":\"/\"}]" }
]
}
Note that value is a JSON string (stringified), not a raw array.
Quick checklist
Are you using an Admin API key (not Content API)?
Is your proxy signing a JWT from it, not passing the key raw?
Is the value field stringified JSON?
If you share how your proxy function is currently building the auth header, I can pinpoint exactly what needs to change.
ALSO, please check the old way of attempting to upload routes. See if we tried uploading a full document, which I don’ think we did before? Try this way…
Got it! Yes, Ghost's routes.yaml requires a multipart file upload — you can't just POST JSON. Here's exactly how to do it:
How Ghost expects it
The endpoint is:
POST https://your-site.com/ghost/api/admin/routes/upload/
And it expects multipart/form-data with the YAML as an actual file in a field named routes.
Step by Step
1. In your PWA — build the request
js
async function saveRoutes(yamlString) {
// Convert the YAML string into a Blob (fake file)
const blob = new Blob([yamlString], { type: 'application/x-yaml' });
// Put it in a FormData object with the field name 'routes'
const formData = new FormData();
formData.append('routes', blob, 'routes.yaml');
// Send to YOUR proxy function
const response = await fetch('https://your-project.supabase.co/functions/v1/ghost-proxy', {
method: 'POST',
headers: {
'x-action': 'upload-routes' // so your proxy knows what to do
},
body: formData
// Do NOT set Content-Type manually — browser sets it with boundary automatically
});
const result = await response.json();
if (!result.ok) throw new Error(result.error);
return result;
}
2. In your Supabase Edge Function — forward it to Ghost
js
import { create } from "https://deno.land/x/djwt/mod.ts";
// Generate Ghost Admin JWT
async function getGhostToken(adminApiKey: string) {
const [id, secret] = adminApiKey.split(':');
const secretBytes = hexToBytes(secret);
const token = await create(
{ alg: "HS256", kid: id },
{ exp: Math.floor(Date.now() / 1000) + 300, aud: '/admin/' },
secretBytes
);
return token;
}
Deno.serve(async (req) => {
const action = req.headers.get('x-action');
if (action === 'upload-routes') {
// Get the formData from the PWA request
const formData = await req.formData();
const routesFile = formData.get('routes'); // this is a File object
// Build a new FormData to send to Ghost
const ghostFormData = new FormData();
ghostFormData.append('routes', routesFile, 'routes.yaml');
// Get your JWT
const token = await getGhostToken(Deno.env.get('GHOST_ADMIN_API_KEY'));
// POST to Ghost
const ghostRes = await fetch(`${Deno.env.get('GHOST_URL')}/ghost/api/admin/routes/upload/`, {
method: 'POST',
headers: {
'Authorization': `Ghost ${token}`
// Again, do NOT set Content-Type manually
},
body: ghostFormData
});
const data = await ghostRes.json();
return new Response(JSON.stringify(data), { status: ghostRes.status });
}
});
The key gotchas
Never manually set Content-Type: multipart/form-data — both the browser and Deno's fetch need to set it automatically so the boundary string gets included. If you set it manually you'll get a malformed request.
The FormData field name must be exactly routes — Ghost will reject it otherwise.
You need the Admin API key + JWT (same as the navigation issue) — not the Content key.
Ghost returns an empty 200 on success, not a JSON body, so don't expect data back.
Quick checklist
Field name is routes
Content-Type header is NOT manually set anywhere
Using Admin API key with JWT auth
Proxy is forwarding the file as FormData, not converting to JSON
Want me to look at your current proxy code and adapt it directly?
In the recently done step of modal creation, the series created and published TOC, but not cover.