* feat: add template field to type entries and template-aware note creation - Rust: add template field to Frontmatter, VaultEntry, SKIP_KEYS - Rust: support YAML block scalar (|) for multi-line strings in frontmatter - Rust: fix value continuation detection to handle block scalars properly - TypeScript: add template to VaultEntry, buildNewEntry, frontmatterToEntryPatch - Add DEFAULT_TEMPLATES for Project, Person, Responsibility, Experiment - resolveNewNote/buildNoteContent accept optional template parameter - handleCreateNote and handleCreateNoteImmediate look up type template - Tests for all new behavior (Rust + TypeScript) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: wire template UI through Sidebar and App, add TypeCustomizePopover template section - Add template textarea with debounced save to TypeCustomizePopover - Wire handleUpdateTypeTemplate through useEntryActions → Sidebar → App - Add useDebouncedCallback hook for 500ms template save debounce - Add tests for handleUpdateTypeTemplate and TypeCustomizePopover template UI - Create design/note-templates.pen placeholder Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: useRef type arg + template field in bulk mock entries * style: rustfmt --------- Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
161 lines
5.7 KiB
TypeScript
161 lines
5.7 KiB
TypeScript
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
|
|
import { MagnifyingGlass } from '@phosphor-icons/react'
|
|
import { ICON_OPTIONS, type IconEntry } from '../utils/iconRegistry'
|
|
import { ACCENT_COLORS } from '../utils/typeColors'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
function filterIcons(icons: IconEntry[], query: string): IconEntry[] {
|
|
if (!query) return icons
|
|
const lower = query.toLowerCase()
|
|
return icons.filter((o) => o.name.includes(lower))
|
|
}
|
|
|
|
interface TypeCustomizePopoverProps {
|
|
currentIcon: string | null
|
|
currentColor: string | null
|
|
currentTemplate: string | null
|
|
onChangeIcon: (icon: string) => void
|
|
onChangeColor: (color: string) => void
|
|
onChangeTemplate: (template: string) => void
|
|
onClose: () => void
|
|
}
|
|
|
|
/** Debounce a callback by `delay` ms. Returns a stable ref-based wrapper. */
|
|
function useDebouncedCallback(fn: (v: string) => void, delay: number): (v: string) => void {
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
const fnRef = useRef(fn)
|
|
useEffect(() => { fnRef.current = fn })
|
|
|
|
useEffect(() => () => { clearTimeout(timerRef.current) }, [])
|
|
|
|
return useCallback((v: string) => {
|
|
clearTimeout(timerRef.current)
|
|
timerRef.current = setTimeout(() => fnRef.current(v), delay)
|
|
}, [delay])
|
|
}
|
|
|
|
export function TypeCustomizePopover({
|
|
currentIcon,
|
|
currentColor,
|
|
currentTemplate,
|
|
onChangeIcon,
|
|
onChangeColor,
|
|
onChangeTemplate,
|
|
onClose,
|
|
}: TypeCustomizePopoverProps) {
|
|
const [selectedColor, setSelectedColor] = useState(currentColor)
|
|
const [selectedIcon, setSelectedIcon] = useState(currentIcon)
|
|
const [search, setSearch] = useState('')
|
|
const [templateText, setTemplateText] = useState(currentTemplate ?? '')
|
|
|
|
const filteredIcons = useMemo(() => filterIcons(ICON_OPTIONS, search), [search])
|
|
|
|
const handleColorClick = (key: string) => {
|
|
setSelectedColor(key)
|
|
onChangeColor(key)
|
|
}
|
|
|
|
const handleIconClick = (name: string) => {
|
|
setSelectedIcon(name)
|
|
onChangeIcon(name)
|
|
}
|
|
|
|
const debouncedSaveTemplate = useDebouncedCallback(onChangeTemplate, 500)
|
|
|
|
const handleTemplateChange = (value: string) => {
|
|
setTemplateText(value)
|
|
debouncedSaveTemplate(value)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="bg-popover text-popover-foreground z-50 rounded-lg border shadow-md"
|
|
style={{ width: 320, padding: 12 }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onContextMenu={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Color section */}
|
|
<div className="font-mono-overline mb-2 text-muted-foreground">COLOR</div>
|
|
<div className="flex gap-2 mb-3 flex-wrap">
|
|
{ACCENT_COLORS.map((c) => (
|
|
<button
|
|
key={c.key}
|
|
className={cn(
|
|
"flex items-center justify-center rounded-full border-2 cursor-pointer transition-all",
|
|
selectedColor === c.key ? "border-foreground scale-110" : "border-transparent hover:scale-105",
|
|
)}
|
|
style={{ width: 24, height: 24, backgroundColor: c.css, border: selectedColor === c.key ? '2px solid var(--foreground)' : '2px solid transparent' }}
|
|
onClick={() => handleColorClick(c.key)}
|
|
title={c.label}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Icon section */}
|
|
<div className="font-mono-overline mb-2 text-muted-foreground">ICON</div>
|
|
|
|
{/* Search input */}
|
|
<div className="relative mb-2">
|
|
<MagnifyingGlass
|
|
size={14}
|
|
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="Search icons…"
|
|
className="w-full rounded border border-border bg-background pl-7 pr-2 py-1 text-[12px] text-foreground placeholder:text-muted-foreground outline-none focus:border-primary"
|
|
/>
|
|
</div>
|
|
|
|
{/* Icon grid */}
|
|
<div className="flex flex-wrap gap-1 overflow-y-auto" style={{ maxHeight: 160 }}>
|
|
{filteredIcons.length === 0 ? (
|
|
<div className="w-full py-6 text-center text-[12px] text-muted-foreground">
|
|
No icons found
|
|
</div>
|
|
) : (
|
|
filteredIcons.map(({ name, Icon }) => (
|
|
<button
|
|
key={name}
|
|
className={cn(
|
|
"flex items-center justify-center rounded cursor-pointer transition-colors",
|
|
selectedIcon === name
|
|
? "bg-primary/10 text-primary"
|
|
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
|
)}
|
|
style={{ width: 30, height: 30 }}
|
|
onClick={() => handleIconClick(name)}
|
|
title={name}
|
|
>
|
|
<Icon size={16} />
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Template section */}
|
|
<div className="font-mono-overline mb-2 mt-3 text-muted-foreground">TEMPLATE</div>
|
|
<textarea
|
|
value={templateText}
|
|
onChange={(e) => handleTemplateChange(e.target.value)}
|
|
placeholder="Markdown template for new notes of this type…"
|
|
className="w-full rounded border border-border bg-background px-2 py-1.5 text-[12px] font-mono text-foreground placeholder:text-muted-foreground outline-none focus:border-primary resize-y"
|
|
style={{ minHeight: 80, maxHeight: 200 }}
|
|
data-testid="template-textarea"
|
|
/>
|
|
|
|
{/* Done button */}
|
|
<div className="mt-3 flex justify-end">
|
|
<button
|
|
className="rounded px-3 py-1 text-[12px] font-medium text-muted-foreground hover:text-foreground hover:bg-accent cursor-pointer transition-colors border-none bg-transparent"
|
|
onClick={onClose}
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|