create-lacspace-app 2.9.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/index.js +666 -0
- package/dist/lib.cjs +666 -0
- package/dist/lib.d.cts +6 -0
- package/dist/lib.d.ts +6 -0
- package/dist/lib.js +666 -0
- package/package.json +1 -1
package/dist/lib.js
CHANGED
|
@@ -1998,6 +1998,11 @@ function applyFeatures(files, ctx) {
|
|
|
1998
1998
|
files[rel] = content;
|
|
1999
1999
|
}
|
|
2000
2000
|
}
|
|
2001
|
+
if (ctx.mode === "static") {
|
|
2002
|
+
for (const feat of features) {
|
|
2003
|
+
for (const [rel, content] of Object.entries(feat.rootFiles?.(ctx) ?? {})) files[rel] = content;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2001
2006
|
const addDeps = {};
|
|
2002
2007
|
const addScripts = {};
|
|
2003
2008
|
for (const feat of features) {
|
|
@@ -2276,6 +2281,128 @@ var FEATURES = [
|
|
|
2276
2281
|
"Reuse the helpers in backend/src/mail/mailer.ts: sendWelcome / sendVerify / sendReset."
|
|
2277
2282
|
],
|
|
2278
2283
|
learn: "https://developer.lacspace.com/packages/mailer"
|
|
2284
|
+
},
|
|
2285
|
+
{
|
|
2286
|
+
key: "i18n",
|
|
2287
|
+
label: "i18n (multi-language)",
|
|
2288
|
+
description: "Multi-language UI: a tiny runtime translator (t() + a language switcher) with English + Nepali locales, plus an i18n:check lint script.",
|
|
2289
|
+
deps: {},
|
|
2290
|
+
scripts: { "i18n:check": "npx lacspace-i18n@0.2.0 check locales" },
|
|
2291
|
+
files: () => ({
|
|
2292
|
+
"lib/i18n.ts": i18nLib(),
|
|
2293
|
+
"components/language-provider.tsx": i18nProvider(),
|
|
2294
|
+
"app/i18n-demo/page.tsx": i18nDemoPage(),
|
|
2295
|
+
"locales/en.json": i18nEn(),
|
|
2296
|
+
"locales/ne.json": i18nNe()
|
|
2297
|
+
}),
|
|
2298
|
+
nextSteps: [
|
|
2299
|
+
"Wrap app/layout.tsx's <body> with <LanguageProvider> (from @/components/language-provider).",
|
|
2300
|
+
'Use it in a client component: const { t } = useT(); then {t("hello", { name })}.',
|
|
2301
|
+
"Add keys in locales/*.json, then lint them with `npm run i18n:check`."
|
|
2302
|
+
],
|
|
2303
|
+
learn: "https://developer.lacspace.com/tools/i18n"
|
|
2304
|
+
},
|
|
2305
|
+
{
|
|
2306
|
+
key: "quality",
|
|
2307
|
+
label: "Quality & CI",
|
|
2308
|
+
description: "One-command quality gates from the Lacspace dev-tools \u2014 bundle-size budget, dependency audit and fake fixtures \u2014 plus a ready GitHub Actions CI workflow.",
|
|
2309
|
+
deps: {},
|
|
2310
|
+
scripts: {
|
|
2311
|
+
"size:check": "npx lacspace-size@0.2.0 .next/static --metric gzip --max 500kb",
|
|
2312
|
+
"deps:audit": "npx lacspace-deps@0.2.0 . --fail-on duplicates",
|
|
2313
|
+
"fixtures:gen": 'npx lacspace-fake@0.2.0 --fields "id:autoincrement,name:fullName,email:unique(email)" -n 50 --pretty -o fixtures/users.json'
|
|
2314
|
+
},
|
|
2315
|
+
files: () => ({}),
|
|
2316
|
+
rootFiles: (ctx) => ({ ".github/workflows/ci.yml": qualityCiWorkflow(ctx) }),
|
|
2317
|
+
nextSteps: [
|
|
2318
|
+
"Run `npm run size:check` (bundle-size budget) and `npm run deps:audit` (dependency audit) \u2014 both fail loudly in CI.",
|
|
2319
|
+
"Generate sample data: `npm run fixtures:gen` \u2192 fixtures/users.json.",
|
|
2320
|
+
"The GitHub Actions workflow (.github/workflows/ci.yml) runs the checks on every push."
|
|
2321
|
+
],
|
|
2322
|
+
learn: "https://developer.lacspace.com/tools/size"
|
|
2323
|
+
},
|
|
2324
|
+
{
|
|
2325
|
+
key: "uploads",
|
|
2326
|
+
label: "File uploads",
|
|
2327
|
+
description: "Authenticated file uploads stored in MongoDB, served via signed, expiring URLs (@lacspace/signed-url). No S3 required. Full-stack.",
|
|
2328
|
+
requiresBackend: true,
|
|
2329
|
+
deps: {},
|
|
2330
|
+
files: () => ({ "app/uploads/page.tsx": uploadsPage() }),
|
|
2331
|
+
backend: () => ({
|
|
2332
|
+
deps: { "@lacspace/signed-url": "^1.1.0" },
|
|
2333
|
+
files: {
|
|
2334
|
+
"src/models/upload.ts": uploadModel(),
|
|
2335
|
+
"src/routes/uploads.ts": uploadsRoutes()
|
|
2336
|
+
},
|
|
2337
|
+
routes: [{ path: "/uploads", handler: "uploadRoutes", auth: false, importLine: 'import uploadRoutes from "./uploads.js";' }],
|
|
2338
|
+
env: { UPLOAD_URL_SECRET: "Secret for signing file download URLs (blank = reuse JWT_SECRET)." }
|
|
2339
|
+
}),
|
|
2340
|
+
nextSteps: [
|
|
2341
|
+
"Sign in, then open http://localhost:3000/uploads and upload an image or file.",
|
|
2342
|
+
"Files are stored in MongoDB and served via signed, expiring links \u2014 no S3 required.",
|
|
2343
|
+
"For large files or production, swap the Mongo storage for disk or object storage."
|
|
2344
|
+
],
|
|
2345
|
+
learn: "https://developer.lacspace.com/packages/signed-url"
|
|
2346
|
+
},
|
|
2347
|
+
{
|
|
2348
|
+
key: "notify",
|
|
2349
|
+
label: "Toast notifications",
|
|
2350
|
+
description: "Beautiful in-app toast notifications (@lacspace/notify) \u2014 a <Toaster/> plus success/error/promise toasts, accessible and zero-config. Frontend, any template.",
|
|
2351
|
+
deps: { "@lacspace/notify": "^1.0.0" },
|
|
2352
|
+
files: () => ({ "components/toaster.tsx": notifyToaster(), "app/notify-demo/page.tsx": notifyDemoPage() }),
|
|
2353
|
+
nextSteps: [
|
|
2354
|
+
"Render <Toaster/> once in app/layout.tsx: import { Toaster } from '@/components/toaster'.",
|
|
2355
|
+
"Then call toast.success('Saved!') (or .error/.info/.promise) from any client component.",
|
|
2356
|
+
"See it live at http://localhost:3000/notify-demo."
|
|
2357
|
+
],
|
|
2358
|
+
learn: "https://developer.lacspace.com/packages/notify"
|
|
2359
|
+
},
|
|
2360
|
+
{
|
|
2361
|
+
key: "captcha",
|
|
2362
|
+
label: "Keyless CAPTCHA",
|
|
2363
|
+
description: "Privacy-friendly proof-of-work CAPTCHA (@lacspace/captcha) \u2014 no Google/Cloudflare, no keys, no tracking. A drop-in widget + a backend verify route. Full-stack.",
|
|
2364
|
+
requiresBackend: true,
|
|
2365
|
+
deps: { "@lacspace/captcha": "^1.0.0" },
|
|
2366
|
+
files: () => ({ "components/captcha.tsx": captchaComponent(), "app/captcha-demo/page.tsx": captchaDemoPage() }),
|
|
2367
|
+
backend: () => ({
|
|
2368
|
+
deps: { "@lacspace/captcha": "^1.0.0" },
|
|
2369
|
+
files: { "src/routes/captcha.ts": captchaRoutesBackend() },
|
|
2370
|
+
routes: [{ path: "/captcha", handler: "captchaRoutes", auth: false, importLine: 'import captchaRoutes from "./captcha.js";' }],
|
|
2371
|
+
env: { CAPTCHA_SECRET: "Secret for signing CAPTCHA challenges (blank = reuse JWT_SECRET)." }
|
|
2372
|
+
}),
|
|
2373
|
+
nextSteps: [
|
|
2374
|
+
"Open http://localhost:3000/captcha-demo \u2014 solve the CAPTCHA, then Submit to verify it server-side.",
|
|
2375
|
+
"Drop <Captcha onVerified={setToken} /> into any form, then POST the token to /captcha/verify.",
|
|
2376
|
+
"No accounts or keys \u2014 tune difficulty via createChallenge({ maxNumber }) in src/routes/captcha.ts."
|
|
2377
|
+
],
|
|
2378
|
+
learn: "https://developer.lacspace.com/packages/captcha"
|
|
2379
|
+
},
|
|
2380
|
+
{
|
|
2381
|
+
key: "push",
|
|
2382
|
+
label: "Web push notifications",
|
|
2383
|
+
description: "Real browser push notifications (@lacspace/web-push) \u2014 keyless VAPID, no Firebase/FCM. Generates a service worker, a subscribe button, and a backend that stores subscriptions and sends. Full-stack.",
|
|
2384
|
+
requiresBackend: true,
|
|
2385
|
+
deps: { "@lacspace/web-push": "^1.0.0" },
|
|
2386
|
+
files: () => ({ "public/sw.js": pushServiceWorkerFile(), "app/push-demo/page.tsx": pushDemoPage() }),
|
|
2387
|
+
backend: () => ({
|
|
2388
|
+
deps: { "@lacspace/web-push": "^1.0.0" },
|
|
2389
|
+
files: {
|
|
2390
|
+
"src/models/push-subscription.ts": pushSubscriptionModel(),
|
|
2391
|
+
"src/routes/push.ts": pushRoutesBackend()
|
|
2392
|
+
},
|
|
2393
|
+
routes: [{ path: "/push", handler: "pushRoutes", auth: false, importLine: 'import pushRoutes from "./push.js";' }],
|
|
2394
|
+
env: {
|
|
2395
|
+
VAPID_PUBLIC: "VAPID public key \u2014 generate with: npx @lacspace/web-push (or generateVapidKeys()).",
|
|
2396
|
+
VAPID_PRIVATE: "VAPID private key (keep secret).",
|
|
2397
|
+
VAPID_SUBJECT: "Contact for the push service, e.g. mailto:you@yoursite.com."
|
|
2398
|
+
}
|
|
2399
|
+
}),
|
|
2400
|
+
nextSteps: [
|
|
2401
|
+
"Generate VAPID keys: npx @lacspace/web-push \u2014 put them in .env (VAPID_PUBLIC / VAPID_PRIVATE).",
|
|
2402
|
+
"Open http://localhost:3000/push-demo \u2192 'Enable notifications', then 'Send test'.",
|
|
2403
|
+
"Push works on https (or localhost); the generated public/sw.js receives the notifications."
|
|
2404
|
+
],
|
|
2405
|
+
learn: "https://developer.lacspace.com/packages/web-push"
|
|
2279
2406
|
}
|
|
2280
2407
|
];
|
|
2281
2408
|
var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
|
|
@@ -6260,6 +6387,542 @@ export default function EmailTestPage() {
|
|
|
6260
6387
|
);
|
|
6261
6388
|
}
|
|
6262
6389
|
`;
|
|
6390
|
+
var i18nLib = () => `import en from "@/locales/en.json";
|
|
6391
|
+
import ne from "@/locales/ne.json";
|
|
6392
|
+
|
|
6393
|
+
// how this works: a tiny, dependency-free translator. Locale files live in locales/,
|
|
6394
|
+
// keys are flat strings, and {vars} are interpolated. A missing key falls back to
|
|
6395
|
+
// English, then to the key itself. Lint the files with \`npm run i18n:check\`.
|
|
6396
|
+
export const locales = ["en", "ne"] as const;
|
|
6397
|
+
export type Locale = (typeof locales)[number];
|
|
6398
|
+
export const defaultLocale: Locale = "en";
|
|
6399
|
+
|
|
6400
|
+
const dicts: Record<Locale, Record<string, string>> = {
|
|
6401
|
+
en: en as Record<string, string>,
|
|
6402
|
+
ne: ne as Record<string, string>,
|
|
6403
|
+
};
|
|
6404
|
+
|
|
6405
|
+
export function translate(locale: Locale, key: string, vars?: Record<string, string | number>): string {
|
|
6406
|
+
const table = dicts[locale] ?? dicts[defaultLocale];
|
|
6407
|
+
let str = table[key] ?? dicts[defaultLocale][key] ?? key;
|
|
6408
|
+
if (vars) for (const [k, v] of Object.entries(vars)) str = str.split("{" + k + "}").join(String(v));
|
|
6409
|
+
return str;
|
|
6410
|
+
}
|
|
6411
|
+
`;
|
|
6412
|
+
var i18nProvider = () => `"use client";
|
|
6413
|
+
import { createContext, useContext, useEffect, useState } from "react";
|
|
6414
|
+
import { locales, defaultLocale, translate, type Locale } from "@/lib/i18n";
|
|
6415
|
+
|
|
6416
|
+
// how this works: holds the current locale (persisted in localStorage) and exposes
|
|
6417
|
+
// t() via the useT() hook. Wrap your app in <LanguageProvider> once (in layout.tsx).
|
|
6418
|
+
interface LocaleCtxValue { locale: Locale; setLocale: (l: Locale) => void; }
|
|
6419
|
+
const LocaleCtx = createContext<LocaleCtxValue>({ locale: defaultLocale, setLocale: () => {} });
|
|
6420
|
+
|
|
6421
|
+
export function LanguageProvider({ children }: { children: React.ReactNode }) {
|
|
6422
|
+
const [locale, setLocaleState] = useState<Locale>(defaultLocale);
|
|
6423
|
+
useEffect(() => {
|
|
6424
|
+
try {
|
|
6425
|
+
const saved = localStorage.getItem("locale");
|
|
6426
|
+
if (saved && (locales as readonly string[]).includes(saved)) setLocaleState(saved as Locale);
|
|
6427
|
+
} catch {}
|
|
6428
|
+
}, []);
|
|
6429
|
+
const setLocale = (l: Locale) => { setLocaleState(l); try { localStorage.setItem("locale", l); } catch {} };
|
|
6430
|
+
return <LocaleCtx.Provider value={{ locale, setLocale }}>{children}</LocaleCtx.Provider>;
|
|
6431
|
+
}
|
|
6432
|
+
|
|
6433
|
+
export function useT() {
|
|
6434
|
+
const { locale, setLocale } = useContext(LocaleCtx);
|
|
6435
|
+
return {
|
|
6436
|
+
t: (key: string, vars?: Record<string, string | number>) => translate(locale, key, vars),
|
|
6437
|
+
locale,
|
|
6438
|
+
setLocale,
|
|
6439
|
+
};
|
|
6440
|
+
}
|
|
6441
|
+
|
|
6442
|
+
export function LanguageSwitcher() {
|
|
6443
|
+
const { locale, setLocale } = useT();
|
|
6444
|
+
return (
|
|
6445
|
+
<div className="inline-flex gap-1 rounded-full border border-hairline p-1">
|
|
6446
|
+
{locales.map((l) => (
|
|
6447
|
+
<button
|
|
6448
|
+
key={l}
|
|
6449
|
+
onClick={() => setLocale(l)}
|
|
6450
|
+
className={"rounded-full px-3 py-1 text-sm transition " + (l === locale ? "gradient-bg on-accent" : "text-muted hover:text-fg")}
|
|
6451
|
+
>
|
|
6452
|
+
{l.toUpperCase()}
|
|
6453
|
+
</button>
|
|
6454
|
+
))}
|
|
6455
|
+
</div>
|
|
6456
|
+
);
|
|
6457
|
+
}
|
|
6458
|
+
`;
|
|
6459
|
+
var i18nDemoPage = () => `"use client";
|
|
6460
|
+
import { LanguageProvider, LanguageSwitcher, useT } from "@/components/language-provider";
|
|
6461
|
+
|
|
6462
|
+
function Demo() {
|
|
6463
|
+
const { t } = useT();
|
|
6464
|
+
return (
|
|
6465
|
+
<div className="space-y-4">
|
|
6466
|
+
<h1 className="text-3xl font-bold">{t("hello", { name: "world" })}</h1>
|
|
6467
|
+
<p className="text-muted">{t("tagline")}</p>
|
|
6468
|
+
<LanguageSwitcher />
|
|
6469
|
+
</div>
|
|
6470
|
+
);
|
|
6471
|
+
}
|
|
6472
|
+
|
|
6473
|
+
export default function I18nDemoPage() {
|
|
6474
|
+
return (
|
|
6475
|
+
<main className="mx-auto max-w-md px-6 py-16">
|
|
6476
|
+
<LanguageProvider>
|
|
6477
|
+
<Demo />
|
|
6478
|
+
</LanguageProvider>
|
|
6479
|
+
</main>
|
|
6480
|
+
);
|
|
6481
|
+
}
|
|
6482
|
+
`;
|
|
6483
|
+
var i18nEn = () => JSON.stringify(
|
|
6484
|
+
{ hello: "Hello, {name}!", tagline: "This text switches language instantly \u2014 no reload.", "nav.home": "Home", "nav.about": "About" },
|
|
6485
|
+
null,
|
|
6486
|
+
2
|
|
6487
|
+
) + "\n";
|
|
6488
|
+
var i18nNe = () => JSON.stringify(
|
|
6489
|
+
{ hello: "\u0928\u092E\u0938\u094D\u0924\u0947, {name}!", tagline: "\u092F\u094B \u092A\u093E\u0920 \u0924\u0941\u0930\u0941\u0928\u094D\u0924\u0948 \u092D\u093E\u0937\u093E \u092B\u0947\u0930\u094D\u091B \u2014 \u0930\u093F\u0932\u094B\u0921 \u092C\u093F\u0928\u093E\u0964", "nav.home": "\u0917\u0943\u0939", "nav.about": "\u092C\u093E\u0930\u0947\u092E\u093E" },
|
|
6490
|
+
null,
|
|
6491
|
+
2
|
|
6492
|
+
) + "\n";
|
|
6493
|
+
var qualityCiWorkflow = (ctx) => {
|
|
6494
|
+
const sizePath = ctx.mode === "dynamic" ? "frontend/.next/static" : ".next/static";
|
|
6495
|
+
const depsGate = ctx.mode === "dynamic" ? "duplicates" : "missing,duplicates";
|
|
6496
|
+
return `name: CI
|
|
6497
|
+
on:
|
|
6498
|
+
push:
|
|
6499
|
+
branches: [main]
|
|
6500
|
+
pull_request:
|
|
6501
|
+
jobs:
|
|
6502
|
+
quality:
|
|
6503
|
+
runs-on: ubuntu-latest
|
|
6504
|
+
steps:
|
|
6505
|
+
- uses: actions/checkout@v4
|
|
6506
|
+
- uses: actions/setup-node@v4
|
|
6507
|
+
with:
|
|
6508
|
+
node-version: 20
|
|
6509
|
+
cache: npm
|
|
6510
|
+
- run: npm ci
|
|
6511
|
+
- run: npm run build
|
|
6512
|
+
- name: Bundle-size budget
|
|
6513
|
+
run: npx lacspace-size@0.2.0 ${sizePath} --metric gzip --max 500kb
|
|
6514
|
+
- name: Dependency audit
|
|
6515
|
+
run: npx lacspace-deps@0.2.0 . --fail-on ${depsGate}
|
|
6516
|
+
`;
|
|
6517
|
+
};
|
|
6518
|
+
var uploadModel = () => `import mongoose from "mongoose";
|
|
6519
|
+
import { uuidv7 } from "@lacspace/id";
|
|
6520
|
+
|
|
6521
|
+
// how this works: files are stored base64-in-Mongo so it works with NO S3/disk setup.
|
|
6522
|
+
// For large files or production, swap this for disk or object storage.
|
|
6523
|
+
export interface UploadDoc {
|
|
6524
|
+
_id: string;
|
|
6525
|
+
userId: string;
|
|
6526
|
+
filename: string;
|
|
6527
|
+
contentType: string;
|
|
6528
|
+
size: number;
|
|
6529
|
+
data: string; // base64
|
|
6530
|
+
createdAt: Date;
|
|
6531
|
+
}
|
|
6532
|
+
|
|
6533
|
+
const schema = new mongoose.Schema<UploadDoc>(
|
|
6534
|
+
{
|
|
6535
|
+
_id: { type: String, default: () => uuidv7() },
|
|
6536
|
+
userId: { type: String, required: true, index: true },
|
|
6537
|
+
filename: { type: String, required: true },
|
|
6538
|
+
contentType: { type: String, default: "application/octet-stream" },
|
|
6539
|
+
size: { type: Number, default: 0 },
|
|
6540
|
+
data: { type: String, required: true },
|
|
6541
|
+
},
|
|
6542
|
+
{ timestamps: { createdAt: true, updatedAt: false } },
|
|
6543
|
+
);
|
|
6544
|
+
|
|
6545
|
+
export const Upload =
|
|
6546
|
+
(mongoose.models.Upload as mongoose.Model<UploadDoc>) ?? mongoose.model<UploadDoc>("Upload", schema);
|
|
6547
|
+
`;
|
|
6548
|
+
var uploadsRoutes = () => `import express from "express";
|
|
6549
|
+
import { signUrl, verifyUrl } from "@lacspace/signed-url";
|
|
6550
|
+
import { v } from "@lacspace/validate";
|
|
6551
|
+
import { asyncHandler, HttpError } from "../http.js";
|
|
6552
|
+
import { requireAuth } from "../middleware/auth.js";
|
|
6553
|
+
import { env } from "../env.js";
|
|
6554
|
+
import { Upload } from "../models/upload.js";
|
|
6555
|
+
|
|
6556
|
+
// how this works: uploads live in Mongo; each file is served from a PUBLIC route
|
|
6557
|
+
// protected by a signed, expiring URL (@lacspace/signed-url) \u2014 so <img src> works
|
|
6558
|
+
// without a bearer token, yet the link can't be forged or shared forever.
|
|
6559
|
+
const router = express.Router();
|
|
6560
|
+
const SECRET = process.env.UPLOAD_URL_SECRET ?? env.JWT_SECRET;
|
|
6561
|
+
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB \u2014 base64-in-Mongo, keep it small
|
|
6562
|
+
|
|
6563
|
+
const UploadInput = v.object({ filename: v.string().min(1).max(200), dataUrl: v.string().min(1) });
|
|
6564
|
+
|
|
6565
|
+
// Sign a RELATIVE URL; the frontend prefixes it with the API base. Verified as-is.
|
|
6566
|
+
function signed(id: string): Promise<string> {
|
|
6567
|
+
return signUrl("/uploads/" + id + "/raw", { secret: SECRET, expiresIn: 3600 });
|
|
6568
|
+
}
|
|
6569
|
+
|
|
6570
|
+
// GET /uploads/:id/raw \u2014 PUBLIC, but the signed query params must verify.
|
|
6571
|
+
router.get("/:id/raw", asyncHandler(async (req, res) => {
|
|
6572
|
+
const check = await verifyUrl(req.originalUrl, { secret: SECRET, clockToleranceSec: 60 });
|
|
6573
|
+
if (!check.valid) throw new HttpError(check.reason === "expired" ? 410 : 403, "Link expired or invalid");
|
|
6574
|
+
const file = await Upload.findById(req.params.id);
|
|
6575
|
+
if (!file) throw new HttpError(404, "Not found");
|
|
6576
|
+
res.setHeader("Content-Type", file.contentType);
|
|
6577
|
+
res.send(Buffer.from(file.data, "base64"));
|
|
6578
|
+
}));
|
|
6579
|
+
|
|
6580
|
+
// POST /uploads \u2014 protected: store a base64 data URL.
|
|
6581
|
+
router.post("/", requireAuth, asyncHandler(async (req, res) => {
|
|
6582
|
+
const { filename, dataUrl } = UploadInput.parse(req.body);
|
|
6583
|
+
const m = /^data:(.+?);base64,(.*)$/.exec(dataUrl);
|
|
6584
|
+
if (!m) throw new HttpError(400, "Expected a base64 data URL");
|
|
6585
|
+
const contentType = m[1]!;
|
|
6586
|
+
const data = m[2]!;
|
|
6587
|
+
const size = Math.floor((data.length * 3) / 4);
|
|
6588
|
+
if (size > MAX_BYTES) throw new HttpError(413, "File too large (max 5 MB)");
|
|
6589
|
+
const file = await Upload.create({ userId: req.user!.sub, filename, contentType, size, data });
|
|
6590
|
+
res.status(201).json({ id: String(file._id), filename, contentType, size, url: await signed(String(file._id)) });
|
|
6591
|
+
}));
|
|
6592
|
+
|
|
6593
|
+
// GET /uploads \u2014 protected: this user's files (with fresh signed URLs).
|
|
6594
|
+
router.get("/", requireAuth, asyncHandler(async (req, res) => {
|
|
6595
|
+
const files = await Upload.find({ userId: req.user!.sub }).sort({ createdAt: -1 }).select("filename contentType size");
|
|
6596
|
+
const out = await Promise.all(
|
|
6597
|
+
files.map(async (f) => ({ id: String(f._id), filename: f.filename, contentType: f.contentType, size: f.size, url: await signed(String(f._id)) })),
|
|
6598
|
+
);
|
|
6599
|
+
res.json(out);
|
|
6600
|
+
}));
|
|
6601
|
+
|
|
6602
|
+
export default router;
|
|
6603
|
+
`;
|
|
6604
|
+
var uploadsPage = () => `"use client";
|
|
6605
|
+
import { useEffect, useState } from "react";
|
|
6606
|
+
import { useRouter } from "next/navigation";
|
|
6607
|
+
import { getToken } from "@/lib/api";
|
|
6608
|
+
|
|
6609
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
6610
|
+
interface FileItem { id: string; filename: string; contentType: string; size: number; url: string; }
|
|
6611
|
+
|
|
6612
|
+
function readAsDataUrl(file: File): Promise<string> {
|
|
6613
|
+
return new Promise((resolve, reject) => {
|
|
6614
|
+
const r = new FileReader();
|
|
6615
|
+
r.onload = () => resolve(String(r.result));
|
|
6616
|
+
r.onerror = () => reject(new Error("read failed"));
|
|
6617
|
+
r.readAsDataURL(file);
|
|
6618
|
+
});
|
|
6619
|
+
}
|
|
6620
|
+
|
|
6621
|
+
export default function UploadsPage() {
|
|
6622
|
+
const router = useRouter();
|
|
6623
|
+
const [files, setFiles] = useState<FileItem[]>([]);
|
|
6624
|
+
const [busy, setBusy] = useState(false);
|
|
6625
|
+
const [error, setError] = useState<string | null>(null);
|
|
6626
|
+
|
|
6627
|
+
async function load() {
|
|
6628
|
+
const token = getToken();
|
|
6629
|
+
if (!token) { router.push("/login"); return; }
|
|
6630
|
+
const res = await fetch(API + "/uploads", { headers: { Authorization: "Bearer " + token } });
|
|
6631
|
+
if (res.ok) setFiles(await res.json());
|
|
6632
|
+
}
|
|
6633
|
+
useEffect(() => { void load(); }, []);
|
|
6634
|
+
|
|
6635
|
+
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
|
6636
|
+
const file = e.target.files?.[0];
|
|
6637
|
+
if (!file) return;
|
|
6638
|
+
setBusy(true); setError(null);
|
|
6639
|
+
try {
|
|
6640
|
+
const dataUrl = await readAsDataUrl(file);
|
|
6641
|
+
const token = getToken();
|
|
6642
|
+
const res = await fetch(API + "/uploads", {
|
|
6643
|
+
method: "POST",
|
|
6644
|
+
headers: { "Content-Type": "application/json", ...(token ? { Authorization: "Bearer " + token } : {}) },
|
|
6645
|
+
body: JSON.stringify({ filename: file.name, dataUrl }),
|
|
6646
|
+
});
|
|
6647
|
+
const data: unknown = await res.json().catch(() => ({}));
|
|
6648
|
+
if (!res.ok) throw new Error((data as { error?: string }).error ?? "Upload failed");
|
|
6649
|
+
await load();
|
|
6650
|
+
} catch (err) { setError(err instanceof Error ? err.message : "Upload failed"); }
|
|
6651
|
+
finally { setBusy(false); }
|
|
6652
|
+
}
|
|
6653
|
+
|
|
6654
|
+
return (
|
|
6655
|
+
<main className="mx-auto max-w-lg px-6 py-16">
|
|
6656
|
+
<h1 className="text-2xl font-bold">Uploads</h1>
|
|
6657
|
+
<p className="mt-1 text-muted">Stored in MongoDB, served via signed, expiring links.</p>
|
|
6658
|
+
<label className="mt-6 flex cursor-pointer items-center justify-center rounded-2xl border border-dashed border-hairline p-8 text-muted transition hover:bg-surface">
|
|
6659
|
+
<input type="file" className="hidden" onChange={onPick} disabled={busy} />
|
|
6660
|
+
{busy ? "Uploading\u2026" : "Choose a file (max 5 MB)"}
|
|
6661
|
+
</label>
|
|
6662
|
+
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
|
|
6663
|
+
<ul className="mt-6 space-y-2">
|
|
6664
|
+
{files.map((f) => (
|
|
6665
|
+
<li key={f.id} className="flex items-center justify-between rounded-xl border border-hairline p-3">
|
|
6666
|
+
<a href={API + f.url} target="_blank" rel="noreferrer" className="truncate underline">{f.filename}</a>
|
|
6667
|
+
<span className="ml-3 shrink-0 text-sm text-muted">{Math.max(1, Math.round(f.size / 1024))} KB</span>
|
|
6668
|
+
</li>
|
|
6669
|
+
))}
|
|
6670
|
+
</ul>
|
|
6671
|
+
</main>
|
|
6672
|
+
);
|
|
6673
|
+
}
|
|
6674
|
+
`;
|
|
6675
|
+
var notifyToaster = () => `"use client";
|
|
6676
|
+
// how this works: re-export the Lacspace toast API + <Toaster/> from one place.
|
|
6677
|
+
// Render <Toaster/> once in app/layout.tsx, then call toast.* from anywhere.
|
|
6678
|
+
export { Toaster, useToast, toast } from "@lacspace/notify/react";
|
|
6679
|
+
`;
|
|
6680
|
+
var notifyDemoPage = () => `"use client";
|
|
6681
|
+
import { toast } from "@/components/toaster";
|
|
6682
|
+
|
|
6683
|
+
export default function NotifyDemo() {
|
|
6684
|
+
return (
|
|
6685
|
+
<main className="mx-auto max-w-lg px-6 py-16">
|
|
6686
|
+
<h1 className="text-2xl font-bold">Toasts</h1>
|
|
6687
|
+
<p className="mt-1 text-muted">In-app notifications from @lacspace/notify.</p>
|
|
6688
|
+
<div className="mt-6 flex flex-wrap gap-3">
|
|
6689
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={() => toast.success("Saved!")}>Success</button>
|
|
6690
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={() => toast.error("Something broke")}>Error</button>
|
|
6691
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={() => toast.info("Heads up", { action: { label: "Undo", onClick: () => toast.success("Undone") } })}>With action</button>
|
|
6692
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={() => void toast.promise(new Promise((r) => setTimeout(r, 1200)), { loading: "Working\u2026", success: "Done!", error: "Failed" })}>Promise</button>
|
|
6693
|
+
</div>
|
|
6694
|
+
</main>
|
|
6695
|
+
);
|
|
6696
|
+
}
|
|
6697
|
+
`;
|
|
6698
|
+
var captchaComponent = () => `"use client";
|
|
6699
|
+
import { useEffect, useRef } from "react";
|
|
6700
|
+
import { renderCaptcha } from "@lacspace/captcha/client";
|
|
6701
|
+
|
|
6702
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
6703
|
+
|
|
6704
|
+
// how this works: the widget fetches a challenge from your backend, solves a small
|
|
6705
|
+
// proof-of-work in the browser, and drops the solution into a hidden input named
|
|
6706
|
+
// "lacspace-captcha". Read it via onVerified, or submit it with a surrounding form.
|
|
6707
|
+
export function Captcha({ onVerified }: { onVerified?: (token: string) => void }) {
|
|
6708
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
6709
|
+
useEffect(() => {
|
|
6710
|
+
if (!ref.current) return;
|
|
6711
|
+
const widget = renderCaptcha(ref.current, {
|
|
6712
|
+
challenge: API + "/captcha",
|
|
6713
|
+
onVerified: (token) => onVerified?.(token),
|
|
6714
|
+
});
|
|
6715
|
+
return () => widget.destroy();
|
|
6716
|
+
}, [onVerified]);
|
|
6717
|
+
return <div ref={ref} />;
|
|
6718
|
+
}
|
|
6719
|
+
`;
|
|
6720
|
+
var captchaDemoPage = () => `"use client";
|
|
6721
|
+
import { useState } from "react";
|
|
6722
|
+
import { Captcha } from "@/components/captcha";
|
|
6723
|
+
|
|
6724
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
6725
|
+
|
|
6726
|
+
export default function CaptchaDemo() {
|
|
6727
|
+
const [token, setToken] = useState<string | null>(null);
|
|
6728
|
+
const [status, setStatus] = useState<string | null>(null);
|
|
6729
|
+
|
|
6730
|
+
async function submit(e: React.FormEvent) {
|
|
6731
|
+
e.preventDefault();
|
|
6732
|
+
if (!token) { setStatus("Please solve the CAPTCHA first."); return; }
|
|
6733
|
+
const res = await fetch(API + "/captcha/verify", {
|
|
6734
|
+
method: "POST",
|
|
6735
|
+
headers: { "Content-Type": "application/json" },
|
|
6736
|
+
body: JSON.stringify({ solution: token }),
|
|
6737
|
+
});
|
|
6738
|
+
setStatus(res.ok ? "Verified on the server \u2713 \u2014 safe to proceed." : "Verification failed.");
|
|
6739
|
+
}
|
|
6740
|
+
|
|
6741
|
+
return (
|
|
6742
|
+
<main className="mx-auto max-w-lg px-6 py-16">
|
|
6743
|
+
<h1 className="text-2xl font-bold">Keyless CAPTCHA</h1>
|
|
6744
|
+
<p className="mt-1 text-muted">Proof-of-work \u2014 no Google / Cloudflare, no keys.</p>
|
|
6745
|
+
<form onSubmit={submit} className="mt-6 space-y-4">
|
|
6746
|
+
<Captcha onVerified={setToken} />
|
|
6747
|
+
<button type="submit" className="rounded-xl border border-hairline px-4 py-2">Submit</button>
|
|
6748
|
+
</form>
|
|
6749
|
+
{status && <p className="mt-3 text-sm text-muted">{status}</p>}
|
|
6750
|
+
</main>
|
|
6751
|
+
);
|
|
6752
|
+
}
|
|
6753
|
+
`;
|
|
6754
|
+
var captchaRoutesBackend = () => `import express from "express";
|
|
6755
|
+
import { createChallenge, verifySolution } from "@lacspace/captcha";
|
|
6756
|
+
import { asyncHandler, HttpError } from "../http.js";
|
|
6757
|
+
import { env } from "../env.js";
|
|
6758
|
+
|
|
6759
|
+
// how this works: a keyless proof-of-work CAPTCHA. GET /captcha issues a signed
|
|
6760
|
+
// challenge; the browser solves it; POST /captcha/verify checks the solution with
|
|
6761
|
+
// only your secret \u2014 stateless, nothing stored between the two calls.
|
|
6762
|
+
const router = express.Router();
|
|
6763
|
+
const SECRET = process.env.CAPTCHA_SECRET ?? env.JWT_SECRET;
|
|
6764
|
+
|
|
6765
|
+
router.get("/", asyncHandler(async (_req, res) => {
|
|
6766
|
+
res.json(await createChallenge({ secret: SECRET }));
|
|
6767
|
+
}));
|
|
6768
|
+
|
|
6769
|
+
router.post("/verify", asyncHandler(async (req, res) => {
|
|
6770
|
+
const solution = typeof req.body?.solution === "string" ? req.body.solution : "";
|
|
6771
|
+
const result = await verifySolution(solution, { secret: SECRET });
|
|
6772
|
+
if (!result.success) throw new HttpError(400, "CAPTCHA failed: " + result.error);
|
|
6773
|
+
res.json({ success: true });
|
|
6774
|
+
}));
|
|
6775
|
+
|
|
6776
|
+
export default router;
|
|
6777
|
+
`;
|
|
6778
|
+
var pushServiceWorkerFile = () => `// Service worker \u2014 web push notifications. Generated by create-lacspace-app.
|
|
6779
|
+
// Served at /sw.js. Wakes on incoming pushes and opens the app on click.
|
|
6780
|
+
|
|
6781
|
+
self.addEventListener("install", () => self.skipWaiting());
|
|
6782
|
+
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
|
|
6783
|
+
|
|
6784
|
+
self.addEventListener("push", (event) => {
|
|
6785
|
+
let data = {};
|
|
6786
|
+
try { data = event.data ? event.data.json() : {}; }
|
|
6787
|
+
catch (err) { data = { body: event.data ? event.data.text() : "" }; }
|
|
6788
|
+
const title = data.title || "Notification";
|
|
6789
|
+
event.waitUntil(
|
|
6790
|
+
self.registration.showNotification(title, {
|
|
6791
|
+
body: data.body,
|
|
6792
|
+
icon: data.icon,
|
|
6793
|
+
data: { url: data.url || "/" },
|
|
6794
|
+
}),
|
|
6795
|
+
);
|
|
6796
|
+
});
|
|
6797
|
+
|
|
6798
|
+
self.addEventListener("notificationclick", (event) => {
|
|
6799
|
+
event.notification.close();
|
|
6800
|
+
const target = (event.notification.data && event.notification.data.url) || "/";
|
|
6801
|
+
event.waitUntil(
|
|
6802
|
+
(async () => {
|
|
6803
|
+
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
|
6804
|
+
for (const client of all) { if (client.url === target && "focus" in client) return client.focus(); }
|
|
6805
|
+
if (self.clients.openWindow) return self.clients.openWindow(target);
|
|
6806
|
+
})(),
|
|
6807
|
+
);
|
|
6808
|
+
});
|
|
6809
|
+
`;
|
|
6810
|
+
var pushDemoPage = () => `"use client";
|
|
6811
|
+
import { useState } from "react";
|
|
6812
|
+
import { subscribeToPush } from "@lacspace/web-push/client";
|
|
6813
|
+
|
|
6814
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
6815
|
+
|
|
6816
|
+
export default function PushDemo() {
|
|
6817
|
+
const [status, setStatus] = useState("");
|
|
6818
|
+
|
|
6819
|
+
async function enable() {
|
|
6820
|
+
try {
|
|
6821
|
+
const { publicKey } = await fetch(API + "/push/vapid").then((r) => r.json());
|
|
6822
|
+
if (!publicKey) { setStatus("Set VAPID_PUBLIC / VAPID_PRIVATE in the backend .env first (npx @lacspace/web-push)."); return; }
|
|
6823
|
+
await subscribeToPush({ vapidPublicKey: publicKey, serviceWorkerUrl: "/sw.js", saveUrl: API + "/push/subscribe" });
|
|
6824
|
+
setStatus("Subscribed! Click 'Send test' to receive a push.");
|
|
6825
|
+
} catch (err) { setStatus("Failed: " + String(err)); }
|
|
6826
|
+
}
|
|
6827
|
+
|
|
6828
|
+
async function sendTest() {
|
|
6829
|
+
const r = await fetch(API + "/push/send", {
|
|
6830
|
+
method: "POST",
|
|
6831
|
+
headers: { "Content-Type": "application/json" },
|
|
6832
|
+
body: JSON.stringify({ title: "It works", body: "Your first web push \u{1F389}" }),
|
|
6833
|
+
}).then((r) => r.json());
|
|
6834
|
+
setStatus("Sent to " + r.sent + " device(s).");
|
|
6835
|
+
}
|
|
6836
|
+
|
|
6837
|
+
return (
|
|
6838
|
+
<main className="mx-auto max-w-lg px-6 py-16">
|
|
6839
|
+
<h1 className="text-2xl font-bold">Web push</h1>
|
|
6840
|
+
<p className="mt-1 text-muted">Real browser notifications \u2014 keyless VAPID, no Firebase.</p>
|
|
6841
|
+
<div className="mt-6 flex flex-wrap gap-3">
|
|
6842
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={enable}>Enable notifications</button>
|
|
6843
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={sendTest}>Send test</button>
|
|
6844
|
+
</div>
|
|
6845
|
+
{status && <p className="mt-3 text-sm text-muted">{status}</p>}
|
|
6846
|
+
</main>
|
|
6847
|
+
);
|
|
6848
|
+
}
|
|
6849
|
+
`;
|
|
6850
|
+
var pushSubscriptionModel = () => `import mongoose from "mongoose";
|
|
6851
|
+
import { uuidv7 } from "@lacspace/id";
|
|
6852
|
+
|
|
6853
|
+
// how this works: one document per browser subscription. endpoint is unique so a
|
|
6854
|
+
// re-subscribe upserts instead of duplicating. Store the two keys the Web Push
|
|
6855
|
+
// protocol needs (p256dh + auth).
|
|
6856
|
+
export interface PushSubDoc {
|
|
6857
|
+
_id: string;
|
|
6858
|
+
endpoint: string;
|
|
6859
|
+
p256dh: string;
|
|
6860
|
+
auth: string;
|
|
6861
|
+
createdAt: Date;
|
|
6862
|
+
}
|
|
6863
|
+
|
|
6864
|
+
const schema = new mongoose.Schema<PushSubDoc>(
|
|
6865
|
+
{
|
|
6866
|
+
_id: { type: String, default: () => uuidv7() },
|
|
6867
|
+
endpoint: { type: String, required: true, unique: true },
|
|
6868
|
+
p256dh: { type: String, required: true },
|
|
6869
|
+
auth: { type: String, required: true },
|
|
6870
|
+
},
|
|
6871
|
+
{ timestamps: { createdAt: true, updatedAt: false } },
|
|
6872
|
+
);
|
|
6873
|
+
|
|
6874
|
+
export const PushSubscriptionModel =
|
|
6875
|
+
(mongoose.models.PushSubscription as mongoose.Model<PushSubDoc>) ??
|
|
6876
|
+
mongoose.model<PushSubDoc>("PushSubscription", schema);
|
|
6877
|
+
`;
|
|
6878
|
+
var pushRoutesBackend = () => `import express from "express";
|
|
6879
|
+
import { sendNotifications, type PushSubscription } from "@lacspace/web-push";
|
|
6880
|
+
import { asyncHandler, HttpError } from "../http.js";
|
|
6881
|
+
import { PushSubscriptionModel } from "../models/push-subscription.js";
|
|
6882
|
+
|
|
6883
|
+
// how this works: /push/vapid gives the browser the public key; /push/subscribe
|
|
6884
|
+
// stores a subscription; /push/send blasts a payload to every stored subscription
|
|
6885
|
+
// and prunes any the push service reports as gone (404/410).
|
|
6886
|
+
const router = express.Router();
|
|
6887
|
+
|
|
6888
|
+
function vapid() {
|
|
6889
|
+
const publicKey = process.env.VAPID_PUBLIC;
|
|
6890
|
+
const privateKey = process.env.VAPID_PRIVATE;
|
|
6891
|
+
if (!publicKey || !privateKey) {
|
|
6892
|
+
throw new HttpError(500, "Missing VAPID keys. Generate them (npx @lacspace/web-push) and set VAPID_PUBLIC / VAPID_PRIVATE in .env.");
|
|
6893
|
+
}
|
|
6894
|
+
return { subject: process.env.VAPID_SUBJECT ?? "mailto:admin@example.com", publicKey, privateKey };
|
|
6895
|
+
}
|
|
6896
|
+
|
|
6897
|
+
router.get("/vapid", asyncHandler(async (_req, res) => {
|
|
6898
|
+
res.json({ publicKey: process.env.VAPID_PUBLIC ?? "" });
|
|
6899
|
+
}));
|
|
6900
|
+
|
|
6901
|
+
router.post("/subscribe", asyncHandler(async (req, res) => {
|
|
6902
|
+
const endpoint = typeof req.body?.endpoint === "string" ? req.body.endpoint : "";
|
|
6903
|
+
const p256dh = req.body?.keys?.p256dh;
|
|
6904
|
+
const auth = req.body?.keys?.auth;
|
|
6905
|
+
if (!endpoint || typeof p256dh !== "string" || typeof auth !== "string") {
|
|
6906
|
+
throw new HttpError(400, "Invalid subscription");
|
|
6907
|
+
}
|
|
6908
|
+
await PushSubscriptionModel.updateOne({ endpoint }, { $set: { endpoint, p256dh, auth } }, { upsert: true });
|
|
6909
|
+
res.status(201).json({ ok: true });
|
|
6910
|
+
}));
|
|
6911
|
+
|
|
6912
|
+
router.post("/send", asyncHandler(async (req, res) => {
|
|
6913
|
+
const title = typeof req.body?.title === "string" ? req.body.title : "Hello from your app";
|
|
6914
|
+
const body = typeof req.body?.body === "string" ? req.body.body : "This is a web push notification.";
|
|
6915
|
+
const docs = await PushSubscriptionModel.find();
|
|
6916
|
+
const subs: PushSubscription[] = docs.map((d) => ({ endpoint: d.endpoint, keys: { p256dh: d.p256dh, auth: d.auth } }));
|
|
6917
|
+
const payload = JSON.stringify({ title, body, url: "/" });
|
|
6918
|
+
const results = await sendNotifications(subs, payload, { vapid: vapid() });
|
|
6919
|
+
const expired = results.filter((r) => r.result?.expired);
|
|
6920
|
+
await Promise.all(expired.map((r) => PushSubscriptionModel.deleteOne({ endpoint: r.subscription.endpoint })));
|
|
6921
|
+
res.json({ sent: results.filter((r) => r.result && !r.result.expired).length, removed: expired.length });
|
|
6922
|
+
}));
|
|
6923
|
+
|
|
6924
|
+
export default router;
|
|
6925
|
+
`;
|
|
6263
6926
|
var scope = (ctx) => `@${ctx.name}`;
|
|
6264
6927
|
var typesPkgJson = (ctx) => JSON.stringify({
|
|
6265
6928
|
name: `${scope(ctx)}/types`,
|
|
@@ -6659,6 +7322,9 @@ function buildFullStack(ctx) {
|
|
|
6659
7322
|
out[".gitignore"] = rootGitignore();
|
|
6660
7323
|
out[".env.example"] = rootEnvExample(ctx);
|
|
6661
7324
|
out["README.md"] = rootReadme(ctx);
|
|
7325
|
+
for (const f of ctx.features) {
|
|
7326
|
+
for (const [rel, content] of Object.entries(f.rootFiles?.(ctx) ?? {})) out[rel] = content;
|
|
7327
|
+
}
|
|
6662
7328
|
const envBase = out[".env.example"];
|
|
6663
7329
|
const seen = new Set(
|
|
6664
7330
|
(envBase.match(/^#?\s*([A-Z0-9_]+)=/gm) ?? []).map((l) => l.replace(/^#?\s*/, "").replace(/=.*/, ""))
|