diff --git a/README.md b/README.md index 0c08ed1..1bee560 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Overroot is independent and unaffiliated with Overleaf or Underleaf.ai. - PDF viewer that preserves page, zoom, and exact scroll position after recompilation - One-click PDF-only mode with `Escape` to return - Article, report, presentation, and blank templates; file uploads and ZIP import/export +- Academic Classic, Modern ML, and Visual Results poster templates with A0, A1, and custom print sizes - Light, dark, and system appearance across desktop and mobile layouts - Project-scoped, revocable credentials for external agents - SQLite persistence plus an online backup command with bounded snapshot retention @@ -86,6 +87,8 @@ exports always use the original project files. Open a `.tex` file and select **Compile** to flush the current collaborative editor state and run Tectonic on that file. Every editor can compile the file they opened; no owner-selected main file is needed. Successful output appears in the PDF pane for every connected collaborator. A completely empty document body has no printable page, so add content between `\begin{document}` and `\end{document}` before compiling a blank template. +For a poster, choose one of the poster templates at project creation and set its size, orientation, columns, and heading metadata. Edit the modular `sections/*.tex` files in **Files**, compile `poster.tex`, and review the PDF's measured page count and physical dimensions before downloading. See [poster guide](docs/POSTERS.md). + ## External agent access Overroot does not embed an AI model or chat interface. A project owner can authorize an external coding agent to edit the project directly: @@ -134,7 +137,7 @@ Requirements, host setup, runtime paths, deployment commands, and recovery are d ## Compilation isolation -The first real compile builds `overroot-tectonic:0.16.9` if necessary and starts `overroot-tectonic-worker`. It runs as an unprivileged user with dropped capabilities, a read-only root filesystem, PID/CPU/memory limits, Tectonic `--untrusted`, and a compile timeout. Only the jobs directory is writable; downloaded packages remain in the `overroot-tectonic-cache` Docker volume. +The first real compile builds `overroot-tectonic:0.16.9-posters-v1` if necessary and starts `overroot-tectonic-worker`. It runs as an unprivileged user with dropped capabilities, a read-only root filesystem, PID/CPU/memory limits, Tectonic `--untrusted`, and a compile timeout. Only the jobs directory is writable; downloaded packages remain in the `overroot-tectonic-cache` Docker volume. The cache key includes the entry file, compiler context, flags, paths, and every source revision. An identical project state reuses its existing PDF immediately. diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index d28e466..b6e219f 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -38,7 +38,7 @@ export const config = { compileTimeoutSeconds: integer('OVERROOT_COMPILE_TIMEOUT_SECONDS', 45), compileMaxJobs: integer('OVERROOT_COMPILE_MAX_JOBS', 8), compileMaxSnapshotBytes: integer('OVERROOT_COMPILE_MAX_SNAPSHOT_BYTES', 128 * 1024 * 1024), - dockerImage: process.env.OVERROOT_DOCKER_IMAGE ?? 'overroot-tectonic:0.16.9', + dockerImage: process.env.OVERROOT_DOCKER_IMAGE ?? 'overroot-tectonic:0.16.9-posters-v1', dockerContainer: process.env.OVERROOT_DOCKER_CONTAINER ?? 'overroot-tectonic-worker', fakeCompiler: bool('OVERROOT_FAKE_COMPILER', false), trustProxy: bool('OVERROOT_TRUST_PROXY', true), diff --git a/apps/server/src/poster-template-files.ts b/apps/server/src/poster-template-files.ts new file mode 100644 index 0000000..9e80fce --- /dev/null +++ b/apps/server/src/poster-template-files.ts @@ -0,0 +1,58 @@ +// Adapted from Overposter (MIT); see docs/POSTERS.md. +export const posterLicense = `MIT License + +Copyright (c) 2026 Overposter contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +`; +export const posterTemplateFiles: Record> = { + "academic-classic": { + "poster.tex": "\\RequirePackage{fix-cm}\n\\documentclass{article}\n\\input{config.tex}\n\\usepackage[paperwidth=\\PosterWidth mm,paperheight=\\PosterHeight mm,margin=25mm]{geometry}\n\\usepackage{xcolor}\n\\definecolor{accent}{HTML}{23496C}\n\\definecolor{ink}{HTML}{172B40}\n\\input{poster-layout.tex}\n\\newcommand{\\PosterHeading}[1]{{\\color{accent}\\fontsize{\\PosterHeadingSize pt}{42pt}\\selectfont\\bfseries #1}\\par\\hrule height 1.2pt}\n\\begin{document}\n\\IfFileExists{assets/logo.pdf}{\\noindent\\includegraphics[height=25mm]{assets/logo.pdf}\\par\\vspace{4mm}}{}\n\\noindent\\begin{minipage}[c]{\\linewidth}\n{\\color{accent}\\fontsize{\\PosterTitleSize pt}{70pt}\\selectfont\\bfseries\\PosterTitle\\par}\n\\vspace{3mm}{\\fontsize{28}{35}\\selectfont\\PosterSubtitle\\par}\n\\vspace{5mm}\\PosterMetadata\n\\end{minipage}\n\\par\\vspace{5mm}{\\color{accent}\\hrule height 3pt}\\vspace{7mm}\n\\PosterGrid{\n\\PosterBlock{Motivation and question}{\\input{sections/introduction.tex}}\n\\PosterBlock{Method}{\\input{sections/methodology.tex}}\n\\PosterBlock{Study design}{\\input{sections/study.tex}}\n\\PosterBlock{Results}{\\input{sections/results.tex}}\n\\PosterBlock{Interpretation}{\\input{sections/conclusions.tex}}\n\\PosterBlock{References}{\\input{sections/references.tex}}\n\\PosterContactBlock\n}\n\\end{document}\n", + "references.bib": "% Add verified BibTeX entries.\n", + "sections/conclusions.tex": "Summarize the supported finding, limitations, and next step.\n", + "sections/introduction.tex": "State the problem, why it matters, and a precise research question. Replace this guidance with your own evidence and citations.\n", + "sections/methodology.tex": "Explain the method and its assumptions. Equations can be included, for example $\\hat y=f_{\\theta}(x)$.\n", + "sections/references.tex": "Add verified references here. Bibliography data can be stored in \\texttt{references.bib}.\n", + "sections/results.tex": "Add a table, uncertainty intervals, and a visual comparison. Clearly label any illustrative numbers.\n", + "sections/study.tex": "Describe the dataset, baselines, and evaluation protocol. Add a vector diagram in \\texttt{figures/} and include it with \\texttt{\\textbackslash includegraphics}.\n", + "poster-layout.tex": "\\usepackage{amsmath,amssymb,graphicx,xcolor,tikz,multicol,qrcode,hyperref}\n\\hypersetup{colorlinks=true,urlcolor=accent}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0pt}\n\\setlength{\\columnsep}{12mm}\n\\newcommand{\\PosterBody}{\\fontsize{\\PosterBodySize pt}{\\PosterBodyLeading pt}\\selectfont}\n\\newcommand{\\PosterMetadata}{%\n \\ifx\\PosterAuthors\\empty\\else\n {\\fontsize{30}{38}\\selectfont\\PosterAuthors\\par}\n \\fi\n \\ifx\\PosterAffiliation\\empty\\else\n \\vspace{3mm}{\\fontsize{22}{29}\\selectfont\\PosterAffiliation\\par}\n \\fi\n}\n\\newcommand{\\PosterBlock}[2]{%\n \\par\\vspace{5mm}\\noindent\n \\begin{minipage}{\\linewidth}\n \\PosterHeading{#1}\\par\\vspace{3mm}\n {\\PosterBody #2\\par}\\vspace{3mm}\n \\end{minipage}\\par\n}\n\\newcommand{\\PosterGrid}[1]{\\begin{multicols}{\\PosterColumns}#1\\end{multicols}}\n\\newcommand{\\PosterContactBlock}{%\n \\ifx\\PosterPaperURL\\empty\\else\n \\par\\vspace{6mm}\n \\noindent\\begin{minipage}[c]{39mm}\\qrcode[height=35mm]{\\PosterPaperURL}\\end{minipage}%\n \\hspace{5mm}\\begin{minipage}[c]{\\dimexpr\\linewidth-46mm\\relax}\n {\\fontsize{21}{28}\\selectfont Paper and supplementary material\\par\n \\url{\\PosterPaperURL}}\n \\end{minipage}\\par\n \\fi\n \\ifx\\PosterContact\\empty\\else\n \\par\\vspace{4mm}{\\fontsize{21}{28}\\selectfont\\PosterContact\\par}\n \\fi\n}\n" + }, + "modern-ml": { + "poster.tex": "\\RequirePackage{fix-cm}\n\\documentclass{article}\n\\input{config.tex}\n\\usepackage[paperwidth=\\PosterWidth mm,paperheight=\\PosterHeight mm,margin=24mm]{geometry}\n\\usepackage{xcolor}\n\\definecolor{accent}{HTML}{0D766E}\n\\definecolor{ink}{HTML}{153448}\n\\definecolor{pale}{HTML}{E8F3F0}\n\\input{poster-layout.tex}\n\\newcommand{\\PosterHeading}[1]{{\\color{accent}\\fontsize{\\PosterHeadingSize pt}{42pt}\\selectfont\\bfseries #1}\\par}\n\\begin{document}\n\\noindent\\colorbox{ink}{\\begin{minipage}{\\dimexpr\\linewidth-2\\fboxsep\\relax}\n\\vspace{8mm}\\hspace{8mm}\\begin{minipage}[c]{0.80\\linewidth}\n{\\color{white}\\fontsize{\\PosterTitleSize pt}{70pt}\\selectfont\\bfseries\\PosterTitle\\par}\n\\vspace{2mm}{\\color{white}\\fontsize{27}{34}\\selectfont\\PosterSubtitle\\par}\n\\vspace{4mm}{\\color{white}\\PosterMetadata}\n\\end{minipage}\n\\IfFileExists{assets/logo.pdf}{\\hfill\\begin{minipage}[c]{0.13\\linewidth}\\includegraphics[width=\\linewidth]{assets/logo.pdf}\\end{minipage}}{}\n\\vspace{8mm}\n\\end{minipage}}\\par\\vspace{8mm}\n\\noindent\\colorbox{pale}{\\begin{minipage}{\\dimexpr\\linewidth-2\\fboxsep\\relax}\\vspace{5mm}\\hspace{7mm}\\begin{minipage}{0.94\\linewidth}\n{\\color{ink}\\fontsize{25}{32}\\selectfont\\bfseries Key takeaway\\par}\n\\vspace{2mm}{\\PosterBody\\input{sections/conclusions.tex}\\par}\n\\end{minipage}\\vspace{5mm}\\end{minipage}}\\par\\vspace{5mm}\n\\PosterGrid{\n\\PosterBlock{The question}{\\input{sections/introduction.tex}}\n\\PosterBlock{Approach}{\\input{sections/methodology.tex}}\n\\PosterBlock{Experimental setup}{\\input{sections/study.tex}}\n\\PosterBlock{Main result}{\\input{sections/results.tex}}\n\\PosterBlock{Sources}{\\input{sections/references.tex}}\n\\PosterContactBlock\n}\n\\end{document}\n", + "references.bib": "% Add verified BibTeX entries.\n", + "sections/conclusions.tex": "Summarize the supported finding, limitations, and next step.\n", + "sections/introduction.tex": "State the problem, why it matters, and a precise research question. Replace this guidance with your own evidence and citations.\n", + "sections/methodology.tex": "Explain the method and its assumptions. Equations can be included, for example $\\hat y=f_{\\theta}(x)$.\n", + "sections/references.tex": "Add verified references here. Bibliography data can be stored in \\texttt{references.bib}.\n", + "sections/results.tex": "Add a table, uncertainty intervals, and a visual comparison. Clearly label any illustrative numbers.\n", + "sections/study.tex": "Describe the dataset, baselines, and evaluation protocol. Add a vector diagram in \\texttt{figures/} and include it with \\texttt{\\textbackslash includegraphics}.\n", + "poster-layout.tex": "\\usepackage{amsmath,amssymb,graphicx,xcolor,tikz,multicol,qrcode,hyperref}\n\\hypersetup{colorlinks=true,urlcolor=accent}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0pt}\n\\setlength{\\columnsep}{12mm}\n\\newcommand{\\PosterBody}{\\fontsize{\\PosterBodySize pt}{\\PosterBodyLeading pt}\\selectfont}\n\\newcommand{\\PosterMetadata}{%\n \\ifx\\PosterAuthors\\empty\\else\n {\\fontsize{30}{38}\\selectfont\\PosterAuthors\\par}\n \\fi\n \\ifx\\PosterAffiliation\\empty\\else\n \\vspace{3mm}{\\fontsize{22}{29}\\selectfont\\PosterAffiliation\\par}\n \\fi\n}\n\\newcommand{\\PosterBlock}[2]{%\n \\par\\vspace{5mm}\\noindent\n \\begin{minipage}{\\linewidth}\n \\PosterHeading{#1}\\par\\vspace{3mm}\n {\\PosterBody #2\\par}\\vspace{3mm}\n \\end{minipage}\\par\n}\n\\newcommand{\\PosterGrid}[1]{\\begin{multicols}{\\PosterColumns}#1\\end{multicols}}\n\\newcommand{\\PosterContactBlock}{%\n \\ifx\\PosterPaperURL\\empty\\else\n \\par\\vspace{6mm}\n \\noindent\\begin{minipage}[c]{39mm}\\qrcode[height=35mm]{\\PosterPaperURL}\\end{minipage}%\n \\hspace{5mm}\\begin{minipage}[c]{\\dimexpr\\linewidth-46mm\\relax}\n {\\fontsize{21}{28}\\selectfont Paper and supplementary material\\par\n \\url{\\PosterPaperURL}}\n \\end{minipage}\\par\n \\fi\n \\ifx\\PosterContact\\empty\\else\n \\par\\vspace{4mm}{\\fontsize{21}{28}\\selectfont\\PosterContact\\par}\n \\fi\n}\n" + }, + "visual-results": { + "poster.tex": "\\RequirePackage{fix-cm}\n\\documentclass{article}\n\\input{config.tex}\n\\usepackage[paperwidth=\\PosterWidth mm,paperheight=\\PosterHeight mm,margin=24mm]{geometry}\n\\usepackage{xcolor}\n\\definecolor{accent}{HTML}{A54431}\n\\definecolor{ink}{HTML}{25323A}\n\\definecolor{pale}{HTML}{F7EDE8}\n\\input{poster-layout.tex}\n\\newcommand{\\PosterHeading}[1]{{\\color{accent}\\fontsize{\\PosterHeadingSize pt}{42pt}\\selectfont\\bfseries #1}\\par}\n\\begin{document}\n{\\color{accent}\\hrule height 7pt}\\vspace{6mm}\n\\IfFileExists{assets/logo.pdf}{\\noindent\\includegraphics[height=25mm]{assets/logo.pdf}\\par\\vspace{4mm}}{}\n\\noindent\\begin{minipage}[c]{\\linewidth}\n{\\color{ink}\\fontsize{\\PosterTitleSize pt}{70pt}\\selectfont\\bfseries\\PosterTitle\\par}\n\\vspace{3mm}{\\fontsize{27}{34}\\selectfont\\PosterSubtitle\\par}\n\\vspace{5mm}\\PosterMetadata\n\\end{minipage}\n\\par\\vspace{8mm}\n\\noindent\\colorbox{pale}{\\begin{minipage}{\\dimexpr\\linewidth-2\\fboxsep\\relax}\\vspace{5mm}\\hspace{7mm}\\begin{minipage}{0.94\\linewidth}\n\\PosterHeading{Visual evidence}\\vspace{3mm}{\\PosterBody\\input{sections/study.tex}\\par}\n\\end{minipage}\\vspace{5mm}\\end{minipage}}\\par\\vspace{8mm}\n\\PosterGrid{\n\\PosterBlock{Question}{\\input{sections/introduction.tex}}\n\\PosterBlock{Method}{\\input{sections/methodology.tex}}\n\\PosterBlock{Measured outcomes}{\\input{sections/results.tex}}\n\\PosterBlock{What to remember}{\\input{sections/conclusions.tex}}\n\\PosterBlock{References}{\\input{sections/references.tex}}\n\\PosterContactBlock\n}\n\\end{document}\n", + "references.bib": "% Add verified BibTeX entries.\n", + "sections/conclusions.tex": "Summarize the supported finding, limitations, and next step.\n", + "sections/introduction.tex": "State the problem, why it matters, and a precise research question. Replace this guidance with your own evidence and citations.\n", + "sections/methodology.tex": "Explain the method and its assumptions. Equations can be included, for example $\\hat y=f_{\\theta}(x)$.\n", + "sections/references.tex": "Add verified references here. Bibliography data can be stored in \\texttt{references.bib}.\n", + "sections/results.tex": "Add a table, uncertainty intervals, and a visual comparison. Clearly label any illustrative numbers.\n", + "sections/study.tex": "Describe the dataset, baselines, and evaluation protocol. Add a vector diagram in \\texttt{figures/} and include it with \\texttt{\\textbackslash includegraphics}.\n", + "poster-layout.tex": "\\usepackage{amsmath,amssymb,graphicx,xcolor,tikz,multicol,qrcode,hyperref}\n\\hypersetup{colorlinks=true,urlcolor=accent}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0pt}\n\\setlength{\\columnsep}{12mm}\n\\newcommand{\\PosterBody}{\\fontsize{\\PosterBodySize pt}{\\PosterBodyLeading pt}\\selectfont}\n\\newcommand{\\PosterMetadata}{%\n \\ifx\\PosterAuthors\\empty\\else\n {\\fontsize{30}{38}\\selectfont\\PosterAuthors\\par}\n \\fi\n \\ifx\\PosterAffiliation\\empty\\else\n \\vspace{3mm}{\\fontsize{22}{29}\\selectfont\\PosterAffiliation\\par}\n \\fi\n}\n\\newcommand{\\PosterBlock}[2]{%\n \\par\\vspace{5mm}\\noindent\n \\begin{minipage}{\\linewidth}\n \\PosterHeading{#1}\\par\\vspace{3mm}\n {\\PosterBody #2\\par}\\vspace{3mm}\n \\end{minipage}\\par\n}\n\\newcommand{\\PosterGrid}[1]{\\begin{multicols}{\\PosterColumns}#1\\end{multicols}}\n\\newcommand{\\PosterContactBlock}{%\n \\ifx\\PosterPaperURL\\empty\\else\n \\par\\vspace{6mm}\n \\noindent\\begin{minipage}[c]{39mm}\\qrcode[height=35mm]{\\PosterPaperURL}\\end{minipage}%\n \\hspace{5mm}\\begin{minipage}[c]{\\dimexpr\\linewidth-46mm\\relax}\n {\\fontsize{21}{28}\\selectfont Paper and supplementary material\\par\n \\url{\\PosterPaperURL}}\n \\end{minipage}\\par\n \\fi\n \\ifx\\PosterContact\\empty\\else\n \\par\\vspace{4mm}{\\fontsize{21}{28}\\selectfont\\PosterContact\\par}\n \\fi\n}\n" + } +}; diff --git a/apps/server/src/posters.ts b/apps/server/src/posters.ts new file mode 100644 index 0000000..0c0f1a4 --- /dev/null +++ b/apps/server/src/posters.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; +import { posterLicense, posterTemplateFiles } from './poster-template-files.js'; + +export const posterThemes = ['academic-classic', 'modern-ml', 'visual-results'] as const; +export const posterOptionsSchema = z.object({ + subtitle: z.string().trim().max(240).default(''), + authors: z.string().trim().max(500).default(''), + affiliation: z.string().trim().max(500).default(''), + size: z.enum(['A0', 'A1', 'custom']).default('A1'), + orientation: z.enum(['landscape', 'portrait']).default('landscape'), + widthMm: z.number().int().min(200).max(3000).optional(), + heightMm: z.number().int().min(200).max(3000).optional(), + columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(2) +}).superRefine((value, context) => { + if (value.size === 'custom' && (!value.widthMm || !value.heightMm)) { + context.addIssue({ code: 'custom', message: 'Custom posters need a width and height in millimetres' }); + } +}); + +export type PosterOptions = z.infer; + +export function isPosterTheme(value: string): value is typeof posterThemes[number] { + return posterThemes.some((theme) => theme === value); +} + +export function posterDimensions(options: PosterOptions): { widthMm: number; heightMm: number } { + if (options.size === 'custom') return { widthMm: options.widthMm!, heightMm: options.heightMm! }; + const short = options.size === 'A0' ? 841 : 594; + const long = options.size === 'A0' ? 1189 : 841; + return options.orientation === 'landscape' + ? { widthMm: long, heightMm: short } + : { widthMm: short, heightMm: long }; +} + +function texEscape(value: string): string { + const chars: Record = { + '\\': '\\textbackslash{}', '{': '\\{', '}': '\\}', '%': '\\%', '$': '\\$', + '&': '\\&', '#': '\\#', '_': '\\_', '^': '\\textasciicircum{}', '~': '\\textasciitilde{}' + }; + return value.replace(/\s+/g, ' ').replace(/[\\{}%$&#_^~]/g, (character) => chars[character]!); +} + +export function posterFiles(theme: typeof posterThemes[number], title: string, input?: PosterOptions): Record { + const options = posterOptionsSchema.parse(input ?? {}); + const { widthMm, heightMm } = posterDimensions(options); + const source = posterTemplateFiles[theme]!; + const config = [ + `% Poster settings. Edit this file to change the print size and metadata.`, + `\\def\\PosterWidth{${widthMm}}`, + `\\def\\PosterHeight{${heightMm}}`, + `\\def\\PosterTitle{${texEscape(title)}}`, + `\\def\\PosterSubtitle{${texEscape(options.subtitle)}}`, + `\\def\\PosterAuthors{${texEscape(options.authors)}}`, + `\\def\\PosterAffiliation{${texEscape(options.affiliation)}}`, + `\\def\\PosterContact{}`, `\\def\\PosterPaperURL{}`, + `\\def\\PosterColumns{${options.columns}}`, + `\\def\\PosterBodySize{27}`, `\\def\\PosterBodyLeading{35}`, + `\\def\\PosterHeadingSize{40}`, `\\def\\PosterTitleSize{68}`, + '' + ].join('\n'); + return { ...source, 'config.tex': config, 'OVERPOSTER-LICENSE.txt': posterLicense }; +} diff --git a/apps/server/src/project-store.ts b/apps/server/src/project-store.ts index aaed6a1..d1e72ef 100644 --- a/apps/server/src/project-store.ts +++ b/apps/server/src/project-store.ts @@ -4,6 +4,7 @@ import * as Y from 'yjs'; import type { Database } from './database.js'; import { HttpError } from './errors.js'; import { isTextMime, normalizeProjectPath } from './paths.js'; +import { isPosterTheme, posterFiles, type PosterOptions } from './posters.js'; import { fileRevision, randomPublicId } from './security.js'; import { findTemplate } from './templates.js'; import type { ActorIdentity, FileRecord, ProjectRecord, ProjectRole } from './types.js'; @@ -18,10 +19,12 @@ export interface ProjectSummary extends ProjectRecord { export class ProjectStore { constructor(private readonly db: Database) {} - createProject(ownerId: string, nameInput: string, templateId = 'article'): ProjectRecord { + createProject(ownerId: string, nameInput: string, templateId = 'article', poster?: PosterOptions): ProjectRecord { const name = cleanProjectName(nameInput); const template = findTemplate(templateId); if (!template) throw new HttpError(400, 'Unknown project template', 'unknown_template'); + if (poster && !isPosterTheme(templateId)) throw new HttpError(400, 'Poster settings require a poster template', 'invalid_poster_settings'); + const initialFiles = isPosterTheme(templateId) ? posterFiles(templateId, name, poster) : template.files; const now = new Date().toISOString(); const project: ProjectRecord = { id: randomUUID(), @@ -51,7 +54,7 @@ export class ProjectStore { 'owner', now ); - for (const [path, content] of Object.entries(template.files)) { + for (const [path, content] of Object.entries(initialFiles)) { this.insertFile(project.id, path, Buffer.from(content), 'text/plain', { type: 'system', id: 'template', name: template.name }, now); } }); diff --git a/apps/server/src/routes/project-routes.ts b/apps/server/src/routes/project-routes.ts index 4b7f38d..c317bc0 100644 --- a/apps/server/src/routes/project-routes.ts +++ b/apps/server/src/routes/project-routes.ts @@ -7,6 +7,7 @@ import { config } from '../config.js'; import type { AppContext } from '../context.js'; import { HttpError } from '../errors.js'; import { projectAccess, requireOwner } from '../permissions.js'; +import { posterOptionsSchema } from '../posters.js'; import { safeUnzip } from '../safe-zip.js'; import { randomToken, sha256 } from '../security.js'; import { projectTemplates } from '../templates.js'; @@ -31,8 +32,8 @@ export function registerProjectRoutes(app: FastifyInstance, context: AppContext) app.post('/api/v1/projects', async (request, reply) => { const actor = context.auth.requireUser(request); - const input = z.object({ name: z.string(), template: z.string().default('article') }).parse(request.body); - const project = context.projects.createProject(actor.id, input.name, input.template); + const input = z.object({ name: z.string(), template: z.string().default('article'), poster: posterOptionsSchema.optional() }).parse(request.body); + const project = context.projects.createProject(actor.id, input.name, input.template, input.poster); audit(context.db, project.id, actorIdentity(actor), 'project.created', { template: input.template }); return reply.code(201).send({ project: projectDetail(context, project.public_hash, actor) }); }); diff --git a/apps/server/src/templates.ts b/apps/server/src/templates.ts index d6d729b..8058dfc 100644 --- a/apps/server/src/templates.ts +++ b/apps/server/src/templates.ts @@ -1,3 +1,6 @@ +import { posterTemplateFiles } from './poster-template-files.js'; +import { posterThemes } from './posters.js'; + export interface ProjectTemplate { id: string; name: string; @@ -88,7 +91,14 @@ Start writing here. files: { 'main.tex': '\\documentclass{article}\n\\begin{document}\n\n\\end{document}\n' } - } + }, + ...posterThemes.map((theme) => ({ + id: theme, + name: ({ 'academic-classic': 'Academic Classic poster', 'modern-ml': 'Modern ML poster', 'visual-results': 'Visual Results poster' })[theme], + description: 'A large-format research poster with modular sections and a print-ready page.', + entryFile: 'poster.tex', + files: posterTemplateFiles[theme]! + })) ]; export function findTemplate(id: string): ProjectTemplate | undefined { diff --git a/apps/server/tests/api.test.ts b/apps/server/tests/api.test.ts index 94edee5..903651f 100644 --- a/apps/server/tests/api.test.ts +++ b/apps/server/tests/api.test.ts @@ -40,6 +40,29 @@ function bearer(token: string) { } describe('Overroot API', () => { + it('creates poster projects inside the normal permission and file model', async () => { + const { app, adminToken } = harness!; + const created = await app.inject({ + method: 'POST', url: '/api/v1/projects', headers: bearer(adminToken), + payload: { name: 'Poster & Results', template: 'visual-results', poster: { + subtitle: 'Example', authors: 'A. Scientist', affiliation: 'Institute', + size: 'A0', orientation: 'portrait', columns: 3 + } } + }); + expect(created.statusCode).toBe(201); + expect(created.json().project.entryFile).toBe('poster.tex'); + const files = await app.inject({ method: 'GET', url: `/api/v1/projects/${created.json().project.hash}/files`, headers: bearer(adminToken) }); + expect(files.statusCode).toBe(200); + const paths = files.json().files.map((file: { path: string }) => file.path); + expect(paths).toContain('config.tex'); + expect(paths).toContain('poster-layout.tex'); + expect(paths).toContain('sections/results.tex'); + const invalid = await app.inject({ method: 'POST', url: '/api/v1/projects', headers: bearer(adminToken), payload: { + name: 'Invalid', template: 'visual-results', poster: { size: 'custom', widthMm: 100 } + } }); + expect(invalid.statusCode).toBe(400); + }); + it('reports the canonical public URL and API contract', async () => { const { app } = harness!; const status = await app.inject({ method: 'GET', url: '/api/v1/status' }); diff --git a/apps/server/tests/compiler-docker.test.ts b/apps/server/tests/compiler-docker.test.ts index 6eaad78..ef64a3b 100644 --- a/apps/server/tests/compiler-docker.test.ts +++ b/apps/server/tests/compiler-docker.test.ts @@ -3,6 +3,9 @@ import { execFileSync } from 'node:child_process'; import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { posterOptionsSchema, posterThemes } from '../src/posters.js'; import { expect, it, vi } from 'vitest'; import { buildApp } from './helpers.js'; import { config } from '../src/config.js'; @@ -73,6 +76,43 @@ it.skipIf(process.env.OVERROOT_TEST_DOCKER !== '1')('replaces a legacy worker, r expect(cached.source_hash).toBe(result.source_hash); expect(cached.synctex_path).toBe(result.synctex_path); expect((await readFile(cached.synctex_path!)).length).toBeGreaterThan(0); + + // Use the same PDF parser already shipped by the web app, without adding a test dependency. + const webRequire = createRequire(new URL('../../web/package.json', import.meta.url)); + const { getDocument } = await import(pathToFileURL(webRequire.resolve('pdfjs-dist/legacy/build/pdf.mjs')).href); + const printCases = [ + { size: 'A1', orientation: 'landscape', columns: 2, width: 841, height: 594 }, + { size: 'A0', orientation: 'portrait', columns: 3, width: 841, height: 1189 }, + { size: 'custom', widthMm: 1000, heightMm: 700, columns: 4, width: 1000, height: 700 } + ]; + for (const theme of posterThemes) { + for (const print of printCases) { + const poster = context.projects.createProject(user.id, 'Research & Results', theme, + posterOptionsSchema.parse({ ...print, subtitle: 'Measured evidence', authors: 'A. Scientist', affiliation: 'Example Institute' })); + const pending = await context.compiler.request(poster, actor, 'poster.tex'); + let compiled = context.compiler.get(pending.id, poster.id)!; + await vi.waitFor(() => { + compiled = context.compiler.get(pending.id, poster.id)!; + expect(['queued', 'compiling']).not.toContain(compiled.status); + }, { timeout: 120_000, interval: 250 }); + expect(compiled.status, `${theme}/${print.size}: ${compiled.error ?? compiled.log}`).toBe('succeeded'); + expect(compiled.log).not.toMatch(/Font shape .*not available|Overfull \\[hv]box/); + const task = getDocument({ data: new Uint8Array(await readFile(compiled.pdf_path!)), useSystemFonts: false }); + try { + const pdf = await task.promise; + expect(pdf.numPages, `${theme}/${print.size}`).toBe(1); + const page = await pdf.getPage(1); + const viewport = page.getViewport({ scale: 1 }); + expect(viewport.width * 25.4 / 72).toBeCloseTo(print.width, 0); + expect(viewport.height * 25.4 / 72).toBeCloseTo(print.height, 0); + const text = await page.getTextContent(); + const contents = text.items.map((item: { str?: string }) => item.str ?? '').join(' '); + expect(contents).toContain('Research & Results'); + expect(contents).toContain('A. Scientist'); + console.log(`Poster verified: ${theme}, ${print.size}, ${print.width} x ${print.height} mm, 1 page`); + } finally { await task.destroy(); } + } + } } finally { await app.close(); try { execFileSync('docker', ['rm', '-f', config.dockerContainer], { stdio: 'ignore' }); } diff --git a/apps/server/tests/posters.test.ts b/apps/server/tests/posters.test.ts new file mode 100644 index 0000000..94143d4 --- /dev/null +++ b/apps/server/tests/posters.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { posterDimensions, posterFiles, posterOptionsSchema, posterThemes } from '../src/posters.js'; + +describe('poster project templates', () => { + it.each(posterThemes)('creates a modular %s poster with one configured entry file', (theme) => { + const files = posterFiles(theme, 'Results & Methods', { + subtitle: 'A study', authors: 'A. Researcher', affiliation: 'Example Institute', + size: 'A1', orientation: 'landscape', columns: 2 + }); + expect(files['poster.tex']).toContain('\\RequirePackage{fix-cm}'); + expect(files['poster.tex']).toContain('\\input{config.tex}'); + expect(files['OVERPOSTER-LICENSE.txt']).toContain('Copyright (c) 2026 Overposter contributors'); + expect(files['OVERPOSTER-LICENSE.txt']).toContain('Permission is hereby granted'); + expect(files['poster-layout.tex']).toContain('\\newcommand{\\PosterGrid}'); + expect(files['sections/results.tex']).toBeTruthy(); + expect(files['config.tex']).toContain('\\def\\PosterWidth{841}'); + expect(files['config.tex']).toContain('\\def\\PosterHeight{594}'); + expect(files['config.tex']).toContain('Results \\& Methods'); + expect(files['config.tex']).not.toContain('\\write18'); + }); + + it('escapes TeX metadata and flattens paragraph breaks inside macro definitions', () => { + const files = posterFiles('academic-classic', 'A\\input{x} & 50%\n\nnext', posterOptionsSchema.parse({ authors: '#1_${x}^~\\' })); + expect(files['config.tex']).toContain('A\\textbackslash{}input\\{x\\} \\& 50\\% next'); + expect(files['config.tex']).toContain('\\#1\\_\\$\\{x\\}\\textasciicircum{}\\textasciitilde{}\\textbackslash{}'); + }); + + it('supports A0 portrait and bounded custom print dimensions', () => { + expect(posterDimensions(posterOptionsSchema.parse({ size: 'A0', orientation: 'portrait' }))) + .toEqual({ widthMm: 841, heightMm: 1189 }); + expect(posterDimensions(posterOptionsSchema.parse({ size: 'custom', widthMm: 1000, heightMm: 700 }))) + .toEqual({ widthMm: 1000, heightMm: 700 }); + expect(() => posterOptionsSchema.parse({ size: 'custom', widthMm: 4000, heightMm: 700 })).toThrow(); + expect(() => posterOptionsSchema.parse({ size: 'custom', widthMm: 700 })).toThrow(); + }); +}); diff --git a/apps/web/src/components/DashboardDialogs.test.tsx b/apps/web/src/components/DashboardDialogs.test.tsx index 0c16313..4fe5d59 100644 --- a/apps/web/src/components/DashboardDialogs.test.tsx +++ b/apps/web/src/components/DashboardDialogs.test.tsx @@ -39,3 +39,16 @@ it('opens central account controls instead of collecting a password', async () = expect(screen.getByRole('link', { name: 'Manage account' })).toHaveAttribute('href', 'https://overroot.toni.foo/api/v1/auth/account'); expect(container.querySelector('input')).toBeNull(); }); + +it('passes poster dimensions and metadata when a poster template is selected', async () => { + const onCreate = vi.fn().mockResolvedValue(undefined); + render(); + fireEvent.change(screen.getByLabelText('Project name'), { target: { value: 'My poster' } }); + fireEvent.change(screen.getByLabelText('Authors'), { target: { value: 'Research Team' } }); + fireEvent.change(screen.getByLabelText('Print size'), { target: { value: 'custom' } }); + expect(screen.queryByLabelText('Orientation')).not.toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('Width (mm)'), { target: { value: '1000' } }); + fireEvent.change(screen.getByLabelText('Height (mm)'), { target: { value: '700' } }); + await act(async () => { fireEvent.submit(screen.getByLabelText('Project name').closest('form')!); }); + expect(onCreate).toHaveBeenCalledWith('My poster', 'modern-ml', expect.objectContaining({ authors: 'Research Team', size: 'custom', widthMm: 1000, heightMm: 700 })); +}); diff --git a/apps/web/src/components/DashboardDialogs.tsx b/apps/web/src/components/DashboardDialogs.tsx index dbb3272..2f21f2b 100644 --- a/apps/web/src/components/DashboardDialogs.tsx +++ b/apps/web/src/components/DashboardDialogs.tsx @@ -1,7 +1,7 @@ import { Check, KeyRound } from 'lucide-react'; import { useEffect, useRef, useState, type FormEvent } from 'react'; import type { ApiClient } from '../lib/api'; -import type { Template } from '../types'; +import type { PosterOptions, Template } from '../types'; import { InlineError } from './Feedback'; import { Modal } from './Modal'; import { PendingLabel } from './RootMark'; @@ -16,11 +16,12 @@ export function NewProjectDialog({ open: boolean; templates: Template[]; onClose: () => void; - onCreate: (name: string, template: string) => Promise; + onCreate: (name: string, template: string, poster?: PosterOptions) => Promise; onPendingChange?: (pending: boolean) => void; }) { const [name, setName] = useState('Untitled paper'); const [template, setTemplate] = useState('article'); + const [poster, setPoster] = useState({ subtitle: '', authors: '', affiliation: '', size: 'A1', orientation: 'landscape', columns: 2 }); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const active = useRef(false); @@ -36,7 +37,8 @@ export function NewProjectDialog({ if (busy) return; setBusy(true); setError(null); onPendingChange?.(true); try { - await onCreate(name, template); + if (template === 'academic-classic' || template === 'modern-ml' || template === 'visual-results') await onCreate(name, template, poster); + else await onCreate(name, template); if (active.current && visible.current) onClose(); } catch (reason) { if (active.current) setError(reason instanceof Error ? reason.message : 'Could not create project'); @@ -54,6 +56,21 @@ export function NewProjectDialog({ {item.name}{item.description}{item.entryFile} )} + {(template === 'academic-classic' || template === 'modern-ml' || template === 'visual-results') &&
+ Poster setup + + + +
+ + {poster.size !== 'custom' && } + +
+ {poster.size === 'custom' &&
+ + +
} +
} {busy &&

You can close this dialog while the project is created.

}
diff --git a/apps/web/src/components/PdfViewer.test.tsx b/apps/web/src/components/PdfViewer.test.tsx index ae6af51..e5e0d9e 100644 --- a/apps/web/src/components/PdfViewer.test.tsx +++ b/apps/web/src/components/PdfViewer.test.tsx @@ -38,6 +38,34 @@ it('reports parser rejection with the exact downloaded buffer identity', async ( expect(props.onDocumentStatus).toHaveBeenCalledExactlyOnceWith(props.data, 'failed'); }); +it('measures poster size independently of zoom and reports multiple pages', async () => { + const pdf = { numPages: 2, getPage: vi.fn(async () => ({ getViewport: ({ scale }: { scale: number }) => ({ width: 841 * 72 / 25.4 * scale, height: 594 * 72 / 25.4 * scale }) })) }; + parser.getDocument.mockImplementation(() => ({ promise: Promise.resolve(pdf), destroy: vi.fn() })); + const props = properties(); + render(); + await waitFor(() => expect(props.onDocumentStatus).toHaveBeenCalledWith(props.data, 'ready')); + expect(screen.getByRole('status')).toHaveTextContent('2 pages: check layout · 841 × 594 mm'); + fireEvent.click(screen.getByLabelText('Zoom in')); + await waitFor(() => expect(screen.getByText('110%')).toBeInTheDocument()); + expect(screen.getByRole('status')).toHaveTextContent('841 × 594 mm'); + expect(screen.getByLabelText('PDF page 1').querySelector('canvas')!.width).toBeLessThanOrEqual(4096); +}); + +it('keeps measurements from the last good poster when replacement parsing fails', async () => { + const props = properties(); + parser.getDocument.mockImplementationOnce(() => ({ promise: Promise.resolve(document()), destroy: vi.fn() })); + const view = render(); + await waitFor(() => expect(props.onDocumentStatus).toHaveBeenCalledWith(props.data, 'ready')); + const previous = screen.getByRole('status').textContent; + parser.getDocument.mockImplementation(() => ({ promise: Promise.reject(new Error('Broken replacement')), destroy: vi.fn() })); + view.rerender(); + await screen.findByText('Broken replacement'); + expect(screen.getByRole('status').textContent).toBe(previous); + expect(screen.getByLabelText('PDF page 1')).toBeInTheDocument(); + view.rerender(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); +}); + it('confirms usable bytes after pages finish loading', async () => { const props = properties(); parser.getDocument.mockImplementation(() => ({ promise: Promise.resolve(document()), destroy: vi.fn() })); diff --git a/apps/web/src/components/PdfViewer.tsx b/apps/web/src/components/PdfViewer.tsx index 4023308..16e2779 100644 --- a/apps/web/src/components/PdfViewer.tsx +++ b/apps/web/src/components/PdfViewer.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { GlobalWorkerOptions, getDocument } from 'pdfjs-dist'; import type { ApiClient } from '../lib/api'; import { paintPdfHighlights } from '../lib/pdf-highlight'; +import { pageMillimetres, rasterScale } from '../lib/pdf-raster'; import { afterLayout, capturePdfViewState, loadStoredPdfViewState, restorePdfViewState, storePdfViewState, type PdfViewState @@ -22,6 +23,7 @@ export function PdfViewer({ focusMode, onFocusMode, projectName, + poster, onDocumentStatus }: { api: ApiClient; @@ -34,6 +36,7 @@ export function PdfViewer({ focusMode: boolean; onFocusMode: (focused: boolean) => void; projectName: string; + poster?: boolean; onDocumentStatus?: (data: ArrayBuffer, status: 'ready' | 'failed') => void; }) { const scroller = useRef(null); @@ -47,6 +50,7 @@ export function PdfViewer({ zoomRef.current = zoom; const [page, setPage] = useState(initialView.page); const [pageCount, setPageCount] = useState(0); + const [printSize, setPrintSize] = useState<{ width: number; height: number } | null>(null); const [rendering, setRendering] = useState(false); const [error, setError] = useState(null); const [sourceHighlights, setSourceHighlights] = useState([]); @@ -57,6 +61,7 @@ export function PdfViewer({ if (!data || !pages.current) { if (pages.current) pages.current.replaceChildren(); setPageCount(0); + setPrintSize(null); return; } let cancelled = false; @@ -67,14 +72,18 @@ export function PdfViewer({ void task.promise .then(async (pdf) => { if (cancelled || !pages.current) return; - setPageCount(pdf.numPages); + let measuredSize: { width: number; height: number } | null = null; const fragment = document.createDocumentFragment(); const renderJobs: Promise[] = []; for (let number = 1; number <= pdf.numPages; number += 1) { const pdfPage = await pdf.getPage(number); if (cancelled) return; + if (poster && number === 1) { + const physical = pdfPage.getViewport({ scale: 1 }); + measuredSize = pageMillimetres(physical.width, physical.height); + } const viewport = pdfPage.getViewport({ scale: zoom }); - const ratio = Math.min(window.devicePixelRatio || 1, 2); + const ratio = rasterScale(viewport.width, viewport.height, window.devicePixelRatio || 1); const wrapper = document.createElement('div'); wrapper.className = 'pdf-page'; wrapper.dataset.page = String(number); @@ -82,8 +91,8 @@ export function PdfViewer({ wrapper.style.height = `${viewport.height}px`; wrapper.setAttribute('aria-label', `PDF page ${number}`); const canvas = document.createElement('canvas'); - canvas.width = Math.floor(viewport.width * ratio); - canvas.height = Math.floor(viewport.height * ratio); + canvas.width = Math.max(1, Math.floor(viewport.width * ratio)); + canvas.height = Math.max(1, Math.floor(viewport.height * ratio)); canvas.style.width = `${viewport.width}px`; canvas.style.height = `${viewport.height}px`; wrapper.append(canvas); @@ -98,6 +107,8 @@ export function PdfViewer({ pages.current.replaceChildren(fragment); await Promise.all(renderJobs); if (cancelled) return; + setPageCount(pdf.numPages); + setPrintSize(measuredSize); setRendering(false); documentStatus.current?.(data, 'ready'); setPage(Math.min(preserved.current.page, pdf.numPages)); @@ -120,7 +131,7 @@ export function PdfViewer({ cancelled = true; void task.destroy(); }; - }, [data, revision, zoom]); + }, [data, revision, zoom, poster]); useEffect(() => { let cancelled = false; @@ -331,6 +342,9 @@ export function PdfViewer({
+ {poster && printSize &&
+ {pageCount === 1 ? '1 page' : `${pageCount} pages: check layout`} · {printSize.width} × {printSize.height} mm +
} {!data && !loading &&
No PDF yet.Compile the project to see it here.
} {(loading || rendering) &&
Rendering PDF…
} {error &&
Preview failed.{error}
} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index fd4465e..66a4542 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -6,6 +6,7 @@ import type { ProjectDetail, ProjectFile, ProjectMember, + PosterOptions, ProjectSummary, SyncTexHighlight, Template, @@ -92,10 +93,10 @@ export class ApiClient { return this.request<{ templates: Template[] }>('/api/v1/templates'); } - createProject(name: string, template: string) { + createProject(name: string, template: string, poster?: PosterOptions) { return this.request<{ project: ProjectDetail }>('/api/v1/projects', { method: 'POST', - body: JSON.stringify({ name, template }) + body: JSON.stringify({ name, template, ...(poster ? { poster } : {}) }) }); } diff --git a/apps/web/src/lib/pdf-raster.test.ts b/apps/web/src/lib/pdf-raster.test.ts new file mode 100644 index 0000000..1059b7d --- /dev/null +++ b/apps/web/src/lib/pdf-raster.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from 'vitest'; +import { pageMillimetres, rasterScale } from './pdf-raster'; + +it('keeps poster previews inside the bitmap budget', () => { + const ratio = rasterScale(8426, 5944, 2); + expect(8426 * ratio).toBeLessThanOrEqual(4096); + expect(8426 * 5944 * ratio * ratio).toBeLessThanOrEqual(16_000_000); + expect(rasterScale(600, 800, 2)).toBe(2); +}); + +it('reports physical size from PDF points', () => { + expect(pageMillimetres(841 * 72 / 25.4, 594 * 72 / 25.4)).toEqual({ width: 841, height: 594 }); +}); diff --git a/apps/web/src/lib/pdf-raster.ts b/apps/web/src/lib/pdf-raster.ts new file mode 100644 index 0000000..14c928b --- /dev/null +++ b/apps/web/src/lib/pdf-raster.ts @@ -0,0 +1,10 @@ +// Cap the longest bitmap edge and total bitmap area while preserving the +// on-screen dimensions. Large-format PDF pages can exceed browser canvas limits. +export function rasterScale(width: number, height: number, deviceRatio: number): number { + const desired = Math.min(Math.max(deviceRatio || 1, 1), 2); + return Math.min(desired, 4096 / Math.max(width, height), Math.sqrt(16_000_000 / (width * height))); +} + +export function pageMillimetres(widthPoints: number, heightPoints: number): { width: number; height: number } { + return { width: Math.round(widthPoints * 25.4 / 72), height: Math.round(heightPoints * 25.4 / 72) }; +} diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx index 80ff5e7..f1e1efb 100644 --- a/apps/web/src/pages/DashboardPage.tsx +++ b/apps/web/src/pages/DashboardPage.tsx @@ -9,7 +9,7 @@ import { ThemeControl } from '../components/ThemeControl'; import { useTheme } from '../hooks/useTheme'; import { initials, relativeTime } from '../lib/format'; import { useSession } from '../lib/session'; -import type { ProjectSummary, Template } from '../types'; +import type { PosterOptions, ProjectSummary, Template } from '../types'; export function DashboardPage() { const { api, user, clearSession } = useSession(); @@ -56,10 +56,10 @@ export function DashboardPage() { catch (reason) { setError(reason instanceof Error ? reason.message : 'Could not sign out. Try again.'); } } - async function create(name: string, template: string) { + async function create(name: string, template: string, poster?: PosterOptions) { const requestGeneration = generation.current; try { - const response = await api.createProject(name, template); + const response = await api.createProject(name, template, poster); if (requestGeneration !== generation.current) return; if (newVisible.current) navigate(`/project/${response.project.hash}`); else void load(); diff --git a/apps/web/src/pages/WorkspacePage.tsx b/apps/web/src/pages/WorkspacePage.tsx index 955150e..b11558b 100644 --- a/apps/web/src/pages/WorkspacePage.tsx +++ b/apps/web/src/pages/WorkspacePage.tsx @@ -192,7 +192,7 @@ function ProjectWorkspace({ hash }: { hash: string }) { ?? fileResponse.files.find((file) => file.path === projectResponse.project.entryFile) ?? fileResponse.files[0]; if (initial) setSelected((await api.getFile(hash, initial.id)).file); - setEditorMode(savedSource?.mode ?? 'manuscript'); + setEditorMode(savedSource?.mode ?? (projectResponse.project.entryFile === 'poster.tex' ? 'files' : 'manuscript')); const previewJob = compileResponse.job?.status === 'succeeded' ? compileResponse.job : compileResponse.lastSuccessfulJob; if (previewJob?.hasPdf && (accepted || !currentJob.current?.hasPdf)) await loadPdf(previewJob); } catch (reason) { setError(reason instanceof Error ? reason.message : 'Could not open project'); } @@ -451,7 +451,7 @@ function ProjectWorkspace({ hash }: { hash: string }) { : }
-
}>
+
}>