124 Commits

Author SHA1 Message Date
claude 60d48ae532 docs: release alpha 3 — remove in-progress marker, fix export description
Build and Deploy Verso / deploy (push) Has been cancelled
Build and Deploy Verso (prod) / deploy (push) Successful in 1m19s
Mark Alpha 3 as released (drop the "in progress" qualifier) and correct
the bidirectional format export entry: only LaTeX ↔ Typst conversion is
available; DOCX, Markdown and HTML exports are not yet implemented.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:32:41 +00:00
claude a796577199 fix(security): restrict publish-presentation routes to project owners
Build and Deploy Verso / deploy (push) Successful in 10m54s
Read-only collaborators and token-link users could publish, unpublish,
and rotate presentation share tokens. Change all three write endpoints
from ensureUserCanReadProject to ensureUserCanAdminProject so only the
project owner can perform these actions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 10:17:24 +00:00
claude 1814cba458 fix(mobile): extend CSS breakpoints to cover Tor Browser spoofed viewport
Build and Deploy Verso / deploy (push) Successful in 10m11s
Tor Browser sets viewport width to ~980px to resist fingerprinting, so
@media (max-width: 767px) never fires on a real phone using it. Add the
secondary condition (pointer: coarse) and (max-width: 1024px) — same
dual-check already used in JS — to all mobile CSS overrides in
ide-lumiere.scss and project-list-lumiere.scss. This activates the font
scaling, toolbar height increase, auto-zoom prevention, and project page
layout fixes on Tor Browser.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 08:24:29 +00:00
claude 4c5db24963 fix(mobile): prevent editor auto-zoom on iOS/Android
Build and Deploy Verso / deploy (push) Successful in 10m28s
Browsers zoom any focused editable element (including contenteditable)
whose font-size is below 16px. Apply font-size: max(1rem, 1em) on
.cm-content inside the mobile media query so the 16px floor is enforced
while still respecting user-set sizes larger than that.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 07:01:20 +00:00
claude 2d44c920fd fix(mobile): scale up editor UI chrome fonts and toolbar on mobile
Build and Deploy Verso / deploy (push) Successful in 12m33s
Adds a @media (max-width: 767px) block scoped to .ide-redesign-main
that bumps the CSS font-size tokens one step each and increases the
toolbar height, making buttons, labels, and panel headers readable
on a phone without touching the CodeMirror editor font size (which
is controlled by user settings independently).

Also reverts the unintended rail auto-collapse from the previous
commit — collapsing the sidebar was not the requested change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 22:23:23 +00:00
claude 7bc60a4e10 feat(mobile): collapse rail by default; tighten project page header layout
Build and Deploy Verso / deploy (push) Has been cancelled
Editor:
- Auto-collapse the file-tree rail on mobile so the editor+PDF panels
  occupy the full screen width instead of sharing it with a 15% sidebar.
  The user can still open the rail from the toolbar; the collapse only
  fires on first load or when switching to a mobile viewport.

Project page (Lumiere):
- Move the XS/M/L zoom buttons from the mobile filter-pill toolbar into
  a row with the New Project button, so neither is stranded alone.
- The search bar gets its own full-width row above the actions row.
- Desktop layout is unchanged (search + new-project stay side-by-side;
  zoom stays in the title row). `display:contents` makes the wrapper
  div transparent to the desktop flex container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 22:18:28 +00:00
claude 51d0314c1c fix(mobile): detect touch devices with spoofed viewport width
Build and Deploy Verso / deploy (push) Successful in 10m17s
Tor Browser and Firefox with privacy.resistFingerprinting report a
desktop-sized viewport (~980px) even on a real Android phone. This made
window.matchMedia('(max-width: 767px)') return false, so isMobile was
always false on Tor, leaving the editor in side-by-side (horizontal)
layout instead of the expected vertical stack.

Fix: add a secondary check using `(pointer: coarse) and (max-width:
1024px)`. Touch hardware is not spoofed by fingerprinting resistance,
so this reliably catches phones and tablets regardless of the reported
viewport width. Applied to both getInitialLayout() and the live
isMobile state in main-layout.tsx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 14:31:56 +00:00
claude 8fc71d677c Fix thumbnail quality and mobile vertical split layout
Build and Deploy Verso / deploy (push) Successful in 10m23s
Thumbnails: update the actual thumbnail endpoint (ConversionController.js
thumbnailFromBuild) to quality=90 and width=794. The previous fix targeted
ConversionManager.js which handles preview mode, not the thumbnail route
called by ThumbnailManager.mjs.

Mobile layout: move the isMobile guard before the stored-preference check
in getInitialLayout(). The autoSave race fix (build 274) stopped future
bad writes, but a stale 'flat' in localStorage was still being read on
every load, blocking the mobile check. Mobile now always starts in
verticalSplit regardless of any stored value.

