@allbluecn/admin-app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +86 -0
- package/src/client.tsx +31 -0
- package/src/components/admin/admin-user-menu.tsx +90 -0
- package/src/components/admin/developer-log.tsx +356 -0
- package/src/components/ai-provider/model-list-editor.tsx +147 -0
- package/src/components/ai-provider/provider-form.tsx +434 -0
- package/src/components/ai-provider/provider-icon.tsx +43 -0
- package/src/components/ai-provider/provider-logo.tsx +87 -0
- package/src/components/ai-provider/provider-table.tsx +258 -0
- package/src/components/auth/admin-login-form.tsx +214 -0
- package/src/components/auth/auth-brand-panel.tsx +60 -0
- package/src/components/auth/auth-header.tsx +38 -0
- package/src/components/auth/auth-layout.tsx +66 -0
- package/src/components/auth/auth-providers.tsx +34 -0
- package/src/components/auth/icon-input.tsx +46 -0
- package/src/components/auth/oauth-section.tsx +51 -0
- package/src/components/auth/password-input.tsx +67 -0
- package/src/components/common/page-header.tsx +42 -0
- package/src/components/common/placeholder-page.tsx +42 -0
- package/src/components/component/component-form-dialog.tsx +348 -0
- package/src/components/component/component-import-dialog.tsx +235 -0
- package/src/components/component/component-table.tsx +153 -0
- package/src/components/data-table/data-table-pagination.tsx +114 -0
- package/src/components/data-table/data-table.tsx +218 -0
- package/src/components/data-table/table-detail-layout.tsx +99 -0
- package/src/components/dependency/changelog-dialog.tsx +108 -0
- package/src/components/dependency/columns.tsx +232 -0
- package/src/components/dependency/dependency-table.tsx +368 -0
- package/src/components/dependency/edit-dialog.tsx +200 -0
- package/src/components/knowledge-public/bookmark-form-dialog.tsx +215 -0
- package/src/components/knowledge-public/compliance-form-dialog.tsx +308 -0
- package/src/components/knowledge-public/legal-form-dialog.tsx +315 -0
- package/src/components/layout/sidebar-02/app-sidebar.tsx +160 -0
- package/src/components/layout/sidebar-02/index.tsx +33 -0
- package/src/components/layout/sidebar-02/logo.tsx +40 -0
- package/src/components/layout/sidebar-02/nav-main.tsx +153 -0
- package/src/components/layout/sidebar-02/nav-notifications.tsx +78 -0
- package/src/components/layout/sidebar-02/team-switcher.tsx +106 -0
- package/src/components/magicui/animated-theme-toggler.tsx +272 -0
- package/src/components/magicui/rainbow-button.tsx +78 -0
- package/src/components/market/columns.tsx +151 -0
- package/src/components/market/market-app-form.tsx +237 -0
- package/src/components/market/market-app-list.tsx +156 -0
- package/src/components/monitoring/error-list.tsx +143 -0
- package/src/components/monitoring/health-cards.tsx +152 -0
- package/src/components/monitoring/metrics-stats.tsx +184 -0
- package/src/components/monitoring/request-log-table.tsx +179 -0
- package/src/components/providers/theme-toggle.tsx +59 -0
- package/src/components/settings/avatar/avatar-config.ts +46 -0
- package/src/components/ui/alert-dialog.tsx +216 -0
- package/src/components/ui/alert.tsx +76 -0
- package/src/components/ui/avatar.tsx +129 -0
- package/src/components/ui/badge.tsx +66 -0
- package/src/components/ui/button.tsx +84 -0
- package/src/components/ui/card.tsx +120 -0
- package/src/components/ui/checkbox.tsx +50 -0
- package/src/components/ui/collapsible.tsx +50 -0
- package/src/components/ui/dialog.tsx +185 -0
- package/src/components/ui/dropdown-menu.tsx +286 -0
- package/src/components/ui/field.tsx +255 -0
- package/src/components/ui/input-group.tsx +171 -0
- package/src/components/ui/input.tsx +36 -0
- package/src/components/ui/label.tsx +41 -0
- package/src/components/ui/pagination.tsx +146 -0
- package/src/components/ui/select.tsx +209 -0
- package/src/components/ui/separator.tsx +45 -0
- package/src/components/ui/sheet.tsx +164 -0
- package/src/components/ui/sidebar.tsx +719 -0
- package/src/components/ui/skeleton.tsx +30 -0
- package/src/components/ui/sonner.tsx +37 -0
- package/src/components/ui/switch.tsx +48 -0
- package/src/components/ui/table.tsx +133 -0
- package/src/components/ui/textarea.tsx +35 -0
- package/src/components/ui/tooltip.tsx +74 -0
- package/src/components/user/columns.tsx +153 -0
- package/src/components/user/settings-activity-log.tsx +358 -0
- package/src/components/user/settings-profile/constants.ts +42 -0
- package/src/components/user/settings-profile/types.ts +47 -0
- package/src/components/user/settings-profile.tsx +664 -0
- package/src/components/user/user-create-dialog.tsx +137 -0
- package/src/components/user/user-filters.tsx +117 -0
- package/src/hooks/use-mobile.ts +36 -0
- package/src/instrument.client.ts +34 -0
- package/src/lib/anyhub/capabilities.ts +59 -0
- package/src/lib/anyhub/types.ts +43 -0
- package/src/lib/auth-client.ts +83 -0
- package/src/lib/auth-utils.ts +24 -0
- package/src/lib/auth.config.ts +264 -0
- package/src/lib/avatar.ts +41 -0
- package/src/lib/i18n/client.ts +32 -0
- package/src/lib/i18n/config.ts +40 -0
- package/src/lib/i18n/detect.ts +43 -0
- package/src/lib/i18n/locales/en/admin-ai.json +128 -0
- package/src/lib/i18n/locales/en/admin-billing.json +56 -0
- package/src/lib/i18n/locales/en/admin-users.json +88 -0
- package/src/lib/i18n/locales/en/common.json +877 -0
- package/src/lib/i18n/locales/en/enterprise.json +208 -0
- package/src/lib/i18n/locales/zh/admin-ai.json +128 -0
- package/src/lib/i18n/locales/zh/admin-billing.json +56 -0
- package/src/lib/i18n/locales/zh/admin-users.json +88 -0
- package/src/lib/i18n/locales/zh/common.json +877 -0
- package/src/lib/i18n/locales/zh/enterprise.json +208 -0
- package/src/lib/i18n/server.ts +18 -0
- package/src/lib/i18n/types.ts +5 -0
- package/src/lib/monitoring-benchmarks.ts +85 -0
- package/src/lib/monitoring-descriptions.ts +78 -0
- package/src/lib/prisma.ts +18 -0
- package/src/lib/router-context.ts +38 -0
- package/src/lib/session.ts +38 -0
- package/src/lib/utils.ts +23 -0
- package/src/providers/index.tsx +45 -0
- package/src/routeTree.gen.ts +1199 -0
- package/src/router.tsx +51 -0
- package/src/routes/__root.tsx +170 -0
- package/src/routes/_nav/ai-providers/$id/edit/route.lazy.tsx +74 -0
- package/src/routes/_nav/ai-providers/$id/edit/route.tsx +35 -0
- package/src/routes/_nav/ai-providers/index/route.lazy.tsx +33 -0
- package/src/routes/_nav/ai-providers/new/route.lazy.tsx +31 -0
- package/src/routes/_nav/ai-providers/new/route.tsx +20 -0
- package/src/routes/_nav/ai-providers/route.lazy.tsx +26 -0
- package/src/routes/_nav/ai-providers/route.tsx +31 -0
- package/src/routes/_nav/apps/route.lazy.tsx +30 -0
- package/src/routes/_nav/apps/route.tsx +20 -0
- package/src/routes/_nav/audit/route.lazy.tsx +100 -0
- package/src/routes/_nav/audit/route.tsx +25 -0
- package/src/routes/_nav/billing/invoices/route.lazy.tsx +86 -0
- package/src/routes/_nav/billing/invoices/route.tsx +25 -0
- package/src/routes/_nav/billing/route.lazy.tsx +48 -0
- package/src/routes/_nav/billing/route.tsx +27 -0
- package/src/routes/_nav/billing/subscriptions/$subscriptionId/route.lazy.tsx +160 -0
- package/src/routes/_nav/billing/subscriptions/$subscriptionId/route.tsx +25 -0
- package/src/routes/_nav/billing/subscriptions/route.lazy.tsx +113 -0
- package/src/routes/_nav/billing/subscriptions/route.tsx +28 -0
- package/src/routes/_nav/compliance/route.lazy.tsx +184 -0
- package/src/routes/_nav/compliance/route.tsx +25 -0
- package/src/routes/_nav/components/route.lazy.tsx +184 -0
- package/src/routes/_nav/components/route.tsx +40 -0
- package/src/routes/_nav/dashboard/route.lazy.tsx +33 -0
- package/src/routes/_nav/dashboard/route.tsx +20 -0
- package/src/routes/_nav/dependencies/route.lazy.tsx +66 -0
- package/src/routes/_nav/dependencies/route.tsx +32 -0
- package/src/routes/_nav/developer-logs/route.lazy.tsx +6 -0
- package/src/routes/_nav/developer-logs/route.tsx +14 -0
- package/src/routes/_nav/docs/route.lazy.tsx +30 -0
- package/src/routes/_nav/docs/route.tsx +20 -0
- package/src/routes/_nav/knowledge-public/bookmarks/route.lazy.tsx +355 -0
- package/src/routes/_nav/knowledge-public/bookmarks/route.tsx +40 -0
- package/src/routes/_nav/knowledge-public/compliance/route.lazy.tsx +245 -0
- package/src/routes/_nav/knowledge-public/compliance/route.tsx +40 -0
- package/src/routes/_nav/knowledge-public/legal/route.lazy.tsx +242 -0
- package/src/routes/_nav/knowledge-public/legal/route.tsx +40 -0
- package/src/routes/_nav/licenses/$licenseId/route.lazy.tsx +235 -0
- package/src/routes/_nav/licenses/$licenseId/route.tsx +25 -0
- package/src/routes/_nav/licenses/new/route.lazy.tsx +237 -0
- package/src/routes/_nav/licenses/route.lazy.tsx +143 -0
- package/src/routes/_nav/licenses/route.tsx +25 -0
- package/src/routes/_nav/market/route.lazy.tsx +31 -0
- package/src/routes/_nav/market/route.tsx +20 -0
- package/src/routes/_nav/monitoring/ai-usage/route.lazy.tsx +305 -0
- package/src/routes/_nav/monitoring/ai-usage/route.tsx +3 -0
- package/src/routes/_nav/monitoring/errors/route.lazy.tsx +111 -0
- package/src/routes/_nav/monitoring/health/route.lazy.tsx +260 -0
- package/src/routes/_nav/monitoring/logs/route.lazy.tsx +165 -0
- package/src/routes/_nav/monitoring/metrics/route.lazy.tsx +240 -0
- package/src/routes/_nav/monitoring/overview/route.lazy.tsx +138 -0
- package/src/routes/_nav/monitoring/route.lazy.tsx +68 -0
- package/src/routes/_nav/monitoring/route.tsx +20 -0
- package/src/routes/_nav/monitoring/sentry/route.lazy.tsx +399 -0
- package/src/routes/_nav/monitoring/sentry/route.tsx +20 -0
- package/src/routes/_nav/permissions/route.lazy.tsx +504 -0
- package/src/routes/_nav/permissions/route.tsx +26 -0
- package/src/routes/_nav/route.lazy.tsx +31 -0
- package/src/routes/_nav/route.tsx +34 -0
- package/src/routes/_nav/settings/route.lazy.tsx +30 -0
- package/src/routes/_nav/settings/route.tsx +20 -0
- package/src/routes/_nav/sso/route.lazy.tsx +145 -0
- package/src/routes/_nav/sso/route.tsx +25 -0
- package/src/routes/_nav/users/$userId/route.lazy.tsx +432 -0
- package/src/routes/_nav/users/$userId/route.tsx +27 -0
- package/src/routes/_nav/users/-components/users-table.tsx +38 -0
- package/src/routes/_nav/users/route.lazy.tsx +74 -0
- package/src/routes/_nav/users/route.tsx +43 -0
- package/src/routes/api/compliance-download/$jobId.ts +35 -0
- package/src/routes/api/observability/errors.ts +41 -0
- package/src/routes/api/observability/health.ts +30 -0
- package/src/routes/api.auth.$.ts +31 -0
- package/src/routes/api.sentry-test.ts +33 -0
- package/src/routes/index/route.lazy.tsx +22 -0
- package/src/routes/index/route.tsx +24 -0
- package/src/routes/login/route.lazy.tsx +46 -0
- package/src/routes/login/route.tsx +28 -0
- package/src/routes/sentry-tunnel.ts +61 -0
- package/src/routes/setup/route.lazy.tsx +94 -0
- package/src/routes/setup/route.tsx +28 -0
- package/src/server/__tests__/admin-ai-alerts.test.ts +113 -0
- package/src/server/__tests__/licenses-issue.test.ts +98 -0
- package/src/server/admin-ai-alerts.ts +177 -0
- package/src/server/admin-ai-usage.ts +134 -0
- package/src/server/auth-helpers.ts +51 -0
- package/src/server/entitlements.ts +24 -0
- package/src/server/knowledge-public/canonical-id.ts +55 -0
- package/src/server/knowledge-public/github-sync.ts +86 -0
- package/src/server/license-issue.ts +78 -0
- package/src/server/middlewares/request.ts +26 -0
- package/src/server/observability/debug-middleware.ts +39 -0
- package/src/server/observability/error-reporter.ts +99 -0
- package/src/server/observability/health-check.ts +140 -0
- package/src/server/observability/index.ts +33 -0
- package/src/server/observability/logger.ts +49 -0
- package/src/server/observability/metrics.ts +105 -0
- package/src/server/observability/request-middleware.ts +125 -0
- package/src/server/observability/timing.ts +42 -0
- package/src/server/pro-services.ts +32 -0
- package/src/server/serverFns/admin-billing.ts +94 -0
- package/src/server/serverFns/ai-provider/create.ts +92 -0
- package/src/server/serverFns/ai-provider/delete.ts +42 -0
- package/src/server/serverFns/ai-provider/get.ts +35 -0
- package/src/server/serverFns/ai-provider/list.ts +92 -0
- package/src/server/serverFns/ai-provider/update.ts +73 -0
- package/src/server/serverFns/anyhub/market.ts +64 -0
- package/src/server/serverFns/audit.ts +57 -0
- package/src/server/serverFns/auth/session.ts +26 -0
- package/src/server/serverFns/auth/setup.ts +73 -0
- package/src/server/serverFns/compliance.ts +85 -0
- package/src/server/serverFns/components/import.ts +197 -0
- package/src/server/serverFns/components/list.ts +51 -0
- package/src/server/serverFns/components/mutations.ts +84 -0
- package/src/server/serverFns/dependency/helpers.ts +245 -0
- package/src/server/serverFns/dependency/list.ts +127 -0
- package/src/server/serverFns/dependency/sync.ts +123 -0
- package/src/server/serverFns/dependency/update.ts +51 -0
- package/src/server/serverFns/developer-log-auto-generate.ts +176 -0
- package/src/server/serverFns/developer-logs.ts +132 -0
- package/src/server/serverFns/knowledge-public/bookmarks/create.ts +53 -0
- package/src/server/serverFns/knowledge-public/bookmarks/delete.ts +31 -0
- package/src/server/serverFns/knowledge-public/bookmarks/detail.ts +32 -0
- package/src/server/serverFns/knowledge-public/bookmarks/list.ts +66 -0
- package/src/server/serverFns/knowledge-public/bookmarks/sync-all-github.ts +56 -0
- package/src/server/serverFns/knowledge-public/bookmarks/update.ts +50 -0
- package/src/server/serverFns/knowledge-public/compliance/create.ts +58 -0
- package/src/server/serverFns/knowledge-public/compliance/delete.ts +31 -0
- package/src/server/serverFns/knowledge-public/compliance/detail.ts +32 -0
- package/src/server/serverFns/knowledge-public/compliance/list.ts +59 -0
- package/src/server/serverFns/knowledge-public/compliance/update.ts +53 -0
- package/src/server/serverFns/knowledge-public/legal/create.ts +62 -0
- package/src/server/serverFns/knowledge-public/legal/delete.ts +31 -0
- package/src/server/serverFns/knowledge-public/legal/detail.ts +32 -0
- package/src/server/serverFns/knowledge-public/legal/list.ts +60 -0
- package/src/server/serverFns/knowledge-public/legal/update.ts +55 -0
- package/src/server/serverFns/licenses.ts +92 -0
- package/src/server/serverFns/locale.ts +10 -0
- package/src/server/serverFns/monitoring/check-ai-alerts.ts +13 -0
- package/src/server/serverFns/monitoring/get-ai-usage-overview.ts +25 -0
- package/src/server/serverFns/monitoring/get-errors.ts +43 -0
- package/src/server/serverFns/monitoring/get-health-settings.ts +35 -0
- package/src/server/serverFns/monitoring/get-health.ts +55 -0
- package/src/server/serverFns/monitoring/get-metrics.ts +46 -0
- package/src/server/serverFns/monitoring/get-request-logs.ts +66 -0
- package/src/server/serverFns/monitoring/report-error.ts +55 -0
- package/src/server/serverFns/monitoring/reset.ts +70 -0
- package/src/server/serverFns/permissions.ts +214 -0
- package/src/server/serverFns/sentry/config.ts +88 -0
- package/src/server/serverFns/sso-config.ts +83 -0
- package/src/server/serverFns/user/create.ts +40 -0
- package/src/server/serverFns/user/delete.ts +29 -0
- package/src/server/serverFns/user/detail.ts +45 -0
- package/src/server/serverFns/user/list.ts +56 -0
- package/src/server/serverFns/user/reset-password.ts +28 -0
- package/src/server/serverFns/user/update-role.ts +26 -0
- package/src/server/serverFns/user/update-status.ts +26 -0
- package/src/server.ts +27 -0
- package/src/start.ts +85 -0
- package/src/styles/_keyframe-animations.scss +91 -0
- package/src/styles/_variables.scss +296 -0
- package/src/styles/globals.css +200 -0
- package/src/types/start-authjs.d.ts +27 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
columnVisibilityFeature,
|
|
20
|
+
createPaginatedRowModel,
|
|
21
|
+
rowPaginationFeature,
|
|
22
|
+
tableFeatures,
|
|
23
|
+
type ColumnDef,
|
|
24
|
+
} from "@tanstack/react-table"
|
|
25
|
+
import { CheckCircle2, ExternalLink, Loader2, RefreshCw } from "lucide-react"
|
|
26
|
+
import {
|
|
27
|
+
DEPENDENCY_CATEGORIES,
|
|
28
|
+
type ThirdPartyDependencyRow,
|
|
29
|
+
type VersionStatus,
|
|
30
|
+
} from "@allbluecn/kernel"
|
|
31
|
+
import { Badge } from "@/components/ui/badge"
|
|
32
|
+
import { Button } from "@/components/ui/button"
|
|
33
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
|
34
|
+
import { cn } from "@/lib/utils"
|
|
35
|
+
import { EditDependencyDialog } from "./edit-dialog"
|
|
36
|
+
import { ChangelogDialog } from "./changelog-dialog"
|
|
37
|
+
|
|
38
|
+
export const dependencyTableFeatures = tableFeatures({
|
|
39
|
+
columnVisibilityFeature,
|
|
40
|
+
rowPaginationFeature,
|
|
41
|
+
paginatedRowModel: createPaginatedRowModel(),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const STATUS_META: Record<VersionStatus, { className: string }> = {
|
|
45
|
+
latest: { className: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 border-emerald-500/30" },
|
|
46
|
+
patch: { className: "bg-sky-500/15 text-sky-700 dark:text-sky-400 border-sky-500/30" },
|
|
47
|
+
minor: { className: "bg-amber-500/15 text-amber-700 dark:text-amber-400 border-amber-500/30" },
|
|
48
|
+
major: { className: "bg-red-500/15 text-red-700 dark:text-red-400 border-red-500/30" },
|
|
49
|
+
unknown: { className: "bg-muted text-muted-foreground border-border" },
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getDependencyStatusLabel(t: (k: string) => string, status: VersionStatus) {
|
|
53
|
+
return t(`dependency.statusLabel.${status}`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getDependencyTimeAgo(t: (k: string, params?: Record<string, unknown>) => string, iso: string | null): string {
|
|
57
|
+
if (!iso) return "—"
|
|
58
|
+
const d = new Date(iso)
|
|
59
|
+
if (Number.isNaN(d.getTime())) return "—"
|
|
60
|
+
const diff = Date.now() - d.getTime()
|
|
61
|
+
const min = Math.floor(diff / 60000)
|
|
62
|
+
if (min < 1) return t('dependency.timeAgo.justNow')
|
|
63
|
+
if (min < 60) return t('dependency.timeAgo.minutes', { count: min })
|
|
64
|
+
const hr = Math.floor(min / 60)
|
|
65
|
+
if (hr < 24) return t('dependency.timeAgo.hours', { count: hr })
|
|
66
|
+
const day = Math.floor(hr / 24)
|
|
67
|
+
return t('dependency.timeAgo.days', { count: day })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface ColumnOpts {
|
|
71
|
+
syncing: Set<string>
|
|
72
|
+
onSync: (name: string) => void
|
|
73
|
+
onSaved: () => void
|
|
74
|
+
t: (k: string, params?: Record<string, unknown>) => string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function getDependencyColumns(opts: ColumnOpts): ColumnDef<typeof dependencyTableFeatures, ThirdPartyDependencyRow>[] {
|
|
78
|
+
const { syncing, onSync, onSaved, t } = opts
|
|
79
|
+
return [
|
|
80
|
+
{
|
|
81
|
+
accessorKey: "displayName",
|
|
82
|
+
header: t('dependency.table.columns.dependency'),
|
|
83
|
+
cell: ({ row }) => {
|
|
84
|
+
const r = row.original
|
|
85
|
+
return (
|
|
86
|
+
<div className="flex flex-col gap-0.5">
|
|
87
|
+
<div className="flex items-center gap-1.5">
|
|
88
|
+
<span className="font-medium">{r.displayName}</span>
|
|
89
|
+
{!r.isWatched && (
|
|
90
|
+
<Badge variant="outline" className="px-1 py-0 text-[10px] text-muted-foreground">
|
|
91
|
+
{t('dependency.table.cell.untracked')}
|
|
92
|
+
</Badge>
|
|
93
|
+
)}
|
|
94
|
+
{r.priority === "high" && r.isWatched && (
|
|
95
|
+
<Badge variant="outline" className={cn("px-1 py-0 text-[10px]", PRIORITY_BADGE.high)}>
|
|
96
|
+
{t('dependency.table.cell.highPriority')}
|
|
97
|
+
</Badge>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
101
|
+
<span>{r.name}</span>
|
|
102
|
+
{r.homepage && (
|
|
103
|
+
<a href={r.homepage} target="_blank" rel="noreferrer" className="hover:text-foreground">
|
|
104
|
+
<ExternalLink className="size-3" />
|
|
105
|
+
</a>
|
|
106
|
+
)}
|
|
107
|
+
<a href={r.npmUrl} target="_blank" rel="noreferrer" className="hover:text-foreground">
|
|
108
|
+
<span className="font-mono text-[10px]">npm</span>
|
|
109
|
+
</a>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
accessorKey: "description",
|
|
117
|
+
header: t('dependency.table.columns.description'),
|
|
118
|
+
cell: ({ row }) => {
|
|
119
|
+
const desc = row.original.description
|
|
120
|
+
if (!desc) return <span className="text-xs text-muted-foreground">—</span>
|
|
121
|
+
return (
|
|
122
|
+
<Tooltip>
|
|
123
|
+
<TooltipTrigger asChild>
|
|
124
|
+
<span className="line-clamp-2 max-w-[280px] text-xs text-muted-foreground">
|
|
125
|
+
{desc}
|
|
126
|
+
</span>
|
|
127
|
+
</TooltipTrigger>
|
|
128
|
+
<TooltipContent className="max-w-sm">{desc}</TooltipContent>
|
|
129
|
+
</Tooltip>
|
|
130
|
+
)
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
accessorKey: "category",
|
|
135
|
+
header: t('dependency.table.columns.category'),
|
|
136
|
+
cell: ({ row }) => (
|
|
137
|
+
<Badge variant="outline" className="font-normal">
|
|
138
|
+
{DEPENDENCY_CATEGORIES.find((c) => c.value === row.original.category)?.label ?? row.original.category}
|
|
139
|
+
</Badge>
|
|
140
|
+
),
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
accessorKey: "currentVersion",
|
|
144
|
+
header: t('dependency.table.columns.currentVersion'),
|
|
145
|
+
cell: ({ row }) => {
|
|
146
|
+
const r = row.original
|
|
147
|
+
if (!r.currentVersion) return <span className="text-xs text-muted-foreground">{t('dependency.table.cell.notInstalled')}</span>
|
|
148
|
+
if (r.usages.length > 1) {
|
|
149
|
+
return (
|
|
150
|
+
<Tooltip>
|
|
151
|
+
<TooltipTrigger asChild>
|
|
152
|
+
<span className="cursor-help border-b border-dashed border-muted-foreground/50">
|
|
153
|
+
{r.currentVersion}
|
|
154
|
+
</span>
|
|
155
|
+
</TooltipTrigger>
|
|
156
|
+
<TooltipContent>
|
|
157
|
+
<div className="space-y-0.5 text-xs">
|
|
158
|
+
{r.usages.map((u) => (
|
|
159
|
+
<div key={u.app}>
|
|
160
|
+
<span className="font-mono">{u.app}</span>: {u.declaredRange}
|
|
161
|
+
</div>
|
|
162
|
+
))}
|
|
163
|
+
</div>
|
|
164
|
+
</TooltipContent>
|
|
165
|
+
</Tooltip>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
return <span className="font-mono text-xs">{r.currentVersion}</span>
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
accessorKey: "latestVersion",
|
|
173
|
+
header: t('dependency.table.columns.latestVersion'),
|
|
174
|
+
cell: ({ row }) =>
|
|
175
|
+
row.original.latestVersion ?? <span className="text-muted-foreground">—</span>,
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: "changelog",
|
|
179
|
+
header: t('dependency.table.columns.changelog'),
|
|
180
|
+
cell: ({ row }) => <ChangelogDialog row={row.original} />,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
accessorKey: "versionStatus",
|
|
184
|
+
header: t('dependency.table.columns.status'),
|
|
185
|
+
cell: ({ row }) => {
|
|
186
|
+
const status = row.original.versionStatus
|
|
187
|
+
return (
|
|
188
|
+
<Badge variant="outline" className={cn("font-normal", STATUS_META[status].className)}>
|
|
189
|
+
{status === "latest" && <CheckCircle2 className="mr-1 size-3" />}
|
|
190
|
+
{getDependencyStatusLabel(t, status)}
|
|
191
|
+
</Badge>
|
|
192
|
+
)
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
accessorKey: "lastSyncAt",
|
|
197
|
+
header: t('dependency.table.columns.lastSync'),
|
|
198
|
+
cell: ({ row }) => (
|
|
199
|
+
<span className="text-xs text-muted-foreground">{getDependencyTimeAgo(t, row.original.lastSyncAt)}</span>
|
|
200
|
+
),
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: "actions",
|
|
204
|
+
header: () => <div className="text-right">{t('dependency.table.columns.actions')}</div>,
|
|
205
|
+
cell: ({ row }) => {
|
|
206
|
+
const r = row.original
|
|
207
|
+
const isSyncing = syncing.has(r.name)
|
|
208
|
+
return (
|
|
209
|
+
<div className="flex items-center justify-end gap-1">
|
|
210
|
+
<Button
|
|
211
|
+
size="sm"
|
|
212
|
+
variant="ghost"
|
|
213
|
+
className="size-7 p-0"
|
|
214
|
+
onClick={() => onSync(r.name)}
|
|
215
|
+
disabled={isSyncing}
|
|
216
|
+
title={t('dependency.table.cell.syncTooltip')}
|
|
217
|
+
>
|
|
218
|
+
{isSyncing ? <Loader2 className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
|
|
219
|
+
</Button>
|
|
220
|
+
<EditDependencyDialog row={r} onSaved={onSaved} />
|
|
221
|
+
</div>
|
|
222
|
+
)
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
]
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const PRIORITY_BADGE: Record<string, string> = {
|
|
229
|
+
high: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-400",
|
|
230
|
+
normal: "border-border bg-muted text-muted-foreground",
|
|
231
|
+
low: "border-border bg-transparent text-muted-foreground",
|
|
232
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { useMemo, useState } from "react"
|
|
19
|
+
import { useTranslation } from 'react-i18next'
|
|
20
|
+
import {
|
|
21
|
+
flexRender,
|
|
22
|
+
useTable,
|
|
23
|
+
type PaginationState,
|
|
24
|
+
} from "@tanstack/react-table"
|
|
25
|
+
import { toast } from '@/components/ui/sonner'
|
|
26
|
+
import { RefreshCw, Search, TriangleAlert, Loader2 } from "lucide-react"
|
|
27
|
+
import {
|
|
28
|
+
DEPENDENCY_CATEGORIES,
|
|
29
|
+
type DependencyCategory,
|
|
30
|
+
type ThirdPartyDependencyRow,
|
|
31
|
+
type VersionStatus,
|
|
32
|
+
} from "@allbluecn/kernel"
|
|
33
|
+
import { syncAllDependenciesFn, syncDependencyFn } from "@/server/serverFns/dependency/sync"
|
|
34
|
+
import { Badge } from "@/components/ui/badge"
|
|
35
|
+
import { Button } from "@/components/ui/button"
|
|
36
|
+
import { Input } from "@/components/ui/input"
|
|
37
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
38
|
+
import {
|
|
39
|
+
Table,
|
|
40
|
+
TableBody,
|
|
41
|
+
TableCell,
|
|
42
|
+
TableHead,
|
|
43
|
+
TableHeader,
|
|
44
|
+
TableRow,
|
|
45
|
+
} from "@/components/ui/table"
|
|
46
|
+
import {
|
|
47
|
+
Pagination,
|
|
48
|
+
PaginationContent,
|
|
49
|
+
PaginationEllipsis,
|
|
50
|
+
PaginationItem,
|
|
51
|
+
PaginationLink,
|
|
52
|
+
PaginationNext,
|
|
53
|
+
PaginationPrevious,
|
|
54
|
+
} from "@/components/ui/pagination"
|
|
55
|
+
import { cn } from "@/lib/utils"
|
|
56
|
+
import { dependencyTableFeatures, getDependencyColumns } from "./columns"
|
|
57
|
+
|
|
58
|
+
const PAGE_SIZE = 15
|
|
59
|
+
|
|
60
|
+
const STATUS_FILTERS: Array<{ value: "all" | VersionStatus }> = [
|
|
61
|
+
{ value: "all" },
|
|
62
|
+
{ value: "latest" },
|
|
63
|
+
{ value: "patch" },
|
|
64
|
+
{ value: "minor" },
|
|
65
|
+
{ value: "major" },
|
|
66
|
+
{ value: "unknown" },
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
export function DependencyTable({
|
|
70
|
+
data,
|
|
71
|
+
onMutated,
|
|
72
|
+
}: {
|
|
73
|
+
data: ThirdPartyDependencyRow[]
|
|
74
|
+
onMutated?: () => void
|
|
75
|
+
}) {
|
|
76
|
+
const { t } = useTranslation('common')
|
|
77
|
+
const [search, setSearch] = useState("")
|
|
78
|
+
const [categoryFilter, setCategoryFilter] = useState<"all" | DependencyCategory>("all")
|
|
79
|
+
const [statusFilter, setStatusFilter] = useState<"all" | VersionStatus>("all")
|
|
80
|
+
const [viewFilter, setViewFilter] = useState<"watched" | "all">("watched")
|
|
81
|
+
const [syncing, setSyncing] = useState<Set<string>>(new Set())
|
|
82
|
+
const [syncingAll, setSyncingAll] = useState(false)
|
|
83
|
+
const [{ pageIndex }, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: PAGE_SIZE })
|
|
84
|
+
|
|
85
|
+
// 外部筛选(搜索 + 分类 + 状态 + 视图),得到 filtered 后交给 TanStack Table 分页
|
|
86
|
+
const filtered = useMemo(() => {
|
|
87
|
+
const lower = search.trim().toLowerCase()
|
|
88
|
+
return data.filter((r) => {
|
|
89
|
+
if (viewFilter === "watched" && !r.isWatched) return false
|
|
90
|
+
if (categoryFilter !== "all" && r.category !== categoryFilter) return false
|
|
91
|
+
if (statusFilter !== "all" && r.versionStatus !== statusFilter) return false
|
|
92
|
+
if (
|
|
93
|
+
lower &&
|
|
94
|
+
!r.name.toLowerCase().includes(lower) &&
|
|
95
|
+
!r.displayName.toLowerCase().includes(lower)
|
|
96
|
+
)
|
|
97
|
+
return false
|
|
98
|
+
return true
|
|
99
|
+
})
|
|
100
|
+
}, [data, search, categoryFilter, statusFilter, viewFilter])
|
|
101
|
+
|
|
102
|
+
const columns = useMemo(
|
|
103
|
+
() => getDependencyColumns({ syncing, onSync: handleSync, onSaved: () => onMutated?.(), t }),
|
|
104
|
+
[syncing, onMutated, t],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const table = useTable({
|
|
108
|
+
features: dependencyTableFeatures,
|
|
109
|
+
data: filtered,
|
|
110
|
+
columns,
|
|
111
|
+
state: { pagination: { pageIndex, pageSize: PAGE_SIZE } },
|
|
112
|
+
onPaginationChange: setPagination,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// 筛选条件变化后,若当前页超出范围,回到第一页
|
|
116
|
+
const pageCount = table.getPageCount()
|
|
117
|
+
if (pageIndex >= pageCount && pageIndex > 0) {
|
|
118
|
+
setPagination({ pageIndex: 0, pageSize: PAGE_SIZE })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const watchedCount = data.filter((r) => r.isWatched).length
|
|
122
|
+
const outdatedCount = data.filter(
|
|
123
|
+
(r) => r.versionStatus === "major" || r.versionStatus === "minor",
|
|
124
|
+
).length
|
|
125
|
+
|
|
126
|
+
async function handleSync(name: string) {
|
|
127
|
+
setSyncing((s) => new Set(s).add(name))
|
|
128
|
+
try {
|
|
129
|
+
const res = await syncDependencyFn({ data: { name } })
|
|
130
|
+
if (res.success) {
|
|
131
|
+
toast.success(t('dependency.table.toast.updated', { name, version: res.latestVersion ?? "?" }))
|
|
132
|
+
onMutated?.()
|
|
133
|
+
} else {
|
|
134
|
+
toast.error(t('dependency.table.toast.syncFailed', { name, error: res.error ?? t('dependency.table.toast.unknown') }))
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
setSyncing((s) => {
|
|
138
|
+
const next = new Set(s)
|
|
139
|
+
next.delete(name)
|
|
140
|
+
return next
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function handleSyncAll() {
|
|
146
|
+
const names = filtered.map((r) => r.name)
|
|
147
|
+
if (names.length === 0) {
|
|
148
|
+
toast.info(t('dependency.table.toast.noSyncable'))
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
setSyncingAll(true)
|
|
152
|
+
try {
|
|
153
|
+
const results = await syncAllDependenciesFn({ data: { names } })
|
|
154
|
+
const ok = results.filter((r) => r.success)
|
|
155
|
+
const fail = results.filter((r) => !r.success)
|
|
156
|
+
if (ok.length) toast.success(t('dependency.table.toast.syncSuccess', { count: ok.length }))
|
|
157
|
+
if (fail.length)
|
|
158
|
+
toast.error(t('dependency.table.toast.syncFailedCount', { count: fail.length }), {
|
|
159
|
+
description: fail.map((f) => `${f.name}: ${f.error}`).slice(0, 3).join("\n"),
|
|
160
|
+
})
|
|
161
|
+
onMutated?.()
|
|
162
|
+
} finally {
|
|
163
|
+
setSyncingAll(false)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const currentPage = pageIndex + 1
|
|
168
|
+
const totalPages = pageCount
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<div className="space-y-4">
|
|
172
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
173
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
174
|
+
<Badge variant="outline">{t('dependency.table.stats.watched', { watchedCount })}</Badge>
|
|
175
|
+
<Badge variant="outline" className="border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400">
|
|
176
|
+
<TriangleAlert className="mr-1 size-3" />
|
|
177
|
+
{t('dependency.table.stats.outdated', { outdatedCount })}
|
|
178
|
+
</Badge>
|
|
179
|
+
</div>
|
|
180
|
+
<div className="ml-auto flex items-center gap-2">
|
|
181
|
+
<Button onClick={handleSyncAll} disabled={syncingAll} variant="outline" size="sm">
|
|
182
|
+
{syncingAll ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : <RefreshCw className="mr-1 size-3.5" />}
|
|
183
|
+
{t('dependency.table.syncAll', { count: filtered.length })}
|
|
184
|
+
</Button>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
|
189
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
190
|
+
<CategoryButton active={categoryFilter === "all"} onClick={() => setCategoryFilter("all")}>
|
|
191
|
+
{t('dependency.table.allCategory')}
|
|
192
|
+
</CategoryButton>
|
|
193
|
+
{DEPENDENCY_CATEGORIES.map((c) => (
|
|
194
|
+
<CategoryButton
|
|
195
|
+
key={c.value}
|
|
196
|
+
active={categoryFilter === c.value}
|
|
197
|
+
onClick={() => setCategoryFilter(c.value)}
|
|
198
|
+
>
|
|
199
|
+
{c.label}
|
|
200
|
+
</CategoryButton>
|
|
201
|
+
))}
|
|
202
|
+
</div>
|
|
203
|
+
<div className="ml-auto flex flex-wrap items-center gap-1.5">
|
|
204
|
+
{STATUS_FILTERS.map((s) => (
|
|
205
|
+
<CategoryButton
|
|
206
|
+
key={s.value}
|
|
207
|
+
active={statusFilter === s.value}
|
|
208
|
+
onClick={() => setStatusFilter(s.value)}
|
|
209
|
+
>
|
|
210
|
+
{t(`dependency.table.statusFilter.${s.value}`)}
|
|
211
|
+
</CategoryButton>
|
|
212
|
+
))}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
217
|
+
<div className="relative">
|
|
218
|
+
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
219
|
+
<Input
|
|
220
|
+
value={search}
|
|
221
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
222
|
+
placeholder={t('dependency.table.searchPlaceholder')}
|
|
223
|
+
className="h-9 w-56 pl-8"
|
|
224
|
+
/>
|
|
225
|
+
</div>
|
|
226
|
+
<Select value={viewFilter} onValueChange={(v) => setViewFilter(v as "watched" | "all")}>
|
|
227
|
+
<SelectTrigger className="h-9 w-32" size="sm">
|
|
228
|
+
<SelectValue />
|
|
229
|
+
</SelectTrigger>
|
|
230
|
+
<SelectContent>
|
|
231
|
+
<SelectItem value="watched">{t('dependency.table.viewFilter.watched')}</SelectItem>
|
|
232
|
+
<SelectItem value="all">{t('dependency.table.viewFilter.all')}</SelectItem>
|
|
233
|
+
</SelectContent>
|
|
234
|
+
</Select>
|
|
235
|
+
<span className="ml-auto text-xs text-muted-foreground">
|
|
236
|
+
{t('pagination.total', { total: filtered.length, page: currentPage, totalPages })}
|
|
237
|
+
</span>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<div className="rounded-lg border">
|
|
241
|
+
<Table>
|
|
242
|
+
<TableHeader>
|
|
243
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
244
|
+
<TableRow key={headerGroup.id}>
|
|
245
|
+
{headerGroup.headers.map((header) => (
|
|
246
|
+
<TableHead key={header.id} className={header.id === "actions" ? "text-right" : undefined}>
|
|
247
|
+
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
|
248
|
+
</TableHead>
|
|
249
|
+
))}
|
|
250
|
+
</TableRow>
|
|
251
|
+
))}
|
|
252
|
+
</TableHeader>
|
|
253
|
+
<TableBody>
|
|
254
|
+
{table.getRowModel().rows.length ? (
|
|
255
|
+
table.getRowModel().rows.map((row) => (
|
|
256
|
+
<TableRow key={row.id}>
|
|
257
|
+
{row.getVisibleCells().map((cell) => (
|
|
258
|
+
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
|
259
|
+
))}
|
|
260
|
+
</TableRow>
|
|
261
|
+
))
|
|
262
|
+
) : (
|
|
263
|
+
<TableRow>
|
|
264
|
+
<TableCell colSpan={columns.length} className="py-10 text-center text-muted-foreground">
|
|
265
|
+
{t('dependency.table.empty')}
|
|
266
|
+
</TableCell>
|
|
267
|
+
</TableRow>
|
|
268
|
+
)}
|
|
269
|
+
</TableBody>
|
|
270
|
+
</Table>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
{totalPages > 1 && (
|
|
274
|
+
<Pagination className="mx-0 justify-end">
|
|
275
|
+
<PaginationContent>
|
|
276
|
+
<PaginationItem>
|
|
277
|
+
<PaginationPrevious
|
|
278
|
+
text={t('pagination.prev')}
|
|
279
|
+
href="#"
|
|
280
|
+
onClick={(e) => {
|
|
281
|
+
e.preventDefault()
|
|
282
|
+
table.previousPage()
|
|
283
|
+
}}
|
|
284
|
+
aria-disabled={!table.getCanPreviousPage()}
|
|
285
|
+
className={cn(!table.getCanPreviousPage() && "pointer-events-none opacity-50")}
|
|
286
|
+
/>
|
|
287
|
+
</PaginationItem>
|
|
288
|
+
{renderPageItems(currentPage, totalPages, (p) => table.setPageIndex(p - 1))}
|
|
289
|
+
<PaginationItem>
|
|
290
|
+
<PaginationNext
|
|
291
|
+
text={t('pagination.next')}
|
|
292
|
+
href="#"
|
|
293
|
+
onClick={(e) => {
|
|
294
|
+
e.preventDefault()
|
|
295
|
+
table.nextPage()
|
|
296
|
+
}}
|
|
297
|
+
aria-disabled={!table.getCanNextPage()}
|
|
298
|
+
className={cn(!table.getCanNextPage() && "pointer-events-none opacity-50")}
|
|
299
|
+
/>
|
|
300
|
+
</PaginationItem>
|
|
301
|
+
</PaginationContent>
|
|
302
|
+
</Pagination>
|
|
303
|
+
)}
|
|
304
|
+
</div>
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function CategoryButton({
|
|
309
|
+
active,
|
|
310
|
+
onClick,
|
|
311
|
+
children,
|
|
312
|
+
}: {
|
|
313
|
+
active: boolean
|
|
314
|
+
onClick: () => void
|
|
315
|
+
children: React.ReactNode
|
|
316
|
+
}) {
|
|
317
|
+
return (
|
|
318
|
+
<button
|
|
319
|
+
type="button"
|
|
320
|
+
onClick={onClick}
|
|
321
|
+
className={cn(
|
|
322
|
+
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
|
323
|
+
active
|
|
324
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
325
|
+
: "border-border bg-transparent text-muted-foreground hover:bg-accent hover:text-foreground",
|
|
326
|
+
)}
|
|
327
|
+
>
|
|
328
|
+
{children}
|
|
329
|
+
</button>
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function renderPageItems(
|
|
334
|
+
current: number,
|
|
335
|
+
total: number,
|
|
336
|
+
setPageIndex: (zeroBased: number) => void,
|
|
337
|
+
): React.ReactNode[] {
|
|
338
|
+
const items: React.ReactNode[] = []
|
|
339
|
+
const push = (p: number) =>
|
|
340
|
+
items.push(
|
|
341
|
+
<PaginationItem key={p}>
|
|
342
|
+
<PaginationLink
|
|
343
|
+
href="#"
|
|
344
|
+
isActive={p === current}
|
|
345
|
+
onClick={(e) => {
|
|
346
|
+
e.preventDefault()
|
|
347
|
+
setPageIndex(p - 1)
|
|
348
|
+
}}
|
|
349
|
+
>
|
|
350
|
+
{p}
|
|
351
|
+
</PaginationLink>
|
|
352
|
+
</PaginationItem>,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if (total <= 7) {
|
|
356
|
+
for (let i = 1; i <= total; i++) push(i)
|
|
357
|
+
return items
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
push(1)
|
|
361
|
+
if (current > 3) items.push(<PaginationEllipsis key="start-ellipsis" />)
|
|
362
|
+
const start = Math.max(2, current - 1)
|
|
363
|
+
const end = Math.min(total - 1, current + 1)
|
|
364
|
+
for (let i = start; i <= end; i++) push(i)
|
|
365
|
+
if (current < total - 2) items.push(<PaginationEllipsis key="end-ellipsis" />)
|
|
366
|
+
push(total)
|
|
367
|
+
return items
|
|
368
|
+
}
|