feat(templates): add category-based organization
- Added 'category' column to routine_templates table - Categorized all 12 templates into: Daily Routines, Getting Things Done, Health & Body, Errands - Added /api/templates/categories endpoint to list unique categories - Updated /api/templates to support filtering by category query param - Redesigned templates page with collapsible accordion sections by category - Categories are sorted in logical order (Daily → Work → Health → Errands) - All categories expanded by default for easy browsing
This commit is contained in:
@@ -3,13 +3,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import { CopyIcon, CheckIcon, FlameIcon } from '@/components/ui/Icons';
|
||||
import { CopyIcon, CheckIcon, FlameIcon, ChevronDownIcon, ChevronUpIcon } from '@/components/ui/Icons';
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
category: string;
|
||||
step_count: number;
|
||||
}
|
||||
|
||||
@@ -18,12 +19,16 @@ export default function TemplatesPage() {
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [cloningId, setCloningId] = useState<string | null>(null);
|
||||
const [expandedCategories, setExpandedCategories] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTemplates = async () => {
|
||||
try {
|
||||
const data = await api.templates.list();
|
||||
setTemplates(data);
|
||||
// Expand all categories by default
|
||||
const categories = [...new Set(data.map((t: Template) => t.category))];
|
||||
setExpandedCategories(categories);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch templates:', err);
|
||||
} finally {
|
||||
@@ -44,6 +49,35 @@ export default function TemplatesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setExpandedCategories(prev =>
|
||||
prev.includes(category)
|
||||
? prev.filter(c => c !== category)
|
||||
: [...prev, category]
|
||||
);
|
||||
};
|
||||
|
||||
// Group templates by category
|
||||
const groupedTemplates = templates.reduce((acc, template) => {
|
||||
const category = template.category || 'Other';
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(template);
|
||||
return acc;
|
||||
}, {} as Record<string, Template[]>);
|
||||
|
||||
// Define category order
|
||||
const categoryOrder = ['Daily Routines', 'Getting Things Done', 'Health & Body', 'Errands', 'Other'];
|
||||
const sortedCategories = Object.keys(groupedTemplates).sort((a, b) => {
|
||||
const indexA = categoryOrder.indexOf(a);
|
||||
const indexB = categoryOrder.indexOf(b);
|
||||
if (indexA === -1 && indexB === -1) return a.localeCompare(b);
|
||||
if (indexA === -1) return 1;
|
||||
if (indexB === -1) return -1;
|
||||
return indexA - indexB;
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
@@ -66,41 +100,68 @@ export default function TemplatesPage() {
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">Templates will appear here when available</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{templates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-indigo-100 to-pink-100 dark:from-indigo-900/50 dark:to-pink-900/50 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-3xl">{template.icon || '✨'}</span>
|
||||
<div className="space-y-4">
|
||||
{sortedCategories.map((category) => (
|
||||
<div key={category} className="bg-white dark:bg-gray-800 rounded-xl shadow-sm overflow-hidden">
|
||||
{/* Category Header */}
|
||||
<button
|
||||
onClick={() => toggleCategory(category)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-semibold text-gray-900 dark:text-gray-100">{category}</h2>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
({groupedTemplates[category].length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{template.name}</h3>
|
||||
{template.description && (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm truncate">{template.description}</p>
|
||||
)}
|
||||
<p className="text-gray-400 dark:text-gray-500 text-xs mt-1">{template.step_count} steps</p>
|
||||
{expandedCategories.includes(category) ? (
|
||||
<ChevronUpIcon className="text-gray-500 dark:text-gray-400" size={20} />
|
||||
) : (
|
||||
<ChevronDownIcon className="text-gray-500 dark:text-gray-400" size={20} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Templates List */}
|
||||
{expandedCategories.includes(category) && (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{groupedTemplates[category].map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-indigo-100 to-pink-100 dark:from-indigo-900/50 dark:to-pink-900/50 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-3xl">{template.icon || '✨'}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{template.name}</h3>
|
||||
{template.description && (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm truncate">{template.description}</p>
|
||||
)}
|
||||
<p className="text-gray-400 dark:text-gray-500 text-xs mt-1">{template.step_count} steps</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleClone(template.id)}
|
||||
disabled={cloningId === template.id}
|
||||
className="bg-indigo-600 text-white px-4 py-2 rounded-lg font-medium flex items-center gap-2 disabled:opacity-50 hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
{cloningId === template.id ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
<span>Cloning...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyIcon size={18} />
|
||||
<span>Use</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleClone(template.id)}
|
||||
disabled={cloningId === template.id}
|
||||
className="bg-indigo-600 text-white px-4 py-2 rounded-lg font-medium flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{cloningId === template.id ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
<span>Cloning...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyIcon size={18} />
|
||||
<span>Use</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user