# Phase 8: Advanced Navigation Features

## Goal
Upgrade the Navigation Manager built in Phase 6 with three professional features:
1. **Draft Mode** — A safe editing experience where changes are not applied to the live site until the user clicks "Save Changes."
2. **Drag-and-Drop Reordering** — Grab a handle and drag root-level items up or down within a section.
3. **▲▼ Button Reordering** — Up/down buttons on every item as an alternative to drag-and-drop.

## Important Context
> [!CAUTION]
> **DO NOT modify the Neon database schema.** The `sort_order` and `parent_id` columns in `navigation_items` are already in place. You only need to update the UI and the save logic.

---

## Feature 1: Draft Mode (Safe Editing)

### The Problem It Solves
Without draft mode, every change (edit, hide, delete) would immediately call an API and update the live site. A single wrong click could break the live navigation.

### How It Works
The Navigation Manager uses **local React state** as a "draft" buffer:

1. When the modal opens, fetch all items via `GET /api/navigation-items?all=1` and load into local `items` state. Also save a deep copy as `initialItems`.
2. Also track a `deletedIds` Set for items removed during this session.
3. All user actions (edit, toggle, delete, reorder) only update the **local `items` state**. They do NOT call any database API.
4. Show a yellow warning in the modal header when there are unsaved changes:
   ```
   ⚠️ You have unsaved changes
   ```
5. A blue **"Save Changes"** button is fixed to the modal footer. It is disabled if there are no changes.
6. When "Save Changes" is clicked, call `POST /api/navigation-items/bulk-save` with only the changed/new items and `deletedIds`.

### How `hasChanges` Is Computed
```typescript
const hasChanges = useMemo(() => {
  if (deletedIds.size > 0) return true;
  if (items.length !== initialItems.length) return true;
  for (const item of items) {
    const init = initialItems.find(i => i.id === item.id);
    if (!init) return true;
    if (
      item.label !== init.label ||
      item.url !== init.url ||
      item.sortOrder !== init.sortOrder ||
      item.isActive !== init.isActive ||
      item.parentId !== init.parentId
    ) return true;
  }
  return false;
}, [items, initialItems, deletedIds]);
```

### Bulk-Save API Contract
`POST /api/navigation-items/bulk-save`

```json
{
  "items": [
    { "id": 1, "label": "About Us", "url": "/about", "menuLocation": "top_header", "parentId": null, "sortOrder": 0, "isActive": true, "menuLocation": "top_header" },
    { "id": -1, "label": "New Page", "url": "/new", "menuLocation": "top_header", "parentId": 1, "sortOrder": 1, "isActive": true }
  ],
  "deletedIds": [5, 6]
}
```

- Items with `id > 0` → **UPSERT** (update existing row).
- Items with `id ≤ 0` (null or negative temp IDs) → **INSERT** as new rows.
- Any DB row whose ID appears in `deletedIds` → **DELETE**.

> [!WARNING]
> **NEVER use "delete all + re-insert all".** `navigation_items.id` is `SERIAL` — deleting and reinserting generates new IDs and instantly orphans all child items that reference the old parent IDs.

### Unsaved Changes Guard on Close
```typescript
const handleClose = () => {
  if (hasChanges) {
    if (!window.confirm('You have unsaved changes. Are you sure you want to discard them?')) return;
  }
  onClose();
};
```

### Key Rule
> [!IMPORTANT]
> No API call is made until the user explicitly clicks **"Save Changes."** The live website must remain unchanged during editing.

---

## Feature 2: Drag-and-Drop Reordering

### Library to Use
Use `@dnd-kit/core` and `@dnd-kit/sortable`.

Check if already installed before running npm install:
```bash
node -e "const p = require('./package.json'); const d = {...p.dependencies,...p.devDependencies}; console.log(Object.keys(d).filter(k=>k.includes('dnd-kit')));"
```

If not installed:
```bash
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
```

