Fix image handling in forms
This commit is contained in:
parent
5edc7eb33b
commit
b3c9529e3f
11 changed files with 293 additions and 81 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
60
src/lib/components/admin/SelectField.svelte
Normal file
60
src/lib/components/admin/SelectField.svelte
Normal 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>
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue