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

Commit c8a0a6f

Browse files
committed
feat(admin): redesign /readers as master-detail with search, role tabs, and ban management
1 parent a7bf5a7 commit c8a0a6f

28 files changed

Lines changed: 1532 additions & 293 deletions

apps/admin/src/api/readers.ts

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,75 @@
11
import type { PaginateResult } from '~/models/base'
22

3-
import { getJson } from './http'
3+
import { getJson, patchJson } from './http'
4+
5+
export type ReaderRole = 'reader' | 'owner'
6+
export type ReaderRoleFilter = 'all' | 'owner' | 'reader'
47

58
export interface ReaderModel {
69
id: string
7-
provider?: string
8-
type?: string
9-
name: string
10-
email: string
11-
image: string
12-
handle?: string
13-
role: 'reader' | 'owner'
10+
email: string | null
11+
emailVerified: boolean
12+
name: string | null
13+
handle: string | null
14+
username: string | null
15+
displayUsername: string | null
16+
image: string | null
17+
role: ReaderRole
18+
bannedAt: string | null
19+
banReason: string | null
20+
createdAt: string
21+
updatedAt: string | null
22+
lastLoginAt: string | null
23+
}
24+
25+
export interface ReaderStats {
26+
all: number
27+
owner: number
28+
reader: number
29+
banned: number
30+
}
31+
32+
export interface ReaderListParams {
33+
page: number
34+
size: number
35+
search?: string
36+
role?: ReaderRoleFilter
37+
}
38+
39+
export function getReaders(params: ReaderListParams) {
40+
return getJson<PaginateResult<ReaderModel>>('/readers', {
41+
page: params.page,
42+
role: params.role,
43+
search: params.search,
44+
size: params.size,
45+
})
46+
}
47+
48+
export function getReaderStats() {
49+
return getJson<ReaderStats>('/readers/stats')
50+
}
51+
52+
export function getReader(id: string) {
53+
return getJson<ReaderModel>(`/readers/${id}`)
54+
}
55+
56+
export function transferOwner(id: string) {
57+
return patchJson<unknown, { id: string }>('/readers/transfer-owner', { id })
58+
}
59+
60+
export function revokeOwner(id: string) {
61+
return patchJson<unknown, { id: string }>('/readers/revoke-owner', { id })
62+
}
63+
64+
export function banReader(id: string, reason?: string) {
65+
return patchJson<ReaderModel, { reason?: string }>(`/readers/${id}/ban`, {
66+
reason,
67+
})
1468
}
1569