### How It Works
- Add a **⠿ grip handle** to the **left side** of every **root-level** item (items with `parentId === null`).
- Only root-level rows get drag handles. Child/nested items use ▲▼ buttons only.
- **Ranktraction category rows ALSO get a drag handle.** Because they are dynamically synced into the `navigation_items` database as "Ghost Rows" (with real IDs), their `sortOrder` can be cleanly tracked and managed just like any other custom link.
- The user grabs the handle and drags the item up or down **within the same menu location section**.
- On drop, update `sortOrder` values in the **local draft state only** — do NOT save to DB yet.
- Moving items **between** different locations or changing an item's parent is done via the inline ✏️ Edit button, not drag-and-drop.
  - *Note:* The "Parent Menu" dropdown in the inline edit form is available for **all custom pages/links at any depth**. This allows users to easily move nested children to different parents or pull them out to the top level.

### Implementation Pattern
```tsx
import { DndContext, closestCenter, PointerSensor, KeyboardSensor, useSensor, useSensors } from '@dnd-kit/core';
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable, arrayMove } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';

// Sortable wrapper component for each root-level item
function SortableWrapper({ id, children }) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
  return (
    <div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 }}>
      {children({ ...attributes, ...listeners })}
    </div>
  );
}

// Each location section wraps its root items with DndContext + SortableContext
<DndContext
  sensors={sensors}
  collisionDetection={closestCenter}
  onDragEnd={event => handleDragEndLocal(event, loc, roots)}
>
  <SortableContext items={roots.map(i => i.id)} strategy={verticalListSortingStrategy}>
    {roots.map(item => (
      <SortableWrapper key={item.id} id={item.id}>
        {(dndProps) => renderItem(item, roots, 0, dndProps)}
      </SortableWrapper>
    ))}
  </SortableContext>
</DndContext>

// onDragEnd updates local state only
const handleDragEndLocal = (event, loc, currentRoots) => {
  const { active, over } = event;
  if (!over || active.id === over.id) return;
  const oldIdx = currentRoots.findIndex(i => i.id === active.id);
  const newIdx = currentRoots.findIndex(i => i.id === over.id);
  const newOrder = arrayMove(currentRoots, oldIdx, newIdx);

  setItems(prev => {
    const others = prev.filter(i => !(i.menuLocation === loc && i.parentId === null));
    return [...others, ...newOrder.map((item, idx) => ({ ...item, sortOrder: idx + 1 }))];
  });
};
```

### Sensor Config
```typescript
const sensors = useSensors(
  useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
  useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
```
The `distance: 5` activation constraint prevents accidental drags when the user just clicks a button.

---

## Feature 3: ▲▼ Button Reordering

- Every item (root AND child) has **▲ Up** and **▼ Down** buttons.
- These move the item one position up/down within the same parent group (same `parentId` and `menuLocation`).
- Like drag-and-drop, these only update **local draft state** — not the database.
- ▲▼ is the **only reordering method for nested child items** since drag handles are not added to children.
- Buttons are visually disabled (grayed out color, `cursor: default`) when the item is already first or last.

```typescript
const handleMoveLocal = (item, siblings, dir) => {
  const idx = siblings.findIndex(s => s.id === item.id);
  const swapIdx = dir === 'up' ? idx - 1 : idx + 1;
  if (swapIdx < 0 || swapIdx >= siblings.length) return;
  const swap = siblings[swapIdx];

  setItems(prev => prev.map(i => {
    if (i.id === item.id) return { ...i, sortOrder: swap.sortOrder };
    if (i.id === swap.id) return { ...i, sortOrder: item.sortOrder };
    return i;
  }));
};
```

---

## Feature 4: ImageBlock Width & Height Controls

### Goal
Allow editors to set custom `width` and `height` on the ImageBlock component from the Puck CMS editor panel, so images can be sized precisely rather than always stretching to full container width.

### Step 1: Update the React Component (`components/sections/ImageBlock.tsx`)

Add `width` and `height` to the props interface and apply them as inline styles:

