create-lacspace-app 2.13.1 → 2.14.1
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/README.md +14 -0
- package/dist/index.js +412 -9
- package/dist/lib.cjs +412 -9
- package/dist/lib.js +412 -9
- package/package.json +2 -2
package/dist/lib.js
CHANGED
|
@@ -62,7 +62,8 @@ var pkgJson = (ctx) => JSON.stringify({
|
|
|
62
62
|
name: ctx.name,
|
|
63
63
|
version: "0.1.0",
|
|
64
64
|
private: true,
|
|
65
|
-
|
|
65
|
+
// `typecheck` so the full-stack root script can run it across both workspaces.
|
|
66
|
+
scripts: { dev: "next dev", build: "next build", start: "next start", lint: "next lint", typecheck: "tsc --noEmit" },
|
|
66
67
|
dependencies: {
|
|
67
68
|
next: "^15.1.0",
|
|
68
69
|
react: "^19.0.0",
|
|
@@ -240,6 +241,27 @@ h1, .display { letter-spacing: -0.035em; line-height: 1.02; }
|
|
|
240
241
|
::-webkit-scrollbar-thumb:hover { background: var(--hairline); }
|
|
241
242
|
:focus-visible { outline: 2px solid var(--accent-to); outline-offset: 2px; border-radius: 6px; }
|
|
242
243
|
|
|
244
|
+
/* Skip link: off-screen until it is focused, then the first thing you see.
|
|
245
|
+
The wrapper it targets carries tabIndex={-1}, so focus actually moves there
|
|
246
|
+
instead of the browser only scrolling. */
|
|
247
|
+
.skip-link {
|
|
248
|
+
position: fixed;
|
|
249
|
+
top: 10px;
|
|
250
|
+
left: 10px;
|
|
251
|
+
z-index: 100;
|
|
252
|
+
padding: 10px 16px;
|
|
253
|
+
border-radius: 10px;
|
|
254
|
+
background: var(--accent-to);
|
|
255
|
+
color: var(--on-accent);
|
|
256
|
+
font-weight: 600;
|
|
257
|
+
text-decoration: none;
|
|
258
|
+
transform: translateY(-160%);
|
|
259
|
+
transition: transform 0.18s ease;
|
|
260
|
+
}
|
|
261
|
+
.skip-link:focus { transform: translateY(0); }
|
|
262
|
+
@media (prefers-reduced-motion: reduce) { .skip-link { transition: none; } }
|
|
263
|
+
#main:focus { outline: none; }
|
|
264
|
+
|
|
243
265
|
.gradient-text {
|
|
244
266
|
background: linear-gradient(115deg, var(--accent-from), var(--accent-to));
|
|
245
267
|
-webkit-background-clip: text; background-clip: text; color: transparent;
|
|
@@ -404,11 +426,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|
|
404
426
|
return (
|
|
405
427
|
<html lang="en" className={\`\${inter.variable} \${inter.className}\`} suppressHydrationWarning>
|
|
406
428
|
<body className="antialiased">
|
|
429
|
+
{/* Keyboard users land here first and can jump past the nav (WCAG 2.4.1). */}
|
|
430
|
+
<a href="#main" className="skip-link">Skip to content</a>
|
|
407
431
|
{/* \u2728 Dark / light / system theming with a built-in no-flash script \u2014 @lacspace/theme */}
|
|
408
432
|
<ThemeProvider defaultTheme="dark">
|
|
409
433
|
{/* \u2728 Press \u2318K / Ctrl-K anywhere \u2014 powered by @lacspace/ui */}
|
|
410
434
|
<CommandMenu />
|
|
411
|
-
${open}{children}
|
|
435
|
+
${open}<div id="main" tabIndex={-1}>{children}</div>${close}
|
|
412
436
|
</ThemeProvider>
|
|
413
437
|
<script
|
|
414
438
|
type="application/ld+json"
|
|
@@ -2435,8 +2459,364 @@ var FEATURES = [
|
|
|
2435
2459
|
"SSE auto-reconnects and needs no WebSocket server \u2014 great for notifications and live feeds."
|
|
2436
2460
|
],
|
|
2437
2461
|
learn: "https://developer.lacspace.com/packages/sse"
|
|
2462
|
+
},
|
|
2463
|
+
{
|
|
2464
|
+
key: "ui",
|
|
2465
|
+
label: "UI components",
|
|
2466
|
+
description: "The @lacspace/components library wired into this template \u2014 96 accessible components, a theme bridge so they inherit your accent and dark mode, and a working settings page. Frontend, any template.",
|
|
2467
|
+
deps: { "@lacspace/components": "^1.0.1" },
|
|
2468
|
+
files: () => ({
|
|
2469
|
+
"app/lacspace-ui.css": uiThemeBridge(),
|
|
2470
|
+
"app/ui/layout.tsx": uiRouteLayout(),
|
|
2471
|
+
"app/ui/page.tsx": uiShowcasePage(),
|
|
2472
|
+
"components/ui-provider.tsx": uiProviderComponent()
|
|
2473
|
+
}),
|
|
2474
|
+
nextSteps: [
|
|
2475
|
+
"Run `npm run dev` and open http://localhost:3000/ui \u2014 a real form, a confirm dialog and toasts, already themed.",
|
|
2476
|
+
'Use the kit anywhere: import "@lacspace/components/styles.css" and "./lacspace-ui.css" in app/layout.tsx, then wrap <body> with <UIProvider> from @/components/ui-provider.',
|
|
2477
|
+
"Rebrand everything from app/lacspace-ui.css \u2014 it maps this template's accent, radius and font onto the kit's --lac-* variables, so dark mode follows your theme toggle."
|
|
2478
|
+
],
|
|
2479
|
+
learn: "https://developer.lacspace.com/components"
|
|
2480
|
+
},
|
|
2481
|
+
{
|
|
2482
|
+
key: "dataviz",
|
|
2483
|
+
label: "Charts, tables & dates",
|
|
2484
|
+
description: "A working insights page from @lacspace/charts, @lacspace/table and @lacspace/date \u2014 SVG charts, a sortable/exportable data table and a date-range filter. No canvas, no D3, no grid licence. Frontend, any template.",
|
|
2485
|
+
deps: {
|
|
2486
|
+
"@lacspace/components": "^1.0.1",
|
|
2487
|
+
"@lacspace/charts": "^1.0.0",
|
|
2488
|
+
"@lacspace/table": "^1.0.0",
|
|
2489
|
+
"@lacspace/date": "^1.0.0"
|
|
2490
|
+
},
|
|
2491
|
+
files: () => ({
|
|
2492
|
+
"app/lacspace-ui.css": uiThemeBridge(),
|
|
2493
|
+
"app/insights/layout.tsx": dataVizRouteLayout(),
|
|
2494
|
+
"app/insights/page.tsx": dataVizPage()
|
|
2495
|
+
}),
|
|
2496
|
+
nextSteps: [
|
|
2497
|
+
"Run `npm run dev` and open http://localhost:3000/insights \u2014 stats, a line chart, a date-range filter and an exportable table.",
|
|
2498
|
+
"Swap the sample arrays at the top of app/insights/page.tsx for your own data; everything else is already wired.",
|
|
2499
|
+
"The CSV export neutralises leading =, +, - and @, so an exported cell can never run as a formula in Excel."
|
|
2500
|
+
],
|
|
2501
|
+
learn: "https://developer.lacspace.com/components#charts"
|
|
2438
2502
|
}
|
|
2439
2503
|
];
|
|
2504
|
+
var uiThemeBridge = () => `/* Map this app's tokens onto @lacspace/components.
|
|
2505
|
+
*
|
|
2506
|
+
* The kit's own dark palette lives behind \`prefers-color-scheme\` and a
|
|
2507
|
+
* \`data-theme\` attribute, but this app switches themes with a \`.dark\` class on
|
|
2508
|
+
* <html>. The repeated \`:root\` below is deliberate: it raises specificity just
|
|
2509
|
+
* enough to win against the kit's own theme blocks, whatever the OS setting is,
|
|
2510
|
+
* so the components follow YOUR toggle and never fight it.
|
|
2511
|
+
*
|
|
2512
|
+
* Every line here is a suggestion \u2014 change any of them and the whole kit
|
|
2513
|
+
* follows. The full token list is at the top of @lacspace/components/styles.css.
|
|
2514
|
+
*/
|
|
2515
|
+
:root:root:root {
|
|
2516
|
+
/* Brand */
|
|
2517
|
+
--lac-accent: var(--accent-to);
|
|
2518
|
+
--lac-accent-hover: var(--accent-from);
|
|
2519
|
+
--lac-accent-active: var(--accent-from);
|
|
2520
|
+
--lac-accent-fg: var(--on-accent);
|
|
2521
|
+
--lac-accent-soft: color-mix(in oklab, var(--accent-to) 14%, transparent);
|
|
2522
|
+
--lac-accent-ring: color-mix(in oklab, var(--accent-to) 38%, transparent);
|
|
2523
|
+
|
|
2524
|
+
/* Surfaces & text \u2014 these already swap with the .dark class, so dark mode
|
|
2525
|
+
comes along for free. */
|
|
2526
|
+
--lac-bg: var(--bg);
|
|
2527
|
+
--lac-bg-subtle: var(--surface);
|
|
2528
|
+
--lac-bg-muted: var(--panel);
|
|
2529
|
+
--lac-surface: var(--surface);
|
|
2530
|
+
--lac-surface-raised: var(--panel);
|
|
2531
|
+
--lac-fg: var(--fg);
|
|
2532
|
+
--lac-fg-muted: var(--muted);
|
|
2533
|
+
--lac-fg-faint: var(--faint);
|
|
2534
|
+
--lac-fg-on-accent: var(--on-accent);
|
|
2535
|
+
|
|
2536
|
+
/* Lines & depth */
|
|
2537
|
+
--lac-border: var(--hairline);
|
|
2538
|
+
--lac-border-strong: color-mix(in oklab, var(--fg) 22%, transparent);
|
|
2539
|
+
--lac-shadow-sm: var(--shadow-sm);
|
|
2540
|
+
--lac-shadow: var(--shadow);
|
|
2541
|
+
--lac-shadow-lg: var(--shadow-lg);
|
|
2542
|
+
|
|
2543
|
+
/* Shape & type */
|
|
2544
|
+
--lac-radius: var(--radius);
|
|
2545
|
+
--lac-radius-lg: var(--radius-lg);
|
|
2546
|
+
--lac-font: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
|
2547
|
+
}
|
|
2548
|
+
`;
|
|
2549
|
+
var uiRouteLayout = () => `import "@lacspace/components/styles.css";
|
|
2550
|
+
import "../lacspace-ui.css";
|
|
2551
|
+
import { ToastProvider } from "@lacspace/components";
|
|
2552
|
+
|
|
2553
|
+
// Scoped to this route so the add-on needs no edits to app/layout.tsx. To use
|
|
2554
|
+
// the kit across the whole app, move these two imports there and wrap <body>
|
|
2555
|
+
// with <UIProvider> from @/components/ui-provider instead.
|
|
2556
|
+
export default function UILayout({ children }: { children: React.ReactNode }) {
|
|
2557
|
+
return <ToastProvider>{children}</ToastProvider>;
|
|
2558
|
+
}
|
|
2559
|
+
`;
|
|
2560
|
+
var uiProviderComponent = () => `"use client";
|
|
2561
|
+
import { ToastProvider } from "@lacspace/components";
|
|
2562
|
+
|
|
2563
|
+
/**
|
|
2564
|
+
* Wrap <body> with this once to use the component kit across the whole app,
|
|
2565
|
+
* and import the two stylesheets in app/layout.tsx:
|
|
2566
|
+
*
|
|
2567
|
+
* import "@lacspace/components/styles.css";
|
|
2568
|
+
* import "./lacspace-ui.css";
|
|
2569
|
+
*/
|
|
2570
|
+
export function UIProvider({ children }: { children: React.ReactNode }) {
|
|
2571
|
+
return <ToastProvider position="bottom-right">{children}</ToastProvider>;
|
|
2572
|
+
}
|
|
2573
|
+
`;
|
|
2574
|
+
var uiShowcasePage = () => `"use client";
|
|
2575
|
+
import { useState } from "react";
|
|
2576
|
+
import {
|
|
2577
|
+
Alert,
|
|
2578
|
+
Badge,
|
|
2579
|
+
Button,
|
|
2580
|
+
Card,
|
|
2581
|
+
CardBody,
|
|
2582
|
+
CardDescription,
|
|
2583
|
+
CardHeader,
|
|
2584
|
+
CardTitle,
|
|
2585
|
+
ConfirmDialog,
|
|
2586
|
+
Field,
|
|
2587
|
+
Input,
|
|
2588
|
+
PasswordInput,
|
|
2589
|
+
Select,
|
|
2590
|
+
Stack,
|
|
2591
|
+
Switch,
|
|
2592
|
+
useToast,
|
|
2593
|
+
} from "@lacspace/components";
|
|
2594
|
+
|
|
2595
|
+
// Everything below is real: a form that validates, a dialog that waits for its
|
|
2596
|
+
// promise, and a toast queue \u2014 none of it styled with utility classes. The look
|
|
2597
|
+
// comes from app/lacspace-ui.css, which maps this template's tokens onto the kit.
|
|
2598
|
+
export default function UIPage() {
|
|
2599
|
+
const { toast } = useToast();
|
|
2600
|
+
const [confirming, setConfirming] = useState(false);
|
|
2601
|
+
const [updates, setUpdates] = useState(true);
|
|
2602
|
+
const [errors, setErrors] = useState<{ email?: string }>({});
|
|
2603
|
+
|
|
2604
|
+
function save(form: FormData) {
|
|
2605
|
+
const email = String(form.get("email") ?? "");
|
|
2606
|
+
if (!email.includes("@")) {
|
|
2607
|
+
setErrors({ email: "That doesn't look like an email address." });
|
|
2608
|
+
return;
|
|
2609
|
+
}
|
|
2610
|
+
setErrors({});
|
|
2611
|
+
toast({ title: "Settings saved", tone: "success" });
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
return (
|
|
2615
|
+
<main className="mx-auto max-w-2xl px-6 py-16">
|
|
2616
|
+
<Stack gap={4}>
|
|
2617
|
+
<Card>
|
|
2618
|
+
<CardHeader>
|
|
2619
|
+
<CardTitle>Account settings</CardTitle>
|
|
2620
|
+
<CardDescription>
|
|
2621
|
+
96 components from @lacspace/components \u2014 accessible, server-render safe, and
|
|
2622
|
+
themed by CSS variables instead of utility classes.
|
|
2623
|
+
</CardDescription>
|
|
2624
|
+
</CardHeader>
|
|
2625
|
+
<CardBody>
|
|
2626
|
+
<form action={save}>
|
|
2627
|
+
<Stack gap={3}>
|
|
2628
|
+
<Field label="Work email" hint="We never share it." error={errors.email}>
|
|
2629
|
+
{({ id, describedBy, invalid }) => (
|
|
2630
|
+
<Input
|
|
2631
|
+
id={id}
|
|
2632
|
+
name="email"
|
|
2633
|
+
type="email"
|
|
2634
|
+
defaultValue="you@company.com"
|
|
2635
|
+
aria-describedby={describedBy}
|
|
2636
|
+
invalid={invalid}
|
|
2637
|
+
/>
|
|
2638
|
+
)}
|
|
2639
|
+
</Field>
|
|
2640
|
+
|
|
2641
|
+
<Field label="New password" hint="At least 12 characters.">
|
|
2642
|
+
{({ id, describedBy }) => (
|
|
2643
|
+
<PasswordInput id={id} name="password" aria-describedby={describedBy} strength />
|
|
2644
|
+
)}
|
|
2645
|
+
</Field>
|
|
2646
|
+
|
|
2647
|
+
<Field label="Plan">
|
|
2648
|
+
{({ id }) => (
|
|
2649
|
+
<Select
|
|
2650
|
+
id={id}
|
|
2651
|
+
name="plan"
|
|
2652
|
+
defaultValue="pro"
|
|
2653
|
+
options={[
|
|
2654
|
+
{ value: "free", label: "Free" },
|
|
2655
|
+
{ value: "pro", label: "Pro" },
|
|
2656
|
+
{ value: "team", label: "Team" },
|
|
2657
|
+
]}
|
|
2658
|
+
/>
|
|
2659
|
+
)}
|
|
2660
|
+
</Field>
|
|
2661
|
+
|
|
2662
|
+
<Switch checked={updates} onChange={setUpdates} label="Email me about product updates" />
|
|
2663
|
+
|
|
2664
|
+
<Button type="submit" full>
|
|
2665
|
+
Save changes
|
|
2666
|
+
</Button>
|
|
2667
|
+
</Stack>
|
|
2668
|
+
</form>
|
|
2669
|
+
</CardBody>
|
|
2670
|
+
</Card>
|
|
2671
|
+
|
|
2672
|
+
<Alert tone="info" title="Rebrand the whole kit from one file">
|
|
2673
|
+
Open <code>app/lacspace-ui.css</code>. It maps this template’s accent, radius and font
|
|
2674
|
+
onto the kit’s variables, so the components already match \u2014 and they follow your
|
|
2675
|
+
existing dark-mode toggle.
|
|
2676
|
+
</Alert>
|
|
2677
|
+
|
|
2678
|
+
<Card>
|
|
2679
|
+
<CardBody>
|
|
2680
|
+
<Stack direction="row" gap={2} align="center" wrap>
|
|
2681
|
+
<Badge tone="success" dot>
|
|
2682
|
+
Live
|
|
2683
|
+
</Badge>
|
|
2684
|
+
<Button variant="soft" onClick={() => toast("Saved to drafts")}>
|
|
2685
|
+
Show a toast
|
|
2686
|
+
</Button>
|
|
2687
|
+
<Button tone="danger" variant="soft" onClick={() => setConfirming(true)}>
|
|
2688
|
+
Delete account
|
|
2689
|
+
</Button>
|
|
2690
|
+
</Stack>
|
|
2691
|
+
</CardBody>
|
|
2692
|
+
</Card>
|
|
2693
|
+
</Stack>
|
|
2694
|
+
|
|
2695
|
+
<ConfirmDialog
|
|
2696
|
+
open={confirming}
|
|
2697
|
+
onOpenChange={setConfirming}
|
|
2698
|
+
tone="danger"
|
|
2699
|
+
title="Delete this account?"
|
|
2700
|
+
message="Everything in it goes with it. This cannot be undone."
|
|
2701
|
+
confirmLabel="Delete account"
|
|
2702
|
+
onConfirm={async () => {
|
|
2703
|
+
// Return a promise and the button spins, blocks repeat clicks, and
|
|
2704
|
+
// keeps the dialog open if it rejects \u2014 a failed delete never looks
|
|
2705
|
+
// like a successful one.
|
|
2706
|
+
await new Promise((resolve) => setTimeout(resolve, 700));
|
|
2707
|
+
toast({ title: "Account deleted", tone: "success" });
|
|
2708
|
+
}}
|
|
2709
|
+
/>
|
|
2710
|
+
</main>
|
|
2711
|
+
);
|
|
2712
|
+
}
|
|
2713
|
+
`;
|
|
2714
|
+
var dataVizRouteLayout = () => `import "@lacspace/components/styles.css";
|
|
2715
|
+
import "@lacspace/charts/styles.css";
|
|
2716
|
+
import "@lacspace/table/styles.css";
|
|
2717
|
+
import "@lacspace/date/styles.css";
|
|
2718
|
+
import "../lacspace-ui.css";
|
|
2719
|
+
|
|
2720
|
+
// Scoped to this route; move these imports to app/layout.tsx to use the kit
|
|
2721
|
+
// everywhere. Order matters: lacspace-ui.css maps this app's tokens onto the
|
|
2722
|
+
// kit, so it comes last.
|
|
2723
|
+
export default function InsightsLayout({ children }: { children: React.ReactNode }) {
|
|
2724
|
+
return <>{children}</>;
|
|
2725
|
+
}
|
|
2726
|
+
`;
|
|
2727
|
+
var dataVizPage = () => `"use client";
|
|
2728
|
+
import { useState } from "react";
|
|
2729
|
+
import { Heading, Grid, Stack, Stat, Text } from "@lacspace/components";
|
|
2730
|
+
import { LineChart } from "@lacspace/charts";
|
|
2731
|
+
import { DataTable, textColumn, badgeColumn, currencyColumn, numberColumn } from "@lacspace/table";
|
|
2732
|
+
import { DateRangePicker, defaultPresets } from "@lacspace/date";
|
|
2733
|
+
|
|
2734
|
+
// Swap these three arrays for your own data \u2014 nothing else needs to change.
|
|
2735
|
+
const MONTHS = ["Apr", "May", "Jun", "Jul", "Aug", "Sep"];
|
|
2736
|
+
const REVENUE = [18, 21, 25, 24, 31, 38];
|
|
2737
|
+
const TARGET = [20, 22, 24, 26, 28, 30];
|
|
2738
|
+
|
|
2739
|
+
type Account = {
|
|
2740
|
+
id: string;
|
|
2741
|
+
account: string;
|
|
2742
|
+
plan: string;
|
|
2743
|
+
seats: number;
|
|
2744
|
+
mrr: number;
|
|
2745
|
+
};
|
|
2746
|
+
|
|
2747
|
+
const ACCOUNTS: Account[] = [
|
|
2748
|
+
{ id: "1", account: "Northwind", plan: "team", seats: 42, mrr: 1890 },
|
|
2749
|
+
{ id: "2", account: "Umbrella", plan: "pro", seats: 12, mrr: 540 },
|
|
2750
|
+
{ id: "3", account: "Initech", plan: "team", seats: 68, mrr: 3060 },
|
|
2751
|
+
{ id: "4", account: "Hooli", plan: "free", seats: 3, mrr: 0 },
|
|
2752
|
+
{ id: "5", account: "Stark Industries", plan: "pro", seats: 19, mrr: 855 },
|
|
2753
|
+
{ id: "6", account: "Wayne Enterprises", plan: "team", seats: 51, mrr: 2295 },
|
|
2754
|
+
];
|
|
2755
|
+
|
|
2756
|
+
export default function InsightsPage() {
|
|
2757
|
+
const [range, setRange] = useState<unknown>(null);
|
|
2758
|
+
|
|
2759
|
+
return (
|
|
2760
|
+
<main className="mx-auto max-w-5xl px-6 py-16">
|
|
2761
|
+
<Stack gap={5}>
|
|
2762
|
+
<Stack direction="row" justify="between" align="center" gap={3} wrap>
|
|
2763
|
+
<div>
|
|
2764
|
+
<Heading level={1}>Insights</Heading>
|
|
2765
|
+
<Text tone="muted">Charts, a data table and a date filter \u2014 all zero-dependency.</Text>
|
|
2766
|
+
</div>
|
|
2767
|
+
<DateRangePicker
|
|
2768
|
+
numberOfMonths={2}
|
|
2769
|
+
presets={defaultPresets()}
|
|
2770
|
+
separator=" \u2013 "
|
|
2771
|
+
onChange={setRange}
|
|
2772
|
+
/>
|
|
2773
|
+
</Stack>
|
|
2774
|
+
|
|
2775
|
+
<Grid columns={{ base: 1, md: 3 }} gap={3}>
|
|
2776
|
+
<Stat label="MRR" value="$8,640" delta={12.4} comparison="vs last month" />
|
|
2777
|
+
<Stat label="Seats" value="195" delta={6.1} comparison="vs last month" />
|
|
2778
|
+
<Stat label="Churn" value="1.8%" delta={-0.3} invertDelta comparison="vs last month" />
|
|
2779
|
+
</Grid>
|
|
2780
|
+
|
|
2781
|
+
<LineChart
|
|
2782
|
+
labels={MONTHS}
|
|
2783
|
+
series={[
|
|
2784
|
+
{ name: "Revenue", data: REVENUE, area: true },
|
|
2785
|
+
{ name: "Target", data: TARGET, dashed: true },
|
|
2786
|
+
]}
|
|
2787
|
+
curve
|
|
2788
|
+
dots
|
|
2789
|
+
tooltip
|
|
2790
|
+
responsive
|
|
2791
|
+
dataTable
|
|
2792
|
+
formatValue={(n) => "$" + n + "k"}
|
|
2793
|
+
/>
|
|
2794
|
+
|
|
2795
|
+
<DataTable<Account>
|
|
2796
|
+
caption="Accounts"
|
|
2797
|
+
data={ACCOUNTS}
|
|
2798
|
+
getRowId={(row) => row.id}
|
|
2799
|
+
columns={[
|
|
2800
|
+
textColumn<Account>("account", { header: "Account", key: "account", pinned: "left" }),
|
|
2801
|
+
badgeColumn<Account>("plan", {
|
|
2802
|
+
header: "Plan",
|
|
2803
|
+
key: "plan",
|
|
2804
|
+
tones: { team: "success", pro: "info", free: "default" },
|
|
2805
|
+
}),
|
|
2806
|
+
numberColumn<Account>("seats", { header: "Seats", key: "seats", aggregate: "sum" }),
|
|
2807
|
+
currencyColumn<Account>("mrr", { header: "MRR", key: "mrr", currency: "USD", aggregate: "sum" }),
|
|
2808
|
+
]}
|
|
2809
|
+
defaultSort={[{ id: "mrr", direction: "desc" }]}
|
|
2810
|
+
searchable
|
|
2811
|
+
selectable
|
|
2812
|
+
exportable
|
|
2813
|
+
stickyHeader
|
|
2814
|
+
/>
|
|
2815
|
+
</Stack>
|
|
2816
|
+
</main>
|
|
2817
|
+
);
|
|
2818
|
+
}
|
|
2819
|
+
`;
|
|
2440
2820
|
var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
|
|
2441
2821
|
import { stream } from "@lacspace/ai";
|
|
2442
2822
|
import type { Message } from "@lacspace/ai";
|
|
@@ -7085,7 +7465,9 @@ var rootPkgJson = (ctx) => JSON.stringify({
|
|
|
7085
7465
|
start: 'concurrently -k -n api,web -c blue,magenta "npm:start:api" "npm:start:web"',
|
|
7086
7466
|
"start:api": `npm run start --workspace ${scope(ctx)}/backend`,
|
|
7087
7467
|
"start:web": `npm run start --workspace ${scope(ctx)}/frontend`,
|
|
7088
|
-
|
|
7468
|
+
// Both workspaces: a gate that silently skips the frontend is worse than
|
|
7469
|
+
// no gate, because it reports green over unchecked code.
|
|
7470
|
+
typecheck: `npm run typecheck --workspace ${scope(ctx)}/backend && npm run typecheck --workspace ${scope(ctx)}/frontend`
|
|
7089
7471
|
},
|
|
7090
7472
|
devDependencies: { concurrently: "^9.1.0" },
|
|
7091
7473
|
engines: { node: ">=18" }
|
|
@@ -7470,6 +7852,7 @@ var backendPkgJson = (ctx) => {
|
|
|
7470
7852
|
"@lacspace/rate-limit": "^1.2.0",
|
|
7471
7853
|
"@lacspace/cache": "^1.1.0",
|
|
7472
7854
|
"@lacspace/logger": "^1.0.0",
|
|
7855
|
+
"@lacspace/headers": "^1.1.2",
|
|
7473
7856
|
...featureDeps,
|
|
7474
7857
|
[`${scope(ctx)}/types`]: "*"
|
|
7475
7858
|
},
|
|
@@ -7798,14 +8181,18 @@ var backendValidation = () => `import { v, type Infer } from "@lacspace/validate
|
|
|
7798
8181
|
// how this works: Zod-like schemas (@lacspace/validate). .parse(body) throws a
|
|
7799
8182
|
// ValidationError on bad input, which the error middleware turns into a clean 400
|
|
7800
8183
|
// with per-field messages.
|
|
8184
|
+
// Email is normalised HERE, not only in the schema. Mongoose applies
|
|
8185
|
+
// \`lowercase\`/\`trim\` setters when it SAVES a document, but not to query
|
|
8186
|
+
// filters \u2014 so \`User.findOne({ email })\` with "Bro@Gmail.com" would miss the
|
|
8187
|
+
// stored "bro@gmail.com", and the account would be unreachable at login.
|
|
7801
8188
|
export const RegisterInput = v.object({
|
|
7802
8189
|
name: v.string().min(2).max(80),
|
|
7803
|
-
email: v.string().email(),
|
|
8190
|
+
email: v.string().email().trim().toLowerCase(),
|
|
7804
8191
|
password: v.string().min(8).max(200),
|
|
7805
8192
|
});
|
|
7806
8193
|
|
|
7807
8194
|
export const LoginInput = v.object({
|
|
7808
|
-
email: v.string().email(),
|
|
8195
|
+
email: v.string().email().trim().toLowerCase(),
|
|
7809
8196
|
password: v.string().min(1),
|
|
7810
8197
|
});
|
|
7811
8198
|
|
|
@@ -7941,19 +8328,35 @@ export default router;
|
|
|
7941
8328
|
`;
|
|
7942
8329
|
var backendApp = (ctx) => `import express from "express";
|
|
7943
8330
|
import cors from "cors";
|
|
8331
|
+
import { expressSecurityHeaders } from "@lacspace/headers";
|
|
7944
8332
|
import { env } from "./env.js";
|
|
7945
8333
|
import { requestLogger } from "./logger.js";
|
|
7946
8334
|
import { errorHandler } from "./middleware/error.js";
|
|
7947
8335
|
import { registerRoutes } from "./routes/index.js";
|
|
7948
8336
|
|
|
7949
|
-
// how this works: assembles the Express app \u2014 CORS for the
|
|
7950
|
-
// structured request logging (@lacspace/logger), a health
|
|
7951
|
-
// (see routes/index.ts), then the error handler LAST.
|
|
8337
|
+
// how this works: assembles the Express app \u2014 security headers, CORS for the
|
|
8338
|
+
// frontend, JSON parsing, structured request logging (@lacspace/logger), a health
|
|
8339
|
+
// check, all route groups (see routes/index.ts), then the error handler LAST.
|
|
7952
8340
|
export function createApp(): express.Express {
|
|
7953
8341
|
const app = express();
|
|
7954
8342
|
|
|
8343
|
+
// Express advertises itself in every response by default; there is no reason
|
|
8344
|
+
// to tell the internet what the API is built on.
|
|
8345
|
+
app.disable("x-powered-by");
|
|
8346
|
+
|
|
8347
|
+
// The same hardening the frontend gets, tuned for an API: deny framing, send
|
|
8348
|
+
// no referrer, and a CSP that allows nothing \u2014 an API returns JSON, so there
|
|
8349
|
+
// is no script, style or image for a browser to load from it.
|
|
8350
|
+
app.use(expressSecurityHeaders({
|
|
8351
|
+
frameOptions: "DENY",
|
|
8352
|
+
referrerPolicy: "no-referrer",
|
|
8353
|
+
crossOriginResourcePolicy: "same-site",
|
|
8354
|
+
contentSecurityPolicy: { defaultSrc: ["'none'"], frameAncestors: ["'none'"] },
|
|
8355
|
+
}));
|
|
8356
|
+
|
|
7955
8357
|
app.use(cors({ origin: env.CORS_ORIGIN.split(",").map((o) => o.trim()), credentials: true }));
|
|
7956
|
-
|
|
8358
|
+
// A JSON API has no business accepting a megabyte of body by default.
|
|
8359
|
+
app.use(express.json({ limit: "256kb" }));
|
|
7957
8360
|
app.use(requestLogger); // one structured log line per request
|
|
7958
8361
|
|
|
7959
8362
|
app.get("/health", (_req, res) => { res.json({ ok: true, service: "${ctx.name}-api" }); });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-lacspace-app",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Scaffold a beautiful, production-ready Next.js app from a Lacspace template
|
|
3
|
+
"version": "2.14.1",
|
|
4
|
+
"description": "Scaffold a beautiful, production-ready Next.js app from a Lacspace template \u2014 portfolio, business, e-commerce, SaaS, blog, docs, dashboard, restaurant or marketplace \u2014 pre-wired with SEO, security headers, sitemap and robots. Use it as a CLI (like create-next-app, but you start gorgeous) or import it as a library to generate projects programmatically.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"create-lacspace-app": "dist/index.js"
|