feat: extract template gallery into dedicated components with expanded template library

Refactor the project wizard's inline template grid into a full-featured
template gallery system with search, category filtering, and rich previews.

Template data architecture (template-registry.ts):
- Define TemplateDefinition schema with categories, subcategories, tags,
  packages, accent colors, and bibliography flag
- Expand from 8 to 12 templates: add IEEE, ACM, Technical Report, Newsletter
- Provide registry API: getAllTemplates, getTemplateById, searchTemplates,
  getTemplatesByCategory

Template gallery UI (components/template-gallery/):
- template-gallery.tsx: main layout with search bar (Cmd+K), category
  sidebar, responsive grid, grouped-by-category default view, empty state
- template-card.tsx: cards with 9 CSS-based document thumbnail layouts
  (paper, slides, poster, CV, letter, book, report, newsletter, blank)
  and hover preview button
- category-sidebar.tsx: category navigation with per-category template counts
- template-preview.tsx: dialog showing LaTeX source, package list, tags,
  document class metadata, and "Use Template" CTA

State management (template-store.ts):
- Zustand store for gallery state: search query, category filter, template
  selection, and preview modal state with computed filtered results

Project wizard refactor (project-wizard.tsx):
- Replace inline TemplateGrid and hardcoded template array with the new
  TemplateGallery component
- Use template.hasBibliography flag instead of hardcoded ID check for
  references.bib generation
- Fix overflow handling for step 2 scrollable content
This commit is contained in:
delibae 2026-02-22 06:15:12 +09:00
parent 34224e51cd
commit c5c3085626
9 changed files with 1622 additions and 485 deletions

47
SHARED_TASK_NOTES.md Normal file
View file

@ -0,0 +1,47 @@
# Template Gallery — Shared Task Notes
## What's Done (Phase 1 + Phase 2 start)
### Template Data Architecture (Phase 1)
- `src/lib/template-registry.ts` — Template schema with rich metadata: categories, subcategories, tags, packages, accent colors, bibliography flag. 12 templates total (was 8). New: IEEE, ACM, Technical Report, Newsletter. Registry API: `getAllTemplates()`, `getTemplateById()`, `searchTemplates()`, `getTemplatesByCategory()`.
- `src/stores/template-store.ts` — Zustand store for gallery state: search query, category filter, template selection, preview state. `computeFiltered()` handles combined search+category filtering.
### Visual Gallery UI (Phase 2 start)
- `src/components/template-gallery/` — New component directory:
- `template-gallery.tsx` — Main gallery: search bar (⌘K), category sidebar + grid layout, grouped view when no filter active, empty state
- `template-card.tsx` — Cards with CSS-based document thumbnail placeholders (9 distinct layouts: paper, slides, poster, CV, letter, book, report, newsletter, blank). Hover reveals "Preview" button
- `category-sidebar.tsx` — Category navigation with template counts
- `template-preview.tsx` — Dialog showing LaTeX source, package list, tags, metadata, "Use Template" CTA
- `index.ts` — Barrel export
### Wizard Refactor
- `project-wizard.tsx` — Now uses `TemplateGallery` for step 1 instead of old inline `TemplateGrid`. Step 2 (project details) unchanged. All project creation logic preserved. Uses `template.hasBibliography` instead of hardcoded ID check.
## Next Priorities
### Phase 2 completion
- **Keyboard navigation** — Arrow keys to navigate grid, Enter to select, Tab between sections
- **Animations** — Consider adding `framer-motion` (not currently installed) for card entrance animations and page transitions
- **Responsive grid** — Test and tune grid breakpoints for various window sizes
### Phase 3: Template Preview System
- **PDF thumbnail generation** — Compile templates to PDF, render first page as image using pdfjs-dist (already a dependency). Store as cached images. Replace CSS placeholders with actual rendered thumbnails
- **Live preview in modal** — Show rendered PDF in the preview dialog instead of just source code
- **Multi-page preview** — For templates like presentations/books
- **Side-by-side compare** — Allow comparing two templates
### Phase 4: Package Integration
- **Package browser** — Curated CTAN package list
- **Package adding** — Let users add packages before project creation
- **Compatibility checker** — Warn about conflicting packages
### Phase 5: More Templates
- **Springer, APA, MLA, Chicago** style templates
- **User template library** — Save customized templates
- **Template import** — From .tex files or Overleaf
## Technical Notes
- No `framer-motion` installed — animations use Tailwind CSS `tw-animate-css`. Install if needed for gallery transitions
- `line-clamp-2` used in template-card.tsx — may need `@tailwindcss/line-clamp` if not natively supported (TW4 may include it)
- Template IDs changed: `paper``paper-standard`, `cv``cv-modern`, etc. Old IDs no longer exist
- `react-pdf` and `pdfjs-dist` already installed — ready for thumbnail generation work

View file

@ -3,20 +3,10 @@ import { open } from "@tauri-apps/plugin-dialog";
import { mkdir, writeTextFile } from "@tauri-apps/plugin-fs";
import {
ArrowLeftIcon,
ArrowRightIcon,
FileTextIcon,
UserIcon,
LayoutIcon,
BookOpenIcon,
MailIcon,
MonitorIcon,
BookIcon,
FileIcon,
FolderOpenIcon,
PaperclipIcon,
XIcon,
SparklesIcon,
GraduationCapIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
@ -25,341 +15,8 @@ import { useProjectStore } from "@/stores/project-store";
import { useDocumentStore } from "@/stores/document-store";
import { useClaudeChatStore } from "@/stores/claude-chat-store";
import { exists, join } from "@/lib/tauri/fs";
// ─── Template Definitions ───
interface Template {
id: string;
name: string;
description: string;
icon: React.ReactNode;
documentClass: string;
mainFileName: string;
content: string;
}
const TEMPLATES: Template[] = [
{
id: "paper",
name: "Research Paper",
description: "Academic paper with abstract, sections, references",
icon: <FileTextIcon className="size-6" />,
documentClass: "article",
mainFileName: "main.tex",
content: `\\documentclass[12pt]{article}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage{amsmath,amssymb}
\\usepackage{graphicx}
\\usepackage[margin=1in]{geometry}
\\usepackage{hyperref}
\\usepackage{booktabs}
\\usepackage{natbib}
\\title{Title}
\\author{Author Name}
\\date{\\today}
\\begin{document}
\\maketitle
\\begin{abstract}
Your abstract here.
\\end{abstract}
\\section{Introduction}
\\section{Related Work}
\\section{Method}
\\section{Results}
\\section{Conclusion}
\\bibliographystyle{plainnat}
\\bibliography{references}
\\end{document}
`,
},
{
id: "cv",
name: "CV / Resume",
description: "Professional curriculum vitae",
icon: <UserIcon className="size-6" />,
documentClass: "article",
mainFileName: "main.tex",
content: `\\documentclass[11pt,a4paper]{article}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage[margin=0.8in]{geometry}
\\usepackage{hyperref}
\\usepackage{enumitem}
\\usepackage{titlesec}
\\titleformat{\\section}{\\large\\bfseries}{}{0em}{}[\\titlerule]
\\titlespacing{\\section}{0pt}{12pt}{6pt}
\\pagestyle{empty}
\\begin{document}
\\begin{center}
{\\LARGE\\bfseries Your Name}\\\\[4pt]
your.email@example.com \\quad | \\quad City, Country
\\end{center}
\\section{Education}
\\section{Experience}
\\section{Skills}
\\section{Publications}
\\end{document}
`,
},
{
id: "poster",
name: "Poster",
description: "Conference or research poster",
icon: <LayoutIcon className="size-6" />,
documentClass: "a0poster",
mainFileName: "main.tex",
content: `\\documentclass[a1paper,portrait]{a0poster}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage{amsmath,amssymb}
\\usepackage{graphicx}
\\usepackage{multicol}
\\usepackage[margin=2cm]{geometry}
\\usepackage{xcolor}
\\begin{document}
\\begin{center}
{\\VERYHuge\\bfseries Poster Title}\\\\[1cm]
{\\LARGE Author Name \\quad Institution}
\\end{center}
\\vspace{1cm}
\\begin{multicols}{2}
\\section*{Introduction}
\\section*{Methods}
\\section*{Results}
\\section*{Conclusions}
\\section*{References}
\\end{multicols}
\\end{document}
`,
},
{
id: "thesis",
name: "Thesis",
description: "Dissertation or thesis with chapters",
icon: <GraduationCapIcon className="size-6" />,
documentClass: "report",
mainFileName: "main.tex",
content: `\\documentclass[12pt,a4paper]{report}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage{amsmath,amssymb}
\\usepackage{graphicx}
\\usepackage[margin=1in]{geometry}
\\usepackage{hyperref}
\\usepackage{booktabs}
\\usepackage{natbib}
\\usepackage{setspace}
\\onehalfspacing
\\title{Thesis Title}
\\author{Author Name}
\\date{\\today}
\\begin{document}
\\maketitle
\\begin{abstract}
Your abstract here.
\\end{abstract}
\\tableofcontents
\\chapter{Introduction}
\\chapter{Literature Review}
\\chapter{Methodology}
\\chapter{Results}
\\chapter{Discussion}
\\chapter{Conclusion}
\\bibliographystyle{plainnat}
\\bibliography{references}
\\end{document}
`,
},
{
id: "presentation",
name: "Presentation",
description: "Beamer slides for talks and lectures",
icon: <MonitorIcon className="size-6" />,
documentClass: "beamer",
mainFileName: "main.tex",
content: `\\documentclass{beamer}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage{amsmath,amssymb}
\\usepackage{graphicx}
\\usetheme{Madrid}
\\title{Presentation Title}
\\author{Author Name}
\\institute{Institution}
\\date{\\today}
\\begin{document}
\\begin{frame}
\\titlepage
\\end{frame}
\\begin{frame}{Outline}
\\tableofcontents
\\end{frame}
\\section{Introduction}
\\begin{frame}{Introduction}
Content here.
\\end{frame}
\\section{Main Content}
\\begin{frame}{Main Content}
Content here.
\\end{frame}
\\section{Conclusion}
\\begin{frame}{Conclusion}
Content here.
\\end{frame}
\\end{document}
`,
},
{
id: "letter",
name: "Letter",
description: "Formal or cover letter",
icon: <MailIcon className="size-6" />,
documentClass: "letter",
mainFileName: "main.tex",
content: `\\documentclass[12pt]{letter}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage[margin=1in]{geometry}
\\usepackage{hyperref}
\\signature{Your Name}
\\address{Your Address \\\\ City, Country}
\\begin{document}
\\begin{letter}{Recipient Name \\\\ Recipient Address \\\\ City, Country}
\\opening{Dear Recipient,}
Your letter content here.
\\closing{Sincerely,}
\\end{letter}
\\end{document}
`,
},
{
id: "book",
name: "Book",
description: "Multi-chapter book or manuscript",
icon: <BookIcon className="size-6" />,
documentClass: "book",
mainFileName: "main.tex",
content: `\\documentclass[12pt,a4paper]{book}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage{amsmath,amssymb}
\\usepackage{graphicx}
\\usepackage[margin=1in]{geometry}
\\usepackage{hyperref}
\\title{Book Title}
\\author{Author Name}
\\date{\\today}
\\begin{document}
\\frontmatter
\\maketitle
\\tableofcontents
\\mainmatter
\\chapter{First Chapter}
\\chapter{Second Chapter}
\\backmatter
\\end{document}
`,
},
{
id: "blank",
name: "Blank",
description: "Minimal template to start from scratch",
icon: <FileIcon className="size-6" />,
documentClass: "article",
mainFileName: "main.tex",
content: `\\documentclass[12pt]{article}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\begin{document}
% Start writing here.
\\end{document}
`,
},
];
const BIB_TEMPLATE = `% Add your references here
% Example:
% @article{key,
% author = {Author Name},
% title = {Article Title},
% journal = {Journal Name},
% year = {2024},
% }
`;
import { getTemplateById, BIB_TEMPLATE } from "@/lib/template-registry";
import { TemplateGallery } from "@/components/template-gallery";
// ─── Wizard Component ───
@ -369,7 +26,7 @@ interface ProjectWizardProps {
export function ProjectWizard({ onBack }: ProjectWizardProps) {
const [step, setStep] = useState<1 | 2>(1);
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
const [purpose, setPurpose] = useState("");
const [attachments, setAttachments] = useState<string[]>([]);
const [projectFolder, setProjectFolder] = useState<string | null>(null);
@ -379,10 +36,10 @@ export function ProjectWizard({ onBack }: ProjectWizardProps) {
const addRecentProject = useProjectStore((s) => s.addRecentProject);
const openProject = useDocumentStore((s) => s.openProject);
const template = TEMPLATES.find((t) => t.id === selectedTemplate);
const template = selectedTemplateId ? getTemplateById(selectedTemplateId) : undefined;
const handleSelectTemplate = (id: string) => {
setSelectedTemplate(id);
setSelectedTemplateId(id);
setStep(2);
};
@ -442,7 +99,7 @@ export function ProjectWizard({ onBack }: ProjectWizardProps) {
}
// Write references.bib for templates that use bibliography
if (["paper", "thesis"].includes(template.id)) {
if (template.hasBibliography) {
const bibPath = await join(projectPath, "references.bib");
const bibExists = await exists(bibPath);
if (!bibExists) {
@ -509,154 +166,123 @@ export function ProjectWizard({ onBack }: ProjectWizardProps) {
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-hidden">
{step === 1 ? (
<TemplateGrid onSelect={handleSelectTemplate} selected={selectedTemplate} />
<TemplateGallery onSelectTemplate={handleSelectTemplate} />
) : (
<div className="mx-auto max-w-lg space-y-6 p-6">
{/* Selected template indicator */}
{template && (
<button
onClick={() => setStep(1)}
className="flex w-full items-center gap-3 rounded-lg border border-border bg-muted/30 p-3 text-left transition-colors hover:bg-muted/50"
>
<div className="flex size-10 items-center justify-center rounded-md bg-background text-muted-foreground">
{template.icon}
</div>
<div className="flex-1">
<div className="font-medium text-sm">{template.name}</div>
<div className="text-muted-foreground text-xs">{template.description}</div>
</div>
<span className="text-muted-foreground text-xs">Change</span>
</button>
)}
{/* Purpose */}
<div className="space-y-2">
<label className="font-medium text-sm">What are you writing?</label>
<Textarea
placeholder="e.g., A research paper on transformer architectures for protein structure prediction, targeting NeurIPS 2025..."
value={purpose}
onChange={(e) => setPurpose(e.target.value)}
rows={4}
className="resize-none"
/>
<p className="text-muted-foreground text-xs">
Claude will use this to customize your template with relevant content and structure.
</p>
</div>
{/* Attachments */}
<div className="space-y-2">
<label className="font-medium text-sm">Reference files (optional)</label>
<div className="space-y-1.5">
{attachments.map((path) => (
<div className="h-full overflow-y-auto">
<div className="mx-auto max-w-lg space-y-6 p-6">
{/* Selected template indicator */}
{template && (
<button
onClick={() => setStep(1)}
className="flex w-full items-center gap-3 rounded-lg border border-border bg-muted/30 p-3 text-left transition-colors hover:bg-muted/50"
>
<div
key={path}
className="flex items-center gap-2 rounded-md bg-muted/30 px-3 py-1.5 text-sm"
className="flex size-10 items-center justify-center rounded-md"
style={{ backgroundColor: template.accentColor + "18" }}
>
<PaperclipIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate text-xs">{path.split("/").pop()}</span>
<button
onClick={() => handleRemoveAttachment(path)}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<XIcon className="size-3.5" />
</button>
<div
className="size-5 rounded"
style={{ backgroundColor: template.accentColor }}
/>
</div>
))}
<Button variant="outline" size="sm" className="gap-1.5 text-xs" onClick={handleAddAttachments}>
<PaperclipIcon className="size-3.5" />
Add files
</Button>
</div>
<p className="text-muted-foreground text-xs">
PDFs, images, .bib, .tex, or data files to include as references.
</p>
</div>
<div className="flex-1">
<div className="font-medium text-sm">{template.name}</div>
<div className="text-muted-foreground text-xs">{template.description}</div>
</div>
<span className="text-muted-foreground text-xs">Change</span>
</button>
)}
{/* Project location */}
<div className="space-y-2">
<label className="font-medium text-sm">Project location</label>
<div className="flex gap-2">
<Input
placeholder="Project name"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
className="flex-1"
{/* Purpose */}
<div className="space-y-2">
<label className="font-medium text-sm">What are you writing?</label>
<Textarea
placeholder="e.g., A research paper on transformer architectures for protein structure prediction, targeting NeurIPS 2025..."
value={purpose}
onChange={(e) => setPurpose(e.target.value)}
rows={4}
className="resize-none"
/>
<Button variant="outline" className="shrink-0 gap-1.5" onClick={handleChooseFolder}>
<FolderOpenIcon className="size-4" />
{projectFolder ? "Change" : "Choose folder"}
</Button>
</div>
{projectFolder && (
<p className="truncate text-muted-foreground text-xs">
{projectFolder}/{projectName.trim() || "..."}
<p className="text-muted-foreground text-xs">
Claude will use this to customize your template with relevant content and structure.
</p>
)}
</div>
</div>
{/* Create button */}
<Button
className="w-full gap-2"
size="lg"
disabled={!canCreate || isCreating}
onClick={handleCreate}
>
{isCreating ? (
"Creating..."
) : purpose.trim() ? (
<>
<SparklesIcon className="size-4" />
Create & Generate with AI
</>
) : (
"Create Project"
)}
</Button>
{/* Attachments */}
<div className="space-y-2">
<label className="font-medium text-sm">Reference files (optional)</label>
<div className="space-y-1.5">
{attachments.map((path) => (
<div
key={path}
className="flex items-center gap-2 rounded-md bg-muted/30 px-3 py-1.5 text-sm"
>
<PaperclipIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate text-xs">{path.split("/").pop()}</span>
<button
onClick={() => handleRemoveAttachment(path)}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<XIcon className="size-3.5" />
</button>
</div>
))}
<Button variant="outline" size="sm" className="gap-1.5 text-xs" onClick={handleAddAttachments}>
<PaperclipIcon className="size-3.5" />
Add files
</Button>
</div>
<p className="text-muted-foreground text-xs">
PDFs, images, .bib, .tex, or data files to include as references.
</p>
</div>
{/* Project location */}
<div className="space-y-2">
<label className="font-medium text-sm">Project location</label>
<div className="flex gap-2">
<Input
placeholder="Project name"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
className="flex-1"
/>
<Button variant="outline" className="shrink-0 gap-1.5" onClick={handleChooseFolder}>
<FolderOpenIcon className="size-4" />
{projectFolder ? "Change" : "Choose folder"}
</Button>
</div>
{projectFolder && (
<p className="truncate text-muted-foreground text-xs">
{projectFolder}/{projectName.trim() || "..."}
</p>
)}
</div>
{/* Create button */}
<Button
className="w-full gap-2"
size="lg"
disabled={!canCreate || isCreating}
onClick={handleCreate}
>
{isCreating ? (
"Creating..."
) : purpose.trim() ? (
<>
<SparklesIcon className="size-4" />
Create & Generate with AI
</>
) : (
"Create Project"
)}
</Button>
</div>
</div>
)}
</div>
</div>
);
}
// ─── Template Grid ───
function TemplateGrid({
onSelect,
selected,
}: {
onSelect: (id: string) => void;
selected: string | null;
}) {
return (
<div className="mx-auto max-w-2xl p-6">
<p className="mb-6 text-center text-muted-foreground text-sm">
Pick a starting template. Claude will customize it based on your needs.
</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{TEMPLATES.map((tmpl) => (
<button
key={tmpl.id}
onClick={() => onSelect(tmpl.id)}
className={`flex flex-col items-center gap-2.5 rounded-xl border p-5 text-center transition-all hover:border-foreground/30 hover:bg-muted/50 ${
selected === tmpl.id
? "border-foreground bg-muted/50"
: "border-border"
}`}
>
<div className="text-muted-foreground">{tmpl.icon}</div>
<div>
<div className="font-medium text-sm">{tmpl.name}</div>
<div className="mt-0.5 text-muted-foreground text-xs leading-tight">
{tmpl.description}
</div>
</div>
</button>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,93 @@
import {
FileTextIcon,
GraduationCapIcon,
BriefcaseIcon,
PaletteIcon,
SparklesIcon,
MonitorIcon,
LayoutIcon,
UserIcon,
MailIcon,
ClipboardListIcon,
BookIcon,
NewspaperIcon,
FileIcon,
} from "lucide-react";
import {
type TemplateCategory,
type TemplateSubcategory,
CATEGORY_LABELS,
SUBCATEGORY_LABELS,
CATEGORY_SUBCATEGORIES,
getCategories,
getAllTemplates,
getTemplatesByCategory,
} from "@/lib/template-registry";
import { useTemplateStore } from "@/stores/template-store";
const CATEGORY_ICONS: Record<TemplateCategory, React.ReactNode> = {
academic: <GraduationCapIcon className="size-4" />,
professional: <BriefcaseIcon className="size-4" />,
creative: <PaletteIcon className="size-4" />,
starter: <SparklesIcon className="size-4" />,
};
const SUBCATEGORY_ICONS: Record<TemplateSubcategory, React.ReactNode> = {
papers: <FileTextIcon className="size-3.5" />,
theses: <GraduationCapIcon className="size-3.5" />,
presentations: <MonitorIcon className="size-3.5" />,
posters: <LayoutIcon className="size-3.5" />,
cv: <UserIcon className="size-3.5" />,
letters: <MailIcon className="size-3.5" />,
reports: <ClipboardListIcon className="size-3.5" />,
books: <BookIcon className="size-3.5" />,
newsletters: <NewspaperIcon className="size-3.5" />,
blank: <FileIcon className="size-3.5" />,
};
export function CategorySidebar() {
const selectedCategory = useTemplateStore((s) => s.selectedCategory);
const setSelectedCategory = useTemplateStore((s) => s.setSelectedCategory);
const allCount = getAllTemplates().length;
return (
<nav className="flex w-48 shrink-0 flex-col gap-1 overflow-y-auto py-2 pr-3">
{/* All templates */}
<button
onClick={() => setSelectedCategory(null)}
className={`flex items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
selectedCategory === null
? "bg-accent font-medium text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
}`}
>
<SparklesIcon className="size-4" />
<span className="flex-1">All Templates</span>
<span className="text-xs tabular-nums text-muted-foreground">{allCount}</span>
</button>
<div className="my-1.5 h-px bg-border" />
{/* Categories */}
{getCategories().map((cat) => {
const count = getTemplatesByCategory(cat).length;
const isActive = selectedCategory === cat;
return (
<button
key={cat}
onClick={() => setSelectedCategory(isActive ? null : cat)}
className={`flex items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
isActive
? "bg-accent font-medium text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
}`}
>
{CATEGORY_ICONS[cat]}
<span className="flex-1">{CATEGORY_LABELS[cat]}</span>
<span className="text-xs tabular-nums text-muted-foreground">{count}</span>
</button>
);
})}
</nav>
);
}

View file

@ -0,0 +1,4 @@
export { TemplateGallery } from "./template-gallery";
export { TemplateCard } from "./template-card";
export { TemplatePreview } from "./template-preview";
export { CategorySidebar } from "./category-sidebar";

View file

@ -0,0 +1,248 @@
import type { TemplateDefinition } from "@/lib/template-registry";
import { useTemplateStore } from "@/stores/template-store";
// ─── Document Thumbnail Layouts ───
// CSS-based miniature document previews showing the structure of each template type.
function ThumbnailPaper({ color }: { color: string }) {
return (
<div className="flex h-full w-full flex-col items-center px-4 py-3">
{/* Title */}
<div className="mb-1.5 h-1.5 w-12 rounded-full" style={{ backgroundColor: color }} />
{/* Author */}
<div className="mb-3 h-1 w-8 rounded-full bg-muted-foreground/20" />
{/* Abstract block */}
<div className="mb-2 w-full rounded-sm bg-muted-foreground/8 p-1.5">
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/15" />
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/15" />
<div className="h-0.5 w-3/4 rounded-full bg-muted-foreground/15" />
</div>
{/* Section heading */}
<div className="mb-1.5 h-1 w-10 self-start rounded-full" style={{ backgroundColor: color, opacity: 0.6 }} />
{/* Body lines */}
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/12" />
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/12" />
<div className="mb-1 h-0.5 w-11/12 rounded-full bg-muted-foreground/12" />
<div className="h-0.5 w-4/5 rounded-full bg-muted-foreground/12" />
</div>
);
}
function ThumbnailSlides({ color }: { color: string }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1.5 px-3 py-2">
{/* Slide 1 - title slide */}
<div className="flex w-full flex-1 flex-col items-center justify-center rounded-sm border border-muted-foreground/10 bg-muted-foreground/5 p-1">
<div className="mb-0.5 h-1 w-10 rounded-full" style={{ backgroundColor: color }} />
<div className="h-0.5 w-6 rounded-full bg-muted-foreground/20" />
</div>
{/* Slide 2 */}
<div className="flex w-full flex-1 flex-col rounded-sm border border-muted-foreground/10 bg-muted-foreground/5 p-1">
<div className="mb-0.5 h-0.5 w-6 rounded-full" style={{ backgroundColor: color, opacity: 0.6 }} />
<div className="mb-0.5 h-0.5 w-full rounded-full bg-muted-foreground/12" />
<div className="h-0.5 w-3/4 rounded-full bg-muted-foreground/12" />
</div>
</div>
);
}
function ThumbnailPoster({ color }: { color: string }) {
return (
<div className="flex h-full w-full flex-col px-2 py-2">
{/* Big title */}
<div className="mb-1.5 h-2 w-16 self-center rounded-full" style={{ backgroundColor: color }} />
<div className="mb-2 h-1 w-10 self-center rounded-full bg-muted-foreground/20" />
{/* Two columns */}
<div className="flex flex-1 gap-1.5">
<div className="flex flex-1 flex-col gap-1 rounded-sm bg-muted-foreground/6 p-1">
<div className="h-0.5 w-full rounded-full bg-muted-foreground/15" />
<div className="h-0.5 w-full rounded-full bg-muted-foreground/15" />
<div className="h-0.5 w-3/4 rounded-full bg-muted-foreground/15" />
</div>
<div className="flex flex-1 flex-col gap-1 rounded-sm bg-muted-foreground/6 p-1">
<div className="h-0.5 w-full rounded-full bg-muted-foreground/15" />
<div className="h-0.5 w-full rounded-full bg-muted-foreground/15" />
<div className="h-0.5 w-2/3 rounded-full bg-muted-foreground/15" />
</div>
</div>
</div>
);
}
function ThumbnailCV({ color }: { color: string }) {
return (
<div className="flex h-full w-full flex-col px-3 py-2.5">
{/* Name */}
<div className="mb-1 h-2 w-14 self-center rounded-full" style={{ backgroundColor: color }} />
<div className="mb-2 h-0.5 w-16 self-center rounded-full bg-muted-foreground/20" />
{/* Section divider */}
<div className="mb-1.5 h-px w-full" style={{ backgroundColor: color, opacity: 0.3 }} />
<div className="mb-1 h-0.5 w-8 rounded-full" style={{ backgroundColor: color, opacity: 0.5 }} />
<div className="mb-0.5 h-0.5 w-full rounded-full bg-muted-foreground/12" />
<div className="mb-2 h-0.5 w-3/4 rounded-full bg-muted-foreground/12" />
{/* Another section */}
<div className="mb-1.5 h-px w-full" style={{ backgroundColor: color, opacity: 0.3 }} />
<div className="mb-1 h-0.5 w-6 rounded-full" style={{ backgroundColor: color, opacity: 0.5 }} />
<div className="mb-0.5 h-0.5 w-full rounded-full bg-muted-foreground/12" />
<div className="h-0.5 w-5/6 rounded-full bg-muted-foreground/12" />
</div>
);
}
function ThumbnailLetter({ color }: { color: string }) {
return (
<div className="flex h-full w-full flex-col px-4 py-3">
{/* Sender address */}
<div className="mb-0.5 h-0.5 w-10 self-end rounded-full bg-muted-foreground/20" />
<div className="mb-3 h-0.5 w-8 self-end rounded-full bg-muted-foreground/20" />
{/* Date */}
<div className="mb-3 h-0.5 w-6 rounded-full bg-muted-foreground/15" />
{/* Greeting */}
<div className="mb-2 h-0.5 w-10 rounded-full" style={{ backgroundColor: color, opacity: 0.5 }} />
{/* Body */}
<div className="mb-0.5 h-0.5 w-full rounded-full bg-muted-foreground/12" />
<div className="mb-0.5 h-0.5 w-full rounded-full bg-muted-foreground/12" />
<div className="mb-0.5 h-0.5 w-11/12 rounded-full bg-muted-foreground/12" />
<div className="mb-3 h-0.5 w-3/4 rounded-full bg-muted-foreground/12" />
{/* Closing */}
<div className="h-0.5 w-8 rounded-full" style={{ backgroundColor: color, opacity: 0.5 }} />
</div>
);
}
function ThumbnailBook({ color }: { color: string }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center px-4 py-3">
{/* Title page feel */}
<div className="mb-6 h-px w-8" style={{ backgroundColor: color, opacity: 0.4 }} />
<div className="mb-1.5 h-2 w-14 rounded-full" style={{ backgroundColor: color }} />
<div className="mb-4 h-1 w-8 rounded-full bg-muted-foreground/20" />
<div className="h-px w-8" style={{ backgroundColor: color, opacity: 0.4 }} />
</div>
);
}
function ThumbnailBlank(_props: { color: string }) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="text-muted-foreground/20 text-xs font-medium">Empty</div>
</div>
);
}
function ThumbnailReport({ color }: { color: string }) {
return (
<div className="flex h-full w-full flex-col px-3 py-2.5">
{/* Title */}
<div className="mb-1 h-1.5 w-14 self-center rounded-full" style={{ backgroundColor: color }} />
<div className="mb-2.5 h-0.5 w-8 self-center rounded-full bg-muted-foreground/20" />
{/* TOC-like entries */}
<div className="mb-1 h-0.5 w-4 rounded-full" style={{ backgroundColor: color, opacity: 0.4 }} />
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/10" />
<div className="mb-1.5 h-0.5 w-4 rounded-full" style={{ backgroundColor: color, opacity: 0.4 }} />
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/10" />
<div className="mb-1 h-0.5 w-4 rounded-full" style={{ backgroundColor: color, opacity: 0.4 }} />
<div className="h-0.5 w-full rounded-full bg-muted-foreground/10" />
</div>
);
}
function ThumbnailNewsletter({ color }: { color: string }) {
return (
<div className="flex h-full w-full flex-col px-2 py-2">
{/* Header bar */}
<div className="mb-2 flex h-3 w-full items-center justify-center rounded-sm" style={{ backgroundColor: color, opacity: 0.15 }}>
<div className="h-1 w-10 rounded-full" style={{ backgroundColor: color }} />
</div>
{/* Two columns */}
<div className="flex flex-1 gap-1">
<div className="flex flex-1 flex-col gap-0.5 p-1">
<div className="h-0.5 w-5 rounded-full" style={{ backgroundColor: color, opacity: 0.5 }} />
<div className="h-0.5 w-full rounded-full bg-muted-foreground/12" />
<div className="h-0.5 w-full rounded-full bg-muted-foreground/12" />
<div className="h-0.5 w-2/3 rounded-full bg-muted-foreground/12" />
</div>
<div className="flex flex-1 flex-col gap-0.5 p-1">
<div className="h-0.5 w-5 rounded-full" style={{ backgroundColor: color, opacity: 0.5 }} />
<div className="h-0.5 w-full rounded-full bg-muted-foreground/12" />
<div className="h-0.5 w-full rounded-full bg-muted-foreground/12" />
<div className="h-0.5 w-3/4 rounded-full bg-muted-foreground/12" />
</div>
</div>
</div>
);
}
const THUMBNAIL_MAP: Record<string, React.FC<{ color: string }>> = {
"paper-standard": ThumbnailPaper,
"paper-ieee": ThumbnailPaper,
"paper-acm": ThumbnailPaper,
"thesis-standard": ThumbnailReport,
"presentation-beamer": ThumbnailSlides,
"poster-academic": ThumbnailPoster,
"cv-modern": ThumbnailCV,
"letter-formal": ThumbnailLetter,
"report-technical": ThumbnailReport,
"book-standard": ThumbnailBook,
"newsletter": ThumbnailNewsletter,
"blank": ThumbnailBlank,
};
function getSubcategoryThumbnail(sub: string): React.FC<{ color: string }> {
const map: Record<string, React.FC<{ color: string }>> = {
papers: ThumbnailPaper,
theses: ThumbnailReport,
presentations: ThumbnailSlides,
posters: ThumbnailPoster,
cv: ThumbnailCV,
letters: ThumbnailLetter,
reports: ThumbnailReport,
books: ThumbnailBook,
newsletters: ThumbnailNewsletter,
blank: ThumbnailBlank,
};
return map[sub] || ThumbnailPaper;
}
// ─── Template Card ───
interface TemplateCardProps {
template: TemplateDefinition;
onSelect: (id: string) => void;
}
export function TemplateCard({ template, onSelect }: TemplateCardProps) {
const openPreview = useTemplateStore((s) => s.openPreview);
const Thumbnail = THUMBNAIL_MAP[template.id] || getSubcategoryThumbnail(template.subcategory);
return (
<div className="group flex flex-col">
{/* Thumbnail area — document-shaped preview */}
<button
onClick={() => onSelect(template.id)}
className="relative aspect-[3/4] w-full overflow-hidden rounded-xl border border-border bg-card transition-all duration-200 hover:border-foreground/20 hover:shadow-md group-hover:scale-[1.02]"
>
<Thumbnail color={template.accentColor} />
{/* Hover overlay with preview button */}
<div className="absolute inset-0 flex items-end justify-center bg-gradient-to-t from-black/40 via-transparent to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation();
openPreview(template.id);
}}
className="mb-3 rounded-lg bg-white/90 px-3 py-1.5 text-xs font-medium text-gray-900 shadow-sm backdrop-blur-sm transition-transform hover:bg-white"
>
Preview
</button>
</div>
</button>
{/* Info below thumbnail */}
<div className="mt-2 px-0.5">
<div className="font-medium text-sm leading-tight">{template.name}</div>
<div className="mt-0.5 text-muted-foreground text-xs leading-snug line-clamp-2">
{template.description}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,149 @@
import { useEffect, useRef } from "react";
import { SearchIcon, XIcon } from "lucide-react";
import { Input } from "@/components/ui/input";
import { useTemplateStore } from "@/stores/template-store";
import {
CATEGORY_LABELS,
type TemplateCategory,
} from "@/lib/template-registry";
import { TemplateCard } from "./template-card";
import { CategorySidebar } from "./category-sidebar";
import { TemplatePreview } from "./template-preview";
interface TemplateGalleryProps {
onSelectTemplate: (templateId: string) => void;
}
export function TemplateGallery({ onSelectTemplate }: TemplateGalleryProps) {
const searchQuery = useTemplateStore((s) => s.searchQuery);
const setSearchQuery = useTemplateStore((s) => s.setSearchQuery);
const selectedCategory = useTemplateStore((s) => s.selectedCategory);
const filteredTemplates = useTemplateStore((s) => s.filteredTemplates);
const reset = useTemplateStore((s) => s.reset);
const searchRef = useRef<HTMLInputElement>(null);
// Reset store when gallery mounts
useEffect(() => {
reset();
}, [reset]);
// Focus search on Cmd/Ctrl+K
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
searchRef.current?.focus();
}
// Escape clears search
if (e.key === "Escape" && document.activeElement === searchRef.current) {
setSearchQuery("");
searchRef.current?.blur();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [setSearchQuery]);
// Group templates by category when showing all
const showGrouped = !selectedCategory && !searchQuery;
const heading = selectedCategory
? CATEGORY_LABELS[selectedCategory]
: searchQuery
? `Results for "${searchQuery}"`
: "All Templates";
return (
<div className="flex h-full flex-col">
{/* Search bar */}
<div className="shrink-0 border-b border-border px-4 py-3">
<div className="relative mx-auto max-w-xl">
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchRef}
placeholder="Search templates... ⌘K"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute top-1/2 right-2.5 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground hover:text-foreground"
>
<XIcon className="size-3.5" />
</button>
)}
</div>
</div>
{/* Main content: sidebar + grid */}
<div className="flex flex-1 overflow-hidden">
{/* Category sidebar */}
<div className="shrink-0 border-r border-border pl-3 pt-2">
<CategorySidebar />
</div>
{/* Template grid */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{filteredTemplates.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<SearchIcon className="mb-3 size-8 text-muted-foreground/40" />
<p className="font-medium text-sm text-muted-foreground">No templates found</p>
<p className="mt-1 text-xs text-muted-foreground/70">
Try a different search term or category
</p>
</div>
) : showGrouped ? (
<GroupedGrid onSelect={onSelectTemplate} />
) : (
<>
<h2 className="mb-4 font-medium text-sm text-muted-foreground">{heading}</h2>
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
{filteredTemplates.map((t) => (
<TemplateCard key={t.id} template={t} onSelect={onSelectTemplate} />
))}
</div>
</>
)}
</div>
</div>
{/* Preview modal */}
<TemplatePreview onUseTemplate={onSelectTemplate} />
</div>
);
}
// ─── Grouped Grid (shows categories as sections) ───
function GroupedGrid({ onSelect }: { onSelect: (id: string) => void }) {
const filteredTemplates = useTemplateStore((s) => s.filteredTemplates);
// Group by category preserving order
const categories: TemplateCategory[] = ["academic", "professional", "creative", "starter"];
const groups = categories
.map((cat) => ({
category: cat,
templates: filteredTemplates.filter((t) => t.category === cat),
}))
.filter((g) => g.templates.length > 0);
return (
<div className="space-y-8">
{groups.map((group) => (
<div key={group.category}>
<h2 className="mb-3 font-semibold text-sm">
{CATEGORY_LABELS[group.category]}
</h2>
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
{group.templates.map((t) => (
<TemplateCard key={t.id} template={t} onSelect={onSelect} />
))}
</div>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,142 @@
import { PackageIcon, FileCodeIcon, SparklesIcon } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useTemplateStore } from "@/stores/template-store";
import { getTemplateById } from "@/lib/template-registry";
interface TemplatePreviewProps {
onUseTemplate: (id: string) => void;
}
export function TemplatePreview({ onUseTemplate }: TemplatePreviewProps) {
const previewTemplateId = useTemplateStore((s) => s.previewTemplateId);
const closePreview = useTemplateStore((s) => s.closePreview);
const template = previewTemplateId ? getTemplateById(previewTemplateId) : null;
if (!template) return null;
return (
<Dialog open={!!previewTemplateId} onOpenChange={(open) => !open && closePreview()}>
<DialogContent className="max-h-[85vh] max-w-3xl overflow-hidden p-0">
<div className="flex h-full max-h-[85vh] flex-col">
{/* Header */}
<DialogHeader className="shrink-0 border-b border-border px-6 py-4">
<div className="flex items-start gap-4">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: template.accentColor + "18" }}
>
<div
className="size-5 rounded"
style={{ backgroundColor: template.accentColor }}
/>
</div>
<div className="flex-1">
<DialogTitle className="text-base">{template.name}</DialogTitle>
<DialogDescription className="mt-0.5">
{template.description}
</DialogDescription>
</div>
<Button
onClick={() => {
closePreview();
onUseTemplate(template.id);
}}
className="gap-1.5"
>
<SparklesIcon className="size-4" />
Use Template
</Button>
</div>
</DialogHeader>
{/* Content split: code preview + info */}
<div className="flex flex-1 overflow-hidden">
{/* LaTeX source preview */}
<div className="flex-1 overflow-y-auto border-r border-border bg-muted/30 p-4">
<div className="mb-2 flex items-center gap-1.5 text-muted-foreground text-xs">
<FileCodeIcon className="size-3.5" />
<span>{template.mainFileName}</span>
</div>
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed text-foreground/80">
{template.content}
</pre>
</div>
{/* Info sidebar */}
<div className="flex w-56 shrink-0 flex-col gap-4 overflow-y-auto p-4">
{/* Metadata */}
<div>
<div className="mb-2 font-medium text-xs text-muted-foreground uppercase tracking-wider">
Details
</div>
<div className="space-y-1.5 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Class</span>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
{template.documentClass}
</code>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Category</span>
<span className="text-xs capitalize">{template.category}</span>
</div>
{template.hasBibliography && (
<div className="flex justify-between">
<span className="text-muted-foreground">Bibliography</span>
<span className="text-xs">Yes</span>
</div>
)}
</div>
</div>
{/* Packages */}
{template.packages.length > 0 && (
<div>
<div className="mb-2 flex items-center gap-1.5 font-medium text-xs text-muted-foreground uppercase tracking-wider">
<PackageIcon className="size-3" />
Packages ({template.packages.length})
</div>
<div className="space-y-1.5">
{template.packages.map((pkg) => (
<div key={pkg.name} className="text-xs">
<code className="font-mono text-foreground/80">{pkg.name}</code>
<p className="mt-0.5 text-muted-foreground leading-snug">
{pkg.description}
</p>
</div>
))}
</div>
</div>
)}
{/* Tags */}
<div>
<div className="mb-2 font-medium text-xs text-muted-foreground uppercase tracking-wider">
Tags
</div>
<div className="flex flex-wrap gap-1">
{template.tags.map((tag) => (
<span
key={tag}
className="rounded-md bg-muted px-1.5 py-0.5 text-xs text-muted-foreground"
>
{tag}
</span>
))}
</div>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,753 @@
// ─── Template Data Architecture ───
export type TemplateCategory = "academic" | "professional" | "creative" | "starter";
export type TemplateSubcategory =
| "papers"
| "theses"
| "presentations"
| "posters"
| "cv"
| "letters"
| "reports"
| "books"
| "newsletters"
| "blank";
export interface TemplatePackage {
name: string;
description: string;
}
export interface TemplateDefinition {
id: string;
name: string;
description: string;
category: TemplateCategory;
subcategory: TemplateSubcategory;
tags: string[];
icon: string; // lucide icon name
documentClass: string;
mainFileName: string;
content: string;
packages: TemplatePackage[];
/** Accent color for thumbnail placeholder */
accentColor: string;
/** Whether template uses bibliography */
hasBibliography: boolean;
}
export const CATEGORY_LABELS: Record<TemplateCategory, string> = {
academic: "Academic",
professional: "Professional",
creative: "Creative",
starter: "Starter",
};
export const SUBCATEGORY_LABELS: Record<TemplateSubcategory, string> = {
papers: "Papers",
theses: "Theses & Dissertations",
presentations: "Presentations",
posters: "Posters",
cv: "CV & Resume",
letters: "Letters",
reports: "Reports",
books: "Books",
newsletters: "Newsletters",
blank: "Blank",
};
export const CATEGORY_SUBCATEGORIES: Record<TemplateCategory, TemplateSubcategory[]> = {
academic: ["papers", "theses", "presentations", "posters"],
professional: ["cv", "letters", "reports"],
creative: ["books", "newsletters"],
starter: ["blank"],
};
// ─── Template Definitions ───
const TEMPLATES: TemplateDefinition[] = [
{
id: "paper-standard",
name: "Research Paper",
description: "Academic paper with abstract, sections, and references",
category: "academic",
subcategory: "papers",
tags: ["article", "research", "journal", "academic", "science", "abstract", "bibliography"],
icon: "FileText",
documentClass: "article",
mainFileName: "main.tex",
accentColor: "#3b82f6",
hasBibliography: true,
packages: [
{ name: "amsmath", description: "AMS mathematical typesetting" },
{ name: "graphicx", description: "Enhanced graphics support" },
{ name: "geometry", description: "Page layout customization" },
{ name: "hyperref", description: "Hyperlinks and PDF metadata" },
{ name: "booktabs", description: "Professional table formatting" },
{ name: "natbib", description: "Bibliography management" },
],
content: `\\documentclass[12pt]{article}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage{amsmath,amssymb}
\\usepackage{graphicx}
\\usepackage[margin=1in]{geometry}
\\usepackage{hyperref}
\\usepackage{booktabs}
\\usepackage{natbib}
\\title{Title}
\\author{Author Name}
\\date{\\today}
\\begin{document}
\\maketitle
\\begin{abstract}
Your abstract here.
\\end{abstract}
\\section{Introduction}
\\section{Related Work}
\\section{Method}
\\section{Results}
\\section{Conclusion}
\\bibliographystyle{plainnat}
\\bibliography{references}
\\end{document}
`,
},
{
id: "paper-ieee",
name: "IEEE Conference Paper",
description: "Two-column IEEE conference format with standard sections",
category: "academic",
subcategory: "papers",
tags: ["ieee", "conference", "two-column", "engineering", "computer science"],
icon: "FileText",
documentClass: "IEEEtran",
mainFileName: "main.tex",
accentColor: "#2563eb",
hasBibliography: true,
packages: [
{ name: "amsmath", description: "AMS mathematical typesetting" },
{ name: "graphicx", description: "Enhanced graphics support" },
{ name: "hyperref", description: "Hyperlinks and PDF metadata" },
{ name: "cite", description: "Citation sorting and compression" },
{ name: "algorithmic", description: "Algorithm typesetting" },
],
content: `\\documentclass[conference]{IEEEtran}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage{amsmath,amssymb}
\\usepackage{graphicx}
\\usepackage{hyperref}
\\usepackage{cite}
\\title{Paper Title}
\\author{
\\IEEEauthorblockN{Author Name}
\\IEEEauthorblockA{Department \\\\ University \\\\ email@example.com}
}
\\begin{document}
\\maketitle
\\begin{abstract}
Your abstract here.
\\end{abstract}
\\begin{IEEEkeywords}
keyword1, keyword2, keyword3
\\end{IEEEkeywords}
\\section{Introduction}
\\section{Related Work}
\\section{Proposed Method}
\\section{Experiments}
\\section{Results}
\\section{Conclusion}
\\bibliographystyle{IEEEtran}
\\bibliography{references}
\\end{document}
`,
},
{
id: "paper-acm",
name: "ACM Conference Paper",
description: "ACM SIGCONF format for computing conferences",
category: "academic",
subcategory: "papers",
tags: ["acm", "conference", "computing", "sigconf", "computer science"],
icon: "FileText",
documentClass: "acmart",
mainFileName: "main.tex",
accentColor: "#1d4ed8",
hasBibliography: true,
packages: [
{ name: "amsmath", description: "AMS mathematical typesetting" },
{ name: "graphicx", description: "Enhanced graphics support" },
{ name: "booktabs", description: "Professional table formatting" },
],
content: `\\documentclass[sigconf]{acmart}
\\title{Paper Title}
\\author{Author Name}
\\affiliation{
\\institution{University}
\\city{City}
\\country{Country}
}
\\email{email@example.com}
\\begin{document}
\\begin{abstract}
Your abstract here.
\\end{abstract}
\\begin{CCSXML}
\\end{CCSXML}
\\keywords{keyword1, keyword2, keyword3}
\\maketitle
\\section{Introduction}
\\section{Related Work}
\\section{Method}
\\section{Evaluation}
\\section{Conclusion}
\\bibliographystyle{ACM-Reference-Format}
\\bibliography{references}
\\end{document}
`,
},
{
id: "thesis-standard",
name: "Thesis",
description: "Dissertation or thesis with chapters and front matter",
category: "academic",
subcategory: "theses",
tags: ["thesis", "dissertation", "phd", "masters", "chapters", "academic"],
icon: "GraduationCap",
documentClass: "report",
mainFileName: "main.tex",
accentColor: "#8b5cf6",
hasBibliography: true,
packages: [
{ name: "amsmath", description: "AMS mathematical typesetting" },
{ name: "graphicx", description: "Enhanced graphics support" },
{ name: "geometry", description: "Page layout customization" },
{ name: "hyperref", description: "Hyperlinks and PDF metadata" },
{ name: "booktabs", description: "Professional table formatting" },
{ name: "natbib", description: "Bibliography management" },
{ name: "setspace", description: "Line spacing control" },
],
content: `\\documentclass[12pt,a4paper]{report}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage{amsmath,amssymb}
\\usepackage{graphicx}
\\usepackage[margin=1in]{geometry}
\\usepackage{hyperref}
\\usepackage{booktabs}
\\usepackage{natbib}
\\usepackage{setspace}
\\onehalfspacing
\\title{Thesis Title}
\\author{Author Name}
\\date{\\today}
\\begin{document}
\\maketitle
\\begin{abstract}
Your abstract here.
\\end{abstract}
\\tableofcontents
\\chapter{Introduction}
\\chapter{Literature Review}
\\chapter{Methodology}
\\chapter{Results}
\\chapter{Discussion}
\\chapter{Conclusion}
\\bibliographystyle{plainnat}
\\bibliography{references}
\\end{document}
`,
},
{
id: "presentation-beamer",
name: "Presentation (Beamer)",
description: "Slide deck for talks, lectures, and conferences",
category: "academic",
subcategory: "presentations",
tags: ["beamer", "slides", "talk", "lecture", "conference", "presentation"],
icon: "Monitor",
documentClass: "beamer",
mainFileName: "main.tex",
accentColor: "#f59e0b",
hasBibliography: false,
packages: [
{ name: "amsmath", description: "AMS mathematical typesetting" },
{ name: "graphicx", description: "Enhanced graphics support" },
],
content: `\\documentclass{beamer}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage{amsmath,amssymb}
\\usepackage{graphicx}
\\usetheme{Madrid}
\\title{Presentation Title}
\\author{Author Name}
\\institute{Institution}
\\date{\\today}
\\begin{document}
\\begin{frame}
\\titlepage
\\end{frame}
\\begin{frame}{Outline}
\\tableofcontents
\\end{frame}
\\section{Introduction}
\\begin{frame}{Introduction}
Content here.
\\end{frame}
\\section{Main Content}
\\begin{frame}{Main Content}
Content here.
\\end{frame}
\\section{Conclusion}
\\begin{frame}{Conclusion}
Content here.
\\end{frame}
\\end{document}
`,
},
{
id: "poster-academic",
name: "Academic Poster",
description: "Conference or research poster with multi-column layout",
category: "academic",
subcategory: "posters",
tags: ["poster", "conference", "research", "a0", "a1", "multi-column"],
icon: "Layout",
documentClass: "a0poster",
mainFileName: "main.tex",
accentColor: "#ec4899",
hasBibliography: false,
packages: [
{ name: "amsmath", description: "AMS mathematical typesetting" },
{ name: "graphicx", description: "Enhanced graphics support" },
{ name: "multicol", description: "Multi-column layouts" },
{ name: "geometry", description: "Page layout customization" },
{ name: "xcolor", description: "Color support" },
],
content: `\\documentclass[a1paper,portrait]{a0poster}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage{amsmath,amssymb}
\\usepackage{graphicx}
\\usepackage{multicol}
\\usepackage[margin=2cm]{geometry}
\\usepackage{xcolor}
\\begin{document}
\\begin{center}
{\\VERYHuge\\bfseries Poster Title}\\\\[1cm]
{\\LARGE Author Name \\quad Institution}
\\end{center}
\\vspace{1cm}
\\begin{multicols}{2}
\\section*{Introduction}
\\section*{Methods}
\\section*{Results}
\\section*{Conclusions}
\\section*{References}
\\end{multicols}
\\end{document}
`,
},
{
id: "cv-modern",
name: "CV / Resume",
description: "Clean, professional curriculum vitae layout",
category: "professional",
subcategory: "cv",
tags: ["cv", "resume", "curriculum vitae", "job", "career", "professional"],
icon: "User",
documentClass: "article",
mainFileName: "main.tex",
accentColor: "#10b981",
hasBibliography: false,
packages: [
{ name: "geometry", description: "Page layout customization" },
{ name: "hyperref", description: "Hyperlinks and PDF metadata" },
{ name: "enumitem", description: "List customization" },
{ name: "titlesec", description: "Section title formatting" },
],
content: `\\documentclass[11pt,a4paper]{article}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage[margin=0.8in]{geometry}
\\usepackage{hyperref}
\\usepackage{enumitem}
\\usepackage{titlesec}
\\titleformat{\\section}{\\large\\bfseries}{}{0em}{}[\\titlerule]
\\titlespacing{\\section}{0pt}{12pt}{6pt}
\\pagestyle{empty}
\\begin{document}
\\begin{center}
{\\LARGE\\bfseries Your Name}\\\\[4pt]
your.email@example.com \\quad | \\quad City, Country
\\end{center}
\\section{Education}
\\section{Experience}
\\section{Skills}
\\section{Publications}
\\end{document}
`,
},
{
id: "letter-formal",
name: "Formal Letter",
description: "Professional or cover letter with standard formatting",
category: "professional",
subcategory: "letters",
tags: ["letter", "formal", "cover letter", "business", "correspondence"],
icon: "Mail",
documentClass: "letter",
mainFileName: "main.tex",
accentColor: "#06b6d4",
hasBibliography: false,
packages: [
{ name: "geometry", description: "Page layout customization" },
{ name: "hyperref", description: "Hyperlinks and PDF metadata" },
],
content: `\\documentclass[12pt]{letter}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage[margin=1in]{geometry}
\\usepackage{hyperref}
\\signature{Your Name}
\\address{Your Address \\\\ City, Country}
\\begin{document}
\\begin{letter}{Recipient Name \\\\ Recipient Address \\\\ City, Country}
\\opening{Dear Recipient,}
Your letter content here.
\\closing{Sincerely,}
\\end{letter}
\\end{document}
`,
},
{
id: "report-technical",
name: "Technical Report",
description: "Structured report with table of contents and sections",
category: "professional",
subcategory: "reports",
tags: ["report", "technical", "business", "documentation", "sections"],
icon: "ClipboardList",
documentClass: "report",
mainFileName: "main.tex",
accentColor: "#6366f1",
hasBibliography: false,
packages: [
{ name: "geometry", description: "Page layout customization" },
{ name: "graphicx", description: "Enhanced graphics support" },
{ name: "hyperref", description: "Hyperlinks and PDF metadata" },
{ name: "booktabs", description: "Professional table formatting" },
{ name: "listings", description: "Source code typesetting" },
{ name: "xcolor", description: "Color support" },
],
content: `\\documentclass[12pt,a4paper]{report}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage[margin=1in]{geometry}
\\usepackage{graphicx}
\\usepackage{hyperref}
\\usepackage{booktabs}
\\usepackage{listings}
\\usepackage{xcolor}
\\lstset{
basicstyle=\\ttfamily\\small,
frame=single,
breaklines=true,
}
\\title{Technical Report Title}
\\author{Author Name}
\\date{\\today}
\\begin{document}
\\maketitle
\\tableofcontents
\\chapter{Introduction}
\\chapter{Background}
\\chapter{Implementation}
\\chapter{Results}
\\chapter{Conclusion}
\\end{document}
`,
},
{
id: "book-standard",
name: "Book",
description: "Multi-chapter book or manuscript with front/back matter",
category: "creative",
subcategory: "books",
tags: ["book", "manuscript", "chapters", "novel", "textbook", "publishing"],
icon: "Book",
documentClass: "book",
mainFileName: "main.tex",
accentColor: "#d946ef",
hasBibliography: false,
packages: [
{ name: "geometry", description: "Page layout customization" },
{ name: "graphicx", description: "Enhanced graphics support" },
{ name: "hyperref", description: "Hyperlinks and PDF metadata" },
],
content: `\\documentclass[12pt,a4paper]{book}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage{amsmath,amssymb}
\\usepackage{graphicx}
\\usepackage[margin=1in]{geometry}
\\usepackage{hyperref}
\\title{Book Title}
\\author{Author Name}
\\date{\\today}
\\begin{document}
\\frontmatter
\\maketitle
\\tableofcontents
\\mainmatter
\\chapter{First Chapter}
\\chapter{Second Chapter}
\\backmatter
\\end{document}
`,
},
{
id: "newsletter",
name: "Newsletter",
description: "Multi-column newsletter with header and styled sections",
category: "creative",
subcategory: "newsletters",
tags: ["newsletter", "column", "publication", "magazine", "bulletin"],
icon: "Newspaper",
documentClass: "article",
mainFileName: "main.tex",
accentColor: "#f97316",
hasBibliography: false,
packages: [
{ name: "geometry", description: "Page layout customization" },
{ name: "multicol", description: "Multi-column layouts" },
{ name: "graphicx", description: "Enhanced graphics support" },
{ name: "xcolor", description: "Color support" },
{ name: "titlesec", description: "Section title formatting" },
{ name: "fancyhdr", description: "Custom headers and footers" },
],
content: `\\documentclass[11pt]{article}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage[margin=0.75in]{geometry}
\\usepackage{multicol}
\\usepackage{graphicx}
\\usepackage{xcolor}
\\usepackage{titlesec}
\\usepackage{fancyhdr}
\\pagestyle{fancy}
\\fancyhf{}
\\fancyhead[C]{\\textbf{Newsletter Title} --- \\today}
\\fancyfoot[C]{\\thepage}
\\titleformat{\\section}{\\large\\bfseries\\color{blue!70!black}}{}{0em}{}
\\begin{document}
\\begin{center}
{\\Huge\\bfseries Newsletter Title}\\\\[0.5cm]
{\\large Volume 1, Issue 1 --- \\today}
\\end{center}
\\vspace{0.5cm}
\\begin{multicols}{2}
\\section{Lead Story}
Your lead story content here.
\\section{Feature Article}
Your feature article content here.
\\section{Announcements}
Your announcements here.
\\end{multicols}
\\end{document}
`,
},
{
id: "blank",
name: "Blank Document",
description: "Minimal template to start from scratch",
category: "starter",
subcategory: "blank",
tags: ["blank", "empty", "minimal", "scratch", "custom"],
icon: "File",
documentClass: "article",
mainFileName: "main.tex",
accentColor: "#71717a",
hasBibliography: false,
packages: [],
content: `\\documentclass[12pt]{article}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\begin{document}
% Start writing here.
\\end{document}
`,
},
];
export const BIB_TEMPLATE = `% Add your references here
% Example:
% @article{key,
% author = {Author Name},
% title = {Article Title},
% journal = {Journal Name},
% year = {2024},
% }
`;
// ─── Registry API ───
let _templates = TEMPLATES;
export function getAllTemplates(): TemplateDefinition[] {
return _templates;
}
export function getTemplateById(id: string): TemplateDefinition | undefined {
return _templates.find((t) => t.id === id);
}
export function getTemplatesByCategory(category: TemplateCategory): TemplateDefinition[] {
return _templates.filter((t) => t.category === category);
}
export function getCategories(): TemplateCategory[] {
return ["academic", "professional", "creative", "starter"];
}
export function searchTemplates(query: string): TemplateDefinition[] {
if (!query.trim()) return _templates;
const q = query.toLowerCase().trim();
const words = q.split(/\s+/);
return _templates.filter((t) => {
const haystack = [
t.name,
t.description,
t.documentClass,
...t.tags,
t.category,
t.subcategory,
]
.join(" ")
.toLowerCase();
return words.every((w) => haystack.includes(w));
});
}

View file

@ -0,0 +1,75 @@
import { create } from "zustand";
import {
type TemplateCategory,
type TemplateDefinition,
getAllTemplates,
searchTemplates,
getTemplatesByCategory,
} from "@/lib/template-registry";
interface TemplateState {
// Filters
searchQuery: string;
selectedCategory: TemplateCategory | null;
// Selection
selectedTemplateId: string | null;
previewTemplateId: string | null;
// Computed
filteredTemplates: TemplateDefinition[];
// Actions
setSearchQuery: (query: string) => void;
setSelectedCategory: (category: TemplateCategory | null) => void;
selectTemplate: (id: string | null) => void;
openPreview: (id: string) => void;
closePreview: () => void;
reset: () => void;
}
function computeFiltered(
query: string,
category: TemplateCategory | null,
): TemplateDefinition[] {
let results = query ? searchTemplates(query) : getAllTemplates();
if (category) {
results = results.filter((t) => t.category === category);
}
return results;
}
export const useTemplateStore = create<TemplateState>((set) => ({
searchQuery: "",
selectedCategory: null,
selectedTemplateId: null,
previewTemplateId: null,
filteredTemplates: getAllTemplates(),
setSearchQuery: (query) =>
set((s) => ({
searchQuery: query,
filteredTemplates: computeFiltered(query, s.selectedCategory),
})),
setSelectedCategory: (category) =>
set((s) => ({
selectedCategory: category,
filteredTemplates: computeFiltered(s.searchQuery, category),
})),
selectTemplate: (id) => set({ selectedTemplateId: id }),
openPreview: (id) => set({ previewTemplateId: id }),
closePreview: () => set({ previewTemplateId: null }),
reset: () =>
set({
searchQuery: "",
selectedCategory: null,
selectedTemplateId: null,
previewTemplateId: null,
filteredTemplates: getAllTemplates(),
}),
}));