From 9f1e91a96faa92f93b785f1ccc1c764b803447bb Mon Sep 17 00:00:00 2001 From: kaltepeter Date: Thu, 16 Apr 2026 09:56:30 -0700 Subject: [PATCH 1/4] feat: layout and about page --- CLAUDE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 0685be3e..03c338a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,6 +89,16 @@ const TagChip = styled((props: React.ComponentProps) => ( ))({ border: "none", borderRadius: 0 }); ``` +## GitHub Comments & Reviews + +When posting any comment, review, or reply on GitHub (PRs, issues, etc.), always append the following signature on a new line at the end: + +``` +— 🤖 Claude +``` + +This applies to PR review comments, inline code comments, issue comments, and review summaries — anything posted via `gh` CLI or GitHub API. + ## Dependency Notes - **`tss-react` is removed.** It does not support MUI v9. Do not re-add it. From 9a3443d5a2a3e82495f1ba2cee9496effd532b3c Mon Sep 17 00:00:00 2001 From: kaltepeter Date: Thu, 16 Apr 2026 17:17:20 -0700 Subject: [PATCH 2/4] feat: adding search --- package-lock.json | 37 ++++ package.json | 3 + src/components/header.tsx | 62 +----- src/components/note-list-entry.tsx | 85 ++++++++ src/components/note-list.tsx | 86 +------- src/components/search-input.tsx | 303 +++++++++++++++++++++++++++++ src/hooks/use-note-search.ts | 36 ++++ src/pages/search.tsx | 55 ++++++ 8 files changed, 528 insertions(+), 139 deletions(-) create mode 100644 src/components/note-list-entry.tsx create mode 100644 src/components/search-input.tsx create mode 100644 src/hooks/use-note-search.ts create mode 100644 src/pages/search.tsx diff --git a/package-lock.json b/package-lock.json index 9e9d5ac6..fe294c79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,13 +29,16 @@ "gatsby-source-filesystem": "^5.11.0", "gatsby-transformer-remark": "^6.11.0", "gatsby-transformer-sharp": "^5.11.0", + "js-search": "^2.0.1", "postcss": "^8.4.26", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "@emotion/cache": "^11.14.0", + "@eslint/js": "^10.0.1", "@graphql-eslint/eslint-plugin": "^4.0.0", + "@types/js-search": "^1.4.4", "@types/node": "^24.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -2335,6 +2338,27 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, "node_modules/@eslint/object-schema": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", @@ -6158,6 +6182,13 @@ "@types/node": "*" } }, + "node_modules/@types/js-search": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@types/js-search/-/js-search-1.4.4.tgz", + "integrity": "sha512-NYIBuSRTi2h6nLne0Ygx78BZaiT/q0lLU7YSkjOrDJWpSx6BioIZA/i2GZ+WmMUzEQs2VNIWcXRRAqisrG3ZNA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -15958,6 +15989,12 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/js-search": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/js-search/-/js-search-2.0.1.tgz", + "integrity": "sha512-8k12LiC3fPt7gLRJTc1azE1BFvlxIw+BG3J9YzjuYf4wSE65uqYSYP4VhweApcTfV51Fzq/ogBulQew5937A9A==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index f82eacc7..8c98c7c3 100644 --- a/package.json +++ b/package.json @@ -29,13 +29,16 @@ "gatsby-source-filesystem": "^5.11.0", "gatsby-transformer-remark": "^6.11.0", "gatsby-transformer-sharp": "^5.11.0", + "js-search": "^2.0.1", "postcss": "^8.4.26", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "@emotion/cache": "^11.14.0", + "@eslint/js": "^10.0.1", "@graphql-eslint/eslint-plugin": "^4.0.0", + "@types/js-search": "^1.4.4", "@types/node": "^24.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/src/components/header.tsx b/src/components/header.tsx index 61601944..76053556 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -1,23 +1,21 @@ import { AppBar, - alpha, Box, IconButton, - InputBase, Toolbar, Typography, useScrollTrigger, } from "@mui/material"; import { styled } from "@mui/material/styles"; import MenuIcon from "@mui/icons-material/Menu"; -import SearchIcon from "@mui/icons-material/Search"; import { Link } from "gatsby"; -import React, { ReactElement, useState } from "react"; +import React, { ReactElement, useCallback, useState } from "react"; import NotesLogo from "../images/logo.svg"; import NotesIcon from "../images/notes-icon.svg"; import ElevationScroll from "./elevation-scroll"; import HideOnScroll from "./hide-on-scroll"; import { Navigation } from "./navigation"; +import SearchInput from "./search-input"; const StyledAppBar = styled(AppBar, { shouldForwardProp: (prop) => prop !== "drawerWidth" && prop !== "scrolled", @@ -65,46 +63,6 @@ const NavLogo = styled("img")({ height: "20px", }); -const Search = styled(Box)(({ theme }) => ({ - position: "relative", - borderRadius: theme.shape.borderRadius, - backgroundColor: alpha(theme.palette.common.white, 0.15), - "&:hover": { - backgroundColor: alpha(theme.palette.common.white, 0.25), - }, - marginLeft: 0, - width: "100%", - [theme.breakpoints.up("sm")]: { - marginLeft: theme.spacing(1), - width: "auto", - }, -})); - -const SearchIconWrapper = styled(Box)(({ theme }) => ({ - padding: theme.spacing(0, 2), - height: "100%", - position: "absolute", - pointerEvents: "none", - display: "flex", - alignItems: "center", - justifyContent: "center", -})); - -const StyledInputBase = styled(InputBase)(({ theme }) => ({ - color: "inherit", - "& .MuiInputBase-input": { - padding: theme.spacing(1, 1, 1, 0), - paddingLeft: `calc(1em + ${theme.spacing(4)})`, - transition: theme.transitions.create("width"), - width: "100%", - [theme.breakpoints.up("sm")]: { - width: "12ch", - "&:focus": { - width: "20ch", - }, - }, - }, -})); type HeaderProps = { siteTitle?: string; @@ -117,9 +75,9 @@ const Header = ({ siteTitle = "", window, drawerWidth }: HeaderProps): ReactElem const trigger = useScrollTrigger({ target: window ? window() : undefined }); const [drawerOpen, setDrawerOpen] = useState(false); - const handleDrawerToggle = () => { - setDrawerOpen(!drawerOpen); - }; + const handleDrawerToggle = useCallback(() => { + setDrawerOpen((open) => !open); + }, []); return ( <> @@ -150,15 +108,7 @@ const Header = ({ siteTitle = "", window, drawerWidth }: HeaderProps): ReactElem - - - - - - + diff --git a/src/components/note-list-entry.tsx b/src/components/note-list-entry.tsx new file mode 100644 index 00000000..047da3d3 --- /dev/null +++ b/src/components/note-list-entry.tsx @@ -0,0 +1,85 @@ +import { Chip, ListItem, ListItemText, Typography } from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { Link } from "gatsby"; +import React from "react"; +import { Note } from "../models/note"; + +export const NoteItemContainer = styled(ListItem)({ + "& .head": { + display: "flex", + justifyContent: "space-between", + alignItems: "baseline", + }, +}); + +const NoteItemLink = styled(Link)({ + color: "inherit", + textDecoration: "none", +}); + +const TagList = styled("ul")(({ theme }) => ({ + display: "flex", + justifyContent: "left", + flexWrap: "wrap", + listStyle: "none", + padding: theme.spacing(0.5), + margin: 0, +})); + +const TagChip = styled((props: React.ComponentProps) => ( + +))({ + border: "none", + borderRadius: 0, +}); + +type NoteListEntryProps = { + note: Note; + titleComponent?: "h2" | "h3"; +}; + +const NoteListEntry: React.FC = ({ + note, + titleComponent = "h3", +}) => ( + + + + + {note.frontmatter.title} + + + {note.frontmatter.date} + + } + secondary={ + <> + + {note.frontmatter.tags?.map((tag, index) => ( + + + + ))} + + + {note.excerpt} + + + } + /> + +); + +export default NoteListEntry; diff --git a/src/components/note-list.tsx b/src/components/note-list.tsx index f712d713..54bbd3f3 100644 --- a/src/components/note-list.tsx +++ b/src/components/note-list.tsx @@ -1,39 +1,8 @@ -import { - Chip, - List, - ListItem, - ListItemText, - Typography, -} from "@mui/material"; -import { styled } from "@mui/material/styles"; -import { Link } from "gatsby"; +import { List } from "@mui/material"; import React from "react"; import { useNoteExcerptList } from "../hooks/use-note-excerpt-list"; import { Note } from "../models/note"; - -const NoteListItem = styled(ListItem)({ - "& .head": { - display: "flex", - justifyContent: "space-between", - alignItems: "baseline", - }, -}); - -const TagList = styled("ul")(({ theme }) => ({ - display: "flex", - justifyContent: "left", - flexWrap: "wrap", - listStyle: "none", - padding: theme.spacing(0.5), - margin: 0, -})); - -const TagChip = styled((props: React.ComponentProps) => ( - -))({ - border: "none", - borderRadius: 0, -}); +import NoteListEntry from "./note-list-entry"; const NoteList: React.FC = () => { const { notes } = useNoteExcerptList(); @@ -44,56 +13,7 @@ const NoteList: React.FC = () => { .filter((note) => (note.node.frontmatter?.title?.length ?? 0) > 0) .map(({ node }) => { const note = node as unknown as Note; - return ( - - - - - {note.frontmatter.title} - - - - {note.frontmatter.date} - - - } - secondary={ - <> - - {note.frontmatter.tags?.map((tag, index) => ( - - - - ))} - - - {note.excerpt} - - - } - /> - - ); + return ; })} ); diff --git a/src/components/search-input.tsx b/src/components/search-input.tsx new file mode 100644 index 00000000..b630bc3c --- /dev/null +++ b/src/components/search-input.tsx @@ -0,0 +1,303 @@ +import SearchIcon from "@mui/icons-material/Search"; +import { + alpha, + Box, + ClickAwayListener, + Divider, + InputBase, + List, + ListItemButton, + ListItemText, + Paper, + Popper, + Typography, +} from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { Link, navigate } from "gatsby"; +import React, { useRef, useState } from "react"; +import { useNoteSearch } from "../hooks/use-note-search"; +import { Note } from "../models/note"; + +const PREVIEW_LIMIT = 5; +const MIN_QUERY_LENGTH = 3; +const SNIPPET_WINDOW = 70; + +function relativeDate(dateStr: string | undefined): string { + if (!dateStr) return ""; + const date = new Date(dateStr); + const diffDays = Math.floor( + (Date.now() - date.getTime()) / 86400000, + ); + if (diffDays < 1) return "today"; + if (diffDays < 7) return `${diffDays}d ago`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`; + if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`; + return `${Math.floor(diffDays / 365)}y ago`; +} + +type Snippet = { before: string; match: string; after: string; prefix: boolean; suffix: boolean }; + +function matchSnippet(excerpt: string, query: string): Snippet { + const lower = excerpt.toLowerCase(); + const term = query.toLowerCase().trim().split(/\s+/)[0]; + const idx = lower.indexOf(term); + if (idx === -1) { + const clipped = excerpt.slice(0, SNIPPET_WINDOW * 2); + return { before: clipped, match: "", after: "", prefix: false, suffix: excerpt.length > SNIPPET_WINDOW * 2 }; + } + const start = Math.max(0, idx - SNIPPET_WINDOW); + const end = Math.min(excerpt.length, idx + term.length + SNIPPET_WINDOW); + return { + before: excerpt.slice(start, idx), + match: excerpt.slice(idx, idx + term.length), + after: excerpt.slice(idx + term.length, end), + prefix: start > 0, + suffix: end < excerpt.length, + }; +} + +export const SearchContainer = styled(Box, { + shouldForwardProp: (prop) => prop !== "expanded", +})<{ expanded: boolean }>(({ theme, expanded }) => ({ + position: "relative", + borderRadius: theme.shape.borderRadius, + backgroundColor: alpha(theme.palette.common.white, 0.15), + "&:hover": { + backgroundColor: alpha(theme.palette.common.white, 0.25), + }, + marginLeft: 0, + width: "100%", + transition: theme.transitions.create("width", { + duration: theme.transitions.duration.standard, + }), + [theme.breakpoints.up("sm")]: { + marginLeft: theme.spacing(1), + width: expanded ? "min(55ch, 70vw)" : "auto", + }, +})); + +export const SearchIconWrapper = styled(Box)(({ theme }) => ({ + padding: theme.spacing(0, 2), + height: "100%", + position: "absolute", + pointerEvents: "none", + display: "flex", + alignItems: "center", + justifyContent: "center", +})); + +export const StyledInputBase = styled(InputBase, { + shouldForwardProp: (prop) => prop !== "expanded", +})<{ expanded: boolean }>(({ theme, expanded }) => ({ + color: "inherit", + "& .MuiInputBase-input": { + padding: theme.spacing(1, 1, 1, 0), + paddingLeft: `calc(1em + ${theme.spacing(4)})`, + transition: theme.transitions.create("width", { + duration: theme.transitions.duration.standard, + }), + width: "100%", + [theme.breakpoints.up("sm")]: { + width: expanded ? "100%" : "12ch", + }, + }, +})); + +const ResultsPopper = styled(Popper)(({ theme }) => ({ + zIndex: theme.zIndex.modal, + minWidth: "320px", + maxWidth: "520px", +})); + +const ResultsPaper = styled(Paper)(({ theme }) => ({ + marginTop: theme.spacing(0.5), + overflow: "hidden", +})); + +const AllResultsLink = styled(Link)(({ theme }) => ({ + display: "block", + padding: theme.spacing(1, 2), + color: theme.palette.secondary.main, + textDecoration: "none", + "&:hover": { + textDecoration: "underline", + }, +})); + +const PreviewMeta = styled("div")(({ theme }) => ({ + display: "flex", + justifyContent: "space-between", + alignItems: "baseline", + gap: theme.spacing(1), + marginTop: theme.spacing(0.25), +})); + +const PreviewTags = styled("span")(({ theme }) => ({ + display: "flex", + flexWrap: "wrap", + gap: theme.spacing(0.5), + minWidth: 0, +})); + +const PreviewTag = styled("span")(({ theme }) => ({ + fontSize: "0.65rem", + color: theme.palette.secondary.main, + whiteSpace: "nowrap", +})); + +const PreviewDate = styled("span")(({ theme }) => ({ + fontSize: "0.65rem", + color: theme.palette.text.secondary, + whiteSpace: "nowrap", + flexShrink: 0, +})); + +const PreviewSnippet = styled("p")(({ theme }) => ({ + fontSize: "0.75rem", + color: theme.palette.text.secondary, + margin: theme.spacing(0.25, 0, 0), + display: "-webkit-box", + WebkitLineClamp: 2, + WebkitBoxOrient: "vertical", + overflow: "hidden", +})); + +const MatchHighlight = styled("mark")(({ theme }) => ({ + backgroundColor: alpha(theme.palette.secondary.main, 0.2), + color: "inherit", + fontWeight: 700, + borderRadius: "2px", + padding: "0 1px", +})); + +type NotePreviewProps = { note: Note; query: string; onClick: () => void }; + +const NotePreview: React.FC = ({ note, query, onClick }) => { + const snippet = note.excerpt ? matchSnippet(note.excerpt, query) : null; + const date = relativeDate(note.frontmatter.date); + const tags = note.frontmatter.tags ?? []; + + return ( + + + + + {tags.map((tag) => ( + #{tag} + ))} + + {date && {date}} + + {snippet && ( + + {snippet.prefix && "…"} + {snippet.before} + {snippet.match && {snippet.match}} + {snippet.after} + {snippet.suffix && "…"} + + )} + + } + /> + + ); +}; + +const SearchInput: React.FC = () => { + const [query, setQuery] = useState(""); + const [focused, setFocused] = useState(false); + const anchorRef = useRef(null); + + const expanded = focused || query.length > 0; + const searchQuery = query.length >= MIN_QUERY_LENGTH ? query : ""; + const results = useNoteSearch(searchQuery); + const previewResults = results.slice(0, PREVIEW_LIMIT); + const open = query.length >= MIN_QUERY_LENGTH; + + const handleClear = () => { + setQuery(""); + setFocused(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + handleClear(); + } else if (e.key === "Enter" && query.trim().length >= MIN_QUERY_LENGTH) { + navigate(`/search?q=${encodeURIComponent(query.trim())}`); + handleClear(); + } + }; + + return ( + + + + + + + setQuery(e.target.value)} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + onKeyDown={handleKeyDown} + /> + + + + {previewResults.length > 0 ? ( + <> + + {previewResults.map((note) => ( + + ))} + + + + + See all {results.length} result + {results.length !== 1 ? "s" : ""} for “{query}” + + + + ) : ( + + No results for “{query}” + + )} + + + + + ); +}; + +export default SearchInput; diff --git a/src/hooks/use-note-search.ts b/src/hooks/use-note-search.ts new file mode 100644 index 00000000..9b6da964 --- /dev/null +++ b/src/hooks/use-note-search.ts @@ -0,0 +1,36 @@ +import { useMemo } from "react"; +import * as JsSearch from "js-search"; +import { useNoteExcerptList } from "./use-note-excerpt-list"; +import { Note } from "../models/note"; + +export const useNoteSearch = (query: string): Note[] => { + const { notes } = useNoteExcerptList(); + + const documents = useMemo( + () => + notes + .map(({ node }) => node as unknown as Note) + .filter( + (note) => + typeof note.frontmatter?.title === "string" && + note.frontmatter.title.trim() !== "" && + typeof note.fields?.slug === "string" && + note.fields.slug.trim() !== "", + ), + [notes], + ); + + const index = useMemo(() => { + const search = new JsSearch.Search("id"); + search.addIndex(["frontmatter", "title"]); + search.addIndex(["frontmatter", "tags"]); + search.addIndex("excerpt"); + search.addDocuments(documents); + return search; + }, [documents]); + + return useMemo(() => { + if (!query.trim()) return []; + return index.search(query) as Note[]; + }, [query, index]); +}; diff --git a/src/pages/search.tsx b/src/pages/search.tsx new file mode 100644 index 00000000..e137a896 --- /dev/null +++ b/src/pages/search.tsx @@ -0,0 +1,55 @@ +import { Box, List, Typography } from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { HeadProps, PageProps } from "gatsby"; +import React, { ReactElement } from "react"; +import Layout from "../components/layout"; +import NoteListEntry from "../components/note-list-entry"; +import SEO from "../components/seo"; +import { useNoteSearch } from "../hooks/use-note-search"; + +const ResultsHeader = styled(Box)(({ theme }) => ({ + marginBottom: theme.spacing(2), +})); + +const MIN_QUERY_LENGTH = 3; + +const SearchPage: React.FC = ({ location }) => { + const params = new URLSearchParams(location.search); + const query = params.get("q") ?? ""; + const searchQuery = query.length >= MIN_QUERY_LENGTH ? query : ""; + const results = useNoteSearch(searchQuery); + + return ( + + + + {searchQuery + ? `${results.length} result${results.length !== 1 ? "s" : ""} for "${query}"` + : "Enter a search term"} + + + {query && !searchQuery && ( + + Enter at least {MIN_QUERY_LENGTH} characters to search. + + )} + {searchQuery && results.length === 0 && ( + + No results found for “{query}”. + + )} + {results.length > 0 && ( + + {results.map((note) => ( + + ))} + + )} + + ); +}; + +export default SearchPage; +export const Head = ({ location }: HeadProps): ReactElement => ( + +); From 453885a7612cb4706537e49e81ec8c6eea35027f Mon Sep 17 00:00:00 2001 From: kaltepeter Date: Tue, 21 Apr 2026 15:07:46 -0700 Subject: [PATCH 3/4] fix: datetime, empty search --- src/components/note-list-entry.tsx | 9 ++------- src/components/search-input.tsx | 20 ++++++++++---------- src/hooks/use-note-excerpt-list.ts | 1 + src/models/note.ts | 1 + src/pages/search.tsx | 2 +- 5 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/components/note-list-entry.tsx b/src/components/note-list-entry.tsx index 047da3d3..83af3057 100644 --- a/src/components/note-list-entry.tsx +++ b/src/components/note-list-entry.tsx @@ -12,11 +12,6 @@ export const NoteItemContainer = styled(ListItem)({ }, }); -const NoteItemLink = styled(Link)({ - color: "inherit", - textDecoration: "none", -}); - const TagList = styled("ul")(({ theme }) => ({ display: "flex", justifyContent: "left", @@ -62,8 +57,8 @@ const NoteListEntry: React.FC = ({ secondary={ <> - {note.frontmatter.tags?.map((tag, index) => ( - + {note.frontmatter.tags?.map((tag) => ( + void }; const NotePreview: React.FC = ({ note, query, onClick }) => { const snippet = note.excerpt ? matchSnippet(note.excerpt, query) : null; - const date = relativeDate(note.frontmatter.date); + const date = relativeDate(note.frontmatter.rawDate); const tags = note.frontmatter.tags ?? []; return ( @@ -221,11 +220,12 @@ const SearchInput: React.FC = () => { const [focused, setFocused] = useState(false); const anchorRef = useRef(null); - const expanded = focused || query.length > 0; - const searchQuery = query.length >= MIN_QUERY_LENGTH ? query : ""; + const trimmedQuery = query.trim(); + const expanded = focused || trimmedQuery.length > 0; + const searchQuery = trimmedQuery.length >= MIN_QUERY_LENGTH ? trimmedQuery : ""; const results = useNoteSearch(searchQuery); const previewResults = results.slice(0, PREVIEW_LIMIT); - const open = query.length >= MIN_QUERY_LENGTH; + const open = trimmedQuery.length >= MIN_QUERY_LENGTH; const handleClear = () => { setQuery(""); diff --git a/src/hooks/use-note-excerpt-list.ts b/src/hooks/use-note-excerpt-list.ts index 5a30ff86..9d238ca0 100644 --- a/src/hooks/use-note-excerpt-list.ts +++ b/src/hooks/use-note-excerpt-list.ts @@ -19,6 +19,7 @@ export const useNoteExcerptList = (): { frontmatter { title date(formatString: "MMMM DD, YYYY") + rawDate: date tags } } diff --git a/src/models/note.ts b/src/models/note.ts index 1b53d7b7..de45046b 100644 --- a/src/models/note.ts +++ b/src/models/note.ts @@ -7,6 +7,7 @@ export type Note = { frontmatter: { title?: string; date?: string; + rawDate?: string; tags?: string[]; }; }; diff --git a/src/pages/search.tsx b/src/pages/search.tsx index e137a896..a2bacdc0 100644 --- a/src/pages/search.tsx +++ b/src/pages/search.tsx @@ -15,7 +15,7 @@ const MIN_QUERY_LENGTH = 3; const SearchPage: React.FC = ({ location }) => { const params = new URLSearchParams(location.search); - const query = params.get("q") ?? ""; + const query = (params.get("q") ?? "").trim(); const searchQuery = query.length >= MIN_QUERY_LENGTH ? query : ""; const results = useNoteSearch(searchQuery); From 2d5c6a5a810c6ddfe8960c4d4643f20a6365b3a8 Mon Sep 17 00:00:00 2001 From: kaltepeter Date: Tue, 21 Apr 2026 15:35:28 -0700 Subject: [PATCH 4/4] fix: package engine/audit, bugs --- package-lock.json | 8 +++---- package.json | 2 +- src/components/note-list-entry.tsx | 2 +- src/components/search-input.tsx | 11 ++++----- src/hooks/use-note-search.ts | 37 ++++++++++++++---------------- src/pages/search.tsx | 4 +--- 6 files changed, 29 insertions(+), 35 deletions(-) diff --git a/package-lock.json b/package-lock.json index fe294c79..8be282a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,7 @@ "typescript": "^6.0.0" }, "engines": { - "node": ">=18.16.1", + "node": ">=24.15.0", "npm": ">=9.5.1" } }, @@ -20728,9 +20728,9 @@ "license": "MIT" }, "node_modules/sanitize-html": { - "version": "2.17.2", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.2.tgz", - "integrity": "sha512-EnffJUl46VE9uvZ0XeWzObHLurClLlT12gsOk1cHyP2Ol1P0BnBnsXmShlBmWVJM+dKieQI68R0tsPY5m/B+Jg==", + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.3.tgz", + "integrity": "sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==", "license": "MIT", "dependencies": { "deepmerge": "^4.2.2", diff --git a/package.json b/package.json index 8c98c7c3..96defc92 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "version": "0.1.0", "author": "Kayla Altepeter", "engines": { - "node": ">=18.16.1", + "node": ">=24.15.0", "npm": ">=9.5.1" }, "dependencies": { diff --git a/src/components/note-list-entry.tsx b/src/components/note-list-entry.tsx index 83af3057..3e7e277c 100644 --- a/src/components/note-list-entry.tsx +++ b/src/components/note-list-entry.tsx @@ -4,7 +4,7 @@ import { Link } from "gatsby"; import React from "react"; import { Note } from "../models/note"; -export const NoteItemContainer = styled(ListItem)({ +const NoteItemContainer = styled(ListItem)({ "& .head": { display: "flex", justifyContent: "space-between", diff --git a/src/components/search-input.tsx b/src/components/search-input.tsx index 33307fa0..c10fe630 100644 --- a/src/components/search-input.tsx +++ b/src/components/search-input.tsx @@ -15,11 +15,10 @@ import { import { styled } from "@mui/material/styles"; import { Link, navigate } from "gatsby"; import React, { useRef, useState } from "react"; -import { useNoteSearch } from "../hooks/use-note-search"; +import { MIN_QUERY_LENGTH, useNoteSearch } from "../hooks/use-note-search"; import { Note } from "../models/note"; const PREVIEW_LIMIT = 5; -const MIN_QUERY_LENGTH = 3; const SNIPPET_WINDOW = 70; function relativeDate(isoDateStr: string | undefined): string { @@ -55,7 +54,7 @@ function matchSnippet(excerpt: string, query: string): Snippet { }; } -export const SearchContainer = styled(Box, { +const SearchContainer = styled(Box, { shouldForwardProp: (prop) => prop !== "expanded", })<{ expanded: boolean }>(({ theme, expanded }) => ({ position: "relative", @@ -75,7 +74,7 @@ export const SearchContainer = styled(Box, { }, })); -export const SearchIconWrapper = styled(Box)(({ theme }) => ({ +const SearchIconWrapper = styled(Box)(({ theme }) => ({ padding: theme.spacing(0, 2), height: "100%", position: "absolute", @@ -85,7 +84,7 @@ export const SearchIconWrapper = styled(Box)(({ theme }) => ({ justifyContent: "center", })); -export const StyledInputBase = styled(InputBase, { +const StyledInputBase = styled(InputBase, { shouldForwardProp: (prop) => prop !== "expanded", })<{ expanded: boolean }>(({ theme, expanded }) => ({ color: "inherit", @@ -279,7 +278,7 @@ const SearchInput: React.FC = () => { diff --git a/src/hooks/use-note-search.ts b/src/hooks/use-note-search.ts index 9b6da964..e0802652 100644 --- a/src/hooks/use-note-search.ts +++ b/src/hooks/use-note-search.ts @@ -3,34 +3,31 @@ import * as JsSearch from "js-search"; import { useNoteExcerptList } from "./use-note-excerpt-list"; import { Note } from "../models/note"; +export const MIN_QUERY_LENGTH = 3; + export const useNoteSearch = (query: string): Note[] => { const { notes } = useNoteExcerptList(); + const isSearchEnabled = query.trim() !== ""; + + return useMemo(() => { + if (!isSearchEnabled) return []; - const documents = useMemo( - () => - notes - .map(({ node }) => node as unknown as Note) - .filter( - (note) => - typeof note.frontmatter?.title === "string" && - note.frontmatter.title.trim() !== "" && - typeof note.fields?.slug === "string" && - note.fields.slug.trim() !== "", - ), - [notes], - ); + const documents = notes + .map(({ node }) => node as unknown as Note) + .filter( + (note) => + typeof note.frontmatter?.title === "string" && + note.frontmatter.title.trim() !== "" && + typeof note.fields?.slug === "string" && + note.fields.slug.trim() !== "", + ); - const index = useMemo(() => { const search = new JsSearch.Search("id"); search.addIndex(["frontmatter", "title"]); search.addIndex(["frontmatter", "tags"]); search.addIndex("excerpt"); search.addDocuments(documents); - return search; - }, [documents]); - return useMemo(() => { - if (!query.trim()) return []; - return index.search(query) as Note[]; - }, [query, index]); + return search.search(query) as Note[]; + }, [isSearchEnabled, notes, query]); }; diff --git a/src/pages/search.tsx b/src/pages/search.tsx index a2bacdc0..a1968dcb 100644 --- a/src/pages/search.tsx +++ b/src/pages/search.tsx @@ -5,14 +5,12 @@ import React, { ReactElement } from "react"; import Layout from "../components/layout"; import NoteListEntry from "../components/note-list-entry"; import SEO from "../components/seo"; -import { useNoteSearch } from "../hooks/use-note-search"; +import { MIN_QUERY_LENGTH, useNoteSearch } from "../hooks/use-note-search"; const ResultsHeader = styled(Box)(({ theme }) => ({ marginBottom: theme.spacing(2), })); -const MIN_QUERY_LENGTH = 3; - const SearchPage: React.FC = ({ location }) => { const params = new URLSearchParams(location.search); const query = (params.get("q") ?? "").trim();