From 86cc6aef6cb61028ab1cbac40e090ebd6ae82b70 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Sat, 28 Mar 2026 14:08:17 -0700 Subject: [PATCH 01/11] fix: preserve and show copied template tags --- .../session/components/note-input/header.tsx | 43 ++++++++++++++++++- apps/desktop/src/store/tinybase/store/main.ts | 4 ++ .../src/templates/components/details.tsx | 6 +++ .../templates/components/template-form.tsx | 10 ++++- apps/desktop/src/templates/index.tsx | 2 + apps/desktop/src/templates/shared.ts | 30 +++++++++++++ .../desktop/src/templates/sidebar-content.tsx | 39 ++++++++++++++++- packages/store/src/tinybase.ts | 2 + 8 files changed, 132 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/session/components/note-input/header.tsx b/apps/desktop/src/session/components/note-input/header.tsx index dd3171dba0..09ce8236d5 100644 --- a/apps/desktop/src/session/components/note-input/header.tsx +++ b/apps/desktop/src/session/components/note-input/header.tsx @@ -434,6 +434,8 @@ function CreateOtherFormatButton({ created_at: string; title: string; description: string; + category?: string; + targets?: string[]; sections: Array<{ title: string; description: string }>; }) => p.id, (p: { @@ -442,11 +444,15 @@ function CreateOtherFormatButton({ created_at: string; title: string; description: string; + category?: string; + targets?: string[]; sections: Array<{ title: string; description: string }>; }) => ({ user_id: p.user_id, title: p.title, description: p.description, + category: p.category, + targets: p.targets ? JSON.stringify(p.targets) : undefined, sections: JSON.stringify(p.sections), }), [], @@ -511,6 +517,8 @@ function CreateOtherFormatButton({ created_at: now, title: template.title, description: template.description, + category: template.category, + targets: template.targets, sections: template.sections ?? [], }); @@ -590,7 +598,11 @@ function CreateOtherFormatButton({ return sortedTemplates.filter( (template) => template.title?.toLowerCase().includes(searchQuery) || - template.description?.toLowerCase().includes(searchQuery), + template.description?.toLowerCase().includes(searchQuery) || + template.category?.toLowerCase().includes(searchQuery) || + template.targets?.some((target) => + target.toLowerCase().includes(searchQuery), + ), ); }, [searchQuery, userTemplates]); @@ -622,6 +634,7 @@ function CreateOtherFormatButton({ key: string; title: string; description?: string; + tags?: string[]; onClick: () => void; }>; }> @@ -635,6 +648,7 @@ function CreateOtherFormatButton({ key: template.slug || `suggested-${index}`, title: template.title || "Untitled", description: template.description, + tags: getTemplateTags(template), onClick: () => handleSuggestedTemplateClick(template), })), emptyMessage: isSuggestedTemplatesLoading @@ -648,6 +662,7 @@ function CreateOtherFormatButton({ key: template.id, title: template.title || "Untitled", description: template.description, + tags: getTemplateTags(template), onClick: () => handleUseTemplate(template.id), })), emptyMessage: "No favorite templates yet", @@ -678,6 +693,7 @@ function CreateOtherFormatButton({ key: template.slug || `suggested-${index}`, title: template.title || "Untitled", description: template.description, + tags: getTemplateTags(template), onClick: () => handleSuggestedTemplateClick(template), })), }, @@ -692,6 +708,7 @@ function CreateOtherFormatButton({ key: template.id, title: template.title || "Untitled", description: template.description, + tags: getTemplateTags(template), onClick: () => handleUseTemplate(template.id), })), }, @@ -825,6 +842,7 @@ function CreateOtherFormatButton({ }} title={item.title} description={item.description} + tags={item.tags} onClick={item.onClick} onKeyDown={(e) => handleResultKeyDown(e, itemIndex)} /> @@ -1111,6 +1129,15 @@ type WebTemplate = { sections: Array<{ title: string; description: string }>; }; +function getTemplateTags(template: { category?: string; targets?: string[] }) { + return [ + ...new Set([ + ...(template.category ? [template.category] : []), + ...(template.targets ?? []), + ]), + ]; +} + const TEMPLATE_SUGGESTION_STOP_WORDS = new Set([ "about", "after", @@ -1297,12 +1324,14 @@ function TemplateResultButton({ buttonRef, title, description, + tags, onClick, onKeyDown, }: { buttonRef?: React.Ref; title: string; description?: string; + tags?: string[]; onClick: () => void; onKeyDown?: (e: React.KeyboardEvent) => void; }) { @@ -1324,6 +1353,18 @@ function TemplateResultButton({ {description} ) : null} + {tags && tags.length > 0 ? ( + + {tags.map((tag, index) => ( + + {tag} + + ))} + + ) : null} ); } diff --git a/apps/desktop/src/store/tinybase/store/main.ts b/apps/desktop/src/store/tinybase/store/main.ts index 106b0b0226..4e35902bdd 100644 --- a/apps/desktop/src/store/tinybase/store/main.ts +++ b/apps/desktop/src/store/tinybase/store/main.ts @@ -138,6 +138,8 @@ export const StoreComponent = () => { ({ select }) => { select("title"); select("description"); + select("category"); + select("targets"); select("sections"); }, ) @@ -194,6 +196,8 @@ export const StoreComponent = () => { ({ select, where, param }) => { select("title"); select("description"); + select("category"); + select("targets"); select("sections"); select("user_id"); where("user_id", (param("user_id") as string) ?? ""); diff --git a/apps/desktop/src/templates/components/details.tsx b/apps/desktop/src/templates/components/details.tsx index f97186247e..3e3b487ab8 100644 --- a/apps/desktop/src/templates/components/details.tsx +++ b/apps/desktop/src/templates/components/details.tsx @@ -33,6 +33,8 @@ export function TemplateDetailsColumn({ handleCloneTemplate: (template: { title: string; description: string; + category?: string; + targets?: string[]; sections: TemplateSection[]; }) => void; }) { @@ -69,6 +71,8 @@ function WebTemplatePreview({ onClone: (template: { title: string; description: string; + category?: string; + targets?: string[]; sections: TemplateSection[]; }) => void; }) { @@ -85,6 +89,8 @@ function WebTemplatePreview({ onClone({ title: template.title ?? "", description: template.description ?? "", + category: template.category, + targets: template.targets, sections: template.sections ?? [], }) } diff --git a/apps/desktop/src/templates/components/template-form.tsx b/apps/desktop/src/templates/components/template-form.tsx index ca7bed9dab..355fadb026 100644 --- a/apps/desktop/src/templates/components/template-form.tsx +++ b/apps/desktop/src/templates/components/template-form.tsx @@ -50,6 +50,7 @@ function normalizeTemplatePayload(template: unknown): Template { title: typeof record.title === "string" ? record.title : "", description: typeof record.description === "string" ? record.description : "", + category: typeof record.category === "string" ? record.category : undefined, sections, targets, }; @@ -161,9 +162,14 @@ export function TemplateForm({ /> )} - {value.targets && value.targets.length > 0 && ( + {(value.category || (value.targets && value.targets.length > 0)) && (
- {value.targets.map((target, index) => ( + {value.category ? ( + + ({value.category}) + + ) : null} + {value.targets?.map((target, index) => ( }) { (template: { title: string; description: string; + category?: string; + targets?: string[]; sections: TemplateSection[]; }) => { const id = createTemplate({ diff --git a/apps/desktop/src/templates/shared.ts b/apps/desktop/src/templates/shared.ts index 9274f545ed..26840c971a 100644 --- a/apps/desktop/src/templates/shared.ts +++ b/apps/desktop/src/templates/shared.ts @@ -18,6 +18,8 @@ export type UserTemplate = Template & { id: string }; type TemplateDraft = { title: string; description: string; + category?: string; + targets?: string[]; sections: TemplateSection[]; }; @@ -115,6 +117,8 @@ export function useCreateTemplate() { created_at: string; title: string; description: string; + category?: string; + targets?: string[]; sections: TemplateSection[]; }) => p.id, (p: { @@ -123,12 +127,16 @@ export function useCreateTemplate() { created_at: string; title: string; description: string; + category?: string; + targets?: string[]; sections: TemplateSection[]; }) => ({ user_id: p.user_id, title: p.title, description: p.description, + category: p.category, + targets: p.targets ? JSON.stringify(p.targets) : undefined, sections: JSON.stringify(p.sections), }) satisfies TemplateStorage, [], @@ -148,6 +156,8 @@ export function useCreateTemplate() { created_at: now, title: template.title, description: template.description, + category: template.category, + targets: template.targets, sections: template.sections.map((section) => ({ ...section })), }); @@ -178,6 +188,26 @@ function normalizeTemplatePayload(template: unknown): Template { title: typeof record.title === "string" ? record.title : "", description: typeof record.description === "string" ? record.description : "", + category: typeof record.category === "string" ? record.category : undefined, + targets: + typeof record.targets === "string" + ? (() => { + try { + const parsed = JSON.parse(record.targets); + return Array.isArray(parsed) + ? parsed.filter( + (target): target is string => typeof target === "string", + ) + : undefined; + } catch { + return undefined; + } + })() + : Array.isArray(record.targets) + ? record.targets.filter( + (target): target is string => typeof target === "string", + ) + : undefined, sections, }; } diff --git a/apps/desktop/src/templates/sidebar-content.tsx b/apps/desktop/src/templates/sidebar-content.tsx index ba816110df..5fe60cf535 100644 --- a/apps/desktop/src/templates/sidebar-content.tsx +++ b/apps/desktop/src/templates/sidebar-content.tsx @@ -97,6 +97,8 @@ export function TemplatesSidebarContent({ const id = createTemplate({ title: getDuplicatedTemplateTitle(template.title), description: template.description ?? "", + category: template.category, + targets: template.targets, sections: template.sections.map((section) => ({ ...section })), }); @@ -139,7 +141,9 @@ export function TemplatesSidebarContent({ return sortedUserTemplates.filter( (template) => template.title?.toLowerCase().includes(q) || - template.description?.toLowerCase().includes(q), + template.description?.toLowerCase().includes(q) || + template.category?.toLowerCase().includes(q) || + template.targets?.some((target) => target.toLowerCase().includes(q)), ); }, [sortedUserTemplates, search]); @@ -319,6 +323,12 @@ export function TemplatesSidebarContent({
{template.title || "Untitled"}
+
@@ -388,12 +398,39 @@ function TemplateListItem({
{template.title?.trim() || "Untitled"}
+ ); } +function TemplateTags({ tags }: { tags: string[] }) { + const uniqueTags = [...new Set(tags)]; + + if (uniqueTags.length === 0) { + return null; + } + + return ( +
+ {uniqueTags.map((tag) => ( + + {tag} + + ))} +
+ ); +} + function getDuplicatedTemplateTitle(title: string) { const value = title.trim(); return value ? `${value} copy` : "Untitled copy"; diff --git a/packages/store/src/tinybase.ts b/packages/store/src/tinybase.ts index 4455153461..96b2eca580 100644 --- a/packages/store/src/tinybase.ts +++ b/packages/store/src/tinybase.ts @@ -118,6 +118,8 @@ export const tableSchemaForTinybase = { user_id: { type: "string" }, title: { type: "string" }, description: { type: "string" }, + category: { type: "string" }, + targets: { type: "string" }, sections: { type: "string" }, } as const satisfies InferTinyBaseSchema, chat_groups: { From d982707e8a515d9b2bc85c31a23b1b6794e79a59 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Sat, 28 Mar 2026 14:08:56 -0700 Subject: [PATCH 02/11] fix: hide template tags in sidebar --- .../desktop/src/templates/sidebar-content.tsx | 33 ------------------- 1 file changed, 33 deletions(-) diff --git a/apps/desktop/src/templates/sidebar-content.tsx b/apps/desktop/src/templates/sidebar-content.tsx index 5fe60cf535..c55865c6a8 100644 --- a/apps/desktop/src/templates/sidebar-content.tsx +++ b/apps/desktop/src/templates/sidebar-content.tsx @@ -323,12 +323,6 @@ export function TemplatesSidebarContent({
{template.title || "Untitled"}
- @@ -398,39 +392,12 @@ function TemplateListItem({
{template.title?.trim() || "Untitled"}
- ); } -function TemplateTags({ tags }: { tags: string[] }) { - const uniqueTags = [...new Set(tags)]; - - if (uniqueTags.length === 0) { - return null; - } - - return ( -
- {uniqueTags.map((tag) => ( - - {tag} - - ))} -
- ); -} - function getDuplicatedTemplateTitle(title: string) { const value = title.trim(); return value ? `${value} copy` : "Untitled copy"; From df4ee1eed021d90e727068572431619e915de00a Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Sat, 28 Mar 2026 14:22:12 -0700 Subject: [PATCH 03/11] fix: add template favorite and action controls --- .../session/components/note-input/header.tsx | 101 +++++++++++++++--- apps/desktop/src/store/tinybase/store/main.ts | 4 + .../src/templates/components/details.tsx | 3 + .../templates/components/template-form.tsx | 96 ++++++++++++++--- apps/desktop/src/templates/index.tsx | 17 +++ apps/desktop/src/templates/shared.ts | 46 +++++++- .../desktop/src/templates/sidebar-content.tsx | 45 ++++++-- packages/store/src/tinybase.ts | 2 + packages/store/src/zod.ts | 2 + 9 files changed, 281 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/session/components/note-input/header.tsx b/apps/desktop/src/session/components/note-input/header.tsx index 09ce8236d5..110be6252a 100644 --- a/apps/desktop/src/session/components/note-input/header.tsx +++ b/apps/desktop/src/session/components/note-input/header.tsx @@ -586,25 +586,34 @@ function CreateOtherFormatButton({ [meetingContent, suggestedTemplates], ); + const favoriteTemplates = useMemo( + () => sortFavoriteTemplates(userTemplates), + [userTemplates], + ); + const otherTemplates = useMemo( + () => sortOtherTemplates(userTemplates), + [userTemplates], + ); + const filteredFavoriteTemplates = useMemo(() => { - const sortedTemplates = [...userTemplates].sort((a, b) => - (a.title || "").localeCompare(b.title || ""), + if (!searchQuery) { + return favoriteTemplates; + } + + return favoriteTemplates.filter((template) => + matchesTemplateSearch(template, searchQuery), ); + }, [favoriteTemplates, searchQuery]); + const filteredOtherTemplates = useMemo(() => { if (!searchQuery) { - return sortedTemplates; + return otherTemplates; } - return sortedTemplates.filter( - (template) => - template.title?.toLowerCase().includes(searchQuery) || - template.description?.toLowerCase().includes(searchQuery) || - template.category?.toLowerCase().includes(searchQuery) || - template.targets?.some((target) => - target.toLowerCase().includes(searchQuery), - ), + return otherTemplates.filter((template) => + matchesTemplateSearch(template, searchQuery), ); - }, [searchQuery, userTemplates]); + }, [otherTemplates, searchQuery]); const filteredSuggestedTemplates = useMemo(() => { if (!searchQuery) { @@ -667,6 +676,18 @@ function CreateOtherFormatButton({ })), emptyMessage: "No favorite templates yet", }, + { + key: "mine", + title: "My templates", + items: filteredOtherTemplates.map((template) => ({ + key: template.id, + title: template.title || "Untitled", + description: template.description, + tags: getTemplateTags(template), + onClick: () => handleUseTemplate(template.id), + })), + emptyMessage: "No other templates yet", + }, ]; } @@ -714,9 +735,25 @@ function CreateOtherFormatButton({ }, ] : []), + ...(filteredOtherTemplates.length > 0 + ? [ + { + key: "mine", + title: "My templates", + items: filteredOtherTemplates.map((template) => ({ + key: template.id, + title: template.title || "Untitled", + description: template.description, + tags: getTemplateTags(template), + onClick: () => handleUseTemplate(template.id), + })), + }, + ] + : []), ]; }, [ filteredFavoriteTemplates, + filteredOtherTemplates, filteredSuggestedTemplates, handleCreateTemplate, handleSuggestedTemplateClick, @@ -1138,6 +1175,46 @@ function getTemplateTags(template: { category?: string; targets?: string[] }) { ]; } +function matchesTemplateSearch( + template: { + title?: string; + description?: string; + category?: string; + targets?: string[]; + }, + query: string, +) { + return ( + template.title?.toLowerCase().includes(query) || + template.description?.toLowerCase().includes(query) || + template.category?.toLowerCase().includes(query) || + template.targets?.some((target) => target.toLowerCase().includes(query)) + ); +} + +function sortFavoriteTemplates< + T extends { pinned?: boolean; pin_order?: number; title?: string }, +>(templates: T[]) { + return [...templates] + .filter((template) => template.pinned) + .sort((a, b) => { + const orderA = a.pin_order ?? Infinity; + const orderB = b.pin_order ?? Infinity; + if (orderA !== orderB) { + return orderA - orderB; + } + return (a.title || "").localeCompare(b.title || ""); + }); +} + +function sortOtherTemplates( + templates: T[], +) { + return [...templates] + .filter((template) => !template.pinned) + .sort((a, b) => (a.title || "").localeCompare(b.title || "")); +} + const TEMPLATE_SUGGESTION_STOP_WORDS = new Set([ "about", "after", diff --git a/apps/desktop/src/store/tinybase/store/main.ts b/apps/desktop/src/store/tinybase/store/main.ts index 4e35902bdd..af7d0c5b9f 100644 --- a/apps/desktop/src/store/tinybase/store/main.ts +++ b/apps/desktop/src/store/tinybase/store/main.ts @@ -138,6 +138,8 @@ export const StoreComponent = () => { ({ select }) => { select("title"); select("description"); + select("pinned"); + select("pin_order"); select("category"); select("targets"); select("sections"); @@ -196,6 +198,8 @@ export const StoreComponent = () => { ({ select, where, param }) => { select("title"); select("description"); + select("pinned"); + select("pin_order"); select("category"); select("targets"); select("sections"); diff --git a/apps/desktop/src/templates/components/details.tsx b/apps/desktop/src/templates/components/details.tsx index 3e3b487ab8..6d8d007742 100644 --- a/apps/desktop/src/templates/components/details.tsx +++ b/apps/desktop/src/templates/components/details.tsx @@ -24,12 +24,14 @@ export function TemplateDetailsColumn({ selectedMineId, selectedWebTemplate, handleDeleteTemplate, + handleDuplicateTemplate, handleCloneTemplate, }: { isWebMode: boolean; selectedMineId: string | null; selectedWebTemplate: WebTemplate | null; handleDeleteTemplate: (id: string) => void; + handleDuplicateTemplate: (id: string) => void; handleCloneTemplate: (template: { title: string; description: string; @@ -59,6 +61,7 @@ export function TemplateDetailsColumn({ key={selectedMineId} id={selectedMineId} handleDeleteTemplate={handleDeleteTemplate} + handleDuplicateTemplate={handleDuplicateTemplate} /> ); } diff --git a/apps/desktop/src/templates/components/template-form.tsx b/apps/desktop/src/templates/components/template-form.tsx index 355fadb026..4c565ec990 100644 --- a/apps/desktop/src/templates/components/template-form.tsx +++ b/apps/desktop/src/templates/components/template-form.tsx @@ -1,10 +1,21 @@ import { useForm } from "@tanstack/react-form"; +import { HeartIcon, MoreHorizontalIcon } from "lucide-react"; +import { useState } from "react"; import type { Template, TemplateSection, TemplateStorage } from "@hypr/store"; +import { Button } from "@hypr/ui/components/ui/button"; +import { + AppFloatingPanel, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@hypr/ui/components/ui/dropdown-menu"; import { Input } from "@hypr/ui/components/ui/input"; import { Textarea } from "@hypr/ui/components/ui/textarea"; import { cn } from "@hypr/utils"; +import { useToggleTemplateFavorite } from "../shared"; import { RelatedSessions } from "./related-sessions"; import { SectionsList } from "./sections-editor"; @@ -50,6 +61,9 @@ function normalizeTemplatePayload(template: unknown): Template { title: typeof record.title === "string" ? record.title : "", description: typeof record.description === "string" ? record.description : "", + pinned: Boolean(record.pinned), + pin_order: + typeof record.pin_order === "number" ? record.pin_order : undefined, category: typeof record.category === "string" ? record.category : undefined, sections, targets, @@ -59,12 +73,16 @@ function normalizeTemplatePayload(template: unknown): Template { export function TemplateForm({ id, handleDeleteTemplate, + handleDuplicateTemplate, }: { id: string; handleDeleteTemplate: (id: string) => void; + handleDuplicateTemplate: (id: string) => void; }) { const row = main.UI.useRow("templates", id, main.STORE_ID); const value = row ? normalizeTemplatePayload(row) : undefined; + const toggleTemplateFavorite = useToggleTemplateFavorite(); + const [actionsOpen, setActionsOpen] = useState(false); const selectedTemplateId = settings.UI.useValue( "selected_template_id", @@ -126,7 +144,7 @@ export function TemplateForm({ return (
-
+
{(field) => ( )} - +
+ + + + + + + + + handleDuplicateTemplate(id)} + className="cursor-pointer" + > + Duplicate + + handleDeleteTemplate(id)} + className="cursor-pointer text-red-600 focus:text-red-600" + > + Delete + + + + +
{(field) => ( diff --git a/apps/desktop/src/templates/index.tsx b/apps/desktop/src/templates/index.tsx index dd0bc354a9..834d36eb6b 100644 --- a/apps/desktop/src/templates/index.tsx +++ b/apps/desktop/src/templates/index.tsx @@ -122,6 +122,22 @@ function TemplateView({ tab }: { tab: Extract }) { [createTemplate, setSelectedMineId], ); + const handleDuplicateTemplate = useCallback( + (id: string) => { + const template = userTemplates.find((item) => item.id === id); + if (!template) return; + + handleCloneTemplate({ + title: template.title, + description: template.description, + category: template.category, + targets: template.targets, + sections: template.sections, + }); + }, + [handleCloneTemplate, userTemplates], + ); + return (
}) { selectedMineId={selectedMineId} selectedWebTemplate={selectedWebTemplate} handleDeleteTemplate={handleDeleteTemplate} + handleDuplicateTemplate={handleDuplicateTemplate} handleCloneTemplate={handleCloneTemplate} />
diff --git a/apps/desktop/src/templates/shared.ts b/apps/desktop/src/templates/shared.ts index 26840c971a..c9b134c584 100644 --- a/apps/desktop/src/templates/shared.ts +++ b/apps/desktop/src/templates/shared.ts @@ -135,6 +135,8 @@ export function useCreateTemplate() { user_id: p.user_id, title: p.title, description: p.description, + pinned: false, + pin_order: undefined, category: p.category, targets: p.targets ? JSON.stringify(p.targets) : undefined, sections: JSON.stringify(p.sections), @@ -167,7 +169,46 @@ export function useCreateTemplate() { ); } -function normalizeTemplatePayload(template: unknown): Template { +export function useToggleTemplateFavorite() { + const store = main.UI.useStore(main.STORE_ID); + + return useCallback( + (templateId: string) => { + if (!store) return; + + const isPinned = Boolean( + store.getCell("templates", templateId, "pinned"), + ); + if (isPinned) { + store.setPartialRow("templates", templateId, { + pinned: false, + pin_order: 0, + }); + return; + } + + const allTemplates = store.getTable("templates"); + const maxPinOrder = Object.entries(allTemplates).reduce( + (max, [id, template]) => { + if (id === templateId) return max; + + const order = + typeof template.pin_order === "number" ? template.pin_order : 0; + return Math.max(max, order); + }, + 0, + ); + + store.setPartialRow("templates", templateId, { + pinned: true, + pin_order: maxPinOrder + 1, + }); + }, + [store], + ); +} + +export function normalizeTemplatePayload(template: unknown): Template { const record = ( template && typeof template === "object" ? template : {} ) as Record; @@ -188,6 +229,9 @@ function normalizeTemplatePayload(template: unknown): Template { title: typeof record.title === "string" ? record.title : "", description: typeof record.description === "string" ? record.description : "", + pinned: Boolean(record.pinned), + pin_order: + typeof record.pin_order === "number" ? record.pin_order : undefined, category: typeof record.category === "string" ? record.category : undefined, targets: typeof record.targets === "string" diff --git a/apps/desktop/src/templates/sidebar-content.tsx b/apps/desktop/src/templates/sidebar-content.tsx index c55865c6a8..9a6d52f9b7 100644 --- a/apps/desktop/src/templates/sidebar-content.tsx +++ b/apps/desktop/src/templates/sidebar-content.tsx @@ -14,6 +14,7 @@ import { cn } from "@hypr/utils"; import { resolveTemplateTabSelection, useCreateTemplate, + useToggleTemplateFavorite, useUserTemplates, type UserTemplate, type WebTemplate, @@ -36,6 +37,7 @@ export function TemplatesSidebarContent({ const [sortOption, setSortOption] = useState("alphabetical"); const userTemplates = useUserTemplates(); const createTemplate = useCreateTemplate(); + const toggleTemplateFavorite = useToggleTemplateFavorite(); const { data: webTemplates = [], isLoading: isWebLoading } = useWebResources("templates"); const deleteTemplateFromStore = main.UI.useDelRowCallback( @@ -120,19 +122,37 @@ export function TemplatesSidebarContent({ [deleteTemplateFromStore, effectiveSelectedMineId, setSelectedMineId], ); + const handleToggleFavorite = useCallback( + (id: string) => { + toggleTemplateFavorite(id); + }, + [toggleTemplateFavorite], + ); + const sortedUserTemplates = useMemo(() => { - const sorted = [...userTemplates]; + const favorites = userTemplates + .filter((template) => template.pinned) + .sort((a, b) => { + const orderA = a.pin_order ?? Infinity; + const orderB = b.pin_order ?? Infinity; + if (orderA !== orderB) { + return orderA - orderB; + } + return (a.title || "").localeCompare(b.title || ""); + }); + + const others = userTemplates.filter((template) => !template.pinned); switch (sortOption) { case "alphabetical": - return sorted.sort((a, b) => - (a.title || "").localeCompare(b.title || ""), - ); + others.sort((a, b) => (a.title || "").localeCompare(b.title || "")); + break; case "reverse-alphabetical": default: - return sorted.sort((a, b) => - (b.title || "").localeCompare(a.title || ""), - ); + others.sort((a, b) => (b.title || "").localeCompare(a.title || "")); + break; } + + return [...favorites, ...others]; }, [userTemplates, sortOption]); const filteredMine = useMemo(() => { @@ -279,6 +299,7 @@ export function TemplatesSidebarContent({ !isWebMode && effectiveSelectedMineId === template.id } onSelect={setSelectedMineId} + onToggleFavorite={handleToggleFavorite} onDuplicate={handleDuplicateTemplate} onDelete={handleDeleteTemplate} /> @@ -348,17 +369,25 @@ function TemplateListItem({ template, selected, onSelect, + onToggleFavorite, onDuplicate, onDelete, }: { template: UserTemplate; selected: boolean; onSelect: (id: string) => void; + onToggleFavorite: (id: string) => void; onDuplicate: (template: UserTemplate) => void; onDelete: (id: string) => void; }) { const contextMenu = useMemo( () => [ + { + id: `favorite-template-${template.id}`, + text: template.pinned ? "Unfavorite" : "Favorite", + action: () => onToggleFavorite(template.id), + }, + { separator: true as const }, { id: `duplicate-template-${template.id}`, text: "Duplicate", @@ -370,7 +399,7 @@ function TemplateListItem({ action: () => onDelete(template.id), }, ], - [onDelete, onDuplicate, template], + [onDelete, onDuplicate, onToggleFavorite, template], ); const showContextMenu = useNativeContextMenu(contextMenu); diff --git a/packages/store/src/tinybase.ts b/packages/store/src/tinybase.ts index 96b2eca580..e3607d4663 100644 --- a/packages/store/src/tinybase.ts +++ b/packages/store/src/tinybase.ts @@ -118,6 +118,8 @@ export const tableSchemaForTinybase = { user_id: { type: "string" }, title: { type: "string" }, description: { type: "string" }, + pinned: { type: "boolean" }, + pin_order: { type: "number" }, category: { type: "string" }, targets: { type: "string" }, sections: { type: "string" }, diff --git a/packages/store/src/zod.ts b/packages/store/src/zod.ts index 1e3fe0d95a..03c5bce9d8 100644 --- a/packages/store/src/zod.ts +++ b/packages/store/src/zod.ts @@ -171,6 +171,8 @@ export const templateSchema = z.object({ user_id: z.string(), title: z.string(), description: z.string(), + pinned: z.preprocess((val) => val ?? false, z.boolean()), + pin_order: z.preprocess((val) => val ?? undefined, z.number().optional()), category: z.preprocess((val) => val ?? undefined, z.string().optional()), targets: z.preprocess( (val) => val ?? undefined, From be8c17c55994a901e59a7f92e54e0d6ec32c0f3e Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Sat, 28 Mar 2026 14:25:42 -0700 Subject: [PATCH 04/11] fix: highlight open template actions menu Show the more-actions trigger as selected while its dropdown is open. --- apps/desktop/src/templates/components/template-form.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/templates/components/template-form.tsx b/apps/desktop/src/templates/components/template-form.tsx index 4c565ec990..04ac4fdaed 100644 --- a/apps/desktop/src/templates/components/template-form.tsx +++ b/apps/desktop/src/templates/components/template-form.tsx @@ -194,7 +194,11 @@ export function TemplateForm({ type="button" size="icon" variant="ghost" - className="text-neutral-500 hover:text-neutral-800" + className={cn([ + "text-neutral-500 hover:text-neutral-800", + actionsOpen && + "bg-neutral-100 text-neutral-800 hover:bg-neutral-100", + ])} aria-label="Template actions" > From a8ec3bc6a46544468db6386e6ca9d2c26cc2da51 Mon Sep 17 00:00:00 2001 From: ComputelessComputer Date: Sat, 28 Mar 2026 15:07:42 -0700 Subject: [PATCH 05/11] fix: align editable template header layout Match the user template header structure to the Char template layout with a larger title block and inline description. --- .../templates/components/template-form.tsx | 78 ++++++++++--------- 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/templates/components/template-form.tsx b/apps/desktop/src/templates/components/template-form.tsx index 04ac4fdaed..c12165033e 100644 --- a/apps/desktop/src/templates/components/template-form.tsx +++ b/apps/desktop/src/templates/components/template-form.tsx @@ -144,18 +144,31 @@ export function TemplateForm({ return (
-
- - {(field) => ( - field.handleChange(e.target.value)} - placeholder="Enter template title" - className="h-8 flex-1 border-0 px-0 text-lg font-semibold shadow-none focus-visible:ring-0" - /> - )} - -
+
+
+ + {(field) => ( + field.handleChange(e.target.value)} + placeholder="Enter template title" + className="h-auto border-0 px-0 py-0 text-lg font-semibold shadow-none focus-visible:ring-0" + /> + )} + + + {(field) => ( +