Add Template Gallery support

This commit is contained in:
yu-i-i
2026-07-17 17:56:04 +02:00
parent e0050414d6
commit 2a8b0bef67
68 changed files with 3063 additions and 31 deletions
@@ -0,0 +1,48 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import useIsMounted from '@/shared/hooks/use-is-mounted'
import OLButton from '@/features/ui/components/ol/ol-button'
import DeleteTemplateModal from './delete-template-modal'
import { Template } from '../../../../../types/template'
import { useTemplateContext } from '../context/template-context'
import { deleteTemplate } from '@/features/template/util/api'
function DeleteTemplateButton() {
const { t } = useTranslation()
const [showModal, setShowModal] = useState(false)
const isMounted = useIsMounted()
const { template, setTemplate } = useTemplateContext()
const handleOpenModal = () => {
setShowModal(true)
}
const handleCloseModal = () => {
if (isMounted.current) {
setShowModal(false)
}
}
const handleDeleteTemplate = async (template: Template) => {
await deleteTemplate(template)
handleCloseModal()
const previousPage = document.referrer || '/templates'
window.location.href = previousPage
}
return (
<>
<OLButton variant="danger" onClick={handleOpenModal}>
{t('delete')}
</OLButton>
<DeleteTemplateModal
template={template}
actionHandler={handleDeleteTemplate}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
</>
)
}
export default DeleteTemplateButton
@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import Notification from '@/shared/components/notification'
import TemplateActionModal from './template-action-modal'
type DeleteTemplateModalProps = Pick<
React.ComponentProps<typeof TemplateActionModal>,
'template' | 'actionHandler' | 'showModal' | 'handleCloseModal'
>
function DeleteTemplateModal({
template,
actionHandler,
showModal,
handleCloseModal,
}: DeleteTemplateModalProps) {
const { t } = useTranslation()
return (
<TemplateActionModal
action="delete"
actionHandler={actionHandler}
title={t('delete_template')}
showModal={showModal}
handleCloseModal={handleCloseModal}
template={template}
>
<p>{t('about_to_delete_template')}</p>
<ul>
<li key={`template-action-list-${template.id}`}>
<b>{template.name}</b>
</li>
</ul>
<Notification
content={t('this_action_cannot_be_undone')}
type="warning"
/>
</TemplateActionModal>
)
}
export default withErrorBoundary(DeleteTemplateModal)
@@ -0,0 +1,46 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import useIsMounted from '@/shared/hooks/use-is-mounted'
import OLButton from '@/features/ui/components/ol/ol-button'
import EditTemplateModal from './edit-template-modal'
import { useTemplateContext } from '../context/template-context'
import { updateTemplate } from '@/features/template/util/api'
import type { Template } from '../../../../../types/template'
export default function EditTemplateButton() {
const { t } = useTranslation()
const [showModal, setShowModal] = useState(false)
const isMounted = useIsMounted()
const { template, setTemplate } = useTemplateContext()
const handleOpenModal = () => {
setShowModal(true)
}
const handleCloseModal = () => {
if (isMounted.current) {
setShowModal(false)
}
}
const handleEditTemplate = async (editedTemplate: Template) => {
const updated = await updateTemplate({ editedTemplate, template })
if (updated) {
setTemplate(prev => ({ ...prev, ...updated }))
}
}
return (
<>
<OLButton variant="secondary" onClick={handleOpenModal}>
{t('edit')}
</OLButton>
<EditTemplateModal
showModal={showModal}
handleCloseModal={handleCloseModal}
actionHandler={handleEditTemplate}
/>
</>
)
}
@@ -0,0 +1,153 @@
import React, { useEffect, useMemo, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLButton from '@/features/ui/components/ol/ol-button'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import { Template } from '../../../../../types/template'
import TemplateActionModal from './template-action-modal'
import { useTemplateContext } from '../context/template-context'
import SettingsTemplateCategory from './settings/settings-template-category'
import SettingsLanguage from './settings/settings-language'
type EditTemplateModalProps = {
showModal: boolean
handleCloseModal: () => void
actionHandler: (editedTemplate: Template) => void | Promise<void>
}
function EditTemplateModal({
showModal,
handleCloseModal,
actionHandler,
}: EditTemplateModalProps) {
const { t } = useTranslation()
const { template } = useTemplateContext()
const [editedTemplate, setEditedTemplate] = useState<Template>({ ...template })
const [actionError, setActionError] = useState<any>(null)
useEffect(() => {
if (showModal) {
setEditedTemplate({ ...template })
setActionError(null)
}
}, [showModal, template])
const isConflictError = actionError?.info?.statusCode === 409
const valid = useMemo(
() => editedTemplate.name.trim().length > 0 && editedTemplate.license.trim().length > 0,
[editedTemplate.name, editedTemplate.license]
)
const clearModalErrorRef = useRef<() => void>(() => {})
return (
<TemplateActionModal
action="edit"
title={t('edit_template')}
template={editedTemplate}
showModal={showModal}
handleCloseModal={handleCloseModal}
actionHandler={() =>
Promise.resolve(actionHandler(editedTemplate)).catch(err => {
setActionError(err)
throw err
})
}
renderFooterButtons={({ onConfirm, onCancel, isProcessing }) => (
<>
<OLButton variant="secondary" onClick={onCancel}>
{t('cancel')}
</OLButton>
<OLButton
onClick={onConfirm}
variant="primary"
type="submit"
form="edit-template-form"
disabled={!valid || isProcessing || isConflictError}
>
{t('save')}
</OLButton>
</>
)}
onClearError={fn => {
clearModalErrorRef.current = fn
}}
>
<OLForm id="edit-template-form" onSubmit={e => e.preventDefault()}>
<OLFormGroup controlId="edit-template-form-title">
<OLFormLabel>{t('template_title')}</OLFormLabel>
<OLFormControl
type="text"
required
value={editedTemplate.name}
onChange={e => {
setEditedTemplate(prev => ({ ...prev, name: e.target.value }))
if (isConflictError) {
setActionError(null)
clearModalErrorRef.current?.()
}
}}
/>
</OLFormGroup>
<OLFormGroup controlId="edit-template-form-category">
<SettingsTemplateCategory
value={editedTemplate.category}
onChange={category =>
setEditedTemplate(prev => ({ ...prev, category }))
}
/>
</OLFormGroup>
<OLFormGroup controlId="edit-template-form-author">
<OLFormLabel>{t('Author')}</OLFormLabel>
<OLFormControl
type="text"
value={editedTemplate.authorMD}
onChange={e =>
setEditedTemplate(prev => ({ ...prev, authorMD: e.target.value }))
}
/>
</OLFormGroup>
<OLFormGroup controlId="edit-template-form-license">
<OLFormLabel>{t('License')}</OLFormLabel>
<OLFormControl
type="text"
required
value={editedTemplate.license}
onChange={e =>
setEditedTemplate(prev => ({ ...prev, license: e.target.value }))
}
/>
</OLFormGroup>
<OLFormGroup controlId="edit-template-form-description">
<OLFormLabel>{t('template_description')}</OLFormLabel>
<OLFormControl
as="textarea"
value={editedTemplate.descriptionMD}
onChange={e =>
setEditedTemplate(prev => ({ ...prev, descriptionMD: e.target.value }))
}
rows={6}
/>
</OLFormGroup>
<OLFormGroup controlId="edit-template-form-language">
<SettingsLanguage
value={editedTemplate.language}
onChange={language =>
setEditedTemplate(prev => ({ ...prev, language }))
}
/>
</OLFormGroup>
</OLForm>
</TemplateActionModal>
)
}
export default withErrorBoundary(React.memo(EditTemplateModal))
@@ -0,0 +1,33 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '../../../../utils/meta'
import SettingsMenuSelect from './settings-menu-select'
export default function SettingsLanguage({ value, onChange }) {
const { t } = useTranslation()
const optgroup: Optgroup = useMemo(() => {
const options = (getMeta('ol-languages') ?? [])
// only include spell-check languages that are available in the client
.filter(language => language.dic !== undefined)
return {
label: 'Language',
options: options.map(language => ({
value: language.code,
label: language.name,
})),
}
}, [])
return (
<SettingsMenuSelect
onChange={onChange}
value={value}
options={[{ value: '', label: t('off') }]}
optgroup={optgroup}
label={t('spell_check')}
name="spellCheckLanguage"
/>
)
}
@@ -0,0 +1,106 @@
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
import { ChangeEventHandler, useCallback, useRef } from 'react'
import { Spinner } from 'react-bootstrap-5'
type PossibleValue = string | number | boolean
export type Option<T extends PossibleValue = string> = {
value: T
label: string
ariaHidden?: 'true' | 'false'
disabled?: boolean
}
export type Optgroup<T extends PossibleValue = string> = {
label: string
options: Array<Option<T>>
}
type SettingsMenuSelectProps<T extends PossibleValue = string> = {
label: string
name: string
options: Array<Option<T>>
optgroup?: Optgroup<T>
loading?: boolean
onChange: (val: T) => void
value?: T
disabled?: boolean
}
export default function SettingsMenuSelect<T extends PossibleValue = string>({
label,
name,
options,
optgroup,
loading,
onChange,
value,
disabled = false,
}: SettingsMenuSelectProps<T>) {
const handleChange: ChangeEventHandler<HTMLSelectElement> = useCallback(
event => {
const selectedValue = event.target.value
let onChangeValue: PossibleValue = selectedValue
if (typeof value === 'boolean') {
onChangeValue = selectedValue === 'true'
} else if (typeof value === 'number') {
onChangeValue = parseInt(selectedValue, 10)
}
onChange(onChangeValue as T)
},
[onChange, value]
)
const selectRef = useRef<HTMLSelectElement | null>(null)
return (
<OLFormGroup
controlId={`settings-menu-${name}`}
className="left-menu-setting"
>
<OLFormLabel>{label}</OLFormLabel>
{loading ? (
<p className="mb-0">
<Spinner
animation="border"
aria-hidden="true"
size="sm"
role="status"
/>
</p>
) : (
<OLFormSelect
size="sm"
onChange={handleChange}
value={value?.toString()}
disabled={disabled}
ref={selectRef}
>
{options.map(option => (
<option
key={`${name}-${option.value}`}
value={option.value.toString()}
aria-hidden={option.ariaHidden}
disabled={option.disabled}
>
{option.label}
</option>
))}
{optgroup ? (
<optgroup label={optgroup.label}>
{optgroup.options.map(option => (
<option
value={option.value.toString()}
key={option.value.toString()}
>
{option.label}
</option>
))}
</optgroup>
) : null}
</OLFormSelect>
)}
</OLFormGroup>
)
}
@@ -0,0 +1,44 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import SettingsMenuSelect from './settings-menu-select'
import type { Option } from './settings-menu-select'
export type SettingsTemplateCategoryProps = {
value: string
onChange: (value: string) => void
}
export default function SettingsTemplateCategory({
value,
onChange,
}: SettingsTemplateCategoryProps) {
const { t } = useTranslation()
const { templateLinks = [] } = getMeta('ol-ExposedSettings') as {
templateLinks?: Array<{ name: string; url: string; description: string }>
}
if (templateLinks.length === 0) {
return null
}
const options: Option[] = useMemo(
() =>
templateLinks.map(({ name, url }) => ({
value: url,
label: name,
})),
[templateLinks]
)
return (
<SettingsMenuSelect
name="category"
label={t('template_category')}
value={value}
onChange={onChange}
options={options}
/>
)
}
@@ -0,0 +1,136 @@
import { memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Template } from '../../../../../types/template'
import { getUserFacingMessage } from '@/infrastructure/fetch-json'
import useIsMounted from '@/shared/hooks/use-is-mounted'
import * as eventTracking from '@/infrastructure/event-tracking'
import { isSmallDevice } from '@/infrastructure/event-tracking'
import Notification from '@/shared/components/notification'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
type TemplateActionModalProps = {
title?: string
action: 'delete' | 'edit'
actionHandler: (template: Template) => Promise<void>
handleCloseModal: () => void
template: Template
showModal: boolean
children?: React.ReactNode
renderFooterButtons?: (props: {
onConfirm: () => void
onCancel: () => void
isProcessing: boolean
}) => React.ReactNode
onClearError?: (clear: () => void) => void
}
function TemplateActionModal({
title,
action,
actionHandler,
handleCloseModal,
showModal,
template,
children,
renderFooterButtons,
onClearError,
}: TemplateActionModalProps) {
const { t } = useTranslation()
const [error, setError] = useState<false | { name: string; error: unknown }>(false)
const [isProcessing, setIsProcessing] = useState(false)
const isMounted = useIsMounted()
useEffect(() => {
if (onClearError) {
onClearError(() => setError(false))
}
}, [onClearError])
async function handleActionForTemplate(template: Template) {
let errored
setIsProcessing(true)
setError(false)
try {
await actionHandler(template)
} catch (e) {
errored = { name: template.name, error: e }
}
if (isMounted.current) {
setIsProcessing(false)
}
if (!errored) {
handleCloseModal()
} else {
setError(errored)
}
}
useEffect(() => {
if (showModal) {
eventTracking.sendMB('template-info-page-interaction', {
action,
isSmallDevice,
})
} else {
setError(false)
}
}, [action, showModal])
return (
<OLModal
animation
show={showModal}
onHide={handleCloseModal}
id="action-tempate-modal"
backdrop="static"
>
<OLModalHeader closeButton>
<OLModalTitle>{title}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{children}
{!isProcessing && error && (
<Notification
type="error"
title={error.name}
content={getUserFacingMessage(error.error) as string}
/>
)}
</OLModalBody>
<OLModalFooter>
{renderFooterButtons ? (
renderFooterButtons({
onConfirm: () => handleActionForTemplate(template),
onCancel: handleCloseModal,
isProcessing,
})
) : (
<>
<OLButton variant="secondary" onClick={handleCloseModal}>
{t('cancel')}
</OLButton>
<OLButton
variant="danger"
onClick={() => handleActionForTemplate(template)}
disabled={isProcessing}
>
{t('confirm')}
</OLButton>
</>
)}
</OLModalFooter>
</OLModal>
)
}
export default memo(TemplateActionModal)
@@ -0,0 +1,103 @@
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import { formatDate, fromNowDate } from '../../../utils/dates'
import { cleanHtml } from '../../../../../modules/template-gallery/app/src/CleanHtml.mjs'
import { useTemplateContext } from '../context/template-context'
import DeleteTemplateButton from './delete-template-button'
import EditTemplateButton from './edit-template-button'
function TemplateDetails() {
const { t } = useTranslation()
const {template, setTemplate} = useTemplateContext()
const lastUpdatedDate = fromNowDate(template.lastUpdated)
const tooltipText = formatDate(template.lastUpdated)
const loggedInUserId = getMeta('ol-user_id')
const loggedInUserIsAdmin = getMeta('ol-userIsAdmin')
const openAsTemplateParams = new URLSearchParams({
version: template.version,
...(template.brandVariationId && { brandVariationId: template.brandVariationId }),
name: template.name,
compiler: template.compiler,
mainFile: template.mainFile,
language: template.language,
...(template.imageName && { imageName: template.imageName })
}).toString()
const sanitizedAuthor = cleanHtml(template.author, 'linksOnly') || t('anonymous')
const sanitizedDescription = cleanHtml(template.description, 'reachText')
return (
<>
<OLRow>
<OLCol md={12}>
<div className={"gallery-item-title"}>
<h1 className="h2">{template.name}</h1>
</div>
</OLCol>
</OLRow>
<OLRow className="cta-links-container">
<OLCol md={12} className="cta-links">
<a className="btn btn-primary cta-link" href={`/project/new/template/${template.id}?${openAsTemplateParams}`}>{t('open_as_template')}</a>
<a className="btn btn-secondary cta-link" href={`/template/${template.id}/preview?version=${template.version}`}>{t('view_pdf')}</a>
</OLCol>
</OLRow>
<div className="template-details-container">
<div className="template-detail">
<div>
<b>{t('author')}:</b>
</div>
<div dangerouslySetInnerHTML={{ __html: sanitizedAuthor }} />
</div>
<div className="template-detail">
<div>
<b>{t('last_updated')}:</b>
</div>
<div>
<OLTooltip
id={`${template.id}`}
description={tooltipText}
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
>
<span>
{lastUpdatedDate.trim()}
</span>
</OLTooltip>
</div>
</div>
<div className="template-detail">
<div>
<b>{t('license')}:</b>
</div>
<div>
{template.license}
</div>
</div>
{sanitizedDescription && (
<div className="template-detail">
<div>
<b>{t('abstract')}:</b>
</div>
<div
className="gallery-abstract"
data-ol-mathjax=""
dangerouslySetInnerHTML={{ __html: sanitizedDescription }}>
</div>
</div>
)}
</div>
{loggedInUserId && (loggedInUserId === template.owner || loggedInUserIsAdmin) && (
<OLRow className="cta-links-container">
<OLCol md={12} className="text-end">
<EditTemplateButton />
<DeleteTemplateButton />
</OLCol>
</OLRow>
)}
</>
)
}
export default TemplateDetails
@@ -0,0 +1,23 @@
import OLCol from '@/features/ui/components/ol/ol-col'
import OLRow from '@/features/ui/components/ol/ol-row'
import { useTemplateContext } from '../context/template-context'
function TemplatePreview() {
const { template, setTemplate } = useTemplateContext()
return (
<div className="entry">
<OLRow>
<OLCol md={12}>
<div className="gallery-large-pdf-preview">
<img
src={`/template/${template.id}/preview?version=${template.version}&style=preview`}
alt={template.name}
/>
</div>
</OLCol>
</OLRow>
</div>
)
}
export default TemplatePreview
@@ -0,0 +1,70 @@
import { useTranslation } from 'react-i18next'
import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import { GenericErrorBoundaryFallback } from '@/shared/components/generic-error-boundary-fallback'
import DefaultNavbar from '@/features/ui/components/bootstrap-5/navbar/default-navbar'
import Footer from '@/features/ui/components/bootstrap-5/footer/footer'
import getMeta from '@/utils/meta'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLRow from '@/features/ui/components/ol/ol-row'
import TemplateDetails from './template-details'
import TemplatePreview from './template-preview'
import { useTemplateContext, TemplateProvider } from '../context/template-context'
function TemplateRoot() {
const { isReady } = useWaitForI18n()
if (!isReady) {
return null
}
return (
<TemplateProvider>
<TemplatePageContent />
</TemplateProvider>
)
}
function TemplatePageContent() {
const { t } = useTranslation()
const navbarProps = getMeta('ol-navbar')
const footerProps = getMeta('ol-footer')
const { template } = useTemplateContext()
const { templateLinks } = getMeta('ol-ExposedSettings') || []
const categoryName = templateLinks?.find(link => link.url === template.category)?.name || t('all_templates')
return (
<>
<DefaultNavbar {...navbarProps} />
<main id="main-content" className="gallery content content-page">
<div className="container">
<OLRow className="previous-page-link-container">
<OLCol lg={6}>
<a className="previous-page-link" href={template.category}>
<i className="material-symbols material-symbols-rounded" aria-hidden="true">arrow_left_alt</i>
{categoryName}
</a>
{template.category !== '/templates/all' && (
<>
<span className="mx-2">/</span>
<a className="previous-page-link" href={'/templates/all'}>
{t('all_templates')}
</a>
</>
)}
</OLCol>
</OLRow>
<OLRow>
<OLCol className="template-item-left-section" md={6}>
<TemplateDetails />
</OLCol>
<OLCol className="template-item-right-section" md={6}>
<TemplatePreview />
</OLCol>
</OLRow>
</div>
</main>
<Footer {...footerProps} />
</>
)
}
export default withErrorBoundary(TemplateRoot, GenericErrorBoundaryFallback)
@@ -0,0 +1,53 @@
import {
createContext,
FC,
useCallback,
useContext,
useState,
useMemo,
} from 'react'
import useEventListener from '@/shared/hooks/use-event-listener'
import getMeta from '@/utils/meta'
import { Template } from '../../../../../types/template'
type TemplateContextType = {
template: Template
setTemplate: (template: Template) => void
}
export const TemplateContext = createContext<TemplateContextType | undefined>(
undefined
)
type TemplateProviderProps = {
loadedTemplate: Template
}
export const TemplateProvider: FC<TemplateProviderProps> = ({ children }) => {
const loadedTemplate = useMemo(() => getMeta('ol-template'), [])
const [template, setTemplate] = useState(loadedTemplate)
const value = useMemo(
() => ({
template,
setTemplate,
}),
[template, setTemplate]
)
return (
<TemplateContext.Provider value={value}>
{children}
</TemplateContext.Provider>
)
}
export const useTemplateContext = () => {
const context = useContext(TemplateContext)
if (!context) {
throw new Error(
`useTemplateContext must be used within a TemplateProvider`
)
}
return context
}
@@ -0,0 +1,47 @@
import { deleteJSON, postJSON } from '@/infrastructure/fetch-json'
import { Template } from '../../../../../types/template'
export function deleteTemplate(template: Template) {
return deleteJSON(`/template/${template.id}/delete`, {
body: {
version: template.version,
},
})
}
type UpdateTemplateOptions = {
template: Template
initialTemplate: Template
descriptionEdited: boolean
}
export function updateTemplate({
editedTemplate,
template
}: UpdateTemplateOptions): Promise<Template | null> {
const updatedFields: Partial<Template> = {
name: editedTemplate.name.trim(),
license: editedTemplate.license.trim(),
category: editedTemplate.category,
language: editedTemplate.language,
authorMD: editedTemplate.authorMD.trim(),
descriptionMD: editedTemplate.descriptionMD.trim(),
}
const changedFields = Object.entries(updatedFields).reduce((diff, [key, value]) => {
if (value !== undefined && template[key as keyof Template] !== value) {
diff[key] = value
}
return diff
}, {} as Partial<Template>)
if (Object.keys(changedFields).length === 0) {
return null
}
const updated = postJSON(`/template/${editedTemplate.id}/edit`, {
body: changedFields
})
return updated
}