jedmund-svelte/src/lib/components/admin/ProjectForm.svelte
2025-06-11 00:54:05 -07:00

441 lines
10 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation'
import { z } from 'zod'
import AdminPage from './AdminPage.svelte'
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
import FormFieldWrapper from './FormFieldWrapper.svelte'
import Editor from './Editor.svelte'
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
import ProjectImagesForm from './ProjectImagesForm.svelte'
import Button from './Button.svelte'
import StatusDropdown from './StatusDropdown.svelte'
import { projectSchema } from '$lib/schemas/project'
import type { Project, ProjectFormData } from '$lib/types/project'
import { defaultProjectFormData } from '$lib/types/project'
interface Props {
project?: Project | null
mode: 'create' | 'edit'
}
let { project = null, mode }: Props = $props()
// State
let isLoading = $state(mode === 'edit')
let isSaving = $state(false)
let error = $state('')
let successMessage = $state('')
let activeTab = $state('metadata')
let validationErrors = $state<Record<string, string>>({})
// Form data
let formData = $state<ProjectFormData>({ ...defaultProjectFormData })
// Ref to the editor component
let editorRef: any
const tabOptions = [
{ value: 'metadata', label: 'Metadata' },
{ value: 'case-study', label: 'Case Study' }
]
// Watch for project changes and populate form data
$effect(() => {
if (project && mode === 'edit') {
populateFormData(project)
} else if (mode === 'create') {
isLoading = false
}
})
function populateFormData(data: Project) {
formData = {
title: data.title || '',
subtitle: data.subtitle || '',
description: data.description || '',
year: data.year || new Date().getFullYear(),
client: data.client || '',
role: data.role || '',
projectType: data.projectType || 'work',
externalUrl: data.externalUrl || '',
featuredImage:
data.featuredImage && data.featuredImage.trim() !== '' ? data.featuredImage : null,
backgroundColor: data.backgroundColor || '',
highlightColor: data.highlightColor || '',
logoUrl: data.logoUrl && data.logoUrl.trim() !== '' ? data.logoUrl : '',
status: data.status || 'draft',
password: data.password || '',
caseStudyContent: data.caseStudyContent || {
type: 'doc',
content: [{ type: 'paragraph' }]
}
}
isLoading = false
}
function validateForm() {
try {
projectSchema.parse({
title: formData.title,
description: formData.description || undefined,
year: formData.year,
client: formData.client || undefined,
externalUrl: formData.externalUrl || undefined,
backgroundColor: formData.backgroundColor || undefined,
highlightColor: formData.highlightColor || undefined,
status: formData.status,
password: formData.password || undefined
})
validationErrors = {}
return true
} catch (err) {
if (err instanceof z.ZodError) {
const errors: Record<string, string> = {}
err.errors.forEach((e) => {
if (e.path[0]) {
errors[e.path[0].toString()] = e.message
}
})
validationErrors = errors
}
return false
}
}
function handleEditorChange(content: any) {
formData.caseStudyContent = content
}
async function handleSave() {
// Check if we're on the case study tab and should save editor content
if (activeTab === 'case-study' && editorRef) {
const editorData = await editorRef.save()
if (editorData) {
formData.caseStudyContent = editorData
}
}
if (!validateForm()) {
error = 'Please fix the validation errors'
return
}
try {
isSaving = true
error = ''
successMessage = ''
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
const payload = {
title: formData.title,
subtitle: formData.subtitle,
description: formData.description,
year: formData.year,
client: formData.client,
role: formData.role,
projectType: formData.projectType,
externalUrl: formData.externalUrl,
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,
password: formData.status === 'password-protected' ? formData.password : null,
caseStudyContent:
formData.caseStudyContent &&
formData.caseStudyContent.content &&
formData.caseStudyContent.content.length > 0
? formData.caseStudyContent
: null
}
const url = mode === 'edit' ? `/api/projects/${project?.id}` : '/api/projects'
const method = mode === 'edit' ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: {
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
if (!response.ok) {
throw new Error(`Failed to ${mode === 'edit' ? 'save' : 'create'} project`)
}
const savedProject = await response.json()
successMessage = `Project ${mode === 'edit' ? 'saved' : 'created'} successfully!`
setTimeout(() => {
successMessage = ''
if (mode === 'create') {
goto(`/admin/projects/${savedProject.id}/edit`)
}
}, 1500)
} catch (err) {
error = `Failed to ${mode === 'edit' ? 'save' : 'create'} project`
console.error(err)
} finally {
isSaving = false
}
}
async function handleStatusChange(newStatus: string) {
formData.status = newStatus as any
await handleSave()
}
</script>
<AdminPage>
<header slot="header">
<div class="header-left">
<button class="btn-icon" onclick={() => goto('/admin/projects')}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M12.5 15L7.5 10L12.5 5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<div class="header-center">
<AdminSegmentedControl
options={tabOptions}
value={activeTab}
onChange={(value) => (activeTab = value)}
/>
</div>
<div class="header-actions">
{#if !isLoading}
<StatusDropdown
currentStatus={formData.status}
onStatusChange={handleStatusChange}
disabled={isSaving}
isLoading={isSaving}
primaryAction={formData.status === 'published'
? { label: 'Save', status: 'published' }
: { label: 'Publish', status: 'published' }}
dropdownActions={[
{ label: 'Save as Draft', status: 'draft', show: formData.status !== 'draft' },
{ label: 'List Only', status: 'list-only', show: formData.status !== 'list-only' },
{
label: 'Password Protected',
status: 'password-protected',
show: formData.status !== 'password-protected'
}
]}
/>
{/if}
</div>
</header>
<div class="admin-container">
{#if isLoading}
<div class="loading">Loading project...</div>
{:else}
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if successMessage}
<div class="success-message">{successMessage}</div>
{/if}
<div class="tab-panels">
<!-- Metadata Panel -->
<div class="panel content-wrapper" class:active={activeTab === 'metadata'}>
<div class="form-content">
<form
onsubmit={(e) => {
e.preventDefault()
handleSave()
}}
>
<ProjectMetadataForm bind:formData {validationErrors} onSave={handleSave} />
<ProjectBrandingForm bind:formData {validationErrors} onSave={handleSave} />
<ProjectImagesForm bind:formData {validationErrors} onSave={handleSave} />
</form>
</div>
</div>
<!-- Case Study Panel -->
<div class="panel case-study-wrapper" class:active={activeTab === 'case-study'}>
<div class="editor-content">
<Editor
bind:this={editorRef}
bind:data={formData.caseStudyContent}
onChange={handleEditorChange}
placeholder="Write your case study here..."
minHeight={400}
autofocus={false}
class="case-study-editor"
/>
</div>
</div>
</div>
{/if}
</div>
</AdminPage>
<style lang="scss">
header {
display: grid;
grid-template-columns: 250px 1fr 250px;
align-items: center;
width: 100%;
gap: $unit-2x;
.header-left {
width: 250px;
display: flex;
align-items: center;
gap: $unit-2x;
}
.header-center {
display: flex;
justify-content: center;
align-items: center;
}
.header-actions {
width: 250px;
display: flex;
justify-content: flex-end;
}
}
.btn-icon {
width: 40px;
height: 40px;
border: none;
background: none;
color: $grey-40;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: all 0.2s ease;
&:hover {
background: $grey-90;
color: $grey-10;
}
}
.admin-container {
width: 100%;
margin: 0 auto;
padding: 0 $unit-2x $unit-4x;
box-sizing: border-box;
@include breakpoint('phone') {
padding: 0 $unit-2x $unit-2x;
}
}
.tab-panels {
position: relative;
.panel {
display: none;
box-sizing: border-box;
&.active {
display: block;
}
}
}
.content-wrapper {
background: white;
border-radius: $unit-2x;
padding: 0;
width: 100%;
margin: 0 auto;
}
.loading,
.error {
text-align: center;
padding: $unit-6x;
color: $grey-40;
}
.error {
color: #d33;
}
.error-message,
.success-message {
padding: $unit-3x;
border-radius: $unit;
margin-bottom: $unit-4x;
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
.error-message {
background-color: #fee;
color: #d33;
}
.success-message {
background-color: #efe;
color: #363;
}
.form-content {
@include breakpoint('phone') {
padding: $unit-3x;
}
}
.form-content form {
display: flex;
flex-direction: column;
gap: $unit-6x;
}
.case-study-wrapper {
background: white;
padding: 0;
min-height: 80vh;
margin: 0 auto;
display: flex;
flex-direction: column;
overflow: hidden;
@include breakpoint('phone') {
height: 600px;
}
}
.editor-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
/* The editor component will handle its own padding and scrolling */
:global(.case-study-editor) {
flex: 1;
overflow: auto;
}
}
</style>