@withpotter/pages-admin 0.13.0-next.78 → 0.13.0-next.80
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/dist/admin/_overview/control-room.js +47 -3
- package/dist/admin/_overview/control-room.js.map +1 -1
- package/dist/admin/_shell/nav-config.js.map +1 -1
- package/dist/admin/_shell/nav-filter.js +4 -2
- package/dist/admin/_shell/nav-filter.js.map +1 -1
- package/dist/admin/_shell/nav-group.js +10 -3
- package/dist/admin/_shell/nav-group.js.map +1 -1
- package/dist/admin/_shell/nav-row.js +5 -2
- package/dist/admin/_shell/nav-row.js.map +1 -1
- package/dist/admin/_shell/sidebar-footer.js +20 -0
- package/dist/admin/_shell/sidebar-footer.js.map +1 -1
- package/dist/admin/_shell/sidebar-footer.module.css +17 -0
- package/dist/admin/_shell/sidebar.js +8 -2
- package/dist/admin/_shell/sidebar.js.map +1 -1
- package/dist/admin/_shell/sidebar.module.css +10 -2
- package/dist/admin/_shell/use-fluid-hover.js +37 -6
- package/dist/admin/_shell/use-fluid-hover.js.map +1 -1
- package/dist/admin/_shell/use-open-groups.js +53 -9
- package/dist/admin/_shell/use-open-groups.js.map +1 -1
- package/dist/admin/layout.js +8 -2
- package/dist/admin/layout.js.map +1 -1
- package/dist/admin/page.js +8 -1
- package/dist/admin/page.js.map +1 -1
- package/package.json +4 -4
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { CountUp } from "../_fx";
|
|
4
|
-
import { StatCard } from "../_ui";
|
|
4
|
+
import { MoneyLines, StatCard, formatMoney } from "../_ui";
|
|
5
|
+
import { usePlatformRevenue } from "../hooks";
|
|
5
6
|
import { useModuleOn } from "../_shell/modules-context";
|
|
6
7
|
import styles from "./control-room.module.css";
|
|
7
8
|
import { useWaitingCases } from "./use-waiting-cases";
|
|
@@ -20,9 +21,24 @@ function VerificationCard() {
|
|
|
20
21
|
}
|
|
21
22
|
);
|
|
22
23
|
}
|
|
24
|
+
function SubscriptionsCard() {
|
|
25
|
+
const revenue = usePlatformRevenue().data;
|
|
26
|
+
if (!revenue?.recurring.tracked) return null;
|
|
27
|
+
return /* @__PURE__ */ jsx(
|
|
28
|
+
StatCard,
|
|
29
|
+
{
|
|
30
|
+
compact: true,
|
|
31
|
+
label: "Subscriptions",
|
|
32
|
+
value: formatMoney(revenue.recurring.mrr, revenue.currency),
|
|
33
|
+
sub: "Monthly recurring revenue",
|
|
34
|
+
href: "/admin/revenue"
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
}
|
|
23
38
|
function ControlRoom({
|
|
24
39
|
attention,
|
|
25
|
-
currency = ""
|
|
40
|
+
currency = "",
|
|
41
|
+
totals
|
|
26
42
|
}) {
|
|
27
43
|
const failed = attention.failedSettlements.count;
|
|
28
44
|
const overdue = attention.overdueInvoices.count;
|
|
@@ -53,7 +69,35 @@ function ControlRoom({
|
|
|
53
69
|
alert: overdueUnknown || overdue > 0
|
|
54
70
|
}
|
|
55
71
|
),
|
|
56
|
-
verification && /* @__PURE__ */ jsx(VerificationCard, {})
|
|
72
|
+
verification && /* @__PURE__ */ jsx(VerificationCard, {}),
|
|
73
|
+
totals?.verifiedWallets !== void 0 && /* @__PURE__ */ jsx(
|
|
74
|
+
StatCard,
|
|
75
|
+
{
|
|
76
|
+
compact: true,
|
|
77
|
+
label: "Verified wallets",
|
|
78
|
+
value: /* @__PURE__ */ jsx(CountUp, { value: totals.verifiedWallets }),
|
|
79
|
+
sub: "Businesses that passed KYC or KYB"
|
|
80
|
+
}
|
|
81
|
+
),
|
|
82
|
+
totals?.gatewayFeesByCurrency !== void 0 && /* @__PURE__ */ jsx(
|
|
83
|
+
StatCard,
|
|
84
|
+
{
|
|
85
|
+
compact: true,
|
|
86
|
+
label: "Gateway revenue",
|
|
87
|
+
value: /* @__PURE__ */ jsx(
|
|
88
|
+
MoneyLines,
|
|
89
|
+
{
|
|
90
|
+
rows: totals.gatewayFeesByCurrency,
|
|
91
|
+
currency,
|
|
92
|
+
amount: (row) => row.amount,
|
|
93
|
+
empty: "No fees yet"
|
|
94
|
+
}
|
|
95
|
+
),
|
|
96
|
+
sub: "Fees on card payments",
|
|
97
|
+
href: "/admin/revenue"
|
|
98
|
+
}
|
|
99
|
+
),
|
|
100
|
+
/* @__PURE__ */ jsx(SubscriptionsCard, {})
|
|
57
101
|
] });
|
|
58
102
|
}
|
|
59
103
|
export {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/_overview/control-room.tsx"],"sourcesContent":["'use client';\n\nimport type { PlatformCockpit } from '@withpotter/lib/admin-api';\nimport { CountUp } from '../_fx';\nimport { StatCard } from '../_ui';\nimport { useModuleOn } from '../_shell/modules-context';\nimport styles from './control-room.module.css';\nimport { useWaitingCases } from './use-waiting-cases';\nimport { overdueOutstanding } from './overdue-outstanding';\n\n/** KYC / KYB waiting count; mounted only where Verification is served. */\nfunction VerificationCard() {\n const waiting = useWaitingCases().data;\n\n return (\n <StatCard\n compact\n label=\"KYC / KYB decisions\"\n value={waiting === undefined ? '–' : <CountUp value={waiting} />}\n sub=\"Businesses waiting for review\"\n href=\"/admin/verification\"\n alert={!!waiting}\n />\n );\n}\n\n/**\n * The control room: every queue that needs a decision, as one row of
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/_overview/control-room.tsx"],"sourcesContent":["'use client';\n\nimport type { PlatformCockpit } from '@withpotter/lib/admin-api';\nimport { CountUp } from '../_fx';\nimport { MoneyLines, StatCard, formatMoney } from '../_ui';\nimport { usePlatformRevenue } from '../hooks';\nimport { useModuleOn } from '../_shell/modules-context';\nimport styles from './control-room.module.css';\nimport { useWaitingCases } from './use-waiting-cases';\nimport { overdueOutstanding } from './overdue-outstanding';\n\n/** KYC / KYB waiting count; mounted only where Verification is served. */\nfunction VerificationCard() {\n const waiting = useWaitingCases().data;\n\n return (\n <StatCard\n compact\n label=\"KYC / KYB decisions\"\n value={waiting === undefined ? '–' : <CountUp value={waiting} />}\n sub=\"Businesses waiting for review\"\n href=\"/admin/verification\"\n alert={!!waiting}\n />\n );\n}\n\n/** Monthly recurring revenue; shown only where billing feeds the figure, never as a made-up zero. */\nfunction SubscriptionsCard() {\n const revenue = usePlatformRevenue().data;\n\n if (!revenue?.recurring.tracked) return null;\n\n return (\n <StatCard\n compact\n label=\"Subscriptions\"\n value={formatMoney(revenue.recurring.mrr, revenue.currency)}\n sub=\"Monthly recurring revenue\"\n href=\"/admin/revenue\"\n />\n );\n}\n\n/**\n * The control room: every queue that needs a decision, then the revenue and\n * wallet figures, as one row of identical metric cards that wraps. A card opens its screen when this Box\n * serves that screen; a queue whose module is off is not shown.\n */\nexport function ControlRoom({\n attention,\n currency = '',\n totals,\n}: {\n attention: PlatformCockpit['attention'];\n /** Lifetime figures; a card whose figure the api does not send is left out. */\n totals?: PlatformCockpit['totals'];\n /** The currency filter's choice; narrows what overdue invoices owe. */\n currency?: string;\n}) {\n const failed = attention.failedSettlements.count;\n const overdue = attention.overdueInvoices.count;\n // A feed that failed to load is unknown, not zero.\n const failedUnknown = !!attention.unavailable?.includes('failedSettlements');\n const overdueUnknown = !!attention.unavailable?.includes('overdueInvoices');\n const withdrawals = useModuleOn('withdrawals');\n const verification = useModuleOn('verification');\n\n return (\n <section className={styles.room} aria-label=\"Control room\">\n <StatCard\n compact\n label=\"Failed payouts\"\n value={failedUnknown ? '–' : <CountUp value={failed} />}\n sub={\n failedUnknown\n ? 'Could not be loaded'\n : 'Settlements that did not land'\n }\n href={withdrawals ? '/admin/withdrawals?tab=failed' : undefined}\n alert={failedUnknown || failed > 0}\n />\n <StatCard\n compact\n label=\"Overdue invoices\"\n value={overdueUnknown ? '–' : <CountUp value={overdue} />}\n sub={\n overdueUnknown\n ? 'Could not be loaded'\n : (overdueOutstanding(attention.overdueInvoices, currency) ??\n 'Invoices past their due date')\n }\n href=\"/admin/revenue\"\n alert={overdueUnknown || overdue > 0}\n />\n {verification && <VerificationCard />}\n {totals?.verifiedWallets !== undefined && (\n <StatCard\n compact\n label=\"Verified wallets\"\n value={<CountUp value={totals.verifiedWallets} />}\n sub=\"Businesses that passed KYC or KYB\"\n />\n )}\n {totals?.gatewayFeesByCurrency !== undefined && (\n <StatCard\n compact\n label=\"Gateway revenue\"\n value={\n <MoneyLines\n rows={totals.gatewayFeesByCurrency}\n currency={currency}\n amount={(row: { amount: number }) => row.amount}\n empty=\"No fees yet\"\n />\n }\n sub=\"Fees on card payments\"\n href=\"/admin/revenue\"\n />\n )}\n <SubscriptionsCard />\n </section>\n );\n}\n"],"mappings":";AAmB2C,cAkDvC,YAlDuC;AAhB3C,SAAS,eAAe;AACxB,SAAS,YAAY,UAAU,mBAAmB;AAClD,SAAS,0BAA0B;AACnC,SAAS,mBAAmB;AAC5B,OAAO,YAAY;AACnB,SAAS,uBAAuB;AAChC,SAAS,0BAA0B;AAGnC,SAAS,mBAAmB;AAC1B,QAAM,UAAU,gBAAgB,EAAE;AAElC,SACE;AAAA,IAAC;AAAA;AAAA,MACC,SAAO;AAAA,MACP,OAAM;AAAA,MACN,OAAO,YAAY,SAAY,WAAM,oBAAC,WAAQ,OAAO,SAAS;AAAA,MAC9D,KAAI;AAAA,MACJ,MAAK;AAAA,MACL,OAAO,CAAC,CAAC;AAAA;AAAA,EACX;AAEJ;AAGA,SAAS,oBAAoB;AAC3B,QAAM,UAAU,mBAAmB,EAAE;AAErC,MAAI,CAAC,SAAS,UAAU,QAAS,QAAO;AAExC,SACE;AAAA,IAAC;AAAA;AAAA,MACC,SAAO;AAAA,MACP,OAAM;AAAA,MACN,OAAO,YAAY,QAAQ,UAAU,KAAK,QAAQ,QAAQ;AAAA,MAC1D,KAAI;AAAA,MACJ,MAAK;AAAA;AAAA,EACP;AAEJ;AAOO,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA,WAAW;AAAA,EACX;AACF,GAMG;AACD,QAAM,SAAS,UAAU,kBAAkB;AAC3C,QAAM,UAAU,UAAU,gBAAgB;AAE1C,QAAM,gBAAgB,CAAC,CAAC,UAAU,aAAa,SAAS,mBAAmB;AAC3E,QAAM,iBAAiB,CAAC,CAAC,UAAU,aAAa,SAAS,iBAAiB;AAC1E,QAAM,cAAc,YAAY,aAAa;AAC7C,QAAM,eAAe,YAAY,cAAc;AAE/C,SACE,qBAAC,aAAQ,WAAW,OAAO,MAAM,cAAW,gBAC1C;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,SAAO;AAAA,QACP,OAAM;AAAA,QACN,OAAO,gBAAgB,WAAM,oBAAC,WAAQ,OAAO,QAAQ;AAAA,QACrD,KACE,gBACI,wBACA;AAAA,QAEN,MAAM,cAAc,kCAAkC;AAAA,QACtD,OAAO,iBAAiB,SAAS;AAAA;AAAA,IACnC;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,SAAO;AAAA,QACP,OAAM;AAAA,QACN,OAAO,iBAAiB,WAAM,oBAAC,WAAQ,OAAO,SAAS;AAAA,QACvD,KACE,iBACI,wBACC,mBAAmB,UAAU,iBAAiB,QAAQ,KACvD;AAAA,QAEN,MAAK;AAAA,QACL,OAAO,kBAAkB,UAAU;AAAA;AAAA,IACrC;AAAA,IACC,gBAAgB,oBAAC,oBAAiB;AAAA,IAClC,QAAQ,oBAAoB,UAC3B;AAAA,MAAC;AAAA;AAAA,QACC,SAAO;AAAA,QACP,OAAM;AAAA,QACN,OAAO,oBAAC,WAAQ,OAAO,OAAO,iBAAiB;AAAA,QAC/C,KAAI;AAAA;AAAA,IACN;AAAA,IAED,QAAQ,0BAA0B,UACjC;AAAA,MAAC;AAAA;AAAA,QACC,SAAO;AAAA,QACP,OAAM;AAAA,QACN,OACE;AAAA,UAAC;AAAA;AAAA,YACC,MAAM,OAAO;AAAA,YACb;AAAA,YACA,QAAQ,CAAC,QAA4B,IAAI;AAAA,YACzC,OAAM;AAAA;AAAA,QACR;AAAA,QAEF,KAAI;AAAA,QACJ,MAAK;AAAA;AAAA,IACP;AAAA,IAEF,oBAAC,qBAAkB;AAAA,KACrB;AAEJ;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/_shell/nav-config.ts"],"sourcesContent":["import {\n LayoutGrid,\n Building2,\n Users,\n ShieldCheck,\n ScrollText,\n KeyRound,\n Coins,\n MessageSquareText,\n Headphones,\n Star,\n Settings,\n Target,\n Gauge,\n BadgeCheck,\n Mail,\n Landmark,\n ArrowDownToLine,\n Percent,\n MonitorSmartphone,\n Store,\n} from 'lucide-react';\nimport type { AdminModule } from './nav-modules';\n\nexport type NavItem = {\n href: string;\n label: string;\n icon: typeof LayoutGrid;\n superadmin?: boolean;\n permission?: string;\n activeRoutes?: string[];\n /** Which inbox count to show beside the label. */\n inbox?: 'support' | 'feedback';\n /** Listed only when the host has turned this module on. */\n module?: AdminModule;\n /** Listed only while this module is off: the entry stands in for a page that module would otherwise carry as a tab. */\n unlessModule?: AdminModule;\n};\n\nexport type NavGroup = { label: string | null; items: NavItem[] };\n\n/**\n * Pages a host adds to one of the groups below (a new label adds a group;\n * `null` is the top group with Overview). `replace` drops that group's Box\n * items first, so a host can fold a group into one entry of its own.\n */\nexport type NavAddition = {\n group: string | null;\n items: NavItem[];\n replace?: boolean;\n};\n\n/**\n * Second-level tabs shown above the page while inside Product feedback.\n * Product insights is not one: no box serves it.\n */\nexport const PRODUCT_FEEDBACK_NAV: NavItem[] = [\n { href: '/admin/feedback', label: 'Feedback', icon: MessageSquareText },\n {\n href: '/admin/reviews',\n label: 'Reviews',\n icon: Star,\n permission: 'reviews.read',\n module: 'reviews',\n },\n];\n\n/**\n * Grouped like a banking control room: the merchant first, then money, then\n * operations, then the platform itself. Same order and words as the Box admin.\n */\nexport const NAV_GROUPS: NavGroup[] = [\n {\n label: null,\n items: [\n { href: '/admin', label: 'Overview', icon: LayoutGrid },\n { href: '/admin/revenue', label: 'Revenue', icon: Coins },\n ],\n },\n {\n label: 'Workspaces',\n items: [\n { href: '/admin/tenants', label: 'Workspaces', icon: Building2 },\n { href: '/admin/accounts', label: 'User accounts', icon: Users },\n { href: '/admin/sales', label: 'Sales', icon: Target, module: 'sales' },\n {\n href: '/admin/verification',\n label: 'Verification',\n icon: BadgeCheck,\n permission: 'compliance.read',\n module: 'verification',\n },\n {\n href: '/admin/newsletters',\n label: 'Newsletters',\n icon: Mail,\n permission: 'newsletters.read',\n module: 'newsletters',\n },\n ],\n },\n {\n label: 'Money',\n items: [\n {\n href: '/admin/wallets',\n label: 'Wallets & ledger',\n icon: Landmark,\n module: 'wallets',\n },\n {\n href: '/admin/withdrawals',\n label: 'Withdrawals',\n icon: ArrowDownToLine,\n module: 'withdrawals',\n },\n {\n href: '/admin/pricing',\n label: 'Pricing',\n icon: Percent,\n module: 'pricing',\n },\n {\n href: '/admin/payout-tiers',\n label: 'Payout tiers',\n icon: Gauge,\n permission: 'money.payout_tiers.read',\n },\n ],\n },\n {\n label: 'Operations',\n items: [\n {\n href: '/admin/pos',\n label: 'POS',\n icon: MonitorSmartphone,\n permission: 'pos.read',\n module: 'pos',\n },\n {\n href: '/admin/commerce',\n label: 'Commerce',\n icon: Store,\n module: 'commerce',\n },\n ],\n },\n {\n label: 'Inbox',\n items: [\n {\n href: '/admin/support',\n label: 'Support',\n icon: Headphones,\n permission: 'support.read',\n inbox: 'support',\n module: 'support',\n },\n {\n href: '/admin/feedback',\n label: 'Product feedback',\n icon: MessageSquareText,\n activeRoutes: PRODUCT_FEEDBACK_NAV.map(({ href }) => href),\n inbox: 'feedback',\n module: 'feedback',\n },\n {\n href: '/admin/reviews',\n label: 'Reviews',\n icon: Star,\n permission: 'reviews.read',\n module: 'reviews',\n unlessModule: 'feedback',\n },\n ],\n },\n {\n label: 'Platform',\n items: [\n {\n href: '/admin/admins',\n label: 'Admins',\n icon: ShieldCheck,\n superadmin: true,\n },\n {\n href: '/admin/roles',\n label: 'Roles',\n icon: KeyRound,\n superadmin: true,\n },\n { href: '/admin/audit', label: 'Audit log', icon: ScrollText },\n {\n href: '/admin/settings',\n label: 'Settings',\n icon: Settings,\n module: 'settings',\n },\n ],\n },\n];\n"],"mappings":"AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/_shell/nav-config.ts"],"sourcesContent":["import {\n LayoutGrid,\n Building2,\n Users,\n ShieldCheck,\n ScrollText,\n KeyRound,\n Coins,\n MessageSquareText,\n Headphones,\n Star,\n Settings,\n Target,\n Gauge,\n BadgeCheck,\n Mail,\n Landmark,\n ArrowDownToLine,\n Percent,\n MonitorSmartphone,\n Store,\n} from 'lucide-react';\nimport type { AdminModule } from './nav-modules';\n\nexport type NavItem = {\n href: string;\n label: string;\n icon: typeof LayoutGrid;\n superadmin?: boolean;\n permission?: string;\n activeRoutes?: string[];\n /** Which inbox count to show beside the label. */\n inbox?: 'support' | 'feedback';\n /** Listed only when the host has turned this module on. */\n module?: AdminModule;\n /** Listed only while this module is off: the entry stands in for a page that module would otherwise carry as a tab. */\n unlessModule?: AdminModule;\n};\n\nexport type NavGroup = { label: string | null; items: NavItem[] };\n\n/**\n * Pages a host adds to one of the groups below (a new label adds a group;\n * `null` is the top group with Overview). `replace` drops that group's Box\n * items first, so a host can fold a group into one entry of its own.\n *\n * `after` is the href of the item to follow: the new items go right after it\n * in that group instead of at the end. When no item has that href (the page\n * is off, or the group was replaced), they go at the end, so an addition is\n * never dropped.\n */\nexport type NavAddition = {\n group: string | null;\n items: NavItem[];\n replace?: boolean;\n after?: string;\n};\n\n/**\n * Second-level tabs shown above the page while inside Product feedback.\n * Product insights is not one: no box serves it.\n */\nexport const PRODUCT_FEEDBACK_NAV: NavItem[] = [\n { href: '/admin/feedback', label: 'Feedback', icon: MessageSquareText },\n {\n href: '/admin/reviews',\n label: 'Reviews',\n icon: Star,\n permission: 'reviews.read',\n module: 'reviews',\n },\n];\n\n/**\n * Grouped like a banking control room: the merchant first, then money, then\n * operations, then the platform itself. Same order and words as the Box admin.\n */\nexport const NAV_GROUPS: NavGroup[] = [\n {\n label: null,\n items: [\n { href: '/admin', label: 'Overview', icon: LayoutGrid },\n { href: '/admin/revenue', label: 'Revenue', icon: Coins },\n ],\n },\n {\n label: 'Workspaces',\n items: [\n { href: '/admin/tenants', label: 'Workspaces', icon: Building2 },\n { href: '/admin/accounts', label: 'User accounts', icon: Users },\n { href: '/admin/sales', label: 'Sales', icon: Target, module: 'sales' },\n {\n href: '/admin/verification',\n label: 'Verification',\n icon: BadgeCheck,\n permission: 'compliance.read',\n module: 'verification',\n },\n {\n href: '/admin/newsletters',\n label: 'Newsletters',\n icon: Mail,\n permission: 'newsletters.read',\n module: 'newsletters',\n },\n ],\n },\n {\n label: 'Money',\n items: [\n {\n href: '/admin/wallets',\n label: 'Wallets & ledger',\n icon: Landmark,\n module: 'wallets',\n },\n {\n href: '/admin/withdrawals',\n label: 'Withdrawals',\n icon: ArrowDownToLine,\n module: 'withdrawals',\n },\n {\n href: '/admin/pricing',\n label: 'Pricing',\n icon: Percent,\n module: 'pricing',\n },\n {\n href: '/admin/payout-tiers',\n label: 'Payout tiers',\n icon: Gauge,\n permission: 'money.payout_tiers.read',\n },\n ],\n },\n {\n label: 'Operations',\n items: [\n {\n href: '/admin/pos',\n label: 'POS',\n icon: MonitorSmartphone,\n permission: 'pos.read',\n module: 'pos',\n },\n {\n href: '/admin/commerce',\n label: 'Commerce',\n icon: Store,\n module: 'commerce',\n },\n ],\n },\n {\n label: 'Inbox',\n items: [\n {\n href: '/admin/support',\n label: 'Support',\n icon: Headphones,\n permission: 'support.read',\n inbox: 'support',\n module: 'support',\n },\n {\n href: '/admin/feedback',\n label: 'Product feedback',\n icon: MessageSquareText,\n activeRoutes: PRODUCT_FEEDBACK_NAV.map(({ href }) => href),\n inbox: 'feedback',\n module: 'feedback',\n },\n {\n href: '/admin/reviews',\n label: 'Reviews',\n icon: Star,\n permission: 'reviews.read',\n module: 'reviews',\n unlessModule: 'feedback',\n },\n ],\n },\n {\n label: 'Platform',\n items: [\n {\n href: '/admin/admins',\n label: 'Admins',\n icon: ShieldCheck,\n superadmin: true,\n },\n {\n href: '/admin/roles',\n label: 'Roles',\n icon: KeyRound,\n superadmin: true,\n },\n { href: '/admin/audit', label: 'Audit log', icon: ScrollText },\n {\n href: '/admin/settings',\n label: 'Settings',\n icon: Settings,\n module: 'settings',\n },\n ],\n },\n];\n"],"mappings":"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","names":[]}
|
|
@@ -12,11 +12,13 @@ const canAccess = (session) => (item) => {
|
|
|
12
12
|
};
|
|
13
13
|
function withAdditions(groups, additions = []) {
|
|
14
14
|
const merged = groups.map((g) => ({ ...g, items: [...g.items] }));
|
|
15
|
-
additions.forEach(({ group, items, replace }) => {
|
|
15
|
+
additions.forEach(({ group, items, replace, after }) => {
|
|
16
16
|
const found = merged.find((g) => g.label === group);
|
|
17
17
|
if (found) {
|
|
18
18
|
if (replace) found.items = [];
|
|
19
|
-
found.items.
|
|
19
|
+
const at2 = after ? found.items.findIndex((item) => item.href === after) : -1;
|
|
20
|
+
if (at2 === -1) found.items.push(...items);
|
|
21
|
+
else found.items.splice(at2 + 1, 0, ...items);
|
|
20
22
|
return;
|
|
21
23
|
}
|
|
22
24
|
const platform = merged.findIndex((g) => g.label === "Platform");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/_shell/nav-filter.ts"],"sourcesContent":["import {\n NAV_GROUPS,\n type NavAddition,\n type NavGroup,\n type NavItem,\n} from './nav-config';\nimport { moduleOn, type AdminModules } from './nav-modules';\n\nexport const isActive = (item: NavItem, pathname: string) =>\n item.href === '/admin'\n ? pathname === '/admin'\n : (item.activeRoutes ?? [item.href]).some((route) =>\n pathname.startsWith(route),\n );\n\nexport interface AdminSessionLike {\n role: string;\n permissions: string[];\n}\n\nexport const canAccess = (session: AdminSessionLike) => (item: NavItem) => {\n if (session.role === 'superadmin') return true;\n\n if (item.superadmin) return false;\n\n return !item.permission || session.permissions.includes(item.permission);\n};\n\n
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/_shell/nav-filter.ts"],"sourcesContent":["import {\n NAV_GROUPS,\n type NavAddition,\n type NavGroup,\n type NavItem,\n} from './nav-config';\nimport { moduleOn, type AdminModules } from './nav-modules';\n\nexport const isActive = (item: NavItem, pathname: string) =>\n item.href === '/admin'\n ? pathname === '/admin'\n : (item.activeRoutes ?? [item.href]).some((route) =>\n pathname.startsWith(route),\n );\n\nexport interface AdminSessionLike {\n role: string;\n permissions: string[];\n}\n\nexport const canAccess = (session: AdminSessionLike) => (item: NavItem) => {\n if (session.role === 'superadmin') return true;\n\n if (item.superadmin) return false;\n\n return !item.permission || session.permissions.includes(item.permission);\n};\n\n/**\n * The host's pages placed into their groups: after the Box items in a group (or instead of them), or\n * right after the item named by `after`; a new group goes before Platform.\n */\nexport function withAdditions(\n groups: NavGroup[],\n additions: NavAddition[] = [],\n): NavGroup[] {\n const merged = groups.map((g) => ({ ...g, items: [...g.items] }));\n\n additions.forEach(({ group, items, replace, after }) => {\n const found = merged.find((g) => g.label === group);\n\n if (found) {\n if (replace) found.items = [];\n\n const at = after ? found.items.findIndex((item) => item.href === after) : -1;\n\n if (at === -1) found.items.push(...items);\n else found.items.splice(at + 1, 0, ...items);\n\n return;\n }\n\n const platform = merged.findIndex((g) => g.label === 'Platform');\n const at = platform === -1 ? merged.length : platform;\n\n merged.splice(at, 0, { label: group, items: [...items] });\n });\n\n return merged;\n}\n\n/** The groups this admin may see, modules off removed, empty groups dropped. */\nexport function visibleGroups(\n session: AdminSessionLike,\n modules: AdminModules = {},\n additions: NavAddition[] = [],\n): NavGroup[] {\n const allowed = canAccess(session);\n\n return withAdditions(NAV_GROUPS, additions)\n .map((group) => ({\n ...group,\n items: group.items.filter(\n (item) =>\n moduleOn(modules, item.module) &&\n !(item.unlessModule && moduleOn(modules, item.unlessModule)) &&\n allowed(item),\n ),\n }))\n .filter((group) => group.items.length);\n}\n"],"mappings":"AAAA;AAAA,EACE;AAAA,OAIK;AACP,SAAS,gBAAmC;AAErC,MAAM,WAAW,CAAC,MAAe,aACtC,KAAK,SAAS,WACV,aAAa,YACZ,KAAK,gBAAgB,CAAC,KAAK,IAAI,GAAG;AAAA,EAAK,CAAC,UACvC,SAAS,WAAW,KAAK;AAC3B;AAOC,MAAM,YAAY,CAAC,YAA8B,CAAC,SAAkB;AACzE,MAAI,QAAQ,SAAS,aAAc,QAAO;AAE1C,MAAI,KAAK,WAAY,QAAO;AAE5B,SAAO,CAAC,KAAK,cAAc,QAAQ,YAAY,SAAS,KAAK,UAAU;AACzE;AAMO,SAAS,cACd,QACA,YAA2B,CAAC,GAChB;AACZ,QAAM,SAAS,OAAO,IAAI,CAAC,OAAO,EAAE,GAAG,GAAG,OAAO,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE;AAEhE,YAAU,QAAQ,CAAC,EAAE,OAAO,OAAO,SAAS,MAAM,MAAM;AACtD,UAAM,QAAQ,OAAO,KAAK,CAAC,MAAM,EAAE,UAAU,KAAK;AAElD,QAAI,OAAO;AACT,UAAI,QAAS,OAAM,QAAQ,CAAC;AAE5B,YAAMA,MAAK,QAAQ,MAAM,MAAM,UAAU,CAAC,SAAS,KAAK,SAAS,KAAK,IAAI;AAE1E,UAAIA,QAAO,GAAI,OAAM,MAAM,KAAK,GAAG,KAAK;AAAA,UACnC,OAAM,MAAM,OAAOA,MAAK,GAAG,GAAG,GAAG,KAAK;AAE3C;AAAA,IACF;AAEA,UAAM,WAAW,OAAO,UAAU,CAAC,MAAM,EAAE,UAAU,UAAU;AAC/D,UAAM,KAAK,aAAa,KAAK,OAAO,SAAS;AAE7C,WAAO,OAAO,IAAI,GAAG,EAAE,OAAO,OAAO,OAAO,CAAC,GAAG,KAAK,EAAE,CAAC;AAAA,EAC1D,CAAC;AAED,SAAO;AACT;AAGO,SAAS,cACd,SACA,UAAwB,CAAC,GACzB,YAA2B,CAAC,GAChB;AACZ,QAAM,UAAU,UAAU,OAAO;AAEjC,SAAO,cAAc,YAAY,SAAS,EACvC,IAAI,CAAC,WAAW;AAAA,IACf,GAAG;AAAA,IACH,OAAO,MAAM,MAAM;AAAA,MACjB,CAAC,SACC,SAAS,SAAS,KAAK,MAAM,KAC7B,EAAE,KAAK,gBAAgB,SAAS,SAAS,KAAK,YAAY,MAC1D,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF,EAAE,EACD,OAAO,CAAC,UAAU,MAAM,MAAM,MAAM;AACzC;","names":["at"]}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { ChevronDown } from "lucide-react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
4
5
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
5
6
|
import { eases } from "@withpotter/lib/motion";
|
|
6
7
|
import { isActive } from "./nav-filter";
|
|
@@ -13,9 +14,11 @@ function NavGroupView({
|
|
|
13
14
|
collapsed,
|
|
14
15
|
canSupport,
|
|
15
16
|
choices,
|
|
16
|
-
onToggle
|
|
17
|
+
onToggle,
|
|
18
|
+
badges
|
|
17
19
|
}) {
|
|
18
20
|
const reduced = useReducedMotion();
|
|
21
|
+
const router = useRouter();
|
|
19
22
|
const { label, items } = group;
|
|
20
23
|
const containsActive = items.some((item) => isActive(item, pathname));
|
|
21
24
|
const rows = items.map((item) => /* @__PURE__ */ jsx(
|
|
@@ -24,7 +27,8 @@ function NavGroupView({
|
|
|
24
27
|
item,
|
|
25
28
|
active: isActive(item, pathname),
|
|
26
29
|
collapsed,
|
|
27
|
-
canSupport
|
|
30
|
+
canSupport,
|
|
31
|
+
badges
|
|
28
32
|
},
|
|
29
33
|
item.href
|
|
30
34
|
));
|
|
@@ -34,13 +38,16 @@ function NavGroupView({
|
|
|
34
38
|
rows
|
|
35
39
|
] });
|
|
36
40
|
const open = isGroupOpen(choices, label, containsActive);
|
|
37
|
-
|
|
41
|
+
const warm = () => items.forEach((item) => router.prefetch(item.href));
|
|
42
|
+
return /* @__PURE__ */ jsxs("div", { className: `${styles.group} ${styles.groupFold}`, children: [
|
|
38
43
|
/* @__PURE__ */ jsxs(
|
|
39
44
|
"button",
|
|
40
45
|
{
|
|
41
46
|
type: "button",
|
|
42
47
|
className: `${styles.groupLabel} ${styles.groupToggle}`,
|
|
43
48
|
"aria-expanded": open,
|
|
49
|
+
onPointerEnter: warm,
|
|
50
|
+
onFocus: warm,
|
|
44
51
|
onClick: () => onToggle(label, !open),
|
|
45
52
|
children: [
|
|
46
53
|
/* @__PURE__ */ jsx("span", { children: label }),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/_shell/nav-group.tsx"],"sourcesContent":["'use client';\n\nimport { ChevronDown } from 'lucide-react';\nimport { AnimatePresence, motion, useReducedMotion } from 'motion/react';\nimport { eases } from '@withpotter/lib/motion';\nimport type { NavGroup } from './nav-config';\nimport { isActive } from './nav-filter';\nimport { foldsGroup, isGroupOpen, type GroupChoices } from './nav-fold';\nimport { NavRow } from './nav-row';\nimport styles from './sidebar.module.css';\n\n/**\n * One sidebar group. With a heading and more than one row, the heading is a\n * button that opens and closes the rows, on its own: no other group moves.\n * The group holding the current page starts open, and the icon-only rail shows\n * every row.\n */\nexport function NavGroupView({\n group,\n pathname,\n collapsed,\n canSupport,\n choices,\n onToggle,\n}: {\n group: NavGroup;\n pathname: string;\n collapsed: boolean;\n canSupport: boolean;\n choices: GroupChoices;\n onToggle: (label: string, next: boolean) => void;\n}) {\n const reduced = useReducedMotion();\n const { label, items } = group;\n const containsActive = items.some((item) => isActive(item, pathname));\n\n const rows = items.map((item) => (\n <NavRow\n key={item.href}\n item={item}\n active={isActive(item, pathname)}\n collapsed={collapsed}\n canSupport={canSupport}\n />\n ));\n\n if (!foldsGroup(label, items.length, collapsed))\n return (\n <div className={styles.group}>\n {label && <div className={styles.groupLabel}>{label}</div>}\n {rows}\n </div>\n );\n\n const open = isGroupOpen(choices, label, containsActive);\n\n return (\n <div className={styles.group}>\n <button\n type=\"button\"\n className={`${styles.groupLabel} ${styles.groupToggle}`}\n aria-expanded={open}\n onClick={() => onToggle(label, !open)}\n >\n <span>{label}</span>\n <ChevronDown size={14} className={styles.groupChevron} aria-hidden />\n </button>\n {/* The rows grow and collapse rather than blink, as the merchant rail's\n hubs do: height so the groups below move with them, opacity finishing\n early on the way in. Clipped, or the rows spill past the closing box. */}\n <AnimatePresence initial={false}>\n {open && (\n <motion.div\n className={styles.groupRows}\n initial={{ height: 0, opacity: 0 }}\n animate={{ height: 'auto', opacity: 1 }}\n exit={{ height: 0, opacity: 0 }}\n transition={\n reduced\n ? { duration: 0 }\n : {\n height: { duration: 0.24, ease: eases.settle },\n opacity: { duration: 0.16, ease: eases.out },\n }\n }\n style={{ overflow: 'hidden' }}\n >\n {rows}\n </motion.div>\n )}\n </AnimatePresence>\n </div>\n );\n}\n"],"mappings":";
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/_shell/nav-group.tsx"],"sourcesContent":["'use client';\n\nimport type { ComponentType } from 'react';\nimport { ChevronDown } from 'lucide-react';\nimport { useRouter } from 'next/navigation';\nimport { AnimatePresence, motion, useReducedMotion } from 'motion/react';\nimport { eases } from '@withpotter/lib/motion';\nimport type { NavGroup } from './nav-config';\nimport { isActive } from './nav-filter';\nimport { foldsGroup, isGroupOpen, type GroupChoices } from './nav-fold';\nimport { NavRow } from './nav-row';\nimport styles from './sidebar.module.css';\n\n/**\n * One sidebar group. With a heading and more than one row, the heading is a\n * button that opens and closes the rows, on its own: no other group moves.\n * The group holding the current page starts open, and the icon-only rail shows\n * every row.\n */\nexport function NavGroupView({\n group,\n pathname,\n collapsed,\n canSupport,\n choices,\n onToggle,\n badges,\n}: {\n group: NavGroup;\n pathname: string;\n collapsed: boolean;\n canSupport: boolean;\n choices: GroupChoices;\n onToggle: (label: string, next: boolean) => void;\n /** A mark beside a row, by the row's href (a count of what needs a decision). */\n badges?: Record<string, ComponentType>;\n}) {\n const reduced = useReducedMotion();\n const router = useRouter();\n const { label, items } = group;\n const containsActive = items.some((item) => isActive(item, pathname));\n\n const rows = items.map((item) => (\n <NavRow\n key={item.href}\n item={item}\n active={isActive(item, pathname)}\n collapsed={collapsed}\n canSupport={canSupport}\n badges={badges}\n />\n ));\n\n if (!foldsGroup(label, items.length, collapsed))\n return (\n <div className={styles.group}>\n {label && <div className={styles.groupLabel}>{label}</div>}\n {rows}\n </div>\n );\n\n const open = isGroupOpen(choices, label, containsActive);\n\n // A heading is only ever pressed on the way to one of its pages, and the\n // pointer or focus arriving is the earliest sign of it, so warm them now.\n const warm = () => items.forEach((item) => router.prefetch(item.href));\n\n return (\n <div className={`${styles.group} ${styles.groupFold}`}>\n <button\n type=\"button\"\n className={`${styles.groupLabel} ${styles.groupToggle}`}\n aria-expanded={open}\n onPointerEnter={warm}\n onFocus={warm}\n onClick={() => onToggle(label, !open)}\n >\n <span>{label}</span>\n <ChevronDown size={14} className={styles.groupChevron} aria-hidden />\n </button>\n {/* The rows grow and collapse rather than blink, as the merchant rail's\n hubs do: height so the groups below move with them, opacity finishing\n early on the way in. Clipped, or the rows spill past the closing box. */}\n <AnimatePresence initial={false}>\n {open && (\n <motion.div\n className={styles.groupRows}\n initial={{ height: 0, opacity: 0 }}\n animate={{ height: 'auto', opacity: 1 }}\n exit={{ height: 0, opacity: 0 }}\n transition={\n reduced\n ? { duration: 0 }\n : {\n height: { duration: 0.24, ease: eases.settle },\n opacity: { duration: 0.16, ease: eases.out },\n }\n }\n style={{ overflow: 'hidden' }}\n >\n {rows}\n </motion.div>\n )}\n </AnimatePresence>\n </div>\n );\n}\n"],"mappings":";AA2CI,cAYE,YAZF;AAxCJ,SAAS,mBAAmB;AAC5B,SAAS,iBAAiB;AAC1B,SAAS,iBAAiB,QAAQ,wBAAwB;AAC1D,SAAS,aAAa;AAEtB,SAAS,gBAAgB;AACzB,SAAS,YAAY,mBAAsC;AAC3D,SAAS,cAAc;AACvB,OAAO,YAAY;AAQZ,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GASG;AACD,QAAM,UAAU,iBAAiB;AACjC,QAAM,SAAS,UAAU;AACzB,QAAM,EAAE,OAAO,MAAM,IAAI;AACzB,QAAM,iBAAiB,MAAM,KAAK,CAAC,SAAS,SAAS,MAAM,QAAQ,CAAC;AAEpE,QAAM,OAAO,MAAM,IAAI,CAAC,SACtB;AAAA,IAAC;AAAA;AAAA,MAEC;AAAA,MACA,QAAQ,SAAS,MAAM,QAAQ;AAAA,MAC/B;AAAA,MACA;AAAA,MACA;AAAA;AAAA,IALK,KAAK;AAAA,EAMZ,CACD;AAED,MAAI,CAAC,WAAW,OAAO,MAAM,QAAQ,SAAS;AAC5C,WACE,qBAAC,SAAI,WAAW,OAAO,OACpB;AAAA,eAAS,oBAAC,SAAI,WAAW,OAAO,YAAa,iBAAM;AAAA,MACnD;AAAA,OACH;AAGJ,QAAM,OAAO,YAAY,SAAS,OAAO,cAAc;AAIvD,QAAM,OAAO,MAAM,MAAM,QAAQ,CAAC,SAAS,OAAO,SAAS,KAAK,IAAI,CAAC;AAErE,SACE,qBAAC,SAAI,WAAW,GAAG,OAAO,KAAK,IAAI,OAAO,SAAS,IACjD;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,WAAW,GAAG,OAAO,UAAU,IAAI,OAAO,WAAW;AAAA,QACrD,iBAAe;AAAA,QACf,gBAAgB;AAAA,QAChB,SAAS;AAAA,QACT,SAAS,MAAM,SAAS,OAAO,CAAC,IAAI;AAAA,QAEpC;AAAA,8BAAC,UAAM,iBAAM;AAAA,UACb,oBAAC,eAAY,MAAM,IAAI,WAAW,OAAO,cAAc,eAAW,MAAC;AAAA;AAAA;AAAA,IACrE;AAAA,IAIA,oBAAC,mBAAgB,SAAS,OACvB,kBACC;AAAA,MAAC,OAAO;AAAA,MAAP;AAAA,QACC,WAAW,OAAO;AAAA,QAClB,SAAS,EAAE,QAAQ,GAAG,SAAS,EAAE;AAAA,QACjC,SAAS,EAAE,QAAQ,QAAQ,SAAS,EAAE;AAAA,QACtC,MAAM,EAAE,QAAQ,GAAG,SAAS,EAAE;AAAA,QAC9B,YACE,UACI,EAAE,UAAU,EAAE,IACd;AAAA,UACE,QAAQ,EAAE,UAAU,MAAM,MAAM,MAAM,OAAO;AAAA,UAC7C,SAAS,EAAE,UAAU,MAAM,MAAM,MAAM,IAAI;AAAA,QAC7C;AAAA,QAEN,OAAO,EAAE,UAAU,SAAS;AAAA,QAE3B;AAAA;AAAA,IACH,GAEJ;AAAA,KACF;AAEJ;","names":[]}
|
|
@@ -24,9 +24,11 @@ function NavRow({
|
|
|
24
24
|
item,
|
|
25
25
|
active,
|
|
26
26
|
collapsed,
|
|
27
|
-
canSupport
|
|
27
|
+
canSupport,
|
|
28
|
+
badges
|
|
28
29
|
}) {
|
|
29
30
|
const Icon = item.icon;
|
|
31
|
+
const Badge = badges?.[item.href];
|
|
30
32
|
return /* @__PURE__ */ jsx(RailTip, { label: item.label, on: collapsed, children: /* @__PURE__ */ jsxs(
|
|
31
33
|
Link,
|
|
32
34
|
{
|
|
@@ -47,7 +49,8 @@ function NavRow({
|
|
|
47
49
|
}
|
|
48
50
|
),
|
|
49
51
|
/* @__PURE__ */ jsx("span", { className: styles.linkText, children: /* @__PURE__ */ jsx("span", { className: styles.linkLabel, children: item.label }) }),
|
|
50
|
-
item.inbox && /* @__PURE__ */ jsx("span", { className: styles.badgeSlot, children: /* @__PURE__ */ jsx(NavBadge, { kind: item.inbox, canSupport }) })
|
|
52
|
+
item.inbox && /* @__PURE__ */ jsx("span", { className: styles.badgeSlot, children: /* @__PURE__ */ jsx(NavBadge, { kind: item.inbox, canSupport }) }),
|
|
53
|
+
Badge && !item.inbox && /* @__PURE__ */ jsx("span", { className: styles.badgeSlot, children: /* @__PURE__ */ jsx(Badge, {}) })
|
|
51
54
|
]
|
|
52
55
|
}
|
|
53
56
|
) });
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/_shell/nav-row.tsx"],"sourcesContent":["'use client';\n\nimport type { ReactElement } from 'react';\nimport Link from 'next/link';\nimport {\n TooltipContent,\n TooltipProvider,\n TooltipRoot,\n TooltipTrigger,\n} from '@withpotter/ui-kit/tooltip';\nimport { NavBadge } from '../_inbox/nav-badge';\nimport type { NavItem } from './nav-config';\nimport styles from './sidebar.module.css';\n\n/**\n * The collapsed rail is icons only, so the name arrives as a tooltip on hover\n * and keyboard focus (the merchant rail's RailLabel, without the drop shadow).\n * Expanded, the visible label is the name and this passes straight through.\n */\nexport function RailTip({\n label,\n on,\n children,\n}: {\n label: string;\n on: boolean;\n children: ReactElement;\n}) {\n if (!on) return children;\n\n return (\n <TooltipProvider delayDuration={200}>\n <TooltipRoot>\n <TooltipTrigger asChild>{children}</TooltipTrigger>\n <TooltipContent side=\"right\" className=\"shadow-none\">\n {label}\n </TooltipContent>\n </TooltipRoot>\n </TooltipProvider>\n );\n}\n\n
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/_shell/nav-row.tsx"],"sourcesContent":["'use client';\n\nimport type { ComponentType, ReactElement } from 'react';\nimport Link from 'next/link';\nimport {\n TooltipContent,\n TooltipProvider,\n TooltipRoot,\n TooltipTrigger,\n} from '@withpotter/ui-kit/tooltip';\nimport { NavBadge } from '../_inbox/nav-badge';\nimport type { NavItem } from './nav-config';\nimport styles from './sidebar.module.css';\n\n/**\n * The collapsed rail is icons only, so the name arrives as a tooltip on hover\n * and keyboard focus (the merchant rail's RailLabel, without the drop shadow).\n * Expanded, the visible label is the name and this passes straight through.\n */\nexport function RailTip({\n label,\n on,\n children,\n}: {\n label: string;\n on: boolean;\n children: ReactElement;\n}) {\n if (!on) return children;\n\n return (\n <TooltipProvider delayDuration={200}>\n <TooltipRoot>\n <TooltipTrigger asChild>{children}</TooltipTrigger>\n <TooltipContent side=\"right\" className=\"shadow-none\">\n {label}\n </TooltipContent>\n </TooltipRoot>\n </TooltipProvider>\n );\n}\n\n/**\n * One sidebar row: a link to its page. A host can show its own count beside the label: `badges` maps\n * an item's href to a component that renders one `span` (it sits in the same slot as the inbox\n * count, so it is styled and moved to the corner in the collapsed rail the same way). An item that\n * has an inbox count keeps that count.\n */\nexport function NavRow({\n item,\n active,\n collapsed,\n canSupport,\n badges,\n}: {\n item: NavItem;\n active: boolean;\n collapsed: boolean;\n canSupport: boolean;\n badges?: Record<string, ComponentType>;\n}) {\n const Icon = item.icon;\n const Badge = badges?.[item.href];\n\n return (\n <RailTip label={item.label} on={collapsed}>\n <Link\n href={item.href}\n className={styles.link}\n data-fluid-item\n data-active={active}\n aria-current={active ? 'page' : undefined}\n aria-label={collapsed ? item.label : undefined}\n >\n <Icon\n size={16}\n strokeWidth={active ? 2 : 1.75}\n className={styles.icon}\n aria-hidden\n />\n <span className={styles.linkText}>\n <span className={styles.linkLabel}>{item.label}</span>\n </span>\n {item.inbox && (\n <span className={styles.badgeSlot}>\n <NavBadge kind={item.inbox} canSupport={canSupport} />\n </span>\n )}\n {Badge && !item.inbox && (\n <span className={styles.badgeSlot}>\n <Badge />\n </span>\n )}\n </Link>\n </RailTip>\n );\n}\n"],"mappings":";AAgCM,SACE,KADF;AA7BN,OAAO,UAAU;AACjB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,gBAAgB;AAEzB,OAAO,YAAY;AAOZ,SAAS,QAAQ;AAAA,EACtB;AAAA,EACA;AAAA,EACA;AACF,GAIG;AACD,MAAI,CAAC,GAAI,QAAO;AAEhB,SACE,oBAAC,mBAAgB,eAAe,KAC9B,+BAAC,eACC;AAAA,wBAAC,kBAAe,SAAO,MAAE,UAAS;AAAA,IAClC,oBAAC,kBAAe,MAAK,SAAQ,WAAU,eACpC,iBACH;AAAA,KACF,GACF;AAEJ;AAQO,SAAS,OAAO;AAAA,EACrB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAMG;AACD,QAAM,OAAO,KAAK;AAClB,QAAM,QAAQ,SAAS,KAAK,IAAI;AAEhC,SACE,oBAAC,WAAQ,OAAO,KAAK,OAAO,IAAI,WAC9B;AAAA,IAAC;AAAA;AAAA,MACC,MAAM,KAAK;AAAA,MACX,WAAW,OAAO;AAAA,MAClB,mBAAe;AAAA,MACf,eAAa;AAAA,MACb,gBAAc,SAAS,SAAS;AAAA,MAChC,cAAY,YAAY,KAAK,QAAQ;AAAA,MAErC;AAAA;AAAA,UAAC;AAAA;AAAA,YACC,MAAM;AAAA,YACN,aAAa,SAAS,IAAI;AAAA,YAC1B,WAAW,OAAO;AAAA,YAClB,eAAW;AAAA;AAAA,QACb;AAAA,QACA,oBAAC,UAAK,WAAW,OAAO,UACtB,8BAAC,UAAK,WAAW,OAAO,WAAY,eAAK,OAAM,GACjD;AAAA,QACC,KAAK,SACJ,oBAAC,UAAK,WAAW,OAAO,WACtB,8BAAC,YAAS,MAAM,KAAK,OAAO,YAAwB,GACtD;AAAA,QAED,SAAS,CAAC,KAAK,SACd,oBAAC,UAAK,WAAW,OAAO,WACtB,8BAAC,SAAM,GACT;AAAA;AAAA;AAAA,EAEJ,GACF;AAEJ;","names":[]}
|
|
@@ -9,6 +9,8 @@ function SidebarFooter({
|
|
|
9
9
|
collapsed,
|
|
10
10
|
settingsActive,
|
|
11
11
|
showSettings,
|
|
12
|
+
actions = [],
|
|
13
|
+
pathname,
|
|
12
14
|
onSearch
|
|
13
15
|
}) {
|
|
14
16
|
const { resolvedTheme, toggleTheme } = useTheme();
|
|
@@ -45,6 +47,24 @@ function SidebarFooter({
|
|
|
45
47
|
]
|
|
46
48
|
}
|
|
47
49
|
) }),
|
|
50
|
+
actions.map(({ href, label, icon: Icon, badge: Badge }) => {
|
|
51
|
+
const active = pathname === href || pathname.startsWith(`${href}/`);
|
|
52
|
+
return /* @__PURE__ */ jsx(RailTip, { label, on: collapsed, children: /* @__PURE__ */ jsxs(
|
|
53
|
+
Link,
|
|
54
|
+
{
|
|
55
|
+
href,
|
|
56
|
+
className: styles.theme,
|
|
57
|
+
"data-active": active,
|
|
58
|
+
"aria-current": active ? "page" : void 0,
|
|
59
|
+
"aria-label": label,
|
|
60
|
+
title: collapsed ? void 0 : label,
|
|
61
|
+
children: [
|
|
62
|
+
/* @__PURE__ */ jsx(Icon, { size: 16, "aria-hidden": true }),
|
|
63
|
+
Badge && /* @__PURE__ */ jsx("span", { className: styles.badge, children: /* @__PURE__ */ jsx(Badge, {}) })
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
) }, href);
|
|
67
|
+
}),
|
|
48
68
|
/* @__PURE__ */ jsx(RailTip, { label: themeLabel, on: collapsed, children: /* @__PURE__ */ jsx(
|
|
49
69
|
"button",
|
|
50
70
|
{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/_shell/sidebar-footer.tsx"],"sourcesContent":["'use client';\n\nimport Link from 'next/link';\nimport { Moon, Search, Settings, Sun } from 'lucide-react';\nimport { useTheme } from '@withpotter/lib/theme-context';\nimport { RailTip } from './nav-row';\nimport styles from './sidebar-footer.module.css';\n\n/**\n * The pinned foot of the sidebar: jump anywhere (⌘K), and Settings with the\n * theme switch beside it. In the rail each is its own 40px target.\n */\nexport function SidebarFooter({\n collapsed,\n settingsActive,\n showSettings,\n onSearch,\n}: {\n collapsed: boolean;\n settingsActive: boolean;\n showSettings: boolean;\n onSearch: () => void;\n}) {\n const { resolvedTheme, toggleTheme } = useTheme();\n const dark = resolvedTheme === 'dark';\n const themeLabel = `Switch to ${dark ? 'light' : 'dark'} mode`;\n\n return (\n <div className={styles.foot} data-collapsed={collapsed}>\n <RailTip label=\"Search\" on={collapsed}>\n <button\n type=\"button\"\n className={styles.row}\n onClick={onSearch}\n aria-label={collapsed ? 'Search' : undefined}\n aria-keyshortcuts=\"Meta+K\"\n >\n <Search size={16} className={styles.icon} aria-hidden />\n <span className={styles.label}>Search</span>\n <kbd className={styles.kbd}>⌘K</kbd>\n </button>\n </RailTip>\n\n <div className={styles.split}>\n {showSettings && (\n <RailTip label=\"Settings\" on={collapsed}>\n <Link\n href=\"/admin/settings\"\n className={styles.row}\n data-active={settingsActive}\n aria-current={settingsActive ? 'page' : undefined}\n aria-label={collapsed ? 'Settings' : undefined}\n >\n <Settings size={16} className={styles.icon} aria-hidden />\n <span className={styles.label}>Settings</span>\n </Link>\n </RailTip>\n )}\n <RailTip label={themeLabel} on={collapsed}>\n <button\n type=\"button\"\n className={styles.theme}\n onClick={toggleTheme}\n aria-label={themeLabel}\n title={collapsed ? undefined : themeLabel}\n >\n {dark ? <Sun size={16} /> : <Moon size={16} />}\n </button>\n </RailTip>\n </div>\n </div>\n );\n}\n"],"mappings":";
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/_shell/sidebar-footer.tsx"],"sourcesContent":["'use client';\n\nimport Link from 'next/link';\nimport type { ComponentType } from 'react';\nimport { Moon, Search, Settings, Sun, type LucideIcon } from 'lucide-react';\nimport { useTheme } from '@withpotter/lib/theme-context';\nimport { RailTip } from './nav-row';\nimport styles from './sidebar-footer.module.css';\n\n/**\n * An icon a host adds to the foot, beside the theme switch: a link with a\n * name for screen readers and a tooltip, and an optional `badge` drawn on its\n * corner (a dot or a count; it draws nothing when there is nothing to show).\n */\nexport type FooterAction = {\n href: string;\n label: string;\n icon: LucideIcon;\n badge?: ComponentType;\n /** Shown to superadmins only, like a menu item marked `superadmin`. */\n superadmin?: boolean;\n};\n\n/**\n * The pinned foot of the sidebar: jump anywhere (⌘K), and Settings with the\n * host's icons and the theme switch beside it. In the rail each is its own\n * 40px target.\n */\nexport function SidebarFooter({\n collapsed,\n settingsActive,\n showSettings,\n actions = [],\n pathname,\n onSearch,\n}: {\n collapsed: boolean;\n settingsActive: boolean;\n showSettings: boolean;\n actions?: FooterAction[];\n pathname: string;\n onSearch: () => void;\n}) {\n const { resolvedTheme, toggleTheme } = useTheme();\n const dark = resolvedTheme === 'dark';\n const themeLabel = `Switch to ${dark ? 'light' : 'dark'} mode`;\n\n return (\n <div className={styles.foot} data-collapsed={collapsed}>\n <RailTip label=\"Search\" on={collapsed}>\n <button\n type=\"button\"\n className={styles.row}\n onClick={onSearch}\n aria-label={collapsed ? 'Search' : undefined}\n aria-keyshortcuts=\"Meta+K\"\n >\n <Search size={16} className={styles.icon} aria-hidden />\n <span className={styles.label}>Search</span>\n <kbd className={styles.kbd}>⌘K</kbd>\n </button>\n </RailTip>\n\n <div className={styles.split}>\n {showSettings && (\n <RailTip label=\"Settings\" on={collapsed}>\n <Link\n href=\"/admin/settings\"\n className={styles.row}\n data-active={settingsActive}\n aria-current={settingsActive ? 'page' : undefined}\n aria-label={collapsed ? 'Settings' : undefined}\n >\n <Settings size={16} className={styles.icon} aria-hidden />\n <span className={styles.label}>Settings</span>\n </Link>\n </RailTip>\n )}\n {actions.map(({ href, label, icon: Icon, badge: Badge }) => {\n const active = pathname === href || pathname.startsWith(`${href}/`);\n return (\n <RailTip key={href} label={label} on={collapsed}>\n <Link\n href={href}\n className={styles.theme}\n data-active={active}\n aria-current={active ? 'page' : undefined}\n aria-label={label}\n title={collapsed ? undefined : label}\n >\n <Icon size={16} aria-hidden />\n {Badge && (\n <span className={styles.badge}>\n <Badge />\n </span>\n )}\n </Link>\n </RailTip>\n );\n })}\n <RailTip label={themeLabel} on={collapsed}>\n <button\n type=\"button\"\n className={styles.theme}\n onClick={toggleTheme}\n aria-label={themeLabel}\n title={collapsed ? undefined : themeLabel}\n >\n {dark ? <Sun size={16} /> : <Moon size={16} />}\n </button>\n </RailTip>\n </div>\n </div>\n );\n}\n"],"mappings":";AAkDQ,SAOE,KAPF;AAhDR,OAAO,UAAU;AAEjB,SAAS,MAAM,QAAQ,UAAU,WAA4B;AAC7D,SAAS,gBAAgB;AACzB,SAAS,eAAe;AACxB,OAAO,YAAY;AAqBZ,SAAS,cAAc;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU,CAAC;AAAA,EACX;AAAA,EACA;AACF,GAOG;AACD,QAAM,EAAE,eAAe,YAAY,IAAI,SAAS;AAChD,QAAM,OAAO,kBAAkB;AAC/B,QAAM,aAAa,aAAa,OAAO,UAAU,MAAM;AAEvD,SACE,qBAAC,SAAI,WAAW,OAAO,MAAM,kBAAgB,WAC3C;AAAA,wBAAC,WAAQ,OAAM,UAAS,IAAI,WAC1B;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,WAAW,OAAO;AAAA,QAClB,SAAS;AAAA,QACT,cAAY,YAAY,WAAW;AAAA,QACnC,qBAAkB;AAAA,QAElB;AAAA,8BAAC,UAAO,MAAM,IAAI,WAAW,OAAO,MAAM,eAAW,MAAC;AAAA,UACtD,oBAAC,UAAK,WAAW,OAAO,OAAO,oBAAM;AAAA,UACrC,oBAAC,SAAI,WAAW,OAAO,KAAK,qBAAE;AAAA;AAAA;AAAA,IAChC,GACF;AAAA,IAEA,qBAAC,SAAI,WAAW,OAAO,OACpB;AAAA,sBACC,oBAAC,WAAQ,OAAM,YAAW,IAAI,WAC5B;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,WAAW,OAAO;AAAA,UAClB,eAAa;AAAA,UACb,gBAAc,iBAAiB,SAAS;AAAA,UACxC,cAAY,YAAY,aAAa;AAAA,UAErC;AAAA,gCAAC,YAAS,MAAM,IAAI,WAAW,OAAO,MAAM,eAAW,MAAC;AAAA,YACxD,oBAAC,UAAK,WAAW,OAAO,OAAO,sBAAQ;AAAA;AAAA;AAAA,MACzC,GACF;AAAA,MAED,QAAQ,IAAI,CAAC,EAAE,MAAM,OAAO,MAAM,MAAM,OAAO,MAAM,MAAM;AAC1D,cAAM,SAAS,aAAa,QAAQ,SAAS,WAAW,GAAG,IAAI,GAAG;AAClE,eACE,oBAAC,WAAmB,OAAc,IAAI,WACpC;AAAA,UAAC;AAAA;AAAA,YACC;AAAA,YACA,WAAW,OAAO;AAAA,YAClB,eAAa;AAAA,YACb,gBAAc,SAAS,SAAS;AAAA,YAChC,cAAY;AAAA,YACZ,OAAO,YAAY,SAAY;AAAA,YAE/B;AAAA,kCAAC,QAAK,MAAM,IAAI,eAAW,MAAC;AAAA,cAC3B,SACC,oBAAC,UAAK,WAAW,OAAO,OACtB,8BAAC,SAAM,GACT;AAAA;AAAA;AAAA,QAEJ,KAfY,IAgBd;AAAA,MAEJ,CAAC;AAAA,MACD,oBAAC,WAAQ,OAAO,YAAY,IAAI,WAC9B;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,WAAW,OAAO;AAAA,UAClB,SAAS;AAAA,UACT,cAAY;AAAA,UACZ,OAAO,YAAY,SAAY;AAAA,UAE9B,iBAAO,oBAAC,OAAI,MAAM,IAAI,IAAK,oBAAC,QAAK,MAAM,IAAI;AAAA;AAAA,MAC9C,GACF;AAAA,OACF;AAAA,KACF;AAEJ;","names":[]}
|
|
@@ -42,10 +42,27 @@
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
.theme {
|
|
45
|
+
position: relative;
|
|
45
46
|
flex: none;
|
|
46
47
|
justify-content: center;
|
|
47
48
|
width: 32px;
|
|
48
49
|
height: 32px;
|
|
50
|
+
text-decoration: none;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.theme[data-active='true'] {
|
|
54
|
+
border-color: var(--border-default);
|
|
55
|
+
background: var(--bg-base);
|
|
56
|
+
color: var(--text-primary);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* A host's count or dot, on the icon's top right corner. */
|
|
60
|
+
.badge {
|
|
61
|
+
position: absolute;
|
|
62
|
+
top: 2px;
|
|
63
|
+
right: 2px;
|
|
64
|
+
display: flex;
|
|
65
|
+
pointer-events: none;
|
|
49
66
|
}
|
|
50
67
|
|
|
51
68
|
.row:hover,
|
|
@@ -15,7 +15,9 @@ function Sidebar({
|
|
|
15
15
|
collapsed,
|
|
16
16
|
email,
|
|
17
17
|
role,
|
|
18
|
-
onSearch
|
|
18
|
+
onSearch,
|
|
19
|
+
badges,
|
|
20
|
+
footerActions
|
|
19
21
|
}) {
|
|
20
22
|
const hover = useFluidHover();
|
|
21
23
|
const settings = groups.flatMap((group) => group.items).find((item) => item.href === FOOT_HREF);
|
|
@@ -36,6 +38,7 @@ function Sidebar({
|
|
|
36
38
|
"aria-label": "Admin",
|
|
37
39
|
onPointerMove: hover.onPointerMove,
|
|
38
40
|
onPointerLeave: hover.onPointerLeave,
|
|
41
|
+
onClick: hover.onClick,
|
|
39
42
|
children: [
|
|
40
43
|
/* @__PURE__ */ jsx(
|
|
41
44
|
"span",
|
|
@@ -57,7 +60,8 @@ function Sidebar({
|
|
|
57
60
|
collapsed,
|
|
58
61
|
canSupport,
|
|
59
62
|
choices,
|
|
60
|
-
onToggle: setGroupOpen
|
|
63
|
+
onToggle: setGroupOpen,
|
|
64
|
+
badges
|
|
61
65
|
},
|
|
62
66
|
group.label ?? index
|
|
63
67
|
))
|
|
@@ -70,6 +74,8 @@ function Sidebar({
|
|
|
70
74
|
collapsed,
|
|
71
75
|
showSettings: Boolean(settings),
|
|
72
76
|
settingsActive: settings ? isActive(settings, pathname) : false,
|
|
77
|
+
actions: footerActions,
|
|
78
|
+
pathname,
|
|
73
79
|
onSearch
|
|
74
80
|
}
|
|
75
81
|
)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/_shell/sidebar.tsx"],"sourcesContent":["'use client';\n\nimport type { NavGroup } from './nav-config';\nimport { isActive } from './nav-filter';\nimport { NavGroupView } from './nav-group';\nimport { useOpenGroups } from './use-open-groups';\nimport { SidebarFooter } from './sidebar-footer';\nimport { useFluidHover } from './use-fluid-hover';\nimport { UserMenu } from './user-menu';\nimport styles from './sidebar.module.css';\n\n/** Settings lives in the pinned foot, beside the theme switch. */\nconst FOOT_HREF = '/admin/settings';\n\nexport function Sidebar({\n groups,\n pathname,\n canSupport,\n collapsed,\n email,\n role,\n onSearch,\n}: {\n groups: NavGroup[];\n pathname: string;\n canSupport: boolean;\n collapsed: boolean;\n email: string;\n role: string;\n onSearch: () => void;\n}) {\n const hover = useFluidHover();\n const settings = groups\n .flatMap((group) => group.items)\n .find((item) => item.href === FOOT_HREF);\n const navGroups = groups\n .map((group) => ({\n ...group,\n items: group.items.filter((item) => item.href !== FOOT_HREF),\n }))\n .filter((group) => group.items.length);\n const activeGroup =\n navGroups.find((group) =>\n group.items.some((item) => isActive(item, pathname)),\n )?.label ?? null;\n const { choices, setGroupOpen } = useOpenGroups(pathname, activeGroup);\n\n return (\n <aside className={styles.sidebar} data-collapsed={collapsed}>\n <UserMenu email={email} role={role} collapsed={collapsed} />\n\n <nav\n className={styles.nav}\n aria-label=\"Admin\"\n onPointerMove={hover.onPointerMove}\n onPointerLeave={hover.onPointerLeave}\n >\n <span\n aria-hidden\n className={styles.glide}\n data-visible={Boolean(hover.rect)}\n style={\n hover.rect\n ? {\n transform: `translateY(${hover.rect.top}px)`,\n height: hover.rect.height,\n }\n : undefined\n }\n />\n {navGroups.map((group, index) => (\n <NavGroupView\n key={group.label ?? index}\n group={group}\n pathname={pathname}\n collapsed={collapsed}\n canSupport={canSupport}\n choices={choices}\n onToggle={setGroupOpen}\n />\n ))}\n </nav>\n\n <SidebarFooter\n collapsed={collapsed}\n showSettings={Boolean(settings)}\n settingsActive={settings ? isActive(settings, pathname) : false}\n onSearch={onSearch}\n />\n </aside>\n );\n}\n"],"mappings":";
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/_shell/sidebar.tsx"],"sourcesContent":["'use client';\n\nimport type { ComponentType } from 'react';\nimport type { NavGroup } from './nav-config';\nimport { isActive } from './nav-filter';\nimport { NavGroupView } from './nav-group';\nimport { useOpenGroups } from './use-open-groups';\nimport { SidebarFooter, type FooterAction } from './sidebar-footer';\nimport { useFluidHover } from './use-fluid-hover';\nimport { UserMenu } from './user-menu';\nimport styles from './sidebar.module.css';\n\n/** Settings lives in the pinned foot, beside the theme switch. */\nconst FOOT_HREF = '/admin/settings';\n\nexport function Sidebar({\n groups,\n pathname,\n canSupport,\n collapsed,\n email,\n role,\n onSearch,\n badges,\n footerActions,\n}: {\n groups: NavGroup[];\n pathname: string;\n canSupport: boolean;\n collapsed: boolean;\n email: string;\n role: string;\n onSearch: () => void;\n /** A host's own count per item, by href. */\n badges?: Record<string, ComponentType>;\n /** Icons a host adds to the foot, beside the theme switch. */\n footerActions?: FooterAction[];\n}) {\n const hover = useFluidHover();\n const settings = groups\n .flatMap((group) => group.items)\n .find((item) => item.href === FOOT_HREF);\n const navGroups = groups\n .map((group) => ({\n ...group,\n items: group.items.filter((item) => item.href !== FOOT_HREF),\n }))\n .filter((group) => group.items.length);\n const activeGroup =\n navGroups.find((group) =>\n group.items.some((item) => isActive(item, pathname)),\n )?.label ?? null;\n const { choices, setGroupOpen } = useOpenGroups(pathname, activeGroup);\n\n return (\n <aside className={styles.sidebar} data-collapsed={collapsed}>\n <UserMenu email={email} role={role} collapsed={collapsed} />\n\n <nav\n className={styles.nav}\n aria-label=\"Admin\"\n onPointerMove={hover.onPointerMove}\n onPointerLeave={hover.onPointerLeave}\n onClick={hover.onClick}\n >\n <span\n aria-hidden\n className={styles.glide}\n data-visible={Boolean(hover.rect)}\n style={\n hover.rect\n ? {\n transform: `translateY(${hover.rect.top}px)`,\n height: hover.rect.height,\n }\n : undefined\n }\n />\n {navGroups.map((group, index) => (\n <NavGroupView\n key={group.label ?? index}\n group={group}\n pathname={pathname}\n collapsed={collapsed}\n canSupport={canSupport}\n choices={choices}\n onToggle={setGroupOpen}\n badges={badges}\n />\n ))}\n </nav>\n\n <SidebarFooter\n collapsed={collapsed}\n showSettings={Boolean(settings)}\n settingsActive={settings ? isActive(settings, pathname) : false}\n actions={footerActions}\n pathname={pathname}\n onSearch={onSearch}\n />\n </aside>\n );\n}\n"],"mappings":";AAwDM,cAEA,YAFA;AApDN,SAAS,gBAAgB;AACzB,SAAS,oBAAoB;AAC7B,SAAS,qBAAqB;AAC9B,SAAS,qBAAwC;AACjD,SAAS,qBAAqB;AAC9B,SAAS,gBAAgB;AACzB,OAAO,YAAY;AAGnB,MAAM,YAAY;AAEX,SAAS,QAAQ;AAAA,EACtB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAYG;AACD,QAAM,QAAQ,cAAc;AAC5B,QAAM,WAAW,OACd,QAAQ,CAAC,UAAU,MAAM,KAAK,EAC9B,KAAK,CAAC,SAAS,KAAK,SAAS,SAAS;AACzC,QAAM,YAAY,OACf,IAAI,CAAC,WAAW;AAAA,IACf,GAAG;AAAA,IACH,OAAO,MAAM,MAAM,OAAO,CAAC,SAAS,KAAK,SAAS,SAAS;AAAA,EAC7D,EAAE,EACD,OAAO,CAAC,UAAU,MAAM,MAAM,MAAM;AACvC,QAAM,cACJ,UAAU;AAAA,IAAK,CAAC,UACd,MAAM,MAAM,KAAK,CAAC,SAAS,SAAS,MAAM,QAAQ,CAAC;AAAA,EACrD,GAAG,SAAS;AACd,QAAM,EAAE,SAAS,aAAa,IAAI,cAAc,UAAU,WAAW;AAErE,SACE,qBAAC,WAAM,WAAW,OAAO,SAAS,kBAAgB,WAChD;AAAA,wBAAC,YAAS,OAAc,MAAY,WAAsB;AAAA,IAE1D;AAAA,MAAC;AAAA;AAAA,QACC,WAAW,OAAO;AAAA,QAClB,cAAW;AAAA,QACX,eAAe,MAAM;AAAA,QACrB,gBAAgB,MAAM;AAAA,QACtB,SAAS,MAAM;AAAA,QAEf;AAAA;AAAA,YAAC;AAAA;AAAA,cACC,eAAW;AAAA,cACX,WAAW,OAAO;AAAA,cAClB,gBAAc,QAAQ,MAAM,IAAI;AAAA,cAChC,OACE,MAAM,OACF;AAAA,gBACE,WAAW,cAAc,MAAM,KAAK,GAAG;AAAA,gBACvC,QAAQ,MAAM,KAAK;AAAA,cACrB,IACA;AAAA;AAAA,UAER;AAAA,UACC,UAAU,IAAI,CAAC,OAAO,UACrB;AAAA,YAAC;AAAA;AAAA,cAEC;AAAA,cACA;AAAA,cACA;AAAA,cACA;AAAA,cACA;AAAA,cACA,UAAU;AAAA,cACV;AAAA;AAAA,YAPK,MAAM,SAAS;AAAA,UAQtB,CACD;AAAA;AAAA;AAAA,IACH;AAAA,IAEA;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA,cAAc,QAAQ,QAAQ;AAAA,QAC9B,gBAAgB,WAAW,SAAS,UAAU,QAAQ,IAAI;AAAA,QAC1D,SAAS;AAAA,QACT;AAAA,QACA;AAAA;AAAA,IACF;AAAA,KACF;AAEJ;","names":[]}
|
|
@@ -115,14 +115,22 @@
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/* A folding group has no gap of its own between heading and rows: that 2px
|
|
119
|
+
would pop in and out as the rows mount, so it lives inside the rows' padding
|
|
120
|
+
and grows with their height. */
|
|
121
|
+
.groupFold {
|
|
122
|
+
gap: 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
118
125
|
/* The rows of a folding group, laid out like the group's own. The padding keeps
|
|
119
|
-
a focus ring from being clipped by the closing box; the
|
|
126
|
+
a focus ring from being clipped by the closing box; the side and bottom
|
|
127
|
+
margins take it back, and the top padding is the gap under the heading. */
|
|
120
128
|
.groupRows {
|
|
121
129
|
display: flex;
|
|
122
130
|
flex-direction: column;
|
|
123
131
|
gap: 2px;
|
|
124
132
|
padding: 2px;
|
|
125
|
-
margin: -2px;
|
|
133
|
+
margin: 0 -2px -2px;
|
|
126
134
|
}
|
|
127
135
|
|
|
128
136
|
.link {
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState
|
|
7
|
+
} from "react";
|
|
3
8
|
function useFluidHover() {
|
|
4
9
|
const [rect, setRect] = useState(null);
|
|
5
|
-
const
|
|
6
|
-
|
|
10
|
+
const pointerY = useRef(null);
|
|
11
|
+
const frame = useRef(0);
|
|
12
|
+
const measure = useCallback((container, y) => {
|
|
7
13
|
const items = container.querySelectorAll("[data-fluid-item]");
|
|
8
14
|
let nearest = null;
|
|
9
15
|
let best = Infinity;
|
|
10
16
|
items.forEach((item) => {
|
|
11
17
|
const box2 = item.getBoundingClientRect();
|
|
12
|
-
const distance = Math.abs(
|
|
18
|
+
const distance = Math.abs(y - (box2.top + box2.height / 2));
|
|
13
19
|
if (distance < best) {
|
|
14
20
|
best = distance;
|
|
15
21
|
nearest = box2;
|
|
@@ -22,8 +28,33 @@ function useFluidHover() {
|
|
|
22
28
|
(current) => current && current.top === top && current.height === box.height ? current : { top, height: box.height }
|
|
23
29
|
);
|
|
24
30
|
}, []);
|
|
25
|
-
const
|
|
26
|
-
|
|
31
|
+
const onPointerMove = useCallback(
|
|
32
|
+
(event) => {
|
|
33
|
+
pointerY.current = event.clientY;
|
|
34
|
+
measure(event.currentTarget, event.clientY);
|
|
35
|
+
},
|
|
36
|
+
[measure]
|
|
37
|
+
);
|
|
38
|
+
const onPointerLeave = useCallback(() => {
|
|
39
|
+
pointerY.current = null;
|
|
40
|
+
setRect(null);
|
|
41
|
+
}, []);
|
|
42
|
+
const onClick = useCallback(
|
|
43
|
+
(event) => {
|
|
44
|
+
const container = event.currentTarget;
|
|
45
|
+
const until = performance.now() + 320;
|
|
46
|
+
cancelAnimationFrame(frame.current);
|
|
47
|
+
const follow = () => {
|
|
48
|
+
if (pointerY.current !== null) measure(container, pointerY.current);
|
|
49
|
+
if (performance.now() < until)
|
|
50
|
+
frame.current = requestAnimationFrame(follow);
|
|
51
|
+
};
|
|
52
|
+
frame.current = requestAnimationFrame(follow);
|
|
53
|
+
},
|
|
54
|
+
[measure]
|
|
55
|
+
);
|
|
56
|
+
useEffect(() => () => cancelAnimationFrame(frame.current), []);
|
|
57
|
+
return { rect, onPointerMove, onPointerLeave, onClick };
|
|
27
58
|
}
|
|
28
59
|
export {
|
|
29
60
|
useFluidHover
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/_shell/use-fluid-hover.ts"],"sourcesContent":["'use client';\n\nimport {
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/_shell/use-fluid-hover.ts"],"sourcesContent":["'use client';\n\nimport {\n useCallback,\n useEffect,\n useRef,\n useState,\n type MouseEvent,\n type PointerEvent,\n} from 'react';\n\ntype Rect = { top: number; height: number } | null;\n\n/**\n * Fluid hover: one highlight that glides to the item nearest the pointer\n * (not just the one under it), so the target is lit before the click lands.\n * Items opt in with `data-fluid-item`; bind the handlers on their scrolling\n * container. The glide is a CSS spring on `transform`, so it retargets\n * smoothly when the pointer changes direction.\n */\nexport function useFluidHover() {\n const [rect, setRect] = useState<Rect>(null);\n const pointerY = useRef<number | null>(null);\n const frame = useRef(0);\n\n const measure = useCallback((container: HTMLElement, y: number) => {\n const items = container.querySelectorAll<HTMLElement>('[data-fluid-item]');\n let nearest: DOMRect | null = null;\n let best = Infinity;\n\n items.forEach((item) => {\n const box = item.getBoundingClientRect();\n const distance = Math.abs(y - (box.top + box.height / 2));\n\n if (distance < best) {\n best = distance;\n nearest = box;\n }\n });\n\n if (!nearest) return;\n\n const box: DOMRect = nearest;\n const top =\n box.top - container.getBoundingClientRect().top + container.scrollTop;\n\n setRect((current) =>\n current && current.top === top && current.height === box.height\n ? current\n : { top, height: box.height },\n );\n }, []);\n\n const onPointerMove = useCallback(\n (event: PointerEvent<HTMLElement>) => {\n pointerY.current = event.clientY;\n measure(event.currentTarget, event.clientY);\n },\n [measure],\n );\n\n const onPointerLeave = useCallback(() => {\n pointerY.current = null;\n setRect(null);\n }, []);\n\n /**\n * A group opening or closing moves the rows under a still pointer, and no\n * pointer event follows, so the highlight would stay where the row was. After\n * a press, follow the rows for the length of the move.\n */\n const onClick = useCallback(\n (event: MouseEvent<HTMLElement>) => {\n const container = event.currentTarget;\n const until = performance.now() + 320;\n cancelAnimationFrame(frame.current);\n\n const follow = () => {\n if (pointerY.current !== null) measure(container, pointerY.current);\n if (performance.now() < until)\n frame.current = requestAnimationFrame(follow);\n };\n\n frame.current = requestAnimationFrame(follow);\n },\n [measure],\n );\n\n useEffect(() => () => cancelAnimationFrame(frame.current), []);\n\n return { rect, onPointerMove, onPointerLeave, onClick };\n}\n"],"mappings":";AAEA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAGK;AAWA,SAAS,gBAAgB;AAC9B,QAAM,CAAC,MAAM,OAAO,IAAI,SAAe,IAAI;AAC3C,QAAM,WAAW,OAAsB,IAAI;AAC3C,QAAM,QAAQ,OAAO,CAAC;AAEtB,QAAM,UAAU,YAAY,CAAC,WAAwB,MAAc;AACjE,UAAM,QAAQ,UAAU,iBAA8B,mBAAmB;AACzE,QAAI,UAA0B;AAC9B,QAAI,OAAO;AAEX,UAAM,QAAQ,CAAC,SAAS;AACtB,YAAMA,OAAM,KAAK,sBAAsB;AACvC,YAAM,WAAW,KAAK,IAAI,KAAKA,KAAI,MAAMA,KAAI,SAAS,EAAE;AAExD,UAAI,WAAW,MAAM;AACnB,eAAO;AACP,kBAAUA;AAAA,MACZ;AAAA,IACF,CAAC;AAED,QAAI,CAAC,QAAS;AAEd,UAAM,MAAe;AACrB,UAAM,MACJ,IAAI,MAAM,UAAU,sBAAsB,EAAE,MAAM,UAAU;AAE9D;AAAA,MAAQ,CAAC,YACP,WAAW,QAAQ,QAAQ,OAAO,QAAQ,WAAW,IAAI,SACrD,UACA,EAAE,KAAK,QAAQ,IAAI,OAAO;AAAA,IAChC;AAAA,EACF,GAAG,CAAC,CAAC;AAEL,QAAM,gBAAgB;AAAA,IACpB,CAAC,UAAqC;AACpC,eAAS,UAAU,MAAM;AACzB,cAAQ,MAAM,eAAe,MAAM,OAAO;AAAA,IAC5C;AAAA,IACA,CAAC,OAAO;AAAA,EACV;AAEA,QAAM,iBAAiB,YAAY,MAAM;AACvC,aAAS,UAAU;AACnB,YAAQ,IAAI;AAAA,EACd,GAAG,CAAC,CAAC;AAOL,QAAM,UAAU;AAAA,IACd,CAAC,UAAmC;AAClC,YAAM,YAAY,MAAM;AACxB,YAAM,QAAQ,YAAY,IAAI,IAAI;AAClC,2BAAqB,MAAM,OAAO;AAElC,YAAM,SAAS,MAAM;AACnB,YAAI,SAAS,YAAY,KAAM,SAAQ,WAAW,SAAS,OAAO;AAClE,YAAI,YAAY,IAAI,IAAI;AACtB,gBAAM,UAAU,sBAAsB,MAAM;AAAA,MAChD;AAEA,YAAM,UAAU,sBAAsB,MAAM;AAAA,IAC9C;AAAA,IACA,CAAC,OAAO;AAAA,EACV;AAEA,YAAU,MAAM,MAAM,qBAAqB,MAAM,OAAO,GAAG,CAAC,CAAC;AAE7D,SAAO,EAAE,MAAM,eAAe,gBAAgB,QAAQ;AACxD;","names":["box"]}
|
|
@@ -1,18 +1,62 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { useState } from "react";
|
|
2
|
+
import { useState, useSyncExternalStore } from "react";
|
|
3
|
+
const STORAGE_KEY = "pb_admin_nav_groups";
|
|
4
|
+
let cache = {};
|
|
5
|
+
let cachedRaw = null;
|
|
6
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
7
|
+
function read() {
|
|
8
|
+
if (typeof window === "undefined") return cache;
|
|
9
|
+
let raw = null;
|
|
10
|
+
try {
|
|
11
|
+
raw = localStorage.getItem(STORAGE_KEY);
|
|
12
|
+
} catch {
|
|
13
|
+
return cache;
|
|
14
|
+
}
|
|
15
|
+
if (raw !== cachedRaw) {
|
|
16
|
+
cachedRaw = raw;
|
|
17
|
+
try {
|
|
18
|
+
const parsed = raw ? JSON.parse(raw) : {};
|
|
19
|
+
cache = parsed && typeof parsed === "object" ? parsed : {};
|
|
20
|
+
} catch {
|
|
21
|
+
cache = {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return cache;
|
|
25
|
+
}
|
|
26
|
+
const empty = {};
|
|
27
|
+
const emptySnapshot = () => empty;
|
|
28
|
+
function subscribe(listener) {
|
|
29
|
+
listeners.add(listener);
|
|
30
|
+
return () => listeners.delete(listener);
|
|
31
|
+
}
|
|
32
|
+
function remember(label, open) {
|
|
33
|
+
const next = { ...read(), [label]: open };
|
|
34
|
+
try {
|
|
35
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
36
|
+
} catch {
|
|
37
|
+
}
|
|
38
|
+
cachedRaw = JSON.stringify(next);
|
|
39
|
+
cache = next;
|
|
40
|
+
listeners.forEach((listener) => listener());
|
|
41
|
+
}
|
|
3
42
|
function useOpenGroups(pathname, activeGroup) {
|
|
4
|
-
const
|
|
5
|
-
const
|
|
43
|
+
const remembered = useSyncExternalStore(subscribe, read, emptySnapshot);
|
|
44
|
+
const [arrived, setArrived] = useState({});
|
|
6
45
|
const [choiceRoute, setChoiceRoute] = useState(pathname);
|
|
7
46
|
if (choiceRoute !== pathname) {
|
|
8
47
|
setChoiceRoute(pathname);
|
|
9
|
-
if (activeGroup && activeGroup
|
|
10
|
-
|
|
11
|
-
delete next[activeGroup];
|
|
12
|
-
setChoices(next);
|
|
13
|
-
}
|
|
48
|
+
if (activeGroup && arrived[activeGroup] !== true)
|
|
49
|
+
setArrived({ ...arrived, [activeGroup]: true });
|
|
14
50
|
}
|
|
15
|
-
|
|
51
|
+
const setGroupOpen = (label, next) => {
|
|
52
|
+
if (label in arrived) {
|
|
53
|
+
const rest = { ...arrived };
|
|
54
|
+
delete rest[label];
|
|
55
|
+
setArrived(rest);
|
|
56
|
+
}
|
|
57
|
+
remember(label, next);
|
|
58
|
+
};
|
|
59
|
+
return { choices: { ...remembered, ...arrived }, setGroupOpen };
|
|
16
60
|
}
|
|
17
61
|
export {
|
|
18
62
|
useOpenGroups
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/_shell/use-open-groups.ts"],"sourcesContent":["'use client';\n\nimport { useState } from 'react';\nimport type { GroupChoices } from './nav-fold';\n\n/**\n *
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/_shell/use-open-groups.ts"],"sourcesContent":["'use client';\n\nimport { useState, useSyncExternalStore } from 'react';\nimport type { GroupChoices } from './nav-fold';\n\nconst STORAGE_KEY = 'pb_admin_nav_groups';\n\n/**\n * The groups the admin has opened or closed by hand, remembered across visits.\n * An external store, not state read in an effect: localStorage does not exist\n * while the server renders, and the snapshot must stay referentially stable\n * between renders, so it is cached by the raw string.\n */\nlet cache: GroupChoices = {};\nlet cachedRaw: string | null = null;\nconst listeners = new Set<() => void>();\n\nfunction read(): GroupChoices {\n if (typeof window === 'undefined') return cache;\n\n let raw: string | null = null;\n\n try {\n raw = localStorage.getItem(STORAGE_KEY);\n } catch {\n return cache;\n }\n\n if (raw !== cachedRaw) {\n cachedRaw = raw;\n\n try {\n const parsed: unknown = raw ? JSON.parse(raw) : {};\n\n cache =\n parsed && typeof parsed === 'object' ? (parsed as GroupChoices) : {};\n } catch {\n cache = {};\n }\n }\n\n return cache;\n}\n\nconst empty: GroupChoices = {};\nconst emptySnapshot = () => empty;\n\nfunction subscribe(listener: () => void) {\n listeners.add(listener);\n\n return () => listeners.delete(listener);\n}\n\nfunction remember(label: string, open: boolean) {\n const next = { ...read(), [label]: open };\n\n try {\n localStorage.setItem(STORAGE_KEY, JSON.stringify(next));\n } catch {\n /* private mode: the choice just won't survive the visit */\n }\n\n cachedRaw = JSON.stringify(next);\n cache = next;\n listeners.forEach((listener) => listener());\n}\n\n/**\n * Which sidebar groups are open by choice, each on its own: pressing one never\n * moves another. `activeGroup` is the group holding the current page, or null.\n *\n * Arriving on a page opens its own group and holds it open, so a group that was\n * open only for the last page does not shut by itself, and one closed by hand\n * does not hide the row you landed on. That arrival is kept for the visit only;\n * what is remembered is what was pressed. Pressing a group again takes over.\n * Adjusted during render, not in an effect, so no stale frame paints.\n */\nexport function useOpenGroups(pathname: string, activeGroup: string | null) {\n const remembered = useSyncExternalStore(subscribe, read, emptySnapshot);\n const [arrived, setArrived] = useState<GroupChoices>({});\n const [choiceRoute, setChoiceRoute] = useState(pathname);\n\n if (choiceRoute !== pathname) {\n setChoiceRoute(pathname);\n\n if (activeGroup && arrived[activeGroup] !== true)\n setArrived({ ...arrived, [activeGroup]: true });\n }\n\n const setGroupOpen = (label: string, next: boolean) => {\n if (label in arrived) {\n const rest = { ...arrived };\n\n delete rest[label];\n setArrived(rest);\n }\n\n remember(label, next);\n };\n\n return { choices: { ...remembered, ...arrived }, setGroupOpen };\n}\n"],"mappings":";AAEA,SAAS,UAAU,4BAA4B;AAG/C,MAAM,cAAc;AAQpB,IAAI,QAAsB,CAAC;AAC3B,IAAI,YAA2B;AAC/B,MAAM,YAAY,oBAAI,IAAgB;AAEtC,SAAS,OAAqB;AAC5B,MAAI,OAAO,WAAW,YAAa,QAAO;AAE1C,MAAI,MAAqB;AAEzB,MAAI;AACF,UAAM,aAAa,QAAQ,WAAW;AAAA,EACxC,QAAQ;AACN,WAAO;AAAA,EACT;AAEA,MAAI,QAAQ,WAAW;AACrB,gBAAY;AAEZ,QAAI;AACF,YAAM,SAAkB,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;AAEjD,cACE,UAAU,OAAO,WAAW,WAAY,SAA0B,CAAC;AAAA,IACvE,QAAQ;AACN,cAAQ,CAAC;AAAA,IACX;AAAA,EACF;AAEA,SAAO;AACT;AAEA,MAAM,QAAsB,CAAC;AAC7B,MAAM,gBAAgB,MAAM;AAE5B,SAAS,UAAU,UAAsB;AACvC,YAAU,IAAI,QAAQ;AAEtB,SAAO,MAAM,UAAU,OAAO,QAAQ;AACxC;AAEA,SAAS,SAAS,OAAe,MAAe;AAC9C,QAAM,OAAO,EAAE,GAAG,KAAK,GAAG,CAAC,KAAK,GAAG,KAAK;AAExC,MAAI;AACF,iBAAa,QAAQ,aAAa,KAAK,UAAU,IAAI,CAAC;AAAA,EACxD,QAAQ;AAAA,EAER;AAEA,cAAY,KAAK,UAAU,IAAI;AAC/B,UAAQ;AACR,YAAU,QAAQ,CAAC,aAAa,SAAS,CAAC;AAC5C;AAYO,SAAS,cAAc,UAAkB,aAA4B;AAC1E,QAAM,aAAa,qBAAqB,WAAW,MAAM,aAAa;AACtE,QAAM,CAAC,SAAS,UAAU,IAAI,SAAuB,CAAC,CAAC;AACvD,QAAM,CAAC,aAAa,cAAc,IAAI,SAAS,QAAQ;AAEvD,MAAI,gBAAgB,UAAU;AAC5B,mBAAe,QAAQ;AAEvB,QAAI,eAAe,QAAQ,WAAW,MAAM;AAC1C,iBAAW,EAAE,GAAG,SAAS,CAAC,WAAW,GAAG,KAAK,CAAC;AAAA,EAClD;AAEA,QAAM,eAAe,CAAC,OAAe,SAAkB;AACrD,QAAI,SAAS,SAAS;AACpB,YAAM,OAAO,EAAE,GAAG,QAAQ;AAE1B,aAAO,KAAK,KAAK;AACjB,iBAAW,IAAI;AAAA,IACjB;AAEA,aAAS,OAAO,IAAI;AAAA,EACtB;AAEA,SAAO,EAAE,SAAS,EAAE,GAAG,YAAY,GAAG,QAAQ,GAAG,aAAa;AAChE;","names":[]}
|
package/dist/admin/layout.js
CHANGED
|
@@ -32,7 +32,9 @@ function AdminLayout({
|
|
|
32
32
|
children,
|
|
33
33
|
modules = NO_MODULES,
|
|
34
34
|
additions = NO_ADDITIONS,
|
|
35
|
-
brand
|
|
35
|
+
brand,
|
|
36
|
+
badges,
|
|
37
|
+
footerActions
|
|
36
38
|
}) {
|
|
37
39
|
const pathname = usePathname();
|
|
38
40
|
const router = useRouter();
|
|
@@ -88,7 +90,11 @@ function AdminLayout({
|
|
|
88
90
|
collapsed: prefs.rail,
|
|
89
91
|
email: session.email ?? "",
|
|
90
92
|
role: session.role,
|
|
91
|
-
|
|
93
|
+
footerActions: footerActions?.filter(
|
|
94
|
+
(action) => !action.superadmin || session.role === "superadmin"
|
|
95
|
+
),
|
|
96
|
+
onSearch: prefs.openPalette,
|
|
97
|
+
badges
|
|
92
98
|
}
|
|
93
99
|
),
|
|
94
100
|
!prefs.narrow && /* @__PURE__ */ jsx(
|
package/dist/admin/layout.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/admin/layout.tsx"],"sourcesContent":["'use client';\n\nimport { useCallback, useEffect } from 'react';\nimport { MotionConfig } from 'motion/react';\nimport { usePathname, useRouter } from 'next/navigation';\nimport { useAdminSession } from './hooks';\nimport { InboxAlerts } from './_inbox/nav-badge';\nimport { CommandPalette } from './_shell/command-palette';\nimport { PRODUCT_FEEDBACK_NAV, type NavAddition } from './_shell/nav-config';\nimport { canAccess, visibleGroups } from './_shell/nav-filter';\nimport { moduleOn, type AdminModules } from './_shell/nav-modules';\nimport { AdminModulesProvider } from './_shell/modules-context';\nimport { SeamToggle } from './_shell/seam-toggle';\nimport { SectionTabs } from './_shell/section-tabs';\nimport { Sidebar } from './_shell/sidebar';\nimport { Topbar } from './_shell/topbar';\nimport { AdminBrandProvider, type AdminBrand } from './_shell/admin-brand';\nimport { useShellPrefs } from './_shell/use-shell-prefs';\nimport { ShellSkeleton } from './_shell/shell-skeleton';\nimport { useAdminQueryDefaults } from './_shell/use-admin-query-defaults';\nimport { PageTransition } from './_fx';\nimport { LiveAnnouncer, useApplyPreferences, useStartPage } from './_settings';\nimport styles from './_shell/frame.module.css';\nimport './_ui/admin.css';\nimport './_ui/motion.css';\nimport './_ui/preferences.css';\nimport './_ui/tint.css';\n\nexport type { AdminModule, AdminModules } from './_shell/nav-modules';\nexport type { NavAddition, NavItem } from './_shell/nav-config';\nexport type { AdminBrand } from './_shell/admin-brand';\n\nconst AUTH_ROUTES = ['/admin/login', '/admin/change-password'];\n\nconst NO_MODULES: AdminModules = {};\nconst NO_ADDITIONS: NavAddition[] = [];\n\n/**\n * The Box admin shell. A host turns on the Box modules its engine serves\n * (`modules`), adds its own pages into the menu's groups (`additions`) and\n * shows its own logo and mark (`brand`). A module that is off is not listed anywhere.\n */\nexport default function AdminLayout({\n children,\n modules = NO_MODULES,\n additions = NO_ADDITIONS,\n brand,\n}: {\n children: React.ReactNode;\n modules?: AdminModules;\n additions?: NavAddition[];\n brand?: AdminBrand;\n}) {\n const pathname = usePathname();\n const router = useRouter();\n const isAuthRoute = AUTH_ROUTES.includes(pathname);\n const { data: session, isLoading, isError } = useAdminSession(!isAuthRoute);\n const prefs = useShellPrefs();\n const { shellProps, reducedMotion } = useApplyPreferences();\n const ready = !isAuthRoute && !!session && !session.mustChangePassword;\n const allowed = useCallback(\n (href: string) =>\n !!session &&\n visibleGroups(session, modules, additions).some((g) =>\n g.items.some((item) => item.href === href),\n ),\n [session, modules, additions],\n );\n\n useAdminQueryDefaults();\n useStartPage(pathname, ready, allowed);\n\n useEffect(() => {\n if (isAuthRoute || isLoading) return;\n\n if (isError) {\n // Back to this page after signing in.\n const back = encodeURIComponent(pathname + window.location.search);\n router.replace(`/admin/login?redirect=${back}`);\n } else if (session?.mustChangePassword) {\n router.replace('/admin/change-password');\n }\n }, [isAuthRoute, isLoading, isError, session, router]);\n\n if (isAuthRoute) {\n return <AdminBrandProvider value={brand}>{children}</AdminBrandProvider>;\n }\n\n if (isLoading) return <ShellSkeleton />;\n\n if (isError || !session || session.mustChangePassword) return null;\n\n const groups = visibleGroups(session, modules, additions);\n const canSupport = groups.some((g) =>\n g.items.some(({ href }) => href === '/admin/support'),\n );\n // The bell and the inbox counts read Product feedback (and Support when\n // this admin has it), so they run only where feedback is served.\n const showInbox = moduleOn(modules, 'feedback');\n const feedbackTabs = PRODUCT_FEEDBACK_NAV.filter(canAccess(session)).filter(\n (tab) => moduleOn(modules, tab.module),\n );\n // One tab left is no choice, so the strip only shows with two or more.\n const showFeedbackTabs =\n showInbox &&\n feedbackTabs.length > 1 &&\n feedbackTabs.some(({ href }) => pathname.startsWith(href));\n\n return (\n <AdminBrandProvider value={brand}>\n <AdminModulesProvider value={modules}>\n <MotionConfig reducedMotion={reducedMotion}>\n <div\n className={`adm-shell ${styles.shell}`}\n data-collapsed={prefs.rail}\n {...shellProps}\n >\n <Sidebar\n groups={groups}\n pathname={pathname}\n canSupport={canSupport}\n collapsed={prefs.rail}\n email={session.email ?? ''}\n role={session.role}\n onSearch={prefs.openPalette}\n />\n {!prefs.narrow && (\n <SeamToggle\n collapsed={prefs.collapsed}\n onToggle={prefs.toggleCollapsed}\n />\n )}\n <div className={styles.column}>\n <Topbar\n groups={groups}\n pathname={pathname}\n canSupport={canSupport}\n showInbox={showInbox}\n />\n <main\n className={`adm-main ${styles.main}`}\n data-full={pathname.startsWith('/admin/support') || undefined}\n >\n {showFeedbackTabs && (\n <SectionTabs\n items={feedbackTabs}\n pathname={pathname}\n label=\"Product feedback\"\n />\n )}\n <PageTransition routeKey={pathname}>{children}</PageTransition>\n </main>\n </div>\n {showInbox && (\n <InboxAlerts canSupport={canSupport} pathname={pathname} />\n )}\n <CommandPalette\n groups={groups}\n open={prefs.paletteOpen}\n onClose={prefs.closePalette}\n />\n <LiveAnnouncer />\n </div>\n </MotionConfig>\n </AdminModulesProvider>\n </AdminBrandProvider>\n );\n}\n"],"mappings":";
|
|
1
|
+
{"version":3,"sources":["../../src/admin/layout.tsx"],"sourcesContent":["'use client';\n\nimport { useCallback, useEffect, type ComponentType } from 'react';\nimport { MotionConfig } from 'motion/react';\nimport { usePathname, useRouter } from 'next/navigation';\nimport { useAdminSession } from './hooks';\nimport { InboxAlerts } from './_inbox/nav-badge';\nimport { CommandPalette } from './_shell/command-palette';\nimport { PRODUCT_FEEDBACK_NAV, type NavAddition } from './_shell/nav-config';\nimport { canAccess, visibleGroups } from './_shell/nav-filter';\nimport { moduleOn, type AdminModules } from './_shell/nav-modules';\nimport { AdminModulesProvider } from './_shell/modules-context';\nimport { SeamToggle } from './_shell/seam-toggle';\nimport { SectionTabs } from './_shell/section-tabs';\nimport { Sidebar } from './_shell/sidebar';\nimport type { FooterAction } from './_shell/sidebar-footer';\nimport { Topbar } from './_shell/topbar';\nimport { AdminBrandProvider, type AdminBrand } from './_shell/admin-brand';\nimport { useShellPrefs } from './_shell/use-shell-prefs';\nimport { ShellSkeleton } from './_shell/shell-skeleton';\nimport { useAdminQueryDefaults } from './_shell/use-admin-query-defaults';\nimport { PageTransition } from './_fx';\nimport { LiveAnnouncer, useApplyPreferences, useStartPage } from './_settings';\nimport styles from './_shell/frame.module.css';\nimport './_ui/admin.css';\nimport './_ui/motion.css';\nimport './_ui/preferences.css';\nimport './_ui/tint.css';\n\nexport type { AdminModule, AdminModules } from './_shell/nav-modules';\nexport type { NavAddition, NavItem } from './_shell/nav-config';\nexport type { AdminBrand } from './_shell/admin-brand';\nexport type { FooterAction } from './_shell/sidebar-footer';\n\nconst AUTH_ROUTES = ['/admin/login', '/admin/change-password'];\n\nconst NO_MODULES: AdminModules = {};\nconst NO_ADDITIONS: NavAddition[] = [];\n\n/**\n * The Box admin shell. A host turns on the Box modules its engine serves\n * (`modules`), adds its own pages into the menu's groups (`additions`) and\n * shows its own logo and mark (`brand`). A module that is off is not listed anywhere.\n * `badges` puts a host's own count beside a menu item: a component per item href that renders one\n * `span`, shown in the same slot as the Support count.\n */\nexport default function AdminLayout({\n children,\n modules = NO_MODULES,\n additions = NO_ADDITIONS,\n brand,\n badges,\n footerActions,\n}: {\n children: React.ReactNode;\n modules?: AdminModules;\n additions?: NavAddition[];\n brand?: AdminBrand;\n badges?: Record<string, ComponentType>;\n /** Icons a host adds to the sidebar's foot, beside the theme switch. */\n footerActions?: FooterAction[];\n}) {\n const pathname = usePathname();\n const router = useRouter();\n const isAuthRoute = AUTH_ROUTES.includes(pathname);\n const { data: session, isLoading, isError } = useAdminSession(!isAuthRoute);\n const prefs = useShellPrefs();\n const { shellProps, reducedMotion } = useApplyPreferences();\n const ready = !isAuthRoute && !!session && !session.mustChangePassword;\n const allowed = useCallback(\n (href: string) =>\n !!session &&\n visibleGroups(session, modules, additions).some((g) =>\n g.items.some((item) => item.href === href),\n ),\n [session, modules, additions],\n );\n\n useAdminQueryDefaults();\n useStartPage(pathname, ready, allowed);\n\n useEffect(() => {\n if (isAuthRoute || isLoading) return;\n\n if (isError) {\n // Back to this page after signing in.\n const back = encodeURIComponent(pathname + window.location.search);\n router.replace(`/admin/login?redirect=${back}`);\n } else if (session?.mustChangePassword) {\n router.replace('/admin/change-password');\n }\n }, [isAuthRoute, isLoading, isError, session, router]);\n\n if (isAuthRoute) {\n return <AdminBrandProvider value={brand}>{children}</AdminBrandProvider>;\n }\n\n if (isLoading) return <ShellSkeleton />;\n\n if (isError || !session || session.mustChangePassword) return null;\n\n const groups = visibleGroups(session, modules, additions);\n const canSupport = groups.some((g) =>\n g.items.some(({ href }) => href === '/admin/support'),\n );\n // The bell and the inbox counts read Product feedback (and Support when\n // this admin has it), so they run only where feedback is served.\n const showInbox = moduleOn(modules, 'feedback');\n const feedbackTabs = PRODUCT_FEEDBACK_NAV.filter(canAccess(session)).filter(\n (tab) => moduleOn(modules, tab.module),\n );\n // One tab left is no choice, so the strip only shows with two or more.\n const showFeedbackTabs =\n showInbox &&\n feedbackTabs.length > 1 &&\n feedbackTabs.some(({ href }) => pathname.startsWith(href));\n\n return (\n <AdminBrandProvider value={brand}>\n <AdminModulesProvider value={modules}>\n <MotionConfig reducedMotion={reducedMotion}>\n <div\n className={`adm-shell ${styles.shell}`}\n data-collapsed={prefs.rail}\n {...shellProps}\n >\n <Sidebar\n groups={groups}\n pathname={pathname}\n canSupport={canSupport}\n collapsed={prefs.rail}\n email={session.email ?? ''}\n role={session.role}\n footerActions={footerActions?.filter(\n (action) => !action.superadmin || session.role === 'superadmin',\n )}\n onSearch={prefs.openPalette}\n badges={badges}\n />\n {!prefs.narrow && (\n <SeamToggle\n collapsed={prefs.collapsed}\n onToggle={prefs.toggleCollapsed}\n />\n )}\n <div className={styles.column}>\n <Topbar\n groups={groups}\n pathname={pathname}\n canSupport={canSupport}\n showInbox={showInbox}\n />\n <main\n className={`adm-main ${styles.main}`}\n data-full={pathname.startsWith('/admin/support') || undefined}\n >\n {showFeedbackTabs && (\n <SectionTabs\n items={feedbackTabs}\n pathname={pathname}\n label=\"Product feedback\"\n />\n )}\n <PageTransition routeKey={pathname}>{children}</PageTransition>\n </main>\n </div>\n {showInbox && (\n <InboxAlerts canSupport={canSupport} pathname={pathname} />\n )}\n <CommandPalette\n groups={groups}\n open={prefs.paletteOpen}\n onClose={prefs.closePalette}\n />\n <LiveAnnouncer />\n </div>\n </MotionConfig>\n </AdminModulesProvider>\n </AdminBrandProvider>\n );\n}\n"],"mappings":"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","names":[]}
|
package/dist/admin/page.js
CHANGED
|
@@ -106,7 +106,14 @@ function AdminIndexPage() {
|
|
|
106
106
|
] })
|
|
107
107
|
}
|
|
108
108
|
),
|
|
109
|
-
/* @__PURE__ */ jsx(
|
|
109
|
+
/* @__PURE__ */ jsx(
|
|
110
|
+
ControlRoom,
|
|
111
|
+
{
|
|
112
|
+
attention: data.attention,
|
|
113
|
+
currency,
|
|
114
|
+
totals: data.totals
|
|
115
|
+
}
|
|
116
|
+
),
|
|
110
117
|
/* @__PURE__ */ jsx(
|
|
111
118
|
KpiRow,
|
|
112
119
|
{
|
package/dist/admin/page.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/admin/page.tsx"],"sourcesContent":["'use client';\n\nimport { useMemo } from 'react';\nimport {\n AdmLoading,\n CurrencyFilter,\n GmvOrdersChart,\n PICK_CURRENCY,\n PageHeader,\n Panel,\n PeriodSelector,\n periodPhrase,\n customRange,\n focusCurrency,\n moneyIn,\n rangeFromKey,\n rowFor,\n toTrend,\n useCurrencyParam,\n usePersistedDateRange,\n} from './_ui';\nimport { usePlatformCockpit } from './hooks';\nimport { ActivationPanel } from './_overview/activation-panel';\nimport { AttentionPanel } from './_overview/attention-panel';\nimport { ControlRoom } from './_overview/control-room';\nimport { KpiRow } from './_overview/kpi-row';\nimport { LeadersPanel } from './_overview/leaders-panel';\nimport { PortfolioPanel } from './_overview/portfolio-panel';\nimport { PositionPanel } from './_overview/position-panel';\n\nexport default function AdminIndexPage() {\n const {\n rangeKey,\n setRangeKey,\n customStart,\n customEnd,\n onCustomChange,\n compare,\n } = usePersistedDateRange();\n // Money comes back once per currency; `?currency=` narrows it to one.\n const [currency, setCurrency] = useCurrencyParam();\n const range = useMemo(\n () =>\n rangeKey === 'custom'\n ? customRange(customStart, customEnd)\n : rangeFromKey(rangeKey),\n [customEnd, customStart, rangeKey],\n );\n const cockpit = usePlatformCockpit({\n startDate: range.startDate,\n endDate: range.endDate,\n });\n const currencies = useMemo(\n () => cockpit.data?.currencies ?? [],\n [cockpit.data],\n );\n // A chart can only draw one currency: the filter's, or the only one.\n const focus = focusCurrency(currencies, currency);\n const series = useMemo(\n () =>\n rowFor(cockpit.data?.trend.performanceSeriesByCurrency, focus)?.series,\n [cockpit.data, focus],\n );\n const gmvTrend = useMemo(() => toTrend(series, 'gmv'), [series]);\n const ordersTrend = useMemo(() => toTrend(series, 'orders'), [series]);\n\n if (cockpit.isLoading) {\n return <AdmLoading label=\"Loading platform operations\" />;\n }\n\n if (cockpit.isError || !cockpit.data) {\n return (\n <div className=\"adm-panel\" role=\"alert\">\n <strong>Platform operations could not be loaded.</strong>\n <p className=\"mb-0 mt-2 text-sm text-[var(--text-secondary)]\">\n The range-aware admin API did not return the operating summary.\n </p>\n <button\n type=\"button\"\n className=\"mt-4 text-sm font-semibold text-[var(--accent-primary)]\"\n onClick={() => void cockpit.refetch()}\n >\n Try again\n </button>\n </div>\n );\n }\n\n const data = cockpit.data;\n const span = periodPhrase(range);\n const period = `${span[0].toUpperCase()}${span.slice(1)}`;\n const caption = focus ? `${period} · ${focus}` : period;\n\n return (\n <div>\n <PageHeader\n title=\"Platform operations\"\n description=\"Merchant growth, money movement and exceptions to review.\"\n actions={\n <div className=\"adm-toolbar\">\n <CurrencyFilter\n value={currency}\n onChange={setCurrency}\n currencies={currencies}\n />\n <PeriodSelector\n value={rangeKey}\n onChange={setRangeKey}\n customStart={customStart}\n customEnd={customEnd}\n onCustomChange={onCustomChange}\n />\n </div>\n }\n />\n\n <ControlRoom
|
|
1
|
+
{"version":3,"sources":["../../src/admin/page.tsx"],"sourcesContent":["'use client';\n\nimport { useMemo } from 'react';\nimport {\n AdmLoading,\n CurrencyFilter,\n GmvOrdersChart,\n PICK_CURRENCY,\n PageHeader,\n Panel,\n PeriodSelector,\n periodPhrase,\n customRange,\n focusCurrency,\n moneyIn,\n rangeFromKey,\n rowFor,\n toTrend,\n useCurrencyParam,\n usePersistedDateRange,\n} from './_ui';\nimport { usePlatformCockpit } from './hooks';\nimport { ActivationPanel } from './_overview/activation-panel';\nimport { AttentionPanel } from './_overview/attention-panel';\nimport { ControlRoom } from './_overview/control-room';\nimport { KpiRow } from './_overview/kpi-row';\nimport { LeadersPanel } from './_overview/leaders-panel';\nimport { PortfolioPanel } from './_overview/portfolio-panel';\nimport { PositionPanel } from './_overview/position-panel';\n\nexport default function AdminIndexPage() {\n const {\n rangeKey,\n setRangeKey,\n customStart,\n customEnd,\n onCustomChange,\n compare,\n } = usePersistedDateRange();\n // Money comes back once per currency; `?currency=` narrows it to one.\n const [currency, setCurrency] = useCurrencyParam();\n const range = useMemo(\n () =>\n rangeKey === 'custom'\n ? customRange(customStart, customEnd)\n : rangeFromKey(rangeKey),\n [customEnd, customStart, rangeKey],\n );\n const cockpit = usePlatformCockpit({\n startDate: range.startDate,\n endDate: range.endDate,\n });\n const currencies = useMemo(\n () => cockpit.data?.currencies ?? [],\n [cockpit.data],\n );\n // A chart can only draw one currency: the filter's, or the only one.\n const focus = focusCurrency(currencies, currency);\n const series = useMemo(\n () =>\n rowFor(cockpit.data?.trend.performanceSeriesByCurrency, focus)?.series,\n [cockpit.data, focus],\n );\n const gmvTrend = useMemo(() => toTrend(series, 'gmv'), [series]);\n const ordersTrend = useMemo(() => toTrend(series, 'orders'), [series]);\n\n if (cockpit.isLoading) {\n return <AdmLoading label=\"Loading platform operations\" />;\n }\n\n if (cockpit.isError || !cockpit.data) {\n return (\n <div className=\"adm-panel\" role=\"alert\">\n <strong>Platform operations could not be loaded.</strong>\n <p className=\"mb-0 mt-2 text-sm text-[var(--text-secondary)]\">\n The range-aware admin API did not return the operating summary.\n </p>\n <button\n type=\"button\"\n className=\"mt-4 text-sm font-semibold text-[var(--accent-primary)]\"\n onClick={() => void cockpit.refetch()}\n >\n Try again\n </button>\n </div>\n );\n }\n\n const data = cockpit.data;\n const span = periodPhrase(range);\n const period = `${span[0].toUpperCase()}${span.slice(1)}`;\n const caption = focus ? `${period} · ${focus}` : period;\n\n return (\n <div>\n <PageHeader\n title=\"Platform operations\"\n description=\"Merchant growth, money movement and exceptions to review.\"\n actions={\n <div className=\"adm-toolbar\">\n <CurrencyFilter\n value={currency}\n onChange={setCurrency}\n currencies={currencies}\n />\n <PeriodSelector\n value={rangeKey}\n onChange={setRangeKey}\n customStart={customStart}\n customEnd={customEnd}\n onCustomChange={onCustomChange}\n />\n </div>\n }\n />\n\n <ControlRoom\n attention={data.attention}\n currency={currency}\n totals={data.totals}\n />\n\n <KpiRow\n data={data}\n currency={currency}\n focus={focus}\n compare={compare}\n caption={caption}\n />\n\n <div className=\"adm-grid\">\n <div className=\"adm-span-8\">\n <Panel title=\"Processing volume\" caption={caption}>\n {focus ? (\n <GmvOrdersChart\n gmvTrend={gmvTrend}\n ordersTrend={ordersTrend}\n previousGmvTrend={[]}\n previousOrdersTrend={[]}\n compare={false}\n span={span}\n money={moneyIn(focus)}\n />\n ) : (\n <p className=\"text-sm text-[var(--text-secondary)]\">\n {PICK_CURRENCY}\n </p>\n )}\n </Panel>\n </div>\n <div className=\"adm-span-4\">\n <PositionPanel data={data} currency={currency} />\n </div>\n </div>\n\n <div className=\"adm-grid\">\n <div className=\"adm-span-12\">\n <ActivationPanel activation={data.activation} />\n </div>\n </div>\n\n <div className=\"adm-grid\">\n <div className=\"adm-span-4\">\n <AttentionPanel attention={data.attention} />\n </div>\n <div className=\"adm-span-4\">\n <LeadersPanel\n tenants={rowFor(data.topTenantsByCurrency, focus)?.tenants ?? []}\n focus={focus}\n caption={focus ? `${span} · ${focus}` : span}\n />\n </div>\n <div className=\"adm-span-4\">\n <PortfolioPanel\n statuses={data.statusBreakdown}\n industries={data.industryBreakdown}\n />\n </div>\n </div>\n </div>\n );\n}\n"],"mappings":";AAmEW,cAKL,YALK;AAjEX,SAAS,eAAe;AACxB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,0BAA0B;AACnC,SAAS,uBAAuB;AAChC,SAAS,sBAAsB;AAC/B,SAAS,mBAAmB;AAC5B,SAAS,cAAc;AACvB,SAAS,oBAAoB;AAC7B,SAAS,sBAAsB;AAC/B,SAAS,qBAAqB;AAEf,SAAR,iBAAkC;AACvC,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,IAAI,sBAAsB;AAE1B,QAAM,CAAC,UAAU,WAAW,IAAI,iBAAiB;AACjD,QAAM,QAAQ;AAAA,IACZ,MACE,aAAa,WACT,YAAY,aAAa,SAAS,IAClC,aAAa,QAAQ;AAAA,IAC3B,CAAC,WAAW,aAAa,QAAQ;AAAA,EACnC;AACA,QAAM,UAAU,mBAAmB;AAAA,IACjC,WAAW,MAAM;AAAA,IACjB,SAAS,MAAM;AAAA,EACjB,CAAC;AACD,QAAM,aAAa;AAAA,IACjB,MAAM,QAAQ,MAAM,cAAc,CAAC;AAAA,IACnC,CAAC,QAAQ,IAAI;AAAA,EACf;AAEA,QAAM,QAAQ,cAAc,YAAY,QAAQ;AAChD,QAAM,SAAS;AAAA,IACb,MACE,OAAO,QAAQ,MAAM,MAAM,6BAA6B,KAAK,GAAG;AAAA,IAClE,CAAC,QAAQ,MAAM,KAAK;AAAA,EACtB;AACA,QAAM,WAAW,QAAQ,MAAM,QAAQ,QAAQ,KAAK,GAAG,CAAC,MAAM,CAAC;AAC/D,QAAM,cAAc,QAAQ,MAAM,QAAQ,QAAQ,QAAQ,GAAG,CAAC,MAAM,CAAC;AAErE,MAAI,QAAQ,WAAW;AACrB,WAAO,oBAAC,cAAW,OAAM,+BAA8B;AAAA,EACzD;AAEA,MAAI,QAAQ,WAAW,CAAC,QAAQ,MAAM;AACpC,WACE,qBAAC,SAAI,WAAU,aAAY,MAAK,SAC9B;AAAA,0BAAC,YAAO,sDAAwC;AAAA,MAChD,oBAAC,OAAE,WAAU,kDAAiD,6EAE9D;AAAA,MACA;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,WAAU;AAAA,UACV,SAAS,MAAM,KAAK,QAAQ,QAAQ;AAAA,UACrC;AAAA;AAAA,MAED;AAAA,OACF;AAAA,EAEJ;AAEA,QAAM,OAAO,QAAQ;AACrB,QAAM,OAAO,aAAa,KAAK;AAC/B,QAAM,SAAS,GAAG,KAAK,CAAC,EAAE,YAAY,CAAC,GAAG,KAAK,MAAM,CAAC,CAAC;AACvD,QAAM,UAAU,QAAQ,GAAG,MAAM,SAAM,KAAK,KAAK;AAEjD,SACE,qBAAC,SACC;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,OAAM;AAAA,QACN,aAAY;AAAA,QACZ,SACE,qBAAC,SAAI,WAAU,eACb;AAAA;AAAA,YAAC;AAAA;AAAA,cACC,OAAO;AAAA,cACP,UAAU;AAAA,cACV;AAAA;AAAA,UACF;AAAA,UACA;AAAA,YAAC;AAAA;AAAA,cACC,OAAO;AAAA,cACP,UAAU;AAAA,cACV;AAAA,cACA;AAAA,cACA;AAAA;AAAA,UACF;AAAA,WACF;AAAA;AAAA,IAEJ;AAAA,IAEA;AAAA,MAAC;AAAA;AAAA,QACC,WAAW,KAAK;AAAA,QAChB;AAAA,QACA,QAAQ,KAAK;AAAA;AAAA,IACf;AAAA,IAEA;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA;AAAA,IACF;AAAA,IAEA,qBAAC,SAAI,WAAU,YACb;AAAA,0BAAC,SAAI,WAAU,cACb,8BAAC,SAAM,OAAM,qBAAoB,SAC9B,kBACC;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA;AAAA,UACA,kBAAkB,CAAC;AAAA,UACnB,qBAAqB,CAAC;AAAA,UACtB,SAAS;AAAA,UACT;AAAA,UACA,OAAO,QAAQ,KAAK;AAAA;AAAA,MACtB,IAEA,oBAAC,OAAE,WAAU,wCACV,yBACH,GAEJ,GACF;AAAA,MACA,oBAAC,SAAI,WAAU,cACb,8BAAC,iBAAc,MAAY,UAAoB,GACjD;AAAA,OACF;AAAA,IAEA,oBAAC,SAAI,WAAU,YACb,8BAAC,SAAI,WAAU,eACb,8BAAC,mBAAgB,YAAY,KAAK,YAAY,GAChD,GACF;AAAA,IAEA,qBAAC,SAAI,WAAU,YACb;AAAA,0BAAC,SAAI,WAAU,cACb,8BAAC,kBAAe,WAAW,KAAK,WAAW,GAC7C;AAAA,MACA,oBAAC,SAAI,WAAU,cACb;AAAA,QAAC;AAAA;AAAA,UACC,SAAS,OAAO,KAAK,sBAAsB,KAAK,GAAG,WAAW,CAAC;AAAA,UAC/D;AAAA,UACA,SAAS,QAAQ,GAAG,IAAI,SAAM,KAAK,KAAK;AAAA;AAAA,MAC1C,GACF;AAAA,MACA,oBAAC,SAAI,WAAU,cACb;AAAA,QAAC;AAAA;AAAA,UACC,UAAU,KAAK;AAAA,UACf,YAAY,KAAK;AAAA;AAAA,MACnB,GACF;AAAA,OACF;AAAA,KACF;AAEJ;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@withpotter/pages-admin",
|
|
3
|
-
"version": "0.13.0-next.
|
|
3
|
+
"version": "0.13.0-next.80",
|
|
4
4
|
"description": "Box console platform-admin routes: overview, revenue, accounts, tenants, admins, roles, audit.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@radix-ui/react-popover": "^1.1.23",
|
|
33
33
|
"@tanstack/react-query": "^5.90.20",
|
|
34
|
-
"@withpotter/lib": "^0.13.0-next.
|
|
35
|
-
"@withpotter/platform-admin": "^0.13.0-next.
|
|
36
|
-
"@withpotter/ui-kit": "^0.13.0-next.
|
|
34
|
+
"@withpotter/lib": "^0.13.0-next.80",
|
|
35
|
+
"@withpotter/platform-admin": "^0.13.0-next.80",
|
|
36
|
+
"@withpotter/ui-kit": "^0.13.0-next.80",
|
|
37
37
|
"lucide-react": "^0.562.0",
|
|
38
38
|
"motion": "^12.42.2"
|
|
39
39
|
},
|