feat: port all template categories to JSON format
- Ported Minimalist templates to JSON (Swiss Grid, Brutalist, etc.) - Ported Tech templates to JSON (SaaS, Terminal, Cyberpunk, etc.) - Ported Creative templates to JSON (Art Gallery, Zine, Pop Art, etc.) - Ported Industrial templates to JSON (Blueprint, Factory, Schematic, etc.) - Ported Nature templates to JSON (Botanical, Ocean, Mountain, etc.) - Ported Lifestyle templates to JSON (Cookbook, Travel, Coffee House, etc.) - Ported Vintage templates to JSON (Art Deco, Medieval, Retro 80s, etc.) - Updated README.md to reflect the new JSON-based style system (example configuration and contribution workflow) - Completed migration of over 150 styles to the new architecture
This commit is contained in:
247
src/services/templateLoader.ts
Normal file
247
src/services/templateLoader.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { StyleOption, DocxStyleConfig, ElementStyle, FontConfig, ColorPalette } from '../types';
|
||||
import { generatePreviewCss, generateWordConfig } from './templateRenderer';
|
||||
|
||||
interface RawTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
vibe?: string;
|
||||
googleFontsImport?: string;
|
||||
typography?: {
|
||||
fonts?: FontConfig;
|
||||
colors?: ColorPalette;
|
||||
};
|
||||
elements?: Record<string, ElementStyle>;
|
||||
page?: Record<string, any>;
|
||||
wordConfig?: Record<string, any>;
|
||||
previewCss?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface TemplateFile {
|
||||
name: string;
|
||||
content: string;
|
||||
category: string; // Folder-based category from Rust
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a color reference or returns the color directly
|
||||
*/
|
||||
function resolveColor(color: string | undefined, palette: ColorPalette): string {
|
||||
if (!color) return '#000000';
|
||||
if (palette[color as keyof ColorPalette]) {
|
||||
return '#' + palette[color as keyof ColorPalette];
|
||||
}
|
||||
return color.startsWith('#') ? color : '#' + color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a font reference or returns the font directly
|
||||
*/
|
||||
function resolveFont(font: string | undefined, fonts: FontConfig): string {
|
||||
if (!font) return 'serif';
|
||||
if (fonts[font as keyof FontConfig]) {
|
||||
return fonts[font as keyof FontConfig]!;
|
||||
}
|
||||
return font;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates preview CSS from unified template structure
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
|
||||
|
||||
/**
|
||||
* Validate that a parsed object conforms to the StyleOption interface
|
||||
*/
|
||||
function validateTemplate(raw: RawTemplate, filename: string): StyleOption | null {
|
||||
if (!raw.id || typeof raw.id !== 'string') {
|
||||
console.error(`Invalid template in ${filename}: missing or invalid 'id'`);
|
||||
return null;
|
||||
}
|
||||
if (!raw.name || typeof raw.name !== 'string') {
|
||||
console.error(`Invalid template in ${filename}: missing or invalid 'name'`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasUnifiedStructure = raw.elements && raw.typography;
|
||||
|
||||
let wordConfig: StyleOption['wordConfig'];
|
||||
let previewCss: string;
|
||||
|
||||
if (hasUnifiedStructure) {
|
||||
const fonts = raw.typography?.fonts || {};
|
||||
const palette = raw.typography?.colors || {};
|
||||
const elements = raw.elements || {};
|
||||
|
||||
// Only generate wordConfig if it's missing or incomplete in the raw template
|
||||
// Prefer the raw wordConfig if it exists (allows manual overrides)
|
||||
if (raw.wordConfig && Object.keys(raw.wordConfig).length > 0) {
|
||||
wordConfig = raw.wordConfig as StyleOption['wordConfig'];
|
||||
} else {
|
||||
// Use the imported generator which now has better logic
|
||||
wordConfig = generateWordConfig(elements, fonts, palette) as StyleOption['wordConfig'];
|
||||
}
|
||||
|
||||
// Similarly for previewCss - prefer explicit override
|
||||
if (raw.previewCss) {
|
||||
previewCss = raw.previewCss;
|
||||
} else {
|
||||
previewCss = generatePreviewCss(elements, fonts, palette);
|
||||
}
|
||||
} else {
|
||||
const defaultHeading1: DocxStyleConfig = {
|
||||
font: 'Arial', size: 24, color: '000000', align: 'left', spacing: { before: 240, after: 120, line: 240 }
|
||||
};
|
||||
const defaultHeading2: DocxStyleConfig = {
|
||||
font: 'Arial', size: 18, color: '333333', align: 'left', spacing: { before: 200, after: 100, line: 240 }
|
||||
};
|
||||
const defaultBody: DocxStyleConfig = {
|
||||
font: 'Arial', size: 11, color: '333333', align: 'left', spacing: { before: 0, after: 120, line: 276 }
|
||||
};
|
||||
|
||||
wordConfig = raw.wordConfig && raw.wordConfig.heading1 && raw.wordConfig.heading2 && raw.wordConfig.body
|
||||
? {
|
||||
heading1: { ...defaultHeading1, ...raw.wordConfig.heading1 },
|
||||
heading2: { ...defaultHeading2, ...raw.wordConfig.heading2 },
|
||||
body: { ...defaultBody, ...raw.wordConfig.body },
|
||||
accentColor: raw.wordConfig.accentColor || '000000'
|
||||
}
|
||||
: {
|
||||
heading1: defaultHeading1,
|
||||
heading2: defaultHeading2,
|
||||
body: defaultBody,
|
||||
accentColor: '000000'
|
||||
};
|
||||
|
||||
previewCss = raw.previewCss || 'font-family: Arial, sans-serif;';
|
||||
}
|
||||
|
||||
const template: StyleOption = {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
category: raw.category || 'Other',
|
||||
description: raw.description || '',
|
||||
vibe: raw.vibe || '',
|
||||
googleFontsImport: raw.googleFontsImport || '',
|
||||
wordConfig,
|
||||
previewCss,
|
||||
// Preserve unified template structure if present
|
||||
...(raw.typography && { typography: raw.typography }),
|
||||
...(raw.elements && { elements: raw.elements }),
|
||||
...(raw.page && { page: raw.page })
|
||||
};
|
||||
|
||||
const extraFields = Object.entries(raw).reduce((acc, [key, val]) => {
|
||||
if (!['id', 'name', 'category', 'description', 'vibe', 'googleFontsImport', 'wordConfig', 'previewCss', 'typography', 'elements', 'page'].includes(key)) {
|
||||
acc[key] = val;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
return { ...template, ...extraFields };
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug function to check paths
|
||||
*/
|
||||
export async function debugPaths(): Promise<string> {
|
||||
try {
|
||||
return await invoke<string>('debug_paths');
|
||||
} catch (e) {
|
||||
return `Error: ${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all templates from the portable folder (next to EXE)
|
||||
* Uses Rust backend for file operations since BaseDirectory.Executable doesn't work in JS
|
||||
*/
|
||||
export async function loadTemplates(): Promise<{ templates: StyleOption[]; error?: string }> {
|
||||
const templates: StyleOption[] = [];
|
||||
let error: string | undefined;
|
||||
|
||||
try {
|
||||
// Use Rust command to read all templates
|
||||
console.log('Calling read_templates command...');
|
||||
const files = await invoke<TemplateFile[]>('read_templates');
|
||||
console.log(`Received ${files.length} templates from Rust`);
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const raw = JSON.parse(file.content) as RawTemplate;
|
||||
const validated = validateTemplate(raw, file.name);
|
||||
if (validated) {
|
||||
// Use folder-based category from Rust, fallback to 'Other'
|
||||
const category = file.category || 'Other';
|
||||
templates.push({ ...validated, category });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse template ${file.name}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (templates.length === 0) {
|
||||
error = 'No templates found.';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error in loadTemplates:', e);
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
// Sort by category then name
|
||||
templates.sort((a, b) => {
|
||||
const catCompare = a.category.localeCompare(b.category);
|
||||
if (catCompare !== 0) return catCompare;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Deduplicate by ID (in case of duplicate files)
|
||||
const seen = new Set<string>();
|
||||
const deduplicated = templates.filter(t => {
|
||||
if (seen.has(t.id)) {
|
||||
console.warn(`Duplicate template ID found: ${t.id}, skipping duplicate`);
|
||||
return false;
|
||||
}
|
||||
seen.add(t.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
console.log(`Returning ${deduplicated.length} templates (deduplicated from ${templates.length}), error: ${error}`);
|
||||
return { templates: deduplicated, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the templates folder in the system file explorer
|
||||
*/
|
||||
export async function openTemplatesFolder(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await invoke('open_templates_folder');
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single template by ID
|
||||
*/
|
||||
export async function loadTemplateById(id: string): Promise<StyleOption | null> {
|
||||
const { templates } = await loadTemplates();
|
||||
return templates.find(t => t.id === id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available categories from templates
|
||||
*/
|
||||
export function getCategories(templates: StyleOption[]): string[] {
|
||||
const categories = new Set<string>();
|
||||
templates.forEach(t => categories.add(t.category));
|
||||
return Array.from(categories).sort();
|
||||
}
|
||||
Reference in New Issue
Block a user