CI: add node --check on all server-side .mjs files in the Dockerfile,
after source copy and before webpack compile, so syntax errors like the
escaped-backtick incident fail the build immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 13:48:44 +00:00
claude 8a821bc91d Fix escaped backtick syntax error in EmailBuilder.mjs
Build and Deploy Verso / deploy (push) Successful in 12m49s
sed substitution literal-escaped the backtick and \${ in the
groupSSOReauthenticate subject line, producing invalid JS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 13:13:05 +00:00
claude 33fa5986c8 Email rebranding, mobile filter alignment fix, minor UI cleanup
Build and Deploy Verso / deploy (push) Has been cancelled
Email:
- Replace Overleaf green palette with Verso teal (#2a9d8f) for CTA
  buttons, links, and logo text
- Rename force-overleaf-style CSS class to force-verso-style in all
  email body templates and layout
- Replace all user-visible "Overleaf" strings with settings.appName
  across email subjects, bodies, and sign-offs (EmailBuilder.mjs)
- Remove Overleaf CEO signature from onboarding email; substitute
  "The Verso Team"
- Fix utm_source=overleaf → utm_source=verso in onboarding links
- Point git token docs URL to local siteUrl instead of docs.overleaf.com
- Fix hard-coded Overleaf green (#0F7A06) in SSO disabled email link

Mobile filter chips:
- Switch from overflow-x:auto (still leaks through ancestor flex chain)
  to flex-wrap:wrap with flex:1 1 auto on each chip so all chips in a
  row grow to equal width — no overflow, clean alignment
- Toolbar becomes flex-column: chips on top, zoom below-right

Misc:
- Remove import_typst_file option from new-project menu
- Add interface_language to extracted-translations.json whitelist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 12:56:34 +00:00
claude f629f6a50c Mobile polish: max thumbnail quality, tab-bar filter chips, fix vertical split default
Build and Deploy Verso / deploy (push) Successful in 10m12s
- CLSI thumbnails: bump to 794px/q90 (matches preview quality) for
  crisp display on 2x/3x phone screens
- Lumière filter chips → underline tab bar: single scrollable row with
  teal active indicator, no more wrapping alignment issues; zoom buttons
  separated by a vertical divider on the right
- Fix editor vertical split default on mobile: disable react-resizable-panels
  autoSaveId on mobile to prevent a stale collapsed PDF pane from firing
  onCollapse → changeLayout('flat') and overriding the verticalSplit
  default set by getInitialLayout()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 12:19:10 +00:00
claude c79ac23a15 Improve thumbnail quality and fix mobile editor default layout
Build and Deploy Verso / deploy (push) Successful in 10m18s
Thumbnails: increase CLSI thumbnail from 190px/q50 to 400px/q80.
At 190px/50% JPEG quality, images are noticeably blurry on 2x phone
screens (source needs 380px device pixels but source is only 190px).

Editor mobile layout: getInitialLayout() was returning sideBySide for
any stored 'split' preference (set from a desktop session), even on
mobile. sideBySide on mobile renders vertically via the isMobile check
in main-layout, but the stated default was still wrong. Now on mobile,
any stored value other than 'flat' maps to verticalSplit so the
top-bottom split is always the default; flat is preserved so a user
who explicitly chose editor-only keeps that preference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 11:44:34 +00:00
claude 5aab716245 lumiere mobile: wrap filter pills instead of horizontal scroll
Build and Deploy Verso / deploy (push) Successful in 12m8s
overflow-x: auto only clips content when every ancestor has a definite
width; that constraint wasn't met so the pills were still expanding the
page. Switch to flex-wrap: wrap so pills flow onto multiple rows and
never cause horizontal overflow. Also allow the toolbar itself to wrap
so the zoom control can drop to a new row if needed.

Remove redundant sidebar hide rule — SidebarDsNav already has d-none
d-md-flex so it was never contributing to the overflow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 11:07:16 +00:00
claude 90df7f5cc2 lumiere mobile: hide sidebar to fix 2x page width
Build and Deploy Verso / deploy (push) Successful in 11m15s
The project-list-wrapper flex row includes the sidebar (min-width:200px)
alongside the content area. On a 375px phone this totals ~575px, causing
the 2x horizontal overflow. Hide the sidebar on mobile — the filter pills
in the mobile toolbar already cover all its navigation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 10:50:37 +00:00
claude d12a39329b lumiere mobile: fix overflow, style ⋮ button, show tile actions on touch
Build and Deploy Verso / deploy (push) Successful in 16m58s
1. Page width overflow: stack lumiere-header as column on mobile so
   lumiere-header-actions (flex-shrink:0) no longer forces the container
   wider than the viewport. Search form and new-project button each take
   full width on their own line.

2. ⋮ button in compact row: override dropdown-table-button-toggle inside
   lumiere-compact-actions with Lumière-palette colours and tighter
   padding, replacing the table-context styling.

3. Tile view action strip: add @media (hover: none) rule so
   lumiere-card-actions is always opacity:1 on touch devices (phones have
   no cursor, so the hover-reveal pattern is unusable).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 10:00:50 +00:00
claude 6f419477df lumiere: fix mobile overflow + restore tile view with XS/M/L zoom
Build and Deploy Verso / deploy (push) Successful in 1m42s
Overflow fix: ActionsCell (up to 9 icon buttons) was assigned to the
`auto` column in the compact row, blowing out the row to ~300px.
Replace with ActionsDropdown (single ⋮ button) on mobile via
d-md-none / d-none d-md-flex, same pattern as the classic table.

Tile view: remove the isMobile force to compact (effectiveScale=0).
Add a mobile-only toolbar row (d-flex d-md-none) combining the filter
pills and a new XS/M/L zoom control:
  - XS (scale=0)  → compact rows
  - M  (scale=1)  → 2 tiles per row  (CSS: repeat(2, 1fr))
  - L  (scale≥1.35) → 1 tile per row (CSS: 1fr, class --mobile-1col)

The --lum-card-scale inline var is suppressed on mobile so CSS media
query controls thumbnail height (0.85 for 2-col, 1.3 for 1-col).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 08:54:30 +00:00
claude a004f21688 Fix mobile ergonomics on Lumière project list page
Build and Deploy Verso / deploy (push) Successful in 14m58s
Compact row (xs): wrap format/owner/date in a lumiere-compact-meta div
that uses display:contents on desktop (preserving the 6-column grid) and
switches to a 2-row grid layout on mobile — row 1: checkbox/name/actions,
row 2: format·owner·date. Actions are always visible on touch devices.

Filter access: add a horizontal scrollable filter-pill bar (d-md-none)
to the Lumière header so users can switch between All/Yours/Shared/Archived/
Trashed on phone without needing the desktop sidebar.

Also: hide zoom control on mobile (d-md-none), auto-force compact view on
mobile via useIsMobile hook, fix search form min-width overflow on xs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 08:22:27 +00:00
claude 065534819c Fix file tree refresh after convert and compiler sync on set-as-main
Build and Deploy Verso / deploy (push) Successful in 14m4s
- Convert: backend now returns parentFolderId+isNew; frontend calls
  dispatchCreateDoc directly so the new file appears without a page refresh
- Set as main: infer compiler from file extension and POST both rootDocId
  and compiler together; updateProject propagates the change to the
  editor settings dropdown and project list immediately

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 07:50:02 +00:00
claude b0b389dc4c feat: set-as-main for .typ/.qmd; fix compiler filter; fix ZIP import compiler
Build and Deploy Verso / deploy (push) Successful in 14m56s
Set as main document (context menu):
- canSetRootDocId used selectedEntityIds so .typ/.qmd files couldn't be
  set as main via right-click on an unselected file.
  Now computed locally from contextMenuEntityId (same pattern as convert)
  using isValidTeXFile which already covers .typ, .qmd, .tex etc.

Compiler filter (editor settings):
- docs?.find(id) could return undefined due to ID format mismatch,
  causing all engines to show as available for non-LaTeX projects.
  Added findInTree fallback so the root doc name is always resolved.

ZIP import compiler:
- Projects created from ZIP always got defaultLatexCompiler ('quarto')
  regardless of content.
- findRootDocFileFromDirectory now also searches for .typ and .qmd root
  files after finding no .tex file.
- ProjectUploadManager now infers the compiler from the root doc
  extension and sets it on the project after import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 21:56:39 +00:00
claude bc131f6440 fix: convert items show for unselected files; add success toast
Build and Deploy Verso / deploy (push) Successful in 15m23s
canRename required selectedEntityIds.size === 1, so right-clicking an
unselected file hid the convert items even though contextMenuEntityId
was correctly set. Replace canRename with !fileTreeReadOnly for the
convert-specific gate, which is the actual write-access check needed.

Also add showExportDocumentSuccess so the user sees the warning toast
on successful conversion instead of silent nothing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 21:18:24 +00:00
claude ff7de70a61 fix: per-file convert — DuplicateNameError + first-click no-op
Build and Deploy Verso / deploy (push) Successful in 21m44s
Two bugs:
1. Converting when output already exists threw DuplicateNameError (400).
   Now overwrites existing doc via setDocument instead of failing.

2. Right-clicking an unselected file left contextMenuEntityId null,
   so the first click on Convert silently did nothing. Added
   contextMenuEntityId to FileTreeMainContext, set it on right-click
   and on the … button click; FileTreeItemMenuItems now uses it for
   the convert hooks rather than relying on selectedEntityIds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 20:35:50 +00:00
claude dbb7899ca3 docs: clean up Alpha 3 section, add Known Issues
Build and Deploy Verso / deploy (push) Successful in 15m23s
Remove RevealJS thumbnails (redundant with Features section) and Upload
reliability (still unresolved). Add Known Issues section documenting
the large file upload timeout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 19:58:52 +00:00
claude 7eeabc93aa fix: send empty JSON body on convert to avoid body-parser 400
postJSON without options sends Content-Type: application/json with no
body; body-parser's BodyParserWrapper intercepts the resulting
SyntaxError and returns 400 before the route handler runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 19:58:48 +00:00
claude ac2315bc8e feat: per-file Convert in explorer menu + fix export success toast
Build and Deploy Verso / deploy (push) Successful in 9m50s
- Add "Convert to Typst (.typ)" in the file tree context menu for .tex
  docs, and "Convert to LaTeX (.tex)" for .typ docs. Clicking runs pandoc
  on the file content and creates the converted file in the same folder.
- New backend endpoint POST /project/:id/doc/:id/convert/:type that reads
  the doc from document-updater, runs pandoc directly, and creates the
  result via ProjectEntityUpdateHandler (file tree updates via socket).
- Rewrite the export success toast for typst and latex conversions: no
  more link to /contact, replaced with a plain warning that errors are
  expected (pandoc does not support all constructs).
- Add i18n keys: convert_to_typst, convert_to_latex,
  typst_export_feedback_message, latex_export_feedback_message (EN + FR)
  and all four to extracted-translations.json.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 15:18:31 +00:00
claude de22dcf87f docs: add mobile layout to Alpha 3 release notes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 14:58:28 +00:00
claude e82f5fbead docs: update Alpha 3 release notes and fix Overleaf description
Add top/bottom split view and bidirectional format export (LaTeX↔Typst,
LaTeX→DOCX/Markdown/HTML) to the Alpha 3 feature list. Fix the security
section which incorrectly referred to Overleaf as a LaTeX/Typst editor —
Overleaf only supports LaTeX.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 14:56:45 +00:00
claude d64365aa56 Add missing i18n keys to extracted-translations.json
Build and Deploy Verso / deploy (push) Successful in 14m28s
top_bottom_split_view, export_as_typst, and export_as_latex were absent
from the whitelist, so translations-loader.js stripped them from the
locale bundles at build time — causing raw keys to show in the UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 14:46:31 +00:00
claude 2d2a85f06f Fix typst export 500, add export-as-latex for typst projects
Build and Deploy Verso / deploy (push) Successful in 10m4s
- clsi-nginx: allow hyphens in project-id regex — conversion IDs are UUIDs
  which nginx was rejecting, causing 500 on file download after conversion
- CLSI ConversionController/Manager: add 'latex' export type (typst→latex via pandoc)
- Web: add 'latex' to SUPPORTED_CONVERSION_TYPES
- Frontend: add Export as LaTeX button (visible only for typst projects)
- Fix visibility logic: export-as-latex shows for typst, export-as-typst shows for latex
- Add export_as_latex translation key (en + fr)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 11:55:41 +00:00
claude b545d08939 Fix four reported bugs: typst export 500, export shown for all project types, Lumiere tile button layout, i18n
Build and Deploy Verso / deploy (push) Successful in 15m7s
- ConversionController.js: add typst to CONVERSION_CONFIGS (missing entry caused 400→500 chain)
- export-project-with-conversion-button: hide button for non-LaTeX projects (typst/quarto) via compiler check
- project-list-lumiere.tsx + scss: revert lumiere-card-actions back inside .lumiere-card (put them back like they were)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 11:12:50 +00:00
claude 9d11683920 feat: Typst → LaTeX import, and fix export button visibility
Build and Deploy Verso / deploy (push) Successful in 1m14s
Typst → LaTeX import:
- CLSI ConversionManager: add 'typst' to CONVERSION_CONFIGS
  (pandoc input.typ --from typst --to latex --standalone → zip archive)
- Web controller: allow 'typst' as a valid importDocument conversion type
- Frontend modal: add .typ file config to ImportDocumentModal
- New project button modal: add 'import_typst' variant + switch case
- New project button: show "Import Typst file" when enablePandocConversions
  is true (no split test gate — Verso has no SaaS split test infra)
- Locales: add choose_typst_file and import_typst_file keys (18 locales)

Export button fix:
- Remove featureFlag="export-typst" from ExportProjectWithConversionButton
  so the button shows whenever enablePandocConversions is true, without
  needing an unconfigured split test to return 'enabled'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 08:54:54 +00:00
claude 15ffaefb87 fix: install pandoc and enable pandoc conversions
Build and Deploy Verso / deploy (push) Failing after 1h2m54s
Pandoc was not installed in the base image, so the export buttons
(docx, markdown, html, typst) were hidden because ENABLE_PANDOC_CONVERSIONS
defaulted to false.

- Dockerfile-base: add pandoc via apt (Ubuntu Noble ships 3.1.3, which
  supports --to typst added in pandoc 3.0)
- env.sh: set ENABLE_PANDOC_CONVERSIONS=true so both the web and CLSI
  services expose and serve the export endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 08:46:09 +00:00
claude 32aec14c41 feat: export LaTeX project as Typst (.typ) via pandoc
Build and Deploy Verso / deploy (push) Successful in 9m58s
Adds a new "Export as Typst" option in the project title dropdown and
File menu, mirroring the existing docx/markdown/html export pipeline.

Changes:
- CLSI ConversionManager: add 'typst' to LATEX_EXPORT_CONFIGS
  (compressOutput: false, pandoc --from latex --to typst)
- Web controller: register 'typst' → 'typ' in SUPPORTED_CONVERSION_TYPES
- Frontend: extend conversionType union and add ExportProjectWithConversionButton
- File menu: add 'export-as-typst' to the download group command structure
- Locales: add export_as_typst key to all 18 locale files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 08:24:54 +00:00
claude f7d0369213 fix: translate hardcoded 'Editor settings' header in View menu
Build and Deploy Verso / deploy (push) Successful in 9m45s
The DropdownHeader in the View > Layout menu was using a raw English
string instead of t('editor_settings'). Added the key to all 18 locale
files with appropriate translations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 08:04:56 +00:00
claude 57e34aa104 fix: dropdown positioned at top-left on first click
Build and Deploy Verso / deploy (push) Successful in 15m17s
Popper lazy-initializes on first open, causing it to place the menu at
[0,0] before it has computed the toggle's position. renderOnMount forces
Popper to initialize while the component is first mounted, so the
position is ready before the user's first click.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 22:05:01 +00:00
claude 48d5e15f7f fix: Lumiere card dropdown clipped by card's backdrop-filter and transform
Build and Deploy Verso / deploy (push) Successful in 14m35s
.lumiere-card has both backdrop-filter and a hover transform, both of
which create a new containing block for position:fixed descendants,
trapping Popper dropdowns inside the card's overflow:hidden bounds.

Fix: move .lumiere-card-actions outside .lumiere-card (sibling inside
.lumiere-card-wrapper, which has position:relative but no filter or
transform). The actions strip is now absolutely positioned at the card
bottom with its own solid background and bottom border-radius.
No backdrop-filter on the strip to avoid re-introducing the same trap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 21:35:52 +00:00
claude b78845a926 fix: show presentation export dropdown on Lumiere tiles for Quarto projects
Build and Deploy Verso / deploy (push) Successful in 14m25s
ProjectCard in the Lumiere tile view always showed CompileAndDownloadProjectPDFButtonTooltip,
ignoring the project compiler. Quarto presentation projects need the
DownloadPresentationButtonTooltip (HTML/PDF dropdown) instead, matching
the logic already in actions-cell.tsx and actions-dropdown.tsx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 21:08:43 +00:00
claude c10a13f605 fix: PC layout regression and silent PDF download failure
Build and Deploy Verso / deploy (push) Successful in 13m21s
layout-context: getInitialLayout() was returning verticalSplit for
any stored 'vertical' preference, including on desktop. Now checks
isMobile first so stored mobile preference doesn't bleed into PC.

compile-and-download-pdf: when compile succeeds but output.pdf is
absent from outputFiles, the code crashed silently at outputFile.build
leaving the user with no feedback. Now shows the error modal instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 20:42:22 +00:00
claude 762d3e75cf fix: footer translation, mobile search bar and table row layout
Build and Deploy Verso / deploy (push) Successful in 13m32s
- Footer: use translate('built_on') key instead of hardcoded 'Built on';
  update fr.json 'built_on' → 'Basé sur Overleaf'
- Mobile project list: move search bar + button outside the bordered
  TableContainer so its width is viewport-constrained, not affected by
  the table's fixed layout
- Mobile table rows: use width:100% (not auto) on cells so they fill the
  full tr width regardless of the higher-specificity column percentage
  rules; add explicit width:100% on tr to anchor flex-column sizing;
  keep width:auto on absolutely-positioned actions cell

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 15:31:09 +00:00
claude db5b2b1f82 fix: show presentation download dropdown for all Quarto projects
quartoFlavor is set only after a project is compiled with the current
build. Existing Quarto projects that haven't been recompiled have
quartoFlavor=undefined, so the old check (quartoFlavor === 'revealjs')
fell through to the regular PDF compile button with no dropdown.

Drop the quartoFlavor guard — compiler === 'quarto' is sufficient since
all Quarto projects in Verso are RevealJS presentations. Changes applied
to ActionsCell (desktop icon), DownloadPresentationButtonTooltip guard,
and ActionsDropdown (mobile three-dot menu).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:53:33 +00:00
claude 685a7ffca1 fix: language picker links, dropdown position, and editor layout defaults
Build and Deploy Verso / deploy (push) Successful in 14m34s
Language picker:
- Add fallback href in Pug so language links navigate even if JS fails
- Anchor dropdown to right edge (right:0) so it stays on-screen when
  the picker is near the right side of the footer on mobile

Editor layout:
- Read stored pdfLayout from localStorage on init so the last-used
  layout is remembered across sessions
- Default to verticalSplit (top/bottom) on mobile when no preference
  is stored, so the editor opens in a sensible layout on phones

Translations:
- Add top_bottom_split_view key to all 16 locales that were missing it

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:40:02 +00:00
claude 84d1efc271 fix: use <details>/<summary> for Pug language picker to get native toggle
Build and Deploy Verso / deploy (push) Successful in 14m10s
The manual JS click-handler approach (tried with stopPropagation,
containment check, and mousedown variants) never worked reliably on
login/password/settings pages. The browser's native <details>/<summary>
toggle behaviour requires no JavaScript and is immune to Bootstrap JS or
React event delegation interference.

The inline script now only builds the return_to hrefs and handles
outside-click-to-close (setting details.open=false). The CSS gains a
.language-picker-details rule that sets position:relative so the
absolutely-positioned dropdown-menu is positioned correctly, and
details[open] .dropdown-menu { display: block } to show the menu.

The #language-picker-toggle id remains on the <summary> so the existing
CSS (cursor, text-decoration, material-symbols alignment) continues to
apply. The React LanguagePicker is unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 12:55:39 +00:00
claude b461343b23 fix: project list phone layout and language picker
Build and Deploy Verso / deploy (push) Successful in 14m3s
- Search bar overflow: min-width:0 on form prevents input min-content
  from overflowing flex container on narrow screens
- Remove duplicate New Project button from header row (tableTopArea
  already provides it for mobile)
- Simplify tableTopArea: single button+search layout regardless of
  isLibraryEnabled flag
- 2-line project rows on mobile: merge dash-cell-date-owner +
  dash-cell-tag into a single dash-cell-meta so date/owner/tags flow
  inline on line 2 below the project name
- Footer mobile: hide language-picker-text on mobile (icon only) to
  prevent 3rd line in 2-row footer
- Language picker: use mousedown instead of click for outside-close
  handler — click bubbling is unreliable on iOS for non-interactive
  elements; mousedown fires for all taps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 11:47:32 +00:00
claude be353d53bd Center footer items on mobile for harmonious two-line layout
Build and Deploy Verso / deploy (push) Successful in 9m44s
Both ul.site-footer-items rows are now justify-content: center on small
screens, so the copyright/language row and the licence/source row are
symmetrically aligned instead of left-justified.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 11:05:22 +00:00
claude 0a5bd4e47d Fix mobile layout key, language picker close handler, and presentation download
Build and Deploy Verso / deploy (push) Has been cancelled
- Mobile vertical layout: add key= based on direction so react-resizable-panels
  remounts cleanly when switching between horizontal and vertical; also use
  isVertical (not just pdfLayout) for the autoSaveId to avoid restoring a
  mismatched layout from localStorage
- Language picker: replace stopPropagation pattern with a containment check on
  the document click handler — more robust on React pages where Bootstrap JS or
  React's event delegation can interfere with stopPropagation
- Presentation download dropdown: use popperConfig strategy:'fixed' so the menu
  escapes overflow:hidden table cells; remove forced drop='up' and let Popper
  choose; defer URL.revokeObjectURL by 10 s to give the browser time to start
  the download before the blob URL is released

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 11:01:15 +00:00
claude 11227d59e3 Editor mobile ergonomics: vertical split layout on phones and as desktop option
Build and Deploy Verso / deploy (push) Successful in 14m3s
On mobile (< 768 px) the existing side-by-side layout automatically switches
to a vertical stack (editor on top, PDF/presentation on bottom) without
changing the stored layout preference.

A new "Top / bottom split" option is added to the layout menu so desktop
users can choose the same vertical split explicitly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 09:25:15 +00:00
claude 0f585ea5bb Fix: use picture_as_pdf icon for presentation download toggle
Using the generic 'download' icon duplicated the ZIP button icon,
giving two identical icons side by side. Switch to picture_as_pdf to
match the previous compile-and-download button's appearance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 08:51:29 +00:00
claude a6ea291ea8 Add PDF/HTML download dropdown for Quarto Slides in project list
Quarto Slides (RevealJS) projects compile to output.html, not output.pdf,
so the existing "Download PDF" button was meaningless for them. Replace it
with a two-option dropdown matching the editor's PdfHybridDownloadButton:

- Desktop (ActionsCell): icon button opens a dropup with
  "Download standalone HTML" and "Download PDF slides"
- Mobile (ActionsDropdown): two separate dropdown items with the same
  choices and per-format spinner while the export is in progress

Both use the same /project/:id/presentation-export/:format endpoint and
show a loading modal (with error reporting) during the server-side render,
exactly as the editor toolbar does.

Non-RevealJS projects continue to show the compile-and-download-PDF button
unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 08:48:03 +00:00
claude 703f4d6ee2 Replace native <select> language picker with styled dropdown
Build and Deploy Verso / deploy (push) Successful in 13m43s
The native <select> looked like a form control ("cheap"). Replace it with
the same HTML+CSS pattern as the React LanguagePicker: btn-inline-link
toggle with a translate icon, Bootstrap dropdown-menu for the list, active
item highlighted in green.

A tiny self-contained inline script handles the toggle since Bootstrap JS
is not loaded on React-layout pages. It builds the return_to URL from
window.location.pathname at click time (same as the old select approach).

Added top: auto / bottom: 100% to .language-picker .dropdown-menu so the
list always opens upward regardless of whether Popper.js is present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 08:29:10 +00:00
claude 86a902c197 Improve mobile ergonomics for footer and project list
Footer (both React and Pug):
- Below md: switch to flex-wrap so items wrap naturally instead of
  overflowing; reset line-height from the fixed 49px to normal; add
  row-gap so wrapped rows aren't crammed together
- Add footer-sep class to pipe separator <li>s so they can be hidden
  on small screens (wrapping mid-separator looks wrong)
- Change col-lg-3 text-end → text-lg-end so the right column (licence,
  source code) aligns left when it stacks full-width below lg

Project list:
- Show NewProjectButton on mobile in the header row (the sidebar that
  holds the button is already hidden below md via d-none d-md-flex,
  leaving users with no way to create projects on their phone)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 08:22:57 +00:00
claude 36f5e9fb06 Remove rounded top-left corner from project list content area
The content panel had border-top-left-radius: var(--border-radius-large)
at md+ breakpoint, left over from the old Overleaf theme. Everything else
is square now so this corner stood out. Removing it makes the edge flush.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 07:58:33 +00:00
claude 2b9ebe5522 Replace Overleaf O with Verso icon mark in editor state favicons
Build and Deploy Verso / deploy (push) Successful in 10m0s
favicon-compiled.svg, favicon-compiling.svg, favicon-error.svg all used
the Overleaf O logo as the base. Replace with the Verso icon mark (dark
background, four colored circles, white V polygon, clipped to a circle)
while keeping the existing status badge icons (✓ / ↻ / ✗) unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 07:54:33 +00:00
claude e4f1eead25 Branding polish: Verso favicons, OG image, and meta tag fixes
Build and Deploy Verso / deploy (push) Successful in 10m24s
Replace Overleaf favicons with Verso icon mark across all sizes (16x16,
32x32, 180x180 apple-touch, 192x192/512x512 android-chrome, ICO). Add
OG social preview image (1200x630) for Discord/Twitter link cards.

- New favicon.svg: Verso icon mark with four overlapping circles and V
- mask-favicon.svg: monochrome V polygon (was Overleaf chevron)
- og-image.png: 1200x630 social card with icon mark and "Verso" wordmark
- web.sitemanifest.json: rename "Overleaf" → "Verso", theme_color updated
- _metadata.pug: add og:url tag, fix og:type missing content= attribute,
  point CE default OG/twitter images to og-image.png instead of apple-touch-icon.png

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 20:19:01 +00:00
claude 323d74cc66 fix: replace Pug language picker dropdown with native select
Build and Deploy Verso / deploy (push) Successful in 14m31s
The old dropdown relied on data-bs-toggle and AngularJS directives,
neither of which are loaded on React-layout pages (layout-react.pug
intentionally excludes Bootstrap JS). The toggle button was inert on
pages like /user/settings.

Replace with a plain <select> that navigates via window.location.href
onchange — works without any framework on all page types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 18:46:03 +00:00
claude 81c1fc92d1 fix: remove data-bs-toggle from LanguagePicker dropdown toggle
Build and Deploy Verso / deploy (push) Successful in 10m8s
Bootstrap vanilla JS and react-bootstrap were both handling the dropdown,
causing the toggle to be unresponsive. react-bootstrap manages its own
state and does not need this attribute.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:15:36 +00:00
claude 319ccc32ee feat: add language picker to logged-in footer and editor settings
Build and Deploy Verso / deploy (push) Successful in 18m46s
- Rewrites LanguagePicker to use availableLanguages from ol-footer meta
  instead of subdomainLang (which is always empty in single-domain setup)
- Passes availableLanguages through layout-react.pug → ol-footer meta so
  React footer picks it up
- Adds InterfaceLanguageSetting component to the editor settings modal
  ("Spelling and language" tab) for use when no footer is present
- Adds interface_language key to all five locale files (en/fr/de/es/it)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 12:22:55 +00:00
claude 1a0197812d feat: cookie+DB language preference, eliminating subdomain requirement
Build and Deploy Verso / deploy (push) Successful in 14m53s
Users can now select their UI language directly without relying on
subdomain routing (fr.verso.alocoq.fr etc.).

Resolution order: (1) verso-lang cookie, (2) subdomain host header,
(3) OVERLEAF_SITE_LANGUAGE default — fully backward compatible.

Changes:
- Translations.mjs: read verso-lang cookie in middleware; include all
  bundled locale files in availableLanguageCodes regardless of subdomain
  config so every loaded locale appears in the picker
- User.mjs: add languageCode field to persist preference per user
- UserController.mjs: setLanguage handler — sets cookie (1 year) and
  writes languageCode to DB when called by a logged-in user
- AuthenticationController.mjs: on login, sync DB languageCode to cookie
  so preference follows the user to any new browser/device after login
- ExpressLocals.mjs: expose availableLanguages to all Pug templates
- router.mjs: GET /set-language?lng=<code> (anonymous + logged-in),
  POST /user/language (logged-in, REST-style)
- language-picker.pug: replace subdomain href links with /set-language
  redirect links; iterate availableLanguages instead of subdomainLang
- thin-footer.pug: show picker whenever availableLanguages.length > 1,
  not only when multiple subdomains are configured

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 11:44:56 +00:00
claude 589c60a325 i18n(de/es/it): correct machine-translation errors in German, Spanish, Italian
Build and Deploy Verso / deploy (push) Successful in 14m22s
German (43 fixes): table→Tabelle (was Tisch/furniture), figure→Abbildung (was Figur),
no_borders→Keine Rahmen (was Grenzen/national borders), run→Ausführen (was Lauf),
right→Rechts (was Richtig/correct), unpausing was "Ununterbrochen" (uninterrupted),
unlink_provider said "verknüpfen" (link) instead of "trennen" (unlink),
unpause_subscription said "pausieren" (pause) instead of "fortsetzen",
zotero_reference_loading_error mentioned Mendeley, light_themes→Helle Themen,
review_panel→Überprüfungsbereich (was Gremium/committee), x_price_per_month untranslated,
Writefull/Papers/booktabs/Labs product names preserved

Spanish (58 fixes): no_borders→Sin bordes (was fronteras/national borders),
invite_resent→Invitación reenviada (was "Invita a resentirse"),
trash→Papelera, trashed→En papelera (was "Destrozado"/destroyed),
plan→Plan (was Planificar/verb), premium→Premium (was prima/bonus),
disabled→Desactivado (was Discapacitado/handicapped), breadcrumbs navigation fixed,
cookie_banner→Banner de cookies, search_match_case fixed, Writefull/Papers/Dropbox/Git preserved

Italian (85 fixes): no_borders→Nessun bordo (was confini/national borders),
right→Destra (was Giusto/correct), run→Esegui (was Corri/run physically),
standard→Standard (was Norma), premium→Premium (was Premio/prize),
characters→Caratteri (was Personaggi), editor→Editor (was Editore/publisher) throughout,
light_themes→Temi chiari (was leggeri/lightweight), unpausing→Riattivazione,
back_to_x buttons fixed from "Torniamo" (we return) to imperative "Torna",
~20 infinitives corrected to imperatives for button labels,
booktabs/Writefull/Papers product names preserved, triple-r typo fixed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 11:30:04 +00:00
claude 38144b1033 i18n(fr): correct 41 machine-translation errors in French locale
Fix a batch of severe mistranslations produced by automated translation:
- typographic terms: bold→Gras (was "Audacieux"), figure→Figure (was "Chiffre"),
  characters→Caractères (was "Personnages"), borders→bordures (was "frontières")
- UI action labels: apply→Appliquer (was "Postuler"/job application),
  run→Exécuter (was "Courir"/to run physically), chat→Chat (was "Discuter"/verb),
  code→Code (was "Coder"/verb), more_actions→Plus d'actions (was "propositions")
- subscription plan terms: plan→Forfait (was "Planifier"/verb),
  premium→Premium (was "Prime"), standard→Standard (was "Norme")
- role/position: role→Rôle, position→Poste (both were "Grade"/military rank)
- light mode: mode lumière→mode clair throughout
- upload direction: Télécharger→Téléverser (download vs upload)
- invite_resent: catastrophic mis-parse of "resent" as resentment
- search_match_case: "Étui de correspondance" (phone case)→"Respecter la casse"
- product names preserved: booktabs, Writefull, GPT (were translated literally)
- typo: "toruvé"→"trouvé", missing spaces: "viaGit"→"via Git", "SSOconfiguration"→"Configuration SSO"
- untranslated: x_price_per_month was left in English
- accent: "Suedois"→"Suédois", "Koréen"→"Coréen", "Turque"→"Turc"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 11:17:32 +00:00
claude 00ccb9748e docs: red CAUTION block for security warning, fix hallucinated Overleaf/Typst claim
Use GitHub's > [!CAUTION] admonition (renders with red background) for the
trusted-environment security warning, matching the style used by Collabst.

Remove invented claim that Overleaf is working on Typst support — that was
a hallucination. Replace with a plain "Verso is built on Overleaf's infra"
statement. Add RevealJS as a separate ecosystem project worth supporting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 09:31:35 +00:00
claude a16ad0b977 docs: no contributions/donations, add security model + Collabst + ecosystem support
Build and Deploy Verso / deploy (push) Successful in 15m37s
- Remove Contributing section (not accepting PRs/issues)
- Add Security model section: Verso is for trusted environments only;
  point untrusted-user use cases at Overleaf non-Community offerings
- Mention Collabst as a promising open-source Typst-only alternative
  in the Verso vs Typst.app comparison
- Add Supporting the ecosystem section redirecting to Overleaf (Typst +
  RevealJS work) and the Typst project instead of Verso donations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 09:22:34 +00:00
claude c2a21da47c docs: rewrite README — positioning vs Overleaf/Quarto/Typst.app, add releases section
Build and Deploy Verso / deploy (push) Has been cancelled
Replace the "how to write a .qmd / .typ / .tex" tutorial content with a
clear positioning narrative: what Verso is vs Overleaf (same engine + more
languages), vs Quarto CLI (browser-based, collaborative, multi-language),
and vs Typst.app (self-hosted, AGPL, OT-backed, three languages).

Add a Releases section covering Alpha 1 (core multi-compiler foundation),
Alpha 2 (Typst grammar overhaul, format sub-types, Python for collaborators),
and Alpha 3 in-progress (Lumière, i18n, visual editors, upload fix).

Keep Quick start, Architecture, Env vars, and License sections intact.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 09:11:02 +00:00
claude 94d3764c05 fix: stream HTTP 200 heartbeat before upload body to prevent proxy timeouts
Build and Deploy Verso / deploy (push) Has been cancelled
Uploads from slow connections consistently fail with 502 after ~60-120s
because an upstream proxy (Traefik or cloud load-balancer) has a
"first response byte" deadline that fires before the request body arrives.

Fix: add startStreamingResponse middleware (after auth, before multer)
that immediately writes HTTP 200 + Transfer-Encoding: chunked + '\n'.
With proxy_request_buffering off in Nginx, this reaches the proxy at T≈0,
so no timeout triggers. The upload body continues streaming; multer writes
to disk; the actual JSON result arrives as the final chunk. Periodic
heartbeat '\n' writes every 30s keep response-idle timeouts at bay too.

Client-side: override Uppy's getResponseData/validateStatus to trim
leading whitespace before JSON.parse so the extra '\n' bytes are ignored.
Server-side: sendUploadResponse() helper handles both streaming mode
(res.headersSent → res.end(json)) and normal mode (res.status(N).json()).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 08:59:02 +00:00
claude 8f372d13f8 fix: Lumière layout/navbar on settings+404, drop proxy_request_buffering
Build and Deploy Verso / deploy (push) Successful in 14m28s
- Move min-height:100vh from body to html:has(body[data-lumiere]) so the
  gradient fills the viewport on short pages without inflating document
  height or pushing the footer below the fold
- Remove min-height:60vh from .error-container (was causing scrollbar on
  404 when combined with thin footer)
- Replace Bootstrap 3 navbar selectors (.navbar-nav > li > a) with CSS
  custom property overrides (--navbar-link-color, --navbar-link-hover-*,
  --navbar-bg, etc.) consumed by navbar.scss — fixes header button colours
- Remove position:relative from .navbar-default override; base CSS already
  has position:absolute which provides the stacking context for ::before
- Drop proxy_request_buffering off from upload location: buffered mode +
  global client_body_timeout 15m (nginx.conf.template) is more compatible
  with multer's multipart stream handling on slow connections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 20:38:00 +00:00
claude 211ca9c46d Fix Lumière theming + upload timeout via global middleware
Build and Deploy Verso / deploy (push) Successful in 14m26s
Theming: replace per-controller isLumiere lookups with a single
ExpressLocals middleware that sets res.locals.isLumiere for every
web request. Uses getOverallTheme() (now exported from
UserSettingsHelper) so the date-based default is handled correctly.
This covers 404, settings, setPassword, activate, and all future
server-rendered pages automatically.

Upload timeout: add client_body_timeout 15m to nginx.conf.template
at the http level (was defaulting to 60s globally). This is more
reliable than the location-specific override from build 229.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 19:33:53 +00:00
claude 2ec6ca827e Fix upload timeout + apply Lumière to settings/auth pages
Build and Deploy Verso / deploy (push) Successful in 15m0s
Nginx: add dedicated upload location with client_body_timeout 15m,
client_max_body_size 550m, and proxy_request_buffering off. Default
client_body_timeout of 60s was the actual culprit cutting slow uploads.
Node.js requestTimeout (build 228) remains as a backstop.

Lumière: pass isLumiere from UserPagesController (settings),
PasswordResetController (set-password), and UserActivateController
(first-time activation). auth.scss adds card styling for auth pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 17:47:03 +00:00
claude 41d38a70ed Increase upload timeout to 15 min for slow connections
Build and Deploy Verso / deploy (push) Successful in 15m39s
Frontend fetch gets AbortSignal.timeout(15 min) so hung connections
fail cleanly. Server requestTimeout raised from Node default (5 min)
to match, preventing large-file uploads from being cut off server-side.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:58:10 +00:00
claude b8d5cb9816 fix: XS badge column, lumiere on welcome and 404 pages
Build and Deploy Verso / deploy (push) Successful in 14m39s
- XS compact row: format column 70px→96px so "QUARTO SLIDES" stays on one line;
  trim owner/date cols slightly to compensate
- Welcome page (0 projects): Lumière branch now renders before the 0-projects
  check; ProjectListLumiere renders WelcomePageContent when totalProjectsCount=0
  so new users get the full onboarding experience in the Lumière shell
- 404 page: notFound() now detects the user's overallTheme and passes isLumiere
  to the template; layout-base.pug sets data-lumiere on the body; error-pages.scss
  and project-list-lumiere.scss add [data-lumiere='true'] rules for the body
  background gradient, navbar white+stripe, and styled error box

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:33:23 +00:00
claude c5883e5954 feat: generate first-slide thumbnail for Quarto RevealJS presentations
Build and Deploy Verso / deploy (push) Successful in 14m10s
thumbnailFromBuild() now tries output.pdf → output-slides.pdf → decktape
on output.html (slide 1 only). The web service's ThumbnailManager already
calls this endpoint fire-and-forget on every successful compile, so RevealJS
project cards will show the first slide thumbnail automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:02:54 +00:00
claude 1ddd219449 fix: nowrap on format badges, widen search bar to 360px
Build and Deploy Verso / deploy (push) Successful in 14m25s
Prevents "Quarto Slides" from wrapping to two lines in XS view.
Widens search input from 300px to 360px so French placeholder text fits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 15:44:57 +00:00
claude be6034afcf fix: remove block comments containing closing delimiter causing babel parse errors
Build and Deploy Verso / deploy (push) Successful in 15m19s
JSDoc blocks in typst-decorations.ts and quarto-decorations.ts contained
*/ sequences inside them, which Babel's parser treats as terminating the
block comment prematurely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:55:35 +00:00
claude 37ed70c7e9 build 223: Quarto visual editor — bold, italic, headings, inline code, strikethrough
Build and Deploy Verso / deploy (push) Has been cancelled
Add quarto-decorations.ts ViewPlugin for .qmd/.md files in visual mode:
- ATXHeading1-6: hide # prefix, apply font-size per level (2em → 1em)
- StrongEmphasis: hide ** markers, apply font-weight:700
- Emphasis: hide * or _ markers, apply font-style:italic
- Strikethrough: hide ~~ markers, apply text-decoration:line-through
- InlineCode: hide backtick markers, apply monospace + subtle bg
Markers reappear when cursor enters the node (selectionSet trigger).
Uses getChildren('EmphasisMark'/'StrikethroughMark'/'CodeMark') to locate
delimiters generically, handling both single- and double-backtick inline code.

CSS rules (.ol-cm-md-{strong,emph,strikethrough,inline-code,heading,h1-h6})
added to visual-theme.ts. Plugin wired into visual.ts after typstDecorations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 13:27:34 +00:00
claude e4dc5f3f5d build 222: Typst visual editor — bold, italic, headings, inline code
Build and Deploy Verso / deploy (push) Has been cancelled
Add typst-decorations.ts ViewPlugin that runs alongside the existing
LaTeX visual decorations. For Typst files in visual mode it:
- Hides *…* markers and applies font-weight:700 to the StrongBody
- Hides _…_ markers and applies font-style:italic to the EmphBody
- Hides = prefix marks and applies heading CSS (h1–h6 font sizes)
- Hides backtick delimiters and applies monospace to RawInlineContent
Markers reappear when the cursor enters the node (selectionSet trigger).

Register CSS rules for .ol-cm-typst-{strong,emph,heading,h1-h6,raw-inline}
in visual-theme.ts. Wire the plugin into visual.ts after markDecorations.

The visual editor toggle was already available for .typ files since 'typ'
is in validRootDocExtensions — no gating change needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 13:10:09 +00:00
claude 0058cc17b5 build 221: fix panel bg cascade, center toggle pill, Lumière XS compact rows
Build and Deploy Verso / deploy (push) Successful in 15m1s
- ide-lumiere.scss: declare --file-tree-bg/--outline-bg-color/--outline-container-color-bg:
  transparent at [data-lumiere] .ide-redesign-main scope to beat file-tree.scss +
  outline.scss which re-declare the same vars at .ide-redesign-main (closer ancestor)
- linked-file-highlight and disconnected-overlay get explicit fallback colors so they
  remain usable when --file-tree-bg is transparent
- custom-toggler: replace left:-5px with left:50%/transform:translateX(-50%) for
  reliable centering of the 16px pill over the 4px resize handle
- project-list-lumiere.scss: compact rows get teal-tinted bg + teal border (Lumière feel)
- project-format-badge-* overrides inside .project-list-lumiere so the XS compact view
  shows the soft Lumière palette (matching .lumiere-format-badge--* on cards)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 12:33:18 +00:00
claude 0754d986e9 build 220: XS compact view reuses classic table cells for full detail
Build and Deploy Verso / deploy (push) Successful in 20m14s
Replace the CSS-only compact card hack with a dedicated
ProjectCardCompact component that reuses FormatCell, OwnerCell,
LastUpdatedCell (with tooltip + updated-by), ActionsCell (full set),
and InlineTags — identical data to the classic table view. CSS Grid
aligns columns across rows: checkbox | name+tags | format | owner |
date | actions. Actions fade in on row hover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 11:47:00 +00:00
claude 6e230e250e build 219: wider toggle pill, transparent panel bg, shorter panel names
Build and Deploy Verso / deploy (push) Has been cancelled
Resize handle toggle: 14px wide (centered over 4px handle) so the
arrow icon is clearly visible. File tree and outline backgrounds set
to transparent so the panel-group noise+gradient shows through.
FR: file_tree→"Fichiers", file_outline→"Plan" (+ hide variants).
IT: file_tree→"File", IT/ES hide keys shortened to match.
DE unchanged (Dateibaum/Gliederung already concise).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 11:35:12 +00:00
claude 4f299c6204 build 221: wider search bar, XS compact list view
Build and Deploy Verso / deploy (push) Successful in 14m55s
Search bar: set min-width: 300px so the placeholder text is fully
visible. XS zoom level (value 0): adds lumiere-card-grid--compact class
which switches cards to horizontal rows, hides the thumbnail, and shows
only name / badge / owner / date / actions in a compact strip. Stored
0.75 from old S already fell back to 1 (S); new 0 is cleanly additive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 11:06:49 +00:00
claude 261ca98103 build 220: remap tile zoom levels S/M/L
Old S (0.75×) removed — too small. Old M→S (1×), old L→M (1.35×),
new L added at 1.75×. Stored 0.75 in localStorage falls back to 1×.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 10:58:55 +00:00
claude 60f1e2c511 build 219: Lumière styling for file tree, outline, and panel dividers
Outline: override all CSS variables to use teal palette (bg, border,
hover, highlight, guide line) — was completely unstyled.
File tree: add teal inset left-accent bar on selected item.
Resize handles: narrow from 7→4px, remove dot-grip SVGs, teal hover
highlight. Collapse toggle: teal pill instead of grey.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 10:54:34 +00:00
claude f6bded1596 build 218: Verso logos on set-password and email-check pages, i18n zoom aria-label
Replace Overleaf icons with Verso branding on setPassword.pug
(square icon) and primaryEmailCheck.pug (wordmark). Replace hardcoded
French aria-label "Taille des cartes" on zoom control with t('card_size')
and add card_size key to all 5 locale files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 10:48:20 +00:00
claude 2f4b60574c build 217: square logo on password reset, align tile colours to classic badges
Build and Deploy Verso / deploy (push) Successful in 15m11s
Use verso-square.svg (small icon) instead of the wordmark on the
password reset page. Align card thumbnail gradients and format badge
colours to match the classic project list badge colours (LaTeX green
#098842, Typst teal #239dad, Quarto blue #447099, Quarto Slides
pink-red #e4637c). Split quarto-slides badge from quarto badge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 10:38:05 +00:00
claude 29880d1cf9 build 216: Verso logo on password reset page, fix SSO/ES translations
Build and Deploy Verso / deploy (push) Successful in 15m31s
Replace Overleaf icon with Verso wordmark on passwordReset.pug.
Fix ES password_reset_email_sent (meaning error: said the password
was sent, not a link). Translate <0>Log in with SSO</0> link text
in FR/DE/IT/ES (was left in English in all languages).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 10:15:37 +00:00
claude 9de127f879 i18n: fix login button and related strings in FR/DE/IT/ES
Build and Deploy Verso / deploy (push) Successful in 19m6s
- FR login button: 'Identifiant' (username field label) → 'Connexion'
- ES forgot_your_password: missing closing '?' added
- DE logging_in: 'Anmeldung' (noun) → 'Anmelden' (verb, fits spinner)
- IT logging_in: 'Entrata in corso' → 'Accesso in corso'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 09:51:16 +00:00
claude ae4e95312d i18n: translate hardcoded strings on project page and footer
Build and Deploy Verso / deploy (push) Successful in 17m39s
- Card owner: "You" now goes through t('you') so it renders as
  "Vous"/"Tu"/"Du" etc. instead of always "You"
- Relative dates (fromNow): moment.locale() is now set from the app
  language so "2 days ago" becomes "il y a 2 jours" etc.
- Footer: "Built on", "Source code", "AGPL licence" are now translated
  via t() with keys added to all locale files and extracted-translations
- New keys: built_on, source_code, agpl_licence (FR/DE/IT/ES translations
  added manually)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 09:33:22 +00:00
claude 093c25f3dd i18n: validate and fix 212 translation errors across FR/DE/IT/ES
Build and Deploy Verso / deploy (push) Successful in 13m46s
Automated validation pass found and corrected:
- Brand names translated literally (Overleaf→"au verso"/dorso/retro/umseitig,
  Verso→"verso", LaTeX→"látex", Quarto→"en cuarto", TeXGPT→"TestoGPT")
- React-Trans <N> tags eaten by Google Translate (5 strings)
- FR grammar: "va sera écrasé" → "sera écrasé"
- FR preposition: "en <b>__email__</b>" → "sur <b>__email__</b>" (×2)
- FR title: "Accepter l'erreur de modification" → "Erreur d'acceptation des modifications"
- FR redundancy: "la version Rolling TeX Live" → "le build Rolling TeX Live"
- ES: "mesa" (furniture) → "tabla" (document table) in 3 strings

Tooling committed: translate_missing.py, fix_translations.py,
validate_translations.py — reusable for future locale additions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 09:16:57 +00:00
claude 375c18873e i18n: complete FR/DE/IT/ES translations via Google Translate (free tier)
Build and Deploy Verso / deploy (push) Successful in 14m37s
All four locales are now at 100% key coverage (was FR 38%, DE 50%,
IT 12%, ES 21%). Translated ~7,700 missing keys using deep-translator
with placeholder preservation (__varName__, <N>…</N> React-Trans tags).
Manual corrections can be made on top of this baseline as needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 22:15:47 +00:00
claude 3ee564ef70 ui: fix header row layout, selected-tile contrast, toolbar gradient
Build and Deploy Verso / deploy (push) Successful in 14m19s
- Header: revert column layout; title+zoom stay on left, search+button
  on right — all on one row (flex-wrap handles narrow viewports)
- Selected cards: switch from teal tint (invisible on teal bg) to solid
  white + blue ring, clearly distinguishable from the page background
- Editor toolbar: replace flat teal wash with an exponential-like decay
  (20%→9%→3%→0% teal overlay) so only the very top has colour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 20:47:31 +00:00
claude 4ca6ec2b58 ui: move zoom picker next to section title, fix tooltip hover-stealing
Build and Deploy Verso / deploy (push) Successful in 14m18s
S/M/L buttons now appear inline with the page title rather than in the
action bar. Bootstrap tooltips get pointer-events:none so they can no
longer steal :hover from the card beneath them, preventing the lift
animation from flickering when hovering over tag dots.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 20:09:48 +00:00
claude be529e53f6 Fix translation keys and remove sidebar logo border
Build and Deploy Verso / deploy (push) Successful in 14m14s
- extracted-translations.json: add n_projects_selected, n_projects_selected_plural,
  deselect_all — the translations-loader.js only bundles keys present in this file,
  which is why the FR translations were silently stripped from the webpack chunk
- Revert lang.default workaround in i18n.ts (not needed)
- SCSS: remove border-top above .ds-nav-verso-logo in sidebar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 18:31:04 +00:00
claude 592b4d3dad Fix translations, center logo/footer, add tile zoom control
Build and Deploy Verso / deploy (push) Successful in 14m8s
- i18n: unwrap webpack module object on dynamic JSON import (lang.default ?? lang)
  so French bundle keys are correctly registered in the i18next store
- Login logo: use flex centering on wrapper instead of display:block + margin:auto
- Footer (project list + login): align-items:center on .row for vertical centering
- Tile zoom: S/M/L control in header with CSS custom property (--lum-card-scale)
  that scales grid column width and card thumbnail height; persisted in localStorage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 17:20:21 +00:00
claude d08e834f49 fix: footer default font size, tag dots inline in meta row, OLTooltip
Build and Deploy Verso / deploy (push) Successful in 14m57s
- Footer: remove font-size/letter-spacing overrides so the browser
  default applies; monospace right col keeps its uppercase + tracking
  but no longer shrinks the font
- Card tags: move coloured dots into the .lumiere-card-meta flex row
  instead of a separate div — zero added height, all cards uniform
- Card tags tooltip: replace native title attr with OLTooltip (React
  Bootstrap tooltip) for a consistent Verso look on hover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 13:55:36 +00:00
claude 93c00d51a7 fix: lighter footer, dot-only card tags, deselect_all fr translation
Build and Deploy Verso / deploy (push) Successful in 14m46s
- Footer: pale teal (#edf7f4) with noise grain instead of dark navy;
  serif author credit darker (#1a2e3b), right col monospace muted;
  teal→blue 2px stripe preserved; same treatment on login page
- Card tags: render as 8px coloured dots only (title attr for tooltip)
  — no text means no wrapping, consistent card heights across the grid
- i18n fr.json: add deselect_all → "Tout désélectionner"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 13:20:33 +00:00
claude 7df583a3b2 feat: creative footer, card tags, selection bar theme, i18n fixes
Build and Deploy Verso / deploy (push) Successful in 15m23s
- Footer: dark navy (#0d1b24) with noise texture, teal→blue gradient
  top stripe; serif author credit on the left, monospace/uppercase
  meta links on the right; same design on login page
- Thumbnail: larger inset (14px top, 12px sides) so more background
  gradient shows around the preview; subtle drop shadow added
- Card tags: projects now show their label chips on the card (colored
  dot + name, read-only, max 3, no close button inside the link)
- Selection bar: btn-secondary recoloured to Lumière palette; dropdown
  menus rounded with teal accent on hover
- i18n fr.json: add n_projects_selected, n_projects_selected_plural,
  toolbar_selected_projects (×4 variants)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 12:54:22 +00:00
claude 194ffe54db fix: target footer.site-footer (ThinFooter) in Lumière theme
Build and Deploy Verso / deploy (push) Successful in 14m49s
Verso forces showThinFooter=true for non-SaaS instances, rendering
footer.site-footer everywhere — never .fat-footer. All previous footer
theme rules silently matched nothing. Fix both project and login page
footer selectors. Also increase login logo max-width to 520px, and
remove bottom inset/radius from thumbnail (folder-behind-card effect).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 12:23:36 +00:00
claude f9622820c7 i18n: fix Verso-branded keys and wire welcome message titles
Build and Deploy Verso / deploy (push) Successful in 21m59s
en.json: add browse_templates and learn_latex_with_a_tutorial.

fr.json:
- fix agree_with_the_terms (said "Overleaf", now says "Verso")
- add browse_templates + learn_latex_with_a_tutorial (FR)
- add 7 Verso-branded keys missing entirely from FR:
  add_manager_user_not_found, compile_timeout_explanation,
  download_metadata, institution_has_overleaf_subscription,
  one_step_away_from_professional_features,
  to_confirm_email_address_you_must_be_logged_in_with_the_requesting_account,
  welcome_to_overleaf_opening_workspace

welcome-message.tsx: replace two hardcoded English title strings with
t('learn_latex_with_a_tutorial') and t('browse_templates').

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 11:00:02 +00:00
claude 933efb1ff9 fix: thumbnail sizing, parallax hover effect, login logo size
Build and Deploy Verso / deploy (push) Successful in 14m45s
Thumbnail: explicit top/left/right/bottom + width/height on the img so
object-fit has a well-defined box in all browsers (inset shorthand alone
was underspecified for some).

Hover effect: the card already rises translateY(-3px) on hover. Add a
matching translateY(+3px) counter-transform on the thumbnail image so
its net viewport motion is zero — the document preview appears to float
in place while the gradient tile and card body lift up around it.

Login logo: raise max-width from 300px to 400px now that the competing
inline style has been removed from the pug template.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 10:41:14 +00:00
claude 0bbc07e0b9 fix: improve thumbnail quality and show gradient border around preview
Build and Deploy Verso / deploy (push) Successful in 15m20s
Increase pdftocairo output from 190px/q50 to 380px/q82 — 2× resolution
for crisp rendering on retina displays, higher quality to eliminate
visible compression artefacts.

Inset the thumbnail image 6px from the tile edges (inset: 6px) with a
4px border-radius so the card's colour gradient is visible as a frame
around the document preview.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 10:10:24 +00:00
claude 7da02d9e3a fix: use pdftocairo directly for thumbnails, no Docker image needed
Build and Deploy Verso / deploy (push) Successful in 15m11s
The previous implementation delegated to ConversionManager which uses
the Docker-based CommandRunner and is gated behind enablePdfConversions
(ENABLE_PDF_CONVERSIONS env var). Neither is configured in the Verso
deployment, so every thumbnail request 404'd before doing any work.

poppler-utils (which provides pdftocairo) is already installed directly
in the CLSI base image via install_deps.sh. Rewrite thumbnailFromBuild
to call pdftocairo via execFile instead — no feature flag, no Docker
image, no ConversionManager indirection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 09:42:35 +00:00
claude b70c8ddd0e feat: show compiled PDF thumbnail in Lumière project cards
Build and Deploy Verso / deploy (push) Successful in 14m47s
After a successful compile, web service calls a new CLSI endpoint
(GET /project/:id/user/:uid/build/:bid/thumbnail) which runs pdftocairo
page-1 to a 190px-wide JPEG using the existing thumbnail preset. The
JPEG is stored in Redis (90-day TTL, overwritten on next compile) by
the new ThumbnailManager.

GET /project/:Project_id/thumbnail serves the cached JPEG to authenticated
users, returning 404 when no thumbnail exists. Project cards in the
Lumière grid show the image overlaying the coloured gradient tile; if
the image 404s (project never compiled or cache expired) the onerror
handler hides it and the gradient + initial letter shows through.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 09:20:48 +00:00
claude 88ddbd2513 fix: center Verso logo on all pages and theme both footers
Build and Deploy Verso / deploy (push) Successful in 14m59s
Trim SVG viewBox from 760 to 590 (content ends ~x=570; the blank
right whitespace was making the wordmark look left-biased). Remove the
scale(1.2) transform from the sidebar logo — the negative-margin
container already fills the sidebar width. Change login logo max-width
to be CSS-controlled only (removed inline 480px override).

Footer: switch to `background` shorthand !important so the dark-theme
`var(--footer-background)` shorthand can't compete; deepen the teal to
#c8e4de so the Lumière colour is clearly visible. Add a
`body:has(.login-page) .fat-footer` rule so the login-page footer gets
the same treatment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 08:59:22 +00:00
claude 464aee7612 Add per-card action buttons to Lumière project grid
Build and Deploy Verso / deploy (push) Successful in 14m46s
Restructures ProjectCard so the card is a <div> container instead of <a>
(buttons cannot be nested inside anchor elements). A .lumiere-card-link
anchor wraps the thumb+body area; a .lumiere-card-actions strip sits below
it and fades in on hover.

Buttons added (reusing the same tooltip components as the classic table):
  - Copy project (opens CloneProjectModal)
  - Download project zip
  - Compile & download PDF
  - Archive project (skipped when already archived)
  - Trash project (skipped when already trashed)

Action icons are coloured $lum-text-muted at rest and shift to $lum-teal
on hover, matching the Lumière palette.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 08:17:45 +00:00
claude e3929572c3 Fix Lumière visual regressions: login, footer, logo, notifications
Build and Deploy Verso / deploy (push) Successful in 14m31s
- Login: centre logo (display:block + margin:auto, max-width 300px) and
  increase h1 from 1.4rem to 1.75rem
- Sidebar logo: switch from width:120%/margin-left:-10% to transform:scale(1.2)
  so the image scales symmetrically from centre and isn't cut on the left
- Footer: use !important on background-color/color to beat the dark-theme
  selector's higher specificity (:root [data-theme] = 0,4,0 vs our 0,3,0)
- Notifications: replace near-transparent rgba backgrounds with solid
  opaque colours so the teal page gradient can't bleed through; make the
  CTA button neutral slate-grey (not teal) with border-radius:8px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 08:10:52 +00:00
claude 7ed1d02271 Lumière: replace pill toggle with rounded-square + sliding indicator
Build and Deploy Verso / deploy (push) Successful in 14m52s
The Code/Visual editor toggle now uses a teal sliding indicator (::before
pseudo-element) that glides between tabs via translateX instead of the
plain background-color crossfade. Container and labels get border-radius:
10px/7px to match the rest of the Lumière toolbar's rounded-square style.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 16:44:44 +00:00
claude aec466c6f3 Enlarge sidebar logo 20% and fix compile button corner rounding
- Logo (all themes): scale the Verso wordmark to 120% width, centered and
  clipped to the sidebar column — the word mark visually fills the full
  sidebar width. Uses overflow:hidden + width:120% !important + margin-left:-10%
  to override the existing inline width style.
- Compile button (Lumière): replace the all-corners border-radius:7px on the
  split button group with corner-specific rules — .compile-button gets 7px on
  the left side only, .compile-dropdown-toggle gets 7px on the right side only,
  so the shared inner edge stays flat as expected for a joined split button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 16:35:11 +00:00
claude 8d3f550cef Fix Lumière notification colors, style CTA button, and theme footer
- Warning notification: switch from teal to amber (#b45309) so it reads as
  a genuine warning and doesn't blend into the teal UI chrome
- Notification CTA button (.btn-secondary): style with teal tint so the
  'Send confirmation code' button matches the Lumière theme
- Footer: override dark footer on .project-list-lumiere with a light teal
  background (#edf7f5), dark text, teal section headings — selector has same
  specificity as the default-theme dark rule but appears later in the cascade

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 16:16:28 +00:00
claude e19cd6e8ba Add multi-select to Lumière project card grid
Each project card now has a checkbox (top-left corner, semi-transparent by
default, fully opaque on hover). When any card is selected a selection bar
slides in above the grid showing: select-all checkbox, count, the existing
bulk-action toolbar (archive, trash, tags, delete), and a deselect-all button.
:has(input:checked) keeps all checkboxes visible once a selection is active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 16:03:15 +00:00
claude 484a2e3aac Apply Lumière theme to editor settings modal and login page
- Settings modal: teal accent stripe on header, teal gradient nav background,
  teal active-tab highlight, teal section titles, teal focus rings on form
  controls — scoped via :has(.ide-settings-modal-body) so other modals are
  unaffected
- Login page: grainy teal gradient background, white rounded-square card with
  teal/blue accent stripe, teal labels, focus rings, primary button — always
  applied since users haven't set a theme yet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 15:53:10 +00:00
claude c70ec1c501 feat(lumiere): Lumière-styled notification banners
Build and Deploy Verso / deploy (push) Successful in 20m14s
Override Bootstrap orange/yellow warning and generic blue info colors
with the Lumière teal palette. Warning banners now use a soft teal
tint instead of orange; info banners use the Lumière blue. Both types
get 10px border-radius and a subtle shadow to match the card style.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 15:35:35 +00:00
claude 466f908f7c fix: admin button font size, extend button rounding to PDF toolbar
Build and Deploy Verso / deploy (push) Has been cancelled
- Remove font-size: 0.8rem from Admin navbar button (was shrinking text)
- Add border-radius: 7px to .toolbar-pdf .btn so the Recompile, Logs and
  Download buttons in the PDF panel get the Lumière rounded-square shape
- Add border-radius to compile-button-group .btn to cover the dropdown
  arrow toggle next to the Recompile button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 15:33:44 +00:00
claude 9e618280b1 fix: restore theme toggle in navbar account menu, Lumière button styling
Build and Deploy Verso / deploy (push) Successful in 21m51s
- logged-in-items: pass showThemeToggle to AccountMenuItems so the theme
  switcher is accessible from the top-right navbar (was lost when the
  sidebar account icons were removed); AccountMenuItems already gates on
  hasOverallThemes so it's a no-op on non-themed pages
- project-list-lumiere: restyle Account + Admin navbar buttons — rounded
  square (8px) instead of pill, teal resting tint on Account, subtle
  teal border on hover; matches Lumière design language
- ide-lumiere: extend rounded-square styling to all toolbar action buttons
  (Share, Present, History, Layout, File/Edit/Help menu buttons) via
  .ide-redesign-toolbar-actions and .ide-redesign-toolbar-menu selectors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 14:59:48 +00:00
claude 62847fbc15 Fix remaining Overleaf branding in email confirmation notifications
Replaces "Overleaf subscription"/"Overleaf Commons"/"Overleaf premium features"
with Verso equivalents in the institution subscription and commons upgrade
notification strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 14:37:33 +00:00
claude fc535590eb fix: restore account button, fix double border, logo, gradient, editor buttons
Build and Deploy Verso / deploy (push) Successful in 14m48s
- project-list-ds-nav.scss: remove display:none for .nav-item-account on
  desktop — it was hidden because the sidebar handled it, but now the sidebar
  no longer has the account icon so this made it invisible everywhere
- logged-in-items / nav-dropdown-menu: show User icon alongside 'Account'
  text in navbar dropdown so it's recognisable as an account button
- Lumière: remove border-top from .ds-nav-verso-logo (was doubling up with
  .ds-nav-sidebar-lower border)
- Logo hover: drop scale transform in both themes, use filter:brightness only
- Gradient: drop background-attachment:fixed (unreliable in scroll containers);
  switch to circle gradients at 0.60/0.45 opacity; base colour #e8f5f2
- Editor ide-lumiere: rounded square (7px) on .ol-cm-toolbar-button with teal
  hover/active states to match the Lumière design language

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 14:27:40 +00:00
claude 5fa73fa8e3 fix: rebrand Overleaf strings, move user menu, fix logo + button alignment
Build and Deploy Verso / deploy (push) Successful in 14m49s
- en.json: replace 'Overleaf' with 'Verso' in 6 user-visible strings
  (email_already_registered, add_manager_user_not_found, compile_timeout,
  download_metadata, to_confirm_email address, welcome_opening_workspace)
- groups-and-enterprise-banner: use dynamic appName instead of hard-coded
  'Overleaf'
- SidebarLowerSection: add showAccountIcons prop (default true); set false
  in project dashboard sidebar — account menu is already in the top-right
  navbar, so the bottom-left duplicate is removed for all themes
- ds-nav-verso-logo: replace opacity-fade hover with scale+brightness
  transform so logo is fully visible at rest
- Lumière: scope new-project-dropdown sidebar padding to avoid misaligning
  the button when it appears next to the search bar in the header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:58:17 +00:00
claude 074ff2791d feat(lumiere): bold background + creative editor theme
Build and Deploy Verso / deploy (push) Successful in 14m43s
Dashboard:
- Grain opacity 0.12→0.28, teal orb 0.22→0.45, blue orb 0.16→0.32
- Added soft centre glow for depth; tinted base colour #f0f7f5

Editor:
- Toolbar: visible teal-wash gradient (dark→light) + 4px accent stripe
  + teal box-shadow
- Rail icon strip: teal-tinted gradient (#cceee8→#daf2ee)
- File tree / outline panel: full grainy gradient (matching dashboard)
  with teal/blue orbs + panel header with teal left-glow
- Compile button: teal gradient replacing the default blue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:40:40 +00:00
claude a0d1829c1b fix(lumiere): fix toolbar dropdown clipping, boost grain/gradient visibility
Build and Deploy Verso / deploy (push) Successful in 14m29s
- Remove overflow:hidden from toolbar — it was clipping dropdown menus
- Increase SVG noise opacity 0.06→0.12 and gradient orb opacity for more
  visible texture on the dashboard background

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 12:21:34 +00:00
claude ac02fd707e feat(lumiere): grainy gradient background + editor chrome theme
Build and Deploy Verso / deploy (push) Successful in 15m0s
- Replace flat #f0f4f8 main content bg with layered grainy gradient:
  soft radial teal/blue orbs (background-attachment: fixed) plus
  SVG feTurbulence noise tile for organic depth
- Cards get backdrop-filter glass effect over the textured surface
- New ide-lumiere.scss: sets body[data-lumiere] via useThemedPage, then
  overrides toolbar (white + 3px teal→blue accent stripe), rail (teal
  active states), and file-tree (teal selected/hover) CSS variables

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 11:39:58 +00:00
claude 10a17c5443 feat: modernize Lumière sidebar and navbar styling
Build and Deploy Verso / deploy (push) Successful in 14m36s
- Navbar: teal-to-blue gradient accent stripe, soft shadow replacing
  hard border, pill-shaped hover on nav links
- New Project button: gradient teal fill, elevated shadow on hover
- Filter buttons: pill shape, solid teal active state, teal tint hover
- Tags section: uppercase teal section header, refined tag items
- Account/help icons: circular buttons with teal hover ring and glow
- Theme toggle: teal selected indicator
- Verso logo: subtle top separator with hover opacity transition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 11:02:36 +00:00
claude 13577693f2 fix: restore sidebar CSS context in Lumière dashboard
Build and Deploy Verso / deploy (push) Successful in 15m50s
The Lumière container now carries project-ds-nav-page and
project-list-wrapper so the sidebar picks up all its existing styles.
The grey-rectangle button issue and broken sidebar layout were caused
by those expected parent classes being absent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:37:08 +00:00
claude 0b616436cf feat: toggle bold/italic and add underline, smallcaps, link for Typst editor
Build and Deploy Verso / deploy (push) Has been cancelled
Bold (Ctrl+B) and italic (Ctrl+I) now unwrap when the cursor is already
inside a Strong/Emphasis node. Added #underline[…] and #smallcaps[…]
wrap commands (toolbar only) and #link("")[…] with Ctrl+K shortcut that
places the cursor in the URL field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:31:28 +00:00
claude 926b6f7cbb feat: add Verso Lumière theme with card-based project dashboard
Build and Deploy Verso / deploy (push) Successful in 14m45s
New theme with gradient document cards, serif title typography and a
light airy palette. Set as the default for new users. Existing users
keep their current theme; all users can switch via the theme toggle
(new sparkle icon). Classic Dark / Classic Light are renamed accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:13:46 +00:00
claude 31db7b2b4e feat: add bold/italic shortcuts and toolbar buttons for Typst editor
Build and Deploy Verso / deploy (push) Successful in 14m39s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:28:47 +00:00
claude 4899afd45f fix: enable shell-escape in test deployment for svg package support
Build and Deploy Verso / deploy (push) Successful in 1m6s
OVERLEAF_LATEX_SHELL_ESCAPE=true was added to the prod workflow but
missed in the test workflow, so the svg package still failed on
test.alocoq.fr despite inkscape being installed in the image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 15:10:06 +00:00
claude 8b6b76c2fa fix: install inkscape after pip to avoid apt/pip numpy conflict
Build and Deploy Verso / deploy (push) Successful in 1m12s
inkscape's apt dependencies include python3-numpy, which pip can't
uninstall (no RECORD file). Moving inkscape to its own RUN layer after
the pip installs avoids the conflict: pip numpy lands in /usr/local/lib
first, then apt installs its numpy into /usr/lib alongside it, and
Python resolves /usr/local/lib first at import time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 12:49:08 +00:00
claude 4285d8d74c fix: add --ignore-installed so pip can coexist with inkscape's apt numpy
Build and Deploy Verso / deploy (push) Successful in 1m8s
inkscape pulls in python3-numpy 1.26.4 via apt; pip can't uninstall apt
packages (no RECORD file). --ignore-installed makes pip install its own
copy into /usr/local/lib without touching the apt version; /usr/local/lib
takes import precedence so runtime code gets the pip-managed numpy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 11:12:11 +00:00
claude d8ce7f9dc1 feat: support svg package — install Inkscape and enable shell-escape
Build and Deploy Verso / deploy (push) Has been cancelled
The LaTeX svg package converts .svg files to PDF at compile time by
shelling out to Inkscape (requires --shell-escape). Without Inkscape in
the image and the flag enabled, compilation fails with "Did you run the
export with Inkscape?".

- Dockerfile-base: add inkscape to the apt install block
- settings.js: expose OVERLEAF_LATEX_SHELL_ESCAPE env var → clsi.latexShellEscape
- LatexRunner.js: pass -shell-escape to latexmk when the setting is on
- deploy-verso-prod.yml: set OVERLEAF_LATEX_SHELL_ESCAPE=true (trusted-user instance)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 10:57:56 +00:00
150 changed files with 14928 additions and 639 deletions
+2
View File
@@ -329,6 +329,8 @@ jobs:
# (CE default): admin creates accounts / sends invites.
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
value: "true"
- name: OVERLEAF_LATEX_SHELL_ESCAPE
value: "true"
# (SMTP email vars are loaded below via envFrom.)
# SMTP for password-reset / invite emails. All
# OVERLEAF_EMAIL_* vars come from the optional 'verso-smtp'
+2
View File
@@ -300,6 +300,8 @@ jobs:
# link-sharing users).
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
value: "true"
- name: OVERLEAF_LATEX_SHELL_ESCAPE
value: "true"
---
apiVersion: v1
kind: Service
+202 -139
View File
@@ -2,73 +2,182 @@
<img src="services/web/public/img/ol-brand/verso-logo.svg" alt="Verso" width="440">
</p>
**A collaborative, real-time editor for Quarto, LaTeX and Typst — documents and presentations.**
**A collaborative, real-time editor for LaTeX, Quarto and Typst — self-hosted.**
Verso is a fork of [Overleaf](https://github.com/overleaf/overleaf) that adds
first-class [Quarto](https://quarto.org) and [Typst](https://typst.app) support
alongside Overleaf's LaTeX toolchain. It keeps Overleaf's real-time
collaboration infrastructure and runs **three compilers side by side**, chosen
automatically from the root file's extension:
---
| Root file | Compiler | Typical output |
|-----------|----------|----------------|
| `.qmd` | Quarto | PDF (via Typst or LaTeX), or an HTML/RevealJS deck |
| `.tex` | `latexmk` / TeX Live | PDF |
| `.typ` | Typst | PDF |
## What is Verso?
All three coexist on one server; no per-project configuration is required to
pick the engine.
Verso is a fork of [Overleaf](https://github.com/overleaf/overleaf) that extends its
collaborative editing infrastructure to support [Quarto](https://quarto.org) and
[Typst](https://typst.app) projects alongside LaTeX. Think of it as Overleaf, but
not limited to LaTeX.
### Verso vs Overleaf
[Overleaf](https://www.overleaf.com) is the gold standard for collaborative LaTeX
editing. Verso keeps everything that makes Overleaf great — real-time co-editing,
operational-transformation history, auth, project management, file storage — and
adds:
- **Quarto and Typst compilers** running alongside TeX Live, dispatched
automatically from the root file's extension (`.qmd` → Quarto, `.typ` → Typst,
`.tex``latexmk`).
- **Language-aware editor** for Quarto and Typst (syntax highlighting, completions,
document outline) — not just LaTeX.
- **Publish & share compiled output** (`/p/:token` with tiered access links) — a
feature absent from Overleaf Community Edition.
- **Lumière theme** — a redesigned project dashboard and editor chrome with a
card-based grid, thumbnails, and a teal gradient identity.
- **Full i18n** — French, German, Italian, and Spanish UI translations on top of
Overleaf's English base.
- Completely **free and self-hosted**; no Overleaf subscription required.
### Verso vs Quarto
[Quarto](https://quarto.org) is a command-line tool: you install it locally, write
`.qmd` files in any text editor, and run `quarto render` in a terminal. It is
excellent for solo authors with full control over their environment.
Verso wraps Quarto in a collaborative web editor:
- **No local install** — Quarto, Typst, TeX Live and Python run on the server.
- **Real-time collaboration** — multiple people edit the same `.qmd` simultaneously
with live cursors and conflict-free merging.
- **Not just Quarto** — LaTeX and Typst projects live in the same workspace, under
the same auth and history system.
- **Publish in one click** — RevealJS decks and PDFs are served at a stable link
without leaving the browser.
Verso is not a replacement for Quarto's CLI — it is a platform that makes Quarto
accessible as a shared, always-on service.
### Verso vs Typst.app
[Typst.app](https://typst.app) is a cloud-hosted web editor for Typst. It is
polished and fast, but it is a proprietary SaaS product and only supports Typst.
Verso differs in that:
- It is **self-hosted** and open-source (AGPL v3) — you control your data.
- It supports **three languages** (Typst, LaTeX, Quarto) in one instance.
- Real-time collaboration is powered by **operational transformation** (the same
engine as Overleaf), not CRDTs, which means it handles concurrent edits
gracefully for long documents.
- It ships with a full **project history** and version-restore workflow.
If you only need Typst and want a lighter, Typst-focused alternative, have a look
at **[Collabst](https://github.com/herluf-ba/collabst)** — an open-source,
self-hosted collaborative Typst editor that is independent of the Overleaf
codebase and shows a lot of promise.
---
## Features
- **Real-time collaboration** — multiple people editing the same file at once,
powered by Overleaf's operational-transformation engine, with live cursors
and full project history.
- **Three compilers, auto-dispatched** — Quarto, LaTeX and Typst projects live
side by side; the runner is selected from the root file's extension.
- **Language-aware editor for all three**:
- *LaTeX* — syntax highlighting, command/environment/reference autocomplete,
linting (inherited from Overleaf).
- *Quarto (`.qmd`)* — Markdown highlighting plus Quarto-aware completions:
code chunks (```` ```{python} ````, `{r}`, `{julia}`, `{ojs}`…), callouts
and fenced divs (`::: {.callout-note}`, columns, tabsets) and
cross-references (`@fig-`, `@tbl-`, `@sec-`, `@eq-`).
- *Typst (`.typ`)* — syntax highlighting and completions for the common
functions and markup (`#import`, `#let`, `#set`, `#show`, `#figure`,
`#table`, `#cite`, …).
- **Document outline** — section headings are extracted into the sidebar
outline panel for LaTeX, Quarto (`#`, `##`, …) and Typst (`=`, `==`, …).
- **Format at a glance** — the project dashboard shows a per-project format
badge (Quarto / Typst / LaTeX), and the compiler dropdown greys out engines
that don't apply to the current root file.
- **Publish & share compiled output** — publish the compiled result as a
standalone page at `/p/:token`, with three independent access tiers (project
members / any logged-in user / public). Works for both HTML/RevealJS decks
(served live) and PDFs (embedded inline). HTML decks also get a one-click
**Present** button in the toolbar.
- **Quarto Python cells** — optional per-project virtual environment built from
the project's `requirements.txt`, so Python code chunks run during render
(gated to the project owner and invited collaborators).
- **Auto-compile** — the preview refreshes automatically shortly after you stop
typing.
- **Real-time collaboration** — multiple editors, live cursors, full project history and version restore.
- **Three compilers, auto-dispatched** by root file extension:
| Root file | Compiler | Typical output |
|-----------|----------|----------------|
| `.qmd` | Quarto | PDF (via Typst or LaTeX), HTML, or RevealJS |
| `.tex` | `latexmk` / TeX Live | PDF |
| `.typ` | Typst | PDF |
- **Language-aware editor for all three** — syntax highlighting, completions, and a document outline panel for LaTeX, Quarto and Typst.
- **Format badge** on the project dashboard; compiler dropdown greys out inapplicable engines.
- **Publish & share** — compile and snapshot to `/p/:token` with three independent access tiers (project members / any logged-in user / public). HTML/RevealJS decks are served live; PDFs are embedded inline. A **Present** toolbar button links directly to the published deck.
- **RevealJS thumbnails** — the first slide of a presentation is rendered as a preview card in the project list.
- **Quarto Python cells** — optional per-project virtual environment built from `requirements.txt`, so Python code chunks execute during render.
- **Visual formatting toolbar** — bold, italic, headings and inline code shortcuts for Quarto (`.qmd`) and Typst (`.typ`) files, in addition to Overleaf's existing LaTeX toolbar.
- **Lumière theme** — card-based project dashboard with PDF/slide thumbnails, a teal gradient identity, dark editor chrome, and an XS compact list view.
- **i18n** — French, German, Italian and Spanish UI translations.
- **Auto-compile** — preview refreshes automatically after you stop typing.
## Output formats
---
In the YAML frontmatter of a `.qmd` file:
## Releases
```yaml
format: typst # → PDF preview, rendered via Typst (no LaTeX required)
format: pdf # → PDF preview, rendered via LaTeX
format: revealjs # → interactive HTML slideshow preview
format: html # → a static HTML page
```
### Alpha 1
Typst ships inside Quarto, so `format: typst` needs no separate installation.
The initial public release. Established Verso as an Overleaf fork with first-class
multi-language support:
> **Note on display math**: keep `$$ … $$` blocks on a single line. Multi-line
> display-math blocks can trigger YAML parse errors in some Quarto versions.
- Quarto (`.qmd`) and Typst (`.typ`) compilers running alongside TeX Live,
dispatched automatically by root file extension — no per-project configuration.
- Language-aware editor for Quarto: Markdown highlighting, code-chunk completions
(`{python}`, `{r}`, `{julia}`, `{ojs}`…), callout and fenced-div completions,
cross-reference completions (`@fig-`, `@tbl-`, `@sec-`…).
- Language-aware editor for Typst: syntax highlighting and completions for
functions, imports, math and markup.
- Document outline panel for all three languages (LaTeX `\section`, Quarto `#`,
Typst `=`).
- Format badge on the project dashboard; compiler selector greys out inapplicable
engines for the current root file.
- Publish & share compiled output — HTML/RevealJS decks and PDFs hosted at
`/p/:token` with tiered access links (project / logged-in / public), each
independently resettable.
- Quarto Python code-cell execution via an optional per-project `requirements.txt`
virtual environment.
- Verso branding: name, logo and Kubernetes production deploy workflow.
### Alpha 2
Refinements to the Typst editor and the format badge system:
- **Quarto format sub-types** — the project badge now distinguishes *Quarto PDF*
from *Quarto Slides*, reading the frontmatter `format:` to pick the right label.
- **Python packages for collaborators** — Quarto Python package installation
extended to all users who have write access to the project, not only the owner.
- **Typst syntax highlighting overhaul** — complete grammar rewrite covering:
function calls and named argument keys, multi-line display math, `#{…}` code
blocks, content blocks, `show`-rule bodies, `let`-value bindings, and keyword
vs identifier disambiguation.
- **Typst visual formatting** — bold and italic toolbar buttons and keyboard
shortcuts (`Ctrl+B`, `Ctrl+I`), plus underline, small-caps and hyperlink
buttons, matching the Quarto and LaTeX toolbar experience.
### Alpha 3
- **Lumière theme** — redesigned project dashboard with a card grid, PDF/slide
thumbnails, parallax hover effects, a teal gradient identity and a dark editor
chrome. Includes an XS compact list view and a tile zoom slider.
- **Full i18n** — French, German, Italian and Spanish translations covering the
complete UI (login, dashboard, editor, settings, emails).
- **Visual editors for Quarto and Typst** — bold, italic, headings and inline code
shortcuts in the toolbar for `.qmd` and `.typ` files.
- **Top/bottom split view** — new editor layout that stacks the source editor
above the PDF preview vertically, in addition to the existing side-by-side mode.
- **Bidirectional format export** — LaTeX projects can be converted to Typst and
Typst projects to LaTeX via pandoc. Available from the File menu in the editor.
- **Mobile layout** — project dashboard, search bar, footer and editor all
adapted for phone screen sizes.
---
## Known issues
- **Large file upload timeouts** — uploads of large files on slow connections
can time out at the proxy layer. A streaming response fix is pending.
---
## Security model — trusted environments only
> [!CAUTION]
> Verso is designed for **closed groups of trusted users** (a lab, a class, a
> small team). All three compilers can execute arbitrary code on the server:
>
> - LaTeX with shell-escape enabled can run system commands.
> - Quarto Python cells execute Python code directly.
> - Typst's scripting layer is sandboxed by design, but runs server-side.
>
> There is **no per-project sandbox or resource isolation** beyond what the
> operating system provides. Exposing Verso to the public internet with open
> registration is not recommended. If you need to host a collaborative
> LaTeX editor for untrusted users or at scale, look at
> [Overleaf's non-Community offerings](https://www.overleaf.com/for/enterprises),
> which include proper sandboxing and enterprise access controls.
---
## Quick start
@@ -82,24 +191,23 @@ docker run -d \
registry.alocoq.fr/verso:latest
```
Open `http://localhost` in your browser, then visit `/launchpad` on first run to
create the admin account.
Open `http://localhost`, then visit `/launchpad` on first run to create the admin
account.
### Build from source
```bash
# Build the base image (system deps + Quarto + TeX Live)
cd server-ce
make build-base
# Build the application image
make build-community
make build-base # base OS image: system deps, Quarto, Typst, TeX Live
make build-community # application image: Node services + compiled frontend
```
| File | Purpose |
|------|---------|
| `server-ce/Dockerfile-base` | Base OS image — system deps, Quarto (with Typst) and a TeX Live (`latexmk`) toolchain |
| `server-ce/Dockerfile` | Application image — Node services and the compiled frontend |
| `server-ce/Dockerfile-base` | Base image — system deps, Quarto (with Typst) and TeX Live |
| `server-ce/Dockerfile` | App image — Node services and the compiled React frontend |
---
## Architecture
@@ -108,10 +216,10 @@ single container managed by `runit`, with `nginx` as the front router.
```
browser ──→ nginx:80
├── / ──────────────────→ web:4000 (main app, React UI)
├── /socket.io ──────────→ real-time:3026 (WebSocket, OT engine)
├── /p/:token ───────────→ web (published output)
└── /project/*/output/* → clsi-nginx:8080 (compiled output files)
├── / ──────────────────→ web:4000 (main app, React UI)
├── /socket.io ──────────→ real-time:3026 (WebSocket, OT engine)
├── /p/:token ───────────→ web (published output)
└── /project/*/output/* → clsi-nginx:8080 (compiled output files)
web → document-updater → Redis pub/sub → real-time → browser
web → CLSI (quarto render / latexmk / typst) → output files → nginx → browser
@@ -122,64 +230,12 @@ web → CLSI (quarto render / latexmk / typst) → output files → nginx → br
| `web` | HTTP API, React frontend, auth, project & sharing management |
| `real-time` | WebSocket layer, live cursors and edit sync |
| `document-updater` | Operational transformation, Redis pub/sub |
| `clsi` | Compiler — runs `quarto render` (`.qmd`), `latexmk` (`.tex`) or `typst` (`.typ`) and serves output |
| `clsi` | Compiler — runs `quarto render`, `latexmk` or `typst` and serves output |
| `docstore` | Document text storage (MongoDB) |
| `filestore` | Binary file storage (S3 or local) |
| `project-history` | Change history and version tracking |
## Writing documents
### Quarto (`main.qmd`)
```markdown
---
title: My Presentation
author: Your Name
date: today
format: revealjs
---
## Slide one
Write **Markdown** here.
## Mathematics
$$\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}$$
```
Switch `format: revealjs` to `format: typst` (or `pdf`) for a PDF preview.
### LaTeX (`main.tex`)
LaTeX works exactly as in Overleaf: a project whose root file is a `.tex` file
compiles with `latexmk`/TeX Live, no setting required. The **Example LaTeX
project** in the *New project* menu is a ready-made starting point.
> The bundled TeX Live is a minimal install. Documents that need extra packages
> may not build out of the box — see `server-ce/Dockerfile-base` for how to
> switch to a fuller TeX Live scheme.
### Typst (`main.typ`)
A project whose root file is a `.typ` file compiles directly to PDF with
[Typst](https://typst.app) — fast, modern markup with a real scripting
language. Verso drives the Typst bundled with Quarto, so no extra install is
needed. Use the **Blank Typst project** entry in the *New project* menu to get
started.
## Publishing compiled output
From **Share → Publish**, Verso compiles the project and snapshots the result to
a standalone page at `/p/:token`:
- **HTML / RevealJS** decks are served as a live page (the **Present** toolbar
button is a one-click shortcut to this).
- **PDF** output is embedded inline; the raw file stays reachable at
`/p/:token/output.pdf`.
Three stable links are issued, one per access tier — project members, any
logged-in user, or anyone — and each can be copied or independently reset.
## Environment variables
@@ -195,39 +251,46 @@ The most commonly needed:
| `OVERLEAF_SITE_URL` | — | Public URL (used in emails and published links) |
| `OVERLEAF_SITE_LANGUAGE` | `en` | Default UI language (e.g. `fr`) |
| `OVERLEAF_ENABLE_PROJECT_PYTHON_VENV` | `false` | Allow Quarto Python cells to use a project `requirements.txt` |
| `OVERLEAF_ADMIN_EMAIL` | — | Email for the first admin account |
| `OVERLEAF_ADMIN_EMAIL` | — | Email shown on the launchpad for the first admin account |
See the [Overleaf Server documentation](https://github.com/overleaf/overleaf/wiki)
for the full list.
---
## Relation to Overleaf
Verso is a fork of [Overleaf Community Edition](https://github.com/overleaf/overleaf).
The main additions on top of upstream are:
Everything that Overleaf CE provides — real-time collaboration, operational-transformation
history, auth, project management, binary file storage — is inherited unchanged. The
Verso-specific additions are listed in the Features section and tracked across releases above.
- Quarto and Typst compilers running alongside LaTeX, dispatched by the root
file's extension.
- Editor language support (highlighting, autocomplete, outline) for Quarto and
Typst.
- A per-project format badge on the dashboard and a root-file-aware compiler
selector.
- Publishing/sharing of compiled output (HTML decks and PDFs) via `/p/:token`
with tiered access links, and a toolbar **Present** shortcut.
- Optional per-project Python virtual environments for Quarto code execution.
- Verso branding (name, logo, palette, loading animation).
Verso is not affiliated with Overleaf Ltd.
All other infrastructure — real-time collaboration, history, auth, file
storage, project management — is unchanged from Overleaf.
---
## Contributing
## Supporting the ecosystem
Contributions are welcome — open an issue or pull request on the
[Verso repository](https://git.alocoq.fr/alois/verso). The upstream Overleaf
contribution guidelines are in [CONTRIBUTING.md](CONTRIBUTING.md).
Verso is not accepting contributions or donations at this time. If you find it
useful and want to support the broader ecosystem it builds on:
- **Support Overleaf** — Verso is built on Overleaf's infrastructure. The best
way to support their work is to use or subscribe to
[Overleaf](https://www.overleaf.com) and encourage your institution to do the
same.
- **Support Typst** — [Typst GmbH](https://typst.app) is the company behind the
Typst compiler. Using Typst.app or sponsoring the
[Typst project on GitHub](https://github.com/typst/typst) helps sustain the
language itself.
- **Support RevealJS** — Verso uses [Reveal.js](https://revealjs.com) for
HTML presentations. Consider sponsoring the
[RevealJS project on GitHub](https://github.com/hakimel/reveal.js).
---
## License
GNU Affero General Public License v3 — see [LICENSE](LICENSE).
Copyright © Overleaf, 20142026 (original code).
Copyright © Overleaf, 20142026 (original code).
Verso modifications © Aloïs Coquillard, 2026.
+7
View File
@@ -54,6 +54,13 @@ RUN --mount=type=cache,target=/root/.cache \
# Add the actual source files
# ---------------------------
COPY --parents libraries/ services/ tools/migrations/ /overleaf/
# Syntax-check all server-side ESM modules before the expensive webpack
# compile. node --check parses without executing, so it's fast and safe.
# Catches things like escaped backticks from sed substitutions that webpack
# never sees (it only bundles frontend code).
RUN find services/web/app/src services/web/modules -name '*.mjs' | xargs node --check
RUN --mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/root/.yarn/berry/cache,id=server-ce-yarn-cache \
--mount=type=tmpfs,target=/usr/local/share/.cache/yarn \
+10
View File
@@ -25,6 +25,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
unattended-upgrades \
build-essential wget net-tools unzip time poppler-utils optipng strace nginx git python3 python-is-python3 zlib1g-dev libpcre3-dev gettext-base libwww-perl ca-certificates curl gnupg \
qpdf \
pandoc \
# upgrade base-image, batch all the upgrades together, rather than installing them on-by-one (which is slow!)
&& unattended-upgrade --verbose --no-minimal-upgrade-steps \
# install Node.js https://github.com/nodesource/distributions#nodejs
@@ -104,6 +105,15 @@ RUN apt-get update \
opencv-python-headless tqdm \
&& rm -rf /var/lib/apt/lists/* /root/.cache
# Install Inkscape (for the LaTeX svg package via shell-escape)
# Must come AFTER the pip installs above: inkscape pulls in python3-numpy via
# apt, which would block pip from upgrading numpy. With pip's numpy already in
# /usr/local/lib, apt installs its own copy into /usr/lib alongside it — no
# conflict — and Python resolves /usr/local/lib first at import time.
RUN apt-get update \
&& apt-get install -y inkscape \
&& rm -rf /var/lib/apt/lists/*
# Install decktape + headless Chromium (for exporting RevealJS decks to PDF)
# -----------------------------------------------------------------------
# decktape drives a headless Chromium (via Puppeteer) to print the rendered
+1
View File
@@ -1,3 +1,4 @@
export ENABLE_PANDOC_CONVERSIONS=true
export CHAT_HOST=127.0.0.1
export CLSI_HOST=127.0.0.1
export DOCSTORE_HOST=127.0.0.1
+1
View File
@@ -50,6 +50,7 @@ const TMP_DIR = '/var/lib/overleaf/tmp'
const settings = {
clsi: {
optimiseInDocker: process.env.OPTIMISE_PDF === 'true',
latexShellEscape: process.env.OVERLEAF_LATEX_SHELL_ESCAPE === 'true',
},
brandPrefix: '',
+3 -3
View File
@@ -48,9 +48,9 @@ server {
rewrite ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/(.+)$ /$4 break;
root /var/lib/overleaf/data/output/$1-$2/generated-files/$3/;
}
# handle output files for anonymous users
location ~ ^/project/([0-9a-f]+)/build/([0-9a-f-]+)/output/(.+)$ {
rewrite ^/project/([0-9a-f]+)/build/([0-9a-f-]+)/output/(.+)$ /$3 break;
# handle output files for anonymous users (project ID may be a UUID with hyphens for conversions)
location ~ ^/project/([0-9a-f-]+)/build/([0-9a-f-]+)/output/(.+)$ {
rewrite ^/project/([0-9a-f-]+)/build/([0-9a-f-]+)/output/(.+)$ /$3 break;
root /var/lib/overleaf/data/output/$1/generated-files/$2/;
}
+1
View File
@@ -47,6 +47,7 @@ http {
gzip_proxied any; # allow upstream server to compress.
client_max_body_size 500m;
client_body_timeout 15m;
# gzip_vary on;
# gzip_proxied any;
+20
View File
@@ -9,6 +9,26 @@ server {
internal;
}
# File upload endpoints: extended timeouts for large files on slow connections.
# proxy_request_buffering off: forward the request body to Node.js immediately
# rather than buffering first, so Node.js can send a keepalive response byte
# before the full body arrives (preventing upstream proxy "first-byte" timeouts).
# proxy_buffering off: forward that keepalive byte to Traefik/LB without delay.
location ~ ^/project/[^/]+/upload$ {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 15m;
proxy_send_timeout 15m;
send_timeout 15m;
client_body_timeout 15m;
client_max_body_size 550m;
}
location / {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
+4
View File
@@ -150,6 +150,10 @@ app.post(
FileUploadMiddleware.multerMiddleware,
ConversionController.convertPDFToJPEG
)
app.get(
'/project/:project_id/user/:user_id/build/:build_id/thumbnail',
ConversionController.thumbnailFromBuild
)
if (process.env.NODE_ENV === 'development' && global.__coverage__) {
app.get('/coverage', (req, res) => {
+117 -1
View File
@@ -1,4 +1,7 @@
import crypto from 'node:crypto'
import { execFile } from 'node:child_process'
import os from 'node:os'
import { promisify } from 'node:util'
import logger from '@overleaf/logger'
import { expressify } from '@overleaf/promise-utils'
import fs from 'node:fs/promises'
@@ -16,10 +19,14 @@ import Settings from '@overleaf/settings'
import Path from 'node:path'
import { z } from '@overleaf/validation-tools'
const execFileAsync = promisify(execFile)
const CONVERSION_CONFIGS = {
docx: { extension: 'docx' },
markdown: { extension: 'zip' },
html: { extension: 'zip' },
typst: { extension: 'typ' },
latex: { extension: 'tex' },
}
async function convertDocumentToLaTeX(req, res) {
@@ -29,7 +36,7 @@ async function convertDocumentToLaTeX(req, res) {
await fs.unlink(path).catch(() => {})
return res.sendStatus(404)
}
if (!conversionType || !['docx', 'markdown'].includes(conversionType)) {
if (!conversionType || !['docx', 'markdown', 'typst'].includes(conversionType)) {
await fs.unlink(path).catch(() => {})
return res.sendStatus(400)
}
@@ -251,8 +258,117 @@ async function convertProjectToDocument(req, res) {
}
}
// Generates a JPEG thumbnail of page 1 of the compiled output using
// pdftocairo (poppler-utils). Tries output.pdf first (LaTeX / Quarto-PDF),
// then output-slides.pdf (Quarto RevealJS after a PDF-export compile), then
// falls back to rendering slide 1 of output.html via decktape (normal
// RevealJS preview compile). All temp dirs are cleaned up in finally.
async function thumbnailFromBuild(req, res) {
const { project_id: projectId, user_id: userId, build_id: buildId } = req.params
if (!buildId?.match(OutputCacheManager.BUILD_REGEX)) return res.sendStatus(400)
const compileName = userId ? `${projectId}-${userId}` : projectId
const buildDir = Path.join(
Settings.path.outputDir,
compileName,
OutputCacheManager.CACHE_SUBDIR,
buildId
)
let pdfPath = null
let deckTapeDir = null
for (const name of ['output.pdf', 'output-slides.pdf']) {
try {
const p = Path.join(buildDir, name)
await fs.access(p)
pdfPath = p
break
} catch {}
}
if (!pdfPath) {
const htmlPath = Path.join(buildDir, 'output.html')
try {
await fs.access(htmlPath)
deckTapeDir = await fs.mkdtemp(Path.join(os.tmpdir(), 'clsi-deck-'))
const chromeHome = Path.join(deckTapeDir, 'chrome')
await fs.mkdir(chromeHome, { recursive: true })
const slidePdf = Path.join(deckTapeDir, 'slide1.pdf')
await execFileAsync(
'decktape',
[
'--slides', '1',
'--chrome-arg=--no-sandbox',
'--chrome-arg=--disable-dev-shm-usage',
'--chrome-arg=--disable-gpu',
`--chrome-arg=--user-data-dir=${chromeHome}/data`,
htmlPath,
slidePdf,
],
{
timeout: 60000,
env: {
...process.env,
HOME: chromeHome,
XDG_CONFIG_HOME: chromeHome,
XDG_CACHE_HOME: chromeHome,
},
}
)
pdfPath = slidePdf
} catch (err) {
logger.warn({ err, projectId, buildId }, 'decktape slide1 thumbnail failed')
if (deckTapeDir) {
await fs.rm(deckTapeDir, { recursive: true, force: true }).catch(() => {})
deckTapeDir = null
}
return res.sendStatus(404)
}
}
const tmpDir = await fs.mkdtemp(Path.join(os.tmpdir(), 'clsi-thumb-'))
const outputBase = Path.join(tmpDir, 'thumb')
const jpegPath = outputBase + '.jpg'
try {
await execFileAsync(
'pdftocairo',
[
'-jpeg',
'-jpegopt', 'quality=90',
'-singlefile',
'-scale-to-x', '794',
'-scale-to-y', '-1',
'-f', '1',
'-l', '1',
pdfPath,
outputBase,
],
{ timeout: 30000 }
)
const jpegStat = await fs.stat(jpegPath)
res.setHeader('Content-Type', 'image/jpeg')
res.setHeader('Content-Length', jpegStat.size)
res.setHeader('Cache-Control', 'public, max-age=86400')
res.setHeader('X-Content-Type-Options', 'nosniff')
const readStream = fsSync.createReadStream(jpegPath)
await pipeline(readStream, res)
} catch (err) {
logger.warn({ err, projectId, buildId }, 'thumbnail generation failed')
if (!res.headersSent) res.sendStatus(500)
} finally {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
if (deckTapeDir) {
await fs.rm(deckTapeDir, { recursive: true, force: true }).catch(() => {})
}
}
}
export default {
convertDocumentToLaTeX: expressify(convertDocumentToLaTeX),
convertProjectToDocument: expressify(convertProjectToDocument),
convertPDFToJPEG: expressify(convertPDFToJPEG),
thumbnailFromBuild: expressify(thumbnailFromBuild),
}
+30 -1
View File
@@ -16,11 +16,15 @@ const CONVERSION_CONFIGS = {
inputFilename: 'input.md',
pandocArgs: ['--from', 'markdown'],
},
typst: {
inputFilename: 'input.typ',
pandocArgs: ['--from', 'typst'],
},
}
const PDF_TO_JPEG_CONFIGS = {
preview: { width: 794, quality: 90 },
thumbnail: { width: 190, quality: 50 },
thumbnail: { width: 794, quality: 90 },
}
const PDF_TO_JPEG_INPUT_FILENAME = 'input.pdf'
@@ -175,6 +179,31 @@ const LATEX_EXPORT_CONFIGS = {
'--standalone',
],
},
typst: {
fileExtension: 'typ',
compressOutput: false,
getPandocArgs: ({ outputPath }) => [
'--output',
outputPath,
'--from',
'latex',
'--to',
'typst',
],
},
latex: {
fileExtension: 'tex',
compressOutput: false,
getPandocArgs: ({ outputPath }) => [
'--output',
outputPath,
'--from',
'typst',
'--to',
'latex',
'--standalone',
],
},
}
async function convertLaTeXToDocumentInDirWithLock(
+4
View File
@@ -197,6 +197,10 @@ function _buildLatexCommand(mainFile, opts = {}) {
command.push(...opts.flags)
}
if (Settings.clsi?.latexShellEscape) {
command.push('-shell-escape')
}
// TeX Engine selection. A .tex project may carry a non-LaTeX compiler value
// (e.g. 'quarto', the fork-wide default for Project.compiler) because the
// runner is chosen by file extension, not by this setting. In that case fall
@@ -26,6 +26,7 @@ import { expressify, promisify } from '@overleaf/promise-utils'
import { handleAuthenticateErrors } from './AuthenticationErrors.mjs'
import EmailHelper from '../Helpers/EmailHelper.mjs'
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
import Translations from '../../infrastructure/Translations.mjs'
const { hasAdminAccess } = AdminAuthorizationHelper
@@ -221,6 +222,17 @@ const AuthenticationController = {
await _afterLoginSessionSetupAsync(req, user)
// Sync user's stored language preference to cookie on login
if (user.languageCode) {
res.cookie(Translations.LANG_COOKIE_NAME, user.languageCode, {
maxAge: 365 * 24 * 60 * 60 * 1000,
httpOnly: true,
secure: Settings.secureCookie,
sameSite: 'Lax',
domain: Settings.cookieDomain,
})
}
AuthenticationController._clearRedirectFromSession(req)
AnalyticsRegistrationSourceHelper.clearSource(req.session)
AnalyticsRegistrationSourceHelper.clearInbound(req.session)
@@ -20,6 +20,7 @@ import {
fetchStreamWithResponse,
RequestFailedError,
} from '@overleaf/fetch-utils'
import ThumbnailManager from './ThumbnailManager.mjs'
import Features from '../../infrastructure/Features.mjs'
import ClsiCacheController from './ClsiCacheController.mjs'
import { prepareZipAttachment } from '../../infrastructure/Response.mjs'
@@ -321,6 +322,10 @@ const _CompileController = {
.catch(err =>
logger.warn({ err, projectId }, 'failed to update quartoFlavor')
)
ThumbnailManager.generateAndCacheThumbnail(projectId, userId, buildId).catch(
err => logger.warn({ err, projectId }, 'thumbnail cache error')
)
}
res.json({
@@ -346,6 +351,15 @@ const _CompileController = {
res.sendStatus(200)
},
async getProjectThumbnail(req, res) {
const projectId = req.params.Project_id
const jpeg = await ThumbnailManager.getCachedThumbnail(projectId)
if (!jpeg) return res.sendStatus(404)
res.setHeader('Content-Type', 'image/jpeg')
res.setHeader('Cache-Control', 'public, max-age=60')
res.send(jpeg)
},
// Used for submissions through the public API
async compileSubmission(req, res) {
res.setTimeout(COMPILE_TIMEOUT_MS)
@@ -822,6 +836,7 @@ const CompileController = {
proxySyncPdf: expressify(_CompileController.proxySyncPdf),
proxySyncCode: expressify(_CompileController.proxySyncCode),
wordCount: expressify(_CompileController.wordCount),
getProjectThumbnail: expressify(_CompileController.getProjectThumbnail),
_getSafeProjectName: _CompileController._getSafeProjectName,
_getSplitTestOptions,
@@ -0,0 +1,63 @@
import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import RedisWrapper from '../../infrastructure/RedisWrapper.mjs'
import { fetchStreamWithResponse, RequestFailedError } from '@overleaf/fetch-utils'
const rclient = RedisWrapper.client('web')
const THUMBNAIL_TTL = 60 * 60 * 24 * 90 // 90 days
function redisKey(projectId) {
return `Thumbnail:${projectId}`
}
function clsiThumbnailUrl(projectId, userId, buildId) {
return `${Settings.apis.clsi.url}/project/${projectId}/user/${userId}/build/${buildId}/thumbnail`
}
async function generateAndCacheThumbnail(projectId, userId, buildId) {
if (!userId || !buildId) return
const url = clsiThumbnailUrl(projectId, userId, buildId)
let stream
try {
;({ stream } = await fetchStreamWithResponse(url))
} catch (err) {
if (!(err instanceof RequestFailedError)) {
logger.warn(
{ err, projectId, buildId },
'unexpected error fetching thumbnail from CLSI'
)
}
return
}
try {
const chunks = []
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
const jpegBuffer = Buffer.concat(chunks)
if (jpegBuffer.length === 0) return
await rclient.set(
redisKey(projectId),
jpegBuffer.toString('base64'),
'EX',
THUMBNAIL_TTL
)
} catch (err) {
logger.warn({ err, projectId }, 'failed to cache thumbnail in Redis')
}
}
async function getCachedThumbnail(projectId) {
try {
const b64 = await rclient.get(redisKey(projectId))
if (!b64) return null
return Buffer.from(b64, 'base64')
} catch (err) {
logger.warn({ err, projectId }, 'failed to get thumbnail from Redis')
return null
}
}
export default { generateAndCacheThumbnail, getCachedThumbnail }
@@ -13,6 +13,15 @@ import { expressify } from '@overleaf/promise-utils'
import { pipeline } from 'node:stream/promises'
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
import { DocumentConversionError } from '../Errors/Errors.js'
import ProjectLocator from '../Project/ProjectLocator.mjs'
import ProjectEntityUpdateHandler from '../Project/ProjectEntityUpdateHandler.mjs'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import nodePath from 'node:path'
const execFileAsync = promisify(execFile)
const { z, zz, parseReq } = Validation
@@ -20,6 +29,8 @@ const SUPPORTED_CONVERSION_TYPES = new Map([
['docx', 'docx'],
['markdown', 'zip'],
['html', 'zip'],
['typst', 'typ'],
['latex', 'tex'],
])
const exportProjectConversionSchema = z.object({
@@ -99,14 +110,14 @@ async function exportProjectConversion(req, res) {
{ compileFromHistory, rootResourcePath }
)
AnalyticsManager.recordEventForUserInBackground(userId, 'convert-format', {
sourceFormat: 'latex',
sourceFormat: type === 'latex' ? 'typst' : 'latex',
targetFormat: type,
status: 'success',
operation: 'export',
})
} catch (error) {
AnalyticsManager.recordEventForUserInBackground(userId, 'convert-format', {
sourceFormat: 'latex',
sourceFormat: type === 'latex' ? 'typst' : 'latex',
targetFormat: type,
status: 'failure',
operation: 'export',
@@ -164,9 +175,102 @@ async function downloadPreparedProjectExport(req, res) {
})
}
const DOC_CONVERSION_CONFIGS = {
typst: { fromExt: 'tex', toExt: 'typ', pandocFrom: 'latex', pandocTo: 'typst' },
latex: { fromExt: 'typ', toExt: 'tex', pandocFrom: 'typst', pandocTo: 'latex' },
}
async function convertDocInProject(req, res) {
const { Project_id: projectId, Doc_id: docId, type } = req.params
const userId = SessionManager.getLoggedInUserId(req.session)
const config = DOC_CONVERSION_CONFIGS[type]
if (!config) return res.status(400).json({ error: 'unsupported conversion type' })
const { lines } = await DocumentUpdaterHandler.promises.getDocument(
projectId,
docId,
-1
)
const content = lines.join('\n')
const { element, folder } = await ProjectLocator.promises.findElement({
project_id: projectId,
element_id: docId,
type: 'doc',
})
const parentFolderId = folder._id
const baseName = element.name.endsWith('.' + config.fromExt)
? element.name.slice(0, -(config.fromExt.length + 1))
: element.name
const outputName = baseName + '.' + config.toExt
const tmpDir = await mkdtemp(nodePath.join(tmpdir(), 'verso-convert-'))
let convertedContent
try {
const inputPath = nodePath.join(tmpDir, `input.${config.fromExt}`)
const outputPath = nodePath.join(tmpDir, `output.${config.toExt}`)
await writeFile(inputPath, content, 'utf8')
try {
await execFileAsync(
'pandoc',
[
inputPath,
'--from', config.pandocFrom,
'--to', config.pandocTo,
'--output', outputPath,
'--standalone',
],
{ timeout: 30_000 }
)
} catch (err) {
return res.status(422).json({ error: err.stderr || err.message })
}
convertedContent = await readFile(outputPath, 'utf8')
} finally {
await rm(tmpDir, { recursive: true, force: true })
}
const existingDoc = folder.docs?.find(d => d.name === outputName)
let resultDocId, resultName
if (existingDoc) {
await DocumentUpdaterHandler.promises.setDocument(
projectId,
existingDoc._id.toString(),
userId,
convertedContent.split('\n'),
'convert'
)
resultDocId = existingDoc._id
resultName = existingDoc.name
} else {
const { doc } = await ProjectEntityUpdateHandler.promises.addDoc(
projectId,
parentFolderId,
outputName,
convertedContent.split('\n'),
userId,
'convert'
)
resultDocId = doc._id
resultName = doc.name
}
ProjectAuditLogHandler.addEntryInBackground(
projectId,
'doc-converted',
userId,
req.ip
)
res.json({ docId: resultDocId, name: resultName, parentFolderId: parentFolderId.toString(), isNew: !existingDoc })
}
export default {
exportProjectConversion: expressify(exportProjectConversion),
downloadPreparedProjectExport: expressify(downloadPreparedProjectExport),
convertDocInProject: expressify(convertDocInProject),
downloadProject(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
@@ -9,7 +9,7 @@ export default _.template(`\
<tr style="padding: 0; text-align: left; vertical-align: top;">
<th style="margin: 0; padding: 0; text-align: left;">
<% if (title) { %>
<h3 class="force-overleaf-style" style="margin: 0; color: #5D6879; font-family: Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; padding: 0; text-align: left; word-wrap: normal;">
<h3 class="force-verso-style" style="margin: 0; color: #5D6879; font-family: Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; padding: 0; text-align: left; word-wrap: normal;">
<%= title %>
</h3>
<% } %>
@@ -25,7 +25,7 @@ export default _.template(`\
<% } %>
<% (message).forEach(function(paragraph) { %>
<p class="force-overleaf-style" style="margin: 0 0 10px 0; padding: 0;">
<p class="force-verso-style" style="margin: 0 0 10px 0; padding: 0;">
<%= paragraph %>
</p>
<% }) %>
@@ -15,7 +15,7 @@ export default _.template(`\
<% } %>
<% (message).forEach(function(paragraph) { %>
<p class="force-overleaf-style" style="margin: 0 0 16px 0;">
<p class="force-verso-style" style="margin: 0 0 16px 0;">
<%= paragraph %>
</p>
<% }) %>
@@ -9,7 +9,7 @@ export default _.template(`\
<tr style="padding: 0; text-align: left; vertical-align: top;">
<th style="margin: 0; padding: 0; text-align: left;">
<% if (title) { %>
<h3 class="force-overleaf-style" style="margin: 0; color: #5D6879; font-family: Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; padding: 0; text-align: left; word-wrap: normal;">
<h3 class="force-verso-style" style="margin: 0; color: #5D6879; font-family: Georgia, serif; font-size: 24px; font-weight: normal; line-height: 1.3; padding: 0; text-align: left; word-wrap: normal;">
<%= title %>
</h3>
<% } %>
@@ -25,7 +25,7 @@ export default _.template(`\
<% } %>
<% (message).forEach(function(paragraph) { %>
<p class="force-overleaf-style" style="margin: 0 0 10px 0; padding: 0;">
<p class="force-verso-style" style="margin: 0 0 10px 0; padding: 0;">
<%= paragraph %>
</p>
<% }) %>
@@ -52,7 +52,7 @@ export default _.template(`\
<p style="margin: 0; padding: 0;">&#xA0;</p>
<% (secondaryMessage).forEach(function(paragraph) { %>
<p class="force-overleaf-style">
<p class="force-verso-style">
<%= paragraph %>
</p>
<% }) %>
@@ -60,11 +60,11 @@ export default _.template(`\
<p style="margin: 0; padding: 0;">&#xA0;</p>
<p class="force-overleaf-style" style="font-size: 12px;">
<p class="force-verso-style" style="font-size: 12px;">
If the button above does not appear, please copy and paste this link into your browser's address bar:
</p>
<p class="force-overleaf-style" style="font-size: 12px;">
<p class="force-verso-style" style="font-size: 12px;">
<%= ctaURL %>
</p>
</td>
@@ -15,7 +15,7 @@ export default _.template(`\
<% } %>
<% (message).forEach(function(paragraph) { %>
<p class="force-overleaf-style" style="margin: 0 0 16px 0;">
<p class="force-verso-style" style="margin: 0 0 16px 0;">
<%= paragraph %>
</p>
<% }) %>
@@ -32,7 +32,7 @@ export default _.template(`\
<% if (secondaryMessage && secondaryMessage.length > 0) { %>
<% (secondaryMessage).forEach(function(paragraph) { %>
<p class="force-overleaf-style" style="margin: 0 0 16px 0;">
<p class="force-verso-style" style="margin: 0 0 16px 0;">
<%= paragraph %>
</p>
<% }) %>
@@ -276,7 +276,7 @@ templates.confirmCode = NoCTAEmailTemplate({
return ''
},
subject(opts) {
return `Confirm your email address on Overleaf (${opts.confirmCode})`
return `Confirm your email address on ${settings.appName} (${opts.confirmCode})`
},
title(opts) {
return 'Confirm your email address'
@@ -284,7 +284,7 @@ templates.confirmCode = NoCTAEmailTemplate({
message(opts, isPlainText) {
const msg = opts.welcomeUser
? [
`Welcome to Overleaf! We're so glad you joined us.`,
`Welcome to ${settings.appName}! We're so glad you joined us.`,
'Use this 6-digit confirmation code to finish your setup.',
]
: ['Use this 6-digit code to confirm your email address.']
@@ -477,7 +477,7 @@ templates.inviteNewUserToJoinManagedUsers = ctaTemplate({
templates.groupSSOLinkingInvite = ctaTemplate({
subject(opts) {
const subjectPrefix = opts.reminder ? 'Reminder: ' : 'Action required: '
return `${subjectPrefix}Authenticate your Overleaf account`
return `${subjectPrefix}Authenticate your ${settings.appName} account`
},
title(opts) {
const titlePrefix = opts.reminder ? 'Reminder: ' : ''
@@ -495,8 +495,8 @@ templates.groupSSOLinkingInvite = ctaTemplate({
</div>
</br>
<div>
You won't need to remember a separate email address and password to sign in to Overleaf.
All you need to do is authenticate your existing Overleaf account with your SSO provider.
You won't need to remember a separate email address and password to sign in to ${settings.appName}.
All you need to do is authenticate your existing ${settings.appName} account with your SSO provider.
</div>
`,
]
@@ -517,7 +517,7 @@ templates.groupSSOLinkingInvite = ctaTemplate({
templates.groupSSOReauthenticate = ctaTemplate({
subject(opts) {
return 'Action required: Reauthenticate your Overleaf account'
return `Action required: Reauthenticate your ${settings.appName} account`
},
title(opts) {
return 'Action required: Reauthenticate SSO'
@@ -526,8 +526,8 @@ templates.groupSSOReauthenticate = ctaTemplate({
return [
`Hi,
<div>
Single sign-on for your Overleaf group has been updated.
This means you need to reauthenticate your Overleaf account with your groups SSO provider.
Single sign-on for your ${settings.appName} group has been updated.
This means you need to reauthenticate your ${settings.appName} account with your groups SSO provider.
</div>
`,
]
@@ -538,7 +538,7 @@ templates.groupSSOReauthenticate = ctaTemplate({
} else {
const passwordResetUrl = `${settings.siteUrl}/user/password/reset`
return [
`If youre not currently logged in to Overleaf, you'll need to <a href="${passwordResetUrl}">set a new password</a> to reauthenticate.`,
`If youre not currently logged in to ${settings.appName}, youll need to <a href="${passwordResetUrl}">set a new password</a> to reauthenticate.`,
]
}
},
@@ -556,9 +556,9 @@ templates.groupSSOReauthenticate = ctaTemplate({
templates.groupSSODisabled = ctaTemplate({
subject(opts) {
if (opts.userIsManaged) {
return `Action required: Set your Overleaf password`
return `Action required: Set your ${settings.appName} password`
} else {
return 'A change to your Overleaf login options'
return `A change to your ${settings.appName} login options`
}
},
title(opts) {
@@ -567,12 +567,12 @@ templates.groupSSODisabled = ctaTemplate({
message(opts, isPlainText) {
const loginUrl = `${settings.siteUrl}/login`
let whatDoesThisMeanExplanation = [
`You can still log in to Overleaf using one of our other <a href="${loginUrl}" style="color: #0F7A06; text-decoration: none;">login options</a> or with your email address and password.`,
`You can still log in to ${settings.appName} using one of our other <a href="${loginUrl}" style="color: #1d7a6e; text-decoration: none;">login options</a> or with your email address and password.`,
`If you don't have a password, you can set one now.`,
]
if (opts.userIsManaged) {
whatDoesThisMeanExplanation = [
'You now need an email address and password to sign in to your Overleaf account.',
`You now need an email address and password to sign in to your ${settings.appName} account.`,
]
}
@@ -626,7 +626,7 @@ templates.surrenderAccountForManagedUsers = ctaTemplate({
const managedUsersLink = EmailMessageHelper.displayLink(
'user account management',
`${settings.siteUrl}/learn/how-to/Understanding_Managed_Overleaf_Accounts`,
`${settings.siteUrl}/learn/how-to/Understanding_Managed_Accounts`,
isPlainText
)
@@ -757,22 +757,17 @@ templates.userOnboardingEmail = NoCTAEmailTemplate({
message(opts, isPlainText) {
const learnLatexLink = EmailMessageHelper.displayLink(
'Learn LaTeX in 30 minutes',
`${settings.siteUrl}/learn/latex/Learn_LaTeX_in_30_minutes?utm_source=overleaf&utm_medium=email&utm_campaign=onboarding`,
`${settings.siteUrl}/learn/latex/Learn_LaTeX_in_30_minutes?utm_source=verso&utm_medium=email&utm_campaign=onboarding`,
isPlainText
)
const templatesLinks = EmailMessageHelper.displayLink(
'Find a beautiful template',
`${settings.siteUrl}/latex/templates?utm_source=overleaf&utm_medium=email&utm_campaign=onboarding`,
`${settings.siteUrl}/latex/templates?utm_source=verso&utm_medium=email&utm_campaign=onboarding`,
isPlainText
)
const collaboratorsLink = EmailMessageHelper.displayLink(
'Work with your collaborators',
`${settings.siteUrl}/learn/how-to/Sharing_a_project?utm_source=overleaf&utm_medium=email&utm_campaign=onboarding`,
isPlainText
)
const siteLink = EmailMessageHelper.displayLink(
'www.overleaf.com',
settings.siteUrl,
`${settings.siteUrl}/learn/how-to/Sharing_a_project?utm_source=verso&utm_medium=email&utm_campaign=onboarding`,
isPlainText
)
const userSettingsLink = EmailMessageHelper.displayLink(
@@ -780,28 +775,21 @@ templates.userOnboardingEmail = NoCTAEmailTemplate({
`${settings.siteUrl}/user/email-preferences`,
isPlainText
)
const onboardingSurveyLink = EmailMessageHelper.displayLink(
'Join our user feedback program',
'https://forms.gle/DB7pdk2B1VFQqVVB9',
isPlainText
)
return [
`Thanks for signing up for ${settings.appName} recently. We hope you've been finding it useful! Here are some key features to help you get the most out of the service:`,
`${learnLatexLink}: In this tutorial we provide a quick and easy first introduction to LaTeX with no prior knowledge required. By the time you are finished, you will have written your first LaTeX document!`,
`${templatesLinks}: If you're looking for a template or example to get started, we've a large selection available in our template gallery, including CVs, project reports, journal articles and more.`,
`${collaboratorsLink}: One of the key features of Overleaf is the ability to share projects and collaborate on them with other users. Find out how to share your projects with your colleagues in this quick how-to guide.`,
`${onboardingSurveyLink} to help us make Overleaf even better!`,
'Thanks again for using Overleaf :)',
`Lee`,
`Lee Shalit<br />CEO<br />${siteLink}<hr>`,
`You're receiving this email because you've recently signed up for an Overleaf account. If you've previously subscribed to emails about product offers and company news and events, you can unsubscribe ${userSettingsLink}.`,
`${collaboratorsLink}: One of the key features of ${settings.appName} is the ability to share projects and collaborate on them with other users. Find out how to share your projects with your colleagues in this quick how-to guide.`,
`Thanks for using ${settings.appName}!`,
`The ${settings.appName} Team<hr>`,
`You're receiving this email because you've recently signed up for a ${settings.appName} account. If you've previously subscribed to emails about product offers and company news and events, you can unsubscribe ${userSettingsLink}.`,
]
},
})
templates.securityAlert = NoCTAEmailTemplate({
subject(opts) {
return `Overleaf security note: ${opts.action}`
return `${settings.appName} security note: ${opts.action}`
},
title(opts) {
return opts.action.charAt(0).toUpperCase() + opts.action.slice(1)
@@ -837,12 +825,11 @@ templates.securityAlert = NoCTAEmailTemplate({
},
})
const GIT_TOKEN_DOCS_URL =
'https://docs.overleaf.com/integrations-and-add-ons/git-integration-and-github-synchronization/git-integration/git-integration-authentication-tokens#how-to-generate-authentication-tokens'
const GIT_TOKEN_DOCS_URL = `${settings.siteUrl}/learn/how-to/Git_integration`
templates.gitTokenExpiringSoon = NoCTAEmailTemplate({
subject() {
return 'Your Overleaf token is about to expire'
return `Your ${settings.appName} token is about to expire`
},
title() {
return 'Your token is about to expire'
@@ -866,14 +853,14 @@ templates.gitTokenExpiringSoon = NoCTAEmailTemplate({
`If you haven't already, you'll need to generate a new token in your ${settingsLink}.`,
`Take a look at ${docsLink} if you need more help.`,
'All the best,',
'Team Overleaf',
`The ${settings.appName} Team`,
]
},
})
templates.gitTokenExpired = NoCTAEmailTemplate({
subject() {
return 'Your Overleaf token has expired'
return `Your ${settings.appName} token has expired`
},
title() {
return 'Token expired'
@@ -897,7 +884,7 @@ templates.gitTokenExpired = NoCTAEmailTemplate({
`If you haven't already, you'll need to generate a new token in your ${settingsLink}.`,
`Take a look at ${docsLink} if you need more help.`,
'All the best,',
'Team Overleaf',
`The ${settings.appName} Team`,
]
},
})
@@ -1040,7 +1027,7 @@ templates.removeGroupMember = NoCTAEmailTemplate({
templates.taxExemptCertificateRequired = NoCTAEmailTemplate({
subject(opts) {
return `Action required: Tax exemption verification for Overleaf [${opts.ein}]`
return `Action required: Tax exemption verification for ${settings.appName} [${opts.ein}]`
},
title() {
return 'Action required: Tax exemption verification'
@@ -1060,7 +1047,7 @@ templates.taxExemptCertificateRequired = NoCTAEmailTemplate({
'If you have any questions, let us know by replying to this email.',
'<br/>',
'Best wishes,',
'Team Overleaf',
`The ${settings.appName} Team`,
'<br/>',
`Our reference: ${opts.stripeCustomerId}`,
]
@@ -1069,17 +1056,17 @@ templates.taxExemptCertificateRequired = NoCTAEmailTemplate({
templates.groupMemberLimitWarning = ctaTemplate({
subject(opts) {
return `Action needed: Your Overleaf group is nearly out of licenses`
return `Action needed: Your ${settings.appName} group is nearly out of licenses`
},
title(opts) {
return `Action needed: Your Overleaf group is nearly out of licenses`
return `Action needed: Your ${settings.appName} group is nearly out of licenses`
},
greeting(opts) {
return opts.firstName ? `Hi ${opts.firstName},` : 'Hi there,'
},
message(opts) {
return [
`Your Overleaf group <b>${opts.groupName}</b> is close to its license limit.`,
`Your ${settings.appName} group <b>${opts.groupName}</b> is close to its license limit.`,
`<b>${opts.currentMembers} of ${opts.membersLimit} licenses are in use (${opts.remainingSeats} remaining).</b>`,
'Because domain capture is enabled, users from your domain can join automatically via SSO.' +
'<br/>' +
@@ -60,8 +60,8 @@ export default _.template(`\
.email-layout-table { border-collapse: collapse !important; }
a[x-apple-data-detectors] { color: inherit !important; text-decoration: none !important; }
.force-overleaf-style a,
.force-overleaf-style a[href] {
.force-verso-style a,
.force-verso-style a[href] {
color: ${colors.linkGreen} !important;
text-decoration: underline !important;
-moz-hyphens: none;
@@ -69,10 +69,10 @@ export default _.template(`\
-webkit-hyphens: none;
hyphens: none;
}
.force-overleaf-style a:visited,
.force-overleaf-style a[href]:visited { color: ${colors.linkGreen}; }
.force-overleaf-style a:hover,
.force-overleaf-style a[href]:hover { color: ${colors.linkHover}; }
.force-verso-style a:visited,
.force-verso-style a[href]:visited { color: ${colors.linkGreen}; }
.force-verso-style a:hover,
.force-verso-style a[href]:hover { color: ${colors.linkHover}; }
@media only screen and (min-width: 621px) {
.email-card-inner { padding: 56px !important; }
@@ -149,7 +149,7 @@ export default _.template(`\
<% if (footerMessage) { %>
<tr>
<td align="center" class="force-overleaf-style" style="padding: 0 0 32px 0; font-family: ${fontFamily}; font-size: 14px; color: ${colors.textMuted}; line-height: 20px;">
<td align="center" class="force-verso-style" style="padding: 0 0 32px 0; font-family: ${fontFamily}; font-size: 14px; color: ${colors.textMuted}; line-height: 20px;">
<%= footerMessage %>
</td>
</tr>
@@ -4,9 +4,9 @@ export const colors = {
textDark: '#1b222c',
textMuted: '#495365',
background: '#f4f5f6',
ctaGreen: '#098842',
ctaGreen: '#2a9d8f',
ctaText: '#ffffff',
linkGreen: '#1e6b41',
linkHover: '#155a30',
logoGreen: '#04652f',
linkGreen: '#1d7a6e',
linkHover: '#155e55',
logoGreen: '#2a9d8f',
}
@@ -1364,6 +1364,7 @@ const _ProjectController = {
function getInitialLoadingScreenTheme(overallThemeSetting) {
switch (overallThemeSetting) {
case 'light-':
case 'lumiere-':
return 'light'
case '':
return 'dark'
@@ -51,24 +51,20 @@ async function setRootDocAutomatically(projectId) {
}
async function findRootDocFileFromDirectory(directoryPath) {
const unsortedFiles = await globby(['**/*.{tex,Rtex,Rnw}'], {
// First try LaTeX files (look for \documentclass)
const unsortedTexFiles = await globby(['**/*.{tex,Rtex,Rnw,ltx}'], {
cwd: directoryPath,
followSymlinkedDirectories: false,
onlyFiles: true,
case: false,
})
// the search order is such that we prefer files closer to the project root, then
// we go by file size in ascending order, because people often have a main
// file that just includes a bunch of other files; then we go by name, in
// order to be deterministic
const files = await _sortFileList(unsortedFiles, directoryPath)
let firstFileInRootFolder
const texFiles = await _sortFileList(unsortedTexFiles, directoryPath)
let firstTexInRootFolder
let doc = null
while (files.length > 0 && doc == null) {
const file = files.shift()
while (texFiles.length > 0 && doc == null) {
const file = texFiles.shift()
const content = await fs.promises.readFile(
Path.join(directoryPath, file),
'utf8'
@@ -77,18 +73,52 @@ async function findRootDocFileFromDirectory(directoryPath) {
if (DocumentHelper.contentHasDocumentclass(normalizedContent)) {
doc = { path: file, content: normalizedContent }
}
if (!firstFileInRootFolder && !file.includes('/')) {
firstFileInRootFolder = { path: file, content: normalizedContent }
if (!firstTexInRootFolder && !file.includes('/')) {
firstTexInRootFolder = { path: file, content: normalizedContent }
}
}
// if no doc was found, use the first file in the root folder as the main doc
if (!doc && firstFileInRootFolder) {
doc = firstFileInRootFolder
if (!doc && firstTexInRootFolder) {
doc = firstTexInRootFolder
}
return { path: doc?.path, content: doc?.content }
if (doc) return { path: doc.path, content: doc.content }
// Then try Typst files
const typFiles = await globby(['*.typ'], {
cwd: directoryPath,
followSymlinkedDirectories: false,
onlyFiles: true,
case: false,
})
if (typFiles.length > 0) {
typFiles.sort()
const file = typFiles[0]
const content = await fs.promises.readFile(
Path.join(directoryPath, file),
'utf8'
)
return { path: file, content }
}
// Then try Quarto/R Markdown files
const qmdFiles = await globby(['*.{qmd,Rmd,rmd}'], {
cwd: directoryPath,
followSymlinkedDirectories: false,
onlyFiles: true,
case: false,
})
if (qmdFiles.length > 0) {
qmdFiles.sort()
const file = qmdFiles[0]
const content = await fs.promises.readFile(
Path.join(directoryPath, file),
'utf8'
)
return { path: file, content }
}
return { path: undefined, content: undefined }
}
async function setRootDocFromName(projectId, rootDocName) {
@@ -1,4 +1,5 @@
const SYSTEM_THEME_USER_CUTOFF_DATE = new Date(Date.UTC(2026, 2, 2, 12, 0, 0)) // 12pm GMT on March 2, 2026
const LUMIERE_THEME_USER_CUTOFF_DATE = new Date(Date.UTC(2026, 5, 11, 12, 0, 0)) // 12pm GMT on June 11, 2026
function getOverallTheme(user) {
if (user.ace.overallTheme != null) {
@@ -10,7 +11,11 @@ function getOverallTheme(user) {
return ''
}
return 'system'
if (user.signUpDate < LUMIERE_THEME_USER_CUTOFF_DATE) {
return 'system'
}
return 'lumiere-'
}
async function buildUserSettings(_req, _res, user) {
@@ -41,4 +46,5 @@ async function buildUserSettings(_req, _res, user) {
export default {
buildUserSettings,
getOverallTheme,
}
@@ -24,6 +24,15 @@ import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
const defaultsDeep = lodash.defaultsDeep
// Send a JSON response compatible with both normal mode and streaming mode
// (where startStreamingResponse already sent HTTP 200 + chunked headers).
function sendUploadResponse(res, statusCode, body) {
if (res.headersSent) {
return res.end(JSON.stringify(body))
}
return res.status(statusCode).json(body)
}
const upload = multer(
defaultsDeep(
{
@@ -92,7 +101,7 @@ async function uploadFile(req, res, next) {
await fsPromises.unlink(path).catch(unlinkErr => {
logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file')
})
return res.status(422).json({
return sendUploadResponse(res, 422, {
success: false,
error: 'invalid_filename',
})
@@ -119,7 +128,7 @@ async function uploadFile(req, res, next) {
await fsPromises.unlink(path).catch(unlinkErr => {
logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file')
})
throw error
return sendUploadResponse(res, 500, { success: false })
}
return FileSystemImportManager.addEntity(
@@ -134,22 +143,22 @@ async function uploadFile(req, res, next) {
timer.done()
if (error != null) {
if (error.name === 'InvalidNameError') {
return res.status(422).json({
return sendUploadResponse(res, 422, {
success: false,
error: 'invalid_filename',
})
} else if (error instanceof DuplicateNameError) {
return res.status(422).json({
return sendUploadResponse(res, 422, {
success: false,
error: 'duplicate_file_name',
})
} else if (error.message === 'project_has_too_many_files') {
return res.status(422).json({
return sendUploadResponse(res, 422, {
success: false,
error: 'project_has_too_many_files',
})
} else if (error.message === 'folder_not_found') {
return res.status(422).json({
return sendUploadResponse(res, 422, {
success: false,
error: 'folder_not_found',
})
@@ -164,10 +173,10 @@ async function uploadFile(req, res, next) {
},
'error uploading file'
)
return res.status(422).json({ success: false })
return sendUploadResponse(res, 422, { success: false })
}
} else {
return res.json({
return sendUploadResponse(res, 200, {
success: true,
entity_id: entity?._id,
entity_type: entity?.type,
@@ -187,7 +196,7 @@ async function importDocument(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
const { path } = req.file
const conversionType = req.query.type
if (!['docx', 'markdown'].includes(conversionType)) {
if (!['docx', 'markdown', 'typst'].includes(conversionType)) {
return res.status(400).json({
success: false,
error: req.i18n.translate('invalid_import_type'),
@@ -273,25 +282,28 @@ async function importDocument(req, res, next) {
*/
function multerMiddleware(req, res, next) {
if (upload == null) {
return res
.status(500)
.json({ success: false, error: req.i18n.translate('upload_failed') })
return sendUploadResponse(res, 500, {
success: false,
error: req.i18n.translate('upload_failed'),
})
}
return upload.single('qqfile')(
req,
res,
/** @param {any} err */ function (err) {
if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') {
return res
.status(422)
.json({ success: false, error: req.i18n.translate('file_too_large') })
return sendUploadResponse(res, 422, {
success: false,
error: req.i18n.translate('file_too_large'),
})
}
if (err) return next(err)
if (!req.file?.path) {
logger.info({ req }, 'missing req.file.path on upload')
return res
.status(400)
.json({ success: false, error: 'invalid_upload_request' })
return sendUploadResponse(res, 400, {
success: false,
error: 'invalid_upload_request',
})
}
next()
}
@@ -14,10 +14,21 @@ import ProjectRootDocManager from '../Project/ProjectRootDocManager.mjs'
import ProjectDetailsHandler from '../Project/ProjectDetailsHandler.mjs'
import ProjectDeleter from '../Project/ProjectDeleter.mjs'
import { DeletedProjectReasons } from '../Project/DeletedProjectReasons.mjs'
import ProjectOptionsHandler from '../Project/ProjectOptionsHandler.mjs'
import TpdsProjectFlusher from '../ThirdPartyDataStore/TpdsProjectFlusher.mjs'
import logger from '@overleaf/logger'
import OError from '@overleaf/o-error'
const COMPILER_BY_EXTENSION = {
tex: 'pdflatex',
rtex: 'pdflatex',
ltx: 'pdflatex',
rnw: 'pdflatex',
typ: 'typst',
qmd: 'quarto',
rmd: 'quarto',
}
export default {
createProjectFromZipArchive: callbackify(createProjectFromZipArchive),
createProjectFromZipArchiveWithName: callbackify(
@@ -52,6 +63,11 @@ async function createProjectFromZipArchive(ownerId, defaultName, zipPath) {
project._id,
path
)
const ext = path.split('.').pop()?.toLowerCase()
const compiler = ext ? COMPILER_BY_EXTENSION[ext] : null
if (compiler) {
await ProjectOptionsHandler.promises.setCompiler(project._id, compiler)
}
}
} catch (err) {
// no need to wait for the cleanup here
@@ -6,6 +6,27 @@ import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs'
import Settings from '@overleaf/settings'
import AsyncLocalStorage from '../../infrastructure/AsyncLocalStorage.mjs'
// Sends HTTP 200 + first chunk immediately so upstream proxies (Traefik, cloud
// LBs) don't timeout waiting for the first response byte during large uploads.
// Must come *after* auth/rate-limit middleware (those still return proper codes)
// but *before* multer so it fires while the request body is still streaming in.
// The upload handler ends the response with the actual JSON result as the final
// chunk; the client's getResponseData trims leading whitespace before parsing.
function startStreamingResponse(req, res, next) {
res.writeHead(200, {
'Content-Type': 'application/json',
'Transfer-Encoding': 'chunked',
'X-Accel-Buffering': 'no',
})
res.write('\n')
const heartbeat = setInterval(() => {
if (!res.writableEnded) res.write('\n')
}, 30000)
res.on('finish', () => clearInterval(heartbeat))
res.on('close', () => clearInterval(heartbeat))
next()
}
const rateLimiters = {
projectUpload: new RateLimiter('project-upload', {
points: 20,
@@ -62,6 +83,7 @@ export default {
fileUploadRateLimit,
AsyncLocalStorage.middleware,
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
startStreamingResponse,
ProjectUploadController.multerMiddleware,
ProjectUploadController.uploadFile
)
@@ -72,6 +94,7 @@ export default {
AuthenticationController.requireLogin(),
AsyncLocalStorage.middleware,
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
startStreamingResponse,
ProjectUploadController.multerMiddleware,
ProjectUploadController.uploadFile
)
@@ -1,3 +1,5 @@
import Settings from '@overleaf/settings'
import Translations from '../../infrastructure/Translations.mjs'
import UserHandler from './UserHandler.mjs'
import UserDeleter from './UserDeleter.mjs'
import UserGetter from './UserGetter.mjs'
@@ -562,6 +564,30 @@ async function expireDeletedUsersAfterDuration(req, res, next) {
res.sendStatus(204)
}
async function setLanguage(req, res) {
const lngCode = req.body.lngCode ?? req.query.lng
if (!Translations.availableLanguageCodes.includes(lngCode)) {
return res.status(400).end()
}
res.cookie(Translations.LANG_COOKIE_NAME, lngCode, {
maxAge: 365 * 24 * 60 * 60 * 1000,
httpOnly: true,
secure: Settings.secureCookie,
sameSite: 'Lax',
domain: Settings.cookieDomain,
})
const userId = SessionManager.getLoggedInUserId(req.session)
if (userId) {
await User.findByIdAndUpdate(userId, { languageCode: lngCode }).exec()
}
const returnTo = req.query.return_to
const redir =
typeof returnTo === 'string' && returnTo.startsWith('/')
? returnTo
: req.get('Referer') || '/project'
res.redirect(302, redir)
}
export default {
clearSessions: expressify(clearSessions),
changePassword: expressify(changePassword),
@@ -574,4 +600,5 @@ export default {
expireDeletedUsersAfterDuration: expressify(expireDeletedUsersAfterDuration),
ensureAffiliationMiddleware: expressify(ensureAffiliationMiddleware),
ensureAffiliation,
setLanguage: expressify(setLanguage),
}
@@ -8,6 +8,8 @@ import { fetchJson } from '@overleaf/fetch-utils'
import contentDisposition from 'content-disposition'
import Features from './Features.mjs'
import SessionManager from '../Features/Authentication/SessionManager.mjs'
import UserGetter from '../Features/User/UserGetter.mjs'
import UserSettingsHelper from '../Features/Project/UserSettingsHelper.mjs'
import PackageVersions from './PackageVersions.js'
import Modules from './Modules.mjs'
import Errors from '../Features/Errors/Errors.js'
@@ -15,6 +17,7 @@ import AdminAuthorizationHelper from '../Features/Helpers/AdminAuthorizationHelp
import { addOptionalCleanupHandlerAfterDrainingConnections } from './GracefulShutdown.mjs'
import { sanitizeSessionUserForFrontEnd } from './FrontEndUser.mjs'
import { expressify } from '@overleaf/promise-utils'
import Translations from './Translations.mjs'
const {
canRedirectToAdminDomain,
@@ -269,6 +272,28 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
next()
})
webRouter.use(
expressify(async function (req, res, next) {
res.locals.isLumiere = false
const sessionUser = SessionManager.getSessionUser(req.session)
if (sessionUser?._id) {
try {
const user = await UserGetter.promises.getUser(sessionUser._id, {
'ace.overallTheme': 1,
signUpDate: 1,
})
if (user) {
res.locals.isLumiere =
UserSettingsHelper.getOverallTheme(user) === 'lumiere-'
}
} catch (err) {
logger.warn({ err }, 'failed to fetch theme for isLumiere')
}
}
next()
})
)
webRouter.use(function (req, res, next) {
res.locals.getLoggedInUserId = () =>
SessionManager.getLoggedInUserId(req.session)
@@ -307,11 +332,15 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
webRouter.use(function (req, res, next) {
res.locals.overallThemes = [
{
name: 'Dark',
name: 'Verso Lumière',
val: 'lumiere-',
},
{
name: 'Classic Dark',
val: '',
},
{
name: 'Light',
name: 'Classic Light',
val: 'light-',
},
{
@@ -324,6 +353,7 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
webRouter.use(function (req, res, next) {
res.locals.settings = Settings
res.locals.availableLanguages = Translations.availableLanguageCodes
next()
})
@@ -348,6 +348,7 @@ if (Settings.csp && Settings.csp.enabled) {
logger.debug('creating HTTP server'.yellow)
const server = http.createServer(app)
server.requestTimeout = 15 * 60 * 1000
// provide settings for separate web and api processes
if (Settings.enabledServices.includes('api')) {
@@ -44,6 +44,8 @@ const locales = {
'zh-CN': zhCN,
}
const LANG_COOKIE_NAME = 'verso-lang'
const fallbackLanguageCode = Settings.i18n.defaultLng || 'en'
const availableLanguageCodes = []
const availableHosts = new Map()
@@ -62,15 +64,20 @@ Object.values(Settings.i18n.subdomainLang || {}).forEach(function (spec) {
subdomainConfigs.set(spec.lngCode, spec)
}
})
if (!availableLanguageCodes.includes(fallbackLanguageCode)) {
// always load the fallback locale
availableLanguageCodes.push(fallbackLanguageCode)
// Make all bundled locale files available regardless of subdomain config.
// This allows the cookie-based language picker to offer every loaded locale
// even when no subdomains are configured.
for (const lngCode of Object.keys(locales)) {
if (!availableLanguageCodes.includes(lngCode)) {
availableLanguageCodes.push(lngCode)
}
}
const resources = Object.fromEntries(
Object.entries(locales)
.filter(([lngCode]) => availableLanguageCodes.includes(lngCode))
.map(([lngCode, translations]) => [lngCode, { translation: translations }])
Object.entries(locales).map(([lngCode, translations]) => [
lngCode,
{ translation: translations },
])
)
i18n
@@ -107,8 +114,13 @@ i18n
})
function setLangBasedOnDomainMiddleware(req, res, next) {
// Determine language from subdomain
const lang = availableHosts.get(req.headers.host) ?? fallbackLanguageCode
// Priority: (1) user-set cookie, (2) subdomain, (3) env-var default
const cookieLng = req.cookies[LANG_COOKIE_NAME]
const hostLng = availableHosts.get(req.headers.host)
const lang =
(availableLanguageCodes.includes(cookieLng) ? cookieLng : null) ??
hostLng ??
fallbackLanguageCode
req.i18n = {
language: lang,
@@ -173,4 +185,6 @@ i18n.translate = i18n.t
export default {
setLangBasedOnDomainMiddleware,
i18n,
LANG_COOKIE_NAME,
availableLanguageCodes,
}
+1
View File
@@ -52,6 +52,7 @@ export const UserSchema = new Schema(
},
role: { type: String, default: '' },
institution: { type: String, default: '' },
languageCode: { type: String, default: null },
hashedPassword: String,
enrollment: {
sso: [
+23 -3
View File
@@ -284,6 +284,14 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
'/read-only/one-time-login'
)
// Language preference — works for both anonymous (GET) and logged-in (POST)
webRouter.get('/set-language', UserController.setLanguage)
webRouter.post(
'/user/language',
AuthenticationController.requireLogin(),
UserController.setLanguage
)
webRouter.get('/logout', UserPagesController.logout)
webRouter.post('/logout', UserController.logout)
@@ -611,6 +619,12 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
CompileController.stopCompile
)
webRouter.get(
'/project/:Project_id/thumbnail',
AuthorizationMiddleware.ensureUserCanReadProject,
CompileController.getProjectThumbnail
)
webRouter.get(
'/project/:Project_id/output/cached/output.overleaf.json',
AsyncLocalStorage.middleware,
@@ -681,17 +695,17 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
)
webRouter.post(
'/project/:Project_id/publish-presentation',
AuthorizationMiddleware.ensureUserCanReadProject,
AuthorizationMiddleware.ensureUserCanAdminProject,
PublishedPresentationController.publish
)
webRouter.post(
'/project/:Project_id/publish-presentation/regenerate',
AuthorizationMiddleware.ensureUserCanReadProject,
AuthorizationMiddleware.ensureUserCanAdminProject,
PublishedPresentationController.regenerate
)
webRouter.delete(
'/project/:Project_id/publish-presentation',
AuthorizationMiddleware.ensureUserCanReadProject,
AuthorizationMiddleware.ensureUserCanAdminProject,
PublishedPresentationController.unpublish
)
// On-demand export of a RevealJS deck (download menu): html | pdf.
@@ -822,6 +836,12 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
AuthorizationMiddleware.ensureUserCanReadProject,
ProjectDownloadsController.downloadPreparedProjectExport
)
webRouter.post(
'/project/:Project_id/doc/:Doc_id/convert/:type',
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
ProjectDownloadsController.convertDocInProject
)
}
webRouter.get(
+12 -9
View File
@@ -42,9 +42,9 @@ else if settings.overleaf
meta(itemprop='image' content=buildImgPath('ol-brand/overleaf_og_logo.png'))
meta(name='image' content=buildImgPath('ol-brand/overleaf_og_logo.png'))
else
//- the default image for Overleaf Community Edition/Server Pro
meta(itemprop='image' content=buildBaseAssetPath() + 'apple-touch-icon.png')
meta(name='image' content=buildBaseAssetPath() + 'apple-touch-icon.png')
//- the default image for Verso Community Edition
meta(itemprop='image' content=buildBaseAssetPath() + 'og-image.png')
meta(name='image' content=buildBaseAssetPath() + 'og-image.png')
//- Keywords
if metadata && metadata.keywords
@@ -78,14 +78,17 @@ else if settings.overleaf
content=buildImgPath('ol-brand/overleaf_og_logo.png')
)
else
//- the default image for Overleaf Community Edition/Server Pro
//- the default image for Verso Community Edition
meta(
name='twitter:image'
content=buildBaseAssetPath() + 'apple-touch-icon.png'
content=buildBaseAssetPath() + 'og-image.png'
)
//- Open Graph
//- to do - add og:url
if metadata && metadata.canonicalURL
meta(property='og:url' content=metadata.canonicalURL)
else if typeof currentUrl !== 'undefined'
meta(property='og:url' content=settings.siteUrl + currentUrl)
if settings.social && settings.social.facebook && settings.social.facebook.appId
meta(property='fb:app_id' content=settings.social.facebook.appId)
@@ -104,14 +107,14 @@ else if settings.overleaf
content=buildImgPath('ol-brand/overleaf_og_logo.png')
)
else
//- the default image for Overleaf Community Edition/Server Pro
//- the default image for Verso Community Edition
meta(
property='og:image'
content=buildBaseAssetPath() + 'apple-touch-icon.png'
content=buildBaseAssetPath() + 'og-image.png'
)
if metadata && metadata.openGraphType
meta(property='og:type' metadata.openGraphType)
meta(property='og:type' content=metadata.openGraphType)
else
meta(property='og:type' content='website')
+1
View File
@@ -106,6 +106,7 @@ html(
'red-nav-bar-for-admins': !settings.isDevEnv && hasFeature('saas') && hasAdminAccess(),
}
data-theme='light'
data-lumiere=isLumiere ? 'true' : 'false'
)
if settings.recaptcha && settings.recaptcha.siteKeyV3
script(
+1
View File
@@ -49,6 +49,7 @@ block append meta
showPoweredBy: !hasFeature('saas') && !settings.nav.hide_powered_by,
subdomainLang: settings.i18n.subdomainLang,
translatedLanguages: settings.translatedLanguages,
availableLanguages: availableLanguages,
leftItems: cloneAndTranslateText(settings.nav.left_footer),
rightItems: settings.nav.right_footer,
}
@@ -1,35 +1,43 @@
include ../_mixins/material_symbol
li.dropdown.dropup.subdued.language-picker(dropdown)
button#language-picker-toggle.btn.btn-link.btn-inline-link(
dropdown-toggle
data-ol-lang-selector-tooltip
data-bs-toggle='dropdown'
aria-haspopup='true'
aria-expanded='false'
aria-label='Select ' + translate('language')
tooltip=translate('language')
title=translate('language')
)
+material-symbol('translate')
| &nbsp;
span.language-picker-text #{settings.translatedLanguages[currentLngCode]}
ul.dropdown-menu.dropdown-menu-sm-width(
role='menu'
aria-labelledby='language-picker-toggle'
)
li.dropdown-header #{translate("language")}
each subdomainDetails, subdomain in settings.i18n.subdomainLang
if !subdomainDetails.hide
- let isActive = subdomainDetails.lngCode === currentLngCode
li.lng-option
a.menu-indent(
href=subdomainDetails.url + currentUrlWithQueryParams
role='menuitem'
class=['dropdown-item', {active: isActive}]
aria-selected=isActive ? 'true' : 'false'
)
| #{settings.translatedLanguages[subdomainDetails.lngCode]}
if subdomainDetails.lngCode === currentLngCode
+material-symbol('check', 'dropdown-item-trailing-icon')
li.language-picker
details.language-picker-details
summary#language-picker-toggle.btn-inline-link(
aria-label=translate('select_a_language')
translate='no'
)
span.material-symbols translate
| &nbsp;
span.language-picker-text= settings.translatedLanguages[currentLngCode] || currentLngCode
ul.dropdown-menu.dropdown-menu-sm-width(
role='menu'
aria-labelledby='language-picker-toggle'
translate='no'
)
each lngCode in availableLanguages
if settings.translatedLanguages[lngCode]
li(role='none')
a.dropdown-item(
role='menuitem'
href='/set-language?lng=' + encodeURIComponent(lngCode) + '&return_to=/'
data-lng=lngCode
class=lngCode === currentLngCode ? 'active' : ''
)= settings.translatedLanguages[lngCode]
script.
(function () {
var details = document.querySelector('.language-picker-details')
var menu = details && details.querySelector('.dropdown-menu')
if (!details || !menu) return
menu.querySelectorAll('a[data-lng]').forEach(function (a) {
var lng = a.getAttribute('data-lng')
a.href =
'/set-language?lng=' +
encodeURIComponent(lng) +
'&return_to=' +
encodeURIComponent(window.location.pathname)
})
document.addEventListener('click', function (e) {
if (!details.contains(e.target)) details.open = false
})
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') details.open = false
})
})()
@@ -1,5 +1,5 @@
footer.site-footer
- var showLanguagePicker = Object.keys(settings.i18n.subdomainLang).length > 1
- var showLanguagePicker = availableLanguages.length > 1
- var hasCustomLeftNav = nav.left_footer && nav.left_footer.length > 0
.site-footer-content.hidden-print
.row
@@ -8,22 +8,22 @@ footer.site-footer
| © #{new Date().getFullYear()}
|
a(href='https://alocoq.fr' target='_blank' rel='noopener noreferrer') Aloïs Coquillard
li
li.footer-sep
strong.text-muted |
li
| Built on
| !{translate('built_on')}
|
a(href='https://github.com/overleaf/overleaf' target='_blank' rel='noopener noreferrer') Overleaf
if showLanguagePicker || hasCustomLeftNav
li
li.footer-sep
strong.text-muted |
if showLanguagePicker
include language-picker
if showLanguagePicker && hasCustomLeftNav
li
li.footer-sep
strong.text-muted |
each item in nav.left_footer
@@ -33,10 +33,10 @@ footer.site-footer
else
| !{item.text}
ul.site-footer-items.col-lg-3.text-end
ul.site-footer-items.col-lg-3.text-lg-end
li
a(href='https://git.alocoq.fr/alois/verso/src/branch/main/LICENSE' target='_blank' rel='noopener noreferrer') AGPL licence
li
li.footer-sep
strong.text-muted |
li
a(href='https://git.alocoq.fr/alois/verso' target='_blank' rel='noopener noreferrer') Source code
+4 -4
View File
@@ -5,18 +5,18 @@ block vars
- var suppressNavbar = true
block content
main#main-content.content
main#main-content.content.login-page
.container
.row
.col-12
.text-center.mb-4
.lumiere-logo-center.mb-4
img.verso-login-logo(
src=buildImgPath('ol-brand/verso-logo.svg')
alt='Verso'
style='width:100%;max-width:480px;height:auto'
style='width:100%;height:auto'
)
.row
.col-lg-6.offset-lg-3.col-xl-4.offset-xl-4
.login-lumiere-card.col-lg-6.offset-lg-3.col-xl-4.offset-xl-4
.page-header
if login_support_title
h1 !{login_support_title}
@@ -26,7 +26,7 @@ block content
main#main-content(data-ol-captcha-retry-trigger-area='')
a.auth-aux-logo(href='/')
img(src=buildImgPath('ol-brand/overleaf-o-dark.svg') alt=settings.appName)
img(src=buildImgPath('ol-brand/verso-square.svg') alt='Verso')
.auth-aux-container
form(
name='passwordResetForm'
@@ -6,9 +6,10 @@ block vars
block content
main#main-content
.auth-aux-container
img.w-50.d-block(
src=buildImgPath('ol-brand/overleaf.svg')
alt=settings.appName
img.d-block(
src=buildImgPath('ol-brand/verso-logo.svg')
alt='Verso'
style='height:36px;width:auto;margin-bottom:1.5rem'
)
h1.h3.mb-3 #{translate("keep_your_account_safe")}
div(data-ol-multi-submit)
+1 -1
View File
@@ -8,7 +8,7 @@ block vars
block content
main#main-content
a.auth-aux-logo(href='/')
img(src=buildImgPath('ol-brand/overleaf-o-dark.svg') alt=settings.appName)
img(src=buildImgPath('ol-brand/verso-square.svg') alt='Verso')
.auth-aux-container
form(
name='passwordResetForm'
@@ -77,8 +77,8 @@
"add_another_address_line": "",
"add_another_email": "",
"add_another_token": "",
"add_comma_separated_emails_help": "",
"add_collaborators": "",
"add_comma_separated_emails_help": "",
"add_comment": "",
"add_comment_error_message": "",
"add_comment_error_title": "",
@@ -117,6 +117,7 @@
"after_that_well_bill_you_x_total_y_subtotal_z_tax_annually_on_date_unless_you_cancel": "",
"aggregate_changed": "",
"aggregate_to": "",
"agpl_licence": "",
"agree": "",
"agree_with_the_terms": "",
"ai_assist_in_overleaf_is_included_via_writefull_groups": "",
@@ -228,6 +229,7 @@
"breadcrumbs": "",
"browser": "",
"build_collection_of_most_used_references": "",
"built_on": "",
"bullet_list": "",
"buy_licenses": "",
"buy_more_licenses": "",
@@ -385,6 +387,8 @@
"continue_using_free_features": "",
"continue_with_free_plan": "",
"conversion_error_details": "",
"convert_to_latex": "",
"convert_to_typst": "",
"cookie_banner": "",
"cookie_banner_info": "",
"copied": "",
@@ -471,6 +475,8 @@
"demonstrating_track_changes_feature": "",
"department": "",
"description": "",
"card_size": "",
"deselect_all": "",
"details": "",
"details_provided_by_google_explanation": "",
"dictionary": "",
@@ -665,7 +671,9 @@
"explore_plans": "",
"export_as_docx": "",
"export_as_html": "",
"export_as_latex": "",
"export_as_markdown": "",
"export_as_typst": "",
"export_csv": "",
"export_project_to_github": "",
"failed": "",
@@ -1010,6 +1018,7 @@
"institution_templates": "",
"integrations": "",
"integrations_like_github": "",
"interface_language": "",
"interested_in_cheaper_personal_plan": "",
"invalid_confirmation_code": "",
"invalid_email": "",
@@ -1093,6 +1102,7 @@
"last_verified": "",
"latam_discount_modal_info": "",
"latam_discount_modal_title": "",
"latex_export_feedback_message": "",
"latex_places_figures_according_to_a_special_algorithm": "",
"latex_places_tables_according_to_a_special_algorithm": "",
"layout_options": "",
@@ -1266,6 +1276,8 @@
"n_more_updates_above_plural": "",
"n_more_updates_below": "",
"n_more_updates_below_plural": "",
"n_projects_selected": "",
"n_projects_selected_plural": "",
"name": "",
"name_usage_explanation": "",
"navigation": "",
@@ -1466,8 +1478,6 @@
"please_ask_the_project_owner_to_upgrade_to_track_changes": "",
"please_change_primary_to_remove": "",
"please_compile_pdf_before_download": "",
"python_packages": "",
"python_packages_help": "",
"please_confirm_primary_email_or_edit": "",
"please_confirm_secondary_email_or_edit": "",
"please_confirm_your_email_before_making_it_default": "",
@@ -1502,14 +1512,14 @@
"premium_plan_label": "",
"preparing_for_export": "",
"preparing_your_download": "",
"present": "",
"present_publishes_and_opens_in_new_tab": "",
"presentation_export_can_take_a_moment": "",
"presentation_export_failed": "",
"presentation_link_members": "",
"presentation_link_private": "",
"presentation_link_public": "",
"presentation_mode": "",
"present": "",
"present_publishes_and_opens_in_new_tab": "",
"press_shift_space_for_suggestions": "",
"press_space_to_open_the_ai_assistant": "",
"preview": "",
@@ -1582,6 +1592,8 @@
"pull_github_changes_into_sharelatex": "",
"push_sharelatex_changes_to_github": "",
"push_to_github_pull_to_overleaf": "",
"python_packages": "",
"python_packages_help": "",
"quoted_text": "",
"raw_logs": "",
"raw_logs_description": "",
@@ -1906,6 +1918,7 @@
"sort_by": "",
"sort_by_x": "",
"sort_projects": "",
"source_code": "",
"speak": "",
"speech_input_not_available": "",
"spellcheck": "",
@@ -2214,6 +2227,7 @@
"tooltip_hide_pdf": "",
"tooltip_show_panel": "",
"tooltip_show_pdf": "",
"top_bottom_split_view": "",
"total_due_in_x_days": "",
"total_due_today": "",
"total_per_month": "",
@@ -2250,6 +2264,7 @@
"turn_off_link_sharing": "",
"turn_on": "",
"turn_on_link_sharing": "",
"typst_export_feedback_message": "",
"unarchive": "",
"uncategorized": "",
"uncategorized_projects": "",
@@ -173,6 +173,24 @@ export default function FileTreeUploadDoc() {
// limit: maxConnections || 1,
limit: 1,
fieldName: 'qqfile', // "qqfile" field inherited from FineUploader
// The server sends HTTP 200 + a keepalive '\n' byte immediately to
// prevent upstream proxy timeouts on slow connections, then streams
// the actual JSON result as the final chunk. Trim before parsing.
getResponseData: (responseText: string) => {
try {
return JSON.parse(responseText.trim())
} catch {
return {}
}
},
validateStatus: (statusCode: number, responseText: string) => {
if (statusCode < 200 || statusCode >= 300) return false
try {
return JSON.parse(responseText.trim()).success === true
} catch {
return false
}
},
})
// close the modal when all the uploads completed successfully
.on('complete', result => {
@@ -30,7 +30,8 @@ function FileTreeItemInner({
onClick?: () => void
}) {
const { fileTreeReadOnly } = useFileTreeData()
const { setContextMenuCoords } = useFileTreeMainContext()
const { setContextMenuCoords, setContextMenuEntityId } =
useFileTreeMainContext()
const { isRenaming } = useFileTreeActionable()
const { selectedEntityIds } = useFileTreeSelectable()
@@ -73,6 +74,7 @@ function FileTreeItemInner({
ev.preventDefault()
setContextMenuEntityId(id)
setContextMenuCoords({
top: ev.pageY,
left: ev.pageX,
@@ -2,12 +2,31 @@ import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import { useProjectContext } from '@/shared/context/project-context'
import { postJSON } from '@/infrastructure/fetch-json'
import type { ProjectCompiler } from '@ol-types/project-settings'
import {
DropdownDivider,
DropdownItem,
} from '@/shared/components/dropdown/dropdown-menu'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
import { useFileTreeSelectable } from '../../contexts/file-tree-selectable'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { useFileTreeMainContext } from '../../contexts/file-tree-main'
import { findInTree } from '../../util/find-in-tree'
import useConvertDoc from '@/features/ide-react/hooks/use-convert-doc'
import getMeta from '@/utils/meta'
import { isValidTeXFile } from '@/main/is-valid-tex-file'
const COMPILER_BY_EXT: Record<string, ProjectCompiler> = {
tex: 'pdflatex',
rtex: 'pdflatex',
ltx: 'pdflatex',
rnw: 'pdflatex',
typ: 'typst',
qmd: 'quarto',
rmd: 'quarto',
}
function FileTreeItemMenuItems() {
const { t } = useTranslation()
@@ -23,12 +42,62 @@ function FileTreeItemMenuItems() {
startUploadingDocOrFile,
downloadPath,
selectedFileName,
canSetRootDocId,
setRootDocId,
} = useFileTreeActionable()
const { project } = useProjectContext()
const { project, projectId, updateProject } = useProjectContext()
const projectOwner = project?.owner?._id
const rootDocId = project?.rootDocId
const { fileTreeData, fileTreeReadOnly } = useFileTreeData()
const { selectedEntityIds } = useFileTreeSelectable()
const { contextMenuEntityId } = useFileTreeMainContext()
const selectedEntityId =
selectedEntityIds.size === 1 ? Array.from(selectedEntityIds)[0] : null
// Use context-menu-target entity for convert/set-as-main; falls back to selection
const convertEntityId = contextMenuEntityId ?? selectedEntityId
const convertEntity = convertEntityId
? findInTree(fileTreeData, convertEntityId)
: null
const isConvertableDoc = convertEntity?.type === 'doc'
const convertEntityName = convertEntity?.entity.name ?? null
const enablePandocConversions =
getMeta('ol-ExposedSettings')?.enablePandocConversions
const canConvertToTypst =
enablePandocConversions &&
!fileTreeReadOnly &&
isConvertableDoc &&
convertEntityName?.endsWith('.tex')
const canConvertToLatex =
enablePandocConversions &&
!fileTreeReadOnly &&
isConvertableDoc &&
convertEntityName?.endsWith('.typ')
const canShowSetAsMain =
!fileTreeReadOnly &&
isConvertableDoc &&
!!convertEntityId &&
convertEntityId !== rootDocId &&
!!convertEntityName &&
isValidTeXFile(convertEntityName)
const handleSetAsMain = useCallback(async () => {
if (!convertEntityId || !convertEntityName) return
const ext = convertEntityName.split('.').pop()?.toLowerCase() ?? ''
const newCompiler = COMPILER_BY_EXT[ext]
const body: Record<string, string> = { rootDocId: convertEntityId }
if (newCompiler && newCompiler !== project?.compiler) body.compiler = newCompiler
await postJSON(`/project/${projectId}/settings`, { body })
const update: Record<string, string> = { rootDocId: convertEntityId }
if (newCompiler) update.compiler = newCompiler
updateProject(update)
}, [convertEntityId, convertEntityName, project, projectId, updateProject])
const { convert: convertToTypst } = useConvertDoc('typst', convertEntityId)
const { convert: convertToLatex } = useConvertDoc('latex', convertEntityId)
const downloadWithAnalytics = useCallback(() => {
// we are only interested in downloads of bib files WRT analytics, for the purposes of promoting the tpr integrations
@@ -65,16 +134,35 @@ function FileTreeItemMenuItems() {
</DropdownItem>
</li>
) : null}
{canSetRootDocId ? (
{canShowSetAsMain ? (
<>
<DropdownDivider />
<li role="none">
<DropdownItem onClick={setRootDocId}>
<DropdownItem onClick={handleSetAsMain}>
{t('set_as_main_document')}
</DropdownItem>
</li>
</>
) : null}
{(canConvertToTypst || canConvertToLatex) ? (
<>
<DropdownDivider />
{canConvertToTypst && (
<li role="none">
<DropdownItem onClick={convertToTypst}>
{t('convert_to_typst')}
</DropdownItem>
</li>
)}
{canConvertToLatex && (
<li role="none">
<DropdownItem onClick={convertToLatex}>
{t('convert_to_latex')}
</DropdownItem>
</li>
)}
</>
) : null}
{canDelete ? (
<>
<DropdownDivider />
@@ -5,7 +5,8 @@ import MaterialIcon from '@/shared/components/material-icon'
function FileTreeItemMenu({ id, name }: { id: string; name: string }) {
const { t } = useTranslation()
const { contextMenuCoords, setContextMenuCoords } = useFileTreeMainContext()
const { contextMenuCoords, setContextMenuCoords, setContextMenuEntityId } =
useFileTreeMainContext()
const menuButtonRef = useRef<HTMLButtonElement>(null)
const isMenuOpen = Boolean(contextMenuCoords)
@@ -13,6 +14,7 @@ function FileTreeItemMenu({ id, name }: { id: string; name: string }) {
function handleClick(event: React.MouseEvent) {
event.stopPropagation()
if (!contextMenuCoords && menuButtonRef.current) {
setContextMenuEntityId(id)
const target = menuButtonRef.current.getBoundingClientRect()
setContextMenuCoords({
top: target.top + target.height / 2,
@@ -9,6 +9,8 @@ const FileTreeMainContext = createContext<
setStartedFreeTrial: (value: boolean) => void
contextMenuCoords: ContextMenuCoords | null
setContextMenuCoords: (value: ContextMenuCoords | null) => void
contextMenuEntityId: string | null
setContextMenuEntityId: (value: string | null) => void
}
| undefined
>(undefined)
@@ -39,6 +41,9 @@ export const FileTreeMainProvider: FC<
}) => {
const [contextMenuCoords, setContextMenuCoords] =
useState<ContextMenuCoords | null>(null)
const [contextMenuEntityId, setContextMenuEntityId] = useState<string | null>(
null
)
return (
<FileTreeMainContext.Provider
@@ -48,6 +53,8 @@ export const FileTreeMainProvider: FC<
setStartedFreeTrial,
contextMenuCoords,
setContextMenuCoords,
contextMenuEntityId,
setContextMenuEntityId,
}}
>
{children}
@@ -1,6 +1,7 @@
import { Panel, PanelGroup } from 'react-resizable-panels'
import classNames from 'classnames'
import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
import PdfPreview from '@/features/pdf-preview/components/pdf-preview'
import { RailLayout } from '../rail/rail'
import { Toolbar } from '../toolbar/toolbar'
@@ -8,7 +9,7 @@ import { HorizontalToggler } from '@/features/ide-react/components/resize/horizo
import { useTranslation } from 'react-i18next'
import { usePdfPane } from '@/features/ide-react/hooks/use-pdf-pane'
import { useLayoutContext } from '@/shared/context/layout-context'
import { ElementType, useState } from 'react'
import { ElementType, useEffect, useState } from 'react'
import EditorPanel from '../editor/editor-panel'
import { useRailContext } from '../../context/rail-context'
import HistoryContainer from '@/features/ide-react/components/history-container'
@@ -25,6 +26,19 @@ const mainEditorLayoutModalsModules: Array<{
path: string
}> = importOverleafModules('mainEditorLayoutModals')
// Bootstrap md breakpoint — below this we stack panels vertically on mobile
const MOBILE_MQ = '(max-width: 767px)'
// Secondary check: browsers that spoof viewport width (e.g. Tor Browser) still
// expose `pointer: coarse` for real touch hardware.
const TOUCH_MQ = '(pointer: coarse) and (max-width: 1024px)'
function detectMobile() {
return (
window.matchMedia(MOBILE_MQ).matches ||
window.matchMedia(TOUCH_MQ).matches
)
}
export default function MainLayout() {
const [resizing, setResizing] = useState(false)
const { resizing: railResizing } = useRailContext()
@@ -38,8 +52,29 @@ export default function MainLayout() {
} = usePdfPane()
const { view, pdfLayout } = useLayoutContext()
const [isMobile, setIsMobile] = useState(detectMobile)
useEffect(() => {
const handler = () => setIsMobile(detectMobile())
const mq1 = window.matchMedia(MOBILE_MQ)
const mq2 = window.matchMedia(TOUCH_MQ)
mq1.addEventListener('change', handler)
mq2.addEventListener('change', handler)
return () => {
mq1.removeEventListener('change', handler)
mq2.removeEventListener('change', handler)
}
}, [])
// verticalSplit is always vertical; sideBySide becomes vertical on mobile
const isVertical =
pdfLayout === 'verticalSplit' ||
(pdfLayout === 'sideBySide' && isMobile)
const editorIsOpen =
view === 'editor' || view === 'file' || pdfLayout === 'sideBySide'
view === 'editor' ||
view === 'file' ||
pdfLayout === 'sideBySide' ||
pdfLayout === 'verticalSplit'
const { t } = useTranslation()
@@ -58,8 +93,20 @@ export default function MainLayout() {
<Panel id="ide-redesign-editor-and-pdf-panel" order={2}>
<HistoryContainer />
<PanelGroup
autoSaveId="ide-redesign-editor-and-pdf-panel-group"
direction="horizontal"
key={isVertical ? 'vertical' : 'horizontal'}
autoSaveId={
isVertical
? // On mobile, skip autoSave: a stale collapsed PDF pane
// would fire onCollapse → changeLayout('flat'), overriding
// the verticalSplit default from getInitialLayout().
// The pdf.layout localStorage key already persists the
// user's explicit flat/open preference independently.
isMobile
? null
: 'ide-redesign-editor-and-pdf-panel-group-vertical'
: 'ide-redesign-editor-and-pdf-panel-group'
}
direction={isVertical ? 'vertical' : 'horizontal'}
className={classNames({
hidden: view === 'history',
})}
@@ -79,29 +126,38 @@ export default function MainLayout() {
<EditorPanel />
</div>
</Panel>
<HorizontalResizeHandle
resizable={pdfLayout === 'sideBySide'}
onDragging={setResizing}
onDoubleClick={togglePdfPane}
hitAreaMargins={{ coarse: 0, fine: 0 }}
className={classNames({
hidden: !editorIsOpen,
})}
>
<HorizontalToggler
id="ide-redesign-pdf-panel"
togglerType="east"
isOpen={isPdfOpen}
setIsOpen={setIsPdfOpen}
tooltipWhenOpen={t('tooltip_hide_pdf')}
tooltipWhenClosed={t('tooltip_show_pdf')}
{isVertical ? (
<VerticalResizeHandle
onDragging={setResizing}
className={classNames({
hidden: !editorIsOpen,
})}
/>
{pdfLayout === 'sideBySide' && (
<div className="synctex-controls">
<DefaultSynctexControl />
</div>
)}
</HorizontalResizeHandle>
) : (
<HorizontalResizeHandle
resizable={pdfLayout === 'sideBySide'}
onDragging={setResizing}
onDoubleClick={togglePdfPane}
hitAreaMargins={{ coarse: 0, fine: 0 }}
className={classNames({
hidden: !editorIsOpen,
})}
>
<HorizontalToggler
id="ide-redesign-pdf-panel"
togglerType="east"
isOpen={isPdfOpen}
setIsOpen={setIsPdfOpen}
tooltipWhenOpen={t('tooltip_hide_pdf')}
tooltipWhenClosed={t('tooltip_show_pdf')}
/>
{pdfLayout === 'sideBySide' && (
<div className="synctex-controls">
<DefaultSynctexControl />
</div>
)}
</HorizontalResizeHandle>
)}
<Panel
collapsible
className={classNames('ide-redesign-pdf-container', {
@@ -15,7 +15,12 @@ import { isMac } from '@/shared/utils/os'
import { Shortcut } from '@/shared/components/shortcut'
import classNames from 'classnames'
type LayoutOption = 'sideBySide' | 'editorOnly' | 'pdfOnly' | 'detachedPdf'
type LayoutOption =
| 'sideBySide'
| 'verticalSplit'
| 'editorOnly'
| 'pdfOnly'
| 'detachedPdf'
const getActiveLayoutOption = ({
pdfLayout,
@@ -46,6 +51,10 @@ const getActiveLayoutOption = ({
return 'sideBySide'
}
if (pdfLayout === 'verticalSplit') {
return 'verticalSplit'
}
return null
}
@@ -92,12 +101,14 @@ const shortcuts: Record<LayoutOption, string[] | null> = isMac
editorOnly: ['⌃', '⌘', '←'],
pdfOnly: ['⌃', '⌘', '→'],
sideBySide: ['⌃', '⌘', '↓'],
verticalSplit: null,
detachedPdf: ['⌃', '⌘', '↑'],
}
: {
editorOnly: null,
pdfOnly: null,
sideBySide: null,
verticalSplit: null,
detachedPdf: null,
}
@@ -136,6 +147,18 @@ export default function ChangeLayoutOptions() {
>
{t('split_view')}
</LayoutDropdownItem>
<LayoutDropdownItem
onClick={() => handleChangeLayout('verticalSplit')}
active={activeLayoutOption === 'verticalSplit'}
leadingIcon="horizontal_split"
trailingIcon={
shortcuts.verticalSplit && (
<Shortcut keys={shortcuts.verticalSplit} />
)
}
>
{t('top_bottom_split_view')}
</LayoutDropdownItem>
<LayoutDropdownItem
onClick={() => handleChangeLayout('flat', 'editor')}
active={activeLayoutOption === 'editorOnly'}
@@ -42,6 +42,7 @@ const ExportDocumentErrorToast = ({ data }: { data?: any }) => {
}
const ExportDocumentSuccessToast = ({ data }: { data?: any }) => {
const { t } = useTranslation()
const type = data?.type
if (type === 'docx') {
return (
@@ -85,6 +86,10 @@ const ExportDocumentSuccessToast = ({ data }: { data?: any }) => {
]}
/>
)
} else if (type === 'typst') {
return <span>{t('typst_export_feedback_message')}</span>
} else if (type === 'latex') {
return <span>{t('latex_export_feedback_message')}</span>
} else {
return (
<Trans
@@ -175,7 +180,7 @@ export const hidePreparingExportToast = (handle: string) => {
}
export const showExportDocumentSuccess = (
type: 'docx' | 'markdown' | 'html'
type: 'docx' | 'markdown' | 'html' | 'typst' | 'latex'
) => {
window.dispatchEvent(
new CustomEvent('ide:show-toast', {
@@ -6,10 +6,11 @@ import { useCommandProvider } from '../../hooks/use-command-provider'
import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
import { useRootDoc } from '@/shared/hooks/use-root-doc'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
type ExportProjectWithConversionProps = {
featureFlag?: string
conversionType: 'docx' | 'markdown' | 'html'
conversionType: 'docx' | 'markdown' | 'html' | 'typst' | 'latex'
label: string
menuBarId: string
}
@@ -22,6 +23,11 @@ export const ExportProjectWithConversionButton: FC<
const enablePandocConversions =
getMeta('ol-ExposedSettings')?.enablePandocConversions
const anonymous = getMeta('ol-anonymous')
const { compiler } = useProjectSettingsContext()
const isLatexProject = !['typst', 'quarto'].includes(compiler ?? '')
// latex export is for converting Typst projects to LaTeX; all others are for LaTeX projects
const isProjectTypeMatch =
conversionType === 'latex' ? compiler === 'typst' : isLatexProject
const getRootDocInfo = useRootDoc()
const { openDocs } = useEditorManagerContext()
const downloadConversion = useConvertProject(
@@ -31,7 +37,10 @@ export const ExportProjectWithConversionButton: FC<
)
const showExportButton =
splitTestEnabledIfNeeded && enablePandocConversions && !anonymous
splitTestEnabledIfNeeded &&
enablePandocConversions &&
!anonymous &&
isProjectTypeMatch
useCommandProvider(
() =>
@@ -97,6 +97,8 @@ export const ToolbarMenuBar = () => {
'export-as-docx',
'export-as-markdown',
'export-as-html',
'export-as-typst',
'export-as-latex',
],
},
],
@@ -243,7 +245,7 @@ export const ToolbarMenuBar = () => {
>
<ChangeLayoutOptions />
<DropdownDivider />
<DropdownHeader>Editor settings</DropdownHeader>
<DropdownHeader>{t('editor_settings')}</DropdownHeader>
<MenuBarOption
eventKey="show_breadcrumbs"
title={t('show_breadcrumbs')}
@@ -95,6 +95,16 @@ export const ToolbarProjectTitle = () => {
label={t('export_as_html')}
menuBarId="export-as-html"
/>
<ExportProjectWithConversionButton
conversionType="typst"
label={t('export_as_typst')}
menuBarId="export-as-typst"
/>
<ExportProjectWithConversionButton
conversionType="latex"
label={t('export_as_latex')}
menuBarId="export-as-latex"
/>
<DropdownDivider />
<DuplicateProject />
<OLDropdownMenuItem
@@ -0,0 +1,44 @@
import { postJSON } from '@/infrastructure/fetch-json'
import { useProjectContext } from '@/shared/context/project-context'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { useCallback, useState } from 'react'
import {
showExportDocumentError,
showExportDocumentSuccess,
hideExportDocumentError,
} from '../components/toolbar/export-document-toasts'
export default function useConvertDoc(
type: 'typst' | 'latex',
docId: string | null
) {
const { projectId } = useProjectContext()
const { dispatchCreateDoc } = useFileTreeData()
const [converting, setConverting] = useState(false)
const convert = useCallback(async () => {
if (!docId) return
setConverting(true)
hideExportDocumentError()
try {
const result = await postJSON(
`/project/${projectId}/doc/${docId}/convert/${type}`,
{ body: {} }
)
showExportDocumentSuccess(type)
if (result.isNew) {
dispatchCreateDoc(result.parentFolderId, {
_id: result.docId,
name: result.name,
})
}
} catch (err: any) {
const errorMessage = err?.data?.error
showExportDocumentError(errorMessage)
} finally {
setConverting(false)
}
}, [projectId, docId, type, dispatchCreateDoc])
return { convert, converting }
}
@@ -16,7 +16,7 @@ import { OpenDocuments } from '../editor/open-documents'
const SLOW_CONVERSION_THRESHOLD = 2000
export default function useConvertProject(
type: 'docx' | 'markdown' | 'html',
type: 'docx' | 'markdown' | 'html' | 'typst' | 'latex',
openDocs: OpenDocuments,
getRootDocInfo: () => RootDocInfo
) {
@@ -45,7 +45,7 @@ export default function useConvertProject(
if (downloadUrl) {
const url = new URL(downloadUrl, window.location.origin)
location.assign(url.toString())
showExportDocumentSuccess(type)
showExportDocumentSuccess(type as 'docx' | 'markdown' | 'html' | 'typst' | 'latex')
} else {
showExportDocumentError()
}
@@ -8,7 +8,8 @@ export const usePdfPane = () => {
useLayoutContext()
const pdfPanelRef = useRef<ImperativePanelHandle>(null)
const pdfIsOpen = pdfLayout === 'sideBySide' || view === 'pdf'
const pdfIsOpen =
pdfLayout === 'sideBySide' || pdfLayout === 'verticalSplit' || view === 'pdf'
useCollapsiblePanel(pdfIsOpen, pdfPanelRef)
@@ -46,7 +47,7 @@ export const usePdfPane = () => {
// triggered when the PDF pane becomes closed (either by dragging or toggling)
const handlePdfPaneCollapse = useCallback(() => {
if (pdfLayout === 'sideBySide') {
if (pdfLayout === 'sideBySide' || pdfLayout === 'verticalSplit') {
changeLayout('flat', 'editor')
}
}, [changeLayout, pdfLayout])
@@ -12,7 +12,7 @@ function SwitchToEditorButton() {
return null
}
if (pdfLayout === 'sideBySide') {
if (pdfLayout === 'sideBySide' || pdfLayout === 'verticalSplit') {
return null
}
@@ -15,6 +15,7 @@ import LeaveProjectButton from '../table/cells/action-buttons/leave-project-butt
import DeleteProjectButton from '../table/cells/action-buttons/delete-project-button'
import { Project } from '../../../../../../types/project/dashboard/api'
import CompileAndDownloadProjectPDFButton from '../table/cells/action-buttons/compile-and-download-project-pdf-button'
import DownloadPresentationButton from '../table/cells/action-buttons/download-presentation-button'
import RenameProjectButton from '../table/cells/action-buttons/rename-project-button'
import MaterialIcon from '@/shared/components/material-icon'
import OLSpinner from '@/shared/components/ol/ol-spinner'
@@ -77,32 +78,81 @@ function ActionsDropdown({ project }: ActionDropdownProps) {
</li>
)}
</DownloadProjectButton>
<CompileAndDownloadProjectPDFButton project={project}>
{(text, pendingCompile, downloadProject) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={e => {
e.stopPropagation()
downloadProject()
}}
leadingIcon={
pendingCompile ? (
<OLSpinner
size="sm"
className="dropdown-item-leading-icon spinner"
/>
) : (
'picture_as_pdf'
)
}
>
{text}
</DropdownItem>
</li>
)}
</CompileAndDownloadProjectPDFButton>
{project.compiler === 'quarto' ? (
<DownloadPresentationButton project={project}>
{(startExport, exporting) => (
<>
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={() => startExport('html')}
disabled={exporting !== null}
leadingIcon={
exporting === 'html' ? (
<OLSpinner
size="sm"
className="dropdown-item-leading-icon spinner"
/>
) : (
'download'
)
}
>
{t('download_as_standalone_html')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={() => startExport('pdf')}
disabled={exporting !== null}
leadingIcon={
exporting === 'pdf' ? (
<OLSpinner
size="sm"
className="dropdown-item-leading-icon spinner"
/>
) : (
'picture_as_pdf'
)
}
>
{t('download_as_pdf_slides')}
</DropdownItem>
</li>
</>
)}
</DownloadPresentationButton>
) : (
<CompileAndDownloadProjectPDFButton project={project}>
{(text, pendingCompile, downloadProject) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={e => {
e.stopPropagation()
downloadProject()
}}
leadingIcon={
pendingCompile ? (
<OLSpinner
size="sm"
className="dropdown-item-leading-icon spinner"
/>
) : (
'picture_as_pdf'
)
}
>
{text}
</DropdownItem>
</li>
)}
</CompileAndDownloadProjectPDFButton>
)}
<ArchiveProjectButton project={project}>
{(text, handleOpenModal) => (
<li role="none">
@@ -67,7 +67,7 @@ function NewProjectButton({
const markdownImportEnabled =
useFeatureFlag('import-markdown') &&
getMeta('ol-ExposedSettings').enablePandocConversions
const { selectedTagId, tags } = useProjectListContext()
const { selectedTagId, tags } = useProjectListContext()
const isLibraryEnabled = isSplitTestEnabled('overleaf-library')
const initialTags =
isLibraryEnabled && selectedTagId
@@ -19,7 +19,7 @@ function ImportDocumentModal({
onHide,
openProject,
}: {
type: 'docx' | 'markdown'
type: 'docx' | 'markdown' | 'typst'
onHide: () => void
openProject: (id: string, convertedFrom?: string) => void
}) {
@@ -39,6 +39,12 @@ function ImportDocumentModal({
browseLabel: 'Select .md file',
dragLabel: '%{browseFiles} or \n\n Drag .md file',
},
typst: {
allowedFileTypes: ['.typ'],
title: t('choose_typst_file'),
browseLabel: 'Select .typ file',
dragLabel: '%{browseFiles} or \n\n Drag .typ file',
},
}),
[t]
)
@@ -21,6 +21,7 @@ export type NewProjectButtonModalVariant =
| 'import_from_github'
| 'import_docx'
| 'import_markdown'
| 'import_typst'
type NewProjectButtonModalProps = {
modal: Nullable<NewProjectButtonModalVariant>
@@ -128,6 +129,16 @@ function NewProjectButtonModal({
/>
</Suspense>
)
case 'import_typst':
return (
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<ImportDocumentModal
type="typst"
onHide={onHide}
openProject={openProject}
/>
</Suspense>
)
case 'import_from_github':
return <ImportProjectFromGithubModalWrapper onHide={onHide} />
default:
@@ -128,20 +128,21 @@ function BannerContent({
}: {
variant: GroupsAndEnterpriseBannerVariant
}) {
const { appName } = getMeta('ol-ExposedSettings')
switch (variant) {
case 'on-premise':
return (
<span>
Overleaf On-Premises: Does your company want to keep its data within
its firewall? Overleaf offers Server Pro, an on-premises solution for
{appName} On-Premises: Does your company want to keep its data within
its firewall? {appName} offers Server Pro, an on-premises solution for
companies. Get in touch to learn more.
</span>
)
case 'FOMO':
return (
<span>
Why do Fortune 500 companies and top research institutions trust
Overleaf to streamline their collaboration? Get in touch to learn
Why do Fortune 500 companies and top research institutions trust{' '}
{appName} to streamline their collaboration? Get in touch to learn
more.
</span>
)
@@ -44,37 +44,19 @@ export function ProjectListDsNav() {
const tableTopArea = (
<div className="pt-2 pb-3 d-md-none d-flex gap-2">
{isLibraryEnabled ? (
<>
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
className="overflow-hidden flex-grow-1"
/>
{showNewProjectButton && (
<NewProjectButton
id="new-project-button-projects-table"
showAddAffiliationWidget
/>
)}
</>
) : (
<>
<NewProjectButton
id="new-project-button-projects-table"
showAddAffiliationWidget
/>
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
className="overflow-hidden flex-grow-1"
/>
</>
{showNewProjectButton && (
<NewProjectButton
id="new-project-button-projects-table"
showAddAffiliationWidget
/>
)}
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
className="overflow-hidden flex-grow-1"
/>
</div>
)
@@ -107,9 +89,6 @@ export function ProjectListDsNav() {
<ProjectTools />
)}
</div>
<div className="d-md-none">
<CurrentPlanWidget />
</div>
</div>
</div>
<div className="project-ds-nav-project-list">
@@ -145,8 +124,8 @@ export function ProjectListDsNav() {
</div>
</div>
<div className="mt-3">
{tableTopArea}
<TableContainer bordered>
{tableTopArea}
<ProjectListTable />
</TableContainer>
</div>
@@ -0,0 +1,485 @@
import React, {
memo,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
Filter,
useProjectListContext,
} from '../context/project-list-context'
import { Project } from '../../../../../types/project/dashboard/api'
import { getOwnerName } from '../util/project'
import { fromNowDate } from '../../../utils/dates'
import { ProjectCompiler } from '../../../../../types/project-settings'
import { getTagColor } from '../util/tag'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import getMeta from '@/utils/meta'
import DefaultNavbar from '@/shared/components/navbar/default-navbar'
import Footer from '@/shared/components/footer/footer'
import SidebarDsNav from '@/features/project-list/components/sidebar/sidebar-ds-nav'
import SystemMessages from '@/shared/components/system-messages'
import CookieBanner from '@/shared/components/cookie-banner'
import UserNotifications from './notifications/user-notifications'
import SearchForm from './search-form'
import NewProjectButton from './new-project-button'
import ProjectListTitle from './title/project-list-title'
import LoadMore from './load-more'
import DashApiError from './dash-api-error'
import { ProjectCheckbox } from './table/project-checkbox'
import ProjectTools from './table/project-tools/project-tools'
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
import { CopyProjectButtonTooltip } from './table/cells/action-buttons/copy-project-button'
import { DownloadProjectButtonTooltip } from './table/cells/action-buttons/download-project-button'
import { CompileAndDownloadProjectPDFButtonTooltip } from './table/cells/action-buttons/compile-and-download-project-pdf-button'
import { DownloadPresentationButtonTooltip } from './table/cells/action-buttons/download-presentation-button'
import { ArchiveProjectButtonTooltip } from './table/cells/action-buttons/archive-project-button'
import { TrashProjectButtonTooltip } from './table/cells/action-buttons/trash-project-button'
import FormatCell from './table/cells/format-cell'
import OwnerCell from './table/cells/owner-cell'
import LastUpdatedCell from './table/cells/last-updated-cell'
import ActionsCell from './table/cells/actions-cell'
import ActionsDropdown from './dropdown/actions-dropdown'
import InlineTags from './table/cells/inline-tags'
import WelcomePageContent from './welcome-page-content'
// ── Mobile breakpoint ─────────────────────────────────────────────────────────
function useIsMobile() {
const [mobile, setMobile] = useState(
() => window.matchMedia('(max-width: 767px)').matches
)
useEffect(() => {
const mq = window.matchMedia('(max-width: 767px)')
const handler = (e: MediaQueryListEvent) => setMobile(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
return mobile
}
// ── Tile zoom ─────────────────────────────────────────────────────────────────
type ZoomLevel = 0 | 1 | 1.35 | 1.75
const ZOOM_OPTIONS: { value: ZoomLevel; label: string }[] = [
{ value: 0, label: 'XS' },
{ value: 1, label: 'S' },
{ value: 1.35, label: 'M' },
{ value: 1.75, label: 'L' },
]
// Mobile offers 3 sizes: XS (rows), M (2 tiles), L (1 tile)
const MOBILE_ZOOM_OPTIONS: { value: ZoomLevel; label: string }[] = [
{ value: 0, label: 'XS' },
{ value: 1, label: 'M' },
{ value: 1.35, label: 'L' },
]
const ZOOM_STORAGE_KEY = 'lumiere-card-scale'
function useLumiereCardScale(): [ZoomLevel, (z: ZoomLevel) => void] {
const [scale, setScale] = useState<ZoomLevel>(() => {
try {
const stored = localStorage.getItem(ZOOM_STORAGE_KEY)
if (stored) {
const val = parseFloat(stored)
if ((ZOOM_OPTIONS.map(o => o.value) as number[]).includes(val)) {
return val as ZoomLevel
}
}
} catch (_) {
// storage unavailable
}
return 1
})
const updateScale = useCallback((z: ZoomLevel) => {
setScale(z)
try {
localStorage.setItem(ZOOM_STORAGE_KEY, String(z))
} catch (_) {
// ignore
}
}, [])
return [scale, updateScale]
}
// ── Format helpers ─────────────────────────────────────────────────────────────
type FormatVariant = 'latex' | 'typst' | 'quarto' | 'quarto-slides'
function getFormatVariant(
compiler: ProjectCompiler | undefined,
quartoFlavor: 'revealjs' | 'pdf' | undefined
): FormatVariant {
if (compiler === 'quarto') {
return quartoFlavor === 'revealjs' ? 'quarto-slides' : 'quarto'
}
if (compiler === 'typst') return 'typst'
return 'latex'
}
function getFormatLabel(variant: FormatVariant): string {
switch (variant) {
case 'typst':
return 'Typst'
case 'quarto':
return 'Quarto'
case 'quarto-slides':
return 'Quarto Slides'
default:
return 'LaTeX'
}
}
const ProjectCard = memo(function ProjectCard({
project,
}: {
project: Project
}) {
const { t } = useTranslation()
const { tags } = useProjectListContext()
const variant = getFormatVariant(project.compiler, project.quartoFlavor)
const rawOwner = getOwnerName(project)
const ownerName = rawOwner === 'You' ? t('you') : rawOwner
const date = fromNowDate(project.lastUpdated)
const initial = project.name.charAt(0).toUpperCase() || '?'
const projectTags = tags
.filter(tag => tag.project_ids?.includes(project.id))
.slice(0, 3)
return (
<div className="lumiere-card-wrapper">
<div className="lumiere-card-checkbox">
<ProjectCheckbox projectId={project.id} projectName={project.name} />
</div>
<div className={`lumiere-card lumiere-card--${variant}`} translate="no">
<a
href={`/project/${project.id}`}
className="lumiere-card-link"
translate="no"
>
<div className="lumiere-card-thumb">
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img
className="lumiere-card-thumb-img"
src={`/project/${project.id}/thumbnail`}
aria-hidden="true"
onError={e => {
e.currentTarget.style.display = 'none'
}}
/>
<span className="lumiere-card-initial">{initial}</span>
</div>
<div className="lumiere-card-body">
<span className="lumiere-card-name">{project.name}</span>
<div className="lumiere-card-meta">
<span
className={`lumiere-format-badge lumiere-format-badge--${variant}`}
>
{getFormatLabel(variant)}
</span>
{ownerName && (
<span className="lumiere-card-owner" translate="yes">
{ownerName}
</span>
)}
{projectTags.map(tag => (
<OLTooltip
key={tag._id}
id={`tag-${tag._id}-${project.id}`}
description={tag.name}
overlayProps={{ placement: 'top' }}
>
<span
className="lumiere-card-tag-dot"
style={{ backgroundColor: getTagColor(tag) }}
translate="no"
/>
</OLTooltip>
))}</div>
<span className="lumiere-card-date">{date}</span>
</div>
</a>
<div className="lumiere-card-actions">
<CopyProjectButtonTooltip project={project} />
<DownloadProjectButtonTooltip project={project} />
{project.compiler === 'quarto' ? (
<DownloadPresentationButtonTooltip project={project} />
) : (
<CompileAndDownloadProjectPDFButtonTooltip project={project} />
)}
<ArchiveProjectButtonTooltip project={project} />
<TrashProjectButtonTooltip project={project} />
</div>
</div>
</div>
)
})
// ── XS compact row (reuses classic table cell components for full detail) ─────
const ProjectCardCompact = memo(function ProjectCardCompact({
project,
}: {
project: Project
}) {
return (
<div className="lumiere-compact-row">
<div className="lumiere-compact-checkbox">
<ProjectCheckbox projectId={project.id} projectName={project.name} />
</div>
<div className="lumiere-compact-name-cell">
<a
href={`/project/${project.id}`}
className="lumiere-compact-name"
translate="no"
>
{project.name}
</a>
<InlineTags projectId={project.id} />
</div>
<div className="lumiere-compact-meta">
<div className="lumiere-compact-format">
<FormatCell project={project} />
</div>
<div className="lumiere-compact-owner" translate="no">
<OwnerCell project={project} />
</div>
<div className="lumiere-compact-date">
<LastUpdatedCell project={project} />
</div>
</div>
<div className="lumiere-compact-actions">
{/* ⋮ dropdown on mobile (single button), icon strip on desktop */}
<div className="d-md-none">
<ActionsDropdown project={project} />
</div>
<div className="d-none d-md-flex">
<ActionsCell project={project} />
</div>
</div>
</div>
)
})
export function ProjectListLumiere() {
const navbarProps = getMeta('ol-navbar')
const footerProps = getMeta('ol-footer')
const { t } = useTranslation()
const [cardScale, setCardScale] = useLumiereCardScale()
const isMobile = useIsMobile()
const {
error,
visibleProjects,
totalProjectsCount,
searchText,
setSearchText,
filter,
tags,
selectedTagId,
selectedProjects,
selectOrUnselectAllProjects,
selectFilter,
} = useProjectListContext()
// On mobile: M option (scale=1) → 2 tiles/row; L (scale≥1.35) → 1 tile/row.
// CSS overrides the card grid columns on mobile; we only add a class for L.
const mobileIsL = isMobile && cardScale !== 0 && cardScale >= 1.35
const activeMobileZoom = cardScale === 0
? 0
: cardScale >= 1.35
? 1.35
: 1
const selectedTag = tags.find(tag => tag._id === selectedTagId)
const allSelected =
visibleProjects.length > 0 &&
selectedProjects.length === visibleProjects.length
const MOBILE_FILTERS: { f: Filter; label: string }[] = [
{ f: 'all', label: t('all_projects') },
{ f: 'owned', label: t('your_projects') },
{ f: 'shared', label: t('shared_with_you') },
{ f: 'archived', label: t('archived_projects') },
{ f: 'trashed', label: t('trashed_projects') },
]
const checkAllRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (checkAllRef.current) {
checkAllRef.current.indeterminate =
selectedProjects.length > 0 && !allSelected
}
}, [selectedProjects, allSelected])
const handleSelectAll = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
selectOrUnselectAllProjects(e.target.checked)
},
[selectOrUnselectAllProjects]
)
const handleDeselectAll = useCallback(() => {
selectOrUnselectAllProjects(false)
}, [selectOrUnselectAllProjects])
return (
// Keep project-ds-nav-page + website-redesign so the sidebar and navbar
// pick up all their existing CSS (scoped under those classes). The
// project-list-lumiere class then adds/overrides the card-grid styles.
<div className="project-ds-nav-page website-redesign project-list-lumiere">
<SystemMessages />
<DefaultNavbar {...navbarProps} showCloseIcon />
{/* project-list-wrapper is required by sidebar CSS */}
<div className="project-list-wrapper">
<SidebarDsNav />
<div className="project-ds-nav-content-and-messages lumiere-content-area">
<div className="project-ds-nav-content lumiere-scroll-area">
<main
className="project-ds-nav-main lumiere-main"
aria-labelledby="lumiere-title"
>
<UserNotifications />
{error && <DashApiError />}
<div className="lumiere-header">
<div className="lumiere-title-row">
<ProjectListTitle
filter={filter}
selectedTag={selectedTag}
selectedTagId={selectedTagId}
className="lumiere-title"
id="lumiere-title"
/>
<div
className="lumiere-zoom-control d-none d-md-flex"
role="group"
aria-label={t('card_size')}
>
{ZOOM_OPTIONS.map(({ value, label }) => (
<button
key={value}
type="button"
className={`lumiere-zoom-btn${cardScale === value ? ' active' : ''}`}
onClick={() => setCardScale(value)}
aria-pressed={cardScale === value}
>
{label}
</button>
))}
</div>
</div>
{/* Mobile-only: filter pills */}
<div className="d-flex d-md-none lumiere-mobile-toolbar">
<div
className="lumiere-mobile-filters"
role="group"
aria-label={t('filter_projects')}
>
{MOBILE_FILTERS.map(({ f, label }) => (
<button
key={f}
type="button"
className={`lumiere-mobile-filter-pill${filter === f ? ' active' : ''}`}
onClick={() => selectFilter(f)}
aria-pressed={filter === f}
>
{label}
</button>
))}
</div>
</div>
<div className="lumiere-header-actions">
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
/>
{/* On mobile: new project + zoom share a row below the search bar */}
<div className="lumiere-header-actions-row">
<NewProjectButton
id="lumiere-new-project-button"
showAddAffiliationWidget
/>
{/* Zoom control: on desktop it sits in the title row; on mobile it moves here */}
<div
className="lumiere-zoom-control lumiere-mobile-zoom d-md-none"
role="group"
aria-label={t('card_size')}
>
{MOBILE_ZOOM_OPTIONS.map(({ value, label }) => (
<button
key={value}
type="button"
className={`lumiere-zoom-btn${activeMobileZoom === value ? ' active' : ''}`}
onClick={() => setCardScale(value)}
aria-pressed={activeMobileZoom === value}
>
{label}
</button>
))}
</div>
</div>
</div>
</div>
{selectedProjects.length > 0 && (
<div className="lumiere-selection-bar">
<div className="lumiere-selection-bar-left">
<OLFormCheckbox
autoComplete="off"
checked={allSelected}
onChange={handleSelectAll}
inputRef={checkAllRef}
aria-label={t('select_all_projects')}
/>
<span className="lumiere-selection-count">
{t('n_projects_selected', {
count: selectedProjects.length,
})}
</span>
</div>
<ProjectTools />
<button
type="button"
className="lumiere-selection-deselect"
onClick={handleDeselectAll}
>
{t('deselect_all')}
</button>
</div>
)}
{totalProjectsCount === 0 ? (
<WelcomePageContent />
) : visibleProjects.length === 0 ? (
<p className="lumiere-empty">{t('no_projects')}</p>
) : (
<div
className={[
'lumiere-card-grid',
cardScale === 0 && 'lumiere-card-grid--compact',
mobileIsL && 'lumiere-card-grid--mobile-1col',
].filter(Boolean).join(' ')}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
style={cardScale === 0 || isMobile ? {} : ({ '--lum-card-scale': cardScale } as React.CSSProperties)}
>
{visibleProjects.map(project =>
cardScale === 0 ? (
<ProjectCardCompact key={project.id} project={project} />
) : (
<ProjectCard key={project.id} project={project} />
)
)}
</div>
)}
<LoadMore />
</main>
<Footer {...footerProps} />
</div>
<CookieBanner />
</div>
</div>
</div>
)
}
@@ -17,10 +17,12 @@ import DefaultNavbar from '@/shared/components/navbar/default-navbar'
import Footer from '@/shared/components/footer/footer'
import WelcomePageContent from '@/features/project-list/components/welcome-page-content'
import { ProjectListDsNav } from '@/features/project-list/components/project-list-ds-nav'
import { ProjectListLumiere } from '@/features/project-list/components/project-list-lumiere'
import { DsNavStyleProvider } from '@/features/project-list/components/use-is-ds-nav'
import CookieBanner from '@/shared/components/cookie-banner'
import useThemedPage from '@/shared/hooks/use-themed-page'
import { UserSettingsProvider } from '@/shared/context/user-settings-context'
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
import { TutorialProvider } from '@/shared/context/tutorial-context'
function ProjectListRoot() {
@@ -80,6 +82,9 @@ function ProjectListPageContent() {
useThemedPage()
const { totalProjectsCount, isLoading, loadProgress } =
useProjectListContext()
const {
userSettings: { overallTheme },
} = useUserSettingsContext()
useEffect(() => {
eventTracking.sendMB('loads_v2_dash', { page: 'projects' })
@@ -95,6 +100,14 @@ function ProjectListPageContent() {
return loadingComponent
}
if (overallTheme === 'lumiere-') {
return (
<DsNavStyleProvider>
<ProjectListLumiere />
</DsNavStyleProvider>
)
}
if (totalProjectsCount === 0) {
return (
<>
@@ -105,6 +118,7 @@ function ProjectListPageContent() {
</>
)
}
return (
<DsNavStyleProvider>
<ProjectListDsNav />
@@ -66,7 +66,7 @@ function SidebarDsNav() {
scrolledUp && 'show-shadow'
)}
>
<SidebarLowerSection showThemeToggle>
<SidebarLowerSection showThemeToggle showAccountIcons={false}>
<div className="project-list-sidebar-survey-wrapper">
<SurveyWidgetDsNav />
</div>
@@ -8,6 +8,8 @@ import { useTranslation } from 'react-i18next'
const getIcon = (theme: OverallThemeMeta) => {
switch (theme.val) {
case 'lumiere-':
return 'auto_awesome'
case 'light-':
return 'light_mode'
case 'system':
@@ -98,6 +98,10 @@ function CompileAndDownloadProjectPDFButton({
const outputFile = data.outputFiles
.filter((file: { path: string }) => file.path === 'output.pdf')
.pop()
if (!outputFile) {
setShowErrorModal(true)
return
}
const params = new URLSearchParams({
compileGroup: data.compileGroup,
@@ -0,0 +1,195 @@
import { memo, useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Project } from '../../../../../../../../types/project/dashboard/api'
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/shared/components/dropdown/dropdown-menu'
import {
OLModal,
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import OLSpinner from '@/shared/components/ol/ol-spinner'
import LoadingSpinner from '@/shared/components/loading-spinner'
import MaterialIcon from '@/shared/components/material-icon'
type ExportFormat = 'html' | 'pdf'
type DownloadPresentationButtonProps = {
project: Project
children: (
startExport: (format: ExportFormat) => void,
exporting: ExportFormat | null
) => React.ReactElement
}
function DownloadPresentationButton({
project,
children,
}: DownloadPresentationButtonProps) {
const { t } = useTranslation()
const [exporting, setExporting] = useState<ExportFormat | null>(null)
const [exportError, setExportError] = useState<string | null>(null)
const requestIdRef = useRef(0)
const dismiss = useCallback(() => {
requestIdRef.current += 1
setExporting(null)
setExportError(null)
}, [])
const startExport = useCallback(
async (format: ExportFormat) => {
const requestId = ++requestIdRef.current
setExportError(null)
setExporting(format)
try {
const response = await fetch(
`/project/${project.id}/presentation-export/${format}`,
{ credentials: 'same-origin' }
)
if (requestId !== requestIdRef.current) return
if (!response.ok) {
const text = await response.text()
if (requestId !== requestIdRef.current) return
setExporting(null)
setExportError(text || `Export failed (HTTP ${response.status})`)
return
}
const blob = await response.blob()
if (requestId !== requestIdRef.current) return
const disposition = response.headers.get('Content-Disposition')
const match = disposition?.match(/filename="?([^"]+)"?/)
const filename =
match ? match[1] : `presentation.${format === 'pdf' ? 'pdf' : 'html'}`
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
setExporting(null)
// Revoke after a delay so the browser has time to initiate the download
setTimeout(() => URL.revokeObjectURL(url), 10000)
} catch (err) {
if (requestId !== requestIdRef.current) return
setExporting(null)
setExportError(err instanceof Error ? err.message : String(err))
}
},
[project.id]
)
return (
<>
{children(startExport, exporting)}
<OLModal
show={exporting !== null || exportError !== null}
onHide={dismiss}
>
<OLModalHeader closeButton>
<OLModalTitle>
{exportError
? t('presentation_export_failed')
: t('preparing_your_download')}
</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{exporting !== null ? (
<LoadingSpinner
loadingText={t('presentation_export_can_take_a_moment')}
/>
) : (
<pre
style={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: '50vh',
overflow: 'auto',
marginBottom: 0,
}}
>
{exportError}
</pre>
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={dismiss}>
{t('close')}
</OLButton>
</OLModalFooter>
</OLModal>
</>
)
}
// For ActionsCell (desktop icon buttons row): an icon button that opens a
// dropdown with PDF and HTML export choices.
const DownloadPresentationButtonTooltip = memo(
function DownloadPresentationButtonTooltip({
project,
}: { project: Project }) {
const { t } = useTranslation()
if (project.compiler !== 'quarto') {
return null
}
return (
<DownloadPresentationButton project={project}>
{(startExport, exporting) => (
<Dropdown>
<DropdownToggle
id={`download-presentation-toggle-${project.id}`}
variant="link"
className="action-btn"
aria-label={t('download')}
>
{exporting !== null ? (
<OLSpinner size="sm" />
) : (
<MaterialIcon type="picture_as_pdf" />
)}
</DropdownToggle>
<DropdownMenu
popperConfig={{ strategy: 'fixed' }}
renderOnMount
flip
>
<li role="none">
<DropdownItem
as="button"
onClick={() => startExport('html')}
disabled={exporting !== null}
leadingIcon="download"
>
{t('download_as_standalone_html')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
onClick={() => startExport('pdf')}
disabled={exporting !== null}
leadingIcon="picture_as_pdf"
>
{t('download_as_pdf_slides')}
</DropdownItem>
</li>
</DropdownMenu>
</Dropdown>
)}
</DownloadPresentationButton>
)
}
)
export default memo(DownloadPresentationButton)
export { DownloadPresentationButtonTooltip }
@@ -8,17 +8,25 @@ import { DownloadProjectButtonTooltip } from './action-buttons/download-project-
import { LeaveProjectButtonTooltip } from './action-buttons/leave-project-button'
import { DeleteProjectButtonTooltip } from './action-buttons/delete-project-button'
import { CompileAndDownloadProjectPDFButtonTooltip } from './action-buttons/compile-and-download-project-pdf-button'
import { DownloadPresentationButtonTooltip } from './action-buttons/download-presentation-button'
type ActionsCellProps = {
project: Project
}
const isQuartoSlides = (project: Project) =>
project.compiler === 'quarto'
export default function ActionsCell({ project }: ActionsCellProps) {
return (
<>
<CopyProjectButtonTooltip project={project} />
<DownloadProjectButtonTooltip project={project} />
<CompileAndDownloadProjectPDFButtonTooltip project={project} />
{isQuartoSlides(project) ? (
<DownloadPresentationButtonTooltip project={project} />
) : (
<CompileAndDownloadProjectPDFButtonTooltip project={project} />
)}
<ArchiveProjectButtonTooltip project={project} />
<TrashProjectButtonTooltip project={project} />
<UnarchiveProjectButtonTooltip project={project} />
@@ -28,9 +28,10 @@ function ProjectListTableRow({ project, selected }: ProjectListTableRowProps) {
</a>{' '}
<InlineTags className="d-none d-md-inline" projectId={project.id} />
</td>
<td className="dash-cell-date-owner pb-0 d-md-none">
<td className="dash-cell-meta d-md-none">
<LastUpdatedCell project={project} />
{ownerName ? <ProjectListOwnerName ownerName={ownerName} /> : null}
<InlineTags projectId={project.id} className="ms-1" />
</td>
<td className="dash-cell-format d-none d-md-table-cell">
<FormatCell project={project} />
@@ -41,9 +42,6 @@ function ProjectListTableRow({ project, selected }: ProjectListTableRowProps) {
<td className="dash-cell-date d-none d-md-table-cell">
<LastUpdatedCell project={project} />
</td>
<td className="dash-cell-tag pt-0 d-md-none">
<InlineTags projectId={project.id} />
</td>
<td className="dash-cell-actions">
<div className="d-none d-lg-block">
<ActionsCell project={project} />
@@ -30,7 +30,7 @@ export default function WelcomeMessage() {
{wikiEnabled && (
<WelcomeMessageLink
imgSrc={learnLatexImage}
title="Learn LaTeX with a tutorial"
title={t('learn_latex_with_a_tutorial')}
href="/learn/latex/Learn_LaTeX_in_30_minutes"
target="_blank"
/>
@@ -38,7 +38,7 @@ export default function WelcomeMessage() {
{templatesEnabled && (
<WelcomeMessageLink
imgSrc={browseTemplatesImage}
title="Browse templates"
title={t('browse_templates')}
href="/templates"
/>
)}
@@ -7,6 +7,7 @@ import { usePermissionsContext } from '@/features/ide-react/context/permissions-
import { ProjectCompiler } from '@ol-types/project-settings'
import { useSetCompilationSettingWithEvent } from '@/features/editor-left-menu/hooks/use-set-compilation-setting'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { findInTree } from '@/features/file-tree/util/find-in-tree'
// Which compiler engines make sense for a given root-file extension. CLSI
// dispatches the real engine from this extension (.qmd → Quarto, .typ → Typst,
@@ -34,7 +35,7 @@ export default function CompilerSetting() {
const [compilerOptions] = useState(() => getCompilerOptions())
const { t } = useTranslation()
const { write } = usePermissionsContext()
const { docs } = useFileTreeData()
const { docs, fileTreeData } = useFileTreeData()
const changeCompiler = useSetCompilationSettingWithEvent(
'compiler',
setCompiler
@@ -43,14 +44,21 @@ export default function CompilerSetting() {
// Disable the engines that don't apply to the current root file's extension.
// The currently-selected engine is always left enabled so it keeps showing.
const options = useMemo(() => {
const rootDoc = rootDocId
? docs?.find(doc => doc.doc.id === rootDocId)
: undefined
const extension = rootDoc?.doc.name.split('.').pop()?.toLowerCase()
let rootDocName: string | undefined
if (rootDocId) {
const fromDocs = docs?.find(doc => doc.doc.id === rootDocId)
if (fromDocs) {
rootDocName = fromDocs.doc.name
} else if (fileTreeData) {
// Fallback: look up directly in tree in case of ID format mismatch
const found = findInTree(fileTreeData, rootDocId)
if (found?.type === 'doc') rootDocName = found.entity.name
}
}
const extension = rootDocName?.split('.').pop()?.toLowerCase()
const allowed = extension ? ENGINES_BY_EXTENSION[extension] : undefined
if (!allowed) {
// Unknown / no root file: don't restrict anything.
return compilerOptions
}
@@ -0,0 +1,52 @@
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import Setting from '../setting'
import OLFormSelect from '@/shared/components/ol/ol-form-select'
export default function InterfaceLanguageSetting() {
const { t } = useTranslation()
const currentLangCode = getMeta('ol-i18n').currentLangCode
const footerMeta = getMeta('ol-footer')
const availableLanguages: string[] = useMemo(
() => footerMeta?.availableLanguages ?? [],
[footerMeta]
)
const translatedLanguages: Record<string, string> = useMemo(
() => footerMeta?.translatedLanguages ?? {},
[footerMeta]
)
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLSelectElement>) => {
const lng = event.target.value
const returnTo = encodeURIComponent(window.location.pathname)
window.location.href = `/set-language?lng=${encodeURIComponent(lng)}&return_to=${returnTo}`
},
[]
)
if (availableLanguages.length <= 1) return null
return (
<Setting controlId="interfaceLanguage" label={t('interface_language')}>
<OLFormSelect
id="interfaceLanguage"
className="ide-dropdown-setting ide-dropdown-setting-wide"
size="sm"
value={currentLangCode}
onChange={handleChange}
translate="no"
>
{availableLanguages
.filter(lng => translatedLanguages[lng])
.map(lng => (
<option key={lng} value={lng}>
{translatedLanguages[lng]}
</option>
))}
</OLFormSelect>
</Setting>
)
}
@@ -9,6 +9,7 @@ import PDFViewerSetting from '@/features/settings/components/editor-settings/pdf
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import SpellCheckSetting from '@/features/settings/components/editor-settings/spell-check-setting'
import DictionarySetting from '@/features/settings/components/editor-settings/dictionary-setting'
import InterfaceLanguageSetting from '@/features/settings/components/editor-settings/interface-language-setting'
import { useTranslation } from 'react-i18next'
import BreadcrumbsSetting from '@/features/settings/components/editor-settings/breadcrumbs-setting'
import NonBlinkingCursorSetting from '@/features/settings/components/editor-settings/non-blinking-cursor-setting'
@@ -165,6 +166,10 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
key: 'dictionary-settings',
component: <DictionarySetting />,
},
{
key: 'interfaceLanguage',
component: <InterfaceLanguageSetting />,
},
],
},
...spellcheckExtraSections,
@@ -0,0 +1,82 @@
import { EditorSelection } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { syntaxTree } from '@codemirror/language'
import { matchingAncestor } from '../utils/tree-operations/ancestors'
import { type TypstFormattingNode } from '../utils/tree-operations/formatting'
import { wrapRanges } from './ranges'
/**
* Toggle Typst symmetric markup (e.g. *bold* or _italic_).
*
* If the cursor/selection is already inside a matching syntax node the
* surrounding markers are removed; otherwise the selection is wrapped.
*/
export const toggleTypstMarkup =
(marker: string, nodeTypeName: TypstFormattingNode) =>
(view: EditorView): boolean => {
if (view.state.readOnly) return false
view.dispatch(
view.state.changeByRange(range => {
const tree = syntaxTree(view.state)
const node = tree.resolveInner(range.from, -1)
const formattingNode = matchingAncestor(
node,
n => n.type.name === nodeTypeName
)
if (formattingNode) {
const mLen = marker.length
const fFrom = formattingNode.from
const fTo = formattingNode.to
// Adjust the selection for the two deletions applied simultaneously.
// Positions after fFrom shift left by mLen (opening deletion).
// Positions at or after fTo shift left by another mLen (closing deletion).
const newFrom =
range.from > fFrom ? range.from - mLen : range.from
const newTo =
range.to -
(range.to > fFrom ? mLen : 0) -
(range.to >= fTo ? mLen : 0)
return {
changes: [
{ from: fFrom, to: fFrom + mLen, insert: '' },
{ from: fTo - mLen, to: fTo, insert: '' },
],
range: range.empty
? EditorSelection.cursor(newFrom)
: EditorSelection.range(newFrom, newTo),
}
}
// Wrap: insert markers around the selection
const content = view.state.sliceDoc(range.from, range.to)
return {
changes: {
from: range.from,
to: range.to,
insert: `${marker}${content}${marker}`,
},
range: range.empty
? EditorSelection.cursor(range.from + marker.length)
: EditorSelection.range(
range.from + marker.length,
range.to + marker.length
),
}
}),
{ scrollIntoView: true }
)
return true
}
// Wraps selection in #link("")[…] and places the cursor in the URL field.
// Prefix breakdown: # l i n k ( " " ) [ = 10 chars; URL slot is at offset 7.
export const wrapTypstLink = wrapRanges(
'#link("")[',
']',
false,
range => EditorSelection.cursor(range.from - 3)
)
@@ -12,7 +12,7 @@ function SwitchToPDFButton() {
return null
}
if (pdfLayout === 'sideBySide') {
if (pdfLayout === 'sideBySide' || pdfLayout === 'verticalSplit') {
return null
}
@@ -12,7 +12,10 @@ import { MathDropdown } from './math-dropdown'
import { InsertListDropdown } from './insert-list-dropdown'
import { TableDropdown } from './table-dropdown'
import { LegacyTableDropdown } from './table-inserter-dropdown-legacy'
import { withinFormattingCommand } from '@/features/source-editor/utils/tree-operations/formatting'
import {
withinFormattingCommand,
withinTypstFormatting,
} from '@/features/source-editor/utils/tree-operations/formatting'
import { isMac } from '@/shared/utils/os'
import { useProjectContext } from '@/shared/context/project-context'
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
@@ -41,6 +44,7 @@ export const ToolbarItems: FC<{
const { features } = useProjectContext()
const permissions = usePermissionsContext()
const isActive = withinFormattingCommand(state)
const isTypstActive = withinTypstFormatting(state)
const symbolPaletteAvailable = getMeta('ol-symbolPaletteAvailable')
const showGroup = (group: string) => !overflowed || overflowed.has(group)
@@ -189,6 +193,61 @@ export const ToolbarItems: FC<{
)}
</>
)}
{languageName === 'typst' && (
<>
{showGroup('group-format') && (
<div
className="ol-cm-toolbar-button-group"
aria-label={t('toolbar_text_style')}
>
<ToolbarButton
id="toolbar-format-bold"
label={t('toolbar_bold')}
command={commands.toggleTypstBold}
active={isTypstActive('Strong')}
icon="format_bold"
shortcut={isMac ? '⌘B' : 'Ctrl+B'}
/>
<ToolbarButton
id="toolbar-format-italic"
label={t('toolbar_italic')}
command={commands.toggleTypstItalic}
active={isTypstActive('Emphasis')}
icon="format_italic"
shortcut={isMac ? '⌘I' : 'Ctrl+I'}
/>
<ToolbarButton
id="toolbar-format-underline"
label={t('toolbar_underline')}
command={commands.wrapTypstUnderline}
icon="format_underlined"
/>
<ToolbarButton
id="toolbar-format-smallcaps"
label={t('toolbar_smallcaps')}
command={commands.wrapTypstSmallcaps}
icon="Sc"
textIcon
/>
</div>
)}
{showGroup('group-misc') && (
<div
className="ol-cm-toolbar-button-group"
data-overflow="group-misc"
aria-label={t('toolbar_insert_misc')}
>
<ToolbarButton
id="toolbar-typst-href"
label={t('toolbar_insert_link')}
command={commands.wrapTypstLink}
icon="add_link"
shortcut={isMac ? '⌘K' : 'Ctrl+K'}
/>
</div>
)}
</>
)}
</>
)
})
@@ -7,6 +7,10 @@ import {
} from '@codemirror/search'
import { sendMB } from '@/infrastructure/event-tracking'
import { toggleRanges, wrapRanges } from '../../commands/ranges'
import {
toggleTypstMarkup,
wrapTypstLink,
} from '../../commands/typst-ranges'
import {
ancestorListType,
toggleListForRanges,
@@ -25,6 +29,11 @@ import { sendSearchEvent } from '@/features/event-tracking/search-events'
export const toggleBold = toggleRanges('\\textbf')
export const toggleItalic = toggleRanges('\\textit')
export const toggleTypstBold = toggleTypstMarkup('*', 'Strong')
export const toggleTypstItalic = toggleTypstMarkup('_', 'Emphasis')
export const wrapTypstUnderline = wrapRanges('#underline[', ']')
export const wrapTypstSmallcaps = wrapRanges('#smallcaps[', ']')
export { wrapTypstLink }
// TODO: apply as a snippet?
// TODO: read URL from clipboard?
@@ -0,0 +1,206 @@
import {
Decoration,
DecorationSet,
ViewPlugin,
ViewUpdate,
} from '@codemirror/view'
import { EditorState, Range } from '@codemirror/state'
import { syntaxTree } from '@codemirror/language'
import { Tree } from '@lezer/common'
import { selectionIntersects } from './selection'
function shouldDecorate(
state: EditorState,
from: number,
to: number
): boolean {
return state.readOnly || !selectionIntersects(state.selection, { from, to })
}
const ATX_HEADING_LEVEL: Record<string, string> = {
ATXHeading1: '1',
ATXHeading2: '2',
ATXHeading3: '3',
ATXHeading4: '4',
ATXHeading5: '5',
ATXHeading6: '6',
}
export const quartoDecorations = ViewPlugin.define(
view => {
const createDecorations = (
state: EditorState,
tree: Tree
): DecorationSet => {
const decorations: Range<Decoration>[] = []
for (const { from, to } of view.visibleRanges) {
tree.iterate({
from,
to,
enter(nodeRef) {
const { from: nFrom, to: nTo } = nodeRef
const level = ATX_HEADING_LEVEL[nodeRef.type.name]
// ── ATX headings: # Title, ## Title, … ─────────────────────────
if (level !== undefined) {
const mark = nodeRef.node.getChild('HeaderMark')
if (mark) {
const cls = `ol-cm-md-heading ol-cm-md-h${level}`
if (shouldDecorate(state, nFrom, nTo)) {
// Hide the `# ` prefix (mark + optional trailing space)
const afterMark = state.doc.sliceString(mark.to, mark.to + 1)
const contentFrom =
afterMark === ' ' ? mark.to + 1 : mark.to
decorations.push(
Decoration.replace({}).range(nFrom, contentFrom)
)
if (contentFrom < nTo) {
decorations.push(
Decoration.mark({ class: cls }).range(contentFrom, nTo)
)
}
}
}
return false
}
switch (nodeRef.type.name) {
// ── Bold: **text** or __text__ ─────────────────────────────────
case 'StrongEmphasis': {
if (shouldDecorate(state, nFrom, nTo)) {
const marks = nodeRef.node.getChildren('EmphasisMark')
const first = marks[0]
const last = marks[marks.length - 1]
if (first && last && first !== last) {
decorations.push(
Decoration.replace({}).range(first.from, first.to)
)
if (last.from > first.to) {
decorations.push(
Decoration.mark({
class: 'ol-cm-md-strong',
inclusive: true,
}).range(first.to, last.from)
)
}
decorations.push(
Decoration.replace({}).range(last.from, last.to)
)
}
}
return false
}
// ── Italic: *text* or _text_ ────────────────────────────────────
case 'Emphasis': {
if (shouldDecorate(state, nFrom, nTo)) {
const marks = nodeRef.node.getChildren('EmphasisMark')
const first = marks[0]
const last = marks[marks.length - 1]
if (first && last && first !== last) {
decorations.push(
Decoration.replace({}).range(first.from, first.to)
)
if (last.from > first.to) {
decorations.push(
Decoration.mark({
class: 'ol-cm-md-emph',
inclusive: true,
}).range(first.to, last.from)
)
}
decorations.push(
Decoration.replace({}).range(last.from, last.to)
)
}
}
return false
}
// ── Strikethrough: ~~text~~ ─────────────────────────────────────
case 'Strikethrough': {
if (shouldDecorate(state, nFrom, nTo)) {
const marks = nodeRef.node.getChildren('StrikethroughMark')
const first = marks[0]
const last = marks[marks.length - 1]
if (first && last && first !== last) {
decorations.push(
Decoration.replace({}).range(first.from, first.to)
)
if (last.from > first.to) {
decorations.push(
Decoration.mark({
class: 'ol-cm-md-strikethrough',
inclusive: true,
}).range(first.to, last.from)
)
}
decorations.push(
Decoration.replace({}).range(last.from, last.to)
)
}
}
return false
}
// ── Inline code: `code` ─────────────────────────────────────────
case 'InlineCode': {
if (shouldDecorate(state, nFrom, nTo)) {
const marks = nodeRef.node.getChildren('CodeMark')
const first = marks[0]
const last = marks[marks.length - 1]
if (first && last && first !== last) {
decorations.push(
Decoration.replace({}).range(first.from, first.to)
)
if (last.from > first.to) {
decorations.push(
Decoration.mark({
class: 'ol-cm-md-inline-code',
}).range(first.to, last.from)
)
}
decorations.push(
Decoration.replace({}).range(last.from, last.to)
)
}
}
return false
}
}
},
})
}
return Decoration.set(decorations, true)
}
let previousTree = syntaxTree(view.state)
return {
decorations: createDecorations(view.state, previousTree),
update(update: ViewUpdate) {
const tree = syntaxTree(update.state)
if (
tree.type === previousTree.type &&
tree.length < update.view.viewport.to
) {
this.decorations = this.decorations.map(update.changes)
} else if (
tree !== previousTree ||
update.viewportChanged ||
update.selectionSet
) {
previousTree = tree
this.decorations = createDecorations(update.state, tree)
}
},
}
},
{
decorations(value) {
return value.decorations
},
}
)
@@ -0,0 +1,159 @@
import {
Decoration,
DecorationSet,
ViewPlugin,
ViewUpdate,
} from '@codemirror/view'
import { EditorState, Range } from '@codemirror/state'
import { syntaxTree } from '@codemirror/language'
import { Tree } from '@lezer/common'
import { selectionIntersects } from './selection'
function shouldDecorate(
state: EditorState,
from: number,
to: number
): boolean {
return state.readOnly || !selectionIntersects(state.selection, { from, to })
}
export const typstDecorations = ViewPlugin.define(
view => {
const createDecorations = (
state: EditorState,
tree: Tree
): DecorationSet => {
const decorations: Range<Decoration>[] = []
for (const { from, to } of view.visibleRanges) {
tree.iterate({
from,
to,
enter(nodeRef) {
const { from: nFrom, to: nTo } = nodeRef
switch (nodeRef.type.name) {
case 'Strong': {
// *bold text*
if (shouldDecorate(state, nFrom, nTo)) {
decorations.push(Decoration.replace({}).range(nFrom, nFrom + 1))
const body = nodeRef.node.getChild('StrongBody')
if (body) {
decorations.push(
Decoration.mark({
class: 'ol-cm-typst-strong',
inclusive: true,
}).range(body.from, body.to)
)
}
if (nTo > nFrom + 1) {
decorations.push(Decoration.replace({}).range(nTo - 1, nTo))
}
}
return false
}
case 'Emphasis': {
// _italic text_
if (shouldDecorate(state, nFrom, nTo)) {
decorations.push(Decoration.replace({}).range(nFrom, nFrom + 1))
const body = nodeRef.node.getChild('EmphBody')
if (body) {
decorations.push(
Decoration.mark({
class: 'ol-cm-typst-emph',
inclusive: true,
}).range(body.from, body.to)
)
}
if (nTo > nFrom + 1) {
decorations.push(Decoration.replace({}).range(nTo - 1, nTo))
}
}
return false
}
case 'Heading': {
// = Title / == Title / === Title …
const markNode = nodeRef.node.getChild('HeadingMark')
const titleNode = nodeRef.node.getChild('HeadingTitle')
if (markNode && titleNode) {
const markText = state.doc.sliceString(
markNode.from,
markNode.to
)
const level = (markText.match(/^=+/) ?? ['='])[0].length
const cls = `ol-cm-typst-heading ol-cm-typst-h${Math.min(level, 6)}`
if (shouldDecorate(state, markNode.from, markNode.to)) {
decorations.push(
Decoration.replace({}).range(markNode.from, markNode.to)
)
}
decorations.push(
Decoration.mark({ class: cls }).range(
titleNode.from,
titleNode.to
)
)
}
return false
}
case 'RawInline': {
// `inline code`
if (shouldDecorate(state, nFrom, nTo)) {
const content = nodeRef.node.getChild('RawInlineContent')
if (content) {
decorations.push(
Decoration.replace({}).range(nFrom, content.from)
)
decorations.push(
Decoration.mark({
class: 'ol-cm-typst-raw-inline',
}).range(content.from, content.to)
)
decorations.push(
Decoration.replace({}).range(content.to, nTo)
)
}
}
return false
}
}
},
})
}
return Decoration.set(decorations, true)
}
let previousTree = syntaxTree(view.state)
return {
decorations: createDecorations(view.state, previousTree),
update(update: ViewUpdate) {
const tree = syntaxTree(update.state)
if (
tree.type === previousTree.type &&
tree.length < update.view.viewport.to
) {
// still parsing — just map existing decorations over any edits
this.decorations = this.decorations.map(update.changes)
} else if (
tree !== previousTree ||
update.viewportChanged ||
update.selectionSet // ← re-evaluate on cursor movement to show/hide markers
) {
previousTree = tree
this.decorations = createDecorations(update.state, tree)
}
},
}
},
{
decorations(value) {
return value.decorations
},
}
)
@@ -467,6 +467,63 @@ const mainVisualTheme = EditorView.theme({
padding: '0 0.5em',
},
},
// ── Quarto / Markdown visual decorations ─────────────────────────────────
'.ol-cm-md-strong': {
fontWeight: 700,
},
'.ol-cm-md-emph': {
fontStyle: 'italic',
},
'.ol-cm-md-strikethrough': {
textDecoration: 'line-through',
},
'.ol-cm-md-inline-code': {
fontFamily: 'var(--source-font-family)',
fontSize: '0.9em',
backgroundColor: 'rgba(125, 125, 125, 0.1)',
borderRadius: '3px',
lineHeight: 1,
},
'.ol-cm-md-heading': {
fontWeight: 550,
lineHeight: '1.35',
color: 'inherit !important',
background: 'inherit !important',
},
'.ol-cm-md-h1': { fontSize: '2em' },
'.ol-cm-md-h2': { fontSize: '1.6em' },
'.ol-cm-md-h3': { fontSize: '1.44em' },
'.ol-cm-md-h4': { fontSize: '1.2em' },
'.ol-cm-md-h5': { fontSize: '1em' },
'.ol-cm-md-h6': { fontSize: '1em' },
// ── Typst visual decorations ──────────────────────────────────────────────
'.ol-cm-typst-strong': {
fontWeight: 700,
},
'.ol-cm-typst-emph': {
fontStyle: 'italic',
},
'.ol-cm-typst-heading': {
fontWeight: 550,
lineHeight: '1.35',
color: 'inherit !important',
background: 'inherit !important',
},
'.ol-cm-typst-h1': { fontSize: '2em' },
'.ol-cm-typst-h2': { fontSize: '1.6em' },
'.ol-cm-typst-h3': { fontSize: '1.44em' },
'.ol-cm-typst-h4': { fontSize: '1.2em' },
'.ol-cm-typst-h5': { fontSize: '1em' },
'.ol-cm-typst-h6': { fontSize: '1em' },
'.ol-cm-typst-raw-inline': {
fontFamily: 'var(--source-font-family)',
fontSize: '0.9em',
backgroundColor: 'rgba(125, 125, 125, 0.1)',
borderRadius: '3px',
lineHeight: 1,
},
})
const contentWidthThemeConf = new Compartment()
@@ -20,6 +20,8 @@ import { listItemMarker } from './list-item-marker'
import { pasteHtml } from './paste-html'
import { commandTooltip } from '../command-tooltip'
import { tableGeneratorTheme } from './table-generator'
import { typstDecorations } from './typst-decorations'
import { quartoDecorations } from './quarto-decorations'
import { debugConsole } from '@/utils/debugging'
import { PreviewPath } from '../../../../../../types/preview-path'
@@ -183,6 +185,8 @@ const extension = (options: Options) => [
atomicDecorations(options),
visualEditorExtensions.map(extension => extension(options)),
markDecorations, // NOTE: must be after atomicDecorations, so that mark decorations wrap inline widgets
typstDecorations,
quartoDecorations,
visualKeymap,
commandTooltip,
scrollJumpAdjuster,
@@ -10,6 +10,7 @@ import { styleTags, tags as t } from '@lezer/highlight'
import { parser } from '../../lezer-typst/typst.mjs'
import { typstCompletions } from './complete'
import { typstDocumentOutline } from './document-outline'
import { shortcuts } from './shortcuts'
// Note on tree structure: rules starting with a lowercase letter in the grammar
// are inline (no tree node), so their children are promoted to the parent.
@@ -111,5 +112,6 @@ export const typst = () => {
TypstLanguage.data.of({ autocomplete: typstCompletions }),
typstDocumentOutline,
syntaxHighlighting(typstHighlightStyle),
shortcuts(),
])
}
@@ -0,0 +1,28 @@
import { Prec } from '@codemirror/state'
import { keymap } from '@codemirror/view'
import { toggleTypstMarkup, wrapTypstLink } from '../../commands/typst-ranges'
export const shortcuts = () => {
return Prec.high(
keymap.of([
{
key: 'Ctrl-b',
mac: 'Mod-b',
preventDefault: true,
run: toggleTypstMarkup('*', 'Strong'),
},
{
key: 'Ctrl-i',
mac: 'Mod-i',
preventDefault: true,
run: toggleTypstMarkup('_', 'Emphasis'),
},
{
key: 'Ctrl-k',
mac: 'Mod-k',
preventDefault: true,
run: wrapTypstLink,
},
])
)
}
@@ -5,6 +5,20 @@ import {
matchingAncestor,
} from '@/features/source-editor/utils/tree-operations/ancestors'
export type TypstFormattingNode = 'Strong' | 'Emphasis'
export const withinTypstFormatting = (state: EditorState) => {
const tree = syntaxTree(state)
return (nodeTypeName: TypstFormattingNode): boolean => {
const isFormatted = (range: SelectionRange): boolean => {
const node = tree.resolveInner(range.from, -1)
return Boolean(matchingAncestor(node, n => n.type.name === nodeTypeName))
}
return state.selection.ranges.every(isFormatted)
}
}
export type FormattingCommand = '\\textbf' | '\\textit'
export type FormattingNodeType = string | number
@@ -70,6 +70,7 @@ async function uploadOne(
headers: {
'X-CSRF-TOKEN': getMeta('ol-csrfToken'),
},
signal: AbortSignal.timeout(15 * 60 * 1000),
})
if (!response.ok) {
@@ -5,6 +5,7 @@ import type {
import OLRow from '@/shared/components/ol/ol-row'
import LanguagePicker from '@/shared/components/language-picker'
import React from 'react'
import { useTranslation } from 'react-i18next'
function FooterItemLi({
text,
@@ -34,16 +35,19 @@ function FooterItemLi({
function Separator() {
return (
<li role="separator" className="text-muted">
<li role="separator" className="text-muted footer-sep">
<strong>|</strong>
</li>
)
}
function ThinFooter({ subdomainLang, leftItems, rightItems }: FooterMetadata) {
const showLanguagePicker = Boolean(
subdomainLang && Object.keys(subdomainLang).length > 1
)
function ThinFooter({
availableLanguages,
leftItems,
rightItems,
}: FooterMetadata) {
const { t } = useTranslation()
const showLanguagePicker = (availableLanguages?.length ?? 0) > 1
return (
<footer className="site-footer">
@@ -62,7 +66,7 @@ function ThinFooter({ subdomainLang, leftItems, rightItems }: FooterMetadata) {
</li>
<Separator />
<li>
Built on{' '}
{t('built_on')}{' '}
<a
href="https://github.com/overleaf/overleaf"
target="_blank"
@@ -83,14 +87,14 @@ function ThinFooter({ subdomainLang, leftItems, rightItems }: FooterMetadata) {
<FooterItemLi key={item.text} {...item} />
))}
</ul>
<ul className="site-footer-items col-lg-3 text-end">
<ul className="site-footer-items col-lg-3 text-lg-end">
<li>
<a
href="https://git.alocoq.fr/alois/verso/src/branch/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
>
AGPL licence
{t('agpl_licence')}
</a>
</li>
<Separator />
@@ -100,7 +104,7 @@ function ThinFooter({ subdomainLang, leftItems, rightItems }: FooterMetadata) {
target="_blank"
rel="noopener noreferrer"
>
Source code
{t('source_code')}
</a>
</li>
{rightItems?.map(item => (
@@ -14,23 +14,23 @@ function LanguagePicker({ showHeader } = { showHeader: false }) {
const { t } = useTranslation()
const currentLangCode = getMeta('ol-i18n').currentLangCode
const translatedLanguages = getMeta('ol-footer').translatedLanguages
const subdomainLang = getMeta('ol-footer').subdomainLang
const currentUrlWithQueryParams = window.location.pathname
const footerMeta = getMeta('ol-footer')
const translatedLanguages = footerMeta?.translatedLanguages
const availableLanguages: string[] = footerMeta?.availableLanguages ?? []
const currentUrl = window.location.pathname
return (
<Dropdown drop="up">
<DropdownToggle
id="language-picker-toggle"
aria-label={t('select_a_language')}
data-bs-toggle="dropdown"
className="btn-inline-link"
variant="link"
>
<MaterialIcon type="translate" />
&nbsp;
<span className="language-picker-text">
{translatedLanguages?.[currentLangCode]}
{translatedLanguages?.[currentLangCode] ?? currentLangCode}
</span>
</DropdownToggle>
@@ -39,24 +39,19 @@ function LanguagePicker({ showHeader } = { showHeader: false }) {
aria-labelledby="language-picker-toggle"
>
{showHeader ? <DropdownHeader>{t('language')}</DropdownHeader> : null}
{subdomainLang &&
Object.entries(subdomainLang).map(([subdomain, subdomainDetails]) => {
if (
!subdomainDetails ||
!subdomainDetails.lngCode ||
subdomainDetails.hide
)
return null
const isActive = subdomainDetails.lngCode === currentLangCode
{availableLanguages
.filter(lng => translatedLanguages?.[lng])
.map(lng => {
const isActive = lng === currentLangCode
return (
<li role="none" key={subdomain} translate="no">
<li role="none" key={lng} translate="no">
<DropdownItem
href={`${subdomainDetails.url}${currentUrlWithQueryParams}`}
href={`/set-language?lng=${encodeURIComponent(lng)}&return_to=${encodeURIComponent(currentUrl)}`}
active={isActive}
aria-current={isActive ? 'true' : false}
trailingIcon={isActive ? 'check' : null}
>
{translatedLanguages?.[subdomainDetails.lngCode]}
{translatedLanguages![lng]}
</DropdownItem>
</li>
)
@@ -4,6 +4,7 @@ import type { NavbarSessionUser } from '@/shared/components/types/navbar'
import NavLinkItem from '@/shared/components/navbar/nav-link-item'
import { AccountMenuItems } from './account-menu-items'
import { useSendProjectListMB } from '@/features/project-list/components/project-list-events'
import { User } from '@phosphor-icons/react'
export default function LoggedInItems({
sessionUser,
@@ -20,7 +21,7 @@ export default function LoggedInItems({
{t('projects')}
</NavLinkItem>
<NavDropdownMenu
title={t('Account')}
title={<><User size={20} className="me-1" />{t('Account')}</>}
className="nav-item-account"
onToggle={nextShow => {
if (nextShow) {
@@ -34,6 +35,7 @@ export default function LoggedInItems({
<AccountMenuItems
sessionUser={sessionUser}
showSubscriptionLink={showSubscriptionLink}
showThemeToggle
/>
</NavDropdownMenu>
</>
@@ -9,7 +9,7 @@ export default function NavDropdownMenu({
children,
onToggle,
}: {
title: string
title: ReactNode
className?: string
children: ReactNode
onToggle?: (nextShow: boolean) => void
@@ -16,11 +16,13 @@ import versoLogoDark from '@/shared/svgs/verso-logo-dark.svg'
export function SidebarLowerSection({
showThemeToggle = false,
showAccountIcons = true,
accountRef,
onAccountOpen,
children,
}: {
showThemeToggle?: boolean
showAccountIcons?: boolean
accountRef?: Ref<HTMLDivElement>
onAccountOpen?: () => void
children?: React.ReactNode
@@ -42,7 +44,7 @@ export function SidebarLowerSection({
return (
<>
{children}
<nav
{showAccountIcons && <nav
className="d-flex flex-row gap-3 mb-2"
aria-label={t('account_help')}
>
@@ -132,7 +134,7 @@ export function SidebarLowerSection({
</Dropdown.Menu>
</Dropdown>
)}
</nav>
</nav>}
<a
href="/"
className="ds-nav-verso-logo"
@@ -13,6 +13,7 @@ export type FooterMetadata = {
translatedLanguages: { [key: string]: string }
showPoweredBy?: boolean
subdomainLang?: SubdomainLang
availableLanguages?: string[]
leftItems?: FooterItem[]
rightItems?: FooterItem[]
}
@@ -25,7 +25,7 @@ import usePersistedState from '@/shared/hooks/use-persisted-state'
import { repositionAllTooltips } from '@/features/source-editor/extensions/tooltips-reposition'
import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
export type IdeLayout = 'sideBySide' | 'flat'
export type IdeLayout = 'sideBySide' | 'flat' | 'verticalSplit'
export type IdeView = 'editor' | 'file' | 'pdf' | 'history'
export type LayoutContextOwnStates = {
@@ -77,10 +77,39 @@ export const LayoutContext = createContext<LayoutContextValue | undefined>(
function setLayoutInLocalStorage(pdfLayout: IdeLayout) {
localStorage.setItem(
'pdf.layout',
pdfLayout === 'sideBySide' ? 'split' : 'flat'
pdfLayout === 'sideBySide'
? 'split'
: pdfLayout === 'verticalSplit'
? 'vertical'
: 'flat'
)
}
const MOBILE_MQ = '(max-width: 767px)'
// Secondary touch check: catches browsers that spoof viewport width (e.g. Tor
// Browser's fingerprinting resistance reports ~980px on an Android phone).
// `pointer: coarse` reflects real hardware and is not spoofed.
const TOUCH_MQ = '(pointer: coarse) and (max-width: 1024px)'
function isMobileDevice(): boolean {
return (
window.matchMedia(MOBILE_MQ).matches ||
window.matchMedia(TOUCH_MQ).matches
)
}
function getInitialLayout(): IdeLayout {
const stored = localStorage.getItem('pdf.layout')
// Check mobile first — must come before the stored-'flat' check so that a
// stale 'flat' (written by an old autoSave race) doesn't block the mobile
// default. isMobileDevice() also catches spoofed-viewport browsers.
if (isMobileDevice()) return 'verticalSplit'
if (stored === 'flat') return 'flat'
if (stored === 'split') return 'sideBySide'
if (stored === 'vertical') return 'verticalSplit'
return 'sideBySide'
}
const reviewPanelStorageKey = `ui.reviewPanelOpen.${getMeta('ol-project_id')}`
export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
@@ -192,14 +221,17 @@ export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
)
// whether to display the editor and preview side-by-side or full-width ("flat")
const [pdfLayout, setPdfLayout] = useState<IdeLayout>('sideBySide')
const [pdfLayout, setPdfLayout] = useState<IdeLayout>(getInitialLayout)
// whether stylesheet on theme is loading
const [loadingStyleSheet, setLoadingStyleSheet] = useState(false)
const changeLayout = useCallback(
(newLayout: IdeLayout, newView: IdeView = 'editor') => {
const targetView = newLayout === 'sideBySide' ? 'editor' : newView
const targetView =
newLayout === 'sideBySide' || newLayout === 'verticalSplit'
? 'editor'
: newView
setPdfLayout(newLayout)
if (targetView === 'editor') {
restoreView()
@@ -230,7 +262,10 @@ export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
} = useDetachLayout()
const pdfPreviewOpen =
pdfLayout === 'sideBySide' || view === 'pdf' || detachRole === 'detacher'
pdfLayout === 'sideBySide' ||
pdfLayout === 'verticalSplit' ||
view === 'pdf' ||
detachRole === 'detacher'
useEffect(() => {
if (debugPdfDetach) {
@@ -13,7 +13,7 @@ function getTheme(
if (isIEEEBranded()) {
return 'dark'
}
if (overallTheme === 'light-') {
if (overallTheme === 'light-' || overallTheme === 'lumiere-') {
return 'light'
}
if (overallTheme === 'system') {

Some files were not shown because too many files have changed in this diff Show More