```tsx
interface ImageBlockProps {
  src?: string;
  alt?: string;
  caption?: string;
  fullWidth?: boolean;
  anchorId?: string;
  width?: string;    // NEW
  height?: string;   // NEW
}

export function ImageBlock({ src = '', alt = '', caption = '', fullWidth = false, anchorId, width, height }: ImageBlockProps) {
  if (!src) return null;

  // Fallback to safe defaults if empty string (Puck passes "" when field is cleared)
  const imgWidth = width?.trim() || '100%';
  const imgHeight = height?.trim() || 'auto';

  return (
    <figure id={anchorId || undefined} className={`${fullWidth ? 'w-full' : 'container max-w-7xl mx-auto px-4'} py-8 scroll-mt-24`}>
      <img
        src={src}
        alt={alt}
        className="max-w-full rounded-xl shadow-sm"
        style={{ width: imgWidth, height: imgHeight, objectFit: 'cover' }}
        loading="lazy"
      />
      {caption && (
        <figcaption className="text-center text-sm text-text-secondary mt-3">{caption}</figcaption>
      )}
    </figure>
  );
}
```

**Key details:**
- Use `max-w-full` (NOT `w-full`) on the className so the image stays responsive on mobile even if a large pixel width is set.
- Apply `objectFit: 'cover'` so the image doesn't distort when both width and height are set.
- The `fullWidth` toggle controls the outer `<figure>` container; `width`/`height` control the `<img>` itself.

### Step 2: Update the Puck Config (`lib/puck/config.tsx`)

Add text fields for width and height to the ImageBlock config:

```tsx
ImageBlock: {
  label: "Image Block",
  fields: {
    // ... existing fields (src, alt, caption, fullWidth) ...
    width: { type: "text", label: "Image Width (e.g., 100%, 500px, auto)" },
    height: { type: "text", label: "Image Height (e.g., auto, 400px)" },
    anchorId: { type: "text", label: "Section ID (for TOC/Nav anchor)" },
  },
  defaultProps: {
    // ... existing defaults ...
    width: "100%",
    height: "auto",
  },
  render: (props: any) => <ImageBlock {...stripPuck(props)} />,
},
```

> [!CAUTION]
> **Empty String Gotcha:** When a user clears the width/height field in Puck, the value becomes `""` (empty string), NOT `undefined`. Default parameters like `width = '100%'` only trigger on `undefined`, so an empty string passes through as `style={{ width: "" }}` — making the image collapse to 0px and disappear. ALWAYS use a runtime fallback: `const imgWidth = width?.trim() || '100%';`

---

## Testing Steps

### Draft Mode
1. Open the Navigation Manager.
2. Rename any menu item using ✏️ Edit.
3. Confirm the live website header **has NOT changed** yet.
4. Confirm the yellow **"⚠️ You have unsaved changes"** warning is visible in the modal header.
5. Click **"Save Changes"**. Refresh the live website. Confirm the new name is now visible.
6. Make another change, then click ✕ Close. Confirm the browser asks: *"You have unsaved changes. Discard?"*
7. Click "Cancel" — confirm the modal stays open. Click "OK" — confirm the changes are discarded.

### Drag-and-Drop
1. Open the Navigation Manager. Confirm a **⠿ grip handle** is visible on the left of each root-level row.
2. Confirm locked Ranktraction **category rows ALSO have a ⠿ handle** and can be dragged to reorder.
3. Confirm child items do NOT have a drag handle.
4. Drag a root item from position 1 to position 3 within the same section.
5. Confirm the order changes in the modal immediately.
6. Confirm the live website is **NOT updated yet**.
7. Click **"Save Changes"**. Refresh the live website. Confirm the new order is applied.

### ▲▼ Buttons
1. Click ▲ on a child item. Confirm it moves up within its parent's group.
2. Click ▼ on a child item. Confirm it moves down.
3. Click ▲ on the first item in a group — confirm the button is visually disabled and nothing happens.
4. Confirm the live website is NOT updated until "Save Changes" is clicked.

### ImageBlock Width & Height
1. Open the Puck editor. Add or select an ImageBlock.
2. In the right panel, set **Width** to `500px`. Confirm the image resizes to 500px wide.
3. Set **Height** to `300px`. Confirm the image is exactly 300px tall with `object-fit: cover` (no distortion).
4. Set **Width** to `50%`. Confirm the image takes half the container width.
5. **Clear both fields** (leave empty). Confirm the image does NOT disappear — it falls back to `100%` width and `auto` height.
6. On mobile viewport, set width to `800px`. Confirm the image does NOT overflow — `max-w-full` keeps it responsive.
7. Publish the page. Confirm the width/height are correctly applied on the live site.

---

## Common Gotchas & Bug Fixes

1. **Double `response.json()` Consumption:** When fetching data from multiple APIs with `Promise.all`, never call `.json()` on the same response twice. The response body stream can only be consumed once. Always save the result to a variable first:
   ```ts
   // ❌ WRONG — consumes body twice
   const data = Array.isArray(await res.json()) ? await res.clone().json() : [];
   
   // ✅ CORRECT — consume once, then check
   const json = await res.json();
   const data = Array.isArray(json) ? json : [];
   ```

2. **Ghost Category Rows Matching as Children (null `parentCategory` bug):** When merging categories into `navigation_items` as ghost rows with `parentCategory: null`, the child-matching filter `child.parentCategory === null` will match ALL items without a parent category — causing every root-level item to appear as a child of every other item. Fix: only match `parentCategory` when the parent IS a category AND the child has a **non-null** `parentCategory` slug:
   ```ts
   // ❌ WRONG — null === null is true for ALL root items
   child.parentCategory === (item.itemType === 'category' ? slug : null)
   
   // ✅ CORRECT — require both sides to be truthy
   if (item.itemType === 'category' && child.parentCategory) {
     return child.parentCategory === catSlug;
   }
   ```

3. **Duplicate Categories in the Live Header After Ghost Row Sync:** `getNavigationTree('top_header', categories)` injects categories from the `categories` table as root nodes. After Phase 8 syncs those same categories into `navigation_items` as ghost rows, the tree builder creates nodes from **both** sources — causing every category to appear twice in the header. Fix: in `getNavigationTree`, check if a category already has a matching `navigation_items` row (by comparing the URL slug) and skip injecting it from the `categories` array:
   ```ts
   const existingCatSlugs = new Set(
     items.filter(i => i.itemType === 'category').map(i => i.url.replace('/category/', ''))
   );
   categories.forEach(cat => {
     if (existingCatSlugs.has(cat.slug)) return; // Skip — already a ghost row
     // ... create node
   });
   ```

4. **Child Pages Under Ghost Categories Appear as Top-Level Links:** Ghost category rows from `navigation_items` are registered in the tree map under their `sourceId` (e.g., `"94"`). But pages with `parentCategory: "生活アイデア"` look for `cat:生活アイデア` in the map. Since there's no match, the child page falls through to `roots.push(node)` and appears as an independent top-level link. Fix: when building the tree map, register ghost category rows under **both** their `sourceId` AND `cat:slug`:
   ```ts
   // In getNavigationTree, after creating the node for a navigation_items row:
   map.set(nodeId, node);
   
   // Also register under cat:slug for parentCategory lookups
   if (item.itemType === 'category' && item.url.startsWith('/category/')) {
     const catSlug = item.url.replace('/category/', '');
     map.set(`cat:${catSlug}`, node);
   }
   ```

---

## ? Phase 8 Completion Verification

Run this checklist to confirm Phase 8 is fully implemented:

- [ ] `npx tsc --noEmit` passes with 0 errors
- [ ] Draft Mode: changes in Nav Manager do NOT affect live site until "Save Changes" is clicked
- [ ] Yellow "?? You have unsaved changes" warning appears when edits are pending
- [ ] "Save Changes" button is disabled when there are no changes
- [ ] Bulk-save API (`POST /api/navigation-items/bulk-save`) correctly upserts and deletes
- [ ] Drag-and-drop reordering works for root-level items (? handle)
- [ ] ?? button reordering works as an alternative to drag-and-drop
- [ ] `sort_order` is persisted correctly after reordering and saving
- [ ] Closing the Nav Manager without saving discards all draft changes

> **Phase 8 is complete when ALL boxes above are checked.**
