From d4966520d58a8985f847454b5178fce6a03a1715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CChris?= <“chris@cgbarlow.com> Date: Sun, 8 Feb 2026 21:09:01 +0000 Subject: [PATCH] feat: Add quest featured image with save button UX improvements - Add featured_image_url column to quests table (migration 142) - Add uploadQuestFeaturedImage and removeQuestFeaturedImage server actions - Add featured image upload to GM quest edit form - Add placeholder for featured image in GM quest create form - Display featured image in quest detail view (below Description) - Improve save button UX: duplicate to top, ghosted when clean, green when dirty - Add SPEC-015 documentation for the feature --- docs/specs/SPEC-015-Quest-Featured-Image.md | 92 ++++++++++++ src/components/gm/quest-edit-form.tsx | 72 +++++++++- src/components/gm/quest-form.tsx | 11 ++ src/components/quests/quest-detail.tsx | 16 +++ src/lib/actions/badge.ts | 135 ++++++++++++++++++ src/lib/actions/quests.ts | 3 + src/lib/hooks/use-templates.ts | 1 + src/lib/types/quest.ts | 1 + .../142_add_quest_featured_image.sql | 5 + 9 files changed, 333 insertions(+), 3 deletions(-) create mode 100644 docs/specs/SPEC-015-Quest-Featured-Image.md create mode 100644 supabase/migrations/142_add_quest_featured_image.sql diff --git a/docs/specs/SPEC-015-Quest-Featured-Image.md b/docs/specs/SPEC-015-Quest-Featured-Image.md new file mode 100644 index 0000000..713db17 --- /dev/null +++ b/docs/specs/SPEC-015-Quest-Featured-Image.md @@ -0,0 +1,92 @@ +# SPEC-015: Quest Featured Image + +## Overview + +Add a featured image capability to quests that displays prominently in the quest detail view, below the Description section and above the Narrative Context. + +## Database Schema + +### New Column + +```sql +ALTER TABLE quests ADD COLUMN IF NOT EXISTS featured_image_url TEXT; +COMMENT ON COLUMN quests.featured_image_url IS 'URL of the featured image displayed in quest detail view'; +``` + +## Storage Configuration + +### Storage Path + +``` +featured-images/{questId}/featured-{timestamp}.{ext} +``` + +Uses the existing `avatars` bucket (same as quest badges). + +### File Limits + +- **Maximum size**: 5MB +- **Allowed types**: JPEG, PNG, GIF, WebP + +## UI Specifications + +### Quest Detail View + +- **Location**: Below Description section, above Narrative Context +- **Aspect Ratio**: 16:9 recommended, but smaller images should not be stretched +- **Sizing**: Full width of content area, height auto-adjusts to maintain aspect ratio +- **Container**: Rounded corners, overflow hidden + +### GM Quest Edit Form + +#### Featured Image Upload + +- **Location**: After badge upload section in Basic Information card +- **Component**: `ImageUpload` with `aspectRatio="video"` and `size="lg"` +- **Label**: "Featured Image" +- **Helper text**: "Banner image for quest detail view (16:9 recommended)" + +#### Save Button UX Improvements + +1. **Duplicate to top**: Add save button after status actions in header +2. **Ghosted when clean**: Button disabled and muted when no unsaved changes +3. **Green when dirty**: Button shows green styling when form has changes +4. **Form ID binding**: Both buttons reference same form via `form="quest-edit-form"` + +### GM Quest Create Form + +- **Placeholder state**: Disabled area with message "Save the quest first, then edit to add a featured image" +- Featured image upload only available after quest creation (in edit mode) + +## Server Actions + +### `uploadQuestFeaturedImage(questId: string, formData: FormData)` + +- Validates file type and size +- Deletes existing featured images in path `featured-images/${questId}/` +- Uploads new image with timestamp: `featured-images/${questId}/featured-${Date.now()}.${ext}` +- Updates `featured_image_url` column in quests table +- Revalidates quest-related paths + +### `removeQuestFeaturedImage(questId: string)` + +- Deletes files from `featured-images/${questId}/` +- Sets `featured_image_url` to null +- Revalidates quest-related paths + +## Implementation Files + +| File | Changes | +|------|---------| +| `supabase/migrations/142_add_quest_featured_image.sql` | Add column | +| `src/lib/types/quest.ts` | Add `featured_image_url` field | +| `src/lib/actions/badge.ts` | Add upload/remove functions | +| `src/components/gm/quest-edit-form.tsx` | Image upload, save button UX | +| `src/components/gm/quest-form.tsx` | Placeholder for create mode | +| `src/components/quests/quest-detail.tsx` | Display featured image | + +## Notes + +- Uses `object-contain` and `h-auto` CSS so smaller images don't stretch +- Next.js Image component handles optimization +- Reuses same storage bucket and policies as quest badges diff --git a/src/components/gm/quest-edit-form.tsx b/src/components/gm/quest-edit-form.tsx index 58e0405..56a8791 100644 --- a/src/components/gm/quest-edit-form.tsx +++ b/src/components/gm/quest-edit-form.tsx @@ -34,7 +34,8 @@ import { ObjectiveEditor } from './objective-editor' import { PrerequisiteSelector } from './prerequisite-selector' import { QuestionEditor } from './question-editor' import { ImageUpload } from '@/components/ui/image-upload' -import { uploadQuestBadge, removeQuestBadge } from '@/lib/actions/badge' +import { uploadQuestBadge, removeQuestBadge, uploadQuestFeaturedImage, removeQuestFeaturedImage } from '@/lib/actions/badge' +import { cn } from '@/lib/utils' import { useQueryClient } from '@tanstack/react-query' import { questFormSchema, type QuestFormData, type QuestDifficultyType } from '@/lib/schemas/quest.schema' import type { Quest, QuestDbStatus, QuestResource } from '@/lib/types/quest' @@ -96,6 +97,27 @@ export function QuestEditForm({ quest }: QuestEditFormProps) { return result } + // Featured image handlers + const handleFeaturedImageUpload = async (file: File) => { + const formData = new FormData() + formData.append('file', file) + const result = await uploadQuestFeaturedImage(quest.id, formData) + if (result.success) { + queryClient.invalidateQueries({ queryKey: ['quest', quest.id] }) + queryClient.invalidateQueries({ queryKey: ['quests'] }) + } + return result + } + + const handleFeaturedImageRemove = async () => { + const result = await removeQuestFeaturedImage(quest.id) + if (result.success) { + queryClient.invalidateQueries({ queryKey: ['quest', quest.id] }) + queryClient.invalidateQueries({ queryKey: ['quests'] }) + } + return result + } + const { register, handleSubmit, @@ -333,7 +355,26 @@ export function QuestEditForm({ quest }: QuestEditFormProps) { -
+ {/* Top Save Button */} +
+ +
+ + {/* Basic Info */} @@ -371,6 +412,22 @@ export function QuestEditForm({ quest }: QuestEditFormProps) { + {/* Featured Image */} +
+ +

+ Banner image for quest detail view (16:9 recommended) +

+ +
+ {/* Description */}
@@ -710,7 +767,16 @@ export function QuestEditForm({ quest }: QuestEditFormProps) { {/* Save button */}
-
+ {/* Featured Image Placeholder */} +
+ +

+ Save the quest first, then edit to add a featured image +

+
+ Available after quest creation +
+
+ {/* Description */}
diff --git a/src/components/quests/quest-detail.tsx b/src/components/quests/quest-detail.tsx index 76cba1c..198a8c3 100644 --- a/src/components/quests/quest-detail.tsx +++ b/src/components/quests/quest-detail.tsx @@ -138,6 +138,22 @@ export function QuestDetail({

+ {/* Featured Image */} + {quest.featured_image_url && ( +
+
+ {`${quest.title} +
+
+ )} + {/* Narrative Context */} {quest.narrative_context && (
diff --git a/src/lib/actions/badge.ts b/src/lib/actions/badge.ts index 599e67a..0abcfcb 100644 --- a/src/lib/actions/badge.ts +++ b/src/lib/actions/badge.ts @@ -144,3 +144,138 @@ export async function removeQuestBadge(questId: string): Promise { + const supabase = await createClient() + + // Get current user and verify GM role + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) { + return { success: false, error: 'Not authenticated' } + } + + // Verify user is a GM + const { data: isGm } = await supabase.rpc('is_gm') + if (!isGm) { + return { success: false, error: 'Not authorized' } + } + + const file = formData.get('file') as File | null + if (!file) { + return { success: false, error: 'No file provided' } + } + + // Validate file type + if (!ALLOWED_TYPES.includes(file.type)) { + return { success: false, error: 'Invalid file type. Please upload a JPEG, PNG, GIF, or WebP image.' } + } + + // Validate file size + if (file.size > MAX_FILE_SIZE) { + return { success: false, error: 'File too large. Maximum size is 5MB.' } + } + + // Generate unique filename + const fileExt = file.name.split('.').pop()?.toLowerCase() || 'png' + const fileName = `featured-images/${questId}/featured-${Date.now()}.${fileExt}` + + // Delete old featured image if exists + const { data: existingFiles } = await supabase.storage + .from('avatars') + .list(`featured-images/${questId}`) + + if (existingFiles && existingFiles.length > 0) { + const filesToDelete = existingFiles.map(f => `featured-images/${questId}/${f.name}`) + await supabase.storage.from('avatars').remove(filesToDelete) + } + + // Upload new featured image + const { error: uploadError } = await supabase.storage + .from('avatars') + .upload(fileName, file, { + cacheControl: '3600', + upsert: true, + }) + + if (uploadError) { + console.error('Featured image upload error:', uploadError) + return { success: false, error: uploadError.message } + } + + // Get public URL + const { data: { publicUrl } } = supabase.storage + .from('avatars') + .getPublicUrl(fileName) + + // Update quest with new featured image URL + const { error: updateError } = await (supabase + .from('quests') as ReturnType) + .update({ featured_image_url: publicUrl, updated_at: new Date().toISOString() } as Record) + .eq('id', questId) + + if (updateError) { + console.error('Quest update error:', updateError) + return { success: false, error: 'Failed to update quest with new featured image' } + } + + revalidatePath('/gm/quests') + revalidatePath(`/gm/quests/${questId}`) + revalidatePath('/quests') + revalidatePath(`/quests/${questId}`) + revalidatePath('/dashboard') + revalidatePath('/my-quests') + + return { success: true, url: publicUrl } +} + +/** + * Remove a quest's featured image + */ +export async function removeQuestFeaturedImage(questId: string): Promise { + const supabase = await createClient() + + // Get current user and verify GM role + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) { + return { success: false, error: 'Not authenticated' } + } + + // Verify user is a GM + const { data: isGm } = await supabase.rpc('is_gm') + if (!isGm) { + return { success: false, error: 'Not authorized' } + } + + // Delete featured image files + const { data: existingFiles } = await supabase.storage + .from('avatars') + .list(`featured-images/${questId}`) + + if (existingFiles && existingFiles.length > 0) { + const filesToDelete = existingFiles.map(f => `featured-images/${questId}/${f.name}`) + await supabase.storage.from('avatars').remove(filesToDelete) + } + + // Clear featured image URL in quest + const { error: updateError } = await (supabase + .from('quests') as ReturnType) + .update({ featured_image_url: null, updated_at: new Date().toISOString() } as Record) + .eq('id', questId) + + if (updateError) { + console.error('Quest update error:', updateError) + return { success: false, error: 'Failed to update quest' } + } + + revalidatePath('/gm/quests') + revalidatePath(`/gm/quests/${questId}`) + revalidatePath('/quests') + revalidatePath(`/quests/${questId}`) + revalidatePath('/dashboard') + revalidatePath('/my-quests') + + return { success: true } +} diff --git a/src/lib/actions/quests.ts b/src/lib/actions/quests.ts index ee7e155..4c10654 100644 --- a/src/lib/actions/quests.ts +++ b/src/lib/actions/quests.ts @@ -80,6 +80,7 @@ export async function getPublishedQuests(filters?: { design_notes: quest.design_notes as string | null, featured: (quest.featured as boolean) ?? false, badge_url: quest.badge_url as string | null, + featured_image_url: quest.featured_image_url as string | null, is_exclusive: (quest.is_exclusive as boolean) ?? false, exclusive_code: quest.exclusive_code as string | null, is_side_quest: (quest.is_side_quest as boolean) ?? false, @@ -156,6 +157,7 @@ export async function getQuestById(questId: string): Promise { design_notes: quest.design_notes as string | null, featured: (quest.featured as boolean) ?? false, badge_url: quest.badge_url as string | null, + featured_image_url: quest.featured_image_url as string | null, is_exclusive: (quest.is_exclusive as boolean) ?? false, exclusive_code: quest.exclusive_code as string | null, is_side_quest: (quest.is_side_quest as boolean) ?? false, diff --git a/src/lib/hooks/use-templates.ts b/src/lib/hooks/use-templates.ts index 9f23147..a7fccb2 100644 --- a/src/lib/hooks/use-templates.ts +++ b/src/lib/hooks/use-templates.ts @@ -53,6 +53,7 @@ async function fetchTemplates(): Promise { design_notes: quest.design_notes ?? null, featured: quest.featured ?? false, badge_url: quest.badge_url ?? null, + featured_image_url: (quest as unknown as { featured_image_url?: string | null }).featured_image_url ?? null, is_exclusive: (quest as unknown as { is_exclusive?: boolean }).is_exclusive ?? false, exclusive_code: (quest as unknown as { exclusive_code?: string | null }).exclusive_code ?? null, is_side_quest: (quest as unknown as { is_side_quest?: boolean }).is_side_quest ?? false, diff --git a/src/lib/types/quest.ts b/src/lib/types/quest.ts index 6d4d830..5c4b789 100644 --- a/src/lib/types/quest.ts +++ b/src/lib/types/quest.ts @@ -116,6 +116,7 @@ export interface Quest { design_notes: string | null featured: boolean badge_url: string | null + featured_image_url: string | null is_exclusive: boolean exclusive_code: string | null is_side_quest: boolean diff --git a/supabase/migrations/142_add_quest_featured_image.sql b/supabase/migrations/142_add_quest_featured_image.sql new file mode 100644 index 0000000..65ea4c9 --- /dev/null +++ b/supabase/migrations/142_add_quest_featured_image.sql @@ -0,0 +1,5 @@ +-- Add featured_image_url column to quests table +-- This stores the URL of the featured image displayed in quest detail view + +ALTER TABLE quests ADD COLUMN IF NOT EXISTS featured_image_url TEXT; +COMMENT ON COLUMN quests.featured_image_url IS 'URL of the featured image displayed in quest detail view';