Refactor Template Gallery; resolves #38 and #39

- Replace free-text license input with a select box
- Improve visual presentation of modals and enhance keyboard interaction
This commit is contained in:
yu-i-i
2025-05-12 14:27:57 +02:00
parent 7a167031ab
commit 1bd6de3439
36 changed files with 723 additions and 590 deletions

View File

@@ -686,16 +686,11 @@ const _ProjectController = {
)
}
console.log("Features.hasFeature('templates-server-pro') = ", Features.hasFeature('templates-server-pro'))
console.log("Settings.templates?.nonAdminCanManage", Settings.templates?.nonAdminCanManage)
const isAdminOrTemplateOwner =
hasAdminAccess(user) || Settings.templates?.nonAdminCanManage
const showTemplatesServerPro =
Features.hasFeature('templates-server-pro') && isAdminOrTemplateOwner
console.log("showTemplatesServerPro = ", showTemplatesServerPro )
const debugPdfDetach = shouldDisplayFeature('debug_pdf_detach')
const detachRole = req.params.detachRole

View File

@@ -97,7 +97,7 @@ const TemplatesManager = {
await ProjectOptionsHandler.setImageName(projectId, imageName)
} catch {
logger.warn({ imageName: imageName }, 'not available')
await ProjectOptionsHandler.setImageName(projectId, process.env.TEX_LIVE_DOCKER_IMAGE)
await ProjectOptionsHandler.setImageName(projectId, settings.currentImageName)
}
},

View File

@@ -232,6 +232,7 @@
"card_payment": "",
"careers": "",
"categories": "",
"category": "",
"category_arrows": "",
"category_greek": "",
"category_misc": "",
@@ -1834,10 +1835,8 @@
"tax_id_type": "",
"tell_the_project_owner_and_ask_them_to_upgrade": "",
"template": "",
"template_category": "",
"template_description": "",
"template_gallery": "",
"template_title": "",
"template_title_taken_from_project_title": "",
"templates": "",
"temporarily_hides_the_preview": "",

View File

