@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,1334 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { type ReactNode, useEffect, useState, useTransition } from "react";
5
+ import {
6
+ type BackgroundType,
7
+ type Block,
8
+ type BlockStyle,
9
+ type BlockType,
10
+ BLOCK_TYPES,
11
+ createBlock,
12
+ FONT_OPTIONS,
13
+ FONT_WEIGHT_OPTIONS,
14
+ GRADIENT_PRESETS,
15
+ type NavLink,
16
+ type TextAlign,
17
+ } from "../../blocks/block.types";
18
+ import { BlockItem } from "../../blocks/block-renderer";
19
+ import { themeToCssVariables } from "../../theme/css";
20
+ import type { OcsmTheme } from "../../theme/theme.types";
21
+ import type { SaveDocumentAction, SaveLayoutAction } from "../admin.types";
22
+ import styles from "../admin.module.css";
23
+ import {
24
+ IconEye,
25
+ IconEyeOff,
26
+ IconGrip,
27
+ IconLogout,
28
+ IconMonitor,
29
+ IconPhone,
30
+ IconTrash,
31
+ } from "./icons";
32
+
33
+ export interface PageBuilderProps {
34
+ collection: string;
35
+ collectionLabel: string;
36
+ backHref: string;
37
+ saveAction: SaveDocumentAction;
38
+ saveLayout: SaveLayoutAction;
39
+ theme: OcsmTheme;
40
+ initialSlug?: string;
41
+ initialTitle?: string;
42
+ initialBlocks: Block[];
43
+ initialHeader: Block[];
44
+ initialFooter: Block[];
45
+ }
46
+
47
+ type Region = "header" | "body" | "footer";
48
+
49
+ const LABELS = Object.fromEntries(
50
+ BLOCK_TYPES.map((entry) => [entry.type, entry.label]),
51
+ ) as Record<BlockType, string>;
52
+
53
+ const REGION_LABEL: Record<Region, string> = {
54
+ header: "Header",
55
+ body: "Şablon",
56
+ footer: "Footer",
57
+ };
58
+
59
+ export function PageBuilder({
60
+ collection,
61
+ collectionLabel,
62
+ backHref,
63
+ saveAction,
64
+ saveLayout,
65
+ theme,
66
+ initialSlug,
67
+ initialTitle,
68
+ initialBlocks,
69
+ initialHeader,
70
+ initialFooter,
71
+ }: PageBuilderProps) {
72
+ const router = useRouter();
73
+ const [title, setTitle] = useState(initialTitle ?? "");
74
+ const [header, setHeader] = useState<Block[]>(initialHeader);
75
+ const [body, setBody] = useState<Block[]>(initialBlocks);
76
+ const [footer, setFooter] = useState<Block[]>(initialFooter);
77
+ const [selected, setSelected] = useState<{
78
+ region: Region;
79
+ id: string;
80
+ } | null>(null);
81
+ const [addRegion, setAddRegion] = useState<Region | null>(null);
82
+ const [inspectorHeight, setInspectorHeight] = useState(340);
83
+ const [device, setDevice] = useState<"desktop" | "mobile">("desktop");
84
+ const [status, setStatus] = useState<{ ok: boolean; text: string } | null>(
85
+ null,
86
+ );
87
+ const [pending, startTransition] = useTransition();
88
+
89
+ // Saved baselines — updated after a successful save so "dirty" resets.
90
+ const [baseTitle, setBaseTitle] = useState(initialTitle ?? "");
91
+ const [baseBody, setBaseBody] = useState(() => JSON.stringify(initialBlocks));
92
+ const [baseHeader, setBaseHeader] = useState(() =>
93
+ JSON.stringify(initialHeader),
94
+ );
95
+ const [baseFooter, setBaseFooter] = useState(() =>
96
+ JSON.stringify(initialFooter),
97
+ );
98
+
99
+ const isNew = !initialSlug;
100
+
101
+ const dirty =
102
+ title !== baseTitle ||
103
+ JSON.stringify(body) !== baseBody ||
104
+ JSON.stringify(header) !== baseHeader ||
105
+ JSON.stringify(footer) !== baseFooter;
106
+
107
+ // Warn before leaving/refreshing with unsaved changes.
108
+ useEffect(() => {
109
+ if (!dirty) return;
110
+ function onBeforeUnload(event: BeforeUnloadEvent) {
111
+ event.preventDefault();
112
+ event.returnValue = "";
113
+ }
114
+ window.addEventListener("beforeunload", onBeforeUnload);
115
+ return () => window.removeEventListener("beforeunload", onBeforeUnload);
116
+ }, [dirty]);
117
+
118
+ function listOf(region: Region): Block[] {
119
+ if (region === "header") return header;
120
+ if (region === "footer") return footer;
121
+ return body;
122
+ }
123
+
124
+ function setList(region: Region, updater: (list: Block[]) => Block[]) {
125
+ if (region === "header") setHeader(updater);
126
+ else if (region === "footer") setFooter(updater);
127
+ else setBody(updater);
128
+ }
129
+
130
+ const selectedBlock = selected
131
+ ? (listOf(selected.region).find((b) => b.id === selected.id) ?? null)
132
+ : null;
133
+
134
+ function addBlock(region: Region, type: BlockType) {
135
+ const block = createBlock(type);
136
+ setList(region, (list) => [...list, block]);
137
+ setSelected({ region, id: block.id });
138
+ setAddRegion(null);
139
+ }
140
+
141
+ function removeBlock(region: Region, id: string) {
142
+ setList(region, (list) => list.filter((b) => b.id !== id));
143
+ if (selected?.region === region && selected.id === id) setSelected(null);
144
+ }
145
+
146
+ function toggleHidden(region: Region, id: string) {
147
+ setList(region, (list) =>
148
+ list.map((b) =>
149
+ b.id === id ? ({ ...b, hidden: !b.hidden } as Block) : b,
150
+ ),
151
+ );
152
+ }
153
+
154
+ function startResize(event: React.MouseEvent) {
155
+ event.preventDefault();
156
+ const startY = event.clientY;
157
+ const startHeight = inspectorHeight;
158
+ function onMove(moveEvent: MouseEvent) {
159
+ const delta = moveEvent.clientY - startY;
160
+ const next = Math.min(
161
+ Math.max(startHeight + delta, 160),
162
+ window.innerHeight - 220,
163
+ );
164
+ setInspectorHeight(next);
165
+ }
166
+ function onUp() {
167
+ document.removeEventListener("mousemove", onMove);
168
+ document.removeEventListener("mouseup", onUp);
169
+ document.body.style.userSelect = "";
170
+ }
171
+ document.body.style.userSelect = "none";
172
+ document.addEventListener("mousemove", onMove);
173
+ document.addEventListener("mouseup", onUp);
174
+ }
175
+
176
+ function reorder(region: Region, from: number, to: number) {
177
+ setList(region, (list) => {
178
+ if (
179
+ from === to ||
180
+ from < 0 ||
181
+ to < 0 ||
182
+ from >= list.length ||
183
+ to >= list.length
184
+ ) {
185
+ return list;
186
+ }
187
+ const next = [...list];
188
+ const [moved] = next.splice(from, 1);
189
+ if (moved) next.splice(to, 0, moved);
190
+ return next;
191
+ });
192
+ }
193
+
194
+ function patchContent(patch: Record<string, unknown>) {
195
+ if (!selected) return;
196
+ setList(selected.region, (list) =>
197
+ list.map((b) =>
198
+ b.id === selected.id ? ({ ...b, ...patch } as Block) : b,
199
+ ),
200
+ );
201
+ }
202
+
203
+ function patchStyle(patch: Partial<BlockStyle>) {
204
+ if (!selected) return;
205
+ setList(selected.region, (list) =>
206
+ list.map((b) =>
207
+ b.id === selected.id
208
+ ? ({ ...b, style: { ...b.style, ...patch } } as Block)
209
+ : b,
210
+ ),
211
+ );
212
+ }
213
+
214
+ function onPublish() {
215
+ setStatus({ ok: true, text: isNew ? "Yayınlanıyor…" : "Kaydediliyor…" });
216
+ startTransition(async () => {
217
+ const slug = initialSlug ?? slugify(title);
218
+ const results = [await saveAction({ collection, slug, title, blocks: body })];
219
+ if (JSON.stringify(header) !== baseHeader) {
220
+ results.push(await saveLayout("header", header));
221
+ }
222
+ if (JSON.stringify(footer) !== baseFooter) {
223
+ results.push(await saveLayout("footer", footer));
224
+ }
225
+ const failed = results.find((result) => !result.ok);
226
+ setStatus({
227
+ ok: !failed,
228
+ text: failed
229
+ ? (failed.message ?? "Hata oluştu")
230
+ : isNew
231
+ ? "Yayınlandı ✓"
232
+ : "Kaydedildi ✓",
233
+ });
234
+ if (!failed) {
235
+ setBaseTitle(title);
236
+ setBaseBody(JSON.stringify(body));
237
+ setBaseHeader(JSON.stringify(header));
238
+ setBaseFooter(JSON.stringify(footer));
239
+ }
240
+ if (!failed && isNew) router.push(`/ocsm-admin/${collection}/${slug}`);
241
+ else router.refresh();
242
+ });
243
+ }
244
+
245
+ const canvasTheme = {
246
+ background: theme.backgroundColor,
247
+ color: theme.textColor,
248
+ fontFamily: theme.fontFamily,
249
+ ...themeToCssVariables(theme),
250
+ } as React.CSSProperties;
251
+
252
+ const totalBlocks = header.length + body.length + footer.length;
253
+
254
+ function renderRegion(region: Region) {
255
+ return listOf(region)
256
+ .filter((block) => !block.hidden)
257
+ .map((block) => (
258
+ <div
259
+ key={block.id}
260
+ data-label={`${REGION_LABEL[region]} · ${LABELS[block.type]}`}
261
+ className={`${styles.fsSelectable} ${
262
+ selected?.region === region && selected.id === block.id
263
+ ? styles.fsSelectableActive
264
+ : ""
265
+ }`}
266
+ onClick={() => setSelected({ region, id: block.id })}
267
+ onKeyDown={(event) => {
268
+ if (event.key === "Enter") setSelected({ region, id: block.id });
269
+ }}
270
+ >
271
+ <BlockItem block={block} />
272
+ </div>
273
+ ));
274
+ }
275
+
276
+ return (
277
+ <div className={styles.fsBuilder}>
278
+ <div className={styles.fsTop}>
279
+ <a
280
+ className={styles.fsExit}
281
+ href={backHref}
282
+ title={`Çıkış — ${collectionLabel}`}
283
+ aria-label="Editörden çık"
284
+ >
285
+ <IconLogout
286
+ width={18}
287
+ height={18}
288
+ style={{ transform: "scaleX(-1)" }}
289
+ />
290
+ </a>
291
+ <input
292
+ className={styles.fsTitleInput}
293
+ value={title}
294
+ onChange={(event) => setTitle(event.target.value)}
295
+ placeholder="Sayfa başlığı"
296
+ />
297
+ <div className={styles.fsTopRight}>
298
+ {status ? (
299
+ <span className={styles.fsStatus}>{status.text}</span>
300
+ ) : dirty ? (
301
+ <span className={styles.fsDirty}>● Kaydedilmemiş</span>
302
+ ) : null}
303
+ <div className={styles.fsDeviceToggle}>
304
+ <button
305
+ type="button"
306
+ className={`${styles.fsDeviceBtn} ${
307
+ device === "desktop" ? styles.fsDeviceBtnActive : ""
308
+ }`}
309
+ onClick={() => setDevice("desktop")}
310
+ title="Masaüstü önizleme"
311
+ aria-label="Masaüstü önizleme"
312
+ >
313
+ <IconMonitor width={17} height={17} />
314
+ </button>
315
+ <button
316
+ type="button"
317
+ className={`${styles.fsDeviceBtn} ${
318
+ device === "mobile" ? styles.fsDeviceBtnActive : ""
319
+ }`}
320
+ onClick={() => setDevice("mobile")}
321
+ title="Telefon önizleme"
322
+ aria-label="Telefon önizleme"
323
+ >
324
+ <IconPhone width={17} height={17} />
325
+ </button>
326
+ </div>
327
+ <button
328
+ type="button"
329
+ className={`${styles.btn} ${styles.btnPrimary}`}
330
+ onClick={onPublish}
331
+ disabled={pending}
332
+ >
333
+ {pending
334
+ ? isNew
335
+ ? "Yayınlanıyor…"
336
+ : "Kaydediliyor…"
337
+ : isNew
338
+ ? "Yayınla"
339
+ : "Kaydet"}
340
+ </button>
341
+ </div>
342
+ </div>
343
+
344
+ <div className={styles.fsBody}>
345
+ <aside className={styles.fsPanel}>
346
+ {selectedBlock ? (
347
+ <>
348
+ <div
349
+ className={styles.fsInspectorPane}
350
+ style={{ height: inspectorHeight }}
351
+ >
352
+ <div className={styles.fsPaneHead}>
353
+ <span>{LABELS[selectedBlock.type]} ayarları</span>
354
+ <button
355
+ type="button"
356
+ className={styles.fsMiniBtn}
357
+ onClick={() => setSelected(null)}
358
+ title="Kapat"
359
+ >
360
+
361
+ </button>
362
+ </div>
363
+ <div className={styles.fsPaneBody}>
364
+ <Inspector
365
+ block={selectedBlock}
366
+ onContent={patchContent}
367
+ onStyle={patchStyle}
368
+ />
369
+ </div>
370
+ </div>
371
+ <button
372
+ type="button"
373
+ className={styles.fsResizer}
374
+ onMouseDown={startResize}
375
+ title="Sürükleyerek boyutlandır"
376
+ aria-label="Bölmeleri yeniden boyutlandır"
377
+ >
378
+ <span className={styles.fsResizerGrip} />
379
+ </button>
380
+ </>
381
+ ) : null}
382
+
383
+ <div className={styles.fsSectionsPane}>
384
+ <SectionGroup
385
+ title="Header"
386
+ note="Tüm sayfalarda görünür"
387
+ region="header"
388
+ blocks={header}
389
+ selectedId={selected?.region === "header" ? selected.id : null}
390
+ addOpen={addRegion === "header"}
391
+ onToggleAdd={() =>
392
+ setAddRegion((r) => (r === "header" ? null : "header"))
393
+ }
394
+ onSelect={(id) => setSelected({ region: "header", id })}
395
+ onRemove={(id) => removeBlock("header", id)}
396
+ onToggleHidden={(id) => toggleHidden("header", id)}
397
+ onReorder={(from, to) => reorder("header", from, to)}
398
+ onAdd={(type) => addBlock("header", type)}
399
+ />
400
+ <SectionGroup
401
+ title="Şablon"
402
+ note="Yalnızca bu sayfa"
403
+ region="body"
404
+ blocks={body}
405
+ selectedId={selected?.region === "body" ? selected.id : null}
406
+ addOpen={addRegion === "body"}
407
+ onToggleAdd={() =>
408
+ setAddRegion((r) => (r === "body" ? null : "body"))
409
+ }
410
+ onSelect={(id) => setSelected({ region: "body", id })}
411
+ onRemove={(id) => removeBlock("body", id)}
412
+ onToggleHidden={(id) => toggleHidden("body", id)}
413
+ onReorder={(from, to) => reorder("body", from, to)}
414
+ onAdd={(type) => addBlock("body", type)}
415
+ />
416
+ <SectionGroup
417
+ title="Footer"
418
+ note="Tüm sayfalarda görünür"
419
+ region="footer"
420
+ blocks={footer}
421
+ selectedId={selected?.region === "footer" ? selected.id : null}
422
+ addOpen={addRegion === "footer"}
423
+ onToggleAdd={() =>
424
+ setAddRegion((r) => (r === "footer" ? null : "footer"))
425
+ }
426
+ onSelect={(id) => setSelected({ region: "footer", id })}
427
+ onRemove={(id) => removeBlock("footer", id)}
428
+ onToggleHidden={(id) => toggleHidden("footer", id)}
429
+ onReorder={(from, to) => reorder("footer", from, to)}
430
+ onAdd={(type) => addBlock("footer", type)}
431
+ />
432
+ </div>
433
+ </aside>
434
+
435
+ <div className={styles.fsCanvas} data-device={device}>
436
+ <div className={styles.fsStage} data-device={device} style={canvasTheme}>
437
+ {totalBlocks === 0 ? (
438
+ <div className={styles.fsCanvasEmpty}>
439
+ Soldan bölüm ekleyerek sayfanı oluştur.
440
+ </div>
441
+ ) : (
442
+ <>
443
+ {renderRegion("header")}
444
+ {renderRegion("body")}
445
+ {renderRegion("footer")}
446
+ </>
447
+ )}
448
+ </div>
449
+ </div>
450
+ </div>
451
+ </div>
452
+ );
453
+ }
454
+
455
+ function SectionGroup({
456
+ title,
457
+ note,
458
+ region,
459
+ blocks,
460
+ selectedId,
461
+ addOpen,
462
+ onToggleAdd,
463
+ onSelect,
464
+ onRemove,
465
+ onToggleHidden,
466
+ onReorder,
467
+ onAdd,
468
+ }: {
469
+ title: string;
470
+ note: string;
471
+ region: Region;
472
+ blocks: Block[];
473
+ selectedId: string | null;
474
+ addOpen: boolean;
475
+ onToggleAdd: () => void;
476
+ onSelect: (id: string) => void;
477
+ onRemove: (id: string) => void;
478
+ onToggleHidden: (id: string) => void;
479
+ onReorder: (from: number, to: number) => void;
480
+ onAdd: (type: BlockType) => void;
481
+ }) {
482
+ const [dragIndex, setDragIndex] = useState<number | null>(null);
483
+ const [overIndex, setOverIndex] = useState<number | null>(null);
484
+
485
+ return (
486
+ <div className={styles.fsGroupSection}>
487
+ <div className={styles.fsGroupHead}>
488
+ <span className={styles.fsGroupName}>{title}</span>
489
+ <span className={styles.fsGroupNote}>{note}</span>
490
+ </div>
491
+ <div className={styles.fsList}>
492
+ {blocks.map((block, index) => (
493
+ <div
494
+ key={block.id}
495
+ draggable
496
+ onDragStart={() => setDragIndex(index)}
497
+ onDragEnter={() => setOverIndex(index)}
498
+ onDragOver={(event) => event.preventDefault()}
499
+ onDrop={() => {
500
+ if (dragIndex !== null) onReorder(dragIndex, index);
501
+ setDragIndex(null);
502
+ setOverIndex(null);
503
+ }}
504
+ onDragEnd={() => {
505
+ setDragIndex(null);
506
+ setOverIndex(null);
507
+ }}
508
+ className={`${styles.fsRow} ${
509
+ block.id === selectedId ? styles.fsRowActive : ""
510
+ } ${
511
+ overIndex === index && dragIndex !== null && dragIndex !== index
512
+ ? styles.fsRowDrag
513
+ : ""
514
+ }`}
515
+ onClick={() => onSelect(block.id)}
516
+ onKeyDown={(event) => {
517
+ if (event.key === "Enter") onSelect(block.id);
518
+ }}
519
+ >
520
+ <span className={styles.fsGrip} title="Sürükleyerek sırala">
521
+ <IconGrip />
522
+ </span>
523
+ <span
524
+ className={styles.fsRowLabel}
525
+ style={
526
+ block.hidden
527
+ ? { opacity: 0.5, textDecoration: "line-through" }
528
+ : undefined
529
+ }
530
+ >
531
+ {LABELS[block.type]}
532
+ </span>
533
+ <button
534
+ type="button"
535
+ className={styles.fsMiniBtn}
536
+ onClick={(event) => {
537
+ event.stopPropagation();
538
+ onToggleHidden(block.id);
539
+ }}
540
+ title={block.hidden ? "Göster" : "Gizle"}
541
+ >
542
+ {block.hidden ? (
543
+ <IconEyeOff width={14} height={14} />
544
+ ) : (
545
+ <IconEye width={14} height={14} />
546
+ )}
547
+ </button>
548
+ <button
549
+ type="button"
550
+ className={styles.fsMiniBtn}
551
+ onClick={(event) => {
552
+ event.stopPropagation();
553
+ onRemove(block.id);
554
+ }}
555
+ title="Sil"
556
+ >
557
+ <IconTrash width={14} height={14} />
558
+ </button>
559
+ </div>
560
+ ))}
561
+
562
+ <button type="button" className={styles.fsAddCard} onClick={onToggleAdd}>
563
+ + Bölüm ekle
564
+ </button>
565
+
566
+ {addOpen ? (
567
+ <div className={styles.fsAddGrid}>
568
+ {BLOCK_TYPES.filter(
569
+ (entry) => region === "header" || !entry.headerOnly,
570
+ ).map((entry) => (
571
+ <button
572
+ key={entry.type}
573
+ type="button"
574
+ className={styles.fsAddBtn}
575
+ onClick={() => onAdd(entry.type)}
576
+ >
577
+ <span className={styles.fsAddIcon}>{entry.icon}</span>
578
+ {entry.label}
579
+ </button>
580
+ ))}
581
+ </div>
582
+ ) : null}
583
+ </div>
584
+ </div>
585
+ );
586
+ }
587
+
588
+ function Inspector({
589
+ block,
590
+ onContent,
591
+ onStyle,
592
+ }: {
593
+ block: Block;
594
+ onContent: (patch: Record<string, unknown>) => void;
595
+ onStyle: (patch: Partial<BlockStyle>) => void;
596
+ }) {
597
+ const noAlign = block.type === "marquee" || block.type === "navbar";
598
+ return (
599
+ <>
600
+ <div className={styles.fsGroup}>
601
+ <h3 className={styles.fsGroupTitle}>İçerik</h3>
602
+ <ContentFields block={block} onChange={onContent} />
603
+ </div>
604
+ {block.type === "spacer" ? (
605
+ <div className={styles.fsGroup}>
606
+ <h3 className={styles.fsGroupTitle}>Arka plan</h3>
607
+ <BackgroundFields style={block.style} onChange={onStyle} />
608
+ </div>
609
+ ) : (
610
+ <div className={styles.fsGroup}>
611
+ <h3 className={styles.fsGroupTitle}>Stil</h3>
612
+ <StyleFields
613
+ style={block.style}
614
+ onChange={onStyle}
615
+ align={!noAlign}
616
+ width={block.type !== "marquee"}
617
+ />
618
+ </div>
619
+ )}
620
+ </>
621
+ );
622
+ }
623
+
624
+ function ContentFields({
625
+ block,
626
+ onChange,
627
+ }: {
628
+ block: Block;
629
+ onChange: (patch: Record<string, unknown>) => void;
630
+ }) {
631
+ switch (block.type) {
632
+ case "navbar":
633
+ return (
634
+ <>
635
+ <Control label="Logo metni">
636
+ <input
637
+ className={styles.fsInput}
638
+ value={block.logoText}
639
+ onChange={(e) => onChange({ logoText: e.target.value })}
640
+ />
641
+ </Control>
642
+ <Control label="Logo görseli (opsiyonel URL)">
643
+ <input
644
+ className={styles.fsInput}
645
+ value={block.logoImageUrl}
646
+ placeholder="https://… (boşsa metin gösterilir)"
647
+ onChange={(e) => onChange({ logoImageUrl: e.target.value })}
648
+ />
649
+ </Control>
650
+ <Control label="Logo bağlantısı">
651
+ <input
652
+ className={styles.fsInput}
653
+ value={block.logoHref}
654
+ onChange={(e) => onChange({ logoHref: e.target.value })}
655
+ />
656
+ </Control>
657
+ <Control label="Logo hizası">
658
+ <SegControl
659
+ value={block.logoAlign}
660
+ options={[
661
+ { value: "left", label: "Sol" },
662
+ { value: "center", label: "Orta" },
663
+ { value: "right", label: "Sağ" },
664
+ ]}
665
+ onChange={(v) => onChange({ logoAlign: v })}
666
+ />
667
+ </Control>
668
+ <Control label="Bağlantılar">
669
+ <NavLinksEditor
670
+ links={block.links}
671
+ onChange={(links) => onChange({ links })}
672
+ />
673
+ </Control>
674
+ <Control label="Bağlantı hizası">
675
+ <SegControl
676
+ value={block.linksAlign}
677
+ options={[
678
+ { value: "left", label: "Sol" },
679
+ { value: "center", label: "Orta" },
680
+ { value: "right", label: "Sağ" },
681
+ ]}
682
+ onChange={(v) => onChange({ linksAlign: v })}
683
+ />
684
+ </Control>
685
+ <Control label="Konumlandırma">
686
+ <SegControl
687
+ value={block.sticky ? "sticky" : "normal"}
688
+ options={[
689
+ { value: "normal", label: "Normal" },
690
+ { value: "sticky", label: "Yapışkan" },
691
+ ]}
692
+ onChange={(v) => onChange({ sticky: v === "sticky" })}
693
+ />
694
+ </Control>
695
+ </>
696
+ );
697
+
698
+ case "hero":
699
+ return (
700
+ <>
701
+ <Control label="Başlık">
702
+ <input
703
+ className={styles.fsInput}
704
+ value={block.heading}
705
+ onChange={(e) => onChange({ heading: e.target.value })}
706
+ />
707
+ </Control>
708
+ <Control label="Alt başlık">
709
+ <textarea
710
+ className={styles.fsTextarea}
711
+ rows={2}
712
+ value={block.subheading}
713
+ onChange={(e) => onChange({ subheading: e.target.value })}
714
+ />
715
+ </Control>
716
+ <div className={styles.fsRowGrid}>
717
+ <Control label="Buton metni">
718
+ <input
719
+ className={styles.fsInput}
720
+ value={block.buttonLabel}
721
+ onChange={(e) => onChange({ buttonLabel: e.target.value })}
722
+ />
723
+ </Control>
724
+ <Control label="Buton linki">
725
+ <input
726
+ className={styles.fsInput}
727
+ value={block.buttonUrl}
728
+ onChange={(e) => onChange({ buttonUrl: e.target.value })}
729
+ />
730
+ </Control>
731
+ </div>
732
+ <Control label="Buton rengi">
733
+ <ColorControl
734
+ value={block.buttonColor}
735
+ onChange={(v) => onChange({ buttonColor: v })}
736
+ />
737
+ </Control>
738
+ </>
739
+ );
740
+
741
+ case "heading":
742
+ return (
743
+ <>
744
+ <Control label="Başlık metni">
745
+ <input
746
+ className={styles.fsInput}
747
+ value={block.text}
748
+ onChange={(e) => onChange({ text: e.target.value })}
749
+ />
750
+ </Control>
751
+ <Control label="Seviye">
752
+ <select
753
+ className={styles.fsSelect}
754
+ value={block.level}
755
+ onChange={(e) => onChange({ level: Number(e.target.value) })}
756
+ >
757
+ <option value={1}>H1</option>
758
+ <option value={2}>H2</option>
759
+ <option value={3}>H3</option>
760
+ </select>
761
+ </Control>
762
+ </>
763
+ );
764
+
765
+ case "richText":
766
+ return (
767
+ <Control label="Metin (Markdown)">
768
+ <textarea
769
+ className={styles.fsTextarea}
770
+ rows={9}
771
+ value={block.markdown}
772
+ onChange={(e) => onChange({ markdown: e.target.value })}
773
+ />
774
+ </Control>
775
+ );
776
+
777
+ case "image":
778
+ return (
779
+ <>
780
+ <Control label="Görsel URL">
781
+ <input
782
+ className={styles.fsInput}
783
+ value={block.url}
784
+ placeholder="https://…"
785
+ onChange={(e) => onChange({ url: e.target.value })}
786
+ />
787
+ </Control>
788
+ <Control label="Alternatif metin (erişilebilirlik)">
789
+ <input
790
+ className={styles.fsInput}
791
+ value={block.alt}
792
+ onChange={(e) => onChange({ alt: e.target.value })}
793
+ />
794
+ </Control>
795
+ <Control label="Bağlantı (opsiyonel)">
796
+ <input
797
+ className={styles.fsInput}
798
+ value={block.link}
799
+ placeholder="Tıklanınca gidilecek adres"
800
+ onChange={(e) => onChange({ link: e.target.value })}
801
+ />
802
+ </Control>
803
+ <Control label="Açıklama (caption)">
804
+ <input
805
+ className={styles.fsInput}
806
+ value={block.caption}
807
+ onChange={(e) => onChange({ caption: e.target.value })}
808
+ />
809
+ </Control>
810
+ <div className={styles.fsRowGrid}>
811
+ <Control label="Genişlik (%)">
812
+ <input
813
+ className={styles.fsInput}
814
+ type="number"
815
+ value={block.width}
816
+ onChange={(e) => onChange({ width: e.target.value })}
817
+ />
818
+ </Control>
819
+ <Control label="Yükseklik (px, boş = otomatik)">
820
+ <input
821
+ className={styles.fsInput}
822
+ type="number"
823
+ value={block.height}
824
+ onChange={(e) => onChange({ height: e.target.value })}
825
+ />
826
+ </Control>
827
+ </div>
828
+ <div className={styles.fsRowGrid}>
829
+ <Control label="Köşe yuvarlaklığı (px)">
830
+ <input
831
+ className={styles.fsInput}
832
+ type="number"
833
+ value={block.radius}
834
+ onChange={(e) => onChange({ radius: e.target.value })}
835
+ />
836
+ </Control>
837
+ <Control label="Sığdırma (yükseklik varsa)">
838
+ <select
839
+ className={styles.fsSelect}
840
+ value={block.fit}
841
+ onChange={(e) => onChange({ fit: e.target.value })}
842
+ >
843
+ <option value="cover">Doldur (kırparak)</option>
844
+ <option value="contain">Sığdır (tümü görünür)</option>
845
+ <option value="fill">Esnet</option>
846
+ </select>
847
+ </Control>
848
+ </div>
849
+ </>
850
+ );
851
+
852
+ case "button":
853
+ return (
854
+ <>
855
+ <div className={styles.fsRowGrid}>
856
+ <Control label="Metin">
857
+ <input
858
+ className={styles.fsInput}
859
+ value={block.label}
860
+ onChange={(e) => onChange({ label: e.target.value })}
861
+ />
862
+ </Control>
863
+ <Control label="Link">
864
+ <input
865
+ className={styles.fsInput}
866
+ value={block.url}
867
+ onChange={(e) => onChange({ url: e.target.value })}
868
+ />
869
+ </Control>
870
+ </div>
871
+ <Control label="Buton rengi">
872
+ <ColorControl
873
+ value={block.buttonColor}
874
+ onChange={(v) => onChange({ buttonColor: v })}
875
+ />
876
+ </Control>
877
+ </>
878
+ );
879
+
880
+ case "quote":
881
+ return (
882
+ <>
883
+ <Control label="Alıntı">
884
+ <textarea
885
+ className={styles.fsTextarea}
886
+ rows={3}
887
+ value={block.text}
888
+ onChange={(e) => onChange({ text: e.target.value })}
889
+ />
890
+ </Control>
891
+ <Control label="Kaynak / yazar">
892
+ <input
893
+ className={styles.fsInput}
894
+ value={block.author}
895
+ onChange={(e) => onChange({ author: e.target.value })}
896
+ />
897
+ </Control>
898
+ </>
899
+ );
900
+
901
+ case "banner":
902
+ return (
903
+ <>
904
+ <Control label="Metin">
905
+ <input
906
+ className={styles.fsInput}
907
+ value={block.text}
908
+ onChange={(e) => onChange({ text: e.target.value })}
909
+ />
910
+ </Control>
911
+ <Control label="Link (opsiyonel)">
912
+ <input
913
+ className={styles.fsInput}
914
+ value={block.url}
915
+ onChange={(e) => onChange({ url: e.target.value })}
916
+ />
917
+ </Control>
918
+ </>
919
+ );
920
+
921
+ case "marquee":
922
+ return (
923
+ <>
924
+ <Control label="Kayan metin">
925
+ <input
926
+ className={styles.fsInput}
927
+ value={block.text}
928
+ onChange={(e) => onChange({ text: e.target.value })}
929
+ />
930
+ </Control>
931
+ <div className={styles.fsRowGrid}>
932
+ <Control label="Yön">
933
+ <select
934
+ className={styles.fsSelect}
935
+ value={block.direction}
936
+ onChange={(e) => onChange({ direction: e.target.value })}
937
+ >
938
+ <option value="left">Sola</option>
939
+ <option value="right">Sağa</option>
940
+ </select>
941
+ </Control>
942
+ <Control label="Hız (sn, düşük = hızlı)">
943
+ <input
944
+ className={styles.fsInput}
945
+ type="number"
946
+ value={block.speed}
947
+ onChange={(e) => onChange({ speed: e.target.value })}
948
+ />
949
+ </Control>
950
+ </div>
951
+ <div className={styles.fsRowGrid}>
952
+ <Control label="Tekrar (kopya sayısı)">
953
+ <input
954
+ className={styles.fsInput}
955
+ type="number"
956
+ value={block.repeat}
957
+ onChange={(e) => onChange({ repeat: e.target.value })}
958
+ />
959
+ </Control>
960
+ <Control label="Kopyalar arası boşluk (px)">
961
+ <input
962
+ className={styles.fsInput}
963
+ type="number"
964
+ value={block.gap}
965
+ onChange={(e) => onChange({ gap: e.target.value })}
966
+ />
967
+ </Control>
968
+ </div>
969
+ </>
970
+ );
971
+
972
+ case "video":
973
+ return (
974
+ <Control label="Video URL (YouTube veya embed)">
975
+ <input
976
+ className={styles.fsInput}
977
+ value={block.url}
978
+ placeholder="https://youtube.com/watch?v=…"
979
+ onChange={(e) => onChange({ url: e.target.value })}
980
+ />
981
+ </Control>
982
+ );
983
+
984
+ case "spacer":
985
+ return (
986
+ <Control label="Yükseklik (px)">
987
+ <input
988
+ className={styles.fsInput}
989
+ type="number"
990
+ value={block.height}
991
+ onChange={(e) => onChange({ height: e.target.value })}
992
+ />
993
+ </Control>
994
+ );
995
+ }
996
+ }
997
+
998
+ function StyleFields({
999
+ style,
1000
+ onChange,
1001
+ align = true,
1002
+ width = true,
1003
+ }: {
1004
+ style: BlockStyle;
1005
+ onChange: (patch: Partial<BlockStyle>) => void;
1006
+ align?: boolean;
1007
+ width?: boolean;
1008
+ }) {
1009
+ return (
1010
+ <>
1011
+ {align ? (
1012
+ <Control label="Hizalama">
1013
+ <SegControl
1014
+ value={style.textAlign}
1015
+ options={[
1016
+ { value: "left", label: "Sola" },
1017
+ { value: "center", label: "Orta" },
1018
+ { value: "right", label: "Sağa" },
1019
+ ]}
1020
+ onChange={(v) => onChange({ textAlign: v })}
1021
+ />
1022
+ </Control>
1023
+ ) : null}
1024
+
1025
+ <BackgroundFields style={style} onChange={onChange} />
1026
+
1027
+ <Control label="Yazı rengi">
1028
+ <ColorControl
1029
+ value={style.textColor}
1030
+ onChange={(v) => onChange({ textColor: v })}
1031
+ />
1032
+ </Control>
1033
+ <Control label="Font">
1034
+ <select
1035
+ className={styles.fsSelect}
1036
+ value={style.fontFamily}
1037
+ onChange={(e) => onChange({ fontFamily: e.target.value })}
1038
+ >
1039
+ {FONT_OPTIONS.map((option) => (
1040
+ <option key={option.label} value={option.value}>
1041
+ {option.label}
1042
+ </option>
1043
+ ))}
1044
+ </select>
1045
+ </Control>
1046
+ <div className={styles.fsRowGrid}>
1047
+ <Control label="Yazı boyutu (px)">
1048
+ <input
1049
+ className={styles.fsInput}
1050
+ type="number"
1051
+ placeholder="otomatik"
1052
+ value={style.fontSize}
1053
+ onChange={(e) => onChange({ fontSize: e.target.value })}
1054
+ />
1055
+ </Control>
1056
+ <Control label="Kalınlık">
1057
+ <select
1058
+ className={styles.fsSelect}
1059
+ value={style.fontWeight}
1060
+ onChange={(e) => onChange({ fontWeight: e.target.value })}
1061
+ >
1062
+ {FONT_WEIGHT_OPTIONS.map((option) => (
1063
+ <option key={option.label} value={option.value}>
1064
+ {option.label}
1065
+ </option>
1066
+ ))}
1067
+ </select>
1068
+ </Control>
1069
+ </div>
1070
+ {width ? (
1071
+ <div className={styles.fsRowGrid}>
1072
+ <Control label="Dikey boşluk (px)">
1073
+ <input
1074
+ className={styles.fsInput}
1075
+ type="number"
1076
+ value={style.paddingY}
1077
+ onChange={(e) => onChange({ paddingY: e.target.value })}
1078
+ />
1079
+ </Control>
1080
+ <Control label="Genişlik">
1081
+ <select
1082
+ className={styles.fsSelect}
1083
+ value={style.maxWidth}
1084
+ onChange={(e) => onChange({ maxWidth: e.target.value })}
1085
+ >
1086
+ {MAX_WIDTH_OPTIONS.map((option) => (
1087
+ <option key={option.value} value={option.value}>
1088
+ {option.label}
1089
+ </option>
1090
+ ))}
1091
+ </select>
1092
+ </Control>
1093
+ </div>
1094
+ ) : (
1095
+ <Control label="Dikey boşluk (px)">
1096
+ <input
1097
+ className={styles.fsInput}
1098
+ type="number"
1099
+ value={style.paddingY}
1100
+ onChange={(e) => onChange({ paddingY: e.target.value })}
1101
+ />
1102
+ </Control>
1103
+ )}
1104
+ </>
1105
+ );
1106
+ }
1107
+
1108
+ function BackgroundFields({
1109
+ style,
1110
+ onChange,
1111
+ }: {
1112
+ style: BlockStyle;
1113
+ onChange: (patch: Partial<BlockStyle>) => void;
1114
+ }) {
1115
+ return (
1116
+ <>
1117
+ <Control label="Arka plan tipi">
1118
+ <SegControl
1119
+ value={style.bgType}
1120
+ options={[
1121
+ { value: "solid", label: "Düz" },
1122
+ { value: "gradient", label: "Degrade" },
1123
+ ]}
1124
+ onChange={(v) => onChange({ bgType: v as BackgroundType })}
1125
+ />
1126
+ </Control>
1127
+
1128
+ {style.bgType === "solid" ? (
1129
+ <Control label="Arka plan rengi">
1130
+ <ColorControl
1131
+ value={style.background}
1132
+ onChange={(v) => onChange({ background: v })}
1133
+ />
1134
+ </Control>
1135
+ ) : (
1136
+ <>
1137
+ <div className={styles.fsRowGrid}>
1138
+ <Control label="Arka plan başlangıç">
1139
+ <ColorControl
1140
+ value={style.gradientFrom}
1141
+ onChange={(v) => onChange({ gradientFrom: v })}
1142
+ />
1143
+ </Control>
1144
+ <Control label="Arka plan bitiş">
1145
+ <ColorControl
1146
+ value={style.gradientTo}
1147
+ onChange={(v) => onChange({ gradientTo: v })}
1148
+ />
1149
+ </Control>
1150
+ </div>
1151
+ <Control label="Arka plan açısı (derece)">
1152
+ <input
1153
+ className={styles.fsInput}
1154
+ type="number"
1155
+ value={style.gradientAngle}
1156
+ onChange={(e) => onChange({ gradientAngle: e.target.value })}
1157
+ />
1158
+ </Control>
1159
+ <Control label="Arka plan hazır degradeler">
1160
+ <div className={styles.fsPresets}>
1161
+ {GRADIENT_PRESETS.map((preset) => (
1162
+ <button
1163
+ key={preset.label}
1164
+ type="button"
1165
+ className={styles.fsPreset}
1166
+ title={preset.label}
1167
+ style={{
1168
+ background: `linear-gradient(${preset.angle}deg, ${preset.from}, ${preset.to})`,
1169
+ }}
1170
+ onClick={() =>
1171
+ onChange({
1172
+ gradientFrom: preset.from,
1173
+ gradientTo: preset.to,
1174
+ gradientAngle: preset.angle,
1175
+ })
1176
+ }
1177
+ />
1178
+ ))}
1179
+ </div>
1180
+ </Control>
1181
+ </>
1182
+ )}
1183
+ </>
1184
+ );
1185
+ }
1186
+
1187
+ /* ---------------- Small controls ---------------- */
1188
+
1189
+ const MAX_WIDTH_OPTIONS = [
1190
+ { label: "Dar", value: "640" },
1191
+ { label: "Normal", value: "720" },
1192
+ { label: "Geniş", value: "960" },
1193
+ { label: "Tam genişlik", value: "full" },
1194
+ ];
1195
+
1196
+ function NavLinksEditor({
1197
+ links,
1198
+ onChange,
1199
+ }: {
1200
+ links: NavLink[];
1201
+ onChange: (links: NavLink[]) => void;
1202
+ }) {
1203
+ function update(index: number, patch: Partial<NavLink>) {
1204
+ onChange(links.map((link, i) => (i === index ? { ...link, ...patch } : link)));
1205
+ }
1206
+ return (
1207
+ <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
1208
+ {links.map((link, index) => (
1209
+ <div
1210
+ // biome-ignore lint/suspicious/noArrayIndexKey: nav links edited in place
1211
+ key={index}
1212
+ style={{ display: "flex", gap: 6, alignItems: "center" }}
1213
+ >
1214
+ <input
1215
+ className={styles.fsInput}
1216
+ value={link.label}
1217
+ placeholder="Etiket"
1218
+ onChange={(event) => update(index, { label: event.target.value })}
1219
+ />
1220
+ <input
1221
+ className={styles.fsInput}
1222
+ value={link.url}
1223
+ placeholder="URL"
1224
+ onChange={(event) => update(index, { url: event.target.value })}
1225
+ />
1226
+ <button
1227
+ type="button"
1228
+ className={styles.fsMiniBtn}
1229
+ onClick={() => onChange(links.filter((_, i) => i !== index))}
1230
+ title="Kaldır"
1231
+ >
1232
+ <IconTrash width={14} height={14} />
1233
+ </button>
1234
+ </div>
1235
+ ))}
1236
+ <button
1237
+ type="button"
1238
+ className={styles.fsAddCard}
1239
+ onClick={() => onChange([...links, { label: "Yeni bağlantı", url: "#" }])}
1240
+ >
1241
+ + Bağlantı ekle
1242
+ </button>
1243
+ </div>
1244
+ );
1245
+ }
1246
+
1247
+ function Control({ label, children }: { label: string; children: ReactNode }) {
1248
+ return (
1249
+ <div className={styles.fsControl}>
1250
+ <span className={styles.fsControlLabel}>{label}</span>
1251
+ {children}
1252
+ </div>
1253
+ );
1254
+ }
1255
+
1256
+ function SegControl<T extends string>({
1257
+ value,
1258
+ options,
1259
+ onChange,
1260
+ }: {
1261
+ value: T;
1262
+ options: { value: T; label: string }[];
1263
+ onChange: (value: T) => void;
1264
+ }) {
1265
+ return (
1266
+ <div className={styles.fsSeg}>
1267
+ {options.map((option) => (
1268
+ <button
1269
+ key={option.value}
1270
+ type="button"
1271
+ className={`${styles.fsSegBtn} ${
1272
+ value === option.value ? styles.fsSegBtnActive : ""
1273
+ }`}
1274
+ onClick={() => onChange(option.value)}
1275
+ >
1276
+ {option.label}
1277
+ </button>
1278
+ ))}
1279
+ </div>
1280
+ );
1281
+ }
1282
+
1283
+ function ColorControl({
1284
+ value,
1285
+ onChange,
1286
+ }: {
1287
+ value: string;
1288
+ onChange: (value: string) => void;
1289
+ }) {
1290
+ return (
1291
+ <div className={styles.fsColorRow}>
1292
+ <input
1293
+ type="color"
1294
+ className={styles.fsSwatch}
1295
+ value={value || "#000000"}
1296
+ onChange={(e) => onChange(e.target.value)}
1297
+ />
1298
+ <input
1299
+ className={styles.fsInput}
1300
+ value={value}
1301
+ placeholder="otomatik"
1302
+ onChange={(e) => onChange(e.target.value)}
1303
+ />
1304
+ <button
1305
+ type="button"
1306
+ className={styles.fsClearBtn}
1307
+ onClick={() => onChange("")}
1308
+ title="Sıfırla"
1309
+ >
1310
+ ×
1311
+ </button>
1312
+ </div>
1313
+ );
1314
+ }
1315
+
1316
+ const TURKISH_CHAR_MAP: Record<string, string> = {
1317
+ ç: "c",
1318
+ ğ: "g",
1319
+ ı: "i",
1320
+ ö: "o",
1321
+ ş: "s",
1322
+ ü: "u",
1323
+ };
1324
+
1325
+ function slugify(input: string): string {
1326
+ return (
1327
+ input
1328
+ .toLowerCase()
1329
+ .replace(/[çğıöşü]/g, (char) => TURKISH_CHAR_MAP[char] ?? char)
1330
+ .trim()
1331
+ .replace(/[^a-z0-9]+/g, "-")
1332
+ .replace(/^-+|-+$/g, "") || "untitled"
1333
+ );
1334
+ }