route element file uploads

Add short and long PDF paths to elements, keep pdfPath as a short PDF compatibility alias, and route generic file uploads by fileType.
This commit is contained in:
Yisroel Baum 2026-06-14 23:17:56 +03:00
parent 0d5783ba9c
commit 9bbabc7fa6
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
28 changed files with 686 additions and 214 deletions

View file

@ -1,4 +1,4 @@
import { ref } from 'vue'
import { ref, type Ref } from 'vue'
import { defineStore } from 'pinia'
export interface ChildElement {
@ -10,10 +10,14 @@ export interface ChildElement {
export interface Element extends ChildElement {
iconImageUrl: string | null
richText: string
shortPdfPath: string | null
longPdfPath: string | null
pdfPath: string | null
youtubeUrl: string | null
}
type ElementFileType = 'iconImage' | 'shortPdf' | 'longPdf'
interface ElementResponse {
element: Element
childElements: ChildElement[]
@ -35,6 +39,8 @@ interface ElementPatchInput {
description?: string
iconImageUrl?: string
richText?: string
shortPdfPath?: string
longPdfPath?: string
pdfPath?: string
youtubeUrl?: string
}
@ -53,7 +59,8 @@ export const useElementsStore = defineStore('elements', () => {
const error = ref<string | null>(null)
const isSaving = ref(false)
const isUploadingIconImage = ref(false)
const isUploadingPdf = ref(false)
const isUploadingShortPdf = ref(false)
const isUploadingLongPdf = ref(false)
const saveError = ref<string | null>(null)
async function fetchElement(elementId: string): Promise<void> {
@ -89,16 +96,35 @@ export const useElementsStore = defineStore('elements', () => {
}
}
async function updateElement(elementId: string, input: UpdateElementInput): Promise<boolean> {
async function updateElement(
elementId: string,
input: UpdateElementInput,
): Promise<boolean> {
return await saveElementPatch(elementId, input, 'Could not save element')
}
async function clearElementIconImage(elementId: string): Promise<boolean> {
return await saveElementPatch(elementId, { iconImageUrl: '' }, 'Could not remove icon image')
return await saveElementPatch(
elementId,
{ iconImageUrl: '' },
'Could not remove icon image',
)
}
async function clearElementPdf(elementId: string): Promise<boolean> {
return await saveElementPatch(elementId, { pdfPath: '' }, 'Could not remove PDF')
async function clearElementShortPdf(elementId: string): Promise<boolean> {
return await saveElementPatch(
elementId,
{ shortPdfPath: '' },
'Could not remove short PDF',
)
}
async function clearElementLongPdf(elementId: string): Promise<boolean> {
return await saveElementPatch(
elementId,
{ longPdfPath: '' },
'Could not remove long PDF',
)
}
async function saveElementPatch(
@ -126,38 +152,64 @@ export const useElementsStore = defineStore('elements', () => {
}
}
async function uploadElementIconImage(elementId: string, file: File): Promise<boolean> {
saveError.value = null
isUploadingIconImage.value = true
try {
const formData = new FormData()
formData.append('elementId', elementId)
formData.append('iconImage', file)
const response = await fetch(ELEMENT_UPDATE_URL, {
method: 'POST',
headers: { Accept: 'application/json' },
credentials: 'include',
body: formData,
})
return await handleElementResponse(response, 'Could not upload icon image')
} catch {
saveError.value = 'Network error - could not upload icon image'
return false
} finally {
isUploadingIconImage.value = false
}
async function uploadElementIconImage(
elementId: string,
file: File,
): Promise<boolean> {
return await uploadElementFile(
elementId,
file,
'iconImage',
isUploadingIconImage,
'Could not upload icon image',
'Network error - could not upload icon image',
)
}
async function uploadElementPdf(elementId: string, file: File): Promise<boolean> {
async function uploadElementShortPdf(
elementId: string,
file: File,
): Promise<boolean> {
return await uploadElementFile(
elementId,
file,
'shortPdf',
isUploadingShortPdf,
'Could not upload short PDF',
'Network error - could not upload short PDF',
)
}
async function uploadElementLongPdf(
elementId: string,
file: File,
): Promise<boolean> {
return await uploadElementFile(
elementId,
file,
'longPdf',
isUploadingLongPdf,
'Could not upload long PDF',
'Network error - could not upload long PDF',
)
}
async function uploadElementFile(
elementId: string,
file: File,
fileType: ElementFileType,
isUploadingFile: Ref<boolean>,
failureMessage: string,
networkErrorMessage: string,
): Promise<boolean> {
saveError.value = null
isUploadingPdf.value = true
isUploadingFile.value = true
try {
const formData = new FormData()
formData.append('elementId', elementId)
formData.append('pdf', file)
formData.append('fileType', fileType)
formData.append('file', file)
const response = await fetch(ELEMENT_UPDATE_URL, {
method: 'POST',
headers: { Accept: 'application/json' },
@ -165,12 +217,12 @@ export const useElementsStore = defineStore('elements', () => {
body: formData,
})
return await handleElementResponse(response, 'Could not upload PDF')
return await handleElementResponse(response, failureMessage)
} catch {
saveError.value = 'Network error - could not upload PDF'
saveError.value = networkErrorMessage
return false
} finally {
isUploadingPdf.value = false
isUploadingFile.value = false
}
}
@ -203,7 +255,10 @@ export const useElementsStore = defineStore('elements', () => {
return true
}
async function errorMessage(response: Response, fallbackMessage: string): Promise<string> {
async function errorMessage(
response: Response,
fallbackMessage: string,
): Promise<string> {
try {
const data: ErrorResponse = await response.json()
return data.error ?? fallbackMessage
@ -219,13 +274,16 @@ export const useElementsStore = defineStore('elements', () => {
error,
isSaving,
isUploadingIconImage,
isUploadingPdf,
isUploadingShortPdf,
isUploadingLongPdf,
saveError,
fetchElement,
updateElement,
uploadElementIconImage,
uploadElementPdf,
uploadElementShortPdf,
uploadElementLongPdf,
clearElementIconImage,
clearElementPdf,
clearElementShortPdf,
clearElementLongPdf,
}
})

View file

@ -14,13 +14,23 @@ interface ElementForm {
const route = useRoute()
const elementsStore = useElementsStore()
const { element, isLoading, error, isSaving, isUploadingIconImage, isUploadingPdf, saveError } =
storeToRefs(elementsStore)
const {
element,
isLoading,
error,
isSaving,
isUploadingIconImage,
isUploadingShortPdf,
isUploadingLongPdf,
saveError,
} = storeToRefs(elementsStore)
const savedMessage = ref<string | null>(null)
const iconImageStatus = ref<string | null>(null)
const pdfStatus = ref<string | null>(null)
const shortPdfStatus = ref<string | null>(null)
const longPdfStatus = ref<string | null>(null)
const iconImageInput = ref<HTMLInputElement | null>(null)
const pdfInput = ref<HTMLInputElement | null>(null)
const shortPdfInput = ref<HTMLInputElement | null>(null)
const longPdfInput = ref<HTMLInputElement | null>(null)
const form = reactive<ElementForm>({
title: '',
@ -56,7 +66,8 @@ watch(
savedMessage.value = null
iconImageStatus.value = null
pdfStatus.value = null
shortPdfStatus.value = null
longPdfStatus.value = null
void elementsStore.fetchElement(currentElementId)
},
{ immediate: true },
@ -95,8 +106,12 @@ function chooseIconImage(): void {
iconImageInput.value?.click()
}
function choosePdf(): void {
pdfInput.value?.click()
function chooseShortPdf(): void {
shortPdfInput.value?.click()
}
function chooseLongPdf(): void {
longPdfInput.value?.click()
}
async function handleIconImageChange(changeEvent: Event): Promise<void> {
@ -107,7 +122,10 @@ async function handleIconImageChange(changeEvent: Event): Promise<void> {
savedMessage.value = null
iconImageStatus.value = null
const uploaded = await elementsStore.uploadElementIconImage(elementId.value, selectedFile)
const uploaded = await elementsStore.uploadElementIconImage(
elementId.value,
selectedFile,
)
resetInput(changeEvent)
if (uploaded) {
@ -115,19 +133,41 @@ async function handleIconImageChange(changeEvent: Event): Promise<void> {
}
}
async function handlePdfChange(changeEvent: Event): Promise<void> {
async function handleShortPdfChange(changeEvent: Event): Promise<void> {
const selectedFile = selectedInputFile(changeEvent)
if (selectedFile === null || elementId.value === '') {
return
}
savedMessage.value = null
pdfStatus.value = null
const uploaded = await elementsStore.uploadElementPdf(elementId.value, selectedFile)
shortPdfStatus.value = null
const uploaded = await elementsStore.uploadElementShortPdf(
elementId.value,
selectedFile,
)
resetInput(changeEvent)
if (uploaded) {
pdfStatus.value = 'PDF updated'
shortPdfStatus.value = 'Short PDF updated'
}
}
async function handleLongPdfChange(changeEvent: Event): Promise<void> {
const selectedFile = selectedInputFile(changeEvent)
if (selectedFile === null || elementId.value === '') {
return
}
savedMessage.value = null
longPdfStatus.value = null
const uploaded = await elementsStore.uploadElementLongPdf(
elementId.value,
selectedFile,
)
resetInput(changeEvent)
if (uploaded) {
longPdfStatus.value = 'Long PDF updated'
}
}
@ -144,16 +184,29 @@ async function handleRemoveIconImage(): Promise<void> {
}
}
async function handleRemovePdf(): Promise<void> {
async function handleRemoveShortPdf(): Promise<void> {
if (elementId.value === '') {
return
}
savedMessage.value = null
pdfStatus.value = null
const removed = await elementsStore.clearElementPdf(elementId.value)
shortPdfStatus.value = null
const removed = await elementsStore.clearElementShortPdf(elementId.value)
if (removed) {
pdfStatus.value = 'PDF removed'
shortPdfStatus.value = 'Short PDF removed'
}
}
async function handleRemoveLongPdf(): Promise<void> {
if (elementId.value === '') {
return
}
savedMessage.value = null
longPdfStatus.value = null
const removed = await elementsStore.clearElementLongPdf(elementId.value)
if (removed) {
longPdfStatus.value = 'Long PDF removed'
}
}
@ -186,7 +239,9 @@ function resetInput(changeEvent: Event): void {
<h1 class="admin-element-page__heading">Edit Element</h1>
</header>
<p v-if="isLoading" class="admin-element-page__status">Loading element...</p>
<p v-if="isLoading" class="admin-element-page__status">
Loading element...
</p>
<p
v-else-if="error"
class="admin-element-page__status admin-element-page__status--error"
@ -194,7 +249,11 @@ function resetInput(changeEvent: Event): void {
>
{{ error }}
</p>
<form v-else-if="element" class="admin-element-page__form" @submit.prevent="handleSubmit">
<form
v-else-if="element"
class="admin-element-page__form"
@submit.prevent="handleSubmit"
>
<label class="admin-element-page__field">
<span class="admin-element-page__label">Title</span>
<input
@ -255,7 +314,10 @@ function resetInput(changeEvent: Event): void {
</div>
<p
v-if="iconImageStatus !== null"
class="admin-element-page__status admin-element-page__status--inline"
:class="[
'admin-element-page__status',
'admin-element-page__status--inline',
]"
data-cy="admin-element-icon-image-status"
aria-live="polite"
>
@ -274,53 +336,110 @@ function resetInput(changeEvent: Event): void {
</label>
<section class="admin-element-page__media-field">
<span class="admin-element-page__label">PDF</span>
<span class="admin-element-page__label">Short PDF</span>
<a
v-if="element.pdfPath !== null"
:href="element.pdfPath"
v-if="element.shortPdfPath !== null"
:href="element.shortPdfPath"
class="admin-element-page__pdf-link"
data-cy="admin-element-current-pdf"
data-cy="admin-element-current-short-pdf"
target="_blank"
rel="noreferrer"
>
View current PDF
View current short PDF
</a>
<p v-else class="admin-element-page__empty-media">No PDF</p>
<p v-else class="admin-element-page__empty-media">No short PDF</p>
<div class="admin-element-page__media-actions">
<input
ref="pdfInput"
ref="shortPdfInput"
class="admin-element-page__file-input"
data-cy="admin-element-pdf-input"
data-cy="admin-element-short-pdf-input"
type="file"
accept="application/pdf"
:disabled="isUploadingPdf"
@change="handlePdfChange"
:disabled="isUploadingShortPdf"
@change="handleShortPdfChange"
/>
<button
class="admin-element-page__secondary-button"
type="button"
:disabled="isUploadingPdf"
@click="choosePdf"
:disabled="isUploadingShortPdf"
@click="chooseShortPdf"
>
{{ isUploadingPdf ? 'Uploading...' : 'Choose PDF' }}
{{ isUploadingShortPdf ? 'Uploading...' : 'Choose short PDF' }}
</button>
<button
v-if="element.pdfPath !== null"
v-if="element.shortPdfPath !== null"
class="admin-element-page__secondary-button"
type="button"
:disabled="isSaving || isUploadingPdf"
@click="handleRemovePdf"
:disabled="isSaving || isUploadingShortPdf"
@click="handleRemoveShortPdf"
>
Remove PDF
Remove short PDF
</button>
</div>
<p
v-if="pdfStatus !== null"
class="admin-element-page__status admin-element-page__status--inline"
data-cy="admin-element-pdf-status"
v-if="shortPdfStatus !== null"
:class="[
'admin-element-page__status',
'admin-element-page__status--inline',
]"
data-cy="admin-element-short-pdf-status"
aria-live="polite"
>
{{ pdfStatus }}
{{ shortPdfStatus }}
</p>
</section>
<section class="admin-element-page__media-field">
<span class="admin-element-page__label">Long PDF</span>
<a
v-if="element.longPdfPath !== null"
:href="element.longPdfPath"
class="admin-element-page__pdf-link"
data-cy="admin-element-current-long-pdf"
target="_blank"
rel="noreferrer"
>
View current long PDF
</a>
<p v-else class="admin-element-page__empty-media">No long PDF</p>
<div class="admin-element-page__media-actions">
<input
ref="longPdfInput"
class="admin-element-page__file-input"
data-cy="admin-element-long-pdf-input"
type="file"
accept="application/pdf"
:disabled="isUploadingLongPdf"
@change="handleLongPdfChange"
/>
<button
class="admin-element-page__secondary-button"
type="button"
:disabled="isUploadingLongPdf"
@click="chooseLongPdf"
>
{{ isUploadingLongPdf ? 'Uploading...' : 'Choose long PDF' }}
</button>
<button
v-if="element.longPdfPath !== null"
class="admin-element-page__secondary-button"
type="button"
:disabled="isSaving || isUploadingLongPdf"
@click="handleRemoveLongPdf"
>
Remove long PDF
</button>
</div>
<p
v-if="longPdfStatus !== null"
:class="[
'admin-element-page__status',
'admin-element-page__status--inline',
]"
data-cy="admin-element-long-pdf-status"
aria-live="polite"
>
{{ longPdfStatus }}
</p>
</section>
@ -345,7 +464,10 @@ function resetInput(changeEvent: Event): void {
</button>
<p
v-if="statusMessage !== null"
:class="['admin-element-page__status', 'admin-element-page__status--inline']"
:class="[
'admin-element-page__status',
'admin-element-page__status--inline',
]"
data-cy="admin-element-status"
aria-live="polite"
>

View file

@ -242,15 +242,29 @@ function isShortYoutubeHost(hostname: string): boolean {
/>
</div>
<div v-if="element.pdfPath !== null" class="element-page__actions">
<div
v-if="element.shortPdfPath !== null || element.longPdfPath !== null"
class="element-page__actions"
>
<a
:href="element.pdfPath"
v-if="element.shortPdfPath !== null"
:href="element.shortPdfPath"
class="element-page__pdf-link"
data-cy="element-pdf-link"
data-cy="element-short-pdf-link"
target="_blank"
rel="noreferrer"
>
View PDF
View Short PDF
</a>
<a
v-if="element.longPdfPath !== null"
:href="element.longPdfPath"
class="element-page__pdf-link"
data-cy="element-long-pdf-link"
target="_blank"
rel="noreferrer"
>
View Long PDF
</a>
</div>
@ -360,7 +374,9 @@ function isShortYoutubeHost(hostname: string): boolean {
.element-page__actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem;
margin-top: 1.75rem;
}