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-400on the active tab. Visible onmd+screens. - Mobile: Hamburger dropdown (not slide-out sidebar). Opens a vertical list from the top with backdrop.
- Content area: Centered
max-w-4xlon white background.
The component file names from PLAN.md are preserved but their implementations differ:
SidebarNav.vueis actually a horizontal tab bar (desktop).MobileHeader.vueis a hamburger dropdown (mobile).AppShell.vuecomposes 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/siteGET /api/segments/— not/api/segmentsPOST /api/segments/— not/api/segmentsPOST /api/assets/— not/api/assetsPUT /api/segments/reorder— no trailing slash (path route, not collection)GET /api/auth/verify— no trailing slashPATCH /api/segments/{id}— no trailing slash (uses PATCH, not PUT)DELETE /api/segments/{id}— no trailing slashDELETE /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/onMountedfrom Vue — no Options API - Components are small and focused — one responsibility per file
- Use
@/path alias for imports (configured in vite.config.ts assrc/) - 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_TOKENenv var (in.envfile, value:changeme). - Token accepted via
?token=<token>query param ORAuthorization: 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 insessionStorage)isAdmin: Ref<boolean>— true when token is verifiedverifying: Ref<boolean>— true during verification requestlogin(token: string): Promise<boolean>— verify token viaGET /api/auth/verify, persist if validlogout(): void— clear token and admin stateauthHeaders: ComputedRef<Record<string, string>>— returns{ "Authorization": "Bearer <token>" }when authenticated, empty object otherwiseauthQuery: 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 toPOST /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 clickedlogout— 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 markdownpdf,video,audio: file upload (via AssetUploader) or URL input showing current assetiframe: URL text inputgallery: 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 updatecancel— close editordelete— 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
TokenPromptto the title bar header (when not authenticated) - Add
AdminToolbarbelow the tab nav (when authenticated) - Pass admin state down or let child components use
useAdmindirectly
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
SegmentEditorinline below that segment - Show drag handles on each card for reorder (use
vuedraggable) - After drag-and-drop reorder, call
PUT /api/segments/reorderwith the new ID order, thenrefresh()
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
AdminToolbaremitsadd-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:
- Choose a segment type (dropdown or button group)
- Enter a title
- Provide initial content (type-dependent: text for markdown, URL for iframe, file upload for pdf/video/audio, etc.)
- Submit →
POST /api/segments/with auth header →refresh()
Step 6: Wire Up All Mutations
All admin mutations must:
- Include auth headers from
useAdmin().authHeaders - Call
useSegments().refresh()after success to sync the UI - 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:
- No admin UI visible without token — only the lock icon in the header
- Click lock icon → enter token
changeme→ admin controls appear - Admin toolbar shows with "Add Segment" and "Logout" buttons
- Create a markdown segment → appears in the list
- Edit a segment's title and content → saves correctly
- Delete a segment → removed from list (after confirmation)
- Drag-and-drop reorder segments → order persists after refresh
- Upload an asset (PDF, image) → segment displays it
- Logout → all admin UI disappears, read-only view restored
- 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 |