@stacksjs/defaults 0.70.54
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/LICENSE.md +21 -0
- package/README.md +2 -0
- package/package.json +20 -0
- package/resources/assets/fonts/013717e9.woff +0 -0
- package/resources/assets/fonts/03355817.woff2 +0 -0
- package/resources/assets/fonts/0380de7e.woff2 +0 -0
- package/resources/assets/fonts/0ba5d32a.woff +0 -0
- package/resources/assets/fonts/0bd57ddf.woff2 +0 -0
- package/resources/assets/fonts/19de0a60.woff2 +0 -0
- package/resources/assets/fonts/20b87e84.woff +0 -0
- package/resources/assets/fonts/4bbc67e1.woff +0 -0
- package/resources/assets/fonts/576fb0d7.woff +0 -0
- package/resources/assets/fonts/5ea10ee5.woff2 +0 -0
- package/resources/assets/fonts/6413fc0a.woff +0 -0
- package/resources/assets/fonts/72c114cf.woff +0 -0
- package/resources/assets/fonts/73c57e73.woff2 +0 -0
- package/resources/assets/fonts/813dca6c.woff +0 -0
- package/resources/assets/fonts/8a9b0995.woff2 +0 -0
- package/resources/assets/fonts/90b4832f.woff +0 -0
- package/resources/assets/fonts/9394ab85.woff2 +0 -0
- package/resources/assets/fonts/9b3c7d7f.woff2 +0 -0
- package/resources/assets/fonts/Monaco.ttf +0 -0
- package/resources/assets/fonts/b1b36d74.woff +0 -0
- package/resources/assets/fonts/b5d74798.woff2 +0 -0
- package/resources/assets/fonts/b9f81a4d.woff +0 -0
- package/resources/assets/fonts/c9784008.woff2 +0 -0
- package/resources/assets/fonts/cf692227.woff +0 -0
- package/resources/assets/fonts/d1c6ab51.woff2 +0 -0
- package/resources/assets/fonts/e2eb62fc.woff +0 -0
- package/resources/assets/fonts/fe21f8d8.woff2 +0 -0
- package/resources/assets/fonts/nps/CampmateScript-Regular.woff2 +0 -0
- package/resources/assets/fonts/nps/NPS_2026-variable.woff2 +0 -0
- package/resources/assets/fonts/nps/RedwoodSerif-Regular.woff2 +0 -0
- package/resources/assets/fonts/nps/SequoiaSans-Regular.woff2 +0 -0
- package/resources/assets/fonts/nps/Switchback-Regular.woff2 +0 -0
- package/resources/assets/images/app-icon.png +0 -0
- package/resources/assets/images/bg-img.png +0 -0
- package/resources/assets/images/park-ridge.svg +15 -0
- package/resources/assets/images/topography.svg +1 -0
- package/resources/assets/scripts/main.ts +2153 -0
- package/resources/assets/styles/README.md +19 -0
- package/resources/assets/styles/docs/main.css +1 -0
- package/resources/assets/styles/docs/overrides.css +1 -0
- package/resources/assets/styles/docs/vars.css +8 -0
- package/resources/assets/styles/main.css +1419 -0
- package/resources/components/Button.stx +70 -0
- package/resources/components/Buttons/BaseButton.stx +39 -0
- package/resources/components/Buttons/Counter.stx +28 -0
- package/resources/components/Buttons/ToggleDark.stx +11 -0
- package/resources/components/Container.stx +10 -0
- package/resources/components/Dashboard/.gitkeep +0 -0
- package/resources/components/Dashboard/Auth/ForgotPassword.stx +127 -0
- package/resources/components/Dashboard/Auth/Login.stx +212 -0
- package/resources/components/Dashboard/Auth/Register.stx +306 -0
- package/resources/components/Dashboard/Avatar.stx +123 -0
- package/resources/components/Dashboard/Billing/ActivePlan.stx +57 -0
- package/resources/components/Dashboard/Billing/CardBrands.stx +32 -0
- package/resources/components/Dashboard/Billing/CardForm.stx +34 -0
- package/resources/components/Dashboard/Billing/OneTimePayment.stx +28 -0
- package/resources/components/Dashboard/Billing/PaymentForm.stx +33 -0
- package/resources/components/Dashboard/Billing/PaymentMethod.stx +66 -0
- package/resources/components/Dashboard/Billing/PaymentMethodList.stx +52 -0
- package/resources/components/Dashboard/Billing/Plans.stx +136 -0
- package/resources/components/Dashboard/Buttons/AppButton.stx +48 -0
- package/resources/components/Dashboard/Buttons/IconButton.stx +20 -0
- package/resources/components/Dashboard/Card.stx +78 -0
- package/resources/components/Dashboard/Chart.stx +75 -0
- package/resources/components/Dashboard/CodeEditor.stx +41 -0
- package/resources/components/Dashboard/Commerce/BulkActionBar.stx +70 -0
- package/resources/components/Dashboard/Commerce/CategoriesTable.stx +133 -0
- package/resources/components/Dashboard/Commerce/CouponsTable.stx +161 -0
- package/resources/components/Dashboard/Commerce/CustomersTable.stx +142 -0
- package/resources/components/Dashboard/Commerce/Delivery/DeliveryRoutesTable.stx +80 -0
- package/resources/components/Dashboard/Commerce/Delivery/DigitalDeliveryTable.stx +85 -0
- package/resources/components/Dashboard/Commerce/Delivery/DriversTable.stx +99 -0
- package/resources/components/Dashboard/Commerce/Delivery/LicenseKeysTable.stx +92 -0
- package/resources/components/Dashboard/Commerce/Delivery/LicenseTemplatesTable.stx +88 -0
- package/resources/components/Dashboard/Commerce/Delivery/Pagination.stx +122 -0
- package/resources/components/Dashboard/Commerce/Delivery/SearchFilter.stx +23 -0
- package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +80 -0
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +59 -0
- package/resources/components/Dashboard/Commerce/Delivery/ShippingZonesTable.stx +71 -0
- package/resources/components/Dashboard/Commerce/Delivery/TabNavigation.stx +36 -0
- package/resources/components/Dashboard/Commerce/Forms/CouponsForm.stx +191 -0
- package/resources/components/Dashboard/Commerce/OrdersTable.stx +131 -0
- package/resources/components/Dashboard/Commerce/ProductsTable.stx +170 -0
- package/resources/components/Dashboard/CreateRecordModal.stx +363 -0
- package/resources/components/Dashboard/DashboardLayout.stx +155 -0
- package/resources/components/Dashboard/Deployments/ActivityFeed.stx +54 -0
- package/resources/components/Dashboard/Deployments/DeployScript.stx +23 -0
- package/resources/components/Dashboard/Deployments/DeploymentHistory.stx +97 -0
- package/resources/components/Dashboard/Deployments/DeploymentList.stx +94 -0
- package/resources/components/Dashboard/Deployments/LiveTerminalOutput.stx +18 -0
- package/resources/components/Dashboard/Elements/Dropdown.stx +57 -0
- package/resources/components/Dashboard/Email/EmailActivityChart.stx +111 -0
- package/resources/components/Dashboard/Email/EmailCompose.stx +72 -0
- package/resources/components/Dashboard/Email/EmailDetail.stx +105 -0
- package/resources/components/Dashboard/Email/EmailList.stx +117 -0
- package/resources/components/Dashboard/Email/EmailSearch.stx +40 -0
- package/resources/components/Dashboard/Email/EmailSidebar.stx +133 -0
- package/resources/components/Dashboard/GlobalSearch.stx +124 -0
- package/resources/components/Dashboard/MobileSidebar.stx +47 -0
- package/resources/components/Dashboard/Modals/BaseModal.stx +50 -0
- package/resources/components/Dashboard/Modals/Popups/Alert.stx +112 -0
- package/resources/components/Dashboard/Modals/Popups/Toast.stx +70 -0
- package/resources/components/Dashboard/Modals/ToastWrapper.stx +35 -0
- package/resources/components/Dashboard/Monitoring/ErrorDetailModal.stx +228 -0
- package/resources/components/Dashboard/Monitoring/ErrorsTable.stx +184 -0
- package/resources/components/Dashboard/Navbar.stx +194 -0
- package/resources/components/Dashboard/NavbarModern.stx +188 -0
- package/resources/components/Dashboard/NotificationErrorModal.stx +105 -0
- package/resources/components/Dashboard/NotificationStatusBadge.stx +20 -0
- package/resources/components/Dashboard/Pagination.stx +90 -0
- package/resources/components/Dashboard/Queue/QueueTable.stx +127 -0
- package/resources/components/Dashboard/Settings/Forms/AIForm.stx +126 -0
- package/resources/components/Dashboard/Settings/Forms/AnalyticsForm.stx +52 -0
- package/resources/components/Dashboard/Settings/Forms/AppForm.stx +157 -0
- package/resources/components/Dashboard/Settings/Forms/CLIForm.stx +66 -0
- package/resources/components/Dashboard/Settings/Forms/CacheForm.stx +253 -0
- package/resources/components/Dashboard/Settings/Forms/CloudForm.stx +441 -0
- package/resources/components/Dashboard/Settings/Forms/DNSForm.stx +307 -0
- package/resources/components/Dashboard/Settings/Forms/DatabaseForm.stx +168 -0
- package/resources/components/Dashboard/Settings/Forms/EmailForm.stx +100 -0
- package/resources/components/Dashboard/Settings/Forms/FileSystemForm.stx +259 -0
- package/resources/components/Dashboard/Settings/Forms/HashingForm.stx +119 -0
- package/resources/components/Dashboard/Settings/Forms/LibraryForm.stx +389 -0
- package/resources/components/Dashboard/Settings/Forms/QueueForm.stx +219 -0
- package/resources/components/Dashboard/Settings/Forms/SearchEngineForm.stx +48 -0
- package/resources/components/Dashboard/Settings/Forms/SecurityForm.stx +143 -0
- package/resources/components/Dashboard/Settings/Forms/ServicesForm.stx +194 -0
- package/resources/components/Dashboard/Settings/Forms/StorageForm.stx +191 -0
- package/resources/components/Dashboard/Settings/Forms/TeamsForm.stx +33 -0
- package/resources/components/Dashboard/Settings/SettingsFormManager.stx +181 -0
- package/resources/components/Dashboard/SettingsHeader.stx +88 -0
- package/resources/components/Dashboard/SettingsSidebar.stx +234 -0
- package/resources/components/Dashboard/Sidebar.stx +23 -0
- package/resources/components/Dashboard/SidebarModern.stx +249 -0
- package/resources/components/Dashboard/Skeleton/LoadingCard.stx +15 -0
- package/resources/components/Dashboard/Skeleton/LoadingDetails.stx +26 -0
- package/resources/components/Dashboard/Table.stx +82 -0
- package/resources/components/Dashboard/TerminalContainer.stx +97 -0
- package/resources/components/Dashboard/Tooltip.stx +166 -0
- package/resources/components/Dashboard/Transaction/index.stx +88 -0
- package/resources/components/Dashboard/UI/ActivityTable.stx +54 -0
- package/resources/components/Dashboard/UI/Avatar.stx +102 -0
- package/resources/components/Dashboard/UI/Badge.stx +66 -0
- package/resources/components/Dashboard/UI/Button.stx +73 -0
- package/resources/components/Dashboard/UI/Card.stx +51 -0
- package/resources/components/Dashboard/UI/ChartCard.stx +36 -0
- package/resources/components/Dashboard/UI/Checkbox.stx +74 -0
- package/resources/components/Dashboard/UI/ConfirmDialog.stx +74 -0
- package/resources/components/Dashboard/UI/DataTable.stx +45 -0
- package/resources/components/Dashboard/UI/Dropdown.stx +72 -0
- package/resources/components/Dashboard/UI/DropdownItem.stx +80 -0
- package/resources/components/Dashboard/UI/EmptyState.stx +50 -0
- package/resources/components/Dashboard/UI/FilterBar.stx +48 -0
- package/resources/components/Dashboard/UI/Input.stx +77 -0
- package/resources/components/Dashboard/UI/Modal.stx +75 -0
- package/resources/components/Dashboard/UI/PageHeader.stx +69 -0
- package/resources/components/Dashboard/UI/PageLayout.stx +84 -0
- package/resources/components/Dashboard/UI/Pagination.stx +126 -0
- package/resources/components/Dashboard/UI/QuickLinks.stx +31 -0
- package/resources/components/Dashboard/UI/RouterLink.stx +31 -0
- package/resources/components/Dashboard/UI/Select.stx +84 -0
- package/resources/components/Dashboard/UI/ServiceHealth.stx +38 -0
- package/resources/components/Dashboard/UI/StatsCard.stx +79 -0
- package/resources/components/Dashboard/UI/Table.stx +143 -0
- package/resources/components/Dashboard/UI/Tabs.stx +45 -0
- package/resources/components/Dashboard/UI/Textarea.stx +93 -0
- package/resources/components/Dashboard/UI/Toggle.stx +84 -0
- package/resources/components/Dashboard/UI/WindowControls.stx +135 -0
- package/resources/components/Dashboard/Widget.stx +89 -0
- package/resources/components/Dashboard/WindowControls.stx +119 -0
- package/resources/components/Dashboard/index.ts +106 -0
- package/resources/components/Docs/Demo/ComboboxCode.md +114 -0
- package/resources/components/Docs/Demo/ComboboxDemo.stx +91 -0
- package/resources/components/Docs/Demo/DialogCode.md +42 -0
- package/resources/components/Docs/Demo/DialogDemo.stx +76 -0
- package/resources/components/Docs/Demo/DocsPlayground.stx +39 -0
- package/resources/components/Docs/Demo/DropdownCode.md +109 -0
- package/resources/components/Docs/Demo/DropdownDemo.stx +82 -0
- package/resources/components/Docs/Demo/Hero.stx +100 -0
- package/resources/components/Docs/Demo/ListboxCode.md +87 -0
- package/resources/components/Docs/Demo/ListboxDemo.stx +59 -0
- package/resources/components/Docs/Demo/NotificationCode.md +173 -0
- package/resources/components/Docs/Demo/NotificationDemo.stx +99 -0
- package/resources/components/Docs/Demo/PopoverCode.md +161 -0
- package/resources/components/Docs/Demo/PopoverDemo.stx +96 -0
- package/resources/components/Docs/Demo/RadioGroupCode.md +98 -0
- package/resources/components/Docs/Demo/RadioGroupDemo.stx +53 -0
- package/resources/components/Docs/Demo/StepperCode.md +216 -0
- package/resources/components/Docs/Demo/StepperDemo.stx +137 -0
- package/resources/components/Docs/Demo/SwitchCode.md +24 -0
- package/resources/components/Docs/Demo/SwitchDemo.stx +28 -0
- package/resources/components/Docs/Home.stx +7 -0
- package/resources/components/Docs/HomeContributors.stx +23 -0
- package/resources/components/Docs/HomeSponsors.stx +17 -0
- package/resources/components/Docs/HomeTeam.stx +18 -0
- package/resources/components/Docs/TeamMember.stx +79 -0
- package/resources/components/Email/EmailButton.stx +32 -0
- package/resources/components/Email/EmailDivider.stx +20 -0
- package/resources/components/Email/EmailImage.stx +24 -0
- package/resources/components/Email/EmailLayout.stx +55 -0
- package/resources/components/Email/EmailLink.stx +22 -0
- package/resources/components/Email/EmailSection.stx +20 -0
- package/resources/components/Email/EmailText.stx +30 -0
- package/resources/components/Forum/ForumBreadcrumb.stx +38 -0
- package/resources/components/Forum/ForumCategory.stx +16 -0
- package/resources/components/Forum/ForumCategoryItem.stx +46 -0
- package/resources/components/Forum/ForumLayout.stx +36 -0
- package/resources/components/Forum/ForumPagination.stx +39 -0
- package/resources/components/Forum/ForumPost.stx +71 -0
- package/resources/components/Forum/ForumReplyForm.stx +76 -0
- package/resources/components/Forum/ForumSidebar.stx +86 -0
- package/resources/components/Forum/ForumTopicItem.stx +77 -0
- package/resources/components/Forum/ForumTopicList.stx +23 -0
- package/resources/components/HelloWorld.stx +253 -0
- package/resources/components/Logo.stx +10 -0
- package/resources/components/Marketing/CallToAction.stx +30 -0
- package/resources/components/Marketing/ComingSoon.stx +155 -0
- package/resources/components/Marketing/Faqs.stx +99 -0
- package/resources/components/Marketing/Feature.stx +32 -0
- package/resources/components/Marketing/Fields.stx +28 -0
- package/resources/components/Marketing/Footer.stx +33 -0
- package/resources/components/Marketing/Header.stx +51 -0
- package/resources/components/Marketing/Hero.stx +46 -0
- package/resources/components/Marketing/Layout.stx +26 -0
- package/resources/components/Marketing/NavLink.stx +13 -0
- package/resources/components/Marketing/Plan.stx +43 -0
- package/resources/components/Marketing/Pricing.stx +120 -0
- package/resources/components/Marketing/PrimaryFeatures.stx +80 -0
- package/resources/components/Marketing/PrimaryFeatures2.stx +98 -0
- package/resources/components/Marketing/SecondaryFeatures.stx +80 -0
- package/resources/components/Marketing/Testimonials.stx +120 -0
- package/resources/components/Pagination.stx +149 -0
- package/resources/components/Playground.stx +9 -0
- package/resources/components/README.md +87 -0
- package/resources/components/Storefront/AnnouncementBar.stx +17 -0
- package/resources/components/Storefront/CartDrawer.stx +227 -0
- package/resources/components/Storefront/CheckoutSteps.stx +50 -0
- package/resources/components/Storefront/Footer.stx +81 -0
- package/resources/components/Storefront/Header.stx +66 -0
- package/resources/components/Storefront/OrderSummary.stx +79 -0
- package/resources/components/Storefront/ProductCard.stx +63 -0
- package/resources/components/Storefront/ProductGallery.stx +88 -0
- package/resources/components/Storefront/QuantitySelector.stx +97 -0
- package/resources/components/index.ts +13 -0
- package/resources/emails/email-verification.stx +46 -0
- package/resources/emails/layouts/base.html +113 -0
- package/resources/emails/password-changed.stx +55 -0
- package/resources/emails/password-reset.stx +43 -0
- package/resources/emails/subscription-confirmation.stx +99 -0
- package/resources/emails/welcome.stx +48 -0
- package/resources/functions/README.md +28 -0
- package/resources/functions/counter.ts +13 -0
- package/resources/functions/dark.ts +4 -0
- package/resources/functions/dashboard/analytics.ts +238 -0
- package/resources/functions/dashboard/config-io.ts +316 -0
- package/resources/functions/dashboard/data.ts +375 -0
- package/resources/functions/dashboard/library.ts +250 -0
- package/resources/functions/dashboard/sidebar.ts +537 -0
- package/resources/functions/dashboard/system.ts +150 -0
- package/resources/layouts/403.stx +188 -0
- package/resources/layouts/404.stx +34 -0
- package/resources/layouts/README.md +14 -0
- package/resources/layouts/dashboard/default.stx +20 -0
- package/resources/layouts/dashboard/guest.stx +10 -0
- package/resources/layouts/default.stx +3 -0
- package/resources/layouts/maintenance.stx +11 -0
- package/resources/layouts/storefront.stx +73 -0
- package/resources/layouts/system-tray/default.stx +3 -0
- package/resources/modules/.gitkeep +0 -0
- package/resources/plugins/.gitkeep +0 -0
- package/resources/plugins/preloader.ts +308 -0
- package/resources/types/README.md +3 -0
- package/resources/views/cart.stx +149 -0
- package/resources/views/checkout/contact.stx +101 -0
- package/resources/views/checkout/payment.stx +114 -0
- package/resources/views/checkout/shipping.stx +120 -0
- package/resources/views/coming-soon.stx +9 -0
- package/resources/views/dashboard/custom-page.stx +45 -0
- package/resources/views/dashboard/index.stx +24 -0
- package/resources/views/emails/welcome.stx +0 -0
- package/resources/views/errors/tester.stx +26 -0
- package/resources/views/index.stx +2865 -0
- package/resources/views/orders/[id].stx +130 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read & write `config/*.ts` files from the dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists
|
|
5
|
+
* ---------------
|
|
6
|
+
* Config files use `export default { ... } satisfies XConfig` and pull
|
|
7
|
+
* defaults from `env.*`. The settings page needs to:
|
|
8
|
+
* - List every config file
|
|
9
|
+
* - Show its currently-resolved values (env-substituted)
|
|
10
|
+
* - Persist edits back to the source `.ts` so they survive restarts
|
|
11
|
+
*
|
|
12
|
+
* Reading is straightforward: `await import(file)` returns the evaluated
|
|
13
|
+
* object. Writing is harder — we want to preserve comments, type imports,
|
|
14
|
+
* and the `env.*` references intact. Instead of regenerating the file,
|
|
15
|
+
* we mutate the source text in place: find the line containing
|
|
16
|
+
* `<key>: <oldValue>` and rewrite the value. This keeps the file
|
|
17
|
+
* round-trippable and human-readable.
|
|
18
|
+
*
|
|
19
|
+
* Edits supported (top-level scalar keys only):
|
|
20
|
+
* - string → string (single- or double-quoted)
|
|
21
|
+
* - number → number
|
|
22
|
+
* - boolean → boolean
|
|
23
|
+
*
|
|
24
|
+
* Edits NOT supported (the settings UI surfaces a read-only badge):
|
|
25
|
+
* - nested objects, arrays
|
|
26
|
+
* - keys whose current value is a function call or expression
|
|
27
|
+
* (`env.X ?? 'y'`) — those need to be edited via env, not config
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
31
|
+
import { join, resolve } from 'node:path'
|
|
32
|
+
import process from 'node:process'
|
|
33
|
+
|
|
34
|
+
let projectRootCache: string | null = null
|
|
35
|
+
function projectRoot(): string {
|
|
36
|
+
if (projectRootCache) return projectRootCache
|
|
37
|
+
let dir = process.cwd()
|
|
38
|
+
for (let i = 0; i < 8; i++) {
|
|
39
|
+
if (existsSync(resolve(dir, 'config'))) {
|
|
40
|
+
projectRootCache = dir
|
|
41
|
+
return dir
|
|
42
|
+
}
|
|
43
|
+
const parent = resolve(dir, '..')
|
|
44
|
+
if (parent === dir) break
|
|
45
|
+
dir = parent
|
|
46
|
+
}
|
|
47
|
+
projectRootCache = process.cwd()
|
|
48
|
+
return projectRootCache
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function configDir(): string {
|
|
52
|
+
return resolve(projectRoot(), 'config')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ConfigFileSummary {
|
|
56
|
+
/** Filename without extension, e.g. 'email'. */
|
|
57
|
+
name: string
|
|
58
|
+
/** Display title, e.g. 'Email'. */
|
|
59
|
+
title: string
|
|
60
|
+
/** Absolute path to the source file. */
|
|
61
|
+
path: string
|
|
62
|
+
/** Bytes — UI uses this to show "small / large config" hints. */
|
|
63
|
+
size: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Enumerate every `.ts` config file. Hidden / non-ts files are skipped. */
|
|
67
|
+
export function listConfigFiles(): ConfigFileSummary[] {
|
|
68
|
+
const dir = configDir()
|
|
69
|
+
if (!existsSync(dir)) return []
|
|
70
|
+
let entries: string[]
|
|
71
|
+
try { entries = readdirSync(dir) }
|
|
72
|
+
catch { return [] }
|
|
73
|
+
const out: ConfigFileSummary[] = []
|
|
74
|
+
for (const e of entries) {
|
|
75
|
+
if (!e.endsWith('.ts') || e.startsWith('.')) continue
|
|
76
|
+
const path = join(dir, e)
|
|
77
|
+
let size = 0
|
|
78
|
+
try { size = Bun.file(path).size }
|
|
79
|
+
catch { /* unreadable — leave at 0 */ }
|
|
80
|
+
const name = e.replace(/\.ts$/, '')
|
|
81
|
+
out.push({ name, title: titleCase(name), path, size })
|
|
82
|
+
}
|
|
83
|
+
return out.sort((a, b) => a.name.localeCompare(b.name))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Cache of imported config modules. We re-import on every call when the
|
|
88
|
+
* file's mtime has changed so the dashboard reflects edits immediately,
|
|
89
|
+
* but skip the cost when nothing has changed in a tight burst of reads.
|
|
90
|
+
*/
|
|
91
|
+
const moduleCache = new Map<string, { mtimeMs: number, value: any }>()
|
|
92
|
+
|
|
93
|
+
export interface ReadResult {
|
|
94
|
+
/** The fully-resolved default export, with env values substituted. */
|
|
95
|
+
values: any
|
|
96
|
+
/** Raw source text (used by the editor + the writer). */
|
|
97
|
+
source: string
|
|
98
|
+
/** Per-key metadata: what's editable, what's read-only and why. */
|
|
99
|
+
fields: ConfigField[]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ConfigField {
|
|
103
|
+
key: string
|
|
104
|
+
/** The currently-resolved value (post env substitution). */
|
|
105
|
+
value: any
|
|
106
|
+
/** 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null' */
|
|
107
|
+
type: string
|
|
108
|
+
/** True when this scalar can be safely round-tripped on write. */
|
|
109
|
+
editable: boolean
|
|
110
|
+
/** Human-readable reason when editable === false. */
|
|
111
|
+
reason?: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Read one config file by name (e.g. 'email'). */
|
|
115
|
+
export async function readConfig(name: string): Promise<ReadResult | null> {
|
|
116
|
+
const path = join(configDir(), `${name}.ts`)
|
|
117
|
+
if (!existsSync(path)) return null
|
|
118
|
+
|
|
119
|
+
const stat = await Bun.file(path).stat?.().catch(() => null)
|
|
120
|
+
const mtimeMs = stat?.mtimeMs ?? Date.now()
|
|
121
|
+
|
|
122
|
+
// Read the source as the source-of-truth for the field listing.
|
|
123
|
+
// We deliberately do NOT re-`import()` the file after every edit — Bun
|
|
124
|
+
// caches modules by URL, query-string cache-busting (`?t=…`) caused
|
|
125
|
+
// intermittent 0-status responses for some configs (likely when the
|
|
126
|
+
// resolved value held a function or unsupported sentinel). The source
|
|
127
|
+
// text + a one-off initial import gives us everything the editor
|
|
128
|
+
// needs without re-executing the config graph on every keystroke.
|
|
129
|
+
const source = readFileSync(path, 'utf8')
|
|
130
|
+
|
|
131
|
+
let modVal: any
|
|
132
|
+
const cached = moduleCache.get(path)
|
|
133
|
+
if (cached) {
|
|
134
|
+
modVal = cached.value
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
try {
|
|
138
|
+
const mod = await import(path)
|
|
139
|
+
modVal = mod?.default ?? mod
|
|
140
|
+
moduleCache.set(path, { mtimeMs, value: modVal })
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
throw new Error(`Failed to load config/${name}.ts: ${(err as Error).message}`)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// For previously-cached modules, refresh scalar fields from the source
|
|
148
|
+
// text instead of re-importing — that way edits made via this API are
|
|
149
|
+
// reflected immediately in subsequent reads without paying the import
|
|
150
|
+
// re-evaluation cost (and without tripping Bun's URL cache).
|
|
151
|
+
modVal = applySourceOverrides(modVal, source)
|
|
152
|
+
const fields = describeFields(modVal, source)
|
|
153
|
+
return { values: modVal, source, fields }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Update a single top-level key in a config file. Returns the rewritten
|
|
158
|
+
* source on success, or throws when the key is non-editable (nested,
|
|
159
|
+
* env-backed, etc.) — caller should surface that to the UI.
|
|
160
|
+
*/
|
|
161
|
+
export interface UpdateResult {
|
|
162
|
+
ok: boolean
|
|
163
|
+
newValue: any
|
|
164
|
+
source: string
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function updateConfigKey(
|
|
168
|
+
name: string,
|
|
169
|
+
key: string,
|
|
170
|
+
newValue: string | number | boolean,
|
|
171
|
+
): Promise<UpdateResult> {
|
|
172
|
+
const path = join(configDir(), `${name}.ts`)
|
|
173
|
+
if (!existsSync(path))
|
|
174
|
+
throw new Error(`config/${name}.ts not found`)
|
|
175
|
+
|
|
176
|
+
const source = readFileSync(path, 'utf8')
|
|
177
|
+
const rewritten = rewriteKey(source, key, newValue)
|
|
178
|
+
if (!rewritten)
|
|
179
|
+
throw new Error(`Cannot edit "${key}" in config/${name}.ts — key is not a top-level scalar literal (likely env-backed or nested).`)
|
|
180
|
+
|
|
181
|
+
writeFileSync(path, rewritten, 'utf8')
|
|
182
|
+
// We don't bust moduleCache here on purpose — re-importing with a
|
|
183
|
+
// changed-on-disk file via Bun reuses the original module from the
|
|
184
|
+
// first import; subsequent reads pick up the edit through the source-
|
|
185
|
+
// text overlay (`applySourceOverrides`) instead.
|
|
186
|
+
return { ok: true, newValue, source: rewritten }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Walk the source text for top-level scalar literal assignments and
|
|
191
|
+
* overlay them onto the given values object. This lets us reflect
|
|
192
|
+
* post-write edits without paying the cost (and risk) of re-importing
|
|
193
|
+
* the whole config module after every keystroke.
|
|
194
|
+
*/
|
|
195
|
+
function applySourceOverrides(values: any, source: string): any {
|
|
196
|
+
if (!values || typeof values !== 'object' || Array.isArray(values)) return values
|
|
197
|
+
const out: Record<string, any> = { ...values }
|
|
198
|
+
for (const key of Object.keys(out)) {
|
|
199
|
+
const re = scalarLiteralRegex(key)
|
|
200
|
+
const m = re.exec(source)
|
|
201
|
+
if (!m) continue
|
|
202
|
+
const literal = m[4]
|
|
203
|
+
const parsed = parseLiteral(literal)
|
|
204
|
+
if (parsed !== UNSUPPORTED) out[key] = parsed
|
|
205
|
+
}
|
|
206
|
+
return out
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const UNSUPPORTED = Symbol('unsupported')
|
|
210
|
+
function parseLiteral(literal: string): any {
|
|
211
|
+
if (literal === 'true') return true
|
|
212
|
+
if (literal === 'false') return false
|
|
213
|
+
if (literal === 'null') return null
|
|
214
|
+
if (/^-?\d+(?:\.\d+)?$/.test(literal)) return Number(literal)
|
|
215
|
+
if ((literal.startsWith('\'') && literal.endsWith('\'')) || (literal.startsWith('"') && literal.endsWith('"'))) {
|
|
216
|
+
return literal.slice(1, -1)
|
|
217
|
+
}
|
|
218
|
+
return UNSUPPORTED
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* -------------------------------------------------------------------------- */
|
|
222
|
+
/* Internals */
|
|
223
|
+
/* -------------------------------------------------------------------------- */
|
|
224
|
+
|
|
225
|
+
function titleCase(name: string): string {
|
|
226
|
+
return name
|
|
227
|
+
.split(/[-_]/)
|
|
228
|
+
.map(s => s.charAt(0).toUpperCase() + s.slice(1))
|
|
229
|
+
.join(' ')
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function describeFields(values: any, source: string): ConfigField[] {
|
|
233
|
+
if (!values || typeof values !== 'object' || Array.isArray(values)) return []
|
|
234
|
+
const out: ConfigField[] = []
|
|
235
|
+
for (const [key, value] of Object.entries(values)) {
|
|
236
|
+
const type = typeOf(value)
|
|
237
|
+
const editable = isScalar(type)
|
|
238
|
+
let reason: string | undefined
|
|
239
|
+
if (!editable) {
|
|
240
|
+
reason = type === 'object' || type === 'array'
|
|
241
|
+
? 'Nested values can be edited by hand in the file'
|
|
242
|
+
: 'Unsupported type'
|
|
243
|
+
}
|
|
244
|
+
else if (!hasScalarLiteral(source, key)) {
|
|
245
|
+
// The source value is a non-literal expression like `env.FOO ?? 'x'`.
|
|
246
|
+
// We can't safely rewrite it without losing the env fallback.
|
|
247
|
+
// Mark as read-only and tell the user to set the env var instead.
|
|
248
|
+
out.push({
|
|
249
|
+
key,
|
|
250
|
+
value,
|
|
251
|
+
type,
|
|
252
|
+
editable: false,
|
|
253
|
+
reason: 'Defined via env var or expression — set the env variable to change',
|
|
254
|
+
})
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
out.push({ key, value, type, editable, reason })
|
|
258
|
+
}
|
|
259
|
+
return out
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function typeOf(v: any): string {
|
|
263
|
+
if (v === null) return 'null'
|
|
264
|
+
if (Array.isArray(v)) return 'array'
|
|
265
|
+
return typeof v
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function isScalar(t: string): boolean {
|
|
269
|
+
return t === 'string' || t === 'number' || t === 'boolean'
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Detects whether a top-level key in the export-default block is set
|
|
274
|
+
* directly to a scalar literal — `key: 'value'`, `key: 42`, `key: true`.
|
|
275
|
+
*
|
|
276
|
+
* The match is intentionally conservative: it only succeeds when the
|
|
277
|
+
* value following the colon is a single string/number/boolean literal,
|
|
278
|
+
* optionally followed by a comma. Anything more complex
|
|
279
|
+
* (`env.X ?? 'y'`, `[1, 2]`, `{ a: 1 }`, multi-line arrays, etc.)
|
|
280
|
+
* fails so we don't accidentally clobber an expression.
|
|
281
|
+
*/
|
|
282
|
+
function hasScalarLiteral(source: string, key: string): boolean {
|
|
283
|
+
const re = scalarLiteralRegex(key)
|
|
284
|
+
return re.test(source)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function scalarLiteralRegex(key: string): RegExp {
|
|
288
|
+
const k = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
289
|
+
// Matches:
|
|
290
|
+
// <indent><key>: 'value' OR "value" OR 123 OR true/false/null
|
|
291
|
+
// followed by optional comma + line-ending. The line MUST not contain
|
|
292
|
+
// operators (??, ||, &&, +, ?), function calls, or sub-objects.
|
|
293
|
+
return new RegExp(
|
|
294
|
+
`^(\\s*)(${k})(\\s*:\\s*)('[^'\\n]*'|"[^"\\n]*"|-?\\d+(?:\\.\\d+)?|true|false|null)(\\s*,?\\s*)$`,
|
|
295
|
+
'm',
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function rewriteKey(source: string, key: string, newValue: string | number | boolean): string | null {
|
|
300
|
+
const re = scalarLiteralRegex(key)
|
|
301
|
+
if (!re.test(source)) return null
|
|
302
|
+
const literal = serializeScalar(newValue)
|
|
303
|
+
return source.replace(re, (_full, indent, k, sep, _old, tail) => {
|
|
304
|
+
return `${indent}${k}${sep}${literal}${tail}`
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function serializeScalar(v: string | number | boolean): string {
|
|
309
|
+
if (typeof v === 'string') {
|
|
310
|
+
// Prefer single quotes; fall back to double if the value contains a
|
|
311
|
+
// bare apostrophe (avoid escape gymnastics).
|
|
312
|
+
if (v.includes('\'') && !v.includes('"')) return `"${v}"`
|
|
313
|
+
return `'${v.replace(/'/g, '\\\'')}'`
|
|
314
|
+
}
|
|
315
|
+
return String(v)
|
|
316
|
+
}
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared dashboard server-side helpers.
|
|
3
|
+
*
|
|
4
|
+
* Dashboard `.stx` pages run a `<script server>` block at request time,
|
|
5
|
+
* import the framework's ORM, query for data, and shape it for the
|
|
6
|
+
* template. Several pages need the same primitives — a safe model load
|
|
7
|
+
* (the orm package only re-exports User/Job/FailedJob, the rest must be
|
|
8
|
+
* loaded by file path), null-tolerant getters, and small aggregation
|
|
9
|
+
* helpers — so they live here instead of being copy-pasted into every
|
|
10
|
+
* page header.
|
|
11
|
+
*
|
|
12
|
+
* Usage from inside a page's `<script server>`:
|
|
13
|
+
*
|
|
14
|
+
* const { loadModel, safeAll, safeGet, countBy } =
|
|
15
|
+
* await import('../../../resources/functions/dashboard/data')
|
|
16
|
+
* const Order = await loadModel('Order')
|
|
17
|
+
* const orders = await safeAll(Order)
|
|
18
|
+
* const byStatus = countBy(orders, 'status')
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync } from 'node:fs'
|
|
22
|
+
import { dirname, resolve } from 'node:path'
|
|
23
|
+
|
|
24
|
+
let projectRootCache: string | null = null
|
|
25
|
+
|
|
26
|
+
function projectRoot(): string {
|
|
27
|
+
if (projectRootCache) return projectRootCache
|
|
28
|
+
// Walk up from this file location to find the project root (the dir
|
|
29
|
+
// that contains `storage/framework/defaults`). This mirrors what
|
|
30
|
+
// `@stacksjs/path` does, but without pulling in the path package
|
|
31
|
+
// (which has its own evaluation graph and would slow page renders).
|
|
32
|
+
let dir = dirname(new URL(import.meta.url).pathname)
|
|
33
|
+
for (let i = 0; i < 12; i++) {
|
|
34
|
+
if (existsSync(resolve(dir, 'storage/framework/defaults'))) {
|
|
35
|
+
projectRootCache = dir
|
|
36
|
+
return dir
|
|
37
|
+
}
|
|
38
|
+
const parent = dirname(dir)
|
|
39
|
+
if (parent === dir) break
|
|
40
|
+
dir = parent
|
|
41
|
+
}
|
|
42
|
+
// Fall back to cwd — STX serves run from the project root.
|
|
43
|
+
projectRootCache = process.cwd()
|
|
44
|
+
return projectRootCache
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Map of model name → list of relative paths to try, in order. The first
|
|
49
|
+
* existing file wins. User overrides at `app/Models/<Name>.ts` always
|
|
50
|
+
* take priority over framework defaults. The defaults are organised in
|
|
51
|
+
* subdirectories (commerce/, Content/, realtime/) so each model needs an
|
|
52
|
+
* explicit lookup table — globbing at request time would add ~50ms per
|
|
53
|
+
* page render.
|
|
54
|
+
*/
|
|
55
|
+
const MODEL_PATHS: Record<string, string[]> = {
|
|
56
|
+
// Auth / core
|
|
57
|
+
User: ['app/Models/User.ts', 'storage/framework/defaults/app/Models/User.ts'],
|
|
58
|
+
Team: ['app/Models/Team.ts', 'storage/framework/defaults/app/Models/Team.ts'],
|
|
59
|
+
Subscriber: ['app/Models/Subscriber.ts', 'storage/framework/defaults/app/Models/Subscriber.ts'],
|
|
60
|
+
SubscriberEmail: ['app/Models/SubscriberEmail.ts', 'storage/framework/defaults/app/Models/SubscriberEmail.ts'],
|
|
61
|
+
Subscription: ['app/Models/Subscription.ts', 'storage/framework/defaults/app/Models/Subscription.ts'],
|
|
62
|
+
|
|
63
|
+
// Content
|
|
64
|
+
Post: ['app/Models/Post.ts', 'storage/framework/defaults/app/Models/Content/Post.ts'],
|
|
65
|
+
Page: ['app/Models/Page.ts', 'storage/framework/defaults/app/Models/Content/Page.ts'],
|
|
66
|
+
Author: ['app/Models/Author.ts', 'storage/framework/defaults/app/Models/Content/Author.ts'],
|
|
67
|
+
Comment: ['app/Models/Comment.ts', 'storage/framework/defaults/app/Models/Comment.ts'],
|
|
68
|
+
Tag: ['app/Models/Tag.ts', 'storage/framework/defaults/app/Models/Tag.ts'],
|
|
69
|
+
Category: ['app/Models/Category.ts', 'storage/framework/defaults/app/Models/commerce/Category.ts'],
|
|
70
|
+
|
|
71
|
+
// App / operations
|
|
72
|
+
Activity: ['app/Models/Activity.ts', 'storage/framework/defaults/app/Models/Activity.ts'],
|
|
73
|
+
Deployment: ['app/Models/Deployment.ts', 'storage/framework/defaults/app/Models/Deployment.ts'],
|
|
74
|
+
Release: ['app/Models/Release.ts', 'storage/framework/defaults/app/Models/Release.ts'],
|
|
75
|
+
Notification: ['app/Models/Notification.ts', 'storage/framework/defaults/app/Models/Notification.ts'],
|
|
76
|
+
Job: ['app/Models/Job.ts', 'storage/framework/defaults/app/Models/Job.ts'],
|
|
77
|
+
FailedJob: ['app/Models/FailedJob.ts', 'storage/framework/defaults/app/Models/FailedJob.ts'],
|
|
78
|
+
Log: ['app/Models/Log.ts', 'storage/framework/defaults/app/Models/Log.ts'],
|
|
79
|
+
Request: ['app/Models/Request.ts', 'storage/framework/defaults/app/Models/Request.ts'],
|
|
80
|
+
Error: ['app/Models/Error.ts', 'storage/framework/defaults/app/Models/Error.ts'],
|
|
81
|
+
Websocket: ['app/Models/Websocket.ts', 'storage/framework/defaults/app/Models/realtime/Websocket.ts'],
|
|
82
|
+
|
|
83
|
+
// Marketing
|
|
84
|
+
Campaign: ['app/Models/Campaign.ts', 'storage/framework/defaults/app/Models/Campaign.ts'],
|
|
85
|
+
EmailList: ['app/Models/EmailList.ts', 'storage/framework/defaults/app/Models/EmailList.ts'],
|
|
86
|
+
SocialPost: ['app/Models/SocialPost.ts', 'storage/framework/defaults/app/Models/SocialPost.ts'],
|
|
87
|
+
|
|
88
|
+
// Commerce
|
|
89
|
+
Order: ['app/Models/Order.ts', 'storage/framework/defaults/app/Models/commerce/Order.ts'],
|
|
90
|
+
OrderItem: ['app/Models/OrderItem.ts', 'storage/framework/defaults/app/Models/commerce/OrderItem.ts'],
|
|
91
|
+
Cart: ['app/Models/Cart.ts', 'storage/framework/defaults/app/Models/commerce/Cart.ts'],
|
|
92
|
+
CartItem: ['app/Models/CartItem.ts', 'storage/framework/defaults/app/Models/commerce/CartItem.ts'],
|
|
93
|
+
Customer: ['app/Models/Customer.ts', 'storage/framework/defaults/app/Models/commerce/Customer.ts'],
|
|
94
|
+
Product: ['app/Models/Product.ts', 'storage/framework/defaults/app/Models/commerce/Product.ts'],
|
|
95
|
+
ProductVariant: ['app/Models/ProductVariant.ts', 'storage/framework/defaults/app/Models/commerce/ProductVariant.ts'],
|
|
96
|
+
ProductUnit: ['app/Models/ProductUnit.ts', 'storage/framework/defaults/app/Models/commerce/ProductUnit.ts'],
|
|
97
|
+
Manufacturer: ['app/Models/Manufacturer.ts', 'storage/framework/defaults/app/Models/commerce/Manufacturer.ts'],
|
|
98
|
+
Coupon: ['app/Models/Coupon.ts', 'storage/framework/defaults/app/Models/commerce/Coupon.ts'],
|
|
99
|
+
GiftCard: ['app/Models/GiftCard.ts', 'storage/framework/defaults/app/Models/commerce/GiftCard.ts'],
|
|
100
|
+
LicenseKey: ['app/Models/LicenseKey.ts', 'storage/framework/defaults/app/Models/commerce/LicenseKey.ts'],
|
|
101
|
+
Review: ['app/Models/Review.ts', 'storage/framework/defaults/app/Models/commerce/Review.ts'],
|
|
102
|
+
Receipt: ['app/Models/Receipt.ts', 'storage/framework/defaults/app/Models/commerce/Receipt.ts'],
|
|
103
|
+
PrintDevice: ['app/Models/PrintDevice.ts', 'storage/framework/defaults/app/Models/commerce/PrintDevice.ts'],
|
|
104
|
+
ShippingMethod: ['app/Models/ShippingMethod.ts', 'storage/framework/defaults/app/Models/commerce/ShippingMethod.ts'],
|
|
105
|
+
ShippingRate: ['app/Models/ShippingRate.ts', 'storage/framework/defaults/app/Models/commerce/ShippingRate.ts'],
|
|
106
|
+
ShippingZone: ['app/Models/ShippingZone.ts', 'storage/framework/defaults/app/Models/commerce/ShippingZone.ts'],
|
|
107
|
+
DeliveryRoute: ['app/Models/DeliveryRoute.ts', 'storage/framework/defaults/app/Models/commerce/DeliveryRoute.ts'],
|
|
108
|
+
DigitalDelivery: ['app/Models/DigitalDelivery.ts', 'storage/framework/defaults/app/Models/commerce/DigitalDelivery.ts'],
|
|
109
|
+
Driver: ['app/Models/Driver.ts', 'storage/framework/defaults/app/Models/commerce/Driver.ts'],
|
|
110
|
+
TaxRate: ['app/Models/TaxRate.ts', 'storage/framework/defaults/app/Models/commerce/TaxRate.ts'],
|
|
111
|
+
WaitlistProduct: ['app/Models/WaitlistProduct.ts', 'storage/framework/defaults/app/Models/commerce/WaitlistProduct.ts'],
|
|
112
|
+
WaitlistRestaurant: ['app/Models/WaitlistRestaurant.ts', 'storage/framework/defaults/app/Models/commerce/WaitlistRestaurant.ts'],
|
|
113
|
+
LoyaltyPoint: ['app/Models/LoyaltyPoint.ts', 'storage/framework/defaults/app/Models/commerce/LoyaltyPoint.ts'],
|
|
114
|
+
LoyaltyReward: ['app/Models/LoyaltyReward.ts', 'storage/framework/defaults/app/Models/commerce/LoyaltyReward.ts'],
|
|
115
|
+
Payment: ['app/Models/Payment.ts', 'storage/framework/defaults/app/Models/commerce/Payment.ts'],
|
|
116
|
+
PaymentMethod: ['app/Models/PaymentMethod.ts', 'storage/framework/defaults/app/Models/PaymentMethod.ts'],
|
|
117
|
+
PaymentProduct: ['app/Models/PaymentProduct.ts', 'storage/framework/defaults/app/Models/PaymentProduct.ts'],
|
|
118
|
+
PaymentTransaction: ['app/Models/PaymentTransaction.ts', 'storage/framework/defaults/app/Models/PaymentTransaction.ts'],
|
|
119
|
+
Transaction: ['app/Models/Transaction.ts', 'storage/framework/defaults/app/Models/commerce/Transaction.ts'],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const modelCache = new Map<string, any>()
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Load a model class by name, searching userland first then framework
|
|
126
|
+
* defaults. Returns a stub with no-op `.all()` / `.orderBy()` / `.count()`
|
|
127
|
+
* methods if the model file doesn't exist or fails to import — pages can
|
|
128
|
+
* always call query methods on the result without try/catch around the
|
|
129
|
+
* load itself.
|
|
130
|
+
*/
|
|
131
|
+
export async function loadModel(name: string): Promise<any> {
|
|
132
|
+
if (modelCache.has(name)) return modelCache.get(name)
|
|
133
|
+
|
|
134
|
+
const candidates = MODEL_PATHS[name]
|
|
135
|
+
if (!candidates) {
|
|
136
|
+
const stub = makeStub(name)
|
|
137
|
+
modelCache.set(name, stub)
|
|
138
|
+
return stub
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const root = projectRoot()
|
|
142
|
+
for (const rel of candidates) {
|
|
143
|
+
const abs = resolve(root, rel)
|
|
144
|
+
if (!existsSync(abs)) continue
|
|
145
|
+
try {
|
|
146
|
+
const mod = await import(abs)
|
|
147
|
+
const M = mod?.default ?? mod
|
|
148
|
+
if (M) {
|
|
149
|
+
modelCache.set(name, M)
|
|
150
|
+
return M
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Try the next candidate path; final fallback is the stub below.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const stub = makeStub(name)
|
|
159
|
+
modelCache.set(name, stub)
|
|
160
|
+
return stub
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Load several models at once. Resolves to a record so callers can
|
|
165
|
+
* destructure: `const { Order, Product } = await loadModels(['Order', 'Product'])`.
|
|
166
|
+
*/
|
|
167
|
+
export async function loadModels<T extends string>(names: T[]): Promise<Record<T, any>> {
|
|
168
|
+
const out = {} as Record<T, any>
|
|
169
|
+
await Promise.all(names.map(async (n) => {
|
|
170
|
+
out[n] = await loadModel(n)
|
|
171
|
+
}))
|
|
172
|
+
return out
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function makeStub(_name: string): any {
|
|
176
|
+
const empty: any[] = []
|
|
177
|
+
const chain: any = {
|
|
178
|
+
get: async () => empty,
|
|
179
|
+
all: async () => empty,
|
|
180
|
+
count: async () => 0,
|
|
181
|
+
first: async () => null,
|
|
182
|
+
find: async () => null,
|
|
183
|
+
where: () => chain,
|
|
184
|
+
orderBy: () => chain,
|
|
185
|
+
orderByDesc: () => chain,
|
|
186
|
+
limit: () => chain,
|
|
187
|
+
take: () => chain,
|
|
188
|
+
skip: () => chain,
|
|
189
|
+
select: () => chain,
|
|
190
|
+
distinct: () => chain,
|
|
191
|
+
groupBy: () => chain,
|
|
192
|
+
whereIn: () => chain,
|
|
193
|
+
whereNotNull: () => chain,
|
|
194
|
+
whereNull: () => chain,
|
|
195
|
+
_isStub: true,
|
|
196
|
+
}
|
|
197
|
+
return chain
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Run `Model.all()` and always resolve to an array — never throws, never
|
|
202
|
+
* returns undefined. Use this whenever you need every row of a table for
|
|
203
|
+
* the page (the seeder caps each table at ~50 rows so the cost is fine).
|
|
204
|
+
*/
|
|
205
|
+
export async function safeAll(Model: any): Promise<any[]> {
|
|
206
|
+
if (!Model) return []
|
|
207
|
+
try {
|
|
208
|
+
if (typeof Model.all === 'function') {
|
|
209
|
+
const rows = await Model.all()
|
|
210
|
+
return Array.isArray(rows) ? rows : []
|
|
211
|
+
}
|
|
212
|
+
if (typeof Model.get === 'function') {
|
|
213
|
+
const rows = await Model.get()
|
|
214
|
+
return Array.isArray(rows) ? rows : []
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// DB unreachable / table missing / model schema mismatch — page
|
|
219
|
+
// should render an empty state, not a 500.
|
|
220
|
+
}
|
|
221
|
+
return []
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Run `Model.count()` and always resolve to a number — never throws. Falls
|
|
226
|
+
* back to `safeAll(Model).length` when the model has no `.count` method
|
|
227
|
+
* (rare, but the makeStub stub provides one so this is mostly defensive).
|
|
228
|
+
*/
|
|
229
|
+
export async function safeCount(Model: any): Promise<number> {
|
|
230
|
+
if (!Model) return 0
|
|
231
|
+
try {
|
|
232
|
+
if (typeof Model.count === 'function') {
|
|
233
|
+
const n = await Model.count()
|
|
234
|
+
return typeof n === 'number' ? n : 0
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// count() may fail if the table doesn't exist; let safeAll absorb that
|
|
239
|
+
// by returning [] and we'll report 0 records.
|
|
240
|
+
}
|
|
241
|
+
const rows = await safeAll(Model)
|
|
242
|
+
return rows.length
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Read a column off a model row, tolerating both proxy-style rows
|
|
247
|
+
* (`row.get('name')`) and plain object rows (`row.name`). Returns the
|
|
248
|
+
* fallback when the column is missing or null/undefined.
|
|
249
|
+
*/
|
|
250
|
+
export function safeGet(row: any, key: string, fallback: any = ''): any {
|
|
251
|
+
if (!row) return fallback
|
|
252
|
+
try {
|
|
253
|
+
if (typeof row.get === 'function') {
|
|
254
|
+
const v = row.get(key)
|
|
255
|
+
if (v !== undefined && v !== null) return v
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch { /* fall through to direct property access */ }
|
|
259
|
+
const direct = row[key]
|
|
260
|
+
return direct !== undefined && direct !== null ? direct : fallback
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Group rows by the value of a column. */
|
|
264
|
+
export function countBy(rows: any[], key: string): Record<string, number> {
|
|
265
|
+
const out: Record<string, number> = {}
|
|
266
|
+
for (const r of rows) {
|
|
267
|
+
const v = String(safeGet(r, key, '') || 'unknown')
|
|
268
|
+
out[v] = (out[v] || 0) + 1
|
|
269
|
+
}
|
|
270
|
+
return out
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Sum a numeric column across rows. */
|
|
274
|
+
export function sumBy(rows: any[], key: string): number {
|
|
275
|
+
let total = 0
|
|
276
|
+
for (const r of rows) {
|
|
277
|
+
const v = Number(safeGet(r, key, 0)) || 0
|
|
278
|
+
total += v
|
|
279
|
+
}
|
|
280
|
+
return total
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Top N values of a column with their counts, sorted desc. */
|
|
284
|
+
export function topN(rows: any[], key: string, n = 5): Array<{ value: string, count: number }> {
|
|
285
|
+
const counts = countBy(rows, key)
|
|
286
|
+
return Object.entries(counts)
|
|
287
|
+
.map(([value, count]) => ({ value, count }))
|
|
288
|
+
.sort((a, b) => b.count - a.count)
|
|
289
|
+
.slice(0, n)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Group rows into buckets by ISO date. Returns a window of `days` from
|
|
294
|
+
* today backwards, with zero-counts for empty days so charts render a
|
|
295
|
+
* smooth line. Date column is parsed as `Date(value)`.
|
|
296
|
+
*/
|
|
297
|
+
export function groupByDay(
|
|
298
|
+
rows: any[],
|
|
299
|
+
key: string,
|
|
300
|
+
days = 30,
|
|
301
|
+
): Array<{ date: string, count: number }> {
|
|
302
|
+
const buckets: Record<string, number> = {}
|
|
303
|
+
const today = new Date()
|
|
304
|
+
today.setHours(0, 0, 0, 0)
|
|
305
|
+
for (let i = days - 1; i >= 0; i--) {
|
|
306
|
+
const d = new Date(today)
|
|
307
|
+
d.setDate(today.getDate() - i)
|
|
308
|
+
buckets[d.toISOString().slice(0, 10)] = 0
|
|
309
|
+
}
|
|
310
|
+
for (const r of rows) {
|
|
311
|
+
const raw = safeGet(r, key, null)
|
|
312
|
+
if (!raw) continue
|
|
313
|
+
const d = new Date(raw)
|
|
314
|
+
if (Number.isNaN(d.getTime())) continue
|
|
315
|
+
const k = d.toISOString().slice(0, 10)
|
|
316
|
+
if (k in buckets) buckets[k]++
|
|
317
|
+
}
|
|
318
|
+
return Object.entries(buckets).map(([date, count]) => ({ date, count }))
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Sum a numeric column into per-day buckets. Same windowing semantics as
|
|
323
|
+
* `groupByDay` — useful for revenue-over-time charts.
|
|
324
|
+
*/
|
|
325
|
+
export function sumByDay(
|
|
326
|
+
rows: any[],
|
|
327
|
+
dateKey: string,
|
|
328
|
+
valueKey: string,
|
|
329
|
+
days = 30,
|
|
330
|
+
): Array<{ date: string, total: number }> {
|
|
331
|
+
const buckets: Record<string, number> = {}
|
|
332
|
+
const today = new Date()
|
|
333
|
+
today.setHours(0, 0, 0, 0)
|
|
334
|
+
for (let i = days - 1; i >= 0; i--) {
|
|
335
|
+
const d = new Date(today)
|
|
336
|
+
d.setDate(today.getDate() - i)
|
|
337
|
+
buckets[d.toISOString().slice(0, 10)] = 0
|
|
338
|
+
}
|
|
339
|
+
for (const r of rows) {
|
|
340
|
+
const raw = safeGet(r, dateKey, null)
|
|
341
|
+
if (!raw) continue
|
|
342
|
+
const d = new Date(raw)
|
|
343
|
+
if (Number.isNaN(d.getTime())) continue
|
|
344
|
+
const k = d.toISOString().slice(0, 10)
|
|
345
|
+
if (!(k in buckets)) continue
|
|
346
|
+
buckets[k] += Number(safeGet(r, valueKey, 0)) || 0
|
|
347
|
+
}
|
|
348
|
+
return Object.entries(buckets).map(([date, total]) => ({ date, total }))
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** "5m ago" / "2h ago" / "3d ago" style relative time. Stable on server. */
|
|
352
|
+
export function formatRelative(input: any): string {
|
|
353
|
+
if (!input) return ''
|
|
354
|
+
const d = new Date(input)
|
|
355
|
+
if (Number.isNaN(d.getTime())) return String(input)
|
|
356
|
+
const diff = Math.max(0, Date.now() - d.getTime())
|
|
357
|
+
const sec = Math.floor(diff / 1000)
|
|
358
|
+
if (sec < 60) return 'just now'
|
|
359
|
+
const min = Math.floor(sec / 60)
|
|
360
|
+
if (min < 60) return `${min}m ago`
|
|
361
|
+
const hr = Math.floor(min / 60)
|
|
362
|
+
if (hr < 24) return `${hr}h ago`
|
|
363
|
+
const day = Math.floor(hr / 24)
|
|
364
|
+
if (day < 30) return `${day}d ago`
|
|
365
|
+
const mo = Math.floor(day / 30)
|
|
366
|
+
if (mo < 12) return `${mo}mo ago`
|
|
367
|
+
const yr = Math.floor(mo / 12)
|
|
368
|
+
return `${yr}y ago`
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Format any value as USD with no fractional cents for whole numbers. */
|
|
372
|
+
export function formatCurrency(value: any): string {
|
|
373
|
+
const n = Number(value) || 0
|
|
374
|
+
return `$${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
|
375
|
+
}
|