add element upload UI
This commit is contained in:
parent
4292494345
commit
a2c3ffad8b
3 changed files with 384 additions and 67 deletions
|
|
@ -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 = '<p>Updated admin rich text</p>'
|
||||
|
|
@ -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,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null)
|
||||
const isSaving = ref(false)
|
||||
const isUploadingIconImage = ref(false)
|
||||
const isUploadingPdf = ref(false)
|
||||
const saveError = ref<string | null>(null)
|
||||
|
||||
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> {
|
||||
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
|
||||
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<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) {
|
||||
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<string> {
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<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>({
|
||||
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<void> {
|
|||
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<void> {
|
|||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -130,15 +215,53 @@ async function handleSubmit(): Promise<void> {
|
|||
/>
|
||||
</label>
|
||||
|
||||
<label class="admin-element-page__field">
|
||||
<span class="admin-element-page__label">Icon Image URL</span>
|
||||
<input
|
||||
v-model="form.iconImageUrl"
|
||||
class="admin-element-page__input"
|
||||
data-cy="admin-element-icon-image-url"
|
||||
type="text"
|
||||
<section class="admin-element-page__media-field">
|
||||
<span class="admin-element-page__label">Icon Image</span>
|
||||
<img
|
||||
v-if="element.iconImageUrl !== null"
|
||||
:src="element.iconImageUrl"
|
||||
:alt="`${element.title} icon`"
|
||||
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">
|
||||
<span class="admin-element-page__label">Rich Text HTML</span>
|
||||
|
|
@ -150,15 +273,56 @@ async function handleSubmit(): Promise<void> {
|
|||
/>
|
||||
</label>
|
||||
|
||||
<label class="admin-element-page__field">
|
||||
<span class="admin-element-page__label">PDF Path</span>
|
||||
<input
|
||||
v-model="form.pdfPath"
|
||||
class="admin-element-page__input"
|
||||
data-cy="admin-element-pdf-path"
|
||||
type="text"
|
||||
/>
|
||||
</label>
|
||||
<section class="admin-element-page__media-field">
|
||||
<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
|
||||
ref="pdfInput"
|
||||
class="admin-element-page__file-input"
|
||||
data-cy="admin-element-pdf-input"
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
:disabled="isUploadingPdf"
|
||||
@change="handlePdfChange"
|
||||
/>
|
||||
<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">
|
||||
<span class="admin-element-page__label">YouTube URL</span>
|
||||
|
|
@ -234,6 +398,16 @@ async function handleSubmit(): Promise<void> {
|
|||
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 {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
|
|
@ -267,6 +441,61 @@ async function handleSubmit(): Promise<void> {
|
|||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -314,5 +543,11 @@ async function handleSubmit(): Promise<void> {
|
|||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-element-page__media-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue