@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +47 -0
  3. package/package.json +53 -0
  4. package/src/admin/admin.module.css +1312 -0
  5. package/src/admin/admin.types.ts +85 -0
  6. package/src/admin/components/access-denied.tsx +12 -0
  7. package/src/admin/components/admin-shell.tsx +168 -0
  8. package/src/admin/components/content-list-view.tsx +83 -0
  9. package/src/admin/components/dashboard-view.tsx +113 -0
  10. package/src/admin/components/data-table.tsx +80 -0
  11. package/src/admin/components/document-delete-button.tsx +44 -0
  12. package/src/admin/components/icons.tsx +150 -0
  13. package/src/admin/components/modal.tsx +78 -0
  14. package/src/admin/components/page-builder.tsx +1334 -0
  15. package/src/admin/components/settings-view.tsx +334 -0
  16. package/src/admin/components/sign-out-button.tsx +22 -0
  17. package/src/admin/components/system-view.tsx +77 -0
  18. package/src/admin/components/users-panel.tsx +321 -0
  19. package/src/admin/index.ts +20 -0
  20. package/src/admin/ocsm-admin.tsx +259 -0
  21. package/src/auth/authenticate.ts +76 -0
  22. package/src/auth/index.ts +9 -0
  23. package/src/auth/password.ts +22 -0
  24. package/src/auth/permissions.ts +27 -0
  25. package/src/auth/session.ts +103 -0
  26. package/src/blocks/block-renderer.tsx +428 -0
  27. package/src/blocks/block.types.ts +401 -0
  28. package/src/blocks/index.ts +15 -0
  29. package/src/blocks/markdown.tsx +11 -0
  30. package/src/config/config.schema.ts +28 -0
  31. package/src/config/config.types.ts +16 -0
  32. package/src/config/define-config.ts +19 -0
  33. package/src/config/index.ts +13 -0
  34. package/src/config/resolve-config.ts +10 -0
  35. package/src/content/content-repository.ts +66 -0
  36. package/src/content/content-store.interface.ts +23 -0
  37. package/src/content/content.types.ts +25 -0
  38. package/src/content/create-content-store.ts +18 -0
  39. package/src/content/frontmatter.ts +25 -0
  40. package/src/content/index.ts +12 -0
  41. package/src/index.ts +10 -0
  42. package/src/layout/index.ts +1 -0
  43. package/src/layout/layout-store.ts +27 -0
  44. package/src/roles/index.ts +10 -0
  45. package/src/roles/role-store.ts +95 -0
  46. package/src/roles/role.types.ts +86 -0
  47. package/src/server/create-ocsm.ts +67 -0
  48. package/src/server/documents.ts +28 -0
  49. package/src/server/index.ts +59 -0
  50. package/src/server/render-mdx.tsx +14 -0
  51. package/src/storage/create-file-backend.ts +26 -0
  52. package/src/storage/file-backend.ts +26 -0
  53. package/src/storage/fs-file-backend.ts +43 -0
  54. package/src/storage/github-file-backend.ts +97 -0
  55. package/src/storage/index.ts +8 -0
  56. package/src/storage/json-store.ts +23 -0
  57. package/src/theme/css.ts +28 -0
  58. package/src/theme/index.ts +8 -0
  59. package/src/theme/theme-store.ts +19 -0
  60. package/src/theme/theme.types.ts +53 -0
  61. package/src/types/css-modules.d.ts +4 -0
  62. package/src/update/check-for-updates.ts +50 -0
  63. package/src/update/index.ts +1 -0
  64. package/src/users/index.ts +6 -0
  65. package/src/users/user-store.ts +120 -0
  66. package/src/users/user.types.ts +18 -0
  67. 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
+ }