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

View file

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

View file

@ -1,21 +1,26 @@
<script lang="ts"> <script lang="ts">
import Input from './Input.svelte' import Input from './Input.svelte'
import ImageUploader from './ImageUploader.svelte' import ImageUploader from './ImageUploader.svelte'
import Button from './Button.svelte'
import type { ProjectFormData } from '$lib/types/project' import type { ProjectFormData } from '$lib/types/project'
import type { Media } from '@prisma/client' import type { Media } from '@prisma/client'
interface Props { interface Props {
formData: ProjectFormData 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 // Convert logoUrl string to Media object for ImageUploader
let logoMedia = $state<Media | null>(null) let logoMedia = $state<Media | null>(null)
// Update logoMedia when logoUrl changes // Update logoMedia when logoUrl changes
$effect(() => { $effect(() => {
if (formData.logoUrl && !logoMedia) { if (formData.logoUrl && formData.logoUrl.trim() !== '' && !logoMedia) {
// Create a minimal Media object from the URL for display // Create a minimal Media object from the URL for display
logoMedia = { logoMedia = {
id: -1, // Temporary ID for existing URLs id: -1, // Temporary ID for existing URLs
@ -33,8 +38,13 @@
createdAt: new Date(), createdAt: new Date(),
updatedAt: 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 logoMedia = media
} }
function handleLogoRemove() { async function handleLogoRemove() {
formData.logoUrl = '' formData.logoUrl = ''
logoMedia = null logoMedia = null
showLogoSection = false
// Auto-save the removal
if (onSave) {
await onSave()
}
} }
</script> </script>
<div class="form-section"> <div class="form-section">
<h2>Branding</h2> <h2>Branding</h2>
<ImageUploader {#if !showLogoSection && (!formData.logoUrl || formData.logoUrl.trim() === '')}
label="Project Logo" <Button
value={logoMedia} variant="secondary"
onUpload={handleLogoUpload} buttonSize="medium"
aspectRatio="1:1" onclick={() => showLogoSection = true}
allowAltText={true} iconPosition="left"
maxFileSize={0.5} >
placeholder="Drag and drop an SVG logo here, or click to browse" <svg
helpText="Upload an SVG logo for project thumbnail (max 500KB). Square logos work best." slot="icon"
showBrowseLibrary={true} width="16"
compact={true} 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> </div>
<style lang="scss"> <style lang="scss">
@ -81,4 +127,23 @@
color: $grey-10; 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> </style>

View file

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

View file

@ -1,19 +1,71 @@
<script lang="ts"> <script lang="ts">
import Input from './Input.svelte' import Input from './Input.svelte'
import Select from './Select.svelte' import SelectField from './SelectField.svelte'
import ImageUploader from './ImageUploader.svelte' import ImageUploader from './ImageUploader.svelte'
import Button from './Button.svelte'
import type { ProjectFormData } from '$lib/types/project' import type { ProjectFormData } from '$lib/types/project'
import type { Media } from '@prisma/client'
interface Props { interface Props {
formData: ProjectFormData formData: ProjectFormData
validationErrors: Record<string, string> 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) { function handleFeaturedImageUpload(media: Media) {
formData.featuredImage = media.url formData.featuredImage = media.url
featuredImageMedia = media
} }
async function handleFeaturedImageRemove() {
formData.featuredImage = ''
featuredImageMedia = null
showFeaturedImage = false
// Auto-save the removal
if (onSave) {
await onSave()
}
}
</script> </script>
<div class="form-section"> <div class="form-section">
@ -34,7 +86,7 @@
placeholder="Short description for project cards" placeholder="Short description for project cards"
/> />
<Select <SelectField
label="Project Type" label="Project Type"
bind:value={formData.projectType} bind:value={formData.projectType}
error={validationErrors.projectType} error={validationErrors.projectType}
@ -72,26 +124,43 @@
placeholder="https://example.com" placeholder="https://example.com"
/> />
<ImageUploader {#if !showFeaturedImage}
label="Featured Image" <Button
value={null} variant="secondary"
onUpload={handleFeaturedImageUpload} buttonSize="medium"
placeholder="Upload a featured image for this project" onclick={() => showFeaturedImage = true}
showBrowseLibrary={true} iconPosition="left"
/> >
<svg
<Select slot="icon"
label="Project Status" width="16"
bind:value={formData.status} height="16"
error={validationErrors.status} viewBox="0 0 24 24"
options={[ fill="none"
{ value: 'draft', label: 'Draft (Hidden)' }, stroke="currentColor"
{ value: 'published', label: 'Published' }, stroke-width="2"
{ value: 'list-only', label: 'List Only (No Access)' }, >
{ value: 'password-protected', label: 'Password Protected' } <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>
helpText="Control how this project appears on the public site" <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'} {#if formData.status === 'password-protected'}
<Input <Input
@ -127,4 +196,23 @@
grid-template-columns: 1fr; 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> </style>

View file

@ -49,6 +49,7 @@
.select-wrapper { .select-wrapper {
position: relative; position: relative;
display: inline-block; display: inline-block;
width: 100%;
} }
.select { .select {
@ -59,6 +60,7 @@
transition: all 0.2s ease; transition: all 0.2s ease;
appearance: none; appearance: none;
padding-right: 36px; padding-right: 36px;
width: 100%;
&:focus { &:focus {
outline: none; 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 role: string | null
featuredImage: string | null featuredImage: string | null
logoUrl: string | null logoUrl: string | null
gallery: any[] | null
externalUrl: string | null externalUrl: string | null
caseStudyContent: any | null caseStudyContent: any | null
backgroundColor: string | null backgroundColor: string | null
@ -39,7 +38,6 @@ export interface ProjectFormData {
backgroundColor: string backgroundColor: string
highlightColor: string highlightColor: string
logoUrl: string logoUrl: string
gallery: any[] | null
status: ProjectStatus status: ProjectStatus
password: string password: string
caseStudyContent: any caseStudyContent: any
@ -58,7 +56,6 @@ export const defaultProjectFormData: ProjectFormData = {
backgroundColor: '', backgroundColor: '',
highlightColor: '', highlightColor: '',
logoUrl: '', logoUrl: '',
gallery: null,
status: 'draft', status: 'draft',
password: '', password: '',
caseStudyContent: { caseStudyContent: {

View file

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

View file

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