16-
export function getReaders(params: { page: number; size: number }) {
17-
return getJson<PaginateResult<ReaderModel>>('/readers', params)
70+
export function unbanReader(id: string) {
71+
return patchJson<ReaderModel, Record<string, never>>(
72+
`/readers/${id}/unban`,
73+
{},
74+
)
1875
}

apps/admin/src/features/readers/components/ProviderIcon.tsx

Lines changed: 0 additions & 35 deletions
This file was deleted.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { Ban, ShieldCheck, UserMinus, UserStar } from 'lucide-react'
2+
import type { ReaderModel } from '~/api/readers'
3+
import type { useReaderMutations } from '../hooks/useReaderMutations'
4+
5+
import { useI18n } from '~/i18n'
6+
import { confirmDialog } from '~/ui/feedback/confirm'
7+
import { Button } from '~/ui/primitives/button'
8+
9+
import { presentBanReaderModal } from './modals/BanReaderModal'
10+
11+
export function ReaderActionsFooter(props: {
12+
reader: ReaderModel
13+
currentUserId: string | null
14+
mutations: ReturnType<typeof useReaderMutations>
15+
}) {
16+
const { t } = useI18n()
17+
const { reader, mutations } = props
18+
const name = reader.name ?? reader.handle ?? reader.id
19+
20+
const isSelf =
21+
props.currentUserId != null && props.currentUserId === reader.id
22+
const isOwner = reader.role === 'owner'
23+
const banned = Boolean(reader.bannedAt)
24+
25+
const banDisabledReason = isOwner
26+
? t('readers.action.cannotBanOwner')
27+
: isSelf
28+
? t('readers.action.cannotBanSelf')
29+
: undefined
30+
const transferDisabled = isOwner || isSelf
31+
32+
const handleTransfer = async () => {
33+
const ok = await confirmDialog({
34+
description: t('readers.transferOwner.desc', { name }),
35+
destructive: false,
36+
title: t('readers.transferOwner.title'),
37+
})
38+
if (ok) mutations.transferOwner.mutate(reader.id)
39+
}
40+
41+
const handleRevoke = async () => {
42+
const ok = await confirmDialog({
43+
description: t('readers.revokeOwner.desc', { name }),
44+
destructive: true,
45+
title: t('readers.revokeOwner.title'),
46+
})
47+
if (ok) mutations.revokeOwner.mutate(reader.id)
48+
}
49+
50+
const handleBan = async () => {
51+
const reason = await presentBanReaderModal(reader)
52+
if (reason === undefined) return
53+
mutations.banReader.mutate({ id: reader.id, reason: reason || undefined })
54+
}
55+
56+
const handleUnban = () => mutations.unbanReader.mutate(reader.id)
57+
58+
return (
59+
<div className="flex flex-wrap items-center gap-2 border-t border-neutral-200 px-4 py-3 dark:border-neutral-800">
60+
<Button
61+
className="h-9 px-3"
62+
disabled={transferDisabled || mutations.transferOwner.isPending}
63+
onClick={handleTransfer}
64+
type="button"
65+
variant="subtle"
66+
>
67+
<UserStar aria-hidden="true" className="size-4" />
68+
{t('readers.action.transferOwner')}
69+
</Button>
70+
71+
{isOwner ? (
72+
<Button
73+
className="h-9 px-3"
74+
disabled={mutations.revokeOwner.isPending}
75+
onClick={handleRevoke}
76+
type="button"
77+
variant="subtle"
78+
>
79+
<UserMinus aria-hidden="true" className="size-4" />
80+
{t('readers.action.revokeOwner')}
81+
</Button>
82+
) : null}
83+
84+
<div className="flex-1" />
85+
86+
{banned ? (
87+
<Button
88+
className="h-9 px-3"
89+
disabled={mutations.unbanReader.isPending}
90+
onClick={handleUnban}
91+
type="button"
92+
variant="subtle"
93+
>
94+
<ShieldCheck aria-hidden="true" className="size-4" />
95+
{t('readers.action.unban')}
96+
</Button>
97+
) : (
98+
<Button
99+
className="h-9 border-red-200 px-3 text-red-600 hover:bg-red-50 disabled:hover:bg-transparent dark:border-red-950 dark:text-red-400 dark:hover:bg-red-950/30"
100+
disabled={Boolean(banDisabledReason) || mutations.banReader.isPending}
101+
onClick={handleBan}
102+
title={banDisabledReason}
103+
type="button"
104+
variant="subtle"
105+
>
106+
<Ban aria-hidden="true" className="size-4" />
107+
{t('readers.action.ban')}
108+
</Button>
109+
)}
110+
</div>
111+
)
112+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { ReaderModel } from '~/api/readers'
2+
import type { TranslationKey } from '~/i18n/types'
3+
4+
import { useI18n } from '~/i18n'
5+
import { parseDate, relativeTimeFromNow } from '~/utils/time'
6+
7+
function ActivityRow(props: { label: string; value: string | null }) {
8+
const { t } = useI18n()
9+
const absolute = props.value
10+
? parseDate(props.value, 'yyyy 年 M 月 d 日 HH:mm:ss')
11+
: undefined
12+
13+
return (
14+
<div className="flex items-baseline justify-between gap-4 py-2">
15+
<span className="shrink-0 text-xs text-neutral-500 dark:text-neutral-400">
16+
{props.label}
17+
</span>
18+
{props.value ? (
19+
<time
20+
className="min-w-0 truncate text-right text-sm text-neutral-800 dark:text-neutral-200"
21+
dateTime={props.value}
22+
title={absolute}
23+
>
24+
{relativeTimeFromNow(props.value)}
25+
</time>
26+
) : (
27+
<span className="text-right text-sm text-neutral-400">
28+
{t('readers.row.lastLoginNever')}
29+
</span>
30+
)}
31+
</div>
32+
)
33+
}
34+
35+
export function ReaderActivityBlock(props: { reader: ReaderModel }) {
36+
const { t } = useI18n()
37+
const { reader } = props
38+
39+
const rows: { labelKey: TranslationKey; value: string | null }[] = [
40+
{ labelKey: 'readers.detail.field.joined', value: reader.createdAt },
41+
{ labelKey: 'readers.detail.field.lastLogin', value: reader.lastLoginAt },
42+
{ labelKey: 'readers.detail.field.updated', value: reader.updatedAt },
43+
]
44+
45+
return (
46+
<section className="flex flex-col gap-3">
47+
<h3 className="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
48+
{t('readers.detail.section.activity')}
49+
</h3>
50+
<div className="divide-y divide-neutral-200 rounded-md border border-neutral-200 px-3 dark:divide-neutral-800 dark:border-neutral-800">
51+
{rows.map((row) => (
52+
<ActivityRow
53+
key={row.labelKey}
54+
label={t(row.labelKey)}
55+
value={row.value}
56+
/>
57+
))}
58+
</div>
59+
</section>
60+
)
61+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { UserRoundSearch } from 'lucide-react'
2+
3+
import { useI18n } from '~/i18n'
4+
5+
export function ReaderDetailEmpty() {
6+
const { t } = useI18n()
7+
return (
8+
<div className="flex h-full min-h-[24rem] flex-col items-center justify-center px-4 text-center">
9+
<UserRoundSearch aria-hidden="true" className="size-8 text-neutral-300" />
10+
<p className="mt-3 text-sm font-medium text-neutral-700 dark:text-neutral-300">
11+
{t('readers.detail.empty.title')}
12+
</p>
13+
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
14+
{t('readers.detail.empty.hint')}
15+
</p>
16+
</div>
17+
)
18+
}

0 commit comments

Comments
 (0)