add element upload UI

This commit is contained in:
Yisroel Baum 2026-06-02 10:30:48 +03:00
parent 4292494345
commit a2c3ffad8b
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
3 changed files with 384 additions and 67 deletions

View file

@ -12,26 +12,17 @@ function loginAsAdmin(): void {
function fillElementForm( function fillElementForm(
title: string, title: string,
description: string, description: string,
iconImageUrl: string,
richText: string, richText: string,
pdfPath: string,
youtubeUrl: string, youtubeUrl: string,
): void { ): void {
cy.get('[data-cy="admin-element-title"]').clear().type(title) 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-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() cy.get('[data-cy="admin-element-rich-text"]').clear()
if (richText !== '') { if (richText !== '') {
cy.get('[data-cy="admin-element-rich-text"]').type(richText, { cy.get('[data-cy="admin-element-rich-text"]').type(richText, {
parseSpecialCharSequences: false, 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() cy.get('[data-cy="admin-element-youtube-url"]').clear()
if (youtubeUrl !== '') { if (youtubeUrl !== '') {
cy.get('[data-cy="admin-element-youtube-url"]').type(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', () => { it('saves element edits and restores the seed content through the UI', () => {
const originalTitle = 'Baderech HaAvodah' const originalTitle = 'Baderech HaAvodah'
const originalDescription = 'a structured path for inner and outer growth' const originalDescription = 'a structured path for inner and outer growth'
const originalIconImageUrl = '/assets/baderech-haavodah-icon.png'
const updatedTitle = 'Baderech HaAvodah Updated' const updatedTitle = 'Baderech HaAvodah Updated'
const updatedDescription = 'Updated admin description' const updatedDescription = 'Updated admin description'
const updatedRichText = '<p>Updated admin rich text</p>' const updatedRichText = '<p>Updated admin rich text</p>'
@ -90,10 +80,8 @@ describe('admin element editing', () => {
fillElementForm( fillElementForm(
updatedTitle, updatedTitle,
updatedDescription, updatedDescription,
originalIconImageUrl,
updatedRichText, updatedRichText,
'', '',
'',
) )
cy.get('[data-cy="admin-element-save"]').click() cy.get('[data-cy="admin-element-save"]').click()
@ -110,8 +98,6 @@ describe('admin element editing', () => {
fillElementForm( fillElementForm(
originalTitle, originalTitle,
originalDescription, originalDescription,
originalIconImageUrl,
'',
'', '',
'', '',
) )

View file

@ -22,9 +22,7 @@ interface ElementResponse {
export interface UpdateElementInput { export interface UpdateElementInput {
title: string title: string
description: string description: string
iconImageUrl: string
richText: string richText: string
pdfPath: string
youtubeUrl: string youtubeUrl: string
} }
@ -32,6 +30,15 @@ interface UpdateElementResponse {
element: Element element: Element
} }
interface ElementPatchInput {
title?: string
description?: string
iconImageUrl?: string
richText?: string
pdfPath?: string
youtubeUrl?: string
}
interface ErrorResponse { interface ErrorResponse {
error?: string error?: string
} }
@ -44,6 +51,8 @@ export const useElementsStore = defineStore('elements', () => {
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const isSaving = ref(false) const isSaving = ref(false)
const isUploadingIconImage = ref(false)
const isUploadingPdf = ref(false)
const saveError = ref<string | null>(null) const saveError = ref<string | null>(null)
async function fetchElement(elementId: string): Promise<void> { async function fetchElement(elementId: string): Promise<void> {
@ -80,6 +89,22 @@ 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')
}
async function clearElementPdf(elementId: string): Promise<boolean> {
return await saveElementPatch(elementId, { pdfPath: '' }, 'Could not remove PDF')
}
async function saveElementPatch(
elementId: string,
input: ElementPatchInput,
failureMessage: string,
): Promise<boolean> {
saveError.value = null saveError.value = null
isSaving.value = true isSaving.value = true
@ -93,6 +118,69 @@ export const useElementsStore = defineStore('elements', () => {
body: JSON.stringify(input), body: JSON.stringify(input),
}) })
return await handleElementResponse(response, failureMessage)
} catch {
saveError.value = `Network error - ${failureMessage.toLowerCase()}`
return false
} finally {
isSaving.value = false
}
}
async function uploadElementIconImage(elementId: string, file: File): Promise<boolean> {
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<boolean> {
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<boolean> {
if (response.status === 401) { if (response.status === 401) {
saveError.value = 'Please log in again' saveError.value = 'Please log in again'
return false return false
@ -104,24 +192,26 @@ export const useElementsStore = defineStore('elements', () => {
} }
if (response.status === 400) { if (response.status === 400) {
const data: ErrorResponse = await response.json() saveError.value = await errorMessage(response, failureMessage)
saveError.value = data.error ?? 'Could not save element'
return false return false
} }
if (!response.ok) { if (!response.ok) {
saveError.value = 'Could not save element' saveError.value = failureMessage
return false return false
} }
const data: UpdateElementResponse = await response.json() const data: UpdateElementResponse = await response.json()
element.value = data.element element.value = data.element
return true return true
}
async function errorMessage(response: Response, fallbackMessage: string): Promise<string> {
try {
const data: ErrorResponse = await response.json()
return data.error ?? fallbackMessage
} catch { } catch {
saveError.value = 'Network error - could not save element' return fallbackMessage
return false
} finally {
isSaving.value = false
} }
} }
@ -131,8 +221,14 @@ export const useElementsStore = defineStore('elements', () => {
isLoading, isLoading,
error, error,
isSaving, isSaving,
isUploadingIconImage,
isUploadingPdf,
saveError, saveError,
fetchElement, fetchElement,
updateElement, updateElement,
uploadElementIconImage,
uploadElementPdf,
clearElementIconImage,
clearElementPdf,
} }
}) })

View file

@ -8,23 +8,24 @@ import { useElementsStore } from '@/stores/elements'
interface ElementForm { interface ElementForm {
title: string title: string
description: string description: string
iconImageUrl: string
richText: string richText: string
pdfPath: string
youtubeUrl: string youtubeUrl: string
} }
const route = useRoute() const route = useRoute()
const elementsStore = useElementsStore() const elementsStore = useElementsStore()
const { element, isLoading, error, isSaving, saveError } = storeToRefs(elementsStore) const { element, isLoading, error, isSaving, isUploadingIconImage, isUploadingPdf, saveError } =
storeToRefs(elementsStore)
const savedMessage = ref<string | null>(null) const savedMessage = ref<string | null>(null)
const iconImageStatus = ref<string | null>(null)
const pdfStatus = ref<string | null>(null)
const iconImageInput = ref<HTMLInputElement | null>(null)
const pdfInput = ref<HTMLInputElement | null>(null)
const form = reactive<ElementForm>({ const form = reactive<ElementForm>({
title: '', title: '',
description: '', description: '',
iconImageUrl: '',
richText: '', richText: '',
pdfPath: '',
youtubeUrl: '', youtubeUrl: '',
}) })
@ -54,6 +55,8 @@ watch(
} }
savedMessage.value = null savedMessage.value = null
iconImageStatus.value = null
pdfStatus.value = null
void elementsStore.fetchElement(currentElementId) void elementsStore.fetchElement(currentElementId)
}, },
{ immediate: true }, { immediate: true },
@ -66,9 +69,7 @@ watch(element, (currentElement) => {
form.title = currentElement.title form.title = currentElement.title
form.description = currentElement.description form.description = currentElement.description
form.iconImageUrl = currentElement.iconImageUrl ?? ''
form.richText = currentElement.richText form.richText = currentElement.richText
form.pdfPath = currentElement.pdfPath ?? ''
form.youtubeUrl = currentElement.youtubeUrl ?? '' form.youtubeUrl = currentElement.youtubeUrl ?? ''
}) })
@ -81,9 +82,7 @@ async function handleSubmit(): Promise<void> {
const saved = await elementsStore.updateElement(elementId.value, { const saved = await elementsStore.updateElement(elementId.value, {
title: form.title, title: form.title,
description: form.description, description: form.description,
iconImageUrl: form.iconImageUrl,
richText: form.richText, richText: form.richText,
pdfPath: form.pdfPath,
youtubeUrl: form.youtubeUrl, youtubeUrl: form.youtubeUrl,
}) })
@ -91,6 +90,92 @@ async function handleSubmit(): Promise<void> {
savedMessage.value = 'Saved' savedMessage.value = 'Saved'
} }
} }
function chooseIconImage(): void {
iconImageInput.value?.click()
}
function choosePdf(): void {
pdfInput.value?.click()
}
async function handleIconImageChange(changeEvent: Event): Promise<void> {
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<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)
resetInput(changeEvent)
if (uploaded) {
pdfStatus.value = 'PDF updated'
}
}
async function handleRemoveIconImage(): Promise<void> {
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<void> {
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 = ''
}
}
</script> </script>
<template> <template>
@ -130,15 +215,53 @@ async function handleSubmit(): Promise<void> {
/> />
</label> </label>
<label class="admin-element-page__field"> <section class="admin-element-page__media-field">
<span class="admin-element-page__label">Icon Image URL</span> <span class="admin-element-page__label">Icon Image</span>
<input <img
v-model="form.iconImageUrl" v-if="element.iconImageUrl !== null"
class="admin-element-page__input" :src="element.iconImageUrl"
data-cy="admin-element-icon-image-url" :alt="`${element.title} icon`"
type="text" class="admin-element-page__icon-preview"
data-cy="admin-element-current-icon"
/> />
</label> <p v-else class="admin-element-page__empty-media">No icon image</p>
<div class="admin-element-page__media-actions">
<input
ref="iconImageInput"
class="admin-element-page__file-input"
data-cy="admin-element-icon-image-input"
type="file"
accept="image/jpeg,image/png,image/webp"
:disabled="isUploadingIconImage"
@change="handleIconImageChange"
/>
<button
class="admin-element-page__secondary-button"
type="button"
:disabled="isUploadingIconImage"
@click="chooseIconImage"
>
{{ isUploadingIconImage ? 'Uploading...' : 'Choose icon image' }}
</button>
<button
v-if="element.iconImageUrl !== null"
class="admin-element-page__secondary-button"
type="button"
:disabled="isSaving || isUploadingIconImage"
@click="handleRemoveIconImage"
>
Remove icon image
</button>
</div>
<p
v-if="iconImageStatus !== null"
class="admin-element-page__status admin-element-page__status--inline"
data-cy="admin-element-icon-image-status"
aria-live="polite"
>
{{ iconImageStatus }}
</p>
</section>
<label class="admin-element-page__field"> <label class="admin-element-page__field">
<span class="admin-element-page__label">Rich Text HTML</span> <span class="admin-element-page__label">Rich Text HTML</span>
@ -150,15 +273,56 @@ async function handleSubmit(): Promise<void> {
/> />
</label> </label>
<label class="admin-element-page__field"> <section class="admin-element-page__media-field">
<span class="admin-element-page__label">PDF Path</span> <span class="admin-element-page__label">PDF</span>
<a
v-if="element.pdfPath !== null"
:href="element.pdfPath"
class="admin-element-page__pdf-link"
data-cy="admin-element-current-pdf"
target="_blank"
rel="noreferrer"
>
View current PDF
</a>
<p v-else class="admin-element-page__empty-media">No PDF</p>
<div class="admin-element-page__media-actions">
<input <input
v-model="form.pdfPath" ref="pdfInput"
class="admin-element-page__input" class="admin-element-page__file-input"
data-cy="admin-element-pdf-path" data-cy="admin-element-pdf-input"
type="text" type="file"
accept="application/pdf"
:disabled="isUploadingPdf"
@change="handlePdfChange"
/> />
</label> <button
class="admin-element-page__secondary-button"
type="button"
:disabled="isUploadingPdf"
@click="choosePdf"
>
{{ isUploadingPdf ? 'Uploading...' : 'Choose PDF' }}
</button>
<button
v-if="element.pdfPath !== null"
class="admin-element-page__secondary-button"
type="button"
:disabled="isSaving || isUploadingPdf"
@click="handleRemovePdf"
>
Remove PDF
</button>
</div>
<p
v-if="pdfStatus !== null"
class="admin-element-page__status admin-element-page__status--inline"
data-cy="admin-element-pdf-status"
aria-live="polite"
>
{{ pdfStatus }}
</p>
</section>
<label class="admin-element-page__field"> <label class="admin-element-page__field">
<span class="admin-element-page__label">YouTube URL</span> <span class="admin-element-page__label">YouTube URL</span>
@ -234,6 +398,16 @@ async function handleSubmit(): Promise<void> {
gap: 0.35rem; gap: 0.35rem;
} }
.admin-element-page__media-field {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.7rem;
padding: 0.9rem;
border: 1px solid var(--color-border);
border-radius: 4px;
}
.admin-element-page__label { .admin-element-page__label {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.9rem; font-size: 0.9rem;
@ -267,6 +441,61 @@ async function handleSubmit(): Promise<void> {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
} }
.admin-element-page__icon-preview {
width: 6rem;
height: 6rem;
border: 1px solid var(--color-border);
border-radius: 4px;
object-fit: contain;
}
.admin-element-page__empty-media {
margin: 0;
color: var(--color-text-muted);
font-size: 0.95rem;
}
.admin-element-page__media-actions {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
}
.admin-element-page__file-input {
display: none;
}
.admin-element-page__secondary-button,
.admin-element-page__pdf-link {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.55rem 0.95rem;
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text);
background: var(--color-white);
font-family: var(--font-sans);
font-size: 0.9rem;
font-weight: 600;
}
.admin-element-page__secondary-button {
cursor: pointer;
}
.admin-element-page__secondary-button:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.admin-element-page__secondary-button:hover:not(:disabled),
.admin-element-page__secondary-button:focus-visible,
.admin-element-page__pdf-link:hover,
.admin-element-page__pdf-link:focus-visible {
border-color: var(--color-slate);
}
.admin-element-page__actions { .admin-element-page__actions {
display: flex; display: flex;
align-items: center; align-items: center;
@ -314,5 +543,11 @@ async function handleSubmit(): Promise<void> {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;
} }
.admin-element-page__media-actions {
align-items: stretch;
flex-direction: column;
width: 100%;
}
} }
</style> </style>