mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 22:23:42 +01:00
Add Template Gallery support
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
+106
@@ -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>
|
||||
)
|
||||
}
|
||||
+44
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user