diff --git a/components/SettingsUI/CustomStatusItem.tsx b/components/SettingsUI/CustomStatusItem.tsx index 585f854..8df4afd 100644 --- a/components/SettingsUI/CustomStatusItem.tsx +++ b/components/SettingsUI/CustomStatusItem.tsx @@ -1,12 +1,10 @@ import { NoteStatus } from "@/types/noteStatus"; -import { PluginSettings } from "@/types/pluginSettings"; import React from "react"; import { Input } from "@/components/atoms/Input"; export type Props = { status: NoteStatus; index: number; - settings: PluginSettings; onCustomStatusChange: ( index: number, column: "name" | "icon" | "color" | "description", diff --git a/components/SettingsUI/TemplateEditorModal.tsx b/components/SettingsUI/TemplateEditorModal.tsx new file mode 100644 index 0000000..d699718 --- /dev/null +++ b/components/SettingsUI/TemplateEditorModal.tsx @@ -0,0 +1,189 @@ +import React, { useState, useCallback } from "react"; +import { StatusTemplate } from "@/types/pluginSettings"; +import { NoteStatus } from "@/types/noteStatus"; +import { Input } from "@/components/atoms/Input"; +import { SettingItem } from "./SettingItem"; +import { CustomStatusItem } from "./CustomStatusItem"; + +interface TemplateEditorModalProps { + template?: StatusTemplate; + onSave: (template: StatusTemplate) => void; + onCancel: () => void; +} + +export const TemplateEditorModal: React.FC = ({ + template, + onSave, + onCancel, +}) => { + const [name, setName] = useState(template?.name || ""); + const [description, setDescription] = useState(template?.description || ""); + const [statuses, setStatuses] = useState( + template?.statuses || [ + { + name: "", + icon: "", + color: "#888888", + templateId: template?.id || "", + }, + ], + ); + + const isEditing = !!template; + const isValid = + name.trim().length > 0 && + description.trim().length > 0 && + statuses.some((s) => s.name.trim().length > 0); + + const handleSave = useCallback(() => { + if (!isValid) return; + + const templateId = template?.id || `custom-${Date.now()}`; + const validStatuses = statuses.filter((s) => s.name.trim().length > 0); + + // Update templateId for all statuses + const statusesWithTemplateId = validStatuses.map((s) => ({ + ...s, + templateId, + })); + + const savedTemplate: StatusTemplate = { + id: templateId, + name: name.trim(), + description: description.trim(), + statuses: statusesWithTemplateId, + isCustom: true, + }; + + onSave(savedTemplate); + }, [name, description, statuses, template, isValid, onSave]); + + const handleAddStatus = useCallback(() => { + setStatuses((prev) => [ + ...prev, + { + name: "", + icon: "", + color: "#888888", + templateId: template?.id || "", + }, + ]); + }, [template?.id]); + + const handleStatusChange = useCallback( + ( + index: number, + column: "name" | "icon" | "color" | "description", + value: string, + ) => { + setStatuses((prev) => + prev.map((status, i) => + i === index ? { ...status, [column]: value } : status, + ), + ); + }, + [], + ); + + const handleStatusRemove = useCallback((index: number) => { + setStatuses((prev) => prev.filter((_, i) => i !== index)); + }, []); + + const handleMoveUp = useCallback((index: number) => { + if (index <= 0) return; + setStatuses((prev) => { + const newStatuses = [...prev]; + [newStatuses[index - 1], newStatuses[index]] = [ + newStatuses[index], + newStatuses[index - 1], + ]; + return newStatuses; + }); + }, []); + + const handleMoveDown = useCallback((index: number) => { + setStatuses((prev) => { + if (index >= prev.length - 1) return prev; + const newStatuses = [...prev]; + [newStatuses[index], newStatuses[index + 1]] = [ + newStatuses[index + 1], + newStatuses[index], + ]; + return newStatuses; + }); + }, []); + + return ( +
+
+

{isEditing ? "Edit Template" : "Create New Template"}

+
+ +
+ + + + + + + + + +
+ {statuses.map((status, index) => ( + 0} + canMoveDown={index < statuses.length - 1} + /> + ))} + + +
+
+
+ +
+ + +
+
+ ); +}; diff --git a/components/SettingsUI/TemplateItem.tsx b/components/SettingsUI/TemplateItem.tsx index 194a100..b2ed256 100644 --- a/components/SettingsUI/TemplateItem.tsx +++ b/components/SettingsUI/TemplateItem.tsx @@ -2,47 +2,120 @@ import React from "react"; import { StatusTemplate } from "@/types/pluginSettings"; import { StatusDisplay } from "../atoms/StatusDisplay"; import { SelectableListItem } from "../atoms/SelectableListItem"; +import { isCustomTemplate } from "@/utils/templateUtils"; interface TemplateItemProps { template: StatusTemplate; isEnabled: boolean; onToggle: (templateId: string, enabled: boolean) => void; + onEdit?: (template: StatusTemplate) => void; + onDelete?: (templateId: string) => void; } export const TemplateItem: React.FC = ({ template, isEnabled, onToggle, -}) => ( - onToggle(template.id, !isEnabled)} - className={`template-item ${isEnabled ? "enabled" : ""}`} - icon={ - + onEdit, + onDelete, +}) => { + const handleClick = (e: React.MouseEvent) => { + // Don't toggle if clicking action buttons + if ((e.target as HTMLElement).closest(".template-item-actions")) { + return; } - > -
-
- {template.name} -
-
- {template.description}: -
-
- {template.statuses.map((status, index) => ( - + - ))} + } + > +
+
+ + {template.name} + + {isCustomTemplate(template.id) && ( + + Custom + + )} +
+
+ {template.description}: +
+
+ {template.statuses.map((status, index) => ( + + ))} +
+
+
+
+ {onEdit && ( + + )} + {onDelete && ( + + )}
- -); + ); +}; diff --git a/components/SettingsUI/TemplateSettings.tsx b/components/SettingsUI/TemplateSettings.tsx index 72adbab..4502016 100644 --- a/components/SettingsUI/TemplateSettings.tsx +++ b/components/SettingsUI/TemplateSettings.tsx @@ -1,51 +1,204 @@ -import React, { useCallback } from "react"; -import { PluginSettings } from "@/types/pluginSettings"; +import React, { useCallback, useState } from "react"; +import { PluginSettings, StatusTemplate } from "@/types/pluginSettings"; import { TemplateItem } from "./TemplateItem"; +import { TemplateEditorModal } from "./TemplateEditorModal"; +import { + generateTemplateId, + isTemplateNameUnique, +} from "@/utils/templateUtils"; interface TemplateSettingsProps { settings: PluginSettings; onChange: (key: keyof PluginSettings, value: unknown) => void; + templates: StatusTemplate[]; } export const TemplateSettings: React.FC = ({ settings, onChange, }) => { + const [showEditor, setShowEditor] = useState(false); + const [editingTemplate, setEditingTemplate] = useState< + StatusTemplate | undefined + >(); + const handleTemplateToggle = useCallback( (templateId: string, enabled: boolean) => { - let newEnabledTemplated = [...settings.enabledTemplates]; + let newEnabledTemplates = [...settings.enabledTemplates]; if (enabled) { - if (!newEnabledTemplated.includes(templateId)) { - newEnabledTemplated.push(templateId); + if (!newEnabledTemplates.includes(templateId)) { + newEnabledTemplates.push(templateId); } } else { - newEnabledTemplated = newEnabledTemplated.filter( + newEnabledTemplates = newEnabledTemplates.filter( (id: string) => id !== templateId, ); } - onChange("enabledTemplates", newEnabledTemplated); + onChange("enabledTemplates", newEnabledTemplates); }, [onChange, settings.enabledTemplates], ); + const handleCreateTemplate = useCallback(() => { + setEditingTemplate(undefined); + setShowEditor(true); + }, []); + + const handleEditTemplate = useCallback((template: StatusTemplate) => { + setEditingTemplate(template); + setShowEditor(true); + }, []); + + const handleSaveTemplate = useCallback( + (template: StatusTemplate) => { + // Validate name uniqueness + if (!isTemplateNameUnique(template.name, editingTemplate?.id)) { + alert( + "A template with this name already exists. Please choose a different name.", + ); + return; + } + + let finalTemplate = template; + + if (editingTemplate) { + // Update existing template - keep same ID + finalTemplate = { ...template, id: editingTemplate.id }; + const updatedTemplates = settings.templates.map((t) => + t.id === editingTemplate.id ? finalTemplate : t, + ); + onChange("templates", updatedTemplates); + } else { + // Add new template - generate unique ID + const uniqueId = generateTemplateId(template.name); + finalTemplate = { ...template, id: uniqueId }; + + // Update statuses with the final template ID + finalTemplate.statuses = finalTemplate.statuses.map( + (status) => ({ + ...status, + templateId: uniqueId, + }), + ); + + onChange("templates", [...settings.templates, finalTemplate]); + // Auto-enable new templates + handleTemplateToggle(uniqueId, true); + } + + setShowEditor(false); + setEditingTemplate(undefined); + }, + [editingTemplate, settings, onChange, handleTemplateToggle], + ); + + const handleDeleteTemplate = useCallback( + (templateId: string) => { + // Show confirmation + const confirmed = confirm( + "Are you sure you want to delete this template? This action cannot be undone.", + ); + if (!confirmed) return; + + // Remove from custom templates + const updatedTemplates = (settings.templates || []).filter( + (t) => t.id !== templateId, + ); + onChange("templates", updatedTemplates); + + // Remove from enabled templates + const updatedEnabled = settings.enabledTemplates.filter( + (id) => id !== templateId, + ); + onChange("enabledTemplates", updatedEnabled); + }, + [settings.templates, settings.enabledTemplates, onChange], + ); + + const handleResetToDefaults = useCallback(() => { + const confirmed = confirm( + "Reset to default templates? This will:\n" + + "• Remove all custom templates\n" + + "• Reset enabled templates to defaults\n" + + "This action cannot be undone.", + ); + if (!confirmed) return; + + onChange("templates", []); + onChange("enabledTemplates", ["colorful"]); + }, [onChange]); + + const handleCancelEditor = useCallback(() => { + setShowEditor(false); + setEditingTemplate(undefined); + }, []); + + if (showEditor) { + return ( +
+

Status templates

+ +
+ ); + } + return (

Status templates

Enable predefined templates to quickly add common status - workflows + workflows, or create your own custom templates.

-
- {settings.templates.map((template) => ( - - ))} + +
+ + +
+ + {/* Custom Templates Section */} +
+

+ + + + + Custom Templates +

+
+ {settings.templates.map((template) => ( + + ))} +
); diff --git a/components/atoms/SelectableListItem.tsx b/components/atoms/SelectableListItem.tsx index f17e97f..eb900ee 100644 --- a/components/atoms/SelectableListItem.tsx +++ b/components/atoms/SelectableListItem.tsx @@ -5,7 +5,7 @@ interface SelectableListItemProps { focused?: boolean; icon?: ReactNode; children: ReactNode; - onClick?: () => void; + onClick?: (e: React.MouseEvent) => void; className?: string; title?: string; } @@ -21,10 +21,10 @@ export const SelectableListItem: React.FC = ({ }) => { const [isHovered, setIsHovered] = useState(false); - const handleClick = () => { + const handleClick = (e: React.MouseEvent) => { if (onClick) { setTimeout(() => { - onClick(); + onClick(e); }, 150); } }; diff --git a/styles/components/settings.css b/styles/components/settings.css index d983a80..c329756 100644 --- a/styles/components/settings.css +++ b/styles/components/settings.css @@ -15,37 +15,6 @@ gap: var(--size-2-2); } -/* Template Item */ -.template-item { - border: 1px solid var(--background-modifier-border); - border-radius: var(--radius-m); - margin-bottom: var(--size-4-2); - padding: var(--size-4-3); - background: var(--background-primary-alt); - cursor: pointer; - transition: all var(--anim-duration-fast) ease; -} - -.template-item:hover { - background: var(--background-modifier-hover); -} - -.template-item.enabled { - background: var(--background-modifier-success); - border-color: var(--interactive-success); -} - -.template-checkbox { - pointer-events: none; -} - -.template-statuses { - margin-top: var(--size-2-2); - display: flex; - flex-wrap: wrap; - gap: var(--size-2-1); -} - /* Custom Status Item - BEM Methodology */ .custom-status-item { background: var(--background-secondary); diff --git a/styles/components/template-settings.css b/styles/components/template-settings.css new file mode 100644 index 0000000..0ce184a --- /dev/null +++ b/styles/components/template-settings.css @@ -0,0 +1,288 @@ +/* ========================================================================== + Template Settings Components + ========================================================================== */ + +/* Template Settings Actions */ +.template-settings-actions { + display: flex; + gap: var(--size-4-2); + margin-bottom: var(--size-4-4); + padding: var(--size-4-2); + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); +} + +.template-create-btn { + display: flex; + align-items: center; + gap: var(--size-2-2); + padding: var(--size-2-3) var(--size-4-3); + font-weight: var(--font-medium); + font-size: var(--font-ui-medium); +} + +.template-reset-btn { + display: flex; + align-items: center; + gap: var(--size-2-2); + padding: var(--size-2-3) var(--size-4-3); + background: var(--background-modifier-border); + border: 1px solid var(--background-modifier-border-hover); + border-radius: var(--radius-s); + color: var(--text-normal); + cursor: pointer; + font-size: var(--font-ui-medium); + transition: all var(--anim-duration-fast) ease; +} + +.template-reset-btn:hover { + background: var(--background-modifier-border-hover); + color: var(--text-accent); +} + +/* Template Sections */ +.template-section { + margin-bottom: var(--size-4-6); +} + +.template-section-title { + display: flex; + align-items: center; + gap: var(--size-2-2); + font-size: var(--font-ui-large); + font-weight: var(--font-semibold); + color: var(--text-normal); + margin-bottom: var(--size-4-3); + padding-bottom: var(--size-2-2); + border-bottom: 2px solid var(--background-modifier-border); +} + +.template-section-title svg { + opacity: 0.8; +} + +.template-list { + display: flex; + flex-direction: column; + gap: var(--size-4-2); +} + +/* Template Item Updates */ +.template-item { + position: relative; + display: flex; + align-items: stretch; + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + background: var(--background-primary-alt); + transition: all var(--anim-duration-fast) ease; + overflow: hidden; +} + +.template-item:hover { + background: var(--background-modifier-hover); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.template-item.enabled { + background: var(--background-modifier-success); + border-color: var(--interactive-success); +} + +.template-item-content { + flex: 1; + border: none !important; + border-radius: 0 !important; + background: transparent !important; +} + +.template-item-main { + flex: 1; +} + +.template-header { + display: flex; + align-items: center; + gap: var(--size-2-2); + margin-bottom: var(--size-2-1); +} + +.template-custom-badge { + padding: var(--size-2-1) var(--size-2-2); + background: var(--interactive-accent); + color: var(--text-on-accent); + font-size: var(--font-ui-smaller); + font-weight: var(--font-medium); + border-radius: var(--radius-s); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Template Item Actions */ +.template-item-actions { + display: flex; + flex-direction: column; + padding: var(--size-4-2); + gap: var(--size-2-1); + background: var(--background-modifier-border); + border-left: 1px solid var(--background-modifier-border-hover); + min-width: 44px; + align-items: center; + justify-content: center; +} + +.template-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: var(--radius-s); + cursor: pointer; + transition: all var(--anim-duration-fast) ease; + color: var(--text-muted); +} + +.template-action-btn:hover { + transform: scale(1.1); +} + +.template-action-btn--edit { + background: var(--background-modifier-border-hover); + color: var(--text-accent); +} + +.template-action-btn--edit:hover { + background: var(--interactive-accent); + color: var(--text-on-accent); +} + +.template-action-btn--delete { + background: var(--background-modifier-error); + color: var(--text-error); +} + +.template-action-btn--delete:hover { + background: var(--text-error); + color: var(--text-on-accent); +} + +/* Template Editor Modal */ +.template-editor-modal { + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-l); + box-shadow: var(--shadow-l); + max-width: 800px; + width: 100%; +} + +.template-editor-modal__header { + padding: var(--size-4-4) var(--size-4-6); + background: var(--background-secondary); + border-bottom: 1px solid var(--background-modifier-border); + border-radius: var(--radius-l) var(--radius-l) 0 0; +} + +.template-editor-modal__header h2 { + margin: 0; + font-size: var(--font-ui-larger); + font-weight: var(--font-bold); + color: var(--text-normal); +} + +.template-editor-modal__content { + padding: var(--size-4-6); + max-height: 70vh; + overflow-y: auto; +} + +.template-editor-statuses { + display: flex; + flex-direction: column; + gap: var(--size-4-3); +} + +.template-editor-add-status { + align-self: flex-start; + margin-top: var(--size-4-2); +} + +.template-editor-modal__actions { + display: flex; + gap: var(--size-4-2); + justify-content: flex-end; + padding: var(--size-4-4) var(--size-4-6); + background: var(--background-secondary); + border-top: 1px solid var(--background-modifier-border); + border-radius: 0 0 var(--radius-l) var(--radius-l); +} + +.template-editor-modal__actions button { + padding: var(--size-2-3) var(--size-4-4); + border-radius: var(--radius-m); + font-size: var(--font-ui-medium); + font-weight: var(--font-medium); + cursor: pointer; + transition: all var(--anim-duration-fast) ease; +} + +.template-editor-modal__actions button:not(.mod-cta) { + background: var(--background-modifier-border); + border: 1px solid var(--background-modifier-border-hover); + color: var(--text-normal); +} + +.template-editor-modal__actions button:not(.mod-cta):hover { + background: var(--background-modifier-border-hover); +} + +.template-editor-modal__actions .mod-cta:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.template-editor-modal__actions .mod-cta:disabled:hover { + transform: none; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .template-settings-actions { + flex-direction: column; + } + + .template-item { + flex-direction: column; + } + + .template-item-actions { + flex-direction: row; + border-left: none; + border-top: 1px solid var(--background-modifier-border-hover); + min-width: unset; + padding: var(--size-2-3) var(--size-4-2); + } + + .template-editor-modal { + margin: var(--size-4-2); + max-width: calc(100% - var(--size-4-4)); + } +} + +/* Scrollbar for template editor */ +.template-editor-modal__content::-webkit-scrollbar { + width: var(--scrollbar-width); +} + +.template-editor-modal__content::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-bg); + border-radius: var(--radius-s); +} + +.template-editor-modal__content::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-active-bg); +} diff --git a/styles/index.css b/styles/index.css index 8370858..4f0b7db 100644 --- a/styles/index.css +++ b/styles/index.css @@ -8,6 +8,7 @@ /* Import component stylesheets */ @import "components/status-bar.css"; @import "components/settings.css"; +@import "components/template-settings.css"; @import "components/dashboard.css"; @import "components/grouped-view.css"; @import "components/file-explorer.css"; diff --git a/utils/templateUtils.ts b/utils/templateUtils.ts new file mode 100644 index 0000000..fe7649a --- /dev/null +++ b/utils/templateUtils.ts @@ -0,0 +1,52 @@ +import { StatusTemplate } from "@/types/pluginSettings"; +import settingsService from "@/core/settingsService"; +import { PREDEFINED_TEMPLATES } from "@/constants/predefinedTemplates"; + +/** + * Get all enabled templates + */ +export const isCustomTemplate = (templateId: string): boolean => { + const i = PREDEFINED_TEMPLATES.findIndex((t) => t.id === templateId); + return i === -1; +}; + +/** + * Get template by ID + */ +export const getTemplateById = (id: string): StatusTemplate | undefined => { + const allTemplates = settingsService.settings.templates; + return allTemplates.find((template) => template.id === id); +}; + +/** + * Validate template name uniqueness + */ +export const isTemplateNameUnique = ( + name: string, + excludeId?: string, +): boolean => { + const allTemplates = settingsService.settings.templates; + return !allTemplates.some( + (template) => + template.name.toLowerCase() === name.toLowerCase() && + template.id !== excludeId, + ); +}; + +/** + * Generate unique template ID + */ +export const generateTemplateId = (name: string): string => { + const baseId = name.toLowerCase().replace(/[^a-z0-9]/g, "-"); + const allTemplates = settingsService.settings.templates; + + let counter = 1; + let id = `custom-${baseId}`; + + while (allTemplates.some((template) => template.id === id)) { + id = `custom-${baseId}-${counter}`; + counter++; + } + + return id; +};