@@ -4,7 +4,7 @@ import * as eventTracking from '../../../infrastructure/event-tracking'
import getMeta from '../../../utils/meta'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import { useDetachCompileContext } from '../../../shared/context/detach-compile-context'
import EditorManageTemplateModalWrapper from '../../manage-template-modal/components/editor-manage-template-modal-wrapper'
import EditorManageTemplateModalWrapper from '../../template/components/manage-template-modal/editor-manage-template-modal-wrapper'
import LeftMenuButton from './left-menu-button'
type TemplateManageResponse = {

View File

@@ -1,37 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import { useProjectContext } from '../../../shared/context/project-context'
import ManageTemplateModal from './manage-template-modal'
const EditorManageTemplateModalWrapper = React.memo(
function EditorManageTemplateModalWrapper({ show, handleHide, openTemplate }) {
const {
_id: projectId,
name: projectName,
} = useProjectContext()
if (!projectName) {
// wait for useProjectContext
return null
} else {
return (
<ManageTemplateModal
handleHide={handleHide}
show={show}
handleAfterPublished={openTemplate}
projectId={projectId}
projectName={projectName}
/>
)
}
}
)
EditorManageTemplateModalWrapper.propTypes = {
show: PropTypes.bool.isRequired,
handleHide: PropTypes.func.isRequired,
openTemplate: PropTypes.func.isRequired,
}
export default withErrorBoundary(EditorManageTemplateModalWrapper)

View File

@@ -1,203 +0,0 @@
import { useMemo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PropTypes from 'prop-types'
import { debugConsole } from '@/utils/debugging'
import { getJSON, postJSON } from '../../../infrastructure/fetch-json'
import Notification from '@/shared/components/notification'
import {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
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 { useDetachCompileContext } from '../../../shared/context/detach-compile-context'
import { useUserContext } from '../../../shared/context/user-context'
import SettingsTemplateCategory from './settings-template-category'
const defaultLicense = 'Creative Commons CC BY 4.0'
export default function ManageTemplateModalContent({
handleHide,
inFlight,
setInFlight,
handleAfterPublished,
projectId,
projectName,
}) {
const { t } = useTranslation()
const { pdfFile } = useDetachCompileContext()
const user = useUserContext()
const [error, setError] = useState()
const [disablePublish, setDisablePublish] = useState(false)
const [notificationType, setNotificationType] = useState('error')
const [name, setName] = useState(`${projectName}`)
const [description, setDescription] = useState('')
const [author, setAuthor] = useState(`${user.first_name} ${user.last_name}`.trim())
const [license, setLicense] = useState(defaultLicense)
const [category, setCategory] = useState()
const [override, setOverride] = useState(false)
const [titleConflict, setTitleConflict] = useState(false)
const valid = useMemo(
() => name.trim().length > 0 && license.trim().length,
[name, license]
)
useEffect(() => {
const queryParams = new URLSearchParams({ key: 'name', val: projectName })
getJSON(`/api/template?${queryParams}`)
.then((data) => {
if (!data) return
setDescription(data.descriptionMD)
setAuthor(data.authorMD)
setLicense(data.license)
setCategory(data.category)
})
.catch(debugConsole.error)
}, [])
const handleSubmit = event => {
event.preventDefault()
if (!valid) {
return
}
setError(false)
setInFlight(true)
postJSON(`/template/new/${projectId}`, {
body: {
category,
name: name.trim(),
authorMD: author.trim(),
license: license.trim(),
descriptionMD: description.trim(),
build: pdfFile.build,
override,
},
})
.then(data => {
// redirect to template page
handleHide()
handleAfterPublished(data)
})
.catch(({ response, data }) => {
if (response?.status === 409 && data.canOverride) {
setNotificationType('warning')
setOverride(true)
} else {
setNotificationType('error')
setDisablePublish(true)
}
setError(data.message)
if (response?.status === 409) setTitleConflict(true)
})
.finally(() => {
setInFlight(false)
})
}
const handleNameChange = event => {
setName(event.target.value)
if (titleConflict) {
setError(false)
setOverride(false)
if (disablePublish) setDisablePublish(false)
}
}
return (
<>
<OLModalHeader closeButton>
<OLModalTitle>{t('publish_as_template')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<OLForm id="publish-template-form" onSubmit={handleSubmit}>
<OLFormGroup controlId="publish-template-form-title">
<OLFormLabel>{t('template_title')}</OLFormLabel>
<OLFormControl
type="text"
placeholder="New Template"
required
value={name}
onChange={handleNameChange}
/>
</OLFormGroup>
<OLFormGroup controlId="publish-template-form-category">
<SettingsTemplateCategory
setCategory={setCategory}
category={category}
/>
</OLFormGroup>
<OLFormGroup controlId="publish-template-form-author">
<OLFormLabel>{t('Author')}</OLFormLabel>
<OLFormControl
type="text"
placeholder="Anonymous"
value={author}
onChange={event => setAuthor(event.target.value)}
/>
</OLFormGroup>
<OLFormGroup controlId="publish-template-form-license">
<OLFormLabel>{t('License')}</OLFormLabel>
<OLFormControl
type="text"
placeholder="Template License"
required
value={license}
onChange={event => setLicense(event.target.value)}
/>
</OLFormGroup>
<OLFormGroup controlId="publish-template-form-description">
<OLFormLabel>{t('template_description')}</OLFormLabel>
<OLFormControl
as="textarea"
value={description}
onChange={event => setDescription(event.target.value)}
rows={4}
autoFocus
/>
</OLFormGroup>
</OLForm>
{error && (
<Notification
content={error.length ? error : t('generic_something_went_wrong')}
type={notificationType}
/>
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" disabled={inFlight} onClick={handleHide}>
{t('cancel')}
</OLButton>
<OLButton
variant={override ? "danger" : "primary"}
disabled={inFlight || !valid || disablePublish}
form="publish-template-form"
type="submit"
>
{inFlight ? <>{t('publishing')}</> : override ? t('overwrite') : t('publish')}
</OLButton>
</OLModalFooter>
</>
)
}
ManageTemplateModalContent.propTypes = {
handleHide: PropTypes.func.isRequired,
inFlight: PropTypes.bool,
handleAfterPublished: PropTypes.func.isRequired,
setInFlight: PropTypes.func.isRequired,
projectId: PropTypes.string,
projectName: PropTypes.string,
}

View File

@@ -1,51 +0,0 @@
import { useMemo, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '../../../utils/meta'
import SettingsMenuSelect from '@/features/editor-left-menu/components/settings/settings-menu-select'
import type { Option } from '@/features/editor-left-menu/components/settings/settings-menu-select'
interface ManageTemplateCategoryProps {
category: string | null
setCategory: (value: string) => void
}
export default function SettingsTemplateCategory({
category,
setCategory
}: ManageTemplateCategoryProps) {
const { t } = useTranslation()
const { templateLinks } = useMemo(
() => getMeta('ol-ExposedSettings') || [],
[]
)
if (templateLinks.length === 0) {
return null
}
const options: Option[] = useMemo(
() =>
templateLinks.map(({ name, url }) => ({
value: url,
label: name,
})),
[templateLinks]
)
useEffect(() => {
if (!category && options.length > 0) {
setCategory(options[0].value)
}
}, [options, category, setCategory])
return (
<SettingsMenuSelect
onChange={setCategory}
value={category ?? ""}
options={options}
label={t('template_category')}
name="category"
/>
)
}

View File

@@ -52,7 +52,7 @@ export default function GallerySearchSortHeader( { gotoAllLink }: { boolean } )
</a>
</OLCol>
)}
<OLCol className="d-flex justify-content-center gap-2">
<OLCol className="d-flex justify-content-center gap-2">
<SortByButton
column="lastUpdated"
text={t('last_updated')}

View File

@@ -35,6 +35,8 @@ export default function SearchForm({
onSubmit={e => e.preventDefault()}
>
<OLFormControl
className="gallery-search-form-control"
id="gallery-search-form-control"
type="text"
value={inputValue}
onChange={handleChange}

View File

@@ -2,10 +2,10 @@ 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 DeleteTemplateModal from './modals/delete-template-modal'
import { useTemplateContext } from '../context/template-context'
import { deleteTemplate } from '@/features/template/util/api'
import { deleteTemplate } from '../util/api'
import type { Template } from '../../../../../types/template'
function DeleteTemplateButton() {
const { t } = useTranslation()

View File

@@ -2,9 +2,9 @@ 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 EditTemplateModal from './modals/edit-template-modal'
import { useTemplateContext } from '../context/template-context'
import { updateTemplate } from '@/features/template/util/api'
import { updateTemplate } from '../util/api'
import type { Template } from '../../../../../types/template'
export default function EditTemplateButton() {

View File

@@ -1,153 +0,0 @@
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))

View File

@@ -0,0 +1,17 @@
import React from 'react'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
interface FormFieldInputProps extends React.ComponentProps<typeof OLFormControl> {
value: string
placeholder?: string
onChange: React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>
}
const FormFieldInput: React.FC<FormFieldInputProps> = ({
type = 'text',
...props
}) => (
<OLFormControl type={type} {...props} />
)
export default FormFieldInput

View File

@@ -0,0 +1,26 @@
import React from 'react'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
interface LabeledRowFormGroupProps {
controlId: string
label: string
children: React.ReactNode
}
const LabeledRowFormGroup: React.FC<LabeledRowFormGroupProps> = ({
controlId,
label,
children,
}) => (
<OLFormGroup controlId={controlId} className="row">
<div className="col-2">
<OLFormLabel className="col-form-label col">{label}</OLFormLabel>
</div>
<div className="col-10">
{children}
</div>
</OLFormGroup>
)
export default React.memo(LabeledRowFormGroup)

View File

@@ -0,0 +1,96 @@
import React, { useCallback } from 'react'
import LabeledRowFormGroup from '../form/labeled-row-form-group'
import FormFieldInput from '../form/form-field-input'
import SettingsTemplateCategory from '../settings/settings-template-category'
import SettingsLicense from '../settings/settings-license'
import SettingsLanguage from '../settings/settings-language'
import { useTranslation } from 'react-i18next'
import type { Template } from '../../../../../../types/template'
interface TemplateFormFieldsProps {
template: Partial<Template>
includeLanguage?: boolean
onChange: (changes: Partial<Template>) => void
onEnterKey?: () => void
}
function TemplateFormFields({
template,
includeLanguage = false,
onChange,
onEnterKey,
}: TemplateFormFieldsProps) {
const { t } = useTranslation()
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
onEnterKey?.()
}
},
[onEnterKey]
)
return (
<>
<LabeledRowFormGroup controlId="form-title" label={t('title') + ':'}>
<FormFieldInput
required
maxLength="255"
value={template.name ?? ''}
placeholder={t('title')}
onChange={e => onChange({ name: e.target.value })}
onKeyDown={handleKeyDown}
/>
</LabeledRowFormGroup>
<LabeledRowFormGroup controlId="form-author" label={t('author') + ':'}>
<FormFieldInput
maxLength="255"
value={template.authorMD ?? ''}
placeholder={t('author')}
onChange={e => onChange({ authorMD: e.target.value })}
onKeyDown={handleKeyDown}
/>
</LabeledRowFormGroup>
<LabeledRowFormGroup controlId="form-category" label={t('category') + ':'}>
<SettingsTemplateCategory
value={template.category}
onChange={val => onChange({ category: val })}
/>
</LabeledRowFormGroup>
<LabeledRowFormGroup controlId="form-description" label={t('description') + ':'}>
<FormFieldInput
as="textarea"
rows={8}
maxLength="5000"
value={template.descriptionMD ?? ''}
placeholder={t('description')}
onChange={e => onChange({ descriptionMD: e.target.value })}
autoFocus
/>
</LabeledRowFormGroup>
<LabeledRowFormGroup controlId="form-license" label={t('license') + ':'}>
<SettingsLicense
value={template.license}
onChange={val => onChange({ license: val })}
/>
</LabeledRowFormGroup>
{includeLanguage && (
<LabeledRowFormGroup controlId="form-language" label={t('language') + ':'}>
<SettingsLanguage
value={template.language}
onChange={val => onChange({ language: val })}
/>
</LabeledRowFormGroup>
)}
</>
)
}
export default React.memo(TemplateFormFields)

View File

@@ -0,0 +1,40 @@
import React from 'react'
import withErrorBoundary from '@/infrastructure/error-boundary'
import { useProjectContext } from '@/shared/context/project-context'
import ManageTemplateModal from './manage-template-modal'
import type { Template } from '../../../../../../types/template'
interface EditorManageTemplateModalWrapperProps {
show: boolean
handleHide: () => void
openTemplate: (data: Template) => void
}
const EditorManageTemplateModalWrapper = React.memo(
function EditorManageTemplateModalWrapper({
show,
handleHide,
openTemplate,
}: EditorManageTemplateModalWrapperProps) {
const {
_id: projectId,
name: projectName,
} = useProjectContext()
if (!projectName) {
// wait for useProjectContext
return null
}
return (
<ManageTemplateModal
handleHide={handleHide}
show={show}
handleAfterPublished={openTemplate}
projectId={projectId}
projectName={projectName}
/>
)
}
)
export default withErrorBoundary(EditorManageTemplateModalWrapper)

View File

@@ -0,0 +1,169 @@
import React, { useEffect, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { debugConsole } from '@/utils/debugging'
import { getJSON, postJSON } from '@/infrastructure/fetch-json'
import Notification from '@/shared/components/notification'
import {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useDetachCompileContext } from '@/shared/context/detach-compile-context'
import { useUserContext } from '@/shared/context/user-context'
import { useFocusTrap } from '../../hooks/use-focus-trap'
import TemplateFormFields from '../form/template-form-fields'
import type { Template } from '../../../../../../types/template'
interface ManageTemplateModalContentProps {
handleHide: () => void
inFlight: boolean
setInFlight: (inFlight: boolean) => void
handleAfterPublished: (data: Template) => void
projectId: string
projectName: string
}
export default function ManageTemplateModalContent({
handleHide,
inFlight,
setInFlight,
handleAfterPublished,
projectId,
projectName,
}: ManageTemplateModalContentProps) {
const { t } = useTranslation()
const { pdfFile } = useDetachCompileContext()
const user = useUserContext()
const [template, setTemplate] = useState<Partial<Template>>({
name: projectName,
authorMD: `${user.first_name} ${user.last_name}`.trim(),
})
const [override, setOverride] = useState(false)
const [titleConflict, setTitleConflict] = useState(false)
const [error, setError] = useState<string | false>(false)
const [notificationType, setNotificationType] = useState<'error' | 'warning'>('error')
const [disablePublish, setDisablePublish] = useState(false)
// Only the trimmed name gates submission
const valid = (template.name ?? '').trim()
useEffect(() => {
const queryParams = new URLSearchParams({ key: 'name', val: projectName })
getJSON(`/api/template?${queryParams}`)
.then((data) => {
if (!data) return
setTemplate(prev => ({
...prev,
descriptionMD: data.descriptionMD,
authorMD: data.authorMD,
license: data.license,
category: data.category,
}))
})
.catch(debugConsole.error)
}, [])
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!valid) return
setError(false)
setInFlight(true)
postJSON(`/template/new/${projectId}`, {
body: {
category: template.category,
name: valid,
authorMD: (template.authorMD ?? '').trim(),
license: template.license,
descriptionMD: (template.descriptionMD ?? '').trim(),
build: pdfFile.build,
override,
},
})
.then(data => {
handleHide()
handleAfterPublished(data)
})
.catch(({ response, data }) => {
if (response?.status === 409 && data.canOverride) {
setNotificationType('warning')
setOverride(true)
} else {
setNotificationType('error')
setDisablePublish(true)
}
setError(data.message)
if (response?.status === 409) setTitleConflict(true)
})
.finally(() => {
setInFlight(false)
})
}
const handleChange = (changes: Partial<Template>) => {
if ('name' in changes && titleConflict) {
setError(false)
setOverride(false)
if (disablePublish) setDisablePublish(false)
}
setTemplate(prev => ({ ...prev, ...changes }))
}
const handleEnterKey = () => {
document.getElementById('submit-publish-template')?.click()
}
const modalRef = useRef<HTMLDivElement>(null)
useFocusTrap(modalRef)
return (
<div ref={modalRef}>
<OLModalHeader closeButton>
<OLModalTitle>{t('publish_as_template')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<div className="modal-body-publish">
<div className="content-as-table">
<OLForm id="publish-template-form" onSubmit={handleSubmit}>
<TemplateFormFields
template={template}
includeLanguage={false}
onChange={handleChange}
onEnterKey={handleEnterKey}
/>
</OLForm>
</div>
</div>
{error && (
<Notification
content={error.length ? error : t('generic_something_went_wrong')}
type={notificationType}
/>
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" disabled={inFlight} onClick={handleHide}>
{t('cancel')}
</OLButton>
<OLButton
id="submit-publish-template"
variant={override ? 'danger' : 'primary'}
disabled={inFlight || !valid || disablePublish}
form="publish-template-form"
type="submit"
>
{inFlight ? <>{t('publishing')}</> : override ? t('overwrite') : t('publish')}
</OLButton>
</OLModalFooter>
</div>
)
}

View File

@@ -1,7 +1,15 @@
import React, { memo, useCallback, useState } from 'react'
import PropTypes from 'prop-types'
import OLModal from '@/features/ui/components/ol/ol-modal'
import ManageTemplateModalContent from './manage-template-modal-content'
import type { Template } from '../../../../../../types/template'
interface ManageTemplateModalProps {
show: boolean
handleHide: () => void
handleAfterPublished: (data: Template) => void
projectId: string
projectName: string
}
function ManageTemplateModal({
show,
@@ -9,7 +17,7 @@ function ManageTemplateModal({
handleAfterPublished,
projectId,
projectName,
}) {
}: ManageTemplateModalProps) {
const [inFlight, setInFlight] = useState(false)
const onHide = useCallback(() => {
@@ -20,6 +28,7 @@ function ManageTemplateModal({
return (
<OLModal
size="lg"
animation
show={show}
onHide={onHide}
@@ -40,12 +49,4 @@ function ManageTemplateModal({
)
}
ManageTemplateModal.propTypes = {
show: PropTypes.bool.isRequired,
handleHide: PropTypes.func.isRequired,
handleAfterPublished: PropTypes.func.isRequired,
projectId: PropTypes.string,
projectName: PropTypes.string,
}
export default memo(ManageTemplateModal)

View File

@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import withErrorBoundary from '@/infrastructure/error-boundary'
import Notification from '@/shared/components/notification'
import TemplateActionModal from './template-action-modal'

View File

@@ -0,0 +1,139 @@
import React, { useReducer, useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLButton from '@/features/ui/components/ol/ol-button'
import withErrorBoundary from '@/infrastructure/error-boundary'
//import { useFocusTrap } from '../../hooks/use-focus-trap'
import TemplateActionModal from './template-action-modal'
import { useTemplateContext } from '../../context/template-context'
import TemplateFormFields from '../form/template-form-fields'
import type { Template } from '../../../../../../types/template'
type EditTemplateModalProps = {
showModal: boolean
handleCloseModal: () => void
actionHandler: (editedTemplate: Template) => void | Promise<void>
}
type ActionError = {
info?: {
statusCode?: number
}
}
type TemplateFormAction =
| { type: 'UPDATE'; payload: Partial<Template> }
| { type: 'RESET'; payload: Template }
| { type: 'CLEAR_FIELD'; field: keyof Template }
function templateFormReducer(state: Template, action: TemplateFormAction): Template {
switch (action.type) {
case 'UPDATE':
return { ...state, ...action.payload }
case 'RESET':
return { ...action.payload }
case 'CLEAR_FIELD':
return { ...state, [action.field]: '' }
default:
return state
}
}
function EditTemplateModal({
showModal,
handleCloseModal,
actionHandler,
}: EditTemplateModalProps) {
const { t } = useTranslation()
const { template } = useTemplateContext()
const [editedTemplate, dispatch] = useReducer(templateFormReducer, template)
const [actionError, setActionError] = useState<ActionError | null>(null)
const clearModalErrorRef = useRef<() => void>(() => {})
useEffect(() => {
if (showModal) {
dispatch({ type: 'RESET', payload: template })
setActionError(null)
}
}, [showModal, template])
const isConflictError = useMemo(
() => actionError?.info?.statusCode === 409,
[actionError]
)
const valid = useMemo(
() => editedTemplate.name.trim().length > 0,
[editedTemplate.name]
)
const handleChange = useCallback(
(changes: Partial<Template>) => {
dispatch({ type: 'UPDATE', payload: changes })
if ('name' in changes && isConflictError) {
setActionError(null)
clearModalErrorRef.current?.()
}
},
[isConflictError]
)
const handleEnterKey = useCallback(() => {
document.getElementById('submit-edit-template')?.click()
}, [])
const handleAction = useCallback(() => {
return Promise.resolve(actionHandler(editedTemplate)).catch(err => {
setActionError(err)
throw err
})
}, [actionHandler, editedTemplate])
const submitButtonDisabled = !valid || isConflictError
return (
<TemplateActionModal
action="edit"
title={t('edit_template')}
template={editedTemplate}
showModal={showModal}
handleCloseModal={handleCloseModal}
size="lg"
actionHandler={handleAction}
renderFooterButtons={({ onConfirm, onCancel, isProcessing }) => (
<>
<OLButton variant="secondary" onClick={onCancel}>
{t('cancel')}
</OLButton>
<OLButton
id="submit-edit-template"
onClick={onConfirm}
variant="primary"
disabled={submitButtonDisabled || isProcessing}
>
{t('save')}
</OLButton>
</>
)}
onClearError={fn => {
clearModalErrorRef.current = fn
}}
>
<div className="modal-body-publish">
<div className="content-as-table">
<OLForm onSubmit={e => e.preventDefault()}>
<TemplateFormFields
template={editedTemplate}
includeLanguage
onChange={handleChange}
onEnterKey={handleEnterKey}
/>
</OLForm>
</div>
</div>
</TemplateActionModal>
)
}
export default withErrorBoundary(React.memo(EditTemplateModal))

View File

@@ -1,10 +1,9 @@
import { memo, useEffect, useState } from 'react'
import { memo, useEffect, useState, useRef } 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 useIsMounted from '@/shared/hooks/use-is-mounted'
import Notification from '@/shared/components/notification'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLModal, {
@@ -13,9 +12,12 @@ import OLModal, {
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import type { Template } from '../../../../../../types/template'
import { useFocusTrap } from '../../hooks/use-focus-trap'
type TemplateActionModalProps = {
title?: string
title: string
size?: string
action: 'delete' | 'edit'
actionHandler: (template: Template) => Promise<void>
handleCloseModal: () => void
@@ -32,6 +34,7 @@ type TemplateActionModalProps = {
function TemplateActionModal({
title,
size,
action,
actionHandler,
handleCloseModal,
@@ -45,6 +48,9 @@ function TemplateActionModal({
const [error, setError] = useState<false | { name: string; error: unknown }>(false)
const [isProcessing, setIsProcessing] = useState(false)
const isMounted = useIsMounted()
const modalRef = useRef<HTMLDivElement>(null)
useFocusTrap(modalRef, showModal)
useEffect(() => {
if (onClearError) {
@@ -81,56 +87,59 @@ function TemplateActionModal({
isSmallDevice,
})
} else {
setError(false)
setError(false)
}
}, [action, showModal])
return (
<OLModal
animation
size={size}
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>
<div ref={modalRef}>
<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>
</div>
</OLModal>
)
}
export default memo(TemplateActionModal)

View File

@@ -2,8 +2,17 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '../../../../utils/meta'
import SettingsMenuSelect from './settings-menu-select'
import type { Optgroup } from './settings-menu-select'
export default function SettingsLanguage({ value, onChange }) {
interface SettingsLanguageProps {
value: string
onChange: (value: string) => void
}
export default function SettingsLanguage({
value,
onChange,
}: SettingsLanguageProps) {
const { t } = useTranslation()
const optgroup: Optgroup = useMemo(() => {

View File

@@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next'
import SettingsMenuSelect from './settings-menu-select'
import type { Option } from './settings-menu-select'
export const licensesMap = {
'cc_by_4.0': 'Creative Commons CC BY 4.0',
'lppl_1.3c': 'LaTeX Project Public License 1.3c',
'other': 'Other (as stated in the work)',
}
interface SettingsLicenseProps {
value: string
onChange: (value: string) => void
}
export default function SettingsLicense({
value,
onChange,
}: SettingsLicenseProps) {
const { t } = useTranslation()
const options = Object.entries(licensesMap).map(([value, label]) => ({ value, label }))
return (
<SettingsMenuSelect
name="license"
label={t('license')}
value={value}
onChange={onChange}
options={options}
/>
)
}

View File

@@ -1,8 +1,7 @@
import { ChangeEventHandler, useCallback, useRef, useEffect } from 'react'
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
@@ -19,26 +18,27 @@ export type Optgroup<T extends PossibleValue = string> = {
}
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>) {
export default function SettingsMenuSelect<T extends PossibleValue = string>(
props: SettingsMenuSelectProps<T>
) {
const { name, options, optgroup, onChange, value, disabled = false } = props
const defaultApplied = useRef(false)
useEffect(() => {
if (value === undefined || value === null) {
onChange(options?.[0]?.value || optgroup?.options?.[0]?.value)
}
}, [value, options, onChange])
const handleChange: ChangeEventHandler<HTMLSelectElement> = useCallback(
event => {
const selectedValue = event.target.value
@@ -55,23 +55,9 @@ export default function SettingsMenuSelect<T extends PossibleValue = string>({
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}
@@ -100,7 +86,6 @@ export default function SettingsMenuSelect<T extends PossibleValue = string>({
</optgroup>
) : null}
</OLFormSelect>
)}
</OLFormGroup>
</>
)
}

View File

@@ -1,44 +1,44 @@
import { useMemo } from 'react'
import React, { 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 = {
interface SettingsTemplateCategoryProps {
value: string
onChange: (value: string) => void
}
export default function SettingsTemplateCategory({
const SettingsTemplateCategory: React.FC<SettingsTemplateCategoryProps> = ({
value,
onChange,
}: SettingsTemplateCategoryProps) {
}) => {
const { t } = useTranslation()
const { templateLinks = [] } = getMeta('ol-ExposedSettings') as {
templateLinks?: Array<{ name: string; url: string; description: string }>
}
const options: Option[] = useMemo(() => {
const { templateLinks = [] } = getMeta('ol-ExposedSettings') as {
templateLinks?: Array<{ name: string; url: string; description: string }>
}
if (templateLinks.length === 0) {
return templateLinks.map(({ name, url }) => ({
value: url,
label: name,
}))
}, [])
if (options.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')}
label={`${t('category')}:`}
value={value}
onChange={onChange}
options={options}
/>
)
}
export default React.memo(SettingsTemplateCategory)

View File

@@ -8,6 +8,7 @@ import { cleanHtml } from '../../../../../modules/template-gallery/app/src/Clean
import { useTemplateContext } from '../context/template-context'
import DeleteTemplateButton from './delete-template-button'
import EditTemplateButton from './edit-template-button'
import { licensesMap } from './settings/settings-license'
function TemplateDetails() {
const { t } = useTranslation()
@@ -73,7 +74,7 @@ function TemplateDetails() {
<b>{t('license')}:</b>
</div>
<div>
{template.license}
{licensesMap[template.license]}
</div>
</div>
{sanitizedDescription && (

View File

@@ -0,0 +1,46 @@
import { useEffect } from 'react'
export function useFocusTrap(ref: React.RefObject<HTMLElement>, enabled = true) {
useEffect(() => {
if (!enabled || !ref.current) return
const element = ref.current
const previouslyFocusedElement = document.activeElement as HTMLElement
const focusableElements = element.querySelectorAll<HTMLElement>(
'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
// Don't override if something inside already received focus
const isAlreadyFocusedInside = element.contains(document.activeElement)
function handleKeyDown(e: KeyboardEvent) {
if (e.key !== 'Tab') return
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault()
lastElement?.focus()
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault()
firstElement?.focus()
}
}
}
element.addEventListener('keydown', handleKeyDown)
if (!isAlreadyFocusedInside) {
firstElement?.focus()
}
return () => {
element.removeEventListener('keydown', handleKeyDown)
previouslyFocusedElement?.focus()
}
}, [ref, enabled])
}

View File

@@ -5,10 +5,11 @@ import '@/i18n'
import '../features/event-tracking'
import '../features/cookie-banner'
import '../features/link-helpers/slow-link'
import ReactDOM from 'react-dom'
import ReactDOM from 'react-dom/client'
import TemplateGalleryRoot from '../features/template-gallery/components/template-gallery-root'
const element = document.getElementById('template-gallery-root')
if (element) {
ReactDOM.render(<TemplateGalleryRoot />, element)
const root = ReactDOM.createRoot(element)
root.render(<TemplateGalleryRoot />)
}

View File

@@ -5,10 +5,11 @@ import '@/i18n'
import '../features/event-tracking'
import '../features/cookie-banner'
import '../features/link-helpers/slow-link'
import ReactDOM from 'react-dom'
import ReactDOM from 'react-dom/client'
import TemplateRoot from '../features/template/components/template-root'
const element = document.getElementById('template-root')
if (element) {
ReactDOM.render(<TemplateRoot />, element)
const root = ReactDOM.createRoot(element)
root.render(<TemplateRoot />)
}

View File

@@ -27,10 +27,17 @@
.gallery {
padding-top: calc($header-height + var(--spacing-10)) !important;
.gallery-search-form-control {
padding-top: 0px;
padding-bottom: 0px;
}
.gallery-header-sort-btn {
font-size: var(--font-size-02);
border: 0;
text-align: left;
color: var(--content-secondary);
color: var(--neutral-90);
background-color: transparent;
padding: 0;
font-weight: bold;
@@ -39,15 +46,14 @@
text-overflow: ellipsis;
text-decoration: none;
&:hover,
&:focus {
color: var(--content-secondary);
text-decoration: none;
&:hover {
color: var(--neutral-90);
text-decoration: underline;
}
.material-symbols {
vertical-align: bottom;
font-size: var(--font-size-06);
vertical-align: top;
font-size: var(--font-size-05);
}
}

View File

@@ -162,6 +162,7 @@
"card_must_be_authenticated_by_3dsecure": "Deine Karte muss mit 3D Secure authentifiziert werden, bevor du fortfahren kannst",
"card_payment": "Kartenzahlung",
"careers": "Karriere",
"category": "Kategorie",
"category_arrows": "Pfeile",
"category_greek": "Griechisch",
"category_misc": "Sonstiges",
@@ -1200,11 +1201,9 @@
"take_short_survey": "Nimm an einer kurzen Umfrage teil",
"template": "Vorlage",
"template_approved_by_publisher": "Diese Vorlage wurde vom Verlag genehmigt",
"template_category": "Vorlagenkategorie",
"template_description": "Vorlagenbeschreibung",
"template_gallery": "Vorlagengalerie",
"template_not_found_description": "Diese Methode zum Erstellen von Projekten aus Vorlagen wurde entfernt. Besuche unsere Vorlagengalerie, um weitere Vorlagen zu finden.",
"template_title": "Vorlagentitel",
"template_title_taken_from_project_title": "Der Vorlagentitel wird automatisch aus dem Projekttitel übernommen",
"template_top_pick_by_overleaf": "Diese Vorlage wurde von Overleaf-Mitarbeitern aufgrund ihrer hohen Qualität ausgewählt",
"template_with_this_title_exists_and_owned_by_x": "Eine Vorlage mit diesem Namen existiert bereits, und der Besitzer ist: __x__.",

View File

@@ -297,6 +297,7 @@
"card_payment": "Card payment",
"careers": "Careers",
"categories": "Categories",
"category": "Category",
"category_arrows": "Arrows",
"category_greek": "Greek",
"category_misc": "Misc",
@@ -1601,7 +1602,7 @@
"overleaf_labs": "Overleaf Labs",
"overleaf_logo": "Overleaf logo",
"overleaf_plans_and_pricing": "overleaf plans and pricing",
"overleaf_template_gallery": "overleaf template gallery",
"overleaf_template_gallery": "overleaf ce template gallery",
"overleafs_functionality_meets_my_needs": "Overleafs functionality meets my needs.",
"overview": "Overview",
"overwrite": "Overwrite",
@@ -2324,11 +2325,9 @@
"tell_the_project_owner_and_ask_them_to_upgrade": "<0>Tell the project owner</0> and ask them to upgrade their Overleaf plan if you need more compile time.",
"template": "Template",
"template_approved_by_publisher": "This template has been approved by the publisher",
"template_category": "Template Category",
"template_description": "Template Description",
"template_gallery": "Template Gallery",
"template_not_found_description": "This way of creating projects from templates has been removed. Please visit our template gallery to find more templates.",
"template_title": "Template Title",
"template_title_taken_from_project_title": "The template title will be taken automatically from the project title",
"template_top_pick_by_overleaf": "This template was hand-picked by Overleaf staff for its high quality",
"template_with_this_title_exists_and_owned_by_x": "A template with this title already exists and is owned by __x__.",

View File

@@ -72,6 +72,7 @@
"cancel_your_subscription": "Остановить подписку",
"cant_find_email": "Извините, данный адрес не зарегистрирован.",
"cant_find_page": "К сожалению, страница не найдена",
"category": "Категория",
"change": "Изменить",
"change_password": "Изменение пароля",
"change_plan": "Сменить тарифный план",
@@ -417,9 +418,7 @@
"sync_to_github": "Синхронизация с GitHub",
"syntax_validation": "Проверка кода",
"take_me_home": "Вернуться в начало",
"template_category": "Тип шаблона",
"template_description": "Описание шаблона",
"template_title": "Название шаблона",
"template_with_this_title_exists_and_owned_by_x": "Уже есть шаблон с таким названием, его владелец __x__.",
"templates": "Шаблоны",
"terminated": "Компиляция отменена",

View File

@@ -5,14 +5,19 @@ import RateLimiterMiddleware from '../../../../app/src/Features/Security/RateLim
import { RateLimiter } from '../../../../app/src/infrastructure/RateLimiter.js'
import TemplateGalleryController from './TemplateGalleryController.mjs'
const rateLimiter = new RateLimiter('create-project-from-template', {
const rateLimiterNewTemplate = new RateLimiter('create-template-from-project', {
points: 20,
duration: 60,
})
const rateLimiterThumbnails = new RateLimiter('gallery-thumbnails', {
points: 360,
const rateLimiter = new RateLimiter('template-gallery', {
points: 60,
duration: 60,
})
const rateLimiterThumbnails = new RateLimiter('template-gallery-thumbnails', {
points: 240,
duration: 60,
})
export default {
rateLimiter,
@@ -22,7 +27,7 @@ export default {
webRouter.post(
'/template/new/:Project_id',
AuthenticationController.requireLogin(),
RateLimiterMiddleware.rateLimit(rateLimiter),
RateLimiterMiddleware.rateLimit(rateLimiterNewTemplate),
TemplateGalleryController.createTemplateFromProject
)

View File

@@ -15,7 +15,7 @@ const TemplateSchema = new Schema(
mainFile: { type: String, required: true },
compiler: { type: String, required: true },
imageName: { type: String },
language: { type: String, required: true },
language: { type: String },
version: { type: Number, default: 1, required: true },
owner: { type: ObjectId, ref: 'User' },
lastUpdated: {

View File

@@ -1,8 +1,8 @@
export type Template = {
id: string
version: string
version: number
name: string
lastUpdated: string
lastUpdated: Date
author: string
authorMD: string
description: string
@@ -10,7 +10,7 @@ export type Template = {
license: string
category: string
compiler?: string
language: string
language?: string
owner: string
}