mirror of
https://github.com/delibae/claude-prism.git
synced 2026-08-28 05:14:59 +00:00
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:
parent
34224e51cd
commit
c5c3085626
9 changed files with 1622 additions and 485 deletions
47
SHARED_TASK_NOTES.md
Normal file
47
SHARED_TASK_NOTES.md
Normal 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
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
4
apps/desktop/src/components/template-gallery/index.ts
Normal file
4
apps/desktop/src/components/template-gallery/index.ts
Normal 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";
|
||||
248
apps/desktop/src/components/template-gallery/template-card.tsx
Normal file
248
apps/desktop/src/components/template-gallery/template-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
753
apps/desktop/src/lib/template-registry.ts
Normal file
753
apps/desktop/src/lib/template-registry.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
75
apps/desktop/src/stores/template-store.ts
Normal file
75
apps/desktop/src/stores/template-store.ts
Normal 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(),
|
||||
}),
|
||||
}));
|
||||
Loading…
Add table
Reference in a new issue