@navneet_25/tempjs 2.0.0 → 3.0.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 +66 -18
- package/cli/brand-manager.js +183 -96
- package/cli/config.js +9 -1
- package/cli/doctor.js +393 -0
- package/cli/fetch-core-modules.js +130 -0
- package/cli/index.js +141 -10
- package/cli/info.js +28 -1
- package/cli/init-options.js +209 -0
- package/cli/module-manager.js +168 -0
- package/cli/parse-args.js +21 -0
- package/cli/update.js +15 -0
- package/cli/version-manager.js +518 -0
- package/package.json +27 -4
- package/packages/core/modules/README.md +72 -0
- package/packages/core/modules/blog-compose/app/admin/components/blog-compose-form.ts +44 -0
- package/packages/core/modules/blog-compose/app/admin/hooks/useBlogPosts.ts +103 -0
- package/packages/core/modules/blog-compose/app/admin/panels/BlogComposePanel.tsx +388 -0
- package/packages/core/modules/blog-compose/app/api/blog-posts/[id]/route.ts +29 -0
- package/packages/core/modules/blog-compose/app/api/blog-posts/route.ts +21 -0
- package/packages/core/modules/blog-compose/app/blog/[slug]/page.tsx +79 -0
- package/packages/core/modules/blog-compose/app/blog/layout.tsx +22 -0
- package/packages/core/modules/blog-compose/app/blog/page.tsx +102 -0
- package/packages/core/modules/blog-compose/app/components/BlogSection.tsx +74 -0
- package/packages/core/modules/blog-compose/app/components/BlogShell.tsx +36 -0
- package/packages/core/modules/blog-compose/app/components/BlogSidebar.tsx +112 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/annotate-text.tsx +103 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/blocks/media-blocks.tsx +97 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/blocks/text-blocks.tsx +115 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/index.ts +21 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/interpreter.tsx +55 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/registry.ts +276 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/types.ts +128 -0
- package/packages/core/modules/blog-compose/lib/blog/register-sitemap.ts +18 -0
- package/packages/core/modules/blog-compose/lib/controllers/BlogComposeController.ts +2 -0
- package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.controller.ts +78 -0
- package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.repository.ts +78 -0
- package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.service.ts +72 -0
- package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.types.ts +20 -0
- package/packages/core/modules/blog-compose/lib/features/blog-compose/index.ts +7 -0
- package/packages/core/modules/blog-compose/prisma/blog-compose.prisma +13 -0
- package/packages/core/modules/enquiry-modal/app/components/EnquiryModal.tsx +308 -0
- package/packages/core/modules/footer/app/components/Footer.tsx +127 -0
- package/packages/core/modules/gallery/app/admin/components/GalleryFormModal.tsx +132 -0
- package/packages/core/modules/gallery/app/admin/components/GalleryList.tsx +142 -0
- package/packages/core/modules/gallery/app/admin/hooks/useGallery.ts +72 -0
- package/packages/core/modules/gallery/app/api/gallery/[id]/route.ts +29 -0
- package/packages/core/modules/gallery/app/api/gallery/route.ts +22 -0
- package/packages/core/modules/gallery/app/components/GallerySection.tsx +92 -0
- package/packages/core/modules/gallery/app/gallery/layout.tsx +12 -0
- package/packages/core/modules/gallery/app/gallery/page.tsx +179 -0
- package/packages/core/modules/gallery/lib/controllers/GalleryController.ts +2 -0
- package/packages/core/modules/gallery/lib/features/gallery/gallery.controller.ts +78 -0
- package/packages/core/modules/gallery/lib/features/gallery/gallery.repository.ts +55 -0
- package/packages/core/modules/gallery/lib/features/gallery/gallery.service.ts +43 -0
- package/packages/core/modules/gallery/lib/features/gallery/gallery.types.ts +17 -0
- package/packages/core/modules/gallery/lib/features/gallery/index.ts +4 -0
- package/packages/core/modules/gallery/prisma/gallery.prisma +11 -0
- package/packages/core/modules/hero-simple/app/components/Hero.tsx +160 -0
- package/packages/core/modules/legal-pages/app/components/LegalDocumentPage.tsx +61 -0
- package/packages/core/modules/legal-pages/app/privacy-policy/page.tsx +19 -0
- package/packages/core/modules/legal-pages/app/terms-and-conditions/page.tsx +19 -0
- package/packages/core/modules/reviews/app/admin/components/ReviewFormModal.tsx +110 -0
- package/packages/core/modules/reviews/app/admin/components/ReviewsList.tsx +116 -0
- package/packages/core/modules/reviews/app/admin/hooks/useReviews.ts +70 -0
- package/packages/core/modules/reviews/app/api/reviews/[id]/route.ts +29 -0
- package/packages/core/modules/reviews/app/api/reviews/route.ts +17 -0
- package/packages/core/modules/reviews/app/components/ReviewsSection.tsx +100 -0
- package/packages/core/modules/reviews/lib/controllers/ReviewController.ts +2 -0
- package/packages/core/modules/reviews/lib/features/reviews/index.ts +3 -0
- package/packages/core/modules/reviews/lib/features/reviews/review.controller.ts +64 -0
- package/packages/core/modules/reviews/lib/features/reviews/review.repository.ts +50 -0
- package/packages/core/modules/reviews/lib/features/reviews/review.service.ts +38 -0
- package/packages/core/modules/reviews/prisma/reviews.prisma +9 -0
- package/packages/core/modules/seo/app/components/SiteJsonLd.tsx +16 -0
- package/packages/core/modules/seo/app/robots.ts +30 -0
- package/packages/core/modules/seo/app/sitemap.ts +9 -0
- package/packages/core/modules/seo/lib/seo/index.ts +5 -0
- package/packages/core/modules/seo/lib/seo/json-ld.ts +128 -0
- package/packages/core/modules/seo/lib/seo/metadata.ts +65 -0
- package/packages/core/modules/seo/lib/seo/sitemap.ts +82 -0
- package/packages/core/modules/seo/lib/seo/types.ts +30 -0
- package/packages/core/modules/seo/lib/seo/urls.ts +27 -0
- package/packages/core/modules/theme-modes/app/components/ThemeModeInit.tsx +24 -0
- package/packages/core/modules/theme-modes/app/components/ThemeModeToggle.tsx +58 -0
- package/packages/core/modules.json +140 -0
- package/packages/core/prisma/schema.prisma +33 -0
- package/scripts/module-installer-core.mjs +657 -0
- package/scripts/template-modules-admin.mjs +366 -0
- package/scripts/template-modules-home.mjs +120 -0
- package/templates.json +163 -8
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { ArrowLeft, Calendar } from "lucide-react";
|
|
6
|
+
import { SITE } from "@/constants";
|
|
7
|
+
|
|
8
|
+
interface BlogListItem {
|
|
9
|
+
id: string;
|
|
10
|
+
slug: string;
|
|
11
|
+
title: string;
|
|
12
|
+
excerpt?: string | null;
|
|
13
|
+
coverImage?: string | null;
|
|
14
|
+
publishedAt?: string | null;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const withSidebar = SITE.features.blogSidebar;
|
|
19
|
+
|
|
20
|
+
export default function BlogIndexPage() {
|
|
21
|
+
const [posts, setPosts] = useState<BlogListItem[]>([]);
|
|
22
|
+
const [loading, setLoading] = useState(true);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
async function load() {
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch("/api/blog-posts");
|
|
28
|
+
const json = await res.json();
|
|
29
|
+
if (json.success && Array.isArray(json.data)) {
|
|
30
|
+
setPosts(json.data);
|
|
31
|
+
}
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.warn("Blog index: failed to load", err);
|
|
34
|
+
} finally {
|
|
35
|
+
setLoading(false);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
load();
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
const listContent = (
|
|
42
|
+
<>
|
|
43
|
+
<p className="text-text-muted text-sm mb-10">{SITE.blog.pageSubtitle}</p>
|
|
44
|
+
{loading ? (
|
|
45
|
+
<p className="text-text-muted text-center py-16">Loading articles…</p>
|
|
46
|
+
) : posts.length === 0 ? (
|
|
47
|
+
<p className="text-center text-text-muted py-20">{SITE.blog.emptyMessage}</p>
|
|
48
|
+
) : (
|
|
49
|
+
<ul className="space-y-8">
|
|
50
|
+
{posts.map((post) => (
|
|
51
|
+
<li key={post.id}>
|
|
52
|
+
<Link
|
|
53
|
+
href={`/blog/${post.slug}`}
|
|
54
|
+
className="block group rounded-2xl border border-primary/10 bg-bg-card p-6 hover:border-primary/25 hover:shadow-md transition-all"
|
|
55
|
+
>
|
|
56
|
+
<h2 className="text-xl font-bold text-primary group-hover:text-primary-hover font-serif">
|
|
57
|
+
{post.title}
|
|
58
|
+
</h2>
|
|
59
|
+
{post.excerpt && (
|
|
60
|
+
<p className="mt-2 text-sm text-text-muted line-clamp-2">{post.excerpt}</p>
|
|
61
|
+
)}
|
|
62
|
+
<p className="mt-3 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-text-muted">
|
|
63
|
+
<Calendar className="w-3 h-3" />
|
|
64
|
+
{post.publishedAt
|
|
65
|
+
? new Date(post.publishedAt).toLocaleDateString()
|
|
66
|
+
: new Date(post.createdAt).toLocaleDateString()}
|
|
67
|
+
</p>
|
|
68
|
+
</Link>
|
|
69
|
+
</li>
|
|
70
|
+
))}
|
|
71
|
+
</ul>
|
|
72
|
+
)}
|
|
73
|
+
</>
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (withSidebar) {
|
|
77
|
+
return (
|
|
78
|
+
<div>
|
|
79
|
+
<h1 className="text-2xl font-bold font-serif text-primary mb-2">{SITE.blog.pageTitle}</h1>
|
|
80
|
+
{listContent}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div className="min-h-screen bg-bg-main">
|
|
87
|
+
<header className="border-b border-primary/10 bg-white/80 backdrop-blur-md sticky top-0 z-30">
|
|
88
|
+
<div className="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between">
|
|
89
|
+
<Link
|
|
90
|
+
href="/"
|
|
91
|
+
className="inline-flex items-center gap-2 text-sm font-semibold text-primary hover:text-primary-hover"
|
|
92
|
+
>
|
|
93
|
+
<ArrowLeft className="w-4 h-4" />
|
|
94
|
+
{SITE.navigation.backToHome}
|
|
95
|
+
</Link>
|
|
96
|
+
<h1 className="text-lg font-bold font-serif text-primary">{SITE.blog.pageTitle}</h1>
|
|
97
|
+
</div>
|
|
98
|
+
</header>
|
|
99
|
+
<main className="max-w-4xl mx-auto px-6 py-12">{listContent}</main>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { SITE } from "@/constants";
|
|
6
|
+
|
|
7
|
+
interface BlogCard {
|
|
8
|
+
id: string;
|
|
9
|
+
slug: string;
|
|
10
|
+
title: string;
|
|
11
|
+
excerpt?: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function BlogSection() {
|
|
15
|
+
const [posts, setPosts] = useState<BlogCard[]>([]);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
async function load() {
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch("/api/blog-posts");
|
|
21
|
+
const json = await res.json();
|
|
22
|
+
if (json.success && Array.isArray(json.data)) {
|
|
23
|
+
setPosts(json.data.slice(0, SITE.blog.homepageLimit));
|
|
24
|
+
}
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.warn("BlogSection: failed to load", err);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
load();
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
if (posts.length === 0) return null;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<section className="py-16 px-6 md:px-12 bg-bg-light border-t border-primary/10">
|
|
36
|
+
<div className="max-w-6xl mx-auto">
|
|
37
|
+
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4 mb-10">
|
|
38
|
+
<div>
|
|
39
|
+
<span className="text-[10px] font-bold uppercase tracking-widest text-accent-gold-dark">
|
|
40
|
+
{SITE.blog.sectionEyebrow}
|
|
41
|
+
</span>
|
|
42
|
+
<h2 className="text-2xl md:text-3xl font-bold font-serif text-primary mt-1">
|
|
43
|
+
{SITE.blog.sectionTitle}
|
|
44
|
+
</h2>
|
|
45
|
+
{SITE.blog.sectionSubtitle && (
|
|
46
|
+
<p className="text-sm text-text-muted mt-2 max-w-xl">{SITE.blog.sectionSubtitle}</p>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
<Link
|
|
50
|
+
href="/blog"
|
|
51
|
+
className="text-xs font-bold uppercase tracking-widest text-primary hover:text-primary-hover"
|
|
52
|
+
>
|
|
53
|
+
{SITE.blog.viewAllLabel}
|
|
54
|
+
</Link>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div className="grid md:grid-cols-3 gap-6">
|
|
58
|
+
{posts.map((post) => (
|
|
59
|
+
<Link
|
|
60
|
+
key={post.id}
|
|
61
|
+
href={`/blog/${post.slug}`}
|
|
62
|
+
className="block rounded-2xl border border-primary/10 bg-bg-card p-5 hover:border-primary/25 hover:shadow-md transition-all"
|
|
63
|
+
>
|
|
64
|
+
<h3 className="font-bold text-primary font-serif line-clamp-2">{post.title}</h3>
|
|
65
|
+
{post.excerpt && (
|
|
66
|
+
<p className="mt-2 text-sm text-text-muted line-clamp-3">{post.excerpt}</p>
|
|
67
|
+
)}
|
|
68
|
+
</Link>
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</section>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { ArrowLeft } from "lucide-react";
|
|
3
|
+
import { SITE } from "@/constants";
|
|
4
|
+
import BlogSidebar, { type BlogSidebarPost } from "./BlogSidebar";
|
|
5
|
+
|
|
6
|
+
export default function BlogShell({
|
|
7
|
+
posts,
|
|
8
|
+
children,
|
|
9
|
+
}: {
|
|
10
|
+
posts: BlogSidebarPost[];
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
}) {
|
|
13
|
+
return (
|
|
14
|
+
<div className="min-h-screen bg-bg-main">
|
|
15
|
+
<header className="border-b border-primary/10 bg-white/80 backdrop-blur-md sticky top-0 z-30">
|
|
16
|
+
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between gap-4">
|
|
17
|
+
<Link
|
|
18
|
+
href="/"
|
|
19
|
+
className="inline-flex items-center gap-2 text-sm font-semibold text-primary hover:text-primary-hover"
|
|
20
|
+
>
|
|
21
|
+
<ArrowLeft className="w-4 h-4" />
|
|
22
|
+
{SITE.navigation.backToHome}
|
|
23
|
+
</Link>
|
|
24
|
+
<span className="text-xs font-bold uppercase tracking-widest text-text-muted truncate">
|
|
25
|
+
{SITE.blog.pageTitle}
|
|
26
|
+
</span>
|
|
27
|
+
</div>
|
|
28
|
+
</header>
|
|
29
|
+
|
|
30
|
+
<div className="max-w-6xl mx-auto px-6 py-8 flex gap-10">
|
|
31
|
+
<BlogSidebar posts={posts} />
|
|
32
|
+
<main className="flex-1 min-w-0 max-w-3xl">{children}</main>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
import { SITE } from "@/constants";
|
|
7
|
+
|
|
8
|
+
export interface BlogSidebarPost {
|
|
9
|
+
slug: string;
|
|
10
|
+
title: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function BlogSidebar({ posts }: { posts: BlogSidebarPost[] }) {
|
|
14
|
+
const pathname = usePathname();
|
|
15
|
+
const [activeHash, setActiveHash] = useState("");
|
|
16
|
+
const [tocItems, setTocItems] = useState<{ id: string; text: string }[]>([]);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const headings = Array.from(document.querySelectorAll(".compose-blog h2[id]"));
|
|
20
|
+
setTocItems(
|
|
21
|
+
headings.map((el) => ({ id: el.id, text: el.textContent?.trim() || el.id }))
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const handleScroll = () => {
|
|
25
|
+
if (headings.length === 0) return;
|
|
26
|
+
let current = "";
|
|
27
|
+
for (const el of headings) {
|
|
28
|
+
const rect = el.getBoundingClientRect();
|
|
29
|
+
if (rect.top <= 120) {
|
|
30
|
+
current = `#${el.id}`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
setActiveHash(current || `#${headings[0].id}`);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
window.addEventListener("scroll", handleScroll, { passive: true });
|
|
37
|
+
handleScroll();
|
|
38
|
+
return () => window.removeEventListener("scroll", handleScroll);
|
|
39
|
+
}, [pathname]);
|
|
40
|
+
|
|
41
|
+
const showToc =
|
|
42
|
+
pathname.startsWith("/blog/") &&
|
|
43
|
+
pathname !== "/blog/playground" &&
|
|
44
|
+
tocItems.length > 0;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<nav className="w-56 shrink-0 sticky top-8 h-fit max-h-[calc(100vh-8rem)] overflow-y-auto pr-2">
|
|
48
|
+
<p className="text-[10px] font-bold uppercase tracking-widest text-text-muted mb-3">
|
|
49
|
+
{SITE.blog.sidebarTitle}
|
|
50
|
+
</p>
|
|
51
|
+
<ul className="space-y-1">
|
|
52
|
+
{posts.map((post) => {
|
|
53
|
+
const href = `/blog/${post.slug}`;
|
|
54
|
+
const isActive = pathname === href;
|
|
55
|
+
return (
|
|
56
|
+
<li key={post.slug}>
|
|
57
|
+
<Link
|
|
58
|
+
href={href}
|
|
59
|
+
className={`block rounded-lg px-3 py-2 text-sm transition-colors ${
|
|
60
|
+
isActive
|
|
61
|
+
? "bg-primary/10 text-primary font-bold"
|
|
62
|
+
: "text-text-muted hover:bg-bg-light hover:text-primary"
|
|
63
|
+
}`}
|
|
64
|
+
>
|
|
65
|
+
{post.title}
|
|
66
|
+
</Link>
|
|
67
|
+
</li>
|
|
68
|
+
);
|
|
69
|
+
})}
|
|
70
|
+
<li>
|
|
71
|
+
<Link
|
|
72
|
+
href="/blog"
|
|
73
|
+
className={`block rounded-lg px-3 py-2 text-sm transition-colors ${
|
|
74
|
+
pathname === "/blog"
|
|
75
|
+
? "bg-primary/10 text-primary font-bold"
|
|
76
|
+
: "text-text-muted hover:bg-bg-light hover:text-primary"
|
|
77
|
+
}`}
|
|
78
|
+
>
|
|
79
|
+
{SITE.blog.viewAllLabel}
|
|
80
|
+
</Link>
|
|
81
|
+
</li>
|
|
82
|
+
</ul>
|
|
83
|
+
|
|
84
|
+
{showToc && (
|
|
85
|
+
<div className="mt-6 border-t border-primary/10 pt-4">
|
|
86
|
+
<p className="text-[10px] font-bold uppercase tracking-widest text-text-muted mb-2">
|
|
87
|
+
On this page
|
|
88
|
+
</p>
|
|
89
|
+
<ul className="ml-2 pl-3 border-l border-primary/15 space-y-1">
|
|
90
|
+
{tocItems.map((item) => {
|
|
91
|
+
const isSubActive = activeHash === `#${item.id}`;
|
|
92
|
+
return (
|
|
93
|
+
<li key={item.id}>
|
|
94
|
+
<a
|
|
95
|
+
href={`#${item.id}`}
|
|
96
|
+
className={`block py-1 text-xs transition-colors ${
|
|
97
|
+
isSubActive
|
|
98
|
+
? "text-primary font-bold"
|
|
99
|
+
: "text-text-muted hover:text-primary"
|
|
100
|
+
}`}
|
|
101
|
+
>
|
|
102
|
+
{item.text}
|
|
103
|
+
</a>
|
|
104
|
+
</li>
|
|
105
|
+
);
|
|
106
|
+
})}
|
|
107
|
+
</ul>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</nav>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
export interface TextAnnotation {
|
|
4
|
+
highlight?: string[];
|
|
5
|
+
underline?: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface Segment {
|
|
9
|
+
text: string;
|
|
10
|
+
highlight?: boolean;
|
|
11
|
+
underline?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Split plain text into segments with highlight/underline flags.
|
|
16
|
+
* Matches are case-sensitive; longer phrases are preferred first to reduce overlap issues.
|
|
17
|
+
*/
|
|
18
|
+
function buildSegments(text: string, annotations: TextAnnotation): Segment[] {
|
|
19
|
+
const phrases: { phrase: string; highlight?: boolean; underline?: boolean }[] = [];
|
|
20
|
+
for (const p of annotations.highlight ?? []) {
|
|
21
|
+
if (p.trim()) phrases.push({ phrase: p, highlight: true });
|
|
22
|
+
}
|
|
23
|
+
for (const p of annotations.underline ?? []) {
|
|
24
|
+
if (p.trim()) phrases.push({ phrase: p, underline: true });
|
|
25
|
+
}
|
|
26
|
+
if (phrases.length === 0) return [{ text }];
|
|
27
|
+
|
|
28
|
+
phrases.sort((a, b) => b.phrase.length - a.phrase.length);
|
|
29
|
+
|
|
30
|
+
type Match = { start: number; end: number; highlight?: boolean; underline?: boolean };
|
|
31
|
+
const matches: Match[] = [];
|
|
32
|
+
|
|
33
|
+
for (const { phrase, highlight, underline } of phrases) {
|
|
34
|
+
let from = 0;
|
|
35
|
+
while (from < text.length) {
|
|
36
|
+
const idx = text.indexOf(phrase, from);
|
|
37
|
+
if (idx === -1) break;
|
|
38
|
+
matches.push({ start: idx, end: idx + phrase.length, highlight, underline });
|
|
39
|
+
from = idx + phrase.length;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (matches.length === 0) return [{ text }];
|
|
44
|
+
|
|
45
|
+
matches.sort((a, b) => a.start - b.start);
|
|
46
|
+
|
|
47
|
+
const segments: Segment[] = [];
|
|
48
|
+
let cursor = 0;
|
|
49
|
+
|
|
50
|
+
for (const m of matches) {
|
|
51
|
+
if (m.start < cursor) continue;
|
|
52
|
+
if (m.start > cursor) {
|
|
53
|
+
segments.push({ text: text.slice(cursor, m.start) });
|
|
54
|
+
}
|
|
55
|
+
segments.push({
|
|
56
|
+
text: text.slice(m.start, m.end),
|
|
57
|
+
highlight: m.highlight,
|
|
58
|
+
underline: m.underline,
|
|
59
|
+
});
|
|
60
|
+
cursor = m.end;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (cursor < text.length) {
|
|
64
|
+
segments.push({ text: text.slice(cursor) });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return segments.length > 0 ? segments : [{ text }];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function AnnotatedText({
|
|
71
|
+
text,
|
|
72
|
+
highlight,
|
|
73
|
+
underline,
|
|
74
|
+
className,
|
|
75
|
+
}: {
|
|
76
|
+
text: string;
|
|
77
|
+
highlight?: string[];
|
|
78
|
+
underline?: string[];
|
|
79
|
+
className?: string;
|
|
80
|
+
}) {
|
|
81
|
+
const segments = buildSegments(text, { highlight, underline });
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<span className={className}>
|
|
85
|
+
{segments.map((seg, i) => {
|
|
86
|
+
if (!seg.highlight && !seg.underline) {
|
|
87
|
+
return <span key={i}>{seg.text}</span>;
|
|
88
|
+
}
|
|
89
|
+
return (
|
|
90
|
+
<span
|
|
91
|
+
key={i}
|
|
92
|
+
className={[
|
|
93
|
+
seg.highlight ? "bg-accent-gold-light/80 text-text-main px-0.5 rounded" : "",
|
|
94
|
+
seg.underline ? "underline decoration-accent-gold decoration-2 underline-offset-2" : "",
|
|
95
|
+
].join(" ")}
|
|
96
|
+
>
|
|
97
|
+
{seg.text}
|
|
98
|
+
</span>
|
|
99
|
+
);
|
|
100
|
+
})}
|
|
101
|
+
</span>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { ComposeCodeBlock, ComposeCommandBlock, ComposeCalloutBlock, ComposeImageBlock } from "../types";
|
|
5
|
+
|
|
6
|
+
export function ComposeCode({ block }: { block: ComposeCodeBlock }) {
|
|
7
|
+
return (
|
|
8
|
+
<div className="my-6 rounded-xl border border-primary/10 bg-bg-light overflow-hidden">
|
|
9
|
+
{block.language && block.language !== "text" && (
|
|
10
|
+
<div className="px-4 py-2 border-b border-primary/10 text-[10px] font-bold uppercase tracking-widest text-text-muted">
|
|
11
|
+
{block.language}
|
|
12
|
+
</div>
|
|
13
|
+
)}
|
|
14
|
+
<pre
|
|
15
|
+
className={[
|
|
16
|
+
"px-4 py-3 text-sm overflow-x-auto font-mono text-text-main leading-relaxed",
|
|
17
|
+
block.showLineNumbers ? "pl-8" : "",
|
|
18
|
+
].join(" ")}
|
|
19
|
+
>
|
|
20
|
+
<code>{block.code}</code>
|
|
21
|
+
</pre>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ComposeCommand({ block }: { block: ComposeCommandBlock }) {
|
|
27
|
+
const [copied, setCopied] = useState(false);
|
|
28
|
+
|
|
29
|
+
async function copy() {
|
|
30
|
+
try {
|
|
31
|
+
await navigator.clipboard.writeText(block.command);
|
|
32
|
+
setCopied(true);
|
|
33
|
+
setTimeout(() => setCopied(false), 2000);
|
|
34
|
+
} catch {
|
|
35
|
+
/* ignore */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="my-6 rounded-xl border border-primary/10 bg-bg-card shadow-sm overflow-hidden">
|
|
41
|
+
<div className="flex items-center justify-between gap-2 border-b border-primary/10 px-4 py-2 bg-bg-light">
|
|
42
|
+
<span className="text-xs font-semibold uppercase tracking-wide text-text-muted">
|
|
43
|
+
{block.title || "Command"}
|
|
44
|
+
</span>
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
onClick={copy}
|
|
48
|
+
className="rounded-md px-3 py-1 text-xs font-bold bg-primary text-white hover:opacity-90 cursor-pointer"
|
|
49
|
+
>
|
|
50
|
+
{copied ? "Copied!" : "Copy"}
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
53
|
+
{block.description && (
|
|
54
|
+
<p className="px-4 py-2 text-sm text-text-muted border-b border-primary/5">
|
|
55
|
+
{block.description}
|
|
56
|
+
</p>
|
|
57
|
+
)}
|
|
58
|
+
<pre className="px-4 py-3 text-sm font-mono text-text-main overflow-x-auto whitespace-pre-wrap">
|
|
59
|
+
<code>{block.command}</code>
|
|
60
|
+
</pre>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function ComposeCallout({ block }: { block: ComposeCalloutBlock }) {
|
|
66
|
+
const styles = {
|
|
67
|
+
info: "bg-primary/5 border-primary/20 text-text-main",
|
|
68
|
+
tip: "bg-emerald-50 border-emerald-200 text-emerald-900",
|
|
69
|
+
warning: "bg-amber-50 border-amber-200 text-amber-900",
|
|
70
|
+
}[block.variant];
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className={`my-6 rounded-xl border p-4 ${styles}`}>
|
|
74
|
+
{block.title && <p className="font-bold text-sm mb-1">{block.title}</p>}
|
|
75
|
+
<p className="text-sm leading-relaxed">{block.text}</p>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function ComposeDivider() {
|
|
81
|
+
return <hr className="my-10 border-t border-primary/10" />;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function ComposeImage({ block }: { block: ComposeImageBlock }) {
|
|
85
|
+
return (
|
|
86
|
+
<figure className="my-8">
|
|
87
|
+
<img
|
|
88
|
+
src={block.src}
|
|
89
|
+
alt={block.alt}
|
|
90
|
+
className="w-full rounded-xl border border-primary/10 shadow-sm"
|
|
91
|
+
/>
|
|
92
|
+
{block.caption && (
|
|
93
|
+
<figcaption className="mt-2 text-center text-sm text-text-muted">{block.caption}</figcaption>
|
|
94
|
+
)}
|
|
95
|
+
</figure>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { ComposeHeadingBlock } from "../types";
|
|
2
|
+
import { AnnotatedText } from "../annotate-text";
|
|
3
|
+
import type { ComposeListBlock, ComposeQuoteBlock } from "../types";
|
|
4
|
+
import { slugify } from "@/lib/utils/slugify";
|
|
5
|
+
|
|
6
|
+
export function ComposeHeading({ block }: { block: ComposeHeadingBlock }) {
|
|
7
|
+
const base = "text-text-main tracking-tight";
|
|
8
|
+
|
|
9
|
+
if (block.level === "title") {
|
|
10
|
+
return (
|
|
11
|
+
<h1
|
|
12
|
+
className={[
|
|
13
|
+
base,
|
|
14
|
+
"text-3xl md:text-4xl font-bold font-serif mb-6",
|
|
15
|
+
block.variant === "accent" ? "text-primary" : "",
|
|
16
|
+
block.variant === "underline-bar"
|
|
17
|
+
? "border-b-4 border-accent-gold pb-3"
|
|
18
|
+
: "",
|
|
19
|
+
].join(" ")}
|
|
20
|
+
>
|
|
21
|
+
{block.text}
|
|
22
|
+
</h1>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (block.level === "h3") {
|
|
27
|
+
return (
|
|
28
|
+
<h3
|
|
29
|
+
className={[
|
|
30
|
+
base,
|
|
31
|
+
"text-lg font-semibold mt-8 mb-3",
|
|
32
|
+
block.variant === "serif" ? "font-serif" : "",
|
|
33
|
+
block.variant === "accent" ? "text-primary" : "",
|
|
34
|
+
].join(" ")}
|
|
35
|
+
>
|
|
36
|
+
{block.text}
|
|
37
|
+
</h3>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<h2
|
|
43
|
+
id={slugify(block.text)}
|
|
44
|
+
className={[
|
|
45
|
+
base,
|
|
46
|
+
"text-2xl font-bold mt-10 mb-4",
|
|
47
|
+
block.variant === "serif" ? "font-serif" : "",
|
|
48
|
+
block.variant === "accent" ? "text-primary" : "",
|
|
49
|
+
block.variant === "underline-bar"
|
|
50
|
+
? "border-b border-primary/20 pb-2"
|
|
51
|
+
: "",
|
|
52
|
+
].join(" ")}
|
|
53
|
+
>
|
|
54
|
+
{block.text}
|
|
55
|
+
</h2>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function ComposeExplain({
|
|
60
|
+
block,
|
|
61
|
+
}: {
|
|
62
|
+
block: import("../types").ComposeExplainBlock;
|
|
63
|
+
}) {
|
|
64
|
+
const variantClass =
|
|
65
|
+
block.variant === "lead"
|
|
66
|
+
? "text-lg leading-relaxed text-text-main"
|
|
67
|
+
: block.variant === "muted"
|
|
68
|
+
? "text-sm text-text-muted"
|
|
69
|
+
: "text-base leading-relaxed text-text-muted";
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<p className={`my-4 ${variantClass}`}>
|
|
73
|
+
<AnnotatedText
|
|
74
|
+
text={block.text}
|
|
75
|
+
highlight={block.highlight}
|
|
76
|
+
underline={block.underline}
|
|
77
|
+
/>
|
|
78
|
+
</p>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function ComposeParagraph({
|
|
83
|
+
block,
|
|
84
|
+
}: {
|
|
85
|
+
block: import("../types").ComposeParagraphBlock;
|
|
86
|
+
}) {
|
|
87
|
+
return <p className="my-4 text-text-muted leading-relaxed">{block.text}</p>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function ComposeList({ block }: { block: ComposeListBlock }) {
|
|
91
|
+
const Tag = block.style === "number" ? "ol" : "ul";
|
|
92
|
+
const listClass =
|
|
93
|
+
block.style === "number"
|
|
94
|
+
? "my-4 list-decimal pl-6 space-y-2 text-text-muted"
|
|
95
|
+
: "my-4 list-disc pl-6 space-y-2 text-text-muted";
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<Tag className={listClass}>
|
|
99
|
+
{block.items.map((item, i) => (
|
|
100
|
+
<li key={i} className="leading-relaxed">{item}</li>
|
|
101
|
+
))}
|
|
102
|
+
</Tag>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function ComposeQuote({ block }: { block: ComposeQuoteBlock }) {
|
|
107
|
+
return (
|
|
108
|
+
<blockquote className="my-6 border-l-4 border-accent-gold pl-4 py-1">
|
|
109
|
+
<p className="text-lg font-serif text-text-main leading-relaxed italic">{block.text}</p>
|
|
110
|
+
{block.attribution && (
|
|
111
|
+
<footer className="mt-2 text-sm text-text-muted not-italic">— {block.attribution}</footer>
|
|
112
|
+
)}
|
|
113
|
+
</blockquote>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export {
|
|
2
|
+
COMPOSE_ENGINE_ID,
|
|
3
|
+
createEmptyComposeDocument,
|
|
4
|
+
isComposeDocument,
|
|
5
|
+
type ComposeBlock,
|
|
6
|
+
type ComposeBlockType,
|
|
7
|
+
type ComposeDocument,
|
|
8
|
+
type BlogPostMeta,
|
|
9
|
+
type BlogPostWithContent,
|
|
10
|
+
} from "./types";
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
COMPOSE_BLOCK_REGISTRY,
|
|
14
|
+
COMPOSE_BLOCK_TYPES,
|
|
15
|
+
getComposeBlockDefinition,
|
|
16
|
+
type ComposeBlockDefinition,
|
|
17
|
+
type ComposeFieldDef,
|
|
18
|
+
} from "./registry";
|
|
19
|
+
|
|
20
|
+
export { AnnotatedText } from "./annotate-text";
|
|
21
|
+
export { ComposeBlogRenderer, ComposeBlockView } from "./interpreter";
|