Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.

Commit adcfc03

Browse files
committed
feat: enhance button component with icon-only variant and refactor class name composition
docs: add design document for List Focus Scope Abstraction & Master-Detail Migration refactor: update package.json scripts to use pnpm for admin app chore: remove turbo.json configuration and related dependencies from pnpm-lock.yaml Signed-off-by: Innei <tukon479@gmail.com>
1 parent f390be3 commit adcfc03

31 files changed

Lines changed: 2029 additions & 592 deletions

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,3 @@ stats.html
1111

1212
/src/views/dev
1313
g.d.ts
14-
.turbo/

CLAUDE.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
44

55
## Project Overview
66

7-
MX Admin is the dashboard for MX Space, a personal blog management system. The repo is a pnpm + turbo monorepo whose only active app is `apps/admin` — a React 19 SPA built with Base UI primitives, React Router (HashRouter), TanStack Query, Sonner, UnoCSS, and a Tailwind v4 layer. Despite the repo name `admin-vue3`, the Vue codebase has been retired; React is the sole runtime.
7+
MX Admin is the dashboard for MX Space, a personal blog management system. The repo is a pnpm workspace whose only active app is `apps/admin` — a React 19 SPA built with Base UI primitives, React Router (HashRouter), TanStack Query, Sonner, UnoCSS, and a Tailwind v4 layer. Despite the repo name `admin-vue3`, the Vue codebase has been retired; React is the sole runtime.
88

99
## Development Commands
1010

11-
All scripts go through turbo at the repo root:
11+
Root scripts proxy to `apps/admin` via `pnpm -C apps/admin <task>`:
1212

