Fix image handling in forms

This commit is contained in:
Justin Edmund 2025-06-10 20:48:02 -07:00
parent 5edc7eb33b
commit b3c9529e3f
11 changed files with 293 additions and 81 deletions

View file

@ -18,12 +18,12 @@
{/if}
</label>
{@render children?.()}
{#if helpText}
<p class="help-text">{helpText}</p>
{/if}
{@render children?.()}
{#if error}
<p class="error-text">{error}</p>
{/if}
@ -39,7 +39,8 @@
&.has-error {
:global(input),
:global(textarea) {
:global(textarea),
:global(select) {
border-color: #c33;
}
}

View file

@ -11,6 +11,7 @@
label: string
value?: Media | null
onUpload: (media: Media) => void
onRemove?: () => void
aspectRatio?: string // e.g., "16:9", "1:1"
required?: boolean
error?: string
@ -26,6 +27,7 @@
label,
value = $bindable(),
onUpload,
onRemove,
aspectRatio,
required = false,
error,
@ -182,6 +184,7 @@
altTextValue = ''
descriptionValue = ''
uploadError = null
onRemove?.()
}
// Update alt text on server

View file

@ -1,21 +1,26 @@
<script lang="ts">
import Input from './Input.svelte'
import ImageUploader from './ImageUploader.svelte'
import Button from './Button.svelte'
import type { ProjectFormData } from '$lib/types/project'
import type { Media } from '@prisma/client'
interface Props {
formData: ProjectFormData
onSave?: () => Promise<void>
}
let { formData = $bindable() }: Props = $props()
let { formData = $bindable(), onSave }: Props = $props()
// State for collapsible logo section
let showLogoSection = $state(!!formData.logoUrl && formData.logoUrl.trim() !== '')
// Convert logoUrl string to Media object for ImageUploader
let logoMedia = $state<Media | null>(null)
// Update logoMedia when logoUrl changes
$effect(() => {
if (formData.logoUrl && !logoMedia) {
if (formData.logoUrl && formData.logoUrl.trim() !== '' && !logoMedia) {
// Create a minimal Media object from the URL for display
logoMedia = {
id: -1, // Temporary ID for existing URLs
@ -33,8 +38,13 @@
createdAt: new Date(),
updatedAt: new Date()
}
} else if (!formData.logoUrl) {
logoMedia = null
}
})
// Sync logoMedia changes back to formData
$effect(() => {
if (!logoMedia && formData.logoUrl) {
formData.logoUrl = ''
}
})
@ -43,27 +53,63 @@
logoMedia = media
}
function handleLogoRemove() {
async function handleLogoRemove() {
formData.logoUrl = ''
logoMedia = null
showLogoSection = false
// Auto-save the removal
if (onSave) {
await onSave()
}
}
</script>
<div class="form-section">
<h2>Branding</h2>
<ImageUploader
label="Project Logo"
value={logoMedia}
onUpload={handleLogoUpload}
aspectRatio="1:1"
allowAltText={true}
maxFileSize={0.5}
placeholder="Drag and drop an SVG logo here, or click to browse"
helpText="Upload an SVG logo for project thumbnail (max 500KB). Square logos work best."
showBrowseLibrary={true}
compact={true}
/>
{#if !showLogoSection && (!formData.logoUrl || formData.logoUrl.trim() === '')}
<Button
variant="secondary"
buttonSize="medium"
onclick={() => showLogoSection = true}
iconPosition="left"
>
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="12" y1="3" x2="12" y2="21"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
</svg>
Add Project Logo
</Button>
{:else}
<div class="collapsible-section">
<div class="section-header">
<h3>Project Logo</h3>
</div>
<ImageUploader
label=""
bind:value={logoMedia}
onUpload={handleLogoUpload}
onRemove={handleLogoRemove}
aspectRatio="1:1"
allowAltText={true}
maxFileSize={0.5}
placeholder="Drag and drop an SVG logo here, or click to browse"
helpText="Upload an SVG logo for project thumbnail (max 500KB). Square logos work best."
showBrowseLibrary={true}
compact={true}
/>
</div>
{/if}
</div>
<style lang="scss">
@ -81,4 +127,23 @@
color: $grey-10;
}
}
.collapsible-section {
// No border or background needed
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $unit-2x;
h3 {
font-size: 0.875rem;
font-weight: 600;
margin: 0;
color: $grey-20;
}
}
</style>

View file

@ -7,7 +7,6 @@
import Editor from './Editor.svelte'
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
import ProjectGalleryForm from './ProjectGalleryForm.svelte'
import ProjectStylingForm from './ProjectStylingForm.svelte'
import Button from './Button.svelte'
import StatusDropdown from './StatusDropdown.svelte'
@ -60,11 +59,10 @@
role: data.role || '',
projectType: data.projectType || 'work',
externalUrl: data.externalUrl || '',
featuredImage: data.featuredImage || null,
featuredImage: data.featuredImage && data.featuredImage.trim() !== '' ? data.featuredImage : null,
backgroundColor: data.backgroundColor || '',
highlightColor: data.highlightColor || '',
logoUrl: data.logoUrl || '',
gallery: data.gallery || null,
logoUrl: data.logoUrl && data.logoUrl.trim() !== '' ? data.logoUrl : '',
status: data.status || 'draft',
password: data.password || '',
caseStudyContent: data.caseStudyContent || {
@ -142,9 +140,8 @@
role: formData.role,
projectType: formData.projectType,
externalUrl: formData.externalUrl,
featuredImage: formData.featuredImage,
logoUrl: formData.logoUrl,
gallery: formData.gallery && formData.gallery.length > 0 ? formData.gallery : null,
featuredImage: formData.featuredImage && formData.featuredImage !== '' ? formData.featuredImage : null,
logoUrl: formData.logoUrl && formData.logoUrl !== '' ? formData.logoUrl : null,
backgroundColor: formData.backgroundColor,
highlightColor: formData.highlightColor,
status: formData.status,
@ -266,9 +263,8 @@
handleSave()
}}
>
<ProjectMetadataForm bind:formData {validationErrors} />
<ProjectBrandingForm bind:formData />
<ProjectGalleryForm bind:formData />
<ProjectMetadataForm bind:formData {validationErrors} onSave={handleSave} />
<ProjectBrandingForm bind:formData onSave={handleSave} />
<ProjectStylingForm bind:formData {validationErrors} />
</form>
</div>

View file

@ -1,19 +1,71 @@
<script lang="ts">
import Input from './Input.svelte'
import Select from './Select.svelte'
import SelectField from './SelectField.svelte'
import ImageUploader from './ImageUploader.svelte'
import Button from './Button.svelte'
import type { ProjectFormData } from '$lib/types/project'
import type { Media } from '@prisma/client'
interface Props {
formData: ProjectFormData
validationErrors: Record<string, string>
onSave?: () => Promise<void>
}
let { formData = $bindable(), validationErrors }: Props = $props()
let { formData = $bindable(), validationErrors, onSave }: Props = $props()
// State for collapsible featured image section
let showFeaturedImage = $state(!!formData.featuredImage && formData.featuredImage !== '' && formData.featuredImage !== null)
// Convert featuredImage string to Media object for ImageUploader
let featuredImageMedia = $state<Media | null>(null)
// Initialize media object from existing featuredImage URL
$effect(() => {
if (formData.featuredImage && formData.featuredImage !== '' && formData.featuredImage !== null && !featuredImageMedia) {
// Only create a minimal Media object if we don't already have one
featuredImageMedia = {
id: -1, // Temporary ID for existing URLs
filename: 'featured-image',
originalName: 'featured-image',
mimeType: 'image/jpeg',
size: 0,
url: formData.featuredImage,
thumbnailUrl: formData.featuredImage,
width: null,
height: null,
altText: null,
description: null,
usedIn: [],
createdAt: new Date(),
updatedAt: new Date()
}
}
})
// Sync featuredImageMedia changes back to formData
$effect(() => {
if (!featuredImageMedia && formData.featuredImage) {
formData.featuredImage = ''
}
})
function handleFeaturedImageUpload(media: Media) {
formData.featuredImage = media.url
featuredImageMedia = media
}
async function handleFeaturedImageRemove() {
formData.featuredImage = ''
featuredImageMedia = null
showFeaturedImage = false
// Auto-save the removal
if (onSave) {
await onSave()
}
}
</script>
<div class="form-section">
@ -34,7 +86,7 @@
placeholder="Short description for project cards"
/>
<Select
<SelectField
label="Project Type"
bind:value={formData.projectType}
error={validationErrors.projectType}
@ -72,26 +124,43 @@
placeholder="https://example.com"
/>
<ImageUploader
label="Featured Image"
value={null}
onUpload={handleFeaturedImageUpload}
placeholder="Upload a featured image for this project"
showBrowseLibrary={true}
/>
<Select
label="Project Status"
bind:value={formData.status}
error={validationErrors.status}
options={[
{ value: 'draft', label: 'Draft (Hidden)' },
{ value: 'published', label: 'Published' },
{ value: 'list-only', label: 'List Only (No Access)' },
{ value: 'password-protected', label: 'Password Protected' }
]}
helpText="Control how this project appears on the public site"
/>
{#if !showFeaturedImage}
<Button
variant="secondary"
buttonSize="medium"
onclick={() => showFeaturedImage = true}
iconPosition="left"
>
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
Add Featured Image
</Button>
{:else if showFeaturedImage}
<div class="collapsible-section">
<div class="section-header">
<h3>Featured Image</h3>
</div>
<ImageUploader
label=""
bind:value={featuredImageMedia}
onUpload={handleFeaturedImageUpload}
onRemove={handleFeaturedImageRemove}
placeholder="Upload a featured image for this project"
showBrowseLibrary={true}
/>
</div>
{/if}
{#if formData.status === 'password-protected'}
<Input
@ -127,4 +196,23 @@
grid-template-columns: 1fr;
}
}
.collapsible-section {
// No border or background needed
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $unit-2x;
h3 {
font-size: 0.875rem;
font-weight: 600;
margin: 0;
color: $grey-20;
}
}
</style>

View file

@ -49,6 +49,7 @@
.select-wrapper {
position: relative;
display: inline-block;
width: 100%;
}
.select {
@ -59,6 +60,7 @@
transition: all 0.2s ease;
appearance: none;
padding-right: 36px;
width: 100%;
&:focus {
outline: none;

View file

@ -0,0 +1,60 @@
<script lang="ts">
import Select from './Select.svelte'
import FormFieldWrapper from './FormFieldWrapper.svelte'
import type { HTMLSelectAttributes } from 'svelte/elements'
interface Option {
value: string
label: string
}
interface Props extends Omit<HTMLSelectAttributes, 'size'> {
label: string
options: Option[]
value?: string
size?: 'small' | 'medium' | 'large'
variant?: 'default' | 'minimal'
fullWidth?: boolean
pill?: boolean
required?: boolean
helpText?: string
error?: string
}
let {
label,
options,
value = $bindable(),
size = 'medium',
variant = 'default',
fullWidth = true,
pill = true,
required = false,
helpText,
error,
...restProps
}: Props = $props()
</script>
<FormFieldWrapper {label} {required} {helpText} {error}>
{#snippet children()}
<Select
bind:value
{options}
{size}
{variant}
{fullWidth}
{pill}
{...restProps}
/>
{/snippet}
</FormFieldWrapper>
<style lang="scss">
// Ensure proper spacing for select fields
:global(.form-field) {
:global(.select-wrapper) {
margin-top: 0;
}
}
</style>

View file

@ -12,7 +12,6 @@ export interface Project {
role: string | null
featuredImage: string | null
logoUrl: string | null
gallery: any[] | null
externalUrl: string | null
caseStudyContent: any | null
backgroundColor: string | null
@ -39,7 +38,6 @@ export interface ProjectFormData {
backgroundColor: string
highlightColor: string
logoUrl: string
gallery: any[] | null
status: ProjectStatus
password: string
caseStudyContent: any
@ -58,7 +56,6 @@ export const defaultProjectFormData: ProjectFormData = {
backgroundColor: '',
highlightColor: '',
logoUrl: '',
gallery: null,
status: 'draft',
password: '',
caseStudyContent: {

View file

@ -134,15 +134,15 @@ export const PUT: RequestHandler = async (event) => {
const album = await prisma.album.update({
where: { id },
data: {
slug: body.slug ?? existing.slug,
title: body.title ?? existing.title,
slug: body.slug !== undefined ? body.slug : existing.slug,
title: body.title !== undefined ? body.title : existing.title,
description: body.description !== undefined ? body.description : existing.description,
date: body.date !== undefined ? (body.date ? new Date(body.date) : null) : existing.date,
location: body.location !== undefined ? body.location : existing.location,
coverPhotoId: body.coverPhotoId !== undefined ? body.coverPhotoId : existing.coverPhotoId,
isPhotography: body.isPhotography ?? existing.isPhotography,
status: body.status ?? existing.status,
showInUniverse: body.showInUniverse ?? existing.showInUniverse
isPhotography: body.isPhotography !== undefined ? body.isPhotography : existing.isPhotography,
status: body.status !== undefined ? body.status : existing.status,
showInUniverse: body.showInUniverse !== undefined ? body.showInUniverse : existing.showInUniverse
}
})

View file

@ -68,9 +68,9 @@ export const PUT: RequestHandler = async (event) => {
const media = await prisma.media.update({
where: { id },
data: {
altText: body.altText ?? existing.altText,
description: body.description ?? existing.description,
isPhotography: body.isPhotography ?? existing.isPhotography
altText: body.altText !== undefined ? body.altText : existing.altText,
description: body.description !== undefined ? body.description : existing.description,
isPhotography: body.isPhotography !== undefined ? body.isPhotography : existing.isPhotography
}
})

View file

@ -76,23 +76,23 @@ export const PUT: RequestHandler = async (event) => {
where: { id },
data: {
slug,
title: body.title ?? existing.title,
subtitle: body.subtitle ?? existing.subtitle,
description: body.description ?? existing.description,
year: body.year ?? existing.year,
client: body.client ?? existing.client,
role: body.role ?? existing.role,
featuredImage: body.featuredImage ?? existing.featuredImage,
logoUrl: body.logoUrl ?? existing.logoUrl,
gallery: body.gallery ?? existing.gallery,
externalUrl: body.externalUrl ?? existing.externalUrl,
caseStudyContent: body.caseStudyContent ?? existing.caseStudyContent,
backgroundColor: body.backgroundColor ?? existing.backgroundColor,
highlightColor: body.highlightColor ?? existing.highlightColor,
projectType: body.projectType ?? existing.projectType,
displayOrder: body.displayOrder ?? existing.displayOrder,
status: body.status ?? existing.status,
password: body.password ?? existing.password,
title: body.title !== undefined ? body.title : existing.title,
subtitle: body.subtitle !== undefined ? body.subtitle : existing.subtitle,
description: body.description !== undefined ? body.description : existing.description,
year: body.year !== undefined ? body.year : existing.year,
client: body.client !== undefined ? body.client : existing.client,
role: body.role !== undefined ? body.role : existing.role,
featuredImage: body.featuredImage !== undefined ? body.featuredImage : existing.featuredImage,
logoUrl: body.logoUrl !== undefined ? body.logoUrl : existing.logoUrl,
gallery: body.gallery !== undefined ? body.gallery : existing.gallery,
externalUrl: body.externalUrl !== undefined ? body.externalUrl : existing.externalUrl,
caseStudyContent: body.caseStudyContent !== undefined ? body.caseStudyContent : existing.caseStudyContent,
backgroundColor: body.backgroundColor !== undefined ? body.backgroundColor : existing.backgroundColor,
highlightColor: body.highlightColor !== undefined ? body.highlightColor : existing.highlightColor,
projectType: body.projectType !== undefined ? body.projectType : existing.projectType,
displayOrder: body.displayOrder !== undefined ? body.displayOrder : existing.displayOrder,
status: body.status !== undefined ? body.status : existing.status,
password: body.password !== undefined ? body.password : existing.password,
publishedAt:
body.status === 'published' && !existing.publishedAt ? new Date() : existing.publishedAt
}