Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions docs/specs/SPEC-015-Quest-Featured-Image.md
Original file line number Diff line number Diff line change
@@ -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
72 changes: 69 additions & 3 deletions src/components/gm/quest-edit-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -333,7 +355,26 @@ export function QuestEditForm({ quest }: QuestEditFormProps) {
</div>
</div>

<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
{/* Top Save Button */}
<div className="flex justify-end">
<Button
type="submit"
form="quest-edit-form"
disabled={isSaving || !isDirty}
className={cn(
"transition-colors",
isDirty
? "bg-green-600 hover:bg-green-700 text-white"
: "bg-muted text-muted-foreground"
)}
>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Save className="mr-2 h-4 w-4" />
Save Changes
</Button>
</div>

<form id="quest-edit-form" onSubmit={handleSubmit(onSubmit)} className="space-y-8">
{/* Basic Info */}
<Card>
<CardHeader>
Expand Down Expand Up @@ -371,6 +412,22 @@ export function QuestEditForm({ quest }: QuestEditFormProps) {
</div>
</div>

{/* Featured Image */}
<div className="space-y-2">
<Label>Featured Image</Label>
<p className="text-sm text-muted-foreground">
Banner image for quest detail view (16:9 recommended)
</p>
<ImageUpload
currentImageUrl={quest.featured_image_url}
onUpload={handleFeaturedImageUpload}
onRemove={handleFeaturedImageRemove}
aspectRatio="video"
size="lg"
placeholderText="Featured Image"
/>
</div>

{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
Expand Down Expand Up @@ -710,7 +767,16 @@ export function QuestEditForm({ quest }: QuestEditFormProps) {

{/* Save button */}
<div className="flex justify-end">
<Button type="submit" disabled={isSaving || !isDirty}>
<Button
type="submit"
disabled={isSaving || !isDirty}
className={cn(
"transition-colors",
isDirty
? "bg-green-600 hover:bg-green-700 text-white"
: "bg-muted text-muted-foreground"
)}
>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Save className="mr-2 h-4 w-4" />
Save Changes
Expand Down
11 changes: 11 additions & 0 deletions src/components/gm/quest-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,17 @@ export function QuestForm({ initialData, onSuccess }: QuestFormProps) {
)}
</div>

{/* Featured Image Placeholder */}
<div className="space-y-2">
<Label>Featured Image</Label>
<p className="text-sm text-muted-foreground">
Save the quest first, then edit to add a featured image
</p>
<div className="h-32 rounded-lg border-2 border-dashed border-muted flex items-center justify-center text-muted-foreground text-sm">
Available after quest creation
</div>
</div>

{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
Expand Down
16 changes: 16 additions & 0 deletions src/components/quests/quest-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,22 @@ export function QuestDetail({
</p>
</div>

{/* Featured Image */}
{quest.featured_image_url && (
<div className="w-full">
<div className="relative w-full rounded-lg overflow-hidden">
<Image
src={quest.featured_image_url}
alt={`${quest.title} featured image`}
width={800}
height={450}
className="w-full h-auto object-contain"
priority
/>
</div>
</div>
)}

{/* Narrative Context */}
{quest.narrative_context && (
<div className="rounded-lg bg-purple-50 dark:bg-purple-950/20 border border-purple-200 dark:border-purple-800 p-4">
Expand Down
135 changes: 135 additions & 0 deletions src/lib/actions/badge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,138 @@ export async function removeQuestBadge(questId: string): Promise<BadgeUploadResu

return { success: true }
}

/**
* Upload a quest featured image to Supabase Storage
*/
export async function uploadQuestFeaturedImage(questId: string, formData: FormData): Promise<BadgeUploadResult> {
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<typeof supabase.from>)
.update({ featured_image_url: publicUrl, updated_at: new Date().toISOString() } as Record<string, unknown>)
.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<BadgeUploadResult> {
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<typeof supabase.from>)
.update({ featured_image_url: null, updated_at: new Date().toISOString() } as Record<string, unknown>)
.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 }
}
Loading