@camox/cli 0.15.1 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +79 -18
- package/package.json +8 -9
- package/{template → templates/default}/components.json +4 -1
- package/{template → templates/default}/package.json +4 -2
- package/{template → templates/default}/src/camox/blocks/footer.tsx +11 -12
- package/templates/default/src/camox/blocks/hero.tsx +98 -0
- package/{template → templates/default}/src/camox/blocks/navbar.tsx +1 -1
- package/templates/default/src/camox/blocks/statistics.tsx +95 -0
- package/templates/default/src/camox/blocks/youtube-video.tsx +158 -0
- package/{template → templates/default}/src/camox/layouts/default.tsx +2 -1
- package/templates/default/src/lib/utils.ts +6 -0
- package/templates/default/src/styles.css +152 -0
- package/{template → templates/default}/vite.config.ts +10 -1
- package/template/node_modules/.bin/camox +0 -21
- package/template/node_modules/.bin/intent +0 -21
- package/template/node_modules/.bin/nitro +0 -21
- package/template/node_modules/.bin/oxfmt +0 -21
- package/template/node_modules/.bin/oxlint +0 -21
- package/template/node_modules/.bin/shadcn +0 -21
- package/template/node_modules/.bin/tsgo +0 -21
- package/template/node_modules/.bin/vp +0 -21
- package/template/src/camox/blocks/hero.tsx +0 -55
- package/template/src/camox/blocks/statistics.tsx +0 -98
- package/template/src/lib/utils.ts +0 -7
- package/template/src/styles.css +0 -136
- /package/{template → templates/default}/.env.example +0 -0
- /package/{template → templates/default}/src/camox/blocks/faq.tsx +0 -0
- /package/{template → templates/default}/src/camox/blocks/testimonial.tsx +0 -0
- /package/{template → templates/default}/src/client.tsx +0 -0
- /package/{template → templates/default}/src/components/ui/accordion.tsx +0 -0
- /package/{template → templates/default}/src/components/ui/button.tsx +0 -0
- /package/{template → templates/default}/src/router.tsx +0 -0
- /package/{template → templates/default}/src/routes/__root.tsx +0 -0
- /package/{template → templates/default}/src/routes/sitemap[.]xml.ts +0 -0
- /package/{template → templates/default}/tsconfig.json +0 -0
package/dist/index.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { defineProgram } from "@optique/core/program";
|
|
|
6
6
|
import { runSync } from "@optique/run";
|
|
7
7
|
import { multiple, optional } from "@optique/core/modifiers";
|
|
8
8
|
import { command, constant, option } from "@optique/core/primitives";
|
|
9
|
-
import { integer, string } from "@optique/core/valueparser";
|
|
9
|
+
import { choice, integer, string } from "@optique/core/valueparser";
|
|
10
10
|
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
11
11
|
import fs from "node:fs";
|
|
12
12
|
import http from "node:http";
|
|
@@ -382,7 +382,11 @@ const parser$6 = command("blocks", or(command("types", object({
|
|
|
382
382
|
type: option("--type", string({ metavar: "TYPE" })),
|
|
383
383
|
content: option("--content", string({ metavar: "JSON" })),
|
|
384
384
|
settings: optional(option("--settings", string({ metavar: "JSON" }))),
|
|
385
|
+
position: optional(option("--position", choice(["first", "last"], { metavar: "WHERE" }))),
|
|
386
|
+
afterId: optional(option("--after-id", integer({ metavar: "ID" }))),
|
|
387
|
+
beforeId: optional(option("--before-id", integer({ metavar: "ID" }))),
|
|
385
388
|
afterPosition: optional(option("--after-position", string({ metavar: "POS" }))),
|
|
389
|
+
beforePosition: optional(option("--before-position", string({ metavar: "POS" }))),
|
|
386
390
|
project: projectFlag$2,
|
|
387
391
|
production: productionFlag$2,
|
|
388
392
|
json: jsonFlag$2
|
|
@@ -397,7 +401,11 @@ const parser$6 = command("blocks", or(command("types", object({
|
|
|
397
401
|
})), command("move", object({
|
|
398
402
|
command: constant("blocks.move"),
|
|
399
403
|
id: option("--id", integer({ metavar: "ID" })),
|
|
404
|
+
position: optional(option("--position", choice(["first", "last"], { metavar: "WHERE" }))),
|
|
405
|
+
afterId: optional(option("--after-id", integer({ metavar: "ID" }))),
|
|
406
|
+
beforeId: optional(option("--before-id", integer({ metavar: "ID" }))),
|
|
400
407
|
afterPosition: optional(option("--after-position", string({ metavar: "POS" }))),
|
|
408
|
+
beforePosition: optional(option("--before-position", string({ metavar: "POS" }))),
|
|
401
409
|
project: projectFlag$2,
|
|
402
410
|
production: productionFlag$2,
|
|
403
411
|
json: jsonFlag$2
|
|
@@ -408,6 +416,32 @@ const parser$6 = command("blocks", or(command("types", object({
|
|
|
408
416
|
production: productionFlag$2,
|
|
409
417
|
json: jsonFlag$2
|
|
410
418
|
}))));
|
|
419
|
+
const POSITIONING_FLAGS = [
|
|
420
|
+
["position", "--position"],
|
|
421
|
+
["afterId", "--after-id"],
|
|
422
|
+
["beforeId", "--before-id"],
|
|
423
|
+
["afterPosition", "--after-position"],
|
|
424
|
+
["beforePosition", "--before-position"]
|
|
425
|
+
];
|
|
426
|
+
/**
|
|
427
|
+
* Both `create` and `move` accept multiple positioning inputs, but only one is
|
|
428
|
+
* meaningful per call. The server will reject conflicts, but we surface the
|
|
429
|
+
* mistake earlier (and with friendlier wording) by checking on the client.
|
|
430
|
+
*/
|
|
431
|
+
function collectPositioningArgs(args) {
|
|
432
|
+
const used = [];
|
|
433
|
+
const toolArgs = {};
|
|
434
|
+
for (const [key, flag] of POSITIONING_FLAGS) {
|
|
435
|
+
const value = args[key];
|
|
436
|
+
if (value === void 0) continue;
|
|
437
|
+
used.push(flag);
|
|
438
|
+
Object.assign(toolArgs, { [key]: value });
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
used,
|
|
442
|
+
toolArgs
|
|
443
|
+
};
|
|
444
|
+
}
|
|
411
445
|
async function handler$6(args) {
|
|
412
446
|
const outputMode = args.json ? "json" : "auto";
|
|
413
447
|
const projectFlag = args.project;
|
|
@@ -431,6 +465,14 @@ async function handler$6(args) {
|
|
|
431
465
|
case "blocks.create": {
|
|
432
466
|
const content = parseJsonFlag("--content", args.content);
|
|
433
467
|
const settings = args.settings !== void 0 ? parseJsonFlag("--settings", args.settings) : void 0;
|
|
468
|
+
const { used, toolArgs } = collectPositioningArgs(args);
|
|
469
|
+
if (used.length > 1) {
|
|
470
|
+
printError({
|
|
471
|
+
code: "INVALID_ARGS",
|
|
472
|
+
message: `Pass at most one positioning flag — got: ${used.join(", ")}.`
|
|
473
|
+
});
|
|
474
|
+
process.exit(2);
|
|
475
|
+
}
|
|
434
476
|
return dispatch({
|
|
435
477
|
toolName: "createBlock",
|
|
436
478
|
args: {
|
|
@@ -438,7 +480,7 @@ async function handler$6(args) {
|
|
|
438
480
|
type: args.type,
|
|
439
481
|
content,
|
|
440
482
|
settings,
|
|
441
|
-
|
|
483
|
+
...toolArgs
|
|
442
484
|
},
|
|
443
485
|
projectFlag,
|
|
444
486
|
production,
|
|
@@ -460,16 +502,33 @@ async function handler$6(args) {
|
|
|
460
502
|
outputMode
|
|
461
503
|
});
|
|
462
504
|
}
|
|
463
|
-
case "blocks.move":
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
505
|
+
case "blocks.move": {
|
|
506
|
+
const { used, toolArgs } = collectPositioningArgs(args);
|
|
507
|
+
if (used.length === 0) {
|
|
508
|
+
printError({
|
|
509
|
+
code: "INVALID_ARGS",
|
|
510
|
+
message: "Pass a positioning flag — one of --position, --after-id, --before-id, --after-position, --before-position. Use `--position last` to move a block to the end of the page."
|
|
511
|
+
});
|
|
512
|
+
process.exit(2);
|
|
513
|
+
}
|
|
514
|
+
if (used.length > 1) {
|
|
515
|
+
printError({
|
|
516
|
+
code: "INVALID_ARGS",
|
|
517
|
+
message: `Pass at most one positioning flag — got: ${used.join(", ")}.`
|
|
518
|
+
});
|
|
519
|
+
process.exit(2);
|
|
520
|
+
}
|
|
521
|
+
return dispatch({
|
|
522
|
+
toolName: "moveBlock",
|
|
523
|
+
args: {
|
|
524
|
+
id: args.id,
|
|
525
|
+
...toolArgs
|
|
526
|
+
},
|
|
527
|
+
projectFlag,
|
|
528
|
+
production,
|
|
529
|
+
outputMode
|
|
530
|
+
});
|
|
531
|
+
}
|
|
473
532
|
case "blocks.delete": return dispatch({
|
|
474
533
|
toolName: "deleteBlock",
|
|
475
534
|
args: { id: args.id },
|
|
@@ -508,7 +567,7 @@ function copyDir(src, dest, replacements) {
|
|
|
508
567
|
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
509
568
|
const srcPath = path.join(src, entry.name);
|
|
510
569
|
const destPath = path.join(dest, entry.name);
|
|
511
|
-
if (
|
|
570
|
+
if (fs.statSync(srcPath).isDirectory()) {
|
|
512
571
|
copyDir(srcPath, destPath, replacements);
|
|
513
572
|
continue;
|
|
514
573
|
}
|
|
@@ -653,10 +712,12 @@ async function init() {
|
|
|
653
712
|
const pm = selected;
|
|
654
713
|
const s = p.spinner();
|
|
655
714
|
s.start("Scaffolding project...");
|
|
656
|
-
copyDir(path.resolve(__dirname, "..", "
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
});
|
|
715
|
+
copyDir(path.resolve(__dirname, "..", "templates", "default"), targetDir, { "{{projectName}}": name });
|
|
716
|
+
const viteConfigPath = path.join(targetDir, "vite.config.ts");
|
|
717
|
+
let viteConfig = fs.readFileSync(viteConfigPath, "utf-8");
|
|
718
|
+
viteConfig = viteConfig.replace(/"[^"]*"(,?)[ \t]*\/\/[ \t]*camox-cli:replace-slug.*$/gm, `"${project.slug}"$1`);
|
|
719
|
+
viteConfig = viteConfig.replace(/^[ \t]*\/\/[ \t]*camox-cli:dev-only-start[ \t]*\r?\n[\s\S]*?^[ \t]*\/\/[ \t]*camox-cli:dev-only-end[ \t]*\r?\n/gm, "");
|
|
720
|
+
fs.writeFileSync(viteConfigPath, viteConfig);
|
|
660
721
|
const pkgPath = path.join(targetDir, "package.json");
|
|
661
722
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
662
723
|
pkg.name = project.slug;
|
|
@@ -963,7 +1024,7 @@ switch (result.command) {
|
|
|
963
1024
|
await handler$3();
|
|
964
1025
|
break;
|
|
965
1026
|
case "logout":
|
|
966
|
-
|
|
1027
|
+
handler$2();
|
|
967
1028
|
break;
|
|
968
1029
|
case "status":
|
|
969
1030
|
await handler(result);
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camox/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"bin": {
|
|
5
5
|
"camox": "./dist/index.mjs"
|
|
6
6
|
},
|
|
7
7
|
"files": [
|
|
8
8
|
"dist",
|
|
9
|
-
"
|
|
9
|
+
"templates"
|
|
10
10
|
],
|
|
11
11
|
"type": "module",
|
|
12
12
|
"main": "./dist/index.mjs",
|
|
@@ -25,9 +25,8 @@
|
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/node": "^24.12.2",
|
|
27
27
|
"@typescript/native-preview": "7.0.0-dev.20260412.1",
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"@camox/api-contract": "0.15.1"
|
|
28
|
+
"vite-plus": "latest",
|
|
29
|
+
"@camox/api-contract": "0.16.1"
|
|
31
30
|
},
|
|
32
31
|
"nx": {
|
|
33
32
|
"tags": [
|
|
@@ -35,9 +34,9 @@
|
|
|
35
34
|
]
|
|
36
35
|
},
|
|
37
36
|
"scripts": {
|
|
38
|
-
"build": "
|
|
39
|
-
"dev": "
|
|
40
|
-
"lint": "
|
|
41
|
-
"check": "tsgo --noEmit &&
|
|
37
|
+
"build": "node scripts/sync-templates.mjs && vp pack",
|
|
38
|
+
"dev": "node scripts/sync-templates.mjs --watch --build",
|
|
39
|
+
"lint": "vp lint",
|
|
40
|
+
"check": "tsgo --noEmit && vp lint --fix"
|
|
42
41
|
}
|
|
43
42
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"tailwind": {
|
|
7
7
|
"config": "",
|
|
8
8
|
"css": "src/styles.css",
|
|
9
|
-
"baseColor": "
|
|
9
|
+
"baseColor": "zinc",
|
|
10
10
|
"cssVariables": true,
|
|
11
11
|
"prefix": ""
|
|
12
12
|
},
|
|
@@ -18,5 +18,8 @@
|
|
|
18
18
|
"lib": "@/lib",
|
|
19
19
|
"hooks": "@/hooks"
|
|
20
20
|
},
|
|
21
|
+
"rtl": false,
|
|
22
|
+
"menuColor": "default",
|
|
23
|
+
"menuAccent": "subtle",
|
|
21
24
|
"registries": {}
|
|
22
25
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "@camox/template",
|
|
2
|
+
"name": "@camox/template-default",
|
|
3
3
|
"version": "0.0.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@base-ui/react": "^1.4.0",
|
|
16
|
+
"@fontsource-variable/inter": "^5.2.8",
|
|
17
|
+
"@fontsource-variable/noto-serif": "^5.2.9",
|
|
16
18
|
"@tailwindcss/vite": "^4.2.2",
|
|
17
19
|
"@tanstack/react-query": "^5.99.0",
|
|
18
20
|
"@tanstack/react-router": "^1.168.18",
|
|
@@ -26,7 +28,7 @@
|
|
|
26
28
|
"nitro": "3.0.260311-beta",
|
|
27
29
|
"react": "^19.2.5",
|
|
28
30
|
"react-dom": "^19.2.5",
|
|
29
|
-
"shadcn": "^4.
|
|
31
|
+
"shadcn": "^4.6.0",
|
|
30
32
|
"tailwind-merge": "^3.5.0",
|
|
31
33
|
"tailwindcss": "^4.0.6"
|
|
32
34
|
},
|
|
@@ -11,11 +11,11 @@ const footer = createBlock({
|
|
|
11
11
|
links: Type.RepeatableItem({
|
|
12
12
|
content: {
|
|
13
13
|
link: Type.Link({
|
|
14
|
-
default: { text: "
|
|
14
|
+
default: { text: "Footer link", href: "#", newTab: false },
|
|
15
15
|
title: "Link",
|
|
16
16
|
}),
|
|
17
17
|
},
|
|
18
|
-
minItems:
|
|
18
|
+
minItems: 2,
|
|
19
19
|
maxItems: 12,
|
|
20
20
|
title: "Links",
|
|
21
21
|
toMarkdown: (c) => [c.link],
|
|
@@ -27,14 +27,17 @@ const footer = createBlock({
|
|
|
27
27
|
|
|
28
28
|
function FooterComponent() {
|
|
29
29
|
return (
|
|
30
|
-
<footer className="dark bg-background py-
|
|
30
|
+
<footer className="dark bg-background py-4">
|
|
31
31
|
<div className="container mx-auto px-4">
|
|
32
|
-
<div className="flex flex-
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2">
|
|
33
|
+
<div className="flex items-center gap-2">
|
|
34
|
+
<footer.Field name="title">
|
|
35
|
+
{(props) => <div {...props} className="text-foreground text-sm font-bold" />}
|
|
36
|
+
</footer.Field>
|
|
37
|
+
<div className="text-muted-foreground text-sm">© {new Date().getFullYear()}</div>
|
|
38
|
+
</div>
|
|
36
39
|
|
|
37
|
-
<div className="flex flex-wrap items-center gap-4">
|
|
40
|
+
<div className="ml-auto flex flex-wrap items-center justify-end gap-4">
|
|
38
41
|
<footer.Repeater name="links">
|
|
39
42
|
{(linkItem) => (
|
|
40
43
|
<linkItem.Link name="link">
|
|
@@ -49,10 +52,6 @@ function FooterComponent() {
|
|
|
49
52
|
</footer.Repeater>
|
|
50
53
|
</div>
|
|
51
54
|
</div>
|
|
52
|
-
|
|
53
|
-
<div className="text-muted-foreground mt-8 text-center text-sm">
|
|
54
|
-
© {new Date().getFullYear()} All rights reserved.
|
|
55
|
-
</div>
|
|
56
55
|
</div>
|
|
57
56
|
</footer>
|
|
58
57
|
);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Link } from "@tanstack/react-router";
|
|
2
|
+
import { Type, createBlock } from "camox/createBlock";
|
|
3
|
+
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
|
|
6
|
+
const hero = createBlock({
|
|
7
|
+
id: "hero",
|
|
8
|
+
title: "Hero",
|
|
9
|
+
description:
|
|
10
|
+
"Use this block as the main landing section at the top of a page. It should capture attention immediately with a clear value proposition.",
|
|
11
|
+
content: {
|
|
12
|
+
title: Type.String({
|
|
13
|
+
default: "Let's get going on {{projectName}}",
|
|
14
|
+
title: "Title",
|
|
15
|
+
}),
|
|
16
|
+
description: Type.String({
|
|
17
|
+
default: "Build something amazing with Camox. Press ⌘+Enter to start editing content.",
|
|
18
|
+
maxLength: 280,
|
|
19
|
+
title: "Description",
|
|
20
|
+
}),
|
|
21
|
+
cta: Type.Link({
|
|
22
|
+
default: { text: "Get Started", href: "/", newTab: false },
|
|
23
|
+
title: "CTA",
|
|
24
|
+
}),
|
|
25
|
+
illustration: Type.Image({
|
|
26
|
+
title: "Illustration",
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
settings: {
|
|
30
|
+
withIllustration: Type.Boolean({
|
|
31
|
+
default: true,
|
|
32
|
+
title: "With illustration",
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
component: HeroComponent,
|
|
36
|
+
toMarkdown: (c, s) => [`# ${c.title}`, c.description, s.withIllustration(c.illustration), c.cta],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function HeroComponent() {
|
|
40
|
+
const withIllustration = hero.useSetting("withIllustration");
|
|
41
|
+
|
|
42
|
+
if (withIllustration) {
|
|
43
|
+
return (
|
|
44
|
+
<section className="py-32">
|
|
45
|
+
<div className="container mx-auto px-4">
|
|
46
|
+
<div className="grid items-center gap-12 lg:grid-cols-[1fr_auto]">
|
|
47
|
+
<div className="text-left">
|
|
48
|
+
<hero.Field name="title">
|
|
49
|
+
{(props) => (
|
|
50
|
+
<h1
|
|
51
|
+
{...props}
|
|
52
|
+
className="text-foreground mb-6 text-5xl font-bold tracking-tight sm:text-6xl"
|
|
53
|
+
/>
|
|
54
|
+
)}
|
|
55
|
+
</hero.Field>
|
|
56
|
+
<hero.Field name="description">
|
|
57
|
+
{(props) => <p {...props} className="text-muted-foreground mb-10 text-xl" />}
|
|
58
|
+
</hero.Field>
|
|
59
|
+
<hero.Link name="cta">
|
|
60
|
+
{(props) => <Button size="lg" nativeButton={false} render={<Link {...props} />} />}
|
|
61
|
+
</hero.Link>
|
|
62
|
+
</div>
|
|
63
|
+
<hero.Image name="illustration">
|
|
64
|
+
{(props) => (
|
|
65
|
+
<img {...props} className="h-auto w-full max-w-sm rounded-lg lg:max-w-md" />
|
|
66
|
+
)}
|
|
67
|
+
</hero.Image>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</section>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<section className="flex flex-col items-center justify-center py-32">
|
|
76
|
+
<div className="container mx-auto px-4">
|
|
77
|
+
<div className="mx-auto max-w-3xl text-center">
|
|
78
|
+
<hero.Field name="title">
|
|
79
|
+
{(props) => (
|
|
80
|
+
<h1
|
|
81
|
+
{...props}
|
|
82
|
+
className="text-foreground mb-6 text-5xl font-bold tracking-tight sm:text-6xl"
|
|
83
|
+
/>
|
|
84
|
+
)}
|
|
85
|
+
</hero.Field>
|
|
86
|
+
<hero.Field name="description">
|
|
87
|
+
{(props) => <p {...props} className="text-muted-foreground mb-10 text-xl" />}
|
|
88
|
+
</hero.Field>
|
|
89
|
+
<hero.Link name="cta">
|
|
90
|
+
{(props) => <Button size="lg" nativeButton={false} render={<Link {...props} />} />}
|
|
91
|
+
</hero.Link>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</section>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export { hero as block };
|
|
@@ -45,7 +45,7 @@ function NavbarComponent() {
|
|
|
45
45
|
<div className="container mx-auto px-4">
|
|
46
46
|
<div className="flex h-16 items-center justify-between">
|
|
47
47
|
<navbar.Link name="title">
|
|
48
|
-
{(props) => <Link {...props} className="text-foreground text-
|
|
48
|
+
{(props) => <Link {...props} className="text-foreground text-lg font-bold" />}
|
|
49
49
|
</navbar.Link>
|
|
50
50
|
|
|
51
51
|
<div className="flex items-center gap-6">
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Type, createBlock } from "camox/createBlock";
|
|
2
|
+
|
|
3
|
+
const statistics = createBlock({
|
|
4
|
+
id: "statistics",
|
|
5
|
+
title: "Statistics",
|
|
6
|
+
description:
|
|
7
|
+
"Showcase key metrics, achievements, or performance indicators. Ideal for displaying platform statistics or company milestones.",
|
|
8
|
+
content: {
|
|
9
|
+
title: Type.String({
|
|
10
|
+
default: "By the numbers",
|
|
11
|
+
maxLength: 30,
|
|
12
|
+
title: "Title",
|
|
13
|
+
}),
|
|
14
|
+
subtitle: Type.String({
|
|
15
|
+
default: "Trusted by teams worldwide",
|
|
16
|
+
title: "Subtitle",
|
|
17
|
+
}),
|
|
18
|
+
description: Type.String({
|
|
19
|
+
default:
|
|
20
|
+
"Our platform empowers teams to build and ship faster. Here are some numbers we're proud of.",
|
|
21
|
+
title: "Description",
|
|
22
|
+
}),
|
|
23
|
+
statistics: Type.RepeatableItem({
|
|
24
|
+
content: {
|
|
25
|
+
number: Type.String({
|
|
26
|
+
default: "100+",
|
|
27
|
+
maxLength: 7,
|
|
28
|
+
title: "Number",
|
|
29
|
+
}),
|
|
30
|
+
label: Type.String({
|
|
31
|
+
default: "projects launched",
|
|
32
|
+
title: "Label",
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
minItems: 3,
|
|
36
|
+
maxItems: 8,
|
|
37
|
+
title: "Statistics",
|
|
38
|
+
toMarkdown: (c) => [`**${c.number}** — ${c.label}`],
|
|
39
|
+
}),
|
|
40
|
+
},
|
|
41
|
+
component: StatisticsComponent,
|
|
42
|
+
toMarkdown: (c) => [`## ${c.subtitle}`, c.description, c.statistics],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function StatisticsComponent() {
|
|
46
|
+
return (
|
|
47
|
+
<section className="dark bg-background py-24">
|
|
48
|
+
<div className="container mx-auto">
|
|
49
|
+
<div className="mb-16">
|
|
50
|
+
<statistics.Field name="title">
|
|
51
|
+
{(props) => (
|
|
52
|
+
<div
|
|
53
|
+
{...props}
|
|
54
|
+
className="text-accent-foreground mb-4 text-sm font-semibold tracking-wider uppercase"
|
|
55
|
+
/>
|
|
56
|
+
)}
|
|
57
|
+
</statistics.Field>
|
|
58
|
+
<statistics.Field name="subtitle">
|
|
59
|
+
{(props) => (
|
|
60
|
+
<h2 {...props} className="text-foreground mb-6 text-4xl font-bold sm:text-5xl" />
|
|
61
|
+
)}
|
|
62
|
+
</statistics.Field>
|
|
63
|
+
<statistics.Field name="description">
|
|
64
|
+
{(props) => (
|
|
65
|
+
<p {...props} className="text-muted-foreground max-w-3xl text-lg leading-relaxed" />
|
|
66
|
+
)}
|
|
67
|
+
</statistics.Field>
|
|
68
|
+
</div>
|
|
69
|
+
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-4">
|
|
70
|
+
<statistics.Repeater name="statistics">
|
|
71
|
+
{(stat) => (
|
|
72
|
+
<div className="flex gap-3">
|
|
73
|
+
<div className="w-0.5 bg-linear-to-b from-teal-400 to-emerald-500" />
|
|
74
|
+
<div className="flex flex-col">
|
|
75
|
+
<stat.Field name="number">
|
|
76
|
+
{(props) => (
|
|
77
|
+
<div {...props} className="text-foreground mb-2 text-4xl font-bold" />
|
|
78
|
+
)}
|
|
79
|
+
</stat.Field>
|
|
80
|
+
<stat.Field name="label">
|
|
81
|
+
{(props) => (
|
|
82
|
+
<p {...props} className="text-muted-foreground text-sm leading-relaxed" />
|
|
83
|
+
)}
|
|
84
|
+
</stat.Field>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
</statistics.Repeater>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</section>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export { statistics as block };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Type, createBlock } from "camox/createBlock";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
const youtubeVideo = createBlock({
|
|
6
|
+
id: "youtube-video",
|
|
7
|
+
title: "YouTube Video",
|
|
8
|
+
description:
|
|
9
|
+
"Embeds a YouTube video. Use this block to display a single YouTube video on a page. Don't try to guess the URL, use a web search tool to find a specific video URL instead.",
|
|
10
|
+
content: {
|
|
11
|
+
url: Type.Embed({
|
|
12
|
+
pattern:
|
|
13
|
+
"https:\\/\\/(www\\.)?(youtube\\.com\\/(watch\\?v=|embed\\/|shorts\\/)|youtu\\.be\\/).+",
|
|
14
|
+
default: "https://www.youtube.com/watch?v=-W_nFlIAWFM",
|
|
15
|
+
title: "YouTube URL",
|
|
16
|
+
}),
|
|
17
|
+
},
|
|
18
|
+
settings: {
|
|
19
|
+
fullWidth: Type.Boolean({
|
|
20
|
+
default: false,
|
|
21
|
+
title: "Full Width",
|
|
22
|
+
}),
|
|
23
|
+
theme: Type.Enum({
|
|
24
|
+
options: {
|
|
25
|
+
light: "Light",
|
|
26
|
+
dark: "Dark",
|
|
27
|
+
},
|
|
28
|
+
default: "light",
|
|
29
|
+
title: "Theme",
|
|
30
|
+
}),
|
|
31
|
+
autoplay: Type.Boolean({
|
|
32
|
+
default: false,
|
|
33
|
+
title: "Autoplay",
|
|
34
|
+
}),
|
|
35
|
+
mute: Type.Boolean({
|
|
36
|
+
default: false,
|
|
37
|
+
title: "Mute",
|
|
38
|
+
}),
|
|
39
|
+
controls: Type.Boolean({
|
|
40
|
+
default: true,
|
|
41
|
+
title: "Controls",
|
|
42
|
+
}),
|
|
43
|
+
showCaptions: Type.Boolean({
|
|
44
|
+
default: false,
|
|
45
|
+
title: "Show Captions",
|
|
46
|
+
}),
|
|
47
|
+
rel: Type.Boolean({
|
|
48
|
+
default: false,
|
|
49
|
+
title: "Related Videos",
|
|
50
|
+
}),
|
|
51
|
+
fullscreen: Type.Boolean({
|
|
52
|
+
default: true,
|
|
53
|
+
title: "Fullscreen",
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
component: YouTubeVideoComponent,
|
|
57
|
+
toMarkdown: (c) => [c.url],
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function extractVideoId(url: string): string | null {
|
|
61
|
+
const shortMatch = url.match(/youtu\.be\/([^?&]+)/);
|
|
62
|
+
if (shortMatch) return shortMatch[1];
|
|
63
|
+
|
|
64
|
+
const shortsMatch = url.match(/youtube\.com\/shorts\/([^?&]+)/);
|
|
65
|
+
if (shortsMatch) return shortsMatch[1];
|
|
66
|
+
|
|
67
|
+
const watchMatch = url.match(/[?&]v=([^&]+)/);
|
|
68
|
+
if (watchMatch) return watchMatch[1];
|
|
69
|
+
|
|
70
|
+
const embedMatch = url.match(/youtube\.com\/embed\/([^?&]+)/);
|
|
71
|
+
if (embedMatch) return embedMatch[1];
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface YouTubeParams {
|
|
77
|
+
autoplay: boolean;
|
|
78
|
+
mute: boolean;
|
|
79
|
+
controls: boolean;
|
|
80
|
+
showCaptions: boolean;
|
|
81
|
+
rel: boolean;
|
|
82
|
+
fullscreen: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getYouTubeEmbedUrl(url: string, params: YouTubeParams): string {
|
|
86
|
+
const videoId = extractVideoId(url);
|
|
87
|
+
if (!videoId) return url;
|
|
88
|
+
|
|
89
|
+
const searchParams = new URLSearchParams();
|
|
90
|
+
|
|
91
|
+
if (params.autoplay) searchParams.set("autoplay", "1");
|
|
92
|
+
if (params.mute) searchParams.set("mute", "1");
|
|
93
|
+
if (!params.controls) searchParams.set("controls", "0");
|
|
94
|
+
if (params.showCaptions) searchParams.set("cc_load_policy", "1");
|
|
95
|
+
if (!params.rel) searchParams.set("rel", "0");
|
|
96
|
+
if (!params.fullscreen) searchParams.set("fs", "0");
|
|
97
|
+
searchParams.set("disablekb", "1");
|
|
98
|
+
|
|
99
|
+
const query = searchParams.toString();
|
|
100
|
+
return `https://www.youtube.com/embed/${videoId}${query ? `?${query}` : ""}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function YouTubeVideoComponent() {
|
|
104
|
+
const fullWidth = youtubeVideo.useSetting("fullWidth");
|
|
105
|
+
const theme = youtubeVideo.useSetting("theme");
|
|
106
|
+
const autoplay = youtubeVideo.useSetting("autoplay");
|
|
107
|
+
const mute = youtubeVideo.useSetting("mute");
|
|
108
|
+
const controls = youtubeVideo.useSetting("controls");
|
|
109
|
+
const showCaptions = youtubeVideo.useSetting("showCaptions");
|
|
110
|
+
const rel = youtubeVideo.useSetting("rel");
|
|
111
|
+
const fullscreen = youtubeVideo.useSetting("fullscreen");
|
|
112
|
+
|
|
113
|
+
const params: YouTubeParams = {
|
|
114
|
+
autoplay,
|
|
115
|
+
mute,
|
|
116
|
+
controls,
|
|
117
|
+
showCaptions,
|
|
118
|
+
rel,
|
|
119
|
+
fullscreen,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<section className={cn(theme === "dark" ? "dark" : "light")}>
|
|
124
|
+
<div className={cn("bg-background", !fullWidth && "py-12")}>
|
|
125
|
+
<div className={cn(!fullWidth && "container mx-auto px-4")}>
|
|
126
|
+
<youtubeVideo.Embed name="url">
|
|
127
|
+
{(_props, { url }) => (
|
|
128
|
+
<div
|
|
129
|
+
className={cn(
|
|
130
|
+
"relative w-full",
|
|
131
|
+
!fullWidth && "overflow-hidden rounded-lg shadow-lg",
|
|
132
|
+
)}
|
|
133
|
+
style={{ paddingBottom: "56.25%" }}
|
|
134
|
+
>
|
|
135
|
+
<iframe
|
|
136
|
+
src={getYouTubeEmbedUrl(url, params)}
|
|
137
|
+
title="YouTube video"
|
|
138
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
139
|
+
allowFullScreen={fullscreen}
|
|
140
|
+
style={{
|
|
141
|
+
position: "absolute",
|
|
142
|
+
top: 0,
|
|
143
|
+
left: 0,
|
|
144
|
+
width: "100%",
|
|
145
|
+
height: "100%",
|
|
146
|
+
border: 0,
|
|
147
|
+
}}
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
</youtubeVideo.Embed>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
</section>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export { youtubeVideo as block };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createLayout } from "camox/createLayout";
|
|
2
2
|
|
|
3
|
+
import { block as faqBlock } from "../blocks/faq";
|
|
3
4
|
import { block as footerBlock } from "../blocks/footer";
|
|
4
5
|
import { block as heroBlock } from "../blocks/hero";
|
|
5
6
|
import { block as navbarBlock } from "../blocks/navbar";
|
|
@@ -12,7 +13,7 @@ const defaultLayout = createLayout({
|
|
|
12
13
|
blocks: {
|
|
13
14
|
before: [navbarBlock],
|
|
14
15
|
after: [footerBlock],
|
|
15
|
-
initial: [heroBlock, statisticsBlock],
|
|
16
|
+
initial: [heroBlock, statisticsBlock, faqBlock],
|
|
16
17
|
},
|
|
17
18
|
component: DefaultLayout,
|
|
18
19
|
buildMetaTitle: ({ pageMetaTitle, projectName }) => `${pageMetaTitle} | ${projectName}`,
|