handin-website/prompts/slice-6-admin-ui.md
2026-03-02 20:09:27 +01:00

14 KiB

Slice 6: Admin UI

Role

You are a frontend TypeScript/Vue developer adding admin functionality to an existing read-only academic submission website. The backend API and read-only frontend are complete — you are adding token-based authentication, inline editing, asset upload, and drag-and-drop reorder.

Objective

Create the full admin experience: token entry, inline segment editing (create/update/delete), asset uploading, and drag-and-drop segment reorder. Admin controls must be invisible when no token is active. All files must pass vue-tsc --noEmit (strict TypeScript).

Architecture Reference

Read PLAN.md for full architecture context. However, note these deviations from PLAN.md that were made during Slice 5 implementation:

Layout Change (IMPORTANT)

The layout was changed from a sidebar to a horizontal top bar with tabs:

  • Title bar: bg-primary-300 (golden yellow) with dark text, full width at top.
  • Tab navigation: Horizontal tab bar below the title, with border-b-2 border-primary-400 on the active tab. Visible on md+ screens.
  • Mobile: Hamburger dropdown (not slide-out sidebar). Opens a vertical list from the top with backdrop.
  • Content area: Centered max-w-4xl on white background.

The component file names from PLAN.md are preserved but their implementations differ:

  • SidebarNav.vue is actually a horizontal tab bar (desktop).
  • MobileHeader.vue is a hamburger dropdown (mobile).
  • AppShell.vue composes the title bar + tab nav + mobile header + main content slot.

Backend Route Trailing Slashes

Backend collection routes use trailing slashes. Always include them in fetch URLs:

  • GET /api/site/ — not /api/site
  • GET /api/segments/ — not /api/segments
  • POST /api/segments/ — not /api/segments
  • POST /api/assets/ — not /api/assets
  • PUT /api/segments/reorder — no trailing slash (path route, not collection)
  • GET /api/auth/verify — no trailing slash
  • PATCH /api/segments/{id} — no trailing slash (uses PATCH, not PUT)
  • DELETE /api/segments/{id} — no trailing slash
  • DELETE /api/assets/{filename} — no trailing slash

Omitting trailing slashes on collection routes causes 307 redirects which break in the Docker proxy setup (CORS errors).

UI Decision-Making Policy

When you encounter any ambiguity in visual design, layout, spacing, interaction patterns, or component behavior — use AskUserQuestion BEFORE implementing. Do not guess or pick defaults for subjective UI choices. Examples:

  • How the "Add Segment" form should look (modal vs inline vs dropdown)
  • Editor layout for different segment types
  • Confirmation dialogs for delete actions
  • Token input styling and placement
  • Admin toolbar positioning relative to the top bar
  • Drag handle icon/style

Only proceed without asking when this prompt gives an unambiguous, specific instruction.

Coding Standards (mandatory)

  • Vue 3 <script setup lang="ts"> single-file components
  • Strict TypeScript — no any, all props/emits typed
  • Tailwind CSS v4 utility classes (no separate CSS files per component)
  • Composables use ref/computed/onMounted from Vue — no Options API
  • Components are small and focused — one responsibility per file
  • Use @/ path alias for imports (configured in vite.config.ts as src/)
  • No external state management library — use composables with reactive state
  • Use native fetch() — no axios

Step 0: Verify Existing Setup

cd frontend && npm run build

Must exit cleanly. If it fails, fix before proceeding.

Context: Backend API

Auth Mechanism

  • Single shared token set via HANDIN_ADMIN_TOKEN env var (in .env file, value: changeme).
  • Token accepted via ?token=<token> query param OR Authorization: Bearer <token> header.
  • GET /api/auth/verify — returns { "valid": true } if token is valid, 401 otherwise.
  • All write endpoints require the token (POST, PATCH, PUT, DELETE on segments/assets/site).

API Endpoints Used by This Slice

AUTH:
  GET    /api/auth/verify              → { "valid": true } or 401

SEGMENTS (admin):
  POST   /api/segments/               → SegmentResponse (201)
         Body: { "type": SegmentType, "title": string, "content"?: string, "metadata"?: object }

  PATCH  /api/segments/{id}            → SegmentResponse
         Body: { "title"?: string, "content"?: string, "metadata"?: object }
         NOTE: PATCH not PUT — only send changed fields

  DELETE /api/segments/{id}            → 204 No Content

  PUT    /api/segments/reorder         → SegmentResponse[]
         Body: { "segment_ids": string[] }

ASSETS (admin):
  POST   /api/assets/                  → { "filename": "uuid.ext" } (201)
         Body: multipart/form-data with "file" field

  DELETE /api/assets/{filename}        → 204 No Content

SITE (admin):
  PUT    /api/site/                    → { "title": string }
         Body: { "title": string }

Existing Frontend Types

// frontend/src/types/segment.ts
export type SegmentType = "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery";

export interface Segment {
  id: string;
  type: SegmentType;
  sort_order: number;
  title: string;
  content: string;
  metadata: Record<string, unknown>;
  created_at: string;
  updated_at: string;
}

export interface SiteInfo {
  title: string;
}

Existing Composable: useSegments

// frontend/src/composables/useSegments.ts
// Returns: { segments, siteTitle, loading, error, refresh }
// refresh() re-fetches both /api/site/ and /api/segments/
// Call refresh() after any admin mutation to sync the UI

Existing Layout Structure

AppShell.vue
├── <header> — yellow title bar (bg-primary-300), shows siteTitle
├── SidebarNav.vue — horizontal tab bar (hidden below md)
├── MobileHeader.vue — hamburger dropdown (hidden at md+)
└── <main> — slot for page content (max-w-4xl centered)

Existing Segment List

<!-- SegmentList.vue renders each segment as: -->
<section :id="segment.id" class="rounded-lg bg-white p-6 shadow-sm">
  <h2>{{ segment.title }}</h2>
  <SegmentRenderer :segment="segment" />
</section>

Step 1: Create useAdmin Composable

frontend/src/composables/useAdmin.ts

Manages admin authentication state. Exports a composable useAdmin() that returns:

  • token: Ref<string | null> — current token (persisted in sessionStorage)
  • isAdmin: Ref<boolean> — true when token is verified
  • verifying: Ref<boolean> — true during verification request
  • login(token: string): Promise<boolean> — verify token via GET /api/auth/verify, persist if valid
  • logout(): void — clear token and admin state
  • authHeaders: ComputedRef<Record<string, string>> — returns { "Authorization": "Bearer <token>" } when authenticated, empty object otherwise
  • authQuery: ComputedRef<string> — returns ?token=<token> or empty string (useful for asset URLs)

On creation, check sessionStorage for an existing token and re-verify it silently.

Important: This composable must use module-level reactive state (defined outside the function) so that all components share the same auth state. The function just returns references to the shared state.

Step 2: Create useAssetUpload Composable

frontend/src/composables/useAssetUpload.ts

Handles file uploads. Exports useAssetUpload() that returns:

  • uploading: Ref<boolean>
  • error: Ref<string | null>
  • upload(file: File, authHeaders: Record<string, string>): Promise<string | null> — uploads file to POST /api/assets/, returns the filename on success or null on failure

Use FormData with native fetch(). Set error on failure with the server's error message.

Step 3: Create Admin Components

frontend/src/components/admin/TokenPrompt.vue

