mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 09:09:36 +02:00
- Replace free-text license input with a select box - Improve visual presentation of modals and enhance keyboard interaction
This commit is contained in:
@@ -752,16 +752,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
|
||||
|
||||
@@ -99,7 +99,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)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -269,6 +269,7 @@
|
||||
"card_payment": "",
|
||||
"careers": "",
|
||||
"categories": "",
|
||||
"category": "",
|
||||
"category_arrows": "",
|
||||
"category_greek": "",
|
||||
"category_misc": "",
|
||||
@@ -1989,10 +1990,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": "",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 />)
|
||||
}
|
||||
|
||||
@@ -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 />)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -275,6 +275,7 @@
|
||||
"card_payment": "Kartenzahlung",
|
||||
"careers": "Karriere",
|
||||
"categories": "Kategorien",
|
||||
"category": "Kategorie",
|
||||
"category_arrows": "Pfeile",
|
||||
"category_greek": "Griechisch",
|
||||
"category_misc": "Sonstiges",
|
||||
@@ -1450,11 +1451,9 @@
|
||||
"take_survey": "An Umfrage teilnehmen",
|
||||
"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__.",
|
||||
|
||||
@@ -352,6 +352,7 @@
|
||||
"card_payment": "Card payment",
|
||||
"careers": "Careers",
|
||||
"categories": "Categories",
|
||||
"category": "Category",
|
||||
"category_arrows": "Arrows",
|
||||
"category_greek": "Greek",
|
||||
"category_misc": "Misc",
|
||||
@@ -1789,7 +1790,7 @@
|
||||
"overleaf_logo": "Overleaf logo",
|
||||
"overleaf_multi_license_plans": "Overleaf multi-license plans",
|
||||
"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": "Overleaf’s functionality meets my needs.",
|
||||
"overview": "Overview",
|
||||
"overwrite": "Overwrite",
|
||||
@@ -2572,11 +2573,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__.",
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"cancel_your_subscription": "Остановить подписку",
|
||||
"cant_find_email": "Извините, данный адрес не зарегистрирован.",
|
||||
"cant_find_page": "К сожалению, страница не найдена",
|
||||
"category": "Категория",
|
||||
"change": "Изменить",
|
||||
"change_password": "Изменение пароля",
|
||||
"change_plan": "Сменить тарифный план",
|
||||
@@ -405,9 +406,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": "Компиляция отменена",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user