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:
TypoGenie
2026-02-01 18:51:43 +02:00
parent da335734d3
commit a6f664088c
405 changed files with 69134 additions and 5936 deletions

View File

@@ -0,0 +1,196 @@
import React, { useEffect, useCallback, useRef, useState } from 'react';
interface UseKeyboardNavOptions {
onArrowUp?: () => void;
onArrowDown?: () => void;
onArrowLeft?: () => void;
onArrowRight?: () => void;
onEnter?: () => void;
onEscape?: () => void;
onTab?: () => void;
onShiftTab?: () => void;
onSpace?: () => void;
onHome?: () => void;
onEnd?: () => void;
onPageUp?: () => void;
onPageDown?: () => void;
onCtrlEnter?: () => void;
disabled?: boolean;
}
export const useKeyboardNavigation = (options: UseKeyboardNavOptions, deps: React.DependencyList) => {
const optionsRef = useRef(options);
optionsRef.current = options;
useEffect(() => {
if (optionsRef.current.disabled) return;
const handleKeyDown = (e: KeyboardEvent) => {
const opts = optionsRef.current;
// Handle Ctrl+Enter separately
if (e.key === 'Enter' && e.ctrlKey) {
if (opts.onCtrlEnter) {
e.preventDefault();
opts.onCtrlEnter();
}
return;
}
switch (e.key) {
case 'ArrowUp':
if (opts.onArrowUp) {
e.preventDefault();
opts.onArrowUp();
}
break;
case 'ArrowDown':
if (opts.onArrowDown) {
e.preventDefault();
opts.onArrowDown();
}
break;
case 'ArrowLeft':
if (opts.onArrowLeft) {
e.preventDefault();
opts.onArrowLeft();
}
break;
case 'ArrowRight':
if (opts.onArrowRight) {
e.preventDefault();
opts.onArrowRight();
}
break;
case 'Enter':
if (opts.onEnter) {
e.preventDefault();
opts.onEnter();
}
break;
case 'Escape':
if (opts.onEscape) {
e.preventDefault();
opts.onEscape();
}
break;
case 'Tab':
if (e.shiftKey && opts.onShiftTab) {
e.preventDefault();
opts.onShiftTab();
} else if (!e.shiftKey && opts.onTab) {
e.preventDefault();
opts.onTab();
}
break;
case ' ':
if (opts.onSpace) {
e.preventDefault();
opts.onSpace();
}
break;
case 'Home':
if (opts.onHome) {
e.preventDefault();
opts.onHome();
}
break;
case 'End':
if (opts.onEnd) {
e.preventDefault();
opts.onEnd();
}
break;
case 'PageUp':
if (opts.onPageUp) {
e.preventDefault();
opts.onPageUp();
}
break;
case 'PageDown':
if (opts.onPageDown) {
e.preventDefault();
opts.onPageDown();
}
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, deps);
};
export const useFocusableList = <T extends HTMLElement>(
itemCount: number,
options: {
onSelect?: (index: number) => void;
onEscape?: () => void;
orientation?: 'horizontal' | 'vertical';
wrap?: boolean;
} = {}
) => {
const [focusedIndex, setFocusedIndex] = useState(-1);
const itemRefs = useRef<(T | null)[]>([]);
const { onSelect, onEscape, orientation = 'vertical', wrap = true } = options;
const focusItem = useCallback((index: number) => {
if (index < 0 || index >= itemCount) return;
setFocusedIndex(index);
itemRefs.current[index]?.focus();
itemRefs.current[index]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, [itemCount]);
const handleArrowNext = useCallback(() => {
const nextIndex = focusedIndex + 1;
if (nextIndex < itemCount) {
focusItem(nextIndex);
} else if (wrap) {
focusItem(0);
}
}, [focusedIndex, itemCount, wrap, focusItem]);
const handleArrowPrev = useCallback(() => {
const prevIndex = focusedIndex - 1;
if (prevIndex >= 0) {
focusItem(prevIndex);
} else if (wrap) {
focusItem(itemCount - 1);
}
}, [focusedIndex, itemCount, wrap, focusItem]);
const handleEnter = useCallback(() => {
if (focusedIndex >= 0 && onSelect) {
onSelect(focusedIndex);
}
}, [focusedIndex, onSelect]);
const handleHome = useCallback(() => {
focusItem(0);
}, [focusItem]);
const handleEnd = useCallback(() => {
focusItem(itemCount - 1);
}, [focusItem, itemCount]);
const setItemRef = useCallback((index: number) => (el: T | null) => {
itemRefs.current[index] = el;
}, []);
useKeyboardNavigation({
[orientation === 'vertical' ? 'onArrowDown' : 'onArrowRight']: handleArrowNext,
[orientation === 'vertical' ? 'onArrowUp' : 'onArrowLeft']: handleArrowPrev,
onEnter: handleEnter,
onEscape,
onHome: handleHome,
onEnd: handleEnd,
}, [handleArrowNext, handleArrowPrev, handleEnter, onEscape, handleHome, handleEnd]);
return {
focusedIndex,
setFocusedIndex,
focusItem,
setItemRef,
itemRefs,
};
};

86
src/hooks/useSettings.ts Normal file
View File

@@ -0,0 +1,86 @@
import { useState, useEffect, useCallback } from 'react';
interface Settings {
uiZoom: number;
}
const DEFAULT_SETTINGS: Settings = {
uiZoom: 100,
};
export const useSettings = () => {
const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS);
const [isLoaded, setIsLoaded] = useState(false);
// Load settings from localStorage
useEffect(() => {
const loadSettings = () => {
try {
const saved = localStorage.getItem('typogenie-settings');
if (saved) {
const parsed = JSON.parse(saved);
const merged = { ...DEFAULT_SETTINGS, ...parsed };
setSettings(merged);
// Apply zoom immediately on load
applyZoom(merged.uiZoom);
}
} catch (e) {
console.error('Failed to load settings:', e);
}
setIsLoaded(true);
};
loadSettings();
}, []);
// Apply zoom using font-size scaling on root element
// This is the most reliable way to scale UI while preserving scrollability
const applyZoom = (zoom: number) => {
const scale = zoom / 100;
const root = document.documentElement;
// Base font size is 16px, scale it
root.style.fontSize = `${16 * scale}px`;
// Store zoom value as CSS variable for other calculations
root.style.setProperty('--ui-zoom', String(scale));
root.style.setProperty('--ui-zoom-percent', `${zoom}%`);
// Adjust the app container
const appRoot = document.getElementById('root');
if (appRoot) {
// Remove any transform-based scaling
appRoot.style.transform = 'none';
appRoot.style.width = '100%';
appRoot.style.height = '100%';
}
};
// Save settings when they change
const saveSettings = useCallback((newSettings: Partial<Settings>) => {
const updated = { ...settings, ...newSettings };
setSettings(updated);
// Apply zoom immediately
if (newSettings.uiZoom !== undefined) {
applyZoom(newSettings.uiZoom);
}
try {
localStorage.setItem('typogenie-settings', JSON.stringify(updated));
} catch (e) {
console.error('Failed to save settings:', e);
}
}, [settings]);
const setUiZoom = useCallback((zoom: number) => {
const clamped = Math.max(50, Math.min(200, zoom));
saveSettings({ uiZoom: clamped });
}, [saveSettings]);
return {
settings,
isLoaded,
setUiZoom,
uiZoom: settings.uiZoom,
};
};

88
src/hooks/useTemplates.ts Normal file
View File

@@ -0,0 +1,88 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { StyleOption } from '../types';
import { loadTemplates, ensureTemplatesFolder, openTemplatesFolder } from '../services/templateLoader';
interface UseTemplatesReturn {
templates: StyleOption[];
isLoading: boolean;
error: string | null;
refresh: () => Promise<void>;
openFolder: () => Promise<void>;
getTemplate: (id: string) => StyleOption | undefined;
categories: string[];
templatesByCategory: Map<string, StyleOption[]>;
}
export const useTemplates = (): UseTemplatesReturn => {
const [templates, setTemplates] = useState<StyleOption[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const result = await loadTemplates();
setTemplates(result.templates);
if (result.error) {
setError(result.error);
} else if (result.templates.length === 0) {
setError('No templates found. Click "Open Templates Folder" to see where they should be.');
}
} catch (e) {
console.error('Failed to load templates:', e);
const errorMsg = e instanceof Error ? e.message : String(e);
setError(`Failed to load templates: ${errorMsg}`);
} finally {
setIsLoading(false);
}
}, []);
const openFolder = useCallback(async () => {
try {
const result = await openTemplatesFolder();
if (!result.success && result.error) {
setError(`Failed to open folder: ${result.error}`);
}
} catch (e) {
console.error('Failed to open templates folder:', e);
setError('Failed to open templates folder. Try navigating to the folder manually.');
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const getTemplate = useCallback((id: string) => {
return templates.find(t => t.id === id);
}, [templates]);
const categories = useMemo(() => {
const cats = new Set<string>();
templates.forEach(t => cats.add(t.category));
return Array.from(cats).sort();
}, [templates]);
const templatesByCategory = useMemo(() => {
const map = new Map<string, StyleOption[]>();
templates.forEach(t => {
if (!map.has(t.category)) {
map.set(t.category, []);
}
map.get(t.category)!.push(t);
});
return map;
}, [templates]);
return {
templates,
isLoading,
error,
refresh,
openFolder,
getTemplate,
categories,
templatesByCategory,
};
};