The entry point for admin access. When no token is active:

  • Show a small, subtle lock icon in the top bar (inside AppShell's header area)
  • Clicking it reveals an inline token input field with a submit button
  • On successful login, the input disappears and admin controls appear

Props: none (uses useAdmin composable directly).

Emits:

  • authenticated — fired when login succeeds

frontend/src/components/admin/AdminToolbar.vue

A thin bar shown below the tab nav when admin is authenticated. Contains:

  • An "Editing" badge (subtle indicator)
  • An "Add Segment" button that triggers segment creation
  • A "Logout" button

Props: none (uses useAdmin composable directly).

Emits:

  • add-segment — when "Add Segment" is clicked
  • logout — when "Logout" is clicked

frontend/src/components/admin/SegmentEditor.vue

Inline editor that appears below a segment card when editing. Handles:

  • Editing title (text input)
  • Editing content based on segment type:
    • markdown: textarea for raw markdown
    • pdf, video, audio: file upload (via AssetUploader) or URL input showing current asset
    • iframe: URL text input
    • gallery: file upload for multiple images, display current images with remove buttons
  • Save and Cancel buttons
  • Delete button (with confirmation)

Props:

  • segment: Segment

Emits:

  • save(updates: { title?: string; content?: string; metadata?: Record<string, unknown> }) — partial update
  • cancel — close editor
  • delete — delete this segment

frontend/src/components/admin/AssetUploader.vue

Drag-and-drop file upload zone. Features:

  • Dashed border drop zone
  • Click to select file
  • Shows upload progress/status
  • Returns the uploaded filename

Props:

  • accept?: string — file type filter (e.g., "application/pdf", "image/*")
  • label?: string — descriptive text inside the zone

Emits:

  • uploaded(filename: string) — when upload completes successfully

Step 4: Integrate Admin into Existing Components

Modify AppShell.vue

  • Add TokenPrompt to the title bar header (when not authenticated)
  • Add AdminToolbar below the tab nav (when authenticated)
  • Pass admin state down or let child components use useAdmin directly

Modify SegmentList.vue

When admin is active:

  • Show a small edit icon (pencil) on each segment card header (next to the title)
  • Clicking the edit icon toggles SegmentEditor inline below that segment
  • Show drag handles on each card for reorder (use vuedraggable)
  • After drag-and-drop reorder, call PUT /api/segments/reorder with the new ID order, then refresh()

The vuedraggable package is already installed ("vuedraggable": "4.1.0" in package.json). Import it as:

import draggable from "vuedraggable";

Note: vuedraggable 4.x may not have TypeScript declarations. If vue-tsc complains, create a type declaration file frontend/src/types/vuedraggable.d.ts:

declare module "vuedraggable" {
  import type { DefineComponent } from "vue";
  const component: DefineComponent;
  export default component;
}

Modify HomePage.vue

  • Wire up the "Add Segment" flow: when AdminToolbar emits add-segment, show a creation form (could be a simple modal or inline form at the top/bottom of the segment list)
  • After creating a segment, call refresh() to reload the list
  • After editing/deleting a segment, call refresh()
  • Handle logout: call useAdmin().logout()

Step 5: Segment Creation Flow

When "Add Segment" is clicked, the user needs to:

  1. Choose a segment type (dropdown or button group)
  2. Enter a title
  3. Provide initial content (type-dependent: text for markdown, URL for iframe, file upload for pdf/video/audio, etc.)
  4. Submit → POST /api/segments/ with auth header → refresh()

Step 6: Wire Up All Mutations

All admin mutations must:

  1. Include auth headers from useAdmin().authHeaders
  2. Call useSegments().refresh() after success to sync the UI
  3. Handle errors gracefully (show error message, don't lose user input)

Mutation summary:

Action Method Endpoint Auth After
Create segment POST /api/segments/ Bearer header refresh()
Update segment PATCH /api/segments/{id} Bearer header refresh()
Delete segment DELETE /api/segments/{id} Bearer header refresh()
Reorder segments PUT /api/segments/reorder Bearer header refresh()
Upload asset POST /api/assets/ Bearer header use filename
Delete asset DELETE /api/assets/{filename} Bearer header refresh()
Update site title PUT /api/site/ Bearer header refresh()

Verification

After implementation, run:

cd frontend && npm run build

Must succeed with zero errors. Then verify functionally:

cd frontend && npm run dev

Or with Docker:

docker compose up --build
# Visit http://localhost:5174

Check:

  1. No admin UI visible without token — only the lock icon in the header
  2. Click lock icon → enter token changeme → admin controls appear
  3. Admin toolbar shows with "Add Segment" and "Logout" buttons
  4. Create a markdown segment → appears in the list
  5. Edit a segment's title and content → saves correctly
  6. Delete a segment → removed from list (after confirmation)
  7. Drag-and-drop reorder segments → order persists after refresh
  8. Upload an asset (PDF, image) → segment displays it
  9. Logout → all admin UI disappears, read-only view restored
  10. No TypeScript errors (npm run type-check)

Files to Create/Modify

Action File
CREATE frontend/src/composables/useAdmin.ts
CREATE frontend/src/composables/useAssetUpload.ts
CREATE frontend/src/components/admin/TokenPrompt.vue
CREATE frontend/src/components/admin/AdminToolbar.vue
CREATE frontend/src/components/admin/SegmentEditor.vue
CREATE frontend/src/components/admin/AssetUploader.vue
CREATE frontend/src/types/vuedraggable.d.ts (if needed for type-check)
MODIFY frontend/src/components/layout/AppShell.vue
MODIFY frontend/src/components/segments/SegmentList.vue
MODIFY frontend/src/views/HomePage.vue