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:
196
src/hooks/useKeyboardNavigation.ts
Normal file
196
src/hooks/useKeyboardNavigation.ts
Normal 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
86
src/hooks/useSettings.ts
Normal 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
88
src/hooks/useTemplates.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user