create-lacspace-app 2.9.0 → 2.10.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 +5 -0
- package/dist/index.js +355 -0
- package/dist/lib.cjs +355 -0
- package/dist/lib.d.cts +6 -0
- package/dist/lib.d.ts +6 -0
- package/dist/lib.js +355 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,6 +20,11 @@ npx create-lacspace-app my-app --template saas --fullstack
|
|
|
20
20
|
|
|
21
21
|
You choose the *kind* of site you're building. It writes a **real Next.js 15 + React 19 + Tailwind v4 app** — not a hello-world, but a genuinely **polished, modern site**: a fluid `clamp()` type scale, tight display headings, a refined light **and** dark palette, glass chrome, soft layered shadows, a smooth logo marquee, animated counters, scroll reveals and a shimmering primary CTA — every page filled in, an SEO stack wired end-to-end, and a **26-component UI kit** you can drop in anywhere.
|
|
22
22
|
|
|
23
|
+
> **New in v2.10 — three more add-ons (11 total).**
|
|
24
|
+
> - **`uploads`** — authenticated file uploads stored in MongoDB, served via **signed, expiring URLs** (`@lacspace/signed-url`) — no S3 required.
|
|
25
|
+
> - **`i18n`** — multi-language UI: a tiny dependency-free `t()` translator + a language switcher, with English + Nepali locales and an `i18n:check` lint script.
|
|
26
|
+
> - **`quality`** — one-command quality gates from the Lacspace dev-tools (bundle-size budget, dependency audit, fake fixtures) **plus a ready GitHub Actions CI workflow** (dropped at the repo root, monorepo-aware).
|
|
27
|
+
|
|
23
28
|
> **New in v2.9 — guided & discoverable.** Made for newcomers:
|
|
24
29
|
> - **Guided start** — the interactive prompt now offers a **recipe** first ("what are you building?"), so you can ship a whole product without knowing the flags.
|
|
25
30
|
> - **`list`** — `npx create-lacspace-app list` shows every template, add-on and recipe. **`explain <name>`** describes what any add-on or recipe gives you (packages, next steps, docs link).
|
package/dist/index.js
CHANGED
|
@@ -1997,6 +1997,11 @@ function applyFeatures(files, ctx) {
|
|
|
1997
1997
|
files[rel] = content;
|
|
1998
1998
|
}
|
|
1999
1999
|
}
|
|
2000
|
+
if (ctx.mode === "static") {
|
|
2001
|
+
for (const feat of features) {
|
|
2002
|
+
for (const [rel, content] of Object.entries(feat.rootFiles?.(ctx) ?? {})) files[rel] = content;
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2000
2005
|
const addDeps = {};
|
|
2001
2006
|
const addScripts = {};
|
|
2002
2007
|
for (const feat of features) {
|
|
@@ -2275,6 +2280,68 @@ var FEATURES = [
|
|
|
2275
2280
|
"Reuse the helpers in backend/src/mail/mailer.ts: sendWelcome / sendVerify / sendReset."
|
|
2276
2281
|
],
|
|
2277
2282
|
learn: "https://developer.lacspace.com/packages/mailer"
|
|
2283
|
+
},
|
|
2284
|
+
{
|
|
2285
|
+
key: "i18n",
|
|
2286
|
+
label: "i18n (multi-language)",
|
|
2287
|
+
description: "Multi-language UI: a tiny runtime translator (t() + a language switcher) with English + Nepali locales, plus an i18n:check lint script.",
|
|
2288
|
+
deps: {},
|
|
2289
|
+
scripts: { "i18n:check": "npx lacspace-i18n@0.2.0 check locales" },
|
|
2290
|
+
files: () => ({
|
|
2291
|
+
"lib/i18n.ts": i18nLib(),
|
|
2292
|
+
"components/language-provider.tsx": i18nProvider(),
|
|
2293
|
+
"app/i18n-demo/page.tsx": i18nDemoPage(),
|
|
2294
|
+
"locales/en.json": i18nEn(),
|
|
2295
|
+
"locales/ne.json": i18nNe()
|
|
2296
|
+
}),
|
|
2297
|
+
nextSteps: [
|
|
2298
|
+
"Wrap app/layout.tsx's <body> with <LanguageProvider> (from @/components/language-provider).",
|
|
2299
|
+
'Use it in a client component: const { t } = useT(); then {t("hello", { name })}.',
|
|
2300
|
+
"Add keys in locales/*.json, then lint them with `npm run i18n:check`."
|
|
2301
|
+
],
|
|
2302
|
+
learn: "https://developer.lacspace.com/tools/i18n"
|
|
2303
|
+
},
|
|
2304
|
+
{
|
|
2305
|
+
key: "quality",
|
|
2306
|
+
label: "Quality & CI",
|
|
2307
|
+
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.",
|
|
2308
|
+
deps: {},
|
|
2309
|
+
scripts: {
|
|
2310
|
+
"size:check": "npx lacspace-size@0.2.0 .next/static --metric gzip --max 500kb",
|
|
2311
|
+
"deps:audit": "npx lacspace-deps@0.2.0 . --fail-on duplicates",
|
|
2312
|
+
"fixtures:gen": 'npx lacspace-fake@0.2.0 --fields "id:autoincrement,name:fullName,email:unique(email)" -n 50 --pretty -o fixtures/users.json'
|
|
2313
|
+
},
|
|
2314
|
+
files: () => ({}),
|
|
2315
|
+
rootFiles: (ctx) => ({ ".github/workflows/ci.yml": qualityCiWorkflow(ctx) }),
|
|
2316
|
+
nextSteps: [
|
|
2317
|
+
"Run `npm run size:check` (bundle-size budget) and `npm run deps:audit` (dependency audit) \u2014 both fail loudly in CI.",
|
|
2318
|
+
"Generate sample data: `npm run fixtures:gen` \u2192 fixtures/users.json.",
|
|
2319
|
+
"The GitHub Actions workflow (.github/workflows/ci.yml) runs the checks on every push."
|
|
2320
|
+
],
|
|
2321
|
+
learn: "https://developer.lacspace.com/tools/size"
|
|
2322
|
+
},
|
|
2323
|
+
{
|
|
2324
|
+
key: "uploads",
|
|
2325
|
+
label: "File uploads",
|
|
2326
|
+
description: "Authenticated file uploads stored in MongoDB, served via signed, expiring URLs (@lacspace/signed-url). No S3 required. Full-stack.",
|
|
2327
|
+
requiresBackend: true,
|
|
2328
|
+
deps: {},
|
|
2329
|
+
files: () => ({ "app/uploads/page.tsx": uploadsPage() }),
|
|
2330
|
+
backend: () => ({
|
|
2331
|
+
deps: { "@lacspace/signed-url": "^1.1.0" },
|
|
2332
|
+
files: {
|
|
2333
|
+
"src/models/upload.ts": uploadModel(),
|
|
2334
|
+
"src/routes/uploads.ts": uploadsRoutes()
|
|
2335
|
+
},
|
|
2336
|
+
routes: [{ path: "/uploads", handler: "uploadRoutes", auth: false, importLine: 'import uploadRoutes from "./uploads.js";' }],
|
|
2337
|
+
env: { UPLOAD_URL_SECRET: "Secret for signing file download URLs (blank = reuse JWT_SECRET)." }
|
|
2338
|
+
}),
|
|
2339
|
+
nextSteps: [
|
|
2340
|
+
"Sign in, then open http://localhost:3000/uploads and upload an image or file.",
|
|
2341
|
+
"Files are stored in MongoDB and served via signed, expiring links \u2014 no S3 required.",
|
|
2342
|
+
"For large files or production, swap the Mongo storage for disk or object storage."
|
|
2343
|
+
],
|
|
2344
|
+
learn: "https://developer.lacspace.com/packages/signed-url"
|
|
2278
2345
|
}
|
|
2279
2346
|
];
|
|
2280
2347
|
var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
|
|
@@ -6259,6 +6326,291 @@ export default function EmailTestPage() {
|
|
|
6259
6326
|
);
|
|
6260
6327
|
}
|
|
6261
6328
|
`;
|
|
6329
|
+
var i18nLib = () => `import en from "@/locales/en.json";
|
|
6330
|
+
import ne from "@/locales/ne.json";
|
|
6331
|
+
|
|
6332
|
+
// how this works: a tiny, dependency-free translator. Locale files live in locales/,
|
|
6333
|
+
// keys are flat strings, and {vars} are interpolated. A missing key falls back to
|
|
6334
|
+
// English, then to the key itself. Lint the files with \`npm run i18n:check\`.
|
|
6335
|
+
export const locales = ["en", "ne"] as const;
|
|
6336
|
+
export type Locale = (typeof locales)[number];
|
|
6337
|
+
export const defaultLocale: Locale = "en";
|
|
6338
|
+
|
|
6339
|
+
const dicts: Record<Locale, Record<string, string>> = {
|
|
6340
|
+
en: en as Record<string, string>,
|
|
6341
|
+
ne: ne as Record<string, string>,
|
|
6342
|
+
};
|
|
6343
|
+
|
|
6344
|
+
export function translate(locale: Locale, key: string, vars?: Record<string, string | number>): string {
|
|
6345
|
+
const table = dicts[locale] ?? dicts[defaultLocale];
|
|
6346
|
+
let str = table[key] ?? dicts[defaultLocale][key] ?? key;
|
|
6347
|
+
if (vars) for (const [k, v] of Object.entries(vars)) str = str.split("{" + k + "}").join(String(v));
|
|
6348
|
+
return str;
|
|
6349
|
+
}
|
|
6350
|
+
`;
|
|
6351
|
+
var i18nProvider = () => `"use client";
|
|
6352
|
+
import { createContext, useContext, useEffect, useState } from "react";
|
|
6353
|
+
import { locales, defaultLocale, translate, type Locale } from "@/lib/i18n";
|
|
6354
|
+
|
|
6355
|
+
// how this works: holds the current locale (persisted in localStorage) and exposes
|
|
6356
|
+
// t() via the useT() hook. Wrap your app in <LanguageProvider> once (in layout.tsx).
|
|
6357
|
+
interface LocaleCtxValue { locale: Locale; setLocale: (l: Locale) => void; }
|
|
6358
|
+
const LocaleCtx = createContext<LocaleCtxValue>({ locale: defaultLocale, setLocale: () => {} });
|
|
6359
|
+
|
|
6360
|
+
export function LanguageProvider({ children }: { children: React.ReactNode }) {
|
|
6361
|
+
const [locale, setLocaleState] = useState<Locale>(defaultLocale);
|
|
6362
|
+
useEffect(() => {
|
|
6363
|
+
try {
|
|
6364
|
+
const saved = localStorage.getItem("locale");
|
|
6365
|
+
if (saved && (locales as readonly string[]).includes(saved)) setLocaleState(saved as Locale);
|
|
6366
|
+
} catch {}
|
|
6367
|
+
}, []);
|
|
6368
|
+
const setLocale = (l: Locale) => { setLocaleState(l); try { localStorage.setItem("locale", l); } catch {} };
|
|
6369
|
+
return <LocaleCtx.Provider value={{ locale, setLocale }}>{children}</LocaleCtx.Provider>;
|
|
6370
|
+
}
|
|
6371
|
+
|
|
6372
|
+
export function useT() {
|
|
6373
|
+
const { locale, setLocale } = useContext(LocaleCtx);
|
|
6374
|
+
return {
|
|
6375
|
+
t: (key: string, vars?: Record<string, string | number>) => translate(locale, key, vars),
|
|
6376
|
+
locale,
|
|
6377
|
+
setLocale,
|
|
6378
|
+
};
|
|
6379
|
+
}
|
|
6380
|
+
|
|
6381
|
+
export function LanguageSwitcher() {
|
|
6382
|
+
const { locale, setLocale } = useT();
|
|
6383
|
+
return (
|
|
6384
|
+
<div className="inline-flex gap-1 rounded-full border border-hairline p-1">
|
|
6385
|
+
{locales.map((l) => (
|
|
6386
|
+
<button
|
|
6387
|
+
key={l}
|
|
6388
|
+
onClick={() => setLocale(l)}
|
|
6389
|
+
className={"rounded-full px-3 py-1 text-sm transition " + (l === locale ? "gradient-bg on-accent" : "text-muted hover:text-fg")}
|
|
6390
|
+
>
|
|
6391
|
+
{l.toUpperCase()}
|
|
6392
|
+
</button>
|
|
6393
|
+
))}
|
|
6394
|
+
</div>
|
|
6395
|
+
);
|
|
6396
|
+
}
|
|
6397
|
+
`;
|
|
6398
|
+
var i18nDemoPage = () => `"use client";
|
|
6399
|
+
import { LanguageProvider, LanguageSwitcher, useT } from "@/components/language-provider";
|
|
6400
|
+
|
|
6401
|
+
function Demo() {
|
|
6402
|
+
const { t } = useT();
|
|
6403
|
+
return (
|
|
6404
|
+
<div className="space-y-4">
|
|
6405
|
+
<h1 className="text-3xl font-bold">{t("hello", { name: "world" })}</h1>
|
|
6406
|
+
<p className="text-muted">{t("tagline")}</p>
|
|
6407
|
+
<LanguageSwitcher />
|
|
6408
|
+
</div>
|
|
6409
|
+
);
|
|
6410
|
+
}
|
|
6411
|
+
|
|
6412
|
+
export default function I18nDemoPage() {
|
|
6413
|
+
return (
|
|
6414
|
+
<main className="mx-auto max-w-md px-6 py-16">
|
|
6415
|
+
<LanguageProvider>
|
|
6416
|
+
<Demo />
|
|
6417
|
+
</LanguageProvider>
|
|
6418
|
+
</main>
|
|
6419
|
+
);
|
|
6420
|
+
}
|
|
6421
|
+
`;
|
|
6422
|
+
var i18nEn = () => JSON.stringify(
|
|
6423
|
+
{ hello: "Hello, {name}!", tagline: "This text switches language instantly \u2014 no reload.", "nav.home": "Home", "nav.about": "About" },
|
|
6424
|
+
null,
|
|
6425
|
+
2
|
|
6426
|
+
) + "\n";
|
|
6427
|
+
var i18nNe = () => JSON.stringify(
|
|
6428
|
+
{ 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" },
|
|
6429
|
+
null,
|
|
6430
|
+
2
|
|
6431
|
+
) + "\n";
|
|
6432
|
+
var qualityCiWorkflow = (ctx) => {
|
|
6433
|
+
const sizePath = ctx.mode === "dynamic" ? "frontend/.next/static" : ".next/static";
|
|
6434
|
+
const depsGate = ctx.mode === "dynamic" ? "duplicates" : "missing,duplicates";
|
|
6435
|
+
return `name: CI
|
|
6436
|
+
on:
|
|
6437
|
+
push:
|
|
6438
|
+
branches: [main]
|
|
6439
|
+
pull_request:
|
|
6440
|
+
jobs:
|
|
6441
|
+
quality:
|
|
6442
|
+
runs-on: ubuntu-latest
|
|
6443
|
+
steps:
|
|
6444
|
+
- uses: actions/checkout@v4
|
|
6445
|
+
- uses: actions/setup-node@v4
|
|
6446
|
+
with:
|
|
6447
|
+
node-version: 20
|
|
6448
|
+
cache: npm
|
|
6449
|
+
- run: npm ci
|
|
6450
|
+
- run: npm run build
|
|
6451
|
+
- name: Bundle-size budget
|
|
6452
|
+
run: npx lacspace-size@0.2.0 ${sizePath} --metric gzip --max 500kb
|
|
6453
|
+
- name: Dependency audit
|
|
6454
|
+
run: npx lacspace-deps@0.2.0 . --fail-on ${depsGate}
|
|
6455
|
+
`;
|
|
6456
|
+
};
|
|
6457
|
+
var uploadModel = () => `import mongoose from "mongoose";
|
|
6458
|
+
import { uuidv7 } from "@lacspace/id";
|
|
6459
|
+
|
|
6460
|
+
// how this works: files are stored base64-in-Mongo so it works with NO S3/disk setup.
|
|
6461
|
+
// For large files or production, swap this for disk or object storage.
|
|
6462
|
+
export interface UploadDoc {
|
|
6463
|
+
_id: string;
|
|
6464
|
+
userId: string;
|
|
6465
|
+
filename: string;
|
|
6466
|
+
contentType: string;
|
|
6467
|
+
size: number;
|
|
6468
|
+
data: string; // base64
|
|
6469
|
+
createdAt: Date;
|
|
6470
|
+
}
|
|
6471
|
+
|
|
6472
|
+
const schema = new mongoose.Schema<UploadDoc>(
|
|
6473
|
+
{
|
|
6474
|
+
_id: { type: String, default: () => uuidv7() },
|
|
6475
|
+
userId: { type: String, required: true, index: true },
|
|
6476
|
+
filename: { type: String, required: true },
|
|
6477
|
+
contentType: { type: String, default: "application/octet-stream" },
|
|
6478
|
+
size: { type: Number, default: 0 },
|
|
6479
|
+
data: { type: String, required: true },
|
|
6480
|
+
},
|
|
6481
|
+
{ timestamps: { createdAt: true, updatedAt: false } },
|
|
6482
|
+
);
|
|
6483
|
+
|
|
6484
|
+
export const Upload =
|
|
6485
|
+
(mongoose.models.Upload as mongoose.Model<UploadDoc>) ?? mongoose.model<UploadDoc>("Upload", schema);
|
|
6486
|
+
`;
|
|
6487
|
+
var uploadsRoutes = () => `import express from "express";
|
|
6488
|
+
import { signUrl, verifyUrl } from "@lacspace/signed-url";
|
|
6489
|
+
import { v } from "@lacspace/validate";
|
|
6490
|
+
import { asyncHandler, HttpError } from "../http.js";
|
|
6491
|
+
import { requireAuth } from "../middleware/auth.js";
|
|
6492
|
+
import { env } from "../env.js";
|
|
6493
|
+
import { Upload } from "../models/upload.js";
|
|
6494
|
+
|
|
6495
|
+
// how this works: uploads live in Mongo; each file is served from a PUBLIC route
|
|
6496
|
+
// protected by a signed, expiring URL (@lacspace/signed-url) \u2014 so <img src> works
|
|
6497
|
+
// without a bearer token, yet the link can't be forged or shared forever.
|
|
6498
|
+
const router = express.Router();
|
|
6499
|
+
const SECRET = process.env.UPLOAD_URL_SECRET ?? env.JWT_SECRET;
|
|
6500
|
+
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB \u2014 base64-in-Mongo, keep it small
|
|
6501
|
+
|
|
6502
|
+
const UploadInput = v.object({ filename: v.string().min(1).max(200), dataUrl: v.string().min(1) });
|
|
6503
|
+
|
|
6504
|
+
// Sign a RELATIVE URL; the frontend prefixes it with the API base. Verified as-is.
|
|
6505
|
+
function signed(id: string): Promise<string> {
|
|
6506
|
+
return signUrl("/uploads/" + id + "/raw", { secret: SECRET, expiresIn: 3600 });
|
|
6507
|
+
}
|
|
6508
|
+
|
|
6509
|
+
// GET /uploads/:id/raw \u2014 PUBLIC, but the signed query params must verify.
|
|
6510
|
+
router.get("/:id/raw", asyncHandler(async (req, res) => {
|
|
6511
|
+
const check = await verifyUrl(req.originalUrl, { secret: SECRET, clockToleranceSec: 60 });
|
|
6512
|
+
if (!check.valid) throw new HttpError(check.reason === "expired" ? 410 : 403, "Link expired or invalid");
|
|
6513
|
+
const file = await Upload.findById(req.params.id);
|
|
6514
|
+
if (!file) throw new HttpError(404, "Not found");
|
|
6515
|
+
res.setHeader("Content-Type", file.contentType);
|
|
6516
|
+
res.send(Buffer.from(file.data, "base64"));
|
|
6517
|
+
}));
|
|
6518
|
+
|
|
6519
|
+
// POST /uploads \u2014 protected: store a base64 data URL.
|
|
6520
|
+
router.post("/", requireAuth, asyncHandler(async (req, res) => {
|
|
6521
|
+
const { filename, dataUrl } = UploadInput.parse(req.body);
|
|
6522
|
+
const m = /^data:(.+?);base64,(.*)$/.exec(dataUrl);
|
|
6523
|
+
if (!m) throw new HttpError(400, "Expected a base64 data URL");
|
|
6524
|
+
const contentType = m[1]!;
|
|
6525
|
+
const data = m[2]!;
|
|
6526
|
+
const size = Math.floor((data.length * 3) / 4);
|
|
6527
|
+
if (size > MAX_BYTES) throw new HttpError(413, "File too large (max 5 MB)");
|
|
6528
|
+
const file = await Upload.create({ userId: req.user!.sub, filename, contentType, size, data });
|
|
6529
|
+
res.status(201).json({ id: String(file._id), filename, contentType, size, url: await signed(String(file._id)) });
|
|
6530
|
+
}));
|
|
6531
|
+
|
|
6532
|
+
// GET /uploads \u2014 protected: this user's files (with fresh signed URLs).
|
|
6533
|
+
router.get("/", requireAuth, asyncHandler(async (req, res) => {
|
|
6534
|
+
const files = await Upload.find({ userId: req.user!.sub }).sort({ createdAt: -1 }).select("filename contentType size");
|
|
6535
|
+
const out = await Promise.all(
|
|
6536
|
+
files.map(async (f) => ({ id: String(f._id), filename: f.filename, contentType: f.contentType, size: f.size, url: await signed(String(f._id)) })),
|
|
6537
|
+
);
|
|
6538
|
+
res.json(out);
|
|
6539
|
+
}));
|
|
6540
|
+
|
|
6541
|
+
export default router;
|
|
6542
|
+
`;
|
|
6543
|
+
var uploadsPage = () => `"use client";
|
|
6544
|
+
import { useEffect, useState } from "react";
|
|
6545
|
+
import { useRouter } from "next/navigation";
|
|
6546
|
+
import { getToken } from "@/lib/api";
|
|
6547
|
+
|
|
6548
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
6549
|
+
interface FileItem { id: string; filename: string; contentType: string; size: number; url: string; }
|
|
6550
|
+
|
|
6551
|
+
function readAsDataUrl(file: File): Promise<string> {
|
|
6552
|
+
return new Promise((resolve, reject) => {
|
|
6553
|
+
const r = new FileReader();
|
|
6554
|
+
r.onload = () => resolve(String(r.result));
|
|
6555
|
+
r.onerror = () => reject(new Error("read failed"));
|
|
6556
|
+
r.readAsDataURL(file);
|
|
6557
|
+
});
|
|
6558
|
+
}
|
|
6559
|
+
|
|
6560
|
+
export default function UploadsPage() {
|
|
6561
|
+
const router = useRouter();
|
|
6562
|
+
const [files, setFiles] = useState<FileItem[]>([]);
|
|
6563
|
+
const [busy, setBusy] = useState(false);
|
|
6564
|
+
const [error, setError] = useState<string | null>(null);
|
|
6565
|
+
|
|
6566
|
+
async function load() {
|
|
6567
|
+
const token = getToken();
|
|
6568
|
+
if (!token) { router.push("/login"); return; }
|
|
6569
|
+
const res = await fetch(API + "/uploads", { headers: { Authorization: "Bearer " + token } });
|
|
6570
|
+
if (res.ok) setFiles(await res.json());
|
|
6571
|
+
}
|
|
6572
|
+
useEffect(() => { void load(); }, []);
|
|
6573
|
+
|
|
6574
|
+
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
|
6575
|
+
const file = e.target.files?.[0];
|
|
6576
|
+
if (!file) return;
|
|
6577
|
+
setBusy(true); setError(null);
|
|
6578
|
+
try {
|
|
6579
|
+
const dataUrl = await readAsDataUrl(file);
|
|
6580
|
+
const token = getToken();
|
|
6581
|
+
const res = await fetch(API + "/uploads", {
|
|
6582
|
+
method: "POST",
|
|
6583
|
+
headers: { "Content-Type": "application/json", ...(token ? { Authorization: "Bearer " + token } : {}) },
|
|
6584
|
+
body: JSON.stringify({ filename: file.name, dataUrl }),
|
|
6585
|
+
});
|
|
6586
|
+
const data: unknown = await res.json().catch(() => ({}));
|
|
6587
|
+
if (!res.ok) throw new Error((data as { error?: string }).error ?? "Upload failed");
|
|
6588
|
+
await load();
|
|
6589
|
+
} catch (err) { setError(err instanceof Error ? err.message : "Upload failed"); }
|
|
6590
|
+
finally { setBusy(false); }
|
|
6591
|
+
}
|
|
6592
|
+
|
|
6593
|
+
return (
|
|
6594
|
+
<main className="mx-auto max-w-lg px-6 py-16">
|
|
6595
|
+
<h1 className="text-2xl font-bold">Uploads</h1>
|
|
6596
|
+
<p className="mt-1 text-muted">Stored in MongoDB, served via signed, expiring links.</p>
|
|
6597
|
+
<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">
|
|
6598
|
+
<input type="file" className="hidden" onChange={onPick} disabled={busy} />
|
|
6599
|
+
{busy ? "Uploading\u2026" : "Choose a file (max 5 MB)"}
|
|
6600
|
+
</label>
|
|
6601
|
+
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
|
|
6602
|
+
<ul className="mt-6 space-y-2">
|
|
6603
|
+
{files.map((f) => (
|
|
6604
|
+
<li key={f.id} className="flex items-center justify-between rounded-xl border border-hairline p-3">
|
|
6605
|
+
<a href={API + f.url} target="_blank" rel="noreferrer" className="truncate underline">{f.filename}</a>
|
|
6606
|
+
<span className="ml-3 shrink-0 text-sm text-muted">{Math.max(1, Math.round(f.size / 1024))} KB</span>
|
|
6607
|
+
</li>
|
|
6608
|
+
))}
|
|
6609
|
+
</ul>
|
|
6610
|
+
</main>
|
|
6611
|
+
);
|
|
6612
|
+
}
|
|
6613
|
+
`;
|
|
6262
6614
|
var scope = (ctx) => `@${ctx.name}`;
|
|
6263
6615
|
var typesPkgJson = (ctx) => JSON.stringify({
|
|
6264
6616
|
name: `${scope(ctx)}/types`,
|
|
@@ -6658,6 +7010,9 @@ function buildFullStack(ctx) {
|
|
|
6658
7010
|
out[".gitignore"] = rootGitignore();
|
|
6659
7011
|
out[".env.example"] = rootEnvExample(ctx);
|
|
6660
7012
|
out["README.md"] = rootReadme(ctx);
|
|
7013
|
+
for (const f of ctx.features) {
|
|
7014
|
+
for (const [rel, content] of Object.entries(f.rootFiles?.(ctx) ?? {})) out[rel] = content;
|
|
7015
|
+
}
|
|
6661
7016
|
const envBase = out[".env.example"];
|
|
6662
7017
|
const seen = new Set(
|
|
6663
7018
|
(envBase.match(/^#?\s*([A-Z0-9_]+)=/gm) ?? []).map((l) => l.replace(/^#?\s*/, "").replace(/=.*/, ""))
|
package/dist/lib.cjs
CHANGED
|
@@ -2001,6 +2001,11 @@ function applyFeatures(files, ctx) {
|
|
|
2001
2001
|
files[rel] = content;
|
|
2002
2002
|
}
|
|
2003
2003
|
}
|
|
2004
|
+
if (ctx.mode === "static") {
|
|
2005
|
+
for (const feat of features) {
|
|
2006
|
+
for (const [rel, content] of Object.entries(feat.rootFiles?.(ctx) ?? {})) files[rel] = content;
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2004
2009
|
const addDeps = {};
|
|
2005
2010
|
const addScripts = {};
|
|
2006
2011
|
for (const feat of features) {
|
|
@@ -2279,6 +2284,68 @@ var FEATURES = [
|
|
|
2279
2284
|
"Reuse the helpers in backend/src/mail/mailer.ts: sendWelcome / sendVerify / sendReset."
|
|
2280
2285
|
],
|
|
2281
2286
|
learn: "https://developer.lacspace.com/packages/mailer"
|
|
2287
|
+
},
|
|
2288
|
+
{
|
|
2289
|
+
key: "i18n",
|
|
2290
|
+
label: "i18n (multi-language)",
|
|
2291
|
+
description: "Multi-language UI: a tiny runtime translator (t() + a language switcher) with English + Nepali locales, plus an i18n:check lint script.",
|
|
2292
|
+
deps: {},
|
|
2293
|
+
scripts: { "i18n:check": "npx lacspace-i18n@0.2.0 check locales" },
|
|
2294
|
+
files: () => ({
|
|
2295
|
+
"lib/i18n.ts": i18nLib(),
|
|
2296
|
+
"components/language-provider.tsx": i18nProvider(),
|
|
2297
|
+
"app/i18n-demo/page.tsx": i18nDemoPage(),
|
|
2298
|
+
"locales/en.json": i18nEn(),
|
|
2299
|
+
"locales/ne.json": i18nNe()
|
|
2300
|
+
}),
|
|
2301
|
+
nextSteps: [
|
|
2302
|
+
"Wrap app/layout.tsx's <body> with <LanguageProvider> (from @/components/language-provider).",
|
|
2303
|
+
'Use it in a client component: const { t } = useT(); then {t("hello", { name })}.',
|
|
2304
|
+
"Add keys in locales/*.json, then lint them with `npm run i18n:check`."
|
|
2305
|
+
],
|
|
2306
|
+
learn: "https://developer.lacspace.com/tools/i18n"
|
|
2307
|
+
},
|
|
2308
|
+
{
|
|
2309
|
+
key: "quality",
|
|
2310
|
+
label: "Quality & CI",
|
|
2311
|
+
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.",
|
|
2312
|
+
deps: {},
|
|
2313
|
+
scripts: {
|
|
2314
|
+
"size:check": "npx lacspace-size@0.2.0 .next/static --metric gzip --max 500kb",
|
|
2315
|
+
"deps:audit": "npx lacspace-deps@0.2.0 . --fail-on duplicates",
|
|
2316
|
+
"fixtures:gen": 'npx lacspace-fake@0.2.0 --fields "id:autoincrement,name:fullName,email:unique(email)" -n 50 --pretty -o fixtures/users.json'
|
|
2317
|
+
},
|
|
2318
|
+
files: () => ({}),
|
|
2319
|
+
rootFiles: (ctx) => ({ ".github/workflows/ci.yml": qualityCiWorkflow(ctx) }),
|
|
2320
|
+
nextSteps: [
|
|
2321
|
+
"Run `npm run size:check` (bundle-size budget) and `npm run deps:audit` (dependency audit) \u2014 both fail loudly in CI.",
|
|
2322
|
+
"Generate sample data: `npm run fixtures:gen` \u2192 fixtures/users.json.",
|
|
2323
|
+
"The GitHub Actions workflow (.github/workflows/ci.yml) runs the checks on every push."
|
|
2324
|
+
],
|
|
2325
|
+
learn: "https://developer.lacspace.com/tools/size"
|
|
2326
|
+
},
|
|
2327
|
+
{
|
|
2328
|
+
key: "uploads",
|
|
2329
|
+
label: "File uploads",
|
|
2330
|
+
description: "Authenticated file uploads stored in MongoDB, served via signed, expiring URLs (@lacspace/signed-url). No S3 required. Full-stack.",
|
|
2331
|
+
requiresBackend: true,
|
|
2332
|
+
deps: {},
|
|
2333
|
+
files: () => ({ "app/uploads/page.tsx": uploadsPage() }),
|
|
2334
|
+
backend: () => ({
|
|
2335
|
+
deps: { "@lacspace/signed-url": "^1.1.0" },
|
|
2336
|
+
files: {
|
|
2337
|
+
"src/models/upload.ts": uploadModel(),
|
|
2338
|
+
"src/routes/uploads.ts": uploadsRoutes()
|
|
2339
|
+
},
|
|
2340
|
+
routes: [{ path: "/uploads", handler: "uploadRoutes", auth: false, importLine: 'import uploadRoutes from "./uploads.js";' }],
|
|
2341
|
+
env: { UPLOAD_URL_SECRET: "Secret for signing file download URLs (blank = reuse JWT_SECRET)." }
|
|
2342
|
+
}),
|
|
2343
|
+
nextSteps: [
|
|
2344
|
+
"Sign in, then open http://localhost:3000/uploads and upload an image or file.",
|
|
2345
|
+
"Files are stored in MongoDB and served via signed, expiring links \u2014 no S3 required.",
|
|
2346
|
+
"For large files or production, swap the Mongo storage for disk or object storage."
|
|
2347
|
+
],
|
|
2348
|
+
learn: "https://developer.lacspace.com/packages/signed-url"
|
|
2282
2349
|
}
|
|
2283
2350
|
];
|
|
2284
2351
|
var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
|
|
@@ -6263,6 +6330,291 @@ export default function EmailTestPage() {
|
|
|
6263
6330
|
);
|
|
6264
6331
|
}
|
|
6265
6332
|
`;
|
|
6333
|
+
var i18nLib = () => `import en from "@/locales/en.json";
|
|
6334
|
+
import ne from "@/locales/ne.json";
|
|
6335
|
+
|
|
6336
|
+
// how this works: a tiny, dependency-free translator. Locale files live in locales/,
|
|
6337
|
+
// keys are flat strings, and {vars} are interpolated. A missing key falls back to
|
|
6338
|
+
// English, then to the key itself. Lint the files with \`npm run i18n:check\`.
|
|
6339
|
+
export const locales = ["en", "ne"] as const;
|
|
6340
|
+
export type Locale = (typeof locales)[number];
|
|
6341
|
+
export const defaultLocale: Locale = "en";
|
|
6342
|
+
|
|
6343
|
+
const dicts: Record<Locale, Record<string, string>> = {
|
|
6344
|
+
en: en as Record<string, string>,
|
|
6345
|
+
ne: ne as Record<string, string>,
|
|
6346
|
+
};
|
|
6347
|
+
|
|
6348
|
+
export function translate(locale: Locale, key: string, vars?: Record<string, string | number>): string {
|
|
6349
|
+
const table = dicts[locale] ?? dicts[defaultLocale];
|
|
6350
|
+
let str = table[key] ?? dicts[defaultLocale][key] ?? key;
|
|
6351
|
+
if (vars) for (const [k, v] of Object.entries(vars)) str = str.split("{" + k + "}").join(String(v));
|
|
6352
|
+
return str;
|
|
6353
|
+
}
|
|
6354
|
+
`;
|
|
6355
|
+
var i18nProvider = () => `"use client";
|
|
6356
|
+
import { createContext, useContext, useEffect, useState } from "react";
|
|
6357
|
+
import { locales, defaultLocale, translate, type Locale } from "@/lib/i18n";
|
|
6358
|
+
|
|
6359
|
+
// how this works: holds the current locale (persisted in localStorage) and exposes
|
|
6360
|
+
// t() via the useT() hook. Wrap your app in <LanguageProvider> once (in layout.tsx).
|
|
6361
|
+
interface LocaleCtxValue { locale: Locale; setLocale: (l: Locale) => void; }
|
|
6362
|
+
const LocaleCtx = createContext<LocaleCtxValue>({ locale: defaultLocale, setLocale: () => {} });
|
|
6363
|
+
|
|
6364
|
+
export function LanguageProvider({ children }: { children: React.ReactNode }) {
|
|
6365
|
+
const [locale, setLocaleState] = useState<Locale>(defaultLocale);
|
|
6366
|
+
useEffect(() => {
|
|
6367
|
+
try {
|
|
6368
|
+
const saved = localStorage.getItem("locale");
|
|
6369
|
+
if (saved && (locales as readonly string[]).includes(saved)) setLocaleState(saved as Locale);
|
|
6370
|
+
} catch {}
|
|
6371
|
+
}, []);
|
|
6372
|
+
const setLocale = (l: Locale) => { setLocaleState(l); try { localStorage.setItem("locale", l); } catch {} };
|
|
6373
|
+
return <LocaleCtx.Provider value={{ locale, setLocale }}>{children}</LocaleCtx.Provider>;
|
|
6374
|
+
}
|
|
6375
|
+
|
|
6376
|
+
export function useT() {
|
|
6377
|
+
const { locale, setLocale } = useContext(LocaleCtx);
|
|
6378
|
+
return {
|
|
6379
|
+
t: (key: string, vars?: Record<string, string | number>) => translate(locale, key, vars),
|
|
6380
|
+
locale,
|
|
6381
|
+
setLocale,
|
|
6382
|
+
};
|
|
6383
|
+
}
|
|
6384
|
+
|
|
6385
|
+
export function LanguageSwitcher() {
|
|
6386
|
+
const { locale, setLocale } = useT();
|
|
6387
|
+
return (
|
|
6388
|
+
<div className="inline-flex gap-1 rounded-full border border-hairline p-1">
|
|
6389
|
+
{locales.map((l) => (
|
|
6390
|
+
<button
|
|
6391
|
+
key={l}
|
|
6392
|
+
onClick={() => setLocale(l)}
|
|
6393
|
+
className={"rounded-full px-3 py-1 text-sm transition " + (l === locale ? "gradient-bg on-accent" : "text-muted hover:text-fg")}
|
|
6394
|
+
>
|
|
6395
|
+
{l.toUpperCase()}
|
|
6396
|
+
</button>
|
|
6397
|
+
))}
|
|
6398
|
+
</div>
|
|
6399
|
+
);
|
|
6400
|
+
}
|
|
6401
|
+
`;
|
|
6402
|
+
var i18nDemoPage = () => `"use client";
|
|
6403
|
+
import { LanguageProvider, LanguageSwitcher, useT } from "@/components/language-provider";
|
|
6404
|
+
|
|
6405
|
+
function Demo() {
|
|
6406
|
+
const { t } = useT();
|
|
6407
|
+
return (
|
|
6408
|
+
<div className="space-y-4">
|
|
6409
|
+
<h1 className="text-3xl font-bold">{t("hello", { name: "world" })}</h1>
|
|
6410
|
+
<p className="text-muted">{t("tagline")}</p>
|
|
6411
|
+
<LanguageSwitcher />
|
|
6412
|
+
</div>
|
|
6413
|
+
);
|
|
6414
|
+
}
|
|
6415
|
+
|
|
6416
|
+
export default function I18nDemoPage() {
|
|
6417
|
+
return (
|
|
6418
|
+
<main className="mx-auto max-w-md px-6 py-16">
|
|
6419
|
+
<LanguageProvider>
|
|
6420
|
+
<Demo />
|
|
6421
|
+
</LanguageProvider>
|
|
6422
|
+
</main>
|
|
6423
|
+
);
|
|
6424
|
+
}
|
|
6425
|
+
`;
|
|
6426
|
+
var i18nEn = () => JSON.stringify(
|
|
6427
|
+
{ hello: "Hello, {name}!", tagline: "This text switches language instantly \u2014 no reload.", "nav.home": "Home", "nav.about": "About" },
|
|
6428
|
+
null,
|
|
6429
|
+
2
|
|
6430
|
+
) + "\n";
|
|
6431
|
+
var i18nNe = () => JSON.stringify(
|
|
6432
|
+
{ 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" },
|
|
6433
|
+
null,
|
|
6434
|
+
2
|
|
6435
|
+
) + "\n";
|
|
6436
|
+
var qualityCiWorkflow = (ctx) => {
|
|
6437
|
+
const sizePath = ctx.mode === "dynamic" ? "frontend/.next/static" : ".next/static";
|
|
6438
|
+
const depsGate = ctx.mode === "dynamic" ? "duplicates" : "missing,duplicates";
|
|
6439
|
+
return `name: CI
|
|
6440
|
+
on:
|
|
6441
|
+
push:
|
|
6442
|
+
branches: [main]
|
|
6443
|
+
pull_request:
|
|
6444
|
+
jobs:
|
|
6445
|
+
quality:
|
|
6446
|
+
runs-on: ubuntu-latest
|
|
6447
|
+
steps:
|
|
6448
|
+
- uses: actions/checkout@v4
|
|
6449
|
+
- uses: actions/setup-node@v4
|
|
6450
|
+
with:
|
|
6451
|
+
node-version: 20
|
|
6452
|
+
cache: npm
|
|
6453
|
+
- run: npm ci
|
|
6454
|
+
- run: npm run build
|
|
6455
|
+
- name: Bundle-size budget
|
|
6456
|
+
run: npx lacspace-size@0.2.0 ${sizePath} --metric gzip --max 500kb
|
|
6457
|
+
- name: Dependency audit
|
|
6458
|
+
run: npx lacspace-deps@0.2.0 . --fail-on ${depsGate}
|
|
6459
|
+
`;
|
|
6460
|
+
};
|
|
6461
|
+
var uploadModel = () => `import mongoose from "mongoose";
|
|
6462
|
+
import { uuidv7 } from "@lacspace/id";
|
|
6463
|
+
|
|
6464
|
+
// how this works: files are stored base64-in-Mongo so it works with NO S3/disk setup.
|
|
6465
|
+
// For large files or production, swap this for disk or object storage.
|
|
6466
|
+
export interface UploadDoc {
|
|
6467
|
+
_id: string;
|
|
6468
|
+
userId: string;
|
|
6469
|
+
filename: string;
|
|
6470
|
+
contentType: string;
|
|
6471
|
+
size: number;
|
|
6472
|
+
data: string; // base64
|
|
6473
|
+
createdAt: Date;
|
|
6474
|
+
}
|
|
6475
|
+
|
|
6476
|
+
const schema = new mongoose.Schema<UploadDoc>(
|
|
6477
|
+
{
|
|
6478
|
+
_id: { type: String, default: () => uuidv7() },
|
|
6479
|
+
userId: { type: String, required: true, index: true },
|
|
6480
|
+
filename: { type: String, required: true },
|
|
6481
|
+
contentType: { type: String, default: "application/octet-stream" },
|
|
6482
|
+
size: { type: Number, default: 0 },
|
|
6483
|
+
data: { type: String, required: true },
|
|
6484
|
+
},
|
|
6485
|
+
{ timestamps: { createdAt: true, updatedAt: false } },
|
|
6486
|
+
);
|
|
6487
|
+
|
|
6488
|
+
export const Upload =
|
|
6489
|
+
(mongoose.models.Upload as mongoose.Model<UploadDoc>) ?? mongoose.model<UploadDoc>("Upload", schema);
|
|
6490
|
+
`;
|
|
6491
|
+
var uploadsRoutes = () => `import express from "express";
|
|
6492
|
+
import { signUrl, verifyUrl } from "@lacspace/signed-url";
|
|
6493
|
+
import { v } from "@lacspace/validate";
|
|
6494
|
+
import { asyncHandler, HttpError } from "../http.js";
|
|
6495
|
+
import { requireAuth } from "../middleware/auth.js";
|
|
6496
|
+
import { env } from "../env.js";
|
|
6497
|
+
import { Upload } from "../models/upload.js";
|
|
6498
|
+
|
|
6499
|
+
// how this works: uploads live in Mongo; each file is served from a PUBLIC route
|
|
6500
|
+
// protected by a signed, expiring URL (@lacspace/signed-url) \u2014 so <img src> works
|
|
6501
|
+
// without a bearer token, yet the link can't be forged or shared forever.
|
|
6502
|
+
const router = express.Router();
|
|
6503
|
+
const SECRET = process.env.UPLOAD_URL_SECRET ?? env.JWT_SECRET;
|
|
6504
|
+
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB \u2014 base64-in-Mongo, keep it small
|
|
6505
|
+
|
|
6506
|
+
const UploadInput = v.object({ filename: v.string().min(1).max(200), dataUrl: v.string().min(1) });
|
|
6507
|
+
|
|
6508
|
+
// Sign a RELATIVE URL; the frontend prefixes it with the API base. Verified as-is.
|
|
6509
|
+
function signed(id: string): Promise<string> {
|
|
6510
|
+
return signUrl("/uploads/" + id + "/raw", { secret: SECRET, expiresIn: 3600 });
|
|
6511
|
+
}
|
|
6512
|
+
|
|
6513
|
+
// GET /uploads/:id/raw \u2014 PUBLIC, but the signed query params must verify.
|
|
6514
|
+
router.get("/:id/raw", asyncHandler(async (req, res) => {
|
|
6515
|
+
const check = await verifyUrl(req.originalUrl, { secret: SECRET, clockToleranceSec: 60 });
|
|
6516
|
+
if (!check.valid) throw new HttpError(check.reason === "expired" ? 410 : 403, "Link expired or invalid");
|
|
6517
|
+
const file = await Upload.findById(req.params.id);
|
|
6518
|
+
if (!file) throw new HttpError(404, "Not found");
|
|
6519
|
+
res.setHeader("Content-Type", file.contentType);
|
|
6520
|
+
res.send(Buffer.from(file.data, "base64"));
|
|
6521
|
+
}));
|
|
6522
|
+
|
|
6523
|
+
// POST /uploads \u2014 protected: store a base64 data URL.
|
|
6524
|
+
router.post("/", requireAuth, asyncHandler(async (req, res) => {
|
|
6525
|
+
const { filename, dataUrl } = UploadInput.parse(req.body);
|
|
6526
|
+
const m = /^data:(.+?);base64,(.*)$/.exec(dataUrl);
|
|
6527
|
+
if (!m) throw new HttpError(400, "Expected a base64 data URL");
|
|
6528
|
+
const contentType = m[1]!;
|
|
6529
|
+
const data = m[2]!;
|
|
6530
|
+
const size = Math.floor((data.length * 3) / 4);
|
|
6531
|
+
if (size > MAX_BYTES) throw new HttpError(413, "File too large (max 5 MB)");
|
|
6532
|
+
const file = await Upload.create({ userId: req.user!.sub, filename, contentType, size, data });
|
|
6533
|
+
res.status(201).json({ id: String(file._id), filename, contentType, size, url: await signed(String(file._id)) });
|
|
6534
|
+
}));
|
|
6535
|
+
|
|
6536
|
+
// GET /uploads \u2014 protected: this user's files (with fresh signed URLs).
|
|
6537
|
+
router.get("/", requireAuth, asyncHandler(async (req, res) => {
|
|
6538
|
+
const files = await Upload.find({ userId: req.user!.sub }).sort({ createdAt: -1 }).select("filename contentType size");
|
|
6539
|
+
const out = await Promise.all(
|
|
6540
|
+
files.map(async (f) => ({ id: String(f._id), filename: f.filename, contentType: f.contentType, size: f.size, url: await signed(String(f._id)) })),
|
|
6541
|
+
);
|
|
6542
|
+
res.json(out);
|
|
6543
|
+
}));
|
|
6544
|
+
|
|
6545
|
+
export default router;
|
|
6546
|
+
`;
|
|
6547
|
+
var uploadsPage = () => `"use client";
|
|
6548
|
+
import { useEffect, useState } from "react";
|
|
6549
|
+
import { useRouter } from "next/navigation";
|
|
6550
|
+
import { getToken } from "@/lib/api";
|
|
6551
|
+
|
|
6552
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
6553
|
+
interface FileItem { id: string; filename: string; contentType: string; size: number; url: string; }
|
|
6554
|
+
|
|
6555
|
+
function readAsDataUrl(file: File): Promise<string> {
|
|
6556
|
+
return new Promise((resolve, reject) => {
|
|
6557
|
+
const r = new FileReader();
|
|
6558
|
+
r.onload = () => resolve(String(r.result));
|
|
6559
|
+
r.onerror = () => reject(new Error("read failed"));
|
|
6560
|
+
r.readAsDataURL(file);
|
|
6561
|
+
});
|
|
6562
|
+
}
|
|
6563
|
+
|
|
6564
|
+
export default function UploadsPage() {
|
|
6565
|
+
const router = useRouter();
|
|
6566
|
+
const [files, setFiles] = useState<FileItem[]>([]);
|
|
6567
|
+
const [busy, setBusy] = useState(false);
|
|
6568
|
+
const [error, setError] = useState<string | null>(null);
|
|
6569
|
+
|
|
6570
|
+
async function load() {
|
|
6571
|
+
const token = getToken();
|
|
6572
|
+
if (!token) { router.push("/login"); return; }
|
|
6573
|
+
const res = await fetch(API + "/uploads", { headers: { Authorization: "Bearer " + token } });
|
|
6574
|
+
if (res.ok) setFiles(await res.json());
|
|
6575
|
+
}
|
|
6576
|
+
useEffect(() => { void load(); }, []);
|
|
6577
|
+
|
|
6578
|
+
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
|
6579
|
+
const file = e.target.files?.[0];
|
|
6580
|
+
if (!file) return;
|
|
6581
|
+
setBusy(true); setError(null);
|
|
6582
|
+
try {
|
|
6583
|
+
const dataUrl = await readAsDataUrl(file);
|
|
6584
|
+
const token = getToken();
|
|
6585
|
+
const res = await fetch(API + "/uploads", {
|
|
6586
|
+
method: "POST",
|
|
6587
|
+
headers: { "Content-Type": "application/json", ...(token ? { Authorization: "Bearer " + token } : {}) },
|
|
6588
|
+
body: JSON.stringify({ filename: file.name, dataUrl }),
|
|
6589
|
+
});
|
|
6590
|
+
const data: unknown = await res.json().catch(() => ({}));
|
|
6591
|
+
if (!res.ok) throw new Error((data as { error?: string }).error ?? "Upload failed");
|
|
6592
|
+
await load();
|
|
6593
|
+
} catch (err) { setError(err instanceof Error ? err.message : "Upload failed"); }
|
|
6594
|
+
finally { setBusy(false); }
|
|
6595
|
+
}
|
|
6596
|
+
|
|
6597
|
+
return (
|
|
6598
|
+
<main className="mx-auto max-w-lg px-6 py-16">
|
|
6599
|
+
<h1 className="text-2xl font-bold">Uploads</h1>
|
|
6600
|
+
<p className="mt-1 text-muted">Stored in MongoDB, served via signed, expiring links.</p>
|
|
6601
|
+
<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">
|
|
6602
|
+
<input type="file" className="hidden" onChange={onPick} disabled={busy} />
|
|
6603
|
+
{busy ? "Uploading\u2026" : "Choose a file (max 5 MB)"}
|
|
6604
|
+
</label>
|
|
6605
|
+
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
|
|
6606
|
+
<ul className="mt-6 space-y-2">
|
|
6607
|
+
{files.map((f) => (
|
|
6608
|
+
<li key={f.id} className="flex items-center justify-between rounded-xl border border-hairline p-3">
|
|
6609
|
+
<a href={API + f.url} target="_blank" rel="noreferrer" className="truncate underline">{f.filename}</a>
|
|
6610
|
+
<span className="ml-3 shrink-0 text-sm text-muted">{Math.max(1, Math.round(f.size / 1024))} KB</span>
|
|
6611
|
+
</li>
|
|
6612
|
+
))}
|
|
6613
|
+
</ul>
|
|
6614
|
+
</main>
|
|
6615
|
+
);
|
|
6616
|
+
}
|
|
6617
|
+
`;
|
|
6266
6618
|
var scope = (ctx) => `@${ctx.name}`;
|
|
6267
6619
|
var typesPkgJson = (ctx) => JSON.stringify({
|
|
6268
6620
|
name: `${scope(ctx)}/types`,
|
|
@@ -6662,6 +7014,9 @@ function buildFullStack(ctx) {
|
|
|
6662
7014
|
out[".gitignore"] = rootGitignore();
|
|
6663
7015
|
out[".env.example"] = rootEnvExample(ctx);
|
|
6664
7016
|
out["README.md"] = rootReadme(ctx);
|
|
7017
|
+
for (const f of ctx.features) {
|
|
7018
|
+
for (const [rel, content] of Object.entries(f.rootFiles?.(ctx) ?? {})) out[rel] = content;
|
|
7019
|
+
}
|
|
6665
7020
|
const envBase = out[".env.example"];
|
|
6666
7021
|
const seen = new Set(
|
|
6667
7022
|
(envBase.match(/^#?\s*([A-Z0-9_]+)=/gm) ?? []).map((l) => l.replace(/^#?\s*/, "").replace(/=.*/, ""))
|
package/dist/lib.d.cts
CHANGED
|
@@ -26,6 +26,12 @@ interface FeatureDef {
|
|
|
26
26
|
deps: Record<string, string>;
|
|
27
27
|
/** The files this feature drops in, given the resolved {@link Ctx}. */
|
|
28
28
|
files: (ctx: Ctx) => Record<string, string>;
|
|
29
|
+
/**
|
|
30
|
+
* Files placed at the PROJECT ROOT — in static mode that's the app root; in
|
|
31
|
+
* dynamic mode it's the monorepo root (not `frontend/`). Use it for things a
|
|
32
|
+
* repo needs at the top, like a CI workflow.
|
|
33
|
+
*/
|
|
34
|
+
rootFiles?: (ctx: Ctx) => Record<string, string>;
|
|
29
35
|
/** Optional `.env.example` entries — `NAME: "explanatory comment"`. */
|
|
30
36
|
env?: Record<string, string>;
|
|
31
37
|
/** Optional `package.json` scripts, merged in. */
|
package/dist/lib.d.ts
CHANGED
|
@@ -26,6 +26,12 @@ interface FeatureDef {
|
|
|
26
26
|
deps: Record<string, string>;
|
|
27
27
|
/** The files this feature drops in, given the resolved {@link Ctx}. */
|
|
28
28
|
files: (ctx: Ctx) => Record<string, string>;
|
|
29
|
+
/**
|
|
30
|
+
* Files placed at the PROJECT ROOT — in static mode that's the app root; in
|
|
31
|
+
* dynamic mode it's the monorepo root (not `frontend/`). Use it for things a
|
|
32
|
+
* repo needs at the top, like a CI workflow.
|
|
33
|
+
*/
|
|
34
|
+
rootFiles?: (ctx: Ctx) => Record<string, string>;
|
|
29
35
|
/** Optional `.env.example` entries — `NAME: "explanatory comment"`. */
|
|
30
36
|
env?: Record<string, string>;
|
|
31
37
|
/** Optional `package.json` scripts, merged in. */
|
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,68 @@ 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"
|
|
2279
2346
|
}
|
|
2280
2347
|
];
|
|
2281
2348
|
var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
|
|
@@ -6260,6 +6327,291 @@ export default function EmailTestPage() {
|
|
|
6260
6327
|
);
|
|
6261
6328
|
}
|
|
6262
6329
|
`;
|
|
6330
|
+
var i18nLib = () => `import en from "@/locales/en.json";
|
|
6331
|
+
import ne from "@/locales/ne.json";
|
|
6332
|
+
|
|
6333
|
+
// how this works: a tiny, dependency-free translator. Locale files live in locales/,
|
|
6334
|
+
// keys are flat strings, and {vars} are interpolated. A missing key falls back to
|
|
6335
|
+
// English, then to the key itself. Lint the files with \`npm run i18n:check\`.
|
|
6336
|
+
export const locales = ["en", "ne"] as const;
|
|
6337
|
+
export type Locale = (typeof locales)[number];
|
|
6338
|
+
export const defaultLocale: Locale = "en";
|
|
6339
|
+
|
|
6340
|
+
const dicts: Record<Locale, Record<string, string>> = {
|
|
6341
|
+
en: en as Record<string, string>,
|
|
6342
|
+
ne: ne as Record<string, string>,
|
|
6343
|
+
};
|
|
6344
|
+
|
|
6345
|
+
export function translate(locale: Locale, key: string, vars?: Record<string, string | number>): string {
|
|
6346
|
+
const table = dicts[locale] ?? dicts[defaultLocale];
|
|
6347
|
+
let str = table[key] ?? dicts[defaultLocale][key] ?? key;
|
|
6348
|
+
if (vars) for (const [k, v] of Object.entries(vars)) str = str.split("{" + k + "}").join(String(v));
|
|
6349
|
+
return str;
|
|
6350
|
+
}
|
|
6351
|
+
`;
|
|
6352
|
+
var i18nProvider = () => `"use client";
|
|
6353
|
+
import { createContext, useContext, useEffect, useState } from "react";
|
|
6354
|
+
import { locales, defaultLocale, translate, type Locale } from "@/lib/i18n";
|
|
6355
|
+
|
|
6356
|
+
// how this works: holds the current locale (persisted in localStorage) and exposes
|
|
6357
|
+
// t() via the useT() hook. Wrap your app in <LanguageProvider> once (in layout.tsx).
|
|
6358
|
+
interface LocaleCtxValue { locale: Locale; setLocale: (l: Locale) => void; }
|
|
6359
|
+
const LocaleCtx = createContext<LocaleCtxValue>({ locale: defaultLocale, setLocale: () => {} });
|
|
6360
|
+
|
|
6361
|
+
export function LanguageProvider({ children }: { children: React.ReactNode }) {
|
|
6362
|
+
const [locale, setLocaleState] = useState<Locale>(defaultLocale);
|
|
6363
|
+
useEffect(() => {
|
|
6364
|
+
try {
|
|
6365
|
+
const saved = localStorage.getItem("locale");
|
|
6366
|
+
if (saved && (locales as readonly string[]).includes(saved)) setLocaleState(saved as Locale);
|
|
6367
|
+
} catch {}
|
|
6368
|
+
}, []);
|
|
6369
|
+
const setLocale = (l: Locale) => { setLocaleState(l); try { localStorage.setItem("locale", l); } catch {} };
|
|
6370
|
+
return <LocaleCtx.Provider value={{ locale, setLocale }}>{children}</LocaleCtx.Provider>;
|
|
6371
|
+
}
|
|
6372
|
+
|
|
6373
|
+
export function useT() {
|
|
6374
|
+
const { locale, setLocale } = useContext(LocaleCtx);
|
|
6375
|
+
return {
|
|
6376
|
+
t: (key: string, vars?: Record<string, string | number>) => translate(locale, key, vars),
|
|
6377
|
+
locale,
|
|
6378
|
+
setLocale,
|
|
6379
|
+
};
|
|
6380
|
+
}
|
|
6381
|
+
|
|
6382
|
+
export function LanguageSwitcher() {
|
|
6383
|
+
const { locale, setLocale } = useT();
|
|
6384
|
+
return (
|
|
6385
|
+
<div className="inline-flex gap-1 rounded-full border border-hairline p-1">
|
|
6386
|
+
{locales.map((l) => (
|
|
6387
|
+
<button
|
|
6388
|
+
key={l}
|
|
6389
|
+
onClick={() => setLocale(l)}
|
|
6390
|
+
className={"rounded-full px-3 py-1 text-sm transition " + (l === locale ? "gradient-bg on-accent" : "text-muted hover:text-fg")}
|
|
6391
|
+
>
|
|
6392
|
+
{l.toUpperCase()}
|
|
6393
|
+
</button>
|
|
6394
|
+
))}
|
|
6395
|
+
</div>
|
|
6396
|
+
);
|
|
6397
|
+
}
|
|
6398
|
+
`;
|
|
6399
|
+
var i18nDemoPage = () => `"use client";
|
|
6400
|
+
import { LanguageProvider, LanguageSwitcher, useT } from "@/components/language-provider";
|
|
6401
|
+
|
|
6402
|
+
function Demo() {
|
|
6403
|
+
const { t } = useT();
|
|
6404
|
+
return (
|
|
6405
|
+
<div className="space-y-4">
|
|
6406
|
+
<h1 className="text-3xl font-bold">{t("hello", { name: "world" })}</h1>
|
|
6407
|
+
<p className="text-muted">{t("tagline")}</p>
|
|
6408
|
+
<LanguageSwitcher />
|
|
6409
|
+
</div>
|
|
6410
|
+
);
|
|
6411
|
+
}
|
|
6412
|
+
|
|
6413
|
+
export default function I18nDemoPage() {
|
|
6414
|
+
return (
|
|
6415
|
+
<main className="mx-auto max-w-md px-6 py-16">
|
|
6416
|
+
<LanguageProvider>
|
|
6417
|
+
<Demo />
|
|
6418
|
+
</LanguageProvider>
|
|
6419
|
+
</main>
|
|
6420
|
+
);
|
|
6421
|
+
}
|
|
6422
|
+
`;
|
|
6423
|
+
var i18nEn = () => JSON.stringify(
|
|
6424
|
+
{ hello: "Hello, {name}!", tagline: "This text switches language instantly \u2014 no reload.", "nav.home": "Home", "nav.about": "About" },
|
|
6425
|
+
null,
|
|
6426
|
+
2
|
|
6427
|
+
) + "\n";
|
|
6428
|
+
var i18nNe = () => JSON.stringify(
|
|
6429
|
+
{ 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" },
|
|
6430
|
+
null,
|
|
6431
|
+
2
|
|
6432
|
+
) + "\n";
|
|
6433
|
+
var qualityCiWorkflow = (ctx) => {
|
|
6434
|
+
const sizePath = ctx.mode === "dynamic" ? "frontend/.next/static" : ".next/static";
|
|
6435
|
+
const depsGate = ctx.mode === "dynamic" ? "duplicates" : "missing,duplicates";
|
|
6436
|
+
return `name: CI
|
|
6437
|
+
on:
|
|
6438
|
+
push:
|
|
6439
|
+
branches: [main]
|
|
6440
|
+
pull_request:
|
|
6441
|
+
jobs:
|
|
6442
|
+
quality:
|
|
6443
|
+
runs-on: ubuntu-latest
|
|
6444
|
+
steps:
|
|
6445
|
+
- uses: actions/checkout@v4
|
|
6446
|
+
- uses: actions/setup-node@v4
|
|
6447
|
+
with:
|
|
6448
|
+
node-version: 20
|
|
6449
|
+
cache: npm
|
|
6450
|
+
- run: npm ci
|
|
6451
|
+
- run: npm run build
|
|
6452
|
+
- name: Bundle-size budget
|
|
6453
|
+
run: npx lacspace-size@0.2.0 ${sizePath} --metric gzip --max 500kb
|
|
6454
|
+
- name: Dependency audit
|
|
6455
|
+
run: npx lacspace-deps@0.2.0 . --fail-on ${depsGate}
|
|
6456
|
+
`;
|
|
6457
|
+
};
|
|
6458
|
+
var uploadModel = () => `import mongoose from "mongoose";
|
|
6459
|
+
import { uuidv7 } from "@lacspace/id";
|
|
6460
|
+
|
|
6461
|
+
// how this works: files are stored base64-in-Mongo so it works with NO S3/disk setup.
|
|
6462
|
+
// For large files or production, swap this for disk or object storage.
|
|
6463
|
+
export interface UploadDoc {
|
|
6464
|
+
_id: string;
|
|
6465
|
+
userId: string;
|
|
6466
|
+
filename: string;
|
|
6467
|
+
contentType: string;
|
|
6468
|
+
size: number;
|
|
6469
|
+
data: string; // base64
|
|
6470
|
+
createdAt: Date;
|
|
6471
|
+
}
|
|
6472
|
+
|
|
6473
|
+
const schema = new mongoose.Schema<UploadDoc>(
|
|
6474
|
+
{
|
|
6475
|
+
_id: { type: String, default: () => uuidv7() },
|
|
6476
|
+
userId: { type: String, required: true, index: true },
|
|
6477
|
+
filename: { type: String, required: true },
|
|
6478
|
+
contentType: { type: String, default: "application/octet-stream" },
|
|
6479
|
+
size: { type: Number, default: 0 },
|
|
6480
|
+
data: { type: String, required: true },
|
|
6481
|
+
},
|
|
6482
|
+
{ timestamps: { createdAt: true, updatedAt: false } },
|
|
6483
|
+
);
|
|
6484
|
+
|
|
6485
|
+
export const Upload =
|
|
6486
|
+
(mongoose.models.Upload as mongoose.Model<UploadDoc>) ?? mongoose.model<UploadDoc>("Upload", schema);
|
|
6487
|
+
`;
|
|
6488
|
+
var uploadsRoutes = () => `import express from "express";
|
|
6489
|
+
import { signUrl, verifyUrl } from "@lacspace/signed-url";
|
|
6490
|
+
import { v } from "@lacspace/validate";
|
|
6491
|
+
import { asyncHandler, HttpError } from "../http.js";
|
|
6492
|
+
import { requireAuth } from "../middleware/auth.js";
|
|
6493
|
+
import { env } from "../env.js";
|
|
6494
|
+
import { Upload } from "../models/upload.js";
|
|
6495
|
+
|
|
6496
|
+
// how this works: uploads live in Mongo; each file is served from a PUBLIC route
|
|
6497
|
+
// protected by a signed, expiring URL (@lacspace/signed-url) \u2014 so <img src> works
|
|
6498
|
+
// without a bearer token, yet the link can't be forged or shared forever.
|
|
6499
|
+
const router = express.Router();
|
|
6500
|
+
const SECRET = process.env.UPLOAD_URL_SECRET ?? env.JWT_SECRET;
|
|
6501
|
+
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB \u2014 base64-in-Mongo, keep it small
|
|
6502
|
+
|
|
6503
|
+
const UploadInput = v.object({ filename: v.string().min(1).max(200), dataUrl: v.string().min(1) });
|
|
6504
|
+
|
|
6505
|
+
// Sign a RELATIVE URL; the frontend prefixes it with the API base. Verified as-is.
|
|
6506
|
+
function signed(id: string): Promise<string> {
|
|
6507
|
+
return signUrl("/uploads/" + id + "/raw", { secret: SECRET, expiresIn: 3600 });
|
|
6508
|
+
}
|
|
6509
|
+
|
|
6510
|
+
// GET /uploads/:id/raw \u2014 PUBLIC, but the signed query params must verify.
|
|
6511
|
+
router.get("/:id/raw", asyncHandler(async (req, res) => {
|
|
6512
|
+
const check = await verifyUrl(req.originalUrl, { secret: SECRET, clockToleranceSec: 60 });
|
|
6513
|
+
if (!check.valid) throw new HttpError(check.reason === "expired" ? 410 : 403, "Link expired or invalid");
|
|
6514
|
+
const file = await Upload.findById(req.params.id);
|
|
6515
|
+
if (!file) throw new HttpError(404, "Not found");
|
|
6516
|
+
res.setHeader("Content-Type", file.contentType);
|
|
6517
|
+
res.send(Buffer.from(file.data, "base64"));
|
|
6518
|
+
}));
|
|
6519
|
+
|
|
6520
|
+
// POST /uploads \u2014 protected: store a base64 data URL.
|
|
6521
|
+
router.post("/", requireAuth, asyncHandler(async (req, res) => {
|
|
6522
|
+
const { filename, dataUrl } = UploadInput.parse(req.body);
|
|
6523
|
+
const m = /^data:(.+?);base64,(.*)$/.exec(dataUrl);
|
|
6524
|
+
if (!m) throw new HttpError(400, "Expected a base64 data URL");
|
|
6525
|
+
const contentType = m[1]!;
|
|
6526
|
+
const data = m[2]!;
|
|
6527
|
+
const size = Math.floor((data.length * 3) / 4);
|
|
6528
|
+
if (size > MAX_BYTES) throw new HttpError(413, "File too large (max 5 MB)");
|
|
6529
|
+
const file = await Upload.create({ userId: req.user!.sub, filename, contentType, size, data });
|
|
6530
|
+
res.status(201).json({ id: String(file._id), filename, contentType, size, url: await signed(String(file._id)) });
|
|
6531
|
+
}));
|
|
6532
|
+
|
|
6533
|
+
// GET /uploads \u2014 protected: this user's files (with fresh signed URLs).
|
|
6534
|
+
router.get("/", requireAuth, asyncHandler(async (req, res) => {
|
|
6535
|
+
const files = await Upload.find({ userId: req.user!.sub }).sort({ createdAt: -1 }).select("filename contentType size");
|
|
6536
|
+
const out = await Promise.all(
|
|
6537
|
+
files.map(async (f) => ({ id: String(f._id), filename: f.filename, contentType: f.contentType, size: f.size, url: await signed(String(f._id)) })),
|
|
6538
|
+
);
|
|
6539
|
+
res.json(out);
|
|
6540
|
+
}));
|
|
6541
|
+
|
|
6542
|
+
export default router;
|
|
6543
|
+
`;
|
|
6544
|
+
var uploadsPage = () => `"use client";
|
|
6545
|
+
import { useEffect, useState } from "react";
|
|
6546
|
+
import { useRouter } from "next/navigation";
|
|
6547
|
+
import { getToken } from "@/lib/api";
|
|
6548
|
+
|
|
6549
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
6550
|
+
interface FileItem { id: string; filename: string; contentType: string; size: number; url: string; }
|
|
6551
|
+
|
|
6552
|
+
function readAsDataUrl(file: File): Promise<string> {
|
|
6553
|
+
return new Promise((resolve, reject) => {
|
|
6554
|
+
const r = new FileReader();
|
|
6555
|
+
r.onload = () => resolve(String(r.result));
|
|
6556
|
+
r.onerror = () => reject(new Error("read failed"));
|
|
6557
|
+
r.readAsDataURL(file);
|
|
6558
|
+
});
|
|
6559
|
+
}
|
|
6560
|
+
|
|
6561
|
+
export default function UploadsPage() {
|
|
6562
|
+
const router = useRouter();
|
|
6563
|
+
const [files, setFiles] = useState<FileItem[]>([]);
|
|
6564
|
+
const [busy, setBusy] = useState(false);
|
|
6565
|
+
const [error, setError] = useState<string | null>(null);
|
|
6566
|
+
|
|
6567
|
+
async function load() {
|
|
6568
|
+
const token = getToken();
|
|
6569
|
+
if (!token) { router.push("/login"); return; }
|
|
6570
|
+
const res = await fetch(API + "/uploads", { headers: { Authorization: "Bearer " + token } });
|
|
6571
|
+
if (res.ok) setFiles(await res.json());
|
|
6572
|
+
}
|
|
6573
|
+
useEffect(() => { void load(); }, []);
|
|
6574
|
+
|
|
6575
|
+
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
|
6576
|
+
const file = e.target.files?.[0];
|
|
6577
|
+
if (!file) return;
|
|
6578
|
+
setBusy(true); setError(null);
|
|
6579
|
+
try {
|
|
6580
|
+
const dataUrl = await readAsDataUrl(file);
|
|
6581
|
+
const token = getToken();
|
|
6582
|
+
const res = await fetch(API + "/uploads", {
|
|
6583
|
+
method: "POST",
|
|
6584
|
+
headers: { "Content-Type": "application/json", ...(token ? { Authorization: "Bearer " + token } : {}) },
|
|
6585
|
+
body: JSON.stringify({ filename: file.name, dataUrl }),
|
|
6586
|
+
});
|
|
6587
|
+
const data: unknown = await res.json().catch(() => ({}));
|
|
6588
|
+
if (!res.ok) throw new Error((data as { error?: string }).error ?? "Upload failed");
|
|
6589
|
+
await load();
|
|
6590
|
+
} catch (err) { setError(err instanceof Error ? err.message : "Upload failed"); }
|
|
6591
|
+
finally { setBusy(false); }
|
|
6592
|
+
}
|
|
6593
|
+
|
|
6594
|
+
return (
|
|
6595
|
+
<main className="mx-auto max-w-lg px-6 py-16">
|
|
6596
|
+
<h1 className="text-2xl font-bold">Uploads</h1>
|
|
6597
|
+
<p className="mt-1 text-muted">Stored in MongoDB, served via signed, expiring links.</p>
|
|
6598
|
+
<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">
|
|
6599
|
+
<input type="file" className="hidden" onChange={onPick} disabled={busy} />
|
|
6600
|
+
{busy ? "Uploading\u2026" : "Choose a file (max 5 MB)"}
|
|
6601
|
+
</label>
|
|
6602
|
+
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
|
|
6603
|
+
<ul className="mt-6 space-y-2">
|
|
6604
|
+
{files.map((f) => (
|
|
6605
|
+
<li key={f.id} className="flex items-center justify-between rounded-xl border border-hairline p-3">
|
|
6606
|
+
<a href={API + f.url} target="_blank" rel="noreferrer" className="truncate underline">{f.filename}</a>
|
|
6607
|
+
<span className="ml-3 shrink-0 text-sm text-muted">{Math.max(1, Math.round(f.size / 1024))} KB</span>
|
|
6608
|
+
</li>
|
|
6609
|
+
))}
|
|
6610
|
+
</ul>
|
|
6611
|
+
</main>
|
|
6612
|
+
);
|
|
6613
|
+
}
|
|
6614
|
+
`;
|
|
6263
6615
|
var scope = (ctx) => `@${ctx.name}`;
|
|
6264
6616
|
var typesPkgJson = (ctx) => JSON.stringify({
|
|
6265
6617
|
name: `${scope(ctx)}/types`,
|
|
@@ -6659,6 +7011,9 @@ function buildFullStack(ctx) {
|
|
|
6659
7011
|
out[".gitignore"] = rootGitignore();
|
|
6660
7012
|
out[".env.example"] = rootEnvExample(ctx);
|
|
6661
7013
|
out["README.md"] = rootReadme(ctx);
|
|
7014
|
+
for (const f of ctx.features) {
|
|
7015
|
+
for (const [rel, content] of Object.entries(f.rootFiles?.(ctx) ?? {})) out[rel] = content;
|
|
7016
|
+
}
|
|
6662
7017
|
const envBase = out[".env.example"];
|
|
6663
7018
|
const seen = new Set(
|
|
6664
7019
|
(envBase.match(/^#?\s*([A-Z0-9_]+)=/gm) ?? []).map((l) => l.replace(/^#?\s*/, "").replace(/=.*/, ""))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-lacspace-app",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0",
|
|
4
4
|
"description": "Scaffold a beautiful, production-ready Next.js app from a Lacspace template — portfolio, business, e-commerce, SaaS, blog, docs, dashboard, restaurant or marketplace — 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": {
|