diff --git a/apps/desktop/src/session/components/note-input/header.tsx b/apps/desktop/src/session/components/note-input/header.tsx index dd3171dba0..110be6252a 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 ?? [], }); @@ -578,21 +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), + return otherTemplates.filter((template) => + matchesTemplateSearch(template, searchQuery), ); - }, [searchQuery, userTemplates]); + }, [otherTemplates, searchQuery]); const filteredSuggestedTemplates = useMemo(() => { if (!searchQuery) { @@ -622,6 +643,7 @@ function CreateOtherFormatButton({ key: string; title: string; description?: string; + tags?: string[]; onClick: () => void; }>; }> @@ -635,6 +657,7 @@ function CreateOtherFormatButton({ key: template.slug || `suggested-${index}`, title: template.title || "Untitled", description: template.description, + tags: getTemplateTags(template), onClick: () => handleSuggestedTemplateClick(template), })), emptyMessage: isSuggestedTemplatesLoading @@ -648,10 +671,23 @@ function CreateOtherFormatButton({ key: template.id, title: template.title || "Untitled", description: template.description, + tags: getTemplateTags(template), onClick: () => handleUseTemplate(template.id), })), 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", + }, ]; } @@ -678,6 +714,7 @@ function CreateOtherFormatButton({ key: template.slug || `suggested-${index}`, title: template.title || "Untitled", description: template.description, + tags: getTemplateTags(template), onClick: () => handleSuggestedTemplateClick(template), })), }, @@ -692,6 +729,22 @@ function CreateOtherFormatButton({ key: template.id, title: template.title || "Untitled", description: template.description, + tags: getTemplateTags(template), + onClick: () => handleUseTemplate(template.id), + })), + }, + ] + : []), + ...(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), })), }, @@ -700,6 +753,7 @@ function CreateOtherFormatButton({ ]; }, [ filteredFavoriteTemplates, + filteredOtherTemplates, filteredSuggestedTemplates, handleCreateTemplate, handleSuggestedTemplateClick, @@ -825,6 +879,7 @@ function CreateOtherFormatButton({ }} title={item.title} description={item.description} + tags={item.tags} onClick={item.onClick} onKeyDown={(e) => handleResultKeyDown(e, itemIndex)} /> @@ -1111,6 +1166,55 @@ type WebTemplate = { sections: Array<{ title: string; description: string }>; }; +function getTemplateTags(template: { category?: string; targets?: string[] }) { + return [ + ...new Set([ + ...(template.category ? [template.category] : []), + ...(template.targets ?? []), + ]), + ]; +} + +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", @@ -1297,12 +1401,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 +1430,18 @@ function TemplateResultButton({ {description} ) : null} + {tags && tags.length > 0 ? ( + + {tags.map((tag, index) => ( + + {tag} + + ))} + + ) : null} ); } diff --git a/apps/desktop/src/shared/ui/resource-list/preview-header.tsx b/apps/desktop/src/shared/ui/resource-list/preview-header.tsx index d93f34b2d2..d5a0d747e7 100644 --- a/apps/desktop/src/shared/ui/resource-list/preview-header.tsx +++ b/apps/desktop/src/shared/ui/resource-list/preview-header.tsx @@ -23,38 +23,38 @@ export function ResourcePreviewHeader({ children?: ReactNode; }) { return ( -
-
-
-

- {title || "Untitled"} -

- {description && ( -

{description}

- )} +
+
+
+ {category ? ( + {category} + ) : null}
-
- {category && ( -
- ({category}) -
- )} - {targets && targets.length > 0 && ( -
- {targets.map((target, index) => ( - - {target} - - ))} -
- )} +
+

+ {title || "Untitled"} +

+ {description && ( +

{description}

+ )} + {targets && targets.length > 0 && ( +
+ {targets.map((target, index) => ( + + {target} + + ))} +
+ )} +
{children}
); diff --git a/apps/desktop/src/store/tinybase/store/main.ts b/apps/desktop/src/store/tinybase/store/main.ts index 106b0b0226..af7d0c5b9f 100644 --- a/apps/desktop/src/store/tinybase/store/main.ts +++ b/apps/desktop/src/store/tinybase/store/main.ts @@ -138,6 +138,10 @@ export const StoreComponent = () => { ({ select }) => { select("title"); select("description"); + select("pinned"); + select("pin_order"); + select("category"); + select("targets"); select("sections"); }, ) @@ -194,6 +198,10 @@ export const StoreComponent = () => { ({ select, where, param }) => { select("title"); select("description"); + select("pinned"); + select("pin_order"); + 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..6d8d007742 100644 --- a/apps/desktop/src/templates/components/details.tsx +++ b/apps/desktop/src/templates/components/details.tsx @@ -24,15 +24,19 @@ 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; + category?: string; + targets?: string[]; sections: TemplateSection[]; }) => void; }) { @@ -57,6 +61,7 @@ export function TemplateDetailsColumn({ key={selectedMineId} id={selectedMineId} handleDeleteTemplate={handleDeleteTemplate} + handleDuplicateTemplate={handleDuplicateTemplate} /> ); } @@ -69,6 +74,8 @@ function WebTemplatePreview({ onClone: (template: { title: string; description: string; + category?: string; + targets?: string[]; sections: TemplateSection[]; }) => void; }) { @@ -85,6 +92,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..cb08ec0d33 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,10 @@ 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, }; @@ -58,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", @@ -124,55 +143,120 @@ export function TemplateForm({ return (
-
-
+
+
+
+ {value.category ? ( + + {value.category} + + ) : null} +
+
+ + + + + + + + + handleDuplicateTemplate(id)} + className="cursor-pointer" + > + Duplicate + + handleDeleteTemplate(id)} + className="cursor-pointer text-red-600 focus:text-red-600" + > + Delete + + + + +
+
+
{(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" + className="h-auto border-0 px-0 py-0 text-lg font-semibold shadow-none focus-visible:ring-0 md:text-lg" /> )} - + + {(field) => ( +