@orhancodestudio/ocsm-core 0.1.0-alpha.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/CHANGELOG.md +13 -0
- package/README.md +47 -0
- package/package.json +53 -0
- package/src/admin/admin.module.css +1312 -0
- package/src/admin/admin.types.ts +85 -0
- package/src/admin/components/access-denied.tsx +12 -0
- package/src/admin/components/admin-shell.tsx +168 -0
- package/src/admin/components/content-list-view.tsx +83 -0
- package/src/admin/components/dashboard-view.tsx +113 -0
- package/src/admin/components/data-table.tsx +80 -0
- package/src/admin/components/document-delete-button.tsx +44 -0
- package/src/admin/components/icons.tsx +150 -0
- package/src/admin/components/modal.tsx +78 -0
- package/src/admin/components/page-builder.tsx +1334 -0
- package/src/admin/components/settings-view.tsx +334 -0
- package/src/admin/components/sign-out-button.tsx +22 -0
- package/src/admin/components/system-view.tsx +77 -0
- package/src/admin/components/users-panel.tsx +321 -0
- package/src/admin/index.ts +20 -0
- package/src/admin/ocsm-admin.tsx +259 -0
- package/src/auth/authenticate.ts +76 -0
- package/src/auth/index.ts +9 -0
- package/src/auth/password.ts +22 -0
- package/src/auth/permissions.ts +27 -0
- package/src/auth/session.ts +103 -0
- package/src/blocks/block-renderer.tsx +428 -0
- package/src/blocks/block.types.ts +401 -0
- package/src/blocks/index.ts +15 -0
- package/src/blocks/markdown.tsx +11 -0
- package/src/config/config.schema.ts +28 -0
- package/src/config/config.types.ts +16 -0
- package/src/config/define-config.ts +19 -0
- package/src/config/index.ts +13 -0
- package/src/config/resolve-config.ts +10 -0
- package/src/content/content-repository.ts +66 -0
- package/src/content/content-store.interface.ts +23 -0
- package/src/content/content.types.ts +25 -0
- package/src/content/create-content-store.ts +18 -0
- package/src/content/frontmatter.ts +25 -0
- package/src/content/index.ts +12 -0
- package/src/index.ts +10 -0
- package/src/layout/index.ts +1 -0
- package/src/layout/layout-store.ts +27 -0
- package/src/roles/index.ts +10 -0
- package/src/roles/role-store.ts +95 -0
- package/src/roles/role.types.ts +86 -0
- package/src/server/create-ocsm.ts +67 -0
- package/src/server/documents.ts +28 -0
- package/src/server/index.ts +59 -0
- package/src/server/render-mdx.tsx +14 -0
- package/src/storage/create-file-backend.ts +26 -0
- package/src/storage/file-backend.ts +26 -0
- package/src/storage/fs-file-backend.ts +43 -0
- package/src/storage/github-file-backend.ts +97 -0
- package/src/storage/index.ts +8 -0
- package/src/storage/json-store.ts +23 -0
- package/src/theme/css.ts +28 -0
- package/src/theme/index.ts +8 -0
- package/src/theme/theme-store.ts +19 -0
- package/src/theme/theme.types.ts +53 -0
- package/src/types/css-modules.d.ts +4 -0
- package/src/update/check-for-updates.ts +50 -0
- package/src/update/index.ts +1 -0
- package/src/users/index.ts +6 -0
- package/src/users/user-store.ts +120 -0
- package/src/users/user.types.ts +18 -0
- package/src/version.ts +11 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { OcsmPermission } from "../roles/role.types";
|
|
2
|
+
import { getSessionUser, type SessionUser } from "./session";
|
|
3
|
+
|
|
4
|
+
/** Whether a session user holds a permission. `null` users have none. */
|
|
5
|
+
export function userHasPermission(
|
|
6
|
+
user: SessionUser | null,
|
|
7
|
+
permission: OcsmPermission,
|
|
8
|
+
): boolean {
|
|
9
|
+
return user !== null && user.permissions.includes(permission);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Asserts the current session user holds a permission, returning them. Throws
|
|
14
|
+
* otherwise — use as a guard at the top of privileged Server Actions.
|
|
15
|
+
*
|
|
16
|
+
* Permissions are resolved into the session at login, so role changes take
|
|
17
|
+
* effect after the affected user signs in again.
|
|
18
|
+
*/
|
|
19
|
+
export async function requirePermission(
|
|
20
|
+
permission: OcsmPermission,
|
|
21
|
+
): Promise<SessionUser> {
|
|
22
|
+
const user = await getSessionUser();
|
|
23
|
+
if (!user || !user.permissions.includes(permission)) {
|
|
24
|
+
throw new Error("OCSM: bu işlem için yetkiniz yok");
|
|
25
|
+
}
|
|
26
|
+
return user;
|
|
27
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { cookies } from "next/headers";
|
|
4
|
+
import type { OcsmPermission } from "../roles/role.types";
|
|
5
|
+
|
|
6
|
+
const SESSION_COOKIE = "ocsm_session";
|
|
7
|
+
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
|
|
8
|
+
const DEV_SECRET = "ocsm-dev-insecure-secret-change-me";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Session schema version. Bump this whenever {@link SessionUser}'s shape changes
|
|
12
|
+
* so that older sessions are automatically invalidated (the user is asked to log
|
|
13
|
+
* in again) instead of running with a stale/incomplete payload.
|
|
14
|
+
*/
|
|
15
|
+
const SESSION_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
/** The authenticated user attached to the current request. */
|
|
18
|
+
export interface SessionUser {
|
|
19
|
+
id: string;
|
|
20
|
+
username: string;
|
|
21
|
+
name: string;
|
|
22
|
+
/** Role id. */
|
|
23
|
+
role: string;
|
|
24
|
+
/** Display name of the role, resolved at login. */
|
|
25
|
+
roleName: string;
|
|
26
|
+
/** Effective permissions, resolved from the role at login. */
|
|
27
|
+
permissions: OcsmPermission[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface SessionPayload extends SessionUser {
|
|
31
|
+
/** Schema version — see {@link SESSION_VERSION}. */
|
|
32
|
+
v: number;
|
|
33
|
+
exp: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Issues a signed session cookie. Call from a Server Action or Route Handler. */
|
|
37
|
+
export async function createSession(user: SessionUser): Promise<void> {
|
|
38
|
+
const payload: SessionPayload = {
|
|
39
|
+
...user,
|
|
40
|
+
v: SESSION_VERSION,
|
|
41
|
+
exp: Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS,
|
|
42
|
+
};
|
|
43
|
+
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
44
|
+
const token = `${body}.${sign(body)}`;
|
|
45
|
+
|
|
46
|
+
const store = await cookies();
|
|
47
|
+
store.set(SESSION_COOKIE, token, {
|
|
48
|
+
httpOnly: true,
|
|
49
|
+
sameSite: "lax",
|
|
50
|
+
secure: process.env.NODE_ENV === "production",
|
|
51
|
+
path: "/",
|
|
52
|
+
maxAge: SESSION_TTL_SECONDS,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Returns the current session user, or `null` if unauthenticated or expired. */
|
|
57
|
+
export async function getSessionUser(): Promise<SessionUser | null> {
|
|
58
|
+
const store = await cookies();
|
|
59
|
+
const token = store.get(SESSION_COOKIE)?.value;
|
|
60
|
+
if (!token) return null;
|
|
61
|
+
|
|
62
|
+
const [body, signature] = token.split(".");
|
|
63
|
+
if (!body || !signature || !safeEqual(signature, sign(body))) return null;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const payload = JSON.parse(
|
|
67
|
+
Buffer.from(body, "base64url").toString("utf8"),
|
|
68
|
+
) as SessionPayload;
|
|
69
|
+
// Stale schema (created before a session-shape change) → force re-login.
|
|
70
|
+
if (payload.v !== SESSION_VERSION) return null;
|
|
71
|
+
if (payload.exp * 1000 < Date.now()) return null;
|
|
72
|
+
return {
|
|
73
|
+
id: payload.id,
|
|
74
|
+
username: payload.username,
|
|
75
|
+
name: payload.name,
|
|
76
|
+
role: payload.role,
|
|
77
|
+
roleName: payload.roleName ?? payload.role,
|
|
78
|
+
permissions: Array.isArray(payload.permissions) ? payload.permissions : [],
|
|
79
|
+
};
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Clears the session cookie. Call from a Server Action or Route Handler. */
|
|
86
|
+
export async function destroySession(): Promise<void> {
|
|
87
|
+
const store = await cookies();
|
|
88
|
+
store.delete(SESSION_COOKIE);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function secret(): string {
|
|
92
|
+
return process.env.OCSM_AUTH_SECRET ?? DEV_SECRET;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sign(value: string): string {
|
|
96
|
+
return createHmac("sha256", secret()).update(value).digest("base64url");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function safeEqual(a: string, b: string): boolean {
|
|
100
|
+
const bufferA = Buffer.from(a);
|
|
101
|
+
const bufferB = Buffer.from(b);
|
|
102
|
+
return bufferA.length === bufferB.length && timingSafeEqual(bufferA, bufferB);
|
|
103
|
+
}
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import type { Block, BlockStyle } from "./block.types";
|
|
3
|
+
import { OcsmMarkdown } from "./markdown";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renders a list of section blocks. Pure and isomorphic — used both for the
|
|
7
|
+
* live preview in the page builder and for the public site.
|
|
8
|
+
*/
|
|
9
|
+
export function BlockRenderer({ blocks }: { blocks: Block[] }) {
|
|
10
|
+
return (
|
|
11
|
+
<>
|
|
12
|
+
{blocks
|
|
13
|
+
.filter((block) => !block.hidden)
|
|
14
|
+
.map((block) => (
|
|
15
|
+
<BlockItem key={block.id} block={block} />
|
|
16
|
+
))}
|
|
17
|
+
</>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const LEVEL_SIZE: Record<1 | 2 | 3, string> = {
|
|
22
|
+
1: "34px",
|
|
23
|
+
2: "26px",
|
|
24
|
+
3: "20px",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Renders a single block. Exported so the builder can wrap it for selection. */
|
|
28
|
+
export function BlockItem({ block }: { block: Block }) {
|
|
29
|
+
const outer = sectionStyle(block.style);
|
|
30
|
+
const inner = innerStyle(block.style);
|
|
31
|
+
|
|
32
|
+
switch (block.type) {
|
|
33
|
+
case "navbar": {
|
|
34
|
+
const justify: Record<string, CSSProperties["justifyContent"]> = {
|
|
35
|
+
left: "flex-start",
|
|
36
|
+
center: "center",
|
|
37
|
+
right: "flex-end",
|
|
38
|
+
};
|
|
39
|
+
const logoEl = block.logoImageUrl ? (
|
|
40
|
+
<a href={block.logoHref || "#"}>
|
|
41
|
+
{/* biome-ignore lint/a11y/useAltText: alt from logo text */}
|
|
42
|
+
<img
|
|
43
|
+
src={block.logoImageUrl}
|
|
44
|
+
alt={block.logoText}
|
|
45
|
+
style={{ height: 34, display: "block" }}
|
|
46
|
+
/>
|
|
47
|
+
</a>
|
|
48
|
+
) : (
|
|
49
|
+
<a
|
|
50
|
+
href={block.logoHref || "#"}
|
|
51
|
+
style={{
|
|
52
|
+
fontWeight: 800,
|
|
53
|
+
fontSize: px(block.style.fontSize) ?? "20px",
|
|
54
|
+
color: "inherit",
|
|
55
|
+
textDecoration: "none",
|
|
56
|
+
whiteSpace: "nowrap",
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
{block.logoText}
|
|
60
|
+
</a>
|
|
61
|
+
);
|
|
62
|
+
const linksEl = (
|
|
63
|
+
<nav style={{ display: "flex", gap: 20, alignItems: "center" }}>
|
|
64
|
+
{block.links.map((link, index) => (
|
|
65
|
+
<a
|
|
66
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: static nav list
|
|
67
|
+
key={index}
|
|
68
|
+
href={link.url || "#"}
|
|
69
|
+
style={{
|
|
70
|
+
color: "inherit",
|
|
71
|
+
textDecoration: "none",
|
|
72
|
+
fontSize: px(block.style.fontSize) ?? "15px",
|
|
73
|
+
whiteSpace: "nowrap",
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
{link.label}
|
|
77
|
+
</a>
|
|
78
|
+
))}
|
|
79
|
+
</nav>
|
|
80
|
+
);
|
|
81
|
+
const slot = (which: "left" | "center" | "right") => {
|
|
82
|
+
const hasLogo = block.logoAlign === which;
|
|
83
|
+
const hasLinks = block.linksAlign === which;
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
style={{
|
|
87
|
+
display: "flex",
|
|
88
|
+
gap: 24,
|
|
89
|
+
alignItems: "center",
|
|
90
|
+
justifyContent: justify[which],
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
{hasLogo ? logoEl : null}
|
|
94
|
+
{hasLinks ? linksEl : null}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
return (
|
|
99
|
+
<section
|
|
100
|
+
style={{
|
|
101
|
+
...outer,
|
|
102
|
+
...(block.sticky
|
|
103
|
+
? { position: "sticky", top: 0, zIndex: 50 }
|
|
104
|
+
: {}),
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
<div
|
|
108
|
+
style={{
|
|
109
|
+
...inner,
|
|
110
|
+
display: "grid",
|
|
111
|
+
gridTemplateColumns: "1fr auto 1fr",
|
|
112
|
+
alignItems: "center",
|
|
113
|
+
gap: 16,
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
{slot("left")}
|
|
117
|
+
{slot("center")}
|
|
118
|
+
{slot("right")}
|
|
119
|
+
</div>
|
|
120
|
+
</section>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
case "hero":
|
|
125
|
+
return (
|
|
126
|
+
<section style={outer}>
|
|
127
|
+
<div style={inner}>
|
|
128
|
+
<h1
|
|
129
|
+
style={{
|
|
130
|
+
fontSize: px(block.style.fontSize) ?? "44px",
|
|
131
|
+
lineHeight: 1.15,
|
|
132
|
+
margin: "0 0 14px",
|
|
133
|
+
}}
|
|
134
|
+
>
|
|
135
|
+
{block.heading}
|
|
136
|
+
</h1>
|
|
137
|
+
{block.subheading ? (
|
|
138
|
+
<p
|
|
139
|
+
style={{
|
|
140
|
+
fontSize: 18,
|
|
141
|
+
opacity: 0.85,
|
|
142
|
+
margin: "0 auto 26px",
|
|
143
|
+
maxWidth: 580,
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
{block.subheading}
|
|
147
|
+
</p>
|
|
148
|
+
) : null}
|
|
149
|
+
{block.buttonLabel ? (
|
|
150
|
+
<a href={block.buttonUrl || "#"} style={buttonStyle(block.buttonColor)}>
|
|
151
|
+
{block.buttonLabel}
|
|
152
|
+
</a>
|
|
153
|
+
) : null}
|
|
154
|
+
</div>
|
|
155
|
+
</section>
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
case "heading": {
|
|
159
|
+
const Tag = `h${block.level}` as "h1" | "h2" | "h3";
|
|
160
|
+
return (
|
|
161
|
+
<section style={outer}>
|
|
162
|
+
<div style={inner}>
|
|
163
|
+
<Tag
|
|
164
|
+
style={{
|
|
165
|
+
margin: 0,
|
|
166
|
+
fontSize: px(block.style.fontSize) ?? LEVEL_SIZE[block.level],
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
{block.text}
|
|
170
|
+
</Tag>
|
|
171
|
+
</div>
|
|
172
|
+
</section>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
case "richText":
|
|
177
|
+
return (
|
|
178
|
+
<section style={outer}>
|
|
179
|
+
<div
|
|
180
|
+
style={{ ...inner, fontSize: px(block.style.fontSize), lineHeight: 1.7 }}
|
|
181
|
+
>
|
|
182
|
+
<OcsmMarkdown>{block.markdown}</OcsmMarkdown>
|
|
183
|
+
</div>
|
|
184
|
+
</section>
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
case "image": {
|
|
188
|
+
const align = block.style.textAlign;
|
|
189
|
+
const imgStyle: CSSProperties = {
|
|
190
|
+
display: "block",
|
|
191
|
+
width: `${block.width || "100"}%`,
|
|
192
|
+
height: block.height ? `${block.height}px` : "auto",
|
|
193
|
+
objectFit: block.height ? block.fit : undefined,
|
|
194
|
+
borderRadius: `${block.radius || "0"}px`,
|
|
195
|
+
marginLeft: align === "center" || align === "right" ? "auto" : undefined,
|
|
196
|
+
marginRight: align === "center" ? "auto" : undefined,
|
|
197
|
+
};
|
|
198
|
+
// biome-ignore lint/a11y/useAltText: alt is provided via the block field
|
|
199
|
+
const img = <img src={block.url} alt={block.alt} style={imgStyle} />;
|
|
200
|
+
return (
|
|
201
|
+
<section style={outer}>
|
|
202
|
+
<figure style={{ ...inner, margin: "0 auto" }}>
|
|
203
|
+
{block.url ? (
|
|
204
|
+
block.link ? (
|
|
205
|
+
<a href={block.link}>{img}</a>
|
|
206
|
+
) : (
|
|
207
|
+
img
|
|
208
|
+
)
|
|
209
|
+
) : (
|
|
210
|
+
<Placeholder>Görsel URL'si girilmedi</Placeholder>
|
|
211
|
+
)}
|
|
212
|
+
{block.caption ? (
|
|
213
|
+
<figcaption style={{ color: "#64748b", fontSize: 13, marginTop: 8 }}>
|
|
214
|
+
{block.caption}
|
|
215
|
+
</figcaption>
|
|
216
|
+
) : null}
|
|
217
|
+
</figure>
|
|
218
|
+
</section>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
case "button":
|
|
223
|
+
return (
|
|
224
|
+
<section style={outer}>
|
|
225
|
+
<div style={inner}>
|
|
226
|
+
<a href={block.url || "#"} style={buttonStyle(block.buttonColor)}>
|
|
227
|
+
{block.label}
|
|
228
|
+
</a>
|
|
229
|
+
</div>
|
|
230
|
+
</section>
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
case "quote":
|
|
234
|
+
return (
|
|
235
|
+
<section style={outer}>
|
|
236
|
+
<div style={inner}>
|
|
237
|
+
<blockquote
|
|
238
|
+
style={{
|
|
239
|
+
borderLeft: "4px solid currentColor",
|
|
240
|
+
margin: 0,
|
|
241
|
+
paddingLeft: 18,
|
|
242
|
+
fontSize: px(block.style.fontSize) ?? "20px",
|
|
243
|
+
fontStyle: "italic",
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
{block.text}
|
|
247
|
+
{block.author ? (
|
|
248
|
+
<footer
|
|
249
|
+
style={{
|
|
250
|
+
marginTop: 10,
|
|
251
|
+
fontSize: 14,
|
|
252
|
+
opacity: 0.7,
|
|
253
|
+
fontStyle: "normal",
|
|
254
|
+
}}
|
|
255
|
+
>
|
|
256
|
+
— {block.author}
|
|
257
|
+
</footer>
|
|
258
|
+
) : null}
|
|
259
|
+
</blockquote>
|
|
260
|
+
</div>
|
|
261
|
+
</section>
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
case "banner":
|
|
265
|
+
return (
|
|
266
|
+
<aside style={{ ...outer, fontSize: px(block.style.fontSize) ?? "15px" }}>
|
|
267
|
+
<div style={inner}>
|
|
268
|
+
{block.url ? (
|
|
269
|
+
<a href={block.url} style={{ color: "inherit" }}>
|
|
270
|
+
{block.text}
|
|
271
|
+
</a>
|
|
272
|
+
) : (
|
|
273
|
+
block.text
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
</aside>
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
case "marquee": {
|
|
280
|
+
const copies = Math.min(
|
|
281
|
+
40,
|
|
282
|
+
Math.max(1, Number.parseInt(block.repeat || "1", 10) || 1),
|
|
283
|
+
);
|
|
284
|
+
return (
|
|
285
|
+
<section style={{ ...outer, paddingLeft: 0, paddingRight: 0 }}>
|
|
286
|
+
<style>
|
|
287
|
+
{"@keyframes ocsm-marquee{from{transform:translateX(0)}to{transform:translateX(-100%)}}"}
|
|
288
|
+
</style>
|
|
289
|
+
<div style={{ overflow: "hidden", whiteSpace: "nowrap" }}>
|
|
290
|
+
<div
|
|
291
|
+
style={{
|
|
292
|
+
display: "inline-block",
|
|
293
|
+
paddingLeft: "100%",
|
|
294
|
+
animation: `ocsm-marquee ${block.speed || "16"}s linear infinite`,
|
|
295
|
+
animationDirection:
|
|
296
|
+
block.direction === "right" ? "reverse" : "normal",
|
|
297
|
+
}}
|
|
298
|
+
>
|
|
299
|
+
{Array.from({ length: copies }).map((_, index) => (
|
|
300
|
+
<span
|
|
301
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: static repeated text
|
|
302
|
+
key={index}
|
|
303
|
+
style={{
|
|
304
|
+
paddingRight: `${block.gap || "40"}px`,
|
|
305
|
+
fontSize: px(block.style.fontSize) ?? "16px",
|
|
306
|
+
fontWeight: 600,
|
|
307
|
+
}}
|
|
308
|
+
>
|
|
309
|
+
{block.text}
|
|
310
|
+
</span>
|
|
311
|
+
))}
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
</section>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
case "video": {
|
|
319
|
+
const src = toEmbedUrl(block.url);
|
|
320
|
+
return (
|
|
321
|
+
<section style={outer}>
|
|
322
|
+
<div style={inner}>
|
|
323
|
+
{src ? (
|
|
324
|
+
<div style={{ position: "relative", paddingBottom: "56.25%", height: 0 }}>
|
|
325
|
+
<iframe
|
|
326
|
+
src={src}
|
|
327
|
+
title="Video"
|
|
328
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
329
|
+
allowFullScreen
|
|
330
|
+
style={{
|
|
331
|
+
position: "absolute",
|
|
332
|
+
inset: 0,
|
|
333
|
+
width: "100%",
|
|
334
|
+
height: "100%",
|
|
335
|
+
border: 0,
|
|
336
|
+
borderRadius: "var(--ocsm-radius, 8px)",
|
|
337
|
+
}}
|
|
338
|
+
/>
|
|
339
|
+
</div>
|
|
340
|
+
) : (
|
|
341
|
+
<Placeholder>Video URL'si girilmedi</Placeholder>
|
|
342
|
+
)}
|
|
343
|
+
</div>
|
|
344
|
+
</section>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
case "spacer":
|
|
349
|
+
return (
|
|
350
|
+
<div
|
|
351
|
+
style={{
|
|
352
|
+
height: `${block.height || "40"}px`,
|
|
353
|
+
background: resolveBackground(block.style) ?? "transparent",
|
|
354
|
+
}}
|
|
355
|
+
/>
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function Placeholder({ children }: { children: React.ReactNode }) {
|
|
361
|
+
return (
|
|
362
|
+
<div
|
|
363
|
+
style={{
|
|
364
|
+
padding: 48,
|
|
365
|
+
textAlign: "center",
|
|
366
|
+
color: "#94a3b8",
|
|
367
|
+
border: "1px dashed #cbd5e1",
|
|
368
|
+
borderRadius: 8,
|
|
369
|
+
}}
|
|
370
|
+
>
|
|
371
|
+
{children}
|
|
372
|
+
</div>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function buttonStyle(color: string): CSSProperties {
|
|
377
|
+
return {
|
|
378
|
+
display: "inline-block",
|
|
379
|
+
background: color || "#2563eb",
|
|
380
|
+
color: "#fff",
|
|
381
|
+
padding: "12px 28px",
|
|
382
|
+
borderRadius: "var(--ocsm-radius, 8px)",
|
|
383
|
+
textDecoration: "none",
|
|
384
|
+
fontWeight: 600,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function resolveBackground(style: BlockStyle): string | undefined {
|
|
389
|
+
if (style.bgType === "gradient") {
|
|
390
|
+
const angle = style.gradientAngle || "135";
|
|
391
|
+
const from = style.gradientFrom || "#7c3aed";
|
|
392
|
+
const to = style.gradientTo || "#2563eb";
|
|
393
|
+
return `linear-gradient(${angle}deg, ${from}, ${to})`;
|
|
394
|
+
}
|
|
395
|
+
return style.background || undefined;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function sectionStyle(style: BlockStyle): CSSProperties {
|
|
399
|
+
return {
|
|
400
|
+
background: resolveBackground(style),
|
|
401
|
+
color: style.textColor || undefined,
|
|
402
|
+
textAlign: style.textAlign,
|
|
403
|
+
fontFamily: style.fontFamily || undefined,
|
|
404
|
+
fontWeight: style.fontWeight || undefined,
|
|
405
|
+
paddingTop: px(style.paddingY) ?? "0",
|
|
406
|
+
paddingBottom: px(style.paddingY) ?? "0",
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function innerStyle(style: BlockStyle): CSSProperties {
|
|
411
|
+
return {
|
|
412
|
+
maxWidth: style.maxWidth === "full" ? "100%" : (px(style.maxWidth) ?? "720px"),
|
|
413
|
+
margin: "0 auto",
|
|
414
|
+
paddingLeft: 24,
|
|
415
|
+
paddingRight: 24,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function toEmbedUrl(url: string): string {
|
|
420
|
+
if (!url) return "";
|
|
421
|
+
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/);
|
|
422
|
+
if (match) return `https://www.youtube.com/embed/${match[1]}`;
|
|
423
|
+
return url;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function px(value: string): string | undefined {
|
|
427
|
+
return value ? `${value}px` : undefined;
|
|
428
|
+
}
|