Skip to content

Commit b2271ee

Browse files
author
Chris Funderburg
committed
feat(templates): add template management functionality and UI integration
- Implement API for retrieving templates - Create routes and controllers for template operations - Add template picker in PageList component - Introduce various templates for meeting notes, project kickoff, and runbooks - Update styles for better UI presentation - Add tests for template retrieval and functionality
1 parent 13f8878 commit b2271ee

21 files changed

Lines changed: 711 additions & 33 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ test-data/
1111
# Data folder - only track Codex Documentation
1212
data/*
1313
!data/Codex Documentation/
14+
!data/templates/

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
- 📁 **Folder Management**: Create, delete, and rename folders in a collapsible tree view with right-click context menus
1616
- 📝 **Markdown Pages**: Create and edit markdown documents with GitHub Flavored Markdown support
17+
- 🧩 **Smart Templates**: Create new pages from reusable templates (stored under `data/templates/`)
18+
- 📊 **Mermaid Diagrams**: Render Mermaid code fences in preview/reading mode, with export/download support
1719
- 🎨 **Three-Pane Layout**: Folder tree (left), markdown editor (center), live preview (right)
1820
- 📐 **Fully Resizable**: Drag to resize both horizontal panes (left/right) and vertical sections (folder tree/page list)
1921
- 🌓 **Theme Options**: Auto-detects system theme preference with manual override - cycles through auto/light/dark/high-contrast modes
@@ -38,6 +40,31 @@
3840
- 🔐 **Password Protection**: Simple password-based authentication to secure your data
3941
- 🛡️ **Security Features**: Rate limiting, request logging, and security headers
4042

43+
## 🧩 Smart Templates
44+
45+
When creating a new page, Codex can start from a template instead of a blank page.
46+
47+
- Templates live in `data/templates/*.md`
48+
- Optional frontmatter at the top of the template file:
49+
- `template: Your Template Name` (display name)
50+
- `autoname: true|false` (auto-generate a filename when creating)
51+
- The frontmatter is stripped from the created page content automatically
52+
53+
## 📊 Mermaid Diagrams
54+
55+
Codex supports Mermaid diagrams in markdown via fenced code blocks:
56+
57+
````md
58+
```mermaid
59+
graph TD
60+
A --> B
61+
```
62+
````
63+
64+
- Diagrams render in the live preview and in reading mode (open in new window)
65+
- You can download rendered diagrams as SVG
66+
- Export flows (e.g. Word/PDF) include diagrams as images rather than raw Mermaid text
67+
4168
## 🔐 Security & Logging
4269

4370
Codex includes several security features to protect your data:

client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"@types/react": "^19.2.13",
3434
"@types/react-dom": "^19.2.3",
3535
"@types/react-syntax-highlighter": "^15.5.13",
36-
"@vitejs/plugin-react": "^5.1.3",
36+
"@vitejs/plugin-react": "^5.1.4",
3737
"jsdom": "^28.0.0",
3838
"typescript": "^5.3.3",
3939
"vite": "^7.3.1",

client/src/components/PageList.css

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@
335335
margin-bottom: 16px;
336336
max-height: 400px;
337337
overflow-y: auto;
338+
overflow-x: hidden;
338339
}
339340

340341
.folder-option {
@@ -347,12 +348,15 @@
347348
font-size: 14px;
348349
transition: all 0.2s;
349350
color: var(--text-primary, #333);
351+
white-space: nowrap;
352+
overflow: hidden;
353+
text-overflow: ellipsis;
354+
max-width: 100%;
350355
}
351356

352357
.folder-option:hover {
353358
background: var(--bg-tertiary, #e8e8e8);
354359
border-color: #667eea;
355-
transform: translateX(4px);
356360
}
357361

358362
.cancel-btn {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { render, screen, waitFor } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { PageList } from "./PageList";
4+
import { api } from "../services/api";
5+
6+
vi.mock("../services/api", () => ({
7+
api: {
8+
getPages: vi.fn(),
9+
getTemplates: vi.fn(),
10+
createPage: vi.fn(),
11+
},
12+
}));
13+
14+
describe("PageList", () => {
15+
beforeEach(() => {
16+
vi.clearAllMocks();
17+
vi.mocked(api.getPages).mockResolvedValue([]);
18+
vi.mocked(api.getTemplates).mockResolvedValue([]);
19+
});
20+
21+
it("opens template picker when clicking +", async () => {
22+
render(
23+
<PageList
24+
selectedFolder={null}
25+
selectedPage={null}
26+
onSelectPage={vi.fn()}
27+
onRefresh={vi.fn()}
28+
folderTree={null}
29+
/>,
30+
);
31+
32+
// Wait for initial pages load.
33+
await waitFor(() => expect(api.getPages).toHaveBeenCalled());
34+
35+
const user = userEvent.setup();
36+
await user.click(screen.getByRole("button", { name: /create new page/i }));
37+
38+
expect(
39+
screen.getByRole("dialog", { name: /create page from template/i }),
40+
).toBeInTheDocument();
41+
expect(screen.getByRole("button", { name: /blank page/i })).toBeInTheDocument();
42+
});
43+
});

client/src/components/PageList.tsx

Lines changed: 178 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import { FileNode, FolderNode } from "../types";
33
import { api } from "../services/api";
44
import "./PageList.css";
55

6+
type TemplateOption = {
7+
path: string;
8+
template: string;
9+
autoname: boolean;
10+
content: string;
11+
};
12+
613
type SortField = "name" | "createdAt" | "modifiedAt";
714
type SortDirection = "asc" | "desc";
815

@@ -44,6 +51,10 @@ export const PageList: React.FC<PageListProps> = ({
4451
const [newPageName, setNewPageName] = useState("");
4552
const [movingPage, setMovingPage] = useState<string | null>(null);
4653
const [isCreating, setIsCreating] = useState(false);
54+
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
55+
const [templates, setTemplates] = useState<TemplateOption[]>([]);
56+
const [templatesLoading, setTemplatesLoading] = useState(false);
57+
const [templatesError, setTemplatesError] = useState<string | null>(null);
4758
const [isDeleting, setIsDeleting] = useState<string | null>(null);
4859
const [isMoving, setIsMoving] = useState(false);
4960
const [sortField, setSortField] = useState<SortField>("name");
@@ -141,27 +152,113 @@ export const PageList: React.FC<PageListProps> = ({
141152
}
142153
};
143154

144-
const handleCreatePage = async () => {
145-
// Allow creating pages in root (selectedFolder can be null or "/")
155+
const getSelectedFolderPath = (): string => {
156+
return !selectedFolder || selectedFolder === "/" ? "" : selectedFolder;
157+
};
158+
159+
const promptForPageName = (): string | null => {
146160
const pageName = prompt("Enter page name (without .md extension):");
147-
if (pageName) {
148-
setIsCreating(true);
149-
try {
150-
const fileName = pageName.endsWith(".md") ? pageName : `${pageName}.md`;
151-
const pagePath =
152-
!selectedFolder || selectedFolder === "/" ? fileName : `${selectedFolder}/${fileName}`;
153-
await api.createPage(pagePath, `# ${pageName}\n\nStart writing...`);
154-
loadPages();
155-
onRefresh();
156-
} catch (err) {
157-
console.error("Failed to create page:", err);
158-
setError("Failed to create page. Please try again.");
159-
} finally {
160-
setIsCreating(false);
161-
}
161+
if (!pageName) return null;
162+
return pageName.trim() ? pageName.trim() : null;
163+
};
164+
165+
const buildPagePath = (folderPath: string, fileName: string): string => {
166+
return folderPath ? `${folderPath}/${fileName}` : fileName;
167+
};
168+
169+
const formatTimestamp = (date: Date): string => {
170+
const pad2 = (n: number) => String(n).padStart(2, "0");
171+
const y = date.getFullYear();
172+
const m = pad2(date.getMonth() + 1);
173+
const d = pad2(date.getDate());
174+
const hh = pad2(date.getHours());
175+
const mm = pad2(date.getMinutes());
176+
return `${y}-${m}-${d}-${hh}-${mm}`;
177+
};
178+
179+
const slugFromTemplateName = (name: string): string => {
180+
return name
181+
.trim()
182+
.replace(/\s+/g, "-")
183+
.replace(/[^A-Za-z0-9._-]/g, "")
184+
.replace(/-+/g, "-");
185+
};
186+
187+
const ensureMd = (name: string): string => {
188+
return name.endsWith(".md") ? name : `${name}.md`;
189+
};
190+
191+
const openTemplatePicker = async () => {
192+
setShowTemplatePicker(true);
193+
setTemplatesError(null);
194+
setTemplatesLoading(true);
195+
try {
196+
const data = await api.getTemplates();
197+
setTemplates(data);
198+
} catch (err) {
199+
console.error("Failed to load templates:", err);
200+
setTemplatesError("Failed to load templates.");
201+
setTemplates([]);
202+
} finally {
203+
setTemplatesLoading(false);
204+
}
205+
};
206+
207+
const createFromBlank = async () => {
208+
const pageName = promptForPageName();
209+
if (!pageName) return;
210+
211+
setIsCreating(true);
212+
try {
213+
const folderPath = getSelectedFolderPath();
214+
const fileName = ensureMd(pageName);
215+
const pagePath = buildPagePath(folderPath, fileName);
216+
await api.createPage(pagePath, `# ${pageName}\n\nStart writing...`);
217+
await loadPages();
218+
onRefresh();
219+
onSelectPage(pagePath);
220+
} catch (err) {
221+
console.error("Failed to create page:", err);
222+
setError("Failed to create page. Please try again.");
223+
} finally {
224+
setIsCreating(false);
225+
}
226+
};
227+
228+
const createFromTemplate = async (t: TemplateOption) => {
229+
const folderPath = getSelectedFolderPath();
230+
231+
let fileName: string | null;
232+
if (t.autoname) {
233+
const slug = slugFromTemplateName(t.template);
234+
const stamp = formatTimestamp(new Date());
235+
fileName = ensureMd(`${slug}-${stamp}`);
236+
} else {
237+
const pageName = promptForPageName();
238+
if (!pageName) return;
239+
fileName = ensureMd(pageName);
240+
}
241+
242+
const pagePath = buildPagePath(folderPath, fileName);
243+
244+
setIsCreating(true);
245+
try {
246+
await api.createPage(pagePath, t.content);
247+
await loadPages();
248+
onRefresh();
249+
onSelectPage(pagePath);
250+
} catch (err) {
251+
console.error("Failed to create page from template:", err);
252+
setError("Failed to create page. Please try again.");
253+
} finally {
254+
setIsCreating(false);
162255
}
163256
};
164257

258+
const handleCreatePage = async () => {
259+
await openTemplatePicker();
260+
};
261+
165262
const handleDeletePage = async (path: string, e: React.MouseEvent) => {
166263
e.stopPropagation();
167264
if (confirm(`Delete page "${path}"?`)) {
@@ -474,6 +571,70 @@ export const PageList: React.FC<PageListProps> = ({
474571
</div>
475572
</div>
476573
)}
574+
575+
{/* Template Picker Modal */}
576+
{showTemplatePicker && (
577+
<div
578+
className="modal-overlay"
579+
onClick={() => !isCreating && setShowTemplatePicker(false)}
580+
role="dialog"
581+
aria-modal="true"
582+
aria-label="Create page from template"
583+
>
584+
<div className="modal" onClick={(e) => e.stopPropagation()}>
585+
<h3>Create New Page</h3>
586+
<p>Choose a template</p>
587+
588+
{templatesLoading ? (
589+
<div className="modal-loading" role="status" aria-label="Loading templates">
590+
<div className="loading-spinner" aria-hidden="true"></div>
591+
<span>Loading templates...</span>
592+
</div>
593+
) : templatesError ? (
594+
<div className="error-state" role="alert">
595+
<span className="error-icon" aria-hidden="true">
596+
⚠️
597+
</span>
598+
<span>{templatesError}</span>
599+
</div>
600+
) : (
601+
<div className="folder-list" role="list">
602+
<button
603+
className="folder-option"
604+
onClick={async () => {
605+
setShowTemplatePicker(false);
606+
await createFromBlank();
607+
}}
608+
disabled={isCreating}
609+
>
610+
Blank page
611+
</button>
612+
{templates.map((t) => (
613+
<button
614+
key={t.path}
615+
className="folder-option"
616+
onClick={async () => {
617+
setShowTemplatePicker(false);
618+
await createFromTemplate(t);
619+
}}
620+
disabled={isCreating}
621+
>
622+
{t.template}
623+
</button>
624+
))}
625+
</div>
626+
)}
627+
628+
<button
629+
className="cancel-btn"
630+
onClick={() => setShowTemplatePicker(false)}
631+
disabled={isCreating}
632+
>
633+
Cancel
634+
</button>
635+
</div>
636+
</div>
637+
)}
477638
</nav>
478639
);
479640
};

client/src/components/Preview.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@
166166
line-height: 1.7;
167167
color: var(--text-primary);
168168
max-width: 75ch;
169-
margin: 0 auto;
169+
margin: 0;
170170
}
171171

172172
.preview-content h1 {

client/src/services/api.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
CommitInfo,
77
VersionContent,
88
SearchResult,
9+
TemplateDefinition,
910
} from "../types";
1011

1112
const API_BASE = "/api";
@@ -79,6 +80,12 @@ export const api = {
7980
await axios.post(`${API_BASE}/pages`, { path, content });
8081
},
8182

83+
// Template operations
84+
getTemplates: async (): Promise<TemplateDefinition[]> => {
85+
const response = await axios.get(`${API_BASE}/templates`);
86+
return response.data;
87+
},
88+
8289
updatePage: async (path: string, content: string): Promise<void> => {
8390
await axios.put(`${API_BASE}/pages/${path}`, { content });
8491
},

client/src/types/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,10 @@ export interface SearchResult {
3939
snippet: string;
4040
matches: number;
4141
}
42+
43+
export interface TemplateDefinition {
44+
path: string;
45+
template: string;
46+
autoname: boolean;
47+
content: string;
48+
}

0 commit comments

Comments
 (0)