Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions scripts/check-notes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ if (!notesDirectoryViewSource.includes('/notes/post/${note.slug}/')) {
throw new Error('Note detail links must use /notes/post/[slug]/')
}

if (!notesDirectoryViewSource.includes('window.history.pushState')) {
throw new Error('Note category tabs must update URLs without a route refresh')
}

if (!notesDirectoryViewSource.includes('popstate')) {
throw new Error('Note category tabs must support browser back and forward navigation')
}

const noteFiles = readdirSync(notesDirectory)
.filter((fileName) => fileName.endsWith('.md'))
.sort()
Expand Down
91 changes: 81 additions & 10 deletions src/app/notes/NotesDirectoryView.tsx
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
Expand All @@ -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]))
}
Comment on lines +36 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling decodeURIComponent on arbitrary URL path segments can throw a URIError if the URL contains a malformed percent-encoded sequence (e.g., /notes/tech%8). If a user navigates to or from such a URL, the popstate listener will throw an unhandled exception, potentially crashing the client-side application.

Wrapping the decoding logic in a try-catch block ensures the application handles malformed URLs gracefully by returning undefined instead of throwing an error.

const getCategoryFromPathname = (pathname: string) => {
    const normalizedPathname = pathname.replace(/\/+$/, '')

    if (normalizedPathname === '/notes') {
        return allNotesCategory
    }

    const categoryMatch = normalizedPathname.match(/^\/notes\/([^/]+)$/)

    if (!categoryMatch) {
        return undefined
    }

    try {
        return getNoteCategoryById(decodeURIComponent(categoryMatch[1]))
    } catch {
        return undefined
    }
}


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Unconditionally calling event.preventDefault() in the onClick handler of a <Link> component breaks standard browser behaviors, such as opening links in a new tab via Cmd + Click (macOS), Ctrl + Click (Windows/Linux), Shift + Click (new window), or middle-clicks.

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.

    const handleCategoryClick = useCallback((
        event: MouseEvent<HTMLAnchorElement>,
        category: NoteCategory
    ) => {
        if (
            event.defaultPrevented ||
            event.button !== 0 ||
            event.metaKey ||
            event.ctrlKey ||
            event.altKey ||
            event.shiftKey
        ) {
            return
        }

        event.preventDefault()

        const nextHref = getCategoryHref(category)

        if (window.location.pathname === nextHref) {
            return
        }

        setActiveCategoryId(category.id)
        window.history.pushState({ notesCategory: category.id }, '', nextHref)
    }, [])


return (
<section className="section-padding pt-28 md:pt-32">
Expand All @@ -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'
Expand Down
93 changes: 93 additions & 0 deletions src/lib/note-categories.ts
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)
}
95 changes: 11 additions & 84 deletions src/lib/notes.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { noteCategories } from './note-categories'

export type PublicNoteStatus = 'draft' | 'reviewed' | 'evergreen' | 'archived'
export {
allNotesCategory,
getNoteCategoryById,
getNotesForCategory,
getStaticNoteCategorySlugs,
noteCategories,
noteFilterCategories,
type NoteCategory,
} from './note-categories'

export interface NoteCategory {
id: string
title: string
aliases?: string[]
}
export type PublicNoteStatus = 'draft' | 'reviewed' | 'evergreen' | 'archived'

export interface PublicNote {
slug: string
Expand Down Expand Up @@ -38,58 +43,6 @@ const allowedStatuses = new Set<PublicNoteStatus>([
'archived',
])

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,
]

const stripQuotes = (value: string) => value.replace(/^['"]|['"]$/g, '')

const parseInlineArray = (value: string) => {
Expand Down Expand Up @@ -282,32 +235,6 @@ export const getPublicNoteSlugs = () => {
return getPublicNotes().map((note) => note.slug)
}

export const getNotesForCategory = (notes: PublicNote[], 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)
}

export const getStaticNotePostSlugs = () => {
return getPublicNoteSlugs()
}