= ({
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 (
)
}
+
+export default React.memo(SettingsTemplateCategory)
diff --git a/services/web/frontend/js/features/template/components/template-details.tsx b/services/web/frontend/js/features/template/components/template-details.tsx
index d5050a0fa2..6c1a9b1059 100644
--- a/services/web/frontend/js/features/template/components/template-details.tsx
+++ b/services/web/frontend/js/features/template/components/template-details.tsx
@@ -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() {
{t('license')}:
- {template.license}
+ {licensesMap[template.license]}
{sanitizedDescription && (
diff --git a/services/web/frontend/js/features/template/hooks/use-focus-trap.ts b/services/web/frontend/js/features/template/hooks/use-focus-trap.ts
new file mode 100644
index 0000000000..fa4e78570e
--- /dev/null
+++ b/services/web/frontend/js/features/template/hooks/use-focus-trap.ts
@@ -0,0 +1,46 @@
+import { useEffect } from 'react'
+
+export function useFocusTrap(ref: React.RefObject, enabled = true) {
+ useEffect(() => {
+ if (!enabled || !ref.current) return
+
+ const element = ref.current
+ const previouslyFocusedElement = document.activeElement as HTMLElement
+ const focusableElements = element.querySelectorAll(
+ '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])
+}
diff --git a/services/web/frontend/js/pages/template-gallery.tsx b/services/web/frontend/js/pages/template-gallery.tsx
index a675f9d63c..58a63be1ad 100644
--- a/services/web/frontend/js/pages/template-gallery.tsx
+++ b/services/web/frontend/js/pages/template-gallery.tsx
@@ -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(, element)
+ const root = ReactDOM.createRoot(element)
+ root.render()
}
diff --git a/services/web/frontend/js/pages/template.tsx b/services/web/frontend/js/pages/template.tsx
index 3165f3d4c3..02d29d04da 100644
--- a/services/web/frontend/js/pages/template.tsx
+++ b/services/web/frontend/js/pages/template.tsx
@@ -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(, element)
+ const root = ReactDOM.createRoot(element)
+ root.render()
}
diff --git a/services/web/frontend/stylesheets/pages/templates-v2.scss b/services/web/frontend/stylesheets/pages/templates-v2.scss
index 823c61221d..13fede40c9 100644
--- a/services/web/frontend/stylesheets/pages/templates-v2.scss
+++ b/services/web/frontend/stylesheets/pages/templates-v2.scss
@@ -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);
}
}
diff --git a/services/web/locales/de.json b/services/web/locales/de.json
index 13cdc6847d..890ab9508d 100644
--- a/services/web/locales/de.json
+++ b/services/web/locales/de.json
@@ -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__.",
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index b9b5e40290..23bb97bf2f 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -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 owner0> 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__.",
diff --git a/services/web/locales/ru.json b/services/web/locales/ru.json
index e8a920fb53..edc5b4a77b 100644
--- a/services/web/locales/ru.json
+++ b/services/web/locales/ru.json
@@ -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": "Компиляция отменена",
diff --git a/services/web/modules/template-gallery/app/src/TemplateGalleryRouter.mjs b/services/web/modules/template-gallery/app/src/TemplateGalleryRouter.mjs
index 1f19b6a66d..b76f86d718 100644
--- a/services/web/modules/template-gallery/app/src/TemplateGalleryRouter.mjs
+++ b/services/web/modules/template-gallery/app/src/TemplateGalleryRouter.mjs
@@ -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
)
diff --git a/services/web/modules/template-gallery/app/src/models/Template.js b/services/web/modules/template-gallery/app/src/models/Template.js
index 93e424fb7f..79792f150e 100644
--- a/services/web/modules/template-gallery/app/src/models/Template.js
+++ b/services/web/modules/template-gallery/app/src/models/Template.js
@@ -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: {
diff --git a/services/web/types/template.ts b/services/web/types/template.ts
index 053a2576c8..214c388969 100644
--- a/services/web/types/template.ts
+++ b/services/web/types/template.ts
@@ -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
}