diff --git a/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts index 760ee87..eb40d27 100644 --- a/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts +++ b/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts @@ -12,26 +12,17 @@ function loginAsAdmin(): void { function fillElementForm( title: string, description: string, - iconImageUrl: string, richText: string, - pdfPath: string, youtubeUrl: string, ): void { cy.get('[data-cy="admin-element-title"]').clear().type(title) cy.get('[data-cy="admin-element-description"]').clear().type(description) - cy.get('[data-cy="admin-element-icon-image-url"]') - .clear() - .type(iconImageUrl) cy.get('[data-cy="admin-element-rich-text"]').clear() if (richText !== '') { cy.get('[data-cy="admin-element-rich-text"]').type(richText, { parseSpecialCharSequences: false, }) } - cy.get('[data-cy="admin-element-pdf-path"]').clear() - if (pdfPath !== '') { - cy.get('[data-cy="admin-element-pdf-path"]').type(pdfPath) - } cy.get('[data-cy="admin-element-youtube-url"]').clear() if (youtubeUrl !== '') { cy.get('[data-cy="admin-element-youtube-url"]').type(youtubeUrl) @@ -79,7 +70,6 @@ describe('admin element editing', () => { it('saves element edits and restores the seed content through the UI', () => { const originalTitle = 'Baderech HaAvodah' const originalDescription = 'a structured path for inner and outer growth' - const originalIconImageUrl = '/assets/baderech-haavodah-icon.png' const updatedTitle = 'Baderech HaAvodah Updated' const updatedDescription = 'Updated admin description' const updatedRichText = '

Updated admin rich text

' @@ -90,10 +80,8 @@ describe('admin element editing', () => { fillElementForm( updatedTitle, updatedDescription, - originalIconImageUrl, updatedRichText, '', - '', ) cy.get('[data-cy="admin-element-save"]').click() @@ -110,8 +98,6 @@ describe('admin element editing', () => { fillElementForm( originalTitle, originalDescription, - originalIconImageUrl, - '', '', '', ) diff --git a/frontend/rabbi_gerzi/src/stores/elements.ts b/frontend/rabbi_gerzi/src/stores/elements.ts index 9da179f..bf01744 100644 --- a/frontend/rabbi_gerzi/src/stores/elements.ts +++ b/frontend/rabbi_gerzi/src/stores/elements.ts @@ -22,9 +22,7 @@ interface ElementResponse { export interface UpdateElementInput { title: string description: string - iconImageUrl: string richText: string - pdfPath: string youtubeUrl: string } @@ -32,6 +30,15 @@ interface UpdateElementResponse { element: Element } +interface ElementPatchInput { + title?: string + description?: string + iconImageUrl?: string + richText?: string + pdfPath?: string + youtubeUrl?: string +} + interface ErrorResponse { error?: string } @@ -44,6 +51,8 @@ export const useElementsStore = defineStore('elements', () => { const isLoading = ref(false) const error = ref(null) const isSaving = ref(false) + const isUploadingIconImage = ref(false) + const isUploadingPdf = ref(false) const saveError = ref(null) async function fetchElement(elementId: string): Promise { @@ -80,6 +89,22 @@ export const useElementsStore = defineStore('elements', () => { } async function updateElement(elementId: string, input: UpdateElementInput): Promise { + return await saveElementPatch(elementId, input, 'Could not save element') + } + + async function clearElementIconImage(elementId: string): Promise { + return await saveElementPatch(elementId, { iconImageUrl: '' }, 'Could not remove icon image') + } + + async function clearElementPdf(elementId: string): Promise { + return await saveElementPatch(elementId, { pdfPath: '' }, 'Could not remove PDF') + } + + async function saveElementPatch( + elementId: string, + input: ElementPatchInput, + failureMessage: string, + ): Promise { saveError.value = null isSaving.value = true @@ -93,46 +118,117 @@ export const useElementsStore = defineStore('elements', () => { body: JSON.stringify(input), }) - if (response.status === 401) { - saveError.value = 'Please log in again' - return false - } - - if (response.status === 404) { - saveError.value = 'Element not found' - return false - } - - if (response.status === 400) { - const data: ErrorResponse = await response.json() - saveError.value = data.error ?? 'Could not save element' - return false - } - - if (!response.ok) { - saveError.value = 'Could not save element' - return false - } - - const data: UpdateElementResponse = await response.json() - element.value = data.element - return true + return await handleElementResponse(response, failureMessage) } catch { - saveError.value = 'Network error - could not save element' + saveError.value = `Network error - ${failureMessage.toLowerCase()}` return false } finally { isSaving.value = false } } + async function uploadElementIconImage(elementId: string, file: File): Promise { + saveError.value = null + isUploadingIconImage.value = true + + try { + const encodedElementId = encodeURIComponent(elementId) + const elementUrl = `${API_BASE_URL}/api/elements/${encodedElementId}` + const formData = new FormData() + formData.append('iconImage', file) + const response = await fetch(`${elementUrl}/icon-image`, { + 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 uploadElementPdf(elementId: string, file: File): Promise { + saveError.value = null + isUploadingPdf.value = true + + try { + const encodedElementId = encodeURIComponent(elementId) + const elementUrl = `${API_BASE_URL}/api/elements/${encodedElementId}` + const formData = new FormData() + formData.append('pdf', file) + const response = await fetch(`${elementUrl}/pdf`, { + method: 'POST', + headers: { Accept: 'application/json' }, + credentials: 'include', + body: formData, + }) + + return await handleElementResponse(response, 'Could not upload PDF') + } catch { + saveError.value = 'Network error - could not upload PDF' + return false + } finally { + isUploadingPdf.value = false + } + } + + async function handleElementResponse( + response: Response, + failureMessage: string, + ): Promise { + if (response.status === 401) { + saveError.value = 'Please log in again' + return false + } + + if (response.status === 404) { + saveError.value = 'Element not found' + return false + } + + if (response.status === 400) { + saveError.value = await errorMessage(response, failureMessage) + return false + } + + if (!response.ok) { + saveError.value = failureMessage + return false + } + + const data: UpdateElementResponse = await response.json() + element.value = data.element + return true + } + + async function errorMessage(response: Response, fallbackMessage: string): Promise { + try { + const data: ErrorResponse = await response.json() + return data.error ?? fallbackMessage + } catch { + return fallbackMessage + } + } + return { element, childElements, isLoading, error, isSaving, + isUploadingIconImage, + isUploadingPdf, saveError, fetchElement, updateElement, + uploadElementIconImage, + uploadElementPdf, + clearElementIconImage, + clearElementPdf, } }) diff --git a/frontend/rabbi_gerzi/src/views/AdminElementPage.vue b/frontend/rabbi_gerzi/src/views/AdminElementPage.vue index cc19049..37f03ff 100644 --- a/frontend/rabbi_gerzi/src/views/AdminElementPage.vue +++ b/frontend/rabbi_gerzi/src/views/AdminElementPage.vue @@ -8,23 +8,24 @@ import { useElementsStore } from '@/stores/elements' interface ElementForm { title: string description: string - iconImageUrl: string richText: string - pdfPath: string youtubeUrl: string } const route = useRoute() const elementsStore = useElementsStore() -const { element, isLoading, error, isSaving, saveError } = storeToRefs(elementsStore) +const { element, isLoading, error, isSaving, isUploadingIconImage, isUploadingPdf, saveError } = + storeToRefs(elementsStore) const savedMessage = ref(null) +const iconImageStatus = ref(null) +const pdfStatus = ref(null) +const iconImageInput = ref(null) +const pdfInput = ref(null) const form = reactive({ title: '', description: '', - iconImageUrl: '', richText: '', - pdfPath: '', youtubeUrl: '', }) @@ -54,6 +55,8 @@ watch( } savedMessage.value = null + iconImageStatus.value = null + pdfStatus.value = null void elementsStore.fetchElement(currentElementId) }, { immediate: true }, @@ -66,9 +69,7 @@ watch(element, (currentElement) => { form.title = currentElement.title form.description = currentElement.description - form.iconImageUrl = currentElement.iconImageUrl ?? '' form.richText = currentElement.richText - form.pdfPath = currentElement.pdfPath ?? '' form.youtubeUrl = currentElement.youtubeUrl ?? '' }) @@ -81,9 +82,7 @@ async function handleSubmit(): Promise { const saved = await elementsStore.updateElement(elementId.value, { title: form.title, description: form.description, - iconImageUrl: form.iconImageUrl, richText: form.richText, - pdfPath: form.pdfPath, youtubeUrl: form.youtubeUrl, }) @@ -91,6 +90,92 @@ async function handleSubmit(): Promise { savedMessage.value = 'Saved' } } + +function chooseIconImage(): void { + iconImageInput.value?.click() +} + +function choosePdf(): void { + pdfInput.value?.click() +} + +async function handleIconImageChange(changeEvent: Event): Promise { + const selectedFile = selectedInputFile(changeEvent) + if (selectedFile === null || elementId.value === '') { + return + } + + savedMessage.value = null + iconImageStatus.value = null + const uploaded = await elementsStore.uploadElementIconImage(elementId.value, selectedFile) + resetInput(changeEvent) + + if (uploaded) { + iconImageStatus.value = 'Icon image updated' + } +} + +async function handlePdfChange(changeEvent: Event): Promise { + const selectedFile = selectedInputFile(changeEvent) + if (selectedFile === null || elementId.value === '') { + return + } + + savedMessage.value = null + pdfStatus.value = null + const uploaded = await elementsStore.uploadElementPdf(elementId.value, selectedFile) + resetInput(changeEvent) + + if (uploaded) { + pdfStatus.value = 'PDF updated' + } +} + +async function handleRemoveIconImage(): Promise { + if (elementId.value === '') { + return + } + + savedMessage.value = null + iconImageStatus.value = null + const removed = await elementsStore.clearElementIconImage(elementId.value) + if (removed) { + iconImageStatus.value = 'Icon image removed' + } +} + +async function handleRemovePdf(): Promise { + if (elementId.value === '') { + return + } + + savedMessage.value = null + pdfStatus.value = null + const removed = await elementsStore.clearElementPdf(elementId.value) + if (removed) { + pdfStatus.value = 'PDF removed' + } +} + +function selectedInputFile(changeEvent: Event): File | null { + const target = changeEvent.target + if (!(target instanceof HTMLInputElement)) { + return null + } + + if (target.files === null || target.files.length === 0) { + return null + } + + return target.files.item(0) +} + +function resetInput(changeEvent: Event): void { + const target = changeEvent.target + if (target instanceof HTMLInputElement) { + target.value = '' + } +}