1313
```bash
1414
pnpm install # Install dependencies
@@ -106,7 +106,6 @@ New admin views must follow the master-detail / content-layout convention. See:
106106
- `apps/admin/uno.config.ts` — UnoCSS breakpoints (`phone:`, `tablet:`, `desktop:`) and theme colors (if present)
107107
- `apps/admin/src/theme.ts` — CSS token installation for the shell
108108
- `apps/admin/src/index.css` — global stylesheet + Tailwind layer
109-
- `turbo.json` — task pipeline (build/dev/lint/typecheck)
110109

111110
## Related Projects
112111

apps/admin/src/features/_shared/components/content-list-item.tsx

Lines changed: 77 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import type {
1010
} from 'react'
1111

1212
import { useI18n } from '~/i18n'
13-
import { ContextMenuTrigger, showContextMenu } from '~/ui/overlay/context-menu'
13+
import { ListRow } from '~/ui/list-actions'
14+
import { showContextMenu } from '~/ui/overlay/context-menu'
1415
import { Checkbox } from '~/ui/primitives/checkbox'
1516
import { cn } from '~/utils/cn'
1617

@@ -19,17 +20,16 @@ export type ContentEntrySelectMode = 'single' | 'toggle' | 'range'
1920
export interface ContentEntryListItemProps {
2021
checkboxLabel?: string
2122
className?: string
22-
/** Stable id of the underlying entity. Exposed on the row as `data-id`. */
23+
/** Stable id of the underlying entity. */
2324
dataId?: string
2425
editTitle: string
2526
editTo: string
2627
externalHref: string
28+
/** Visual leading slot rendered inline with the title (icon, pin, etc.). */
2729
leading?: ReactNode
2830
menuItems: ContextMenuItem[] | (() => ContextMenuItem[])
2931
meta?: ReactNode
30-
/** Called when the row body is clicked (away from links / buttons / checkbox). */
3132
onSelect?: (mode: ContentEntrySelectMode) => void
32-
/** Called when the checkbox itself is toggled. */
3333
onSelectedChange?: (checked: boolean) => void
3434
openTitle: string
3535
selected?: boolean
@@ -38,21 +38,19 @@ export interface ContentEntryListItemProps {
3838
titleTo: string
3939
}
4040

41-
const INTERACTIVE_SELECTOR =
42-
'a[href], button, input, [role="menuitem"], [role="checkbox"]'
43-
4441
/**
4542
* Two-line list item shared by `/posts` and `/notes`.
4643
*
4744
* ☐ [leading] Title text [status] ✎ ↗ ⋯
4845
* category · tags · 👁 N · ♡ N relative time
4946
*
50-
* The whole row is wrapped in `<ContextMenuTrigger>`; right-clicking
51-
* anywhere on the row opens the menu and the row receives
52-
* `data-popup-open=""` so it can highlight via the `data-[popup-open]:`
53-
* variant. Left-clicking the row body (outside links, buttons, checkbox)
54-
* fires `onSelect` with a modifier-aware mode so consumers can do
55-
* single / toggle (`$mod`+click) / range (`Shift`+click) selection.
47+
* Delegates row keyboard/selection/context-menu plumbing to `ListRow`. Owns the
48+
* checkbox column + title link + status badge + meta line + edit/external/more
49+
* action button trio.
50+
*
51+
* Note: `props.leading` is the *visual* leading slot rendered next to the title
52+
* (e.g. a pin icon). It is distinct from `ListRow.leading`, which holds the
53+
* selection-column checkbox.
5654
*/
5755
export function ContentEntryListItem(props: ContentEntryListItemProps) {
5856
const { t } = useI18n()
@@ -68,103 +66,82 @@ export function ContentEntryListItem(props: ContentEntryListItemProps) {
6866
showContextMenu(resolved)
6967
}
7068

71-
const onRowClick = (event: ReactMouseEvent<HTMLElement>) => {
72-
if (event.defaultPrevented) return
73-
if (!props.onSelect) return
74-
const target = event.target as Element | null
75-
// Skip clicks on links, buttons, inputs, the checkbox — those interactive
76-
// descendants do their own thing.
77-
if (target?.closest(INTERACTIVE_SELECTOR)) return
78-
const mode: ContentEntrySelectMode = event.shiftKey
79-
? 'range'
80-
: event.metaKey || event.ctrlKey
81-
? 'toggle'
82-
: 'single'
83-
props.onSelect(mode)
84-
}
85-
8669
return (
87-
<ContextMenuTrigger items={props.menuItems}>
88-
<article
89-
className={cn(
90-
'group grid cursor-default gap-x-3 gap-y-1.5 px-4 py-3',
91-
selectable
92-
? 'grid-cols-[auto_minmax(0,1fr)_auto]'
93-
: 'grid-cols-[minmax(0,1fr)_auto]',
94-
// Vercel/Geist convention: selection lives on the neutral scale,
95-
// brand color stays out of row backgrounds. Hover < popup-open ≈
96-
// selected < selected+hover < selected+popup-open.
97-
'hover:bg-neutral-50 dark:hover:bg-neutral-900/50',
98-
'data-popup-open:bg-neutral-100 dark:data-popup-open:bg-neutral-800/60',
99-
'data-selected:bg-neutral-100 dark:data-selected:bg-neutral-800/60',
100-
'data-selected:hover:bg-neutral-200/60 dark:data-selected:hover:bg-neutral-800/80',
101-
'data-selected:data-popup-open:bg-neutral-200 dark:data-selected:data-popup-open:bg-neutral-800',
102-
// Keyboard cursor (J/K/Arrow). `:focus-visible` only fires on
103-
// keyboard focus, so mouse clicks don't draw the ring.
104-
'outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-400 dark:focus-visible:outline-neutral-500',
105-
props.className,
106-
)}
107-
data-id={props.dataId}
108-
data-scope-item="row"
109-
data-selected={props.selected ? '' : undefined}
110-
onClick={onRowClick}
111-
tabIndex={-1}
112-
>
113-
{selectable ? (
70+
<ListRow
71+
as="article"
72+
className={cn(
73+
'group grid cursor-default gap-x-3 gap-y-1.5 px-4 py-3',
74+
selectable
75+
? 'grid-cols-[auto_minmax(0,1fr)_auto]'
76+
: 'grid-cols-[minmax(0,1fr)_auto]',
77+
'hover:bg-neutral-50 dark:hover:bg-neutral-900/50',
78+
'data-popup-open:bg-neutral-100 dark:data-popup-open:bg-neutral-800/60',
79+
'data-selected:bg-neutral-100 dark:data-selected:bg-neutral-800/60',
80+
'data-selected:hover:bg-neutral-200/60 dark:data-selected:hover:bg-neutral-800/80',
81+
'data-selected:data-popup-open:bg-neutral-200 dark:data-selected:data-popup-open:bg-neutral-800',
82+
'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-400 dark:focus-visible:outline-neutral-500',
83+
props.className,
84+
)}
85+
dataId={props.dataId ?? ''}
86+
leading={
87+
selectable ? (
11488
<Checkbox
11589
aria-label={props.checkboxLabel}
11690
checked={props.selected ?? false}
11791
className="mt-0.5"
11892
onCheckedChange={handleSelectedChange}
11993
/>
120-
) : null}
121-
122-
<div className="min-w-0">
123-
<div className="flex min-w-0 items-center gap-2">
124-
{props.leading}
125-
<Link
126-
className="outline-hidden min-w-0 truncate text-sm font-medium text-neutral-950 hover:text-neutral-600 focus-visible:ring-2 focus-visible:ring-[var(--color-primary-shallow)] dark:text-neutral-50 dark:hover:text-neutral-300"
127-
to={props.titleTo}
128-
>
129-
{props.title}
130-
</Link>
131-
{props.status}
132-
</div>
133-
{props.meta ? (
134-
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-neutral-500 dark:text-neutral-400">
135-
{props.meta}
136-
</div>
137-
) : null}
94+
) : null
95+
}
96+
menuItems={props.menuItems}
97+
onSelect={props.onSelect}
98+
selected={props.selected}
99+
>
100+
<div className="min-w-0">
101+
<div className="flex min-w-0 items-center gap-2">
102+
{props.leading}
103+
<Link
104+
className="outline-hidden min-w-0 truncate text-sm font-medium text-neutral-950 hover:text-neutral-600 focus-visible:ring-2 focus-visible:ring-[var(--color-primary-shallow)] dark:text-neutral-50 dark:hover:text-neutral-300"
105+
to={props.titleTo}
106+
>
107+
{props.title}
108+
</Link>
109+
{props.status}
138110
</div>
111+
{props.meta ? (
112+
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-neutral-500 dark:text-neutral-400">
113+
{props.meta}
114+
</div>
115+
) : null}
116+
</div>
139117

140-
<div
141-
className={cn(
142-
'row-start-1 flex shrink-0 items-center gap-0.5 self-start text-neutral-300',
143-
'group-hover:text-neutral-600 group-data-[popup-open]:text-neutral-600 group-data-[selected]:text-neutral-600',
144-
'dark:text-neutral-700 dark:group-hover:text-neutral-300 dark:group-data-[popup-open]:text-neutral-300 dark:group-data-[selected]:text-neutral-300',
145-
selectable ? 'col-start-3' : 'col-start-2',
146-
)}
118+
<div
119+
className={cn(
120+
'row-start-1 flex shrink-0 items-center gap-0.5 self-start text-neutral-300',
121+
'group-hover:text-neutral-600 group-data-[popup-open]:text-neutral-600 group-data-[selected]:text-neutral-600',
122+
'dark:text-neutral-700 dark:group-hover:text-neutral-300 dark:group-data-[popup-open]:text-neutral-300 dark:group-data-[selected]:text-neutral-300',
123+
selectable ? 'col-start-3' : 'col-start-2',
124+
)}
125+
>
126+
<ActionLink title={props.editTitle} to={props.editTo}>
127+
<Pencil aria-hidden="true" className="size-4" />
128+
</ActionLink>
129+
<ActionLink
130+
href={props.externalHref}
131+
rel="noreferrer"
132+
target="_blank"
133+
title={props.openTitle}
147134
>
148-
<ActionLink title={props.editTitle} to={props.editTo}>
149-
<Pencil aria-hidden="true" className="size-4" />
150-
</ActionLink>
151-
<ActionLink
152-
href={props.externalHref}
153-
rel="noreferrer"
154-
target="_blank"
155-
title={props.openTitle}
156-
>
157-
<ExternalLink aria-hidden="true" className="size-4" />
158-
</ActionLink>
159-
<ActionButton
160-
onClick={onMoreClick}
161-
title={t('shared.contentListItem.moreActions')}
162-
>
163-
<MoreHorizontal aria-hidden="true" className="size-4" />
164-
</ActionButton>
165-
</div>
166-
</article>
167-
</ContextMenuTrigger>
135+
<ExternalLink aria-hidden="true" className="size-4" />
136+
</ActionLink>
137+
<ActionButton
138+
onClick={onMoreClick}
139+
title={t('shared.contentListItem.moreActions')}
140+
>
141+
<MoreHorizontal aria-hidden="true" className="size-4" />
142+
</ActionButton>
143+
</div>
144+
</ListRow>
168145
)
169146
}
170147

apps/admin/src/features/comments/components/CommentListItem.tsx

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,92 @@
1+
import { CheckCheck, ShieldAlert, Trash2 } from 'lucide-react'
12
import type { CommentModel } from '~/models/comment'
3+
import type { ListAction, ListRowSelectMode } from '~/ui/list-actions'
4+
import type { ContextMenuItem } from '~/ui/overlay/context-menu'
25

36
import { useI18n } from '~/i18n'
7+
import { CommentState } from '~/models/comment'
8+
import { ListRow } from '~/ui/list-actions'
49
import { Checkbox } from '~/ui/primitives/checkbox'
510
import { cn } from '~/utils/cn'
611

712
import { formatCommentDate } from '../utils/comments'
813
import { Avatar } from './CommentPrimitives'
914

1015
export function CommentListItem(props: {
16+
actions: ReadonlyArray<ListAction<CommentModel>>
1117
checked: boolean
1218
comment: CommentModel
19+
currentFilter: CommentState
20+
isDetailTarget: boolean
1321
onCheck: (id: string, checked: boolean) => void
14-
onSelect: () => void
22+
onMarkJunk: (id: string) => void
23+
onMarkRead: (id: string) => void
24+
onSelect: (mode: ListRowSelectMode) => void
1525
selected: boolean
1626
}) {
1727
const { t } = useI18n()
1828
const commentText = props.comment.isDeleted
1929
? t('comments.deletedPlaceholder')
2030
: props.comment.text
2131

32+
const menuItems = (): ContextMenuItem[] => {
33+
const items: ContextMenuItem[] = []
34+
if (props.currentFilter !== CommentState.Read) {
35+
items.push({
36+
icon: CheckCheck,
37+
key: 'mark-read',
38+
label: t('comments.action.markRead'),
39+
onClick: () => props.onMarkRead(props.comment.id),
40+
})
41+
}
42+
if (props.currentFilter !== CommentState.Junk) {
43+
items.push({
44+
icon: ShieldAlert,
45+
key: 'mark-junk',
46+
label: t('comments.action.markJunk'),
47+
onClick: () => props.onMarkJunk(props.comment.id),
48+
})
49+
}
50+
if (items.length > 0) items.push({ key: 'sep-1', type: 'divider' })
51+
items.push({
52+
danger: true,
53+
icon: Trash2,
54+
key: 'delete',
55+
label: t('common.delete'),
56+
onClick: () =>
57+
props.actions.find((a) => a.key === 'delete')?.run([props.comment]),
58+
})
59+
return items
60+
}
61+
2262
return (
23-
<article
63+
<ListRow
64+
as="article"
65+
ariaCurrent={props.isDetailTarget}
2466
className={cn(
25-
'flex cursor-pointer gap-3 border-b border-neutral-100 px-4 py-3 transition-colors last:border-b-0 dark:border-neutral-900',
26-
props.selected
27-
? 'bg-neutral-100 dark:bg-neutral-900'
28-
: props.checked
29-
? 'bg-neutral-50 dark:bg-neutral-900/60'
30-
: 'hover:bg-neutral-50 dark:hover:bg-neutral-900/40',
67+
'group grid cursor-default grid-cols-[auto_auto_minmax(0,1fr)] gap-3 border-b border-neutral-100 px-4 py-3 last:border-b-0 dark:border-neutral-900',
68+
'hover:bg-neutral-50 dark:hover:bg-neutral-900/40',
69+
'data-popup-open:bg-neutral-100 dark:data-popup-open:bg-neutral-800/60',
70+
'data-selected:bg-neutral-100 dark:data-selected:bg-neutral-900',
71+
'data-selected:hover:bg-neutral-200/60 dark:data-selected:hover:bg-neutral-800/80',
72+
'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-400 dark:focus-visible:outline-neutral-500',
3173
)}
32-
onClick={props.onSelect}
74+
dataId={props.comment.id}
75+
leading={
76+
<Checkbox
77+
aria-label={t('comments.list.selectComment')}
78+
checked={props.checked}
79+
className="mt-1"
80+
onCheckedChange={(checked) =>
81+
props.onCheck(props.comment.id, checked)
82+
}
83+
/>
84+
}
85+
menuItems={menuItems}
86+
onSelect={props.onSelect}
87+
role="row"
88+
selected={props.selected}
3389
>
34-
<Checkbox
35-
aria-label={t('comments.list.selectComment')}
36-
checked={props.checked}
37-
className="mt-1 shrink-0"
38-
onCheckedChange={(checked) => props.onCheck(props.comment.id, checked)}
39-
onClick={(event) => event.stopPropagation()}
40-
/>
4190
<Avatar comment={props.comment} size="sm" />
4291
<div className="min-w-0 flex-1">
4392
<div className="flex items-baseline gap-2">
@@ -65,6 +114,6 @@ export function CommentListItem(props: {
65114
</span>
66115
) : null}
67116
</div>
68-
</article>
117+
</ListRow>
69118
)
70119
}

0 commit comments

Comments
 (0)