Rabbi_Gerzi/frontend/rabbi_gerzi/src/views/AdminElementPage.vue

894 lines
23 KiB
Vue

<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ElementSiblingNavigation from '@/components/ElementSiblingNavigation.vue'
import RichTextEditor from '@/components/RichTextEditor.vue'
import SiteHeader from '@/components/SiteHeader.vue'
import { useElementsStore } from '@/stores/elements'
interface ElementForm {
title: string
description: string
richText: string
youtubeUrl: string
}
interface ChildElementForm {
title: string
}
const route = useRoute()
const router = useRouter()
const elementsStore = useElementsStore()
const {
element,
childElements,
siblingElements,
isLoading,
error,
isSaving,
isUploadingIconImage,
isUploadingShortPdf,
isUploadingLongPdf,
isManagingChildren,
saveError,
childActionError,
} = storeToRefs(elementsStore)
const savedMessage = ref<string | null>(null)
const childStatus = ref<string | null>(null)
const iconImageStatus = ref<string | null>(null)
const shortPdfStatus = ref<string | null>(null)
const longPdfStatus = ref<string | null>(null)
const iconImageInput = ref<HTMLInputElement | null>(null)
const shortPdfInput = ref<HTMLInputElement | null>(null)
const longPdfInput = ref<HTMLInputElement | null>(null)
const form = reactive<ElementForm>({
title: '',
description: '',
richText: '',
youtubeUrl: '',
})
const childForm = reactive<ChildElementForm>({
title: '',
})
const elementId = computed(() => {
const routeElementId = route.params.id
if (Array.isArray(routeElementId)) {
return routeElementId.join('/')
}
if (typeof routeElementId === 'string') {
return routeElementId
}
return ''
})
const statusMessage = computed(() => {
return saveError.value ?? savedMessage.value
})
const childStatusMessage = computed(() => {
return childActionError.value ?? childStatus.value
})
watch(
elementId,
(currentElementId) => {
if (currentElementId === '') {
return
}
savedMessage.value = null
childStatus.value = null
childForm.title = ''
iconImageStatus.value = null
shortPdfStatus.value = null
longPdfStatus.value = null
void elementsStore.fetchElement(currentElementId)
},
{ immediate: true },
)
watch(element, (currentElement) => {
if (currentElement === null) {
return
}
form.title = currentElement.title
form.description = currentElement.description
form.richText = currentElement.richText
form.youtubeUrl = currentElement.youtubeUrl ?? ''
})
async function handleSubmit(): Promise<void> {
if (elementId.value === '') {
return
}
savedMessage.value = null
const saved = await elementsStore.updateElement(elementId.value, {
title: form.title,
description: form.description,
richText: form.richText,
youtubeUrl: form.youtubeUrl,
})
if (saved) {
savedMessage.value = 'Saved'
}
}
async function handleAddChild(): Promise<void> {
if (elementId.value === '' || childForm.title === '') {
return
}
childStatus.value = null
const childElement = await elementsStore.createChildElement(elementId.value, {
title: childForm.title,
})
if (childElement === null) {
return
}
childForm.title = ''
await router.push({
name: 'admin-element',
params: { id: childElement.id.toString() },
})
}
async function handleRemoveChild(childElementId: number): Promise<void> {
if (elementId.value === '') {
return
}
const confirmed = window.confirm(
'Delete this child element and all of its descendants?',
)
if (!confirmed) {
return
}
childStatus.value = null
const deleted = await elementsStore.deleteChildElement(
elementId.value,
childElementId,
)
if (deleted) {
childStatus.value = 'Child element removed'
}
}
function chooseIconImage(): void {
iconImageInput.value?.click()
}
function chooseShortPdf(): void {
shortPdfInput.value?.click()
}
function chooseLongPdf(): void {
longPdfInput.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 handleShortPdfChange(changeEvent: Event): Promise<void> {
const selectedFile = selectedInputFile(changeEvent)
if (selectedFile === null || elementId.value === '') {
return
}
savedMessage.value = null
shortPdfStatus.value = null
const uploaded = await elementsStore.uploadElementShortPdf(
elementId.value,
selectedFile,
)
resetInput(changeEvent)
if (uploaded) {
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'
}
}
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 handleRemoveShortPdf(): Promise<void> {
if (elementId.value === '') {
return
}
savedMessage.value = null
shortPdfStatus.value = null
const removed = await elementsStore.clearElementShortPdf(elementId.value)
if (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'
}
}
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>
<div class="admin-element-page">
<SiteHeader />
<main class="admin-element-page__main" data-cy="admin-element-page">
<header class="admin-element-page__header">
<h1 class="admin-element-page__heading">Edit Element</h1>
</header>
<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"
data-cy="admin-element-load-error"
>
{{ error }}
</p>
<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
v-model="form.title"
class="admin-element-page__input"
data-cy="admin-element-title"
type="text"
/>
</label>
<label class="admin-element-page__field">
<span class="admin-element-page__label">Description</span>
<textarea
v-model="form.description"
class="admin-element-page__textarea"
data-cy="admin-element-description"
rows="4"
/>
</label>
<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"
/>
<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>
<div class="admin-element-page__field">
<span class="admin-element-page__label">Rich Text</span>
<RichTextEditor v-model="form.richText" :disabled="isSaving" />
</div>
<section class="admin-element-page__media-field">
<span class="admin-element-page__label">Short PDF</span>
<a
v-if="element.shortPdfPath !== null"
:href="element.shortPdfPath"
class="admin-element-page__pdf-link"
data-cy="admin-element-current-short-pdf"
target="_blank"
rel="noreferrer"
>
View current short PDF
</a>
<p v-else class="admin-element-page__empty-media">No short PDF</p>
<div class="admin-element-page__media-actions">
<input
ref="shortPdfInput"
class="admin-element-page__file-input"
data-cy="admin-element-short-pdf-input"
type="file"
accept="application/pdf"
:disabled="isUploadingShortPdf"
@change="handleShortPdfChange"
/>
<button
class="admin-element-page__secondary-button"
type="button"
:disabled="isUploadingShortPdf"
@click="chooseShortPdf"
>
{{ isUploadingShortPdf ? 'Uploading...' : 'Choose short PDF' }}
</button>
<button
v-if="element.shortPdfPath !== null"
class="admin-element-page__secondary-button"
type="button"
:disabled="isSaving || isUploadingShortPdf"
@click="handleRemoveShortPdf"
>
Remove short PDF
</button>
</div>
<p
v-if="shortPdfStatus !== null"
:class="[
'admin-element-page__status',
'admin-element-page__status--inline',
]"
data-cy="admin-element-short-pdf-status"
aria-live="polite"
>
{{ 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>
<label class="admin-element-page__field">
<span class="admin-element-page__label">YouTube URL</span>
<input
v-model="form.youtubeUrl"
class="admin-element-page__input"
data-cy="admin-element-youtube-url"
type="text"
/>
</label>
<section class="admin-element-page__children">
<span class="admin-element-page__label">Child Elements</span>
<ul
v-if="childElements.length > 0"
class="admin-element-page__child-list"
data-cy="admin-child-list"
>
<li
v-for="childElement in childElements"
:key="childElement.id"
class="admin-element-page__child-item"
data-cy="admin-child-item"
>
<div class="admin-element-page__child-body">
<RouterLink
:to="{
name: 'admin-element',
params: { id: childElement.id },
}"
class="admin-element-page__child-link"
data-cy="admin-child-edit-link"
>
{{ childElement.title }}
</RouterLink>
<p
v-if="childElement.description !== ''"
class="admin-element-page__child-description"
>
{{ childElement.description }}
</p>
</div>
<button
class="admin-element-page__secondary-button"
data-cy="admin-remove-child"
type="button"
:disabled="isManagingChildren"
@click="handleRemoveChild(childElement.id)"
>
Remove
</button>
</li>
</ul>
<p
v-else
class="admin-element-page__empty-media"
data-cy="admin-child-empty"
>
No child elements
</p>
<div class="admin-element-page__child-add">
<label class="admin-element-page__child-title-field">
<span class="admin-element-page__label">New child title</span>
<input
v-model="childForm.title"
class="admin-element-page__input"
data-cy="admin-child-title"
type="text"
:disabled="isManagingChildren"
@keydown.enter.prevent="handleAddChild"
/>
</label>
<button
class="admin-element-page__secondary-button"
data-cy="admin-add-child"
type="button"
:disabled="isManagingChildren || childForm.title === ''"
@click="handleAddChild"
>
{{ isManagingChildren ? 'Adding...' : 'Add child' }}
</button>
</div>
<p
v-if="childStatusMessage !== null"
:class="[
'admin-element-page__status',
'admin-element-page__status--inline',
]"
data-cy="admin-child-status"
aria-live="polite"
>
{{ childStatusMessage }}
</p>
</section>
<div class="admin-element-page__actions">
<button
class="admin-element-page__save"
data-cy="admin-element-save"
type="submit"
:disabled="isSaving"
>
{{ isSaving ? 'Saving...' : 'Save' }}
</button>
<p
v-if="statusMessage !== null"
:class="[
'admin-element-page__status',
'admin-element-page__status--inline',
]"
data-cy="admin-element-status"
aria-live="polite"
>
{{ statusMessage }}
</p>
</div>
<ElementSiblingNavigation
:current-element-id="element.id"
:sibling-elements="siblingElements"
data-cy-prefix="admin"
route-name="admin-element"
/>
</form>
</main>
</div>
</template>
<style scoped>
.admin-element-page {
min-height: 100vh;
background: var(--color-white);
}
.admin-element-page__main {
min-height: 100vh;
padding: 4rem 2rem 5rem;
}
.admin-element-page__header,
.admin-element-page__form,
.admin-element-page__status {
max-width: 48rem;
margin-right: auto;
margin-left: auto;
}
.admin-element-page__heading {
margin: 0;
color: #2c2c2c;
font-family: var(--font-serif);
font-size: 2rem;
font-weight: 400;
line-height: 1.2;
}
.admin-element-page__form {
display: flex;
flex-direction: column;
gap: 1.1rem;
margin-top: 2rem;
}
.admin-element-page__field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.admin-element-page__media-field,
.admin-element-page__children {
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;
font-weight: 500;
}
.admin-element-page__input,
.admin-element-page__textarea {
width: 100%;
padding: 0.7rem 0.8rem;
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.96rem;
line-height: 1.5;
}
.admin-element-page__input:focus,
.admin-element-page__textarea:focus {
border-color: var(--color-slate);
outline: none;
}
.admin-element-page__textarea {
resize: vertical;
}
.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__child-list {
display: flex;
flex-direction: column;
gap: 0.65rem;
width: 100%;
padding: 0;
margin: 0;
list-style: none;
}
.admin-element-page__child-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.7rem;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 4px;
}
.admin-element-page__child-body {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.admin-element-page__child-link {
color: var(--color-slate);
font-size: 0.95rem;
font-weight: 600;
}
.admin-element-page__child-link:hover,
.admin-element-page__child-link:focus-visible {
color: var(--color-text);
}
.admin-element-page__child-description {
margin: 0;
color: var(--color-text-muted);
font-size: 0.9rem;
line-height: 1.45;
}
.admin-element-page__child-add {
display: flex;
align-items: end;
gap: 0.7rem;
width: 100%;
}
.admin-element-page__child-title-field {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.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;
gap: 1rem;
margin-top: 0.5rem;
}
.admin-element-page__save {
padding: 0.7rem 1.4rem;
border: 1px solid var(--color-slate);
border-radius: 4px;
color: var(--color-white);
background: var(--color-slate);
font-family: var(--font-sans);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
}
.admin-element-page__save:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.admin-element-page__status {
margin-top: 2rem;
color: var(--color-text-muted);
}
.admin-element-page__status--inline {
margin: 0;
color: var(--color-slate);
}
.admin-element-page__status--error {
color: #9f2f2f;
}
@media (max-width: 768px) {
.admin-element-page__main {
padding: 2.5rem 1rem 4rem;
}
.admin-element-page__actions {
align-items: stretch;
flex-direction: column;
}
.admin-element-page__media-actions {
align-items: stretch;
flex-direction: column;
width: 100%;
}
.admin-element-page__child-add {
align-items: stretch;
flex-direction: column;
}
.admin-element-page__child-item {
flex-direction: column;
}
}
</style>