-
Notifications
You must be signed in to change notification settings - Fork 0
[codex] Make notes category navigation client-side #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,16 @@ | ||
| 'use client' | ||
|
|
||
| import { useCallback, useEffect, useMemo, useState } from 'react' | ||
| import type { MouseEvent } from 'react' | ||
| import Link from 'next/link' | ||
| import { | ||
| allNotesCategory, | ||
| getNoteCategoryById, | ||
| getNotesForCategory, | ||
| noteFilterCategories, | ||
| type NoteCategory, | ||
| type PublicNote, | ||
| } from '@/lib/notes' | ||
| } from '@/lib/note-categories' | ||
| import type { PublicNote } from '@/lib/notes' | ||
|
|
||
| interface NotesDirectoryViewProps { | ||
| activeCategory: NoteCategory | ||
|
|
@@ -28,15 +33,79 @@ const getCategoryHref = (category: NoteCategory) => { | |
| return `/notes/${category.id}/` | ||
| } | ||
|
|
||
| const getCategoryFromPathname = (pathname: string) => { | ||
| const normalizedPathname = pathname.replace(/\/+$/, '') | ||
|
|
||
| if (normalizedPathname === '/notes') { | ||
| return allNotesCategory | ||
| } | ||
|
|
||
| const categoryMatch = normalizedPathname.match(/^\/notes\/([^/]+)$/) | ||
|
|
||
| if (!categoryMatch) { | ||
| return undefined | ||
| } | ||
|
|
||
| return getNoteCategoryById(decodeURIComponent(categoryMatch[1])) | ||
| } | ||
|
|
||
| export default function NotesDirectoryView({ activeCategory, notes }: NotesDirectoryViewProps) { | ||
| const activeNotes = getNotesForCategory(notes, activeCategory) | ||
| const categoryTabs = noteFilterCategories.map((category) => ({ | ||
| ...category, | ||
| count: getNotesForCategory(notes, category).length, | ||
| })) | ||
| const title = activeCategory.id === allNotesCategory.id | ||
| const [activeCategoryId, setActiveCategoryId] = useState(activeCategory.id) | ||
| const currentCategory = useMemo(() => ( | ||
| noteFilterCategories.find((category) => category.id === activeCategoryId) ?? activeCategory | ||
| ), [activeCategory, activeCategoryId]) | ||
| const activeNotes = useMemo(() => ( | ||
| getNotesForCategory(notes, currentCategory) | ||
| ), [currentCategory, notes]) | ||
| const categoryTabs = useMemo(() => ( | ||
| noteFilterCategories.map((category) => ({ | ||
| ...category, | ||
| count: getNotesForCategory(notes, category).length, | ||
| })) | ||
| ), [notes]) | ||
| const title = currentCategory.id === allNotesCategory.id | ||
| ? 'All Notes' | ||
| : `${activeCategory.title} Notes` | ||
| : `${currentCategory.title} Notes` | ||
|
|
||
| useEffect(() => { | ||
| setActiveCategoryId(activeCategory.id) | ||
| }, [activeCategory.id]) | ||
|
|
||
| useEffect(() => { | ||
| const handlePopState = () => { | ||
| const nextCategory = getCategoryFromPathname(window.location.pathname) | ||
|
|
||
| if (nextCategory) { | ||
| setActiveCategoryId(nextCategory.id) | ||
| } | ||
| } | ||
|
|
||
| window.addEventListener('popstate', handlePopState) | ||
|
|
||
| return () => window.removeEventListener('popstate', handlePopState) | ||
| }, []) | ||
|
|
||
| useEffect(() => { | ||
| document.title = currentCategory.id === allNotesCategory.id | ||
| ? 'Notes | dd3ok' | ||
| : `${currentCategory.title} Notes | dd3ok` | ||
| }, [currentCategory]) | ||
|
|
||
| const handleCategoryClick = useCallback(( | ||
| event: MouseEvent<HTMLAnchorElement>, | ||
| category: NoteCategory | ||
| ) => { | ||
| event.preventDefault() | ||
|
|
||
| const nextHref = getCategoryHref(category) | ||
|
|
||
| if (window.location.pathname === nextHref) { | ||
| return | ||
| } | ||
|
|
||
| setActiveCategoryId(category.id) | ||
| window.history.pushState({ notesCategory: category.id }, '', nextHref) | ||
| }, []) | ||
|
Comment on lines
+94
to
+108
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unconditionally calling To preserve these native browser features and maintain accessibility, the handler should check for modifier keys and non-primary mouse clicks, returning early without preventing the default behavior if any are detected. |
||
|
|
||
| return ( | ||
| <section className="section-padding pt-28 md:pt-32"> | ||
|
|
@@ -57,13 +126,15 @@ export default function NotesDirectoryView({ activeCategory, notes }: NotesDirec | |
|
|
||
| <nav className="mt-6 flex flex-wrap gap-2" aria-label="노트 카테고리"> | ||
| {categoryTabs.map((category) => { | ||
| const isActiveCategory = category.id === activeCategory.id | ||
| const isActiveCategory = category.id === currentCategory.id | ||
|
|
||
| return ( | ||
| <Link | ||
| key={category.id} | ||
| href={getCategoryHref(category)} | ||
| scroll={false} | ||
| aria-current={isActiveCategory ? 'page' : undefined} | ||
| onClick={(event) => handleCategoryClick(event, category)} | ||
| className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-bold transition-colors ${ | ||
| isActiveCategory | ||
| ? 'border-[var(--accent-color)] bg-[var(--accent-color)] text-white' | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| export interface NoteCategory { | ||
| id: string | ||
| title: string | ||
| aliases?: string[] | ||
| } | ||
|
|
||
| export interface NoteCategoryMatch { | ||
| category: string | ||
| type?: string | ||
| domain?: string | ||
| tags: string[] | ||
| } | ||
|
|
||
| export const noteCategories: NoteCategory[] = [ | ||
| { | ||
| id: 'ai-tools', | ||
| title: 'AI & Tools', | ||
| aliases: ['ai', 'tools', 'ai-workflow', 'agent', 'agents'], | ||
| }, | ||
| { | ||
| id: 'tech', | ||
| title: 'Tech', | ||
| }, | ||
| { | ||
| id: 'business', | ||
| title: 'Business', | ||
| }, | ||
| { | ||
| id: 'finance', | ||
| title: 'Finance', | ||
| }, | ||
| { | ||
| id: 'learning', | ||
| title: 'Learning', | ||
| }, | ||
| { | ||
| id: 'life', | ||
| title: 'Life', | ||
| }, | ||
| { | ||
| id: 'health', | ||
| title: 'Health', | ||
| aliases: ['health-longevity', 'longevity', 'exercise'], | ||
| }, | ||
| { | ||
| id: 'insights', | ||
| title: 'Insights', | ||
| aliases: ['insight', 'research-notes', 'notes'], | ||
| }, | ||
| { | ||
| id: 'other', | ||
| title: 'Other', | ||
| }, | ||
| ] | ||
|
|
||
| export const allNotesCategory: NoteCategory = { | ||
| id: 'all', | ||
| title: 'All', | ||
| } | ||
|
|
||
| export const noteFilterCategories: NoteCategory[] = [ | ||
| allNotesCategory, | ||
| ...noteCategories, | ||
| ] | ||
|
|
||
| export const getNotesForCategory = <TNote extends NoteCategoryMatch>( | ||
| notes: TNote[], | ||
| category: NoteCategory | ||
| ) => { | ||
| if (category.id === allNotesCategory.id) { | ||
| return notes | ||
| } | ||
|
|
||
| const categoryKeys = new Set([ | ||
| category.id, | ||
| ...(category.aliases ?? []), | ||
| ]) | ||
|
|
||
| return notes.filter((note) => ( | ||
| categoryKeys.has(note.category) || | ||
| Boolean(note.domain && categoryKeys.has(note.domain)) || | ||
| Boolean(note.type && categoryKeys.has(note.type)) || | ||
| note.tags.some((tag) => categoryKeys.has(tag)) | ||
| )) | ||
| } | ||
|
|
||
| export const getNoteCategoryById = (id: string) => { | ||
| return noteCategories.find((category) => category.id === id) | ||
| } | ||
|
|
||
| export const getStaticNoteCategorySlugs = () => { | ||
| return noteCategories.map((category) => category.id) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Calling
decodeURIComponenton arbitrary URL path segments can throw aURIErrorif the URL contains a malformed percent-encoded sequence (e.g.,/notes/tech%8). If a user navigates to or from such a URL, thepopstatelistener will throw an unhandled exception, potentially crashing the client-side application.Wrapping the decoding logic in a
try-catchblock ensures the application handles malformed URLs gracefully by returningundefinedinstead of throwing an error.