@orhancodestudio/ocsm-core 0.1.0-alpha.1 → 0.1.0-alpha.2
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 +21 -0
- package/package.json +1 -1
- package/src/admin/admin.module.css +21 -0
- package/src/admin/admin.types.ts +10 -0
- package/src/admin/components/media-input.tsx +92 -0
- package/src/admin/components/page-builder.tsx +27 -12
- package/src/admin/index.ts +2 -0
- package/src/admin/ocsm-admin.tsx +2 -0
- package/src/media/index.ts +15 -0
- package/src/media/media-store.ts +95 -0
- package/src/media/mime.ts +41 -0
- package/src/server/create-ocsm.ts +4 -0
- package/src/server/index.ts +11 -0
- package/src/server/media-route.ts +32 -0
- package/src/storage/file-backend.ts +4 -0
- package/src/storage/fs-file-backend.ts +15 -0
- package/src/storage/github-file-backend.ts +33 -1
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog — @orhancodestudio/ocsm-core
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.2
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Visual full-screen **page builder** with section blocks (navbar, hero, heading,
|
|
8
|
+
rich text, image, button, quote, banner, marquee, video, spacer), per-block
|
|
9
|
+
styling (colors, gradients, fonts, spacing), drag-and-drop reorder, visibility
|
|
10
|
+
toggle, device preview, and live themed canvas.
|
|
11
|
+
- Global **header/footer** regions (`LayoutStore`) shared across pages.
|
|
12
|
+
- **Dynamic roles & permissions** (`RoleStore`) with a reusable `Modal`, a redesigned
|
|
13
|
+
Users page, and a tabbed **Settings** page (Roles & Permissions).
|
|
14
|
+
- **Media upload** from device: `MediaStore` + `handleMediaRequest` route helper,
|
|
15
|
+
served live via `/ocsm-media/*` (works before rebuild).
|
|
16
|
+
- Reusable `DataTable`; versioned sessions (auto-invalidate stale cookies).
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Markdown rendering moved from `next-mdx-remote` to `react-markdown`.
|
|
21
|
+
- Content layer unified on a single `FileBackend` abstraction (fs + GitHub),
|
|
22
|
+
now with binary read/write for media.
|
|
23
|
+
|
|
3
24
|
## 0.1.0-alpha.1
|
|
4
25
|
|
|
5
26
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orhancodestudio/ocsm-core",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.2",
|
|
4
4
|
"description": "OCS Management — the git-based CMS core for Next.js. Built by Orhan Code Studio.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Orhan Code Studio (https://orhancodestudio.com)",
|
|
@@ -1310,3 +1310,24 @@ a.card:hover {
|
|
|
1310
1310
|
color: var(--p-muted);
|
|
1311
1311
|
margin-top: 2px;
|
|
1312
1312
|
}
|
|
1313
|
+
|
|
1314
|
+
/* ---------- Media input ---------- */
|
|
1315
|
+
.mediaRow {
|
|
1316
|
+
display: flex;
|
|
1317
|
+
gap: 8px;
|
|
1318
|
+
align-items: center;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
.mediaRow .fsInput {
|
|
1322
|
+
flex: 1;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
.mediaPreview {
|
|
1326
|
+
margin-top: 8px;
|
|
1327
|
+
max-width: 100%;
|
|
1328
|
+
max-height: 120px;
|
|
1329
|
+
border-radius: 8px;
|
|
1330
|
+
border: 1px solid #e2e8f0;
|
|
1331
|
+
display: block;
|
|
1332
|
+
object-fit: contain;
|
|
1333
|
+
}
|
package/src/admin/admin.types.ts
CHANGED
|
@@ -60,6 +60,15 @@ export type UpdateRoleAction = (
|
|
|
60
60
|
) => Promise<ActionResult>;
|
|
61
61
|
export type DeleteRoleAction = (id: string) => Promise<ActionResult>;
|
|
62
62
|
|
|
63
|
+
// --- Media ---
|
|
64
|
+
export interface UploadMediaResult {
|
|
65
|
+
ok: boolean;
|
|
66
|
+
url?: string;
|
|
67
|
+
message?: string;
|
|
68
|
+
}
|
|
69
|
+
/** Uploads a file (FormData field `file`) and returns its public URL. */
|
|
70
|
+
export type UploadMediaAction = (form: FormData) => Promise<UploadMediaResult>;
|
|
71
|
+
|
|
63
72
|
export type SaveThemeAction = (theme: OcsmTheme) => Promise<ActionResult>;
|
|
64
73
|
|
|
65
74
|
export type SaveLayoutAction = (
|
|
@@ -79,6 +88,7 @@ export interface OcsmAdminActions {
|
|
|
79
88
|
createRole: CreateRoleAction;
|
|
80
89
|
updateRole: UpdateRoleAction;
|
|
81
90
|
deleteRole: DeleteRoleAction;
|
|
91
|
+
uploadMedia: UploadMediaAction;
|
|
82
92
|
saveTheme: SaveThemeAction;
|
|
83
93
|
saveLayout: SaveLayoutAction;
|
|
84
94
|
signOut: SignOutAction;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
ACCEPTED_UPLOAD_HINT,
|
|
6
|
+
UPLOAD_ACCEPT_ATTR,
|
|
7
|
+
} from "../../media/mime";
|
|
8
|
+
import type { UploadMediaAction } from "../admin.types";
|
|
9
|
+
import styles from "../admin.module.css";
|
|
10
|
+
|
|
11
|
+
export interface MediaInputProps {
|
|
12
|
+
value: string;
|
|
13
|
+
onChange: (url: string) => void;
|
|
14
|
+
uploadMedia: UploadMediaAction;
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Image field with both a URL text input and a "device upload" button. Uploads
|
|
20
|
+
* go through the host's {@link UploadMediaAction} (stored via the CMS backend)
|
|
21
|
+
* and the returned URL is written back into the field.
|
|
22
|
+
*/
|
|
23
|
+
export function MediaInput({
|
|
24
|
+
value,
|
|
25
|
+
onChange,
|
|
26
|
+
uploadMedia,
|
|
27
|
+
placeholder,
|
|
28
|
+
}: MediaInputProps) {
|
|
29
|
+
const fileRef = useRef<HTMLInputElement>(null);
|
|
30
|
+
const [busy, setBusy] = useState(false);
|
|
31
|
+
const [error, setError] = useState<string | null>(null);
|
|
32
|
+
|
|
33
|
+
async function onFile(file: File) {
|
|
34
|
+
setError(null);
|
|
35
|
+
setBusy(true);
|
|
36
|
+
try {
|
|
37
|
+
const form = new FormData();
|
|
38
|
+
form.append("file", file);
|
|
39
|
+
const result = await uploadMedia(form);
|
|
40
|
+
if (result.ok && result.url) {
|
|
41
|
+
onChange(result.url);
|
|
42
|
+
} else {
|
|
43
|
+
setError(result.message ?? "Yükleme başarısız");
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
setError("Yükleme sırasında hata oluştu");
|
|
47
|
+
} finally {
|
|
48
|
+
setBusy(false);
|
|
49
|
+
if (fileRef.current) fileRef.current.value = "";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div>
|
|
55
|
+
<div className={styles.mediaRow}>
|
|
56
|
+
<input
|
|
57
|
+
className={styles.fsInput}
|
|
58
|
+
value={value}
|
|
59
|
+
placeholder={placeholder ?? "https://… veya cihazdan yükle"}
|
|
60
|
+
onChange={(event) => onChange(event.target.value)}
|
|
61
|
+
/>
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
className={`${styles.btn} ${styles.btnSm}`}
|
|
65
|
+
disabled={busy}
|
|
66
|
+
onClick={() => fileRef.current?.click()}
|
|
67
|
+
>
|
|
68
|
+
{busy ? "Yükleniyor…" : "Yükle"}
|
|
69
|
+
</button>
|
|
70
|
+
<input
|
|
71
|
+
ref={fileRef}
|
|
72
|
+
type="file"
|
|
73
|
+
accept={UPLOAD_ACCEPT_ATTR}
|
|
74
|
+
hidden
|
|
75
|
+
onChange={(event) => {
|
|
76
|
+
const file = event.target.files?.[0];
|
|
77
|
+
if (file) void onFile(file);
|
|
78
|
+
}}
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
{value ? (
|
|
82
|
+
// biome-ignore lint/a11y/useAltText: decorative thumbnail preview
|
|
83
|
+
<img src={value} alt="" className={styles.mediaPreview} />
|
|
84
|
+
) : null}
|
|
85
|
+
{error ? (
|
|
86
|
+
<p className={styles.errorText}>{error}</p>
|
|
87
|
+
) : (
|
|
88
|
+
<p className={styles.help}>{ACCEPTED_UPLOAD_HINT}</p>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -18,7 +18,11 @@ import {
|
|
|
18
18
|
import { BlockItem } from "../../blocks/block-renderer";
|
|
19
19
|
import { themeToCssVariables } from "../../theme/css";
|
|
20
20
|
import type { OcsmTheme } from "../../theme/theme.types";
|
|
21
|
-
import type {
|
|
21
|
+
import type {
|
|
22
|
+
SaveDocumentAction,
|
|
23
|
+
SaveLayoutAction,
|
|
24
|
+
UploadMediaAction,
|
|
25
|
+
} from "../admin.types";
|
|
22
26
|
import styles from "../admin.module.css";
|
|
23
27
|
import {
|
|
24
28
|
IconEye,
|
|
@@ -29,6 +33,7 @@ import {
|
|
|
29
33
|
IconPhone,
|
|
30
34
|
IconTrash,
|
|
31
35
|
} from "./icons";
|
|
36
|
+
import { MediaInput } from "./media-input";
|
|
32
37
|
|
|
33
38
|
export interface PageBuilderProps {
|
|
34
39
|
collection: string;
|
|
@@ -36,6 +41,7 @@ export interface PageBuilderProps {
|
|
|
36
41
|
backHref: string;
|
|
37
42
|
saveAction: SaveDocumentAction;
|
|
38
43
|
saveLayout: SaveLayoutAction;
|
|
44
|
+
uploadMedia: UploadMediaAction;
|
|
39
45
|
theme: OcsmTheme;
|
|
40
46
|
initialSlug?: string;
|
|
41
47
|
initialTitle?: string;
|
|
@@ -62,6 +68,7 @@ export function PageBuilder({
|
|
|
62
68
|
backHref,
|
|
63
69
|
saveAction,
|
|
64
70
|
saveLayout,
|
|
71
|
+
uploadMedia,
|
|
65
72
|
theme,
|
|
66
73
|
initialSlug,
|
|
67
74
|
initialTitle,
|
|
@@ -365,6 +372,7 @@ export function PageBuilder({
|
|
|
365
372
|
block={selectedBlock}
|
|
366
373
|
onContent={patchContent}
|
|
367
374
|
onStyle={patchStyle}
|
|
375
|
+
uploadMedia={uploadMedia}
|
|
368
376
|
/>
|
|
369
377
|
</div>
|
|
370
378
|
</div>
|
|
@@ -589,17 +597,23 @@ function Inspector({
|
|
|
589
597
|
block,
|
|
590
598
|
onContent,
|
|
591
599
|
onStyle,
|
|
600
|
+
uploadMedia,
|
|
592
601
|
}: {
|
|
593
602
|
block: Block;
|
|
594
603
|
onContent: (patch: Record<string, unknown>) => void;
|
|
595
604
|
onStyle: (patch: Partial<BlockStyle>) => void;
|
|
605
|
+
uploadMedia: UploadMediaAction;
|
|
596
606
|
}) {
|
|
597
607
|
const noAlign = block.type === "marquee" || block.type === "navbar";
|
|
598
608
|
return (
|
|
599
609
|
<>
|
|
600
610
|
<div className={styles.fsGroup}>
|
|
601
611
|
<h3 className={styles.fsGroupTitle}>İçerik</h3>
|
|
602
|
-
<ContentFields
|
|
612
|
+
<ContentFields
|
|
613
|
+
block={block}
|
|
614
|
+
onChange={onContent}
|
|
615
|
+
uploadMedia={uploadMedia}
|
|
616
|
+
/>
|
|
603
617
|
</div>
|
|
604
618
|
{block.type === "spacer" ? (
|
|
605
619
|
<div className={styles.fsGroup}>
|
|
@@ -624,9 +638,11 @@ function Inspector({
|
|
|
624
638
|
function ContentFields({
|
|
625
639
|
block,
|
|
626
640
|
onChange,
|
|
641
|
+
uploadMedia,
|
|
627
642
|
}: {
|
|
628
643
|
block: Block;
|
|
629
644
|
onChange: (patch: Record<string, unknown>) => void;
|
|
645
|
+
uploadMedia: UploadMediaAction;
|
|
630
646
|
}) {
|
|
631
647
|
switch (block.type) {
|
|
632
648
|
case "navbar":
|
|
@@ -639,12 +655,12 @@ function ContentFields({
|
|
|
639
655
|
onChange={(e) => onChange({ logoText: e.target.value })}
|
|
640
656
|
/>
|
|
641
657
|
</Control>
|
|
642
|
-
<Control label="Logo görseli (opsiyonel
|
|
643
|
-
<
|
|
644
|
-
className={styles.fsInput}
|
|
658
|
+
<Control label="Logo görseli (opsiyonel)">
|
|
659
|
+
<MediaInput
|
|
645
660
|
value={block.logoImageUrl}
|
|
646
|
-
|
|
647
|
-
|
|
661
|
+
onChange={(url) => onChange({ logoImageUrl: url })}
|
|
662
|
+
uploadMedia={uploadMedia}
|
|
663
|
+
placeholder="Boşsa logo metni gösterilir"
|
|
648
664
|
/>
|
|
649
665
|
</Control>
|
|
650
666
|
<Control label="Logo bağlantısı">
|
|
@@ -777,12 +793,11 @@ function ContentFields({
|
|
|
777
793
|
case "image":
|
|
778
794
|
return (
|
|
779
795
|
<>
|
|
780
|
-
<Control label="Görsel
|
|
781
|
-
<
|
|
782
|
-
className={styles.fsInput}
|
|
796
|
+
<Control label="Görsel">
|
|
797
|
+
<MediaInput
|
|
783
798
|
value={block.url}
|
|
784
|
-
|
|
785
|
-
|
|
799
|
+
onChange={(url) => onChange({ url })}
|
|
800
|
+
uploadMedia={uploadMedia}
|
|
786
801
|
/>
|
|
787
802
|
</Control>
|
|
788
803
|
<Control label="Alternatif metin (erişilebilirlik)">
|
package/src/admin/index.ts
CHANGED
package/src/admin/ocsm-admin.tsx
CHANGED
|
@@ -147,6 +147,7 @@ export async function OcsmAdmin({
|
|
|
147
147
|
backHref={`/ocsm-admin/${collection.name}`}
|
|
148
148
|
saveAction={actions.saveDocument}
|
|
149
149
|
saveLayout={actions.saveLayout}
|
|
150
|
+
uploadMedia={actions.uploadMedia}
|
|
150
151
|
theme={theme}
|
|
151
152
|
initialBlocks={[]}
|
|
152
153
|
initialHeader={headerBlocks}
|
|
@@ -169,6 +170,7 @@ export async function OcsmAdmin({
|
|
|
169
170
|
backHref={`/ocsm-admin/${collection.name}`}
|
|
170
171
|
saveAction={actions.saveDocument}
|
|
171
172
|
saveLayout={actions.saveLayout}
|
|
173
|
+
uploadMedia={actions.uploadMedia}
|
|
172
174
|
theme={theme}
|
|
173
175
|
initialSlug={seg1}
|
|
174
176
|
initialTitle={asString(document?.frontmatter.title)}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export {
|
|
2
|
+
MEDIA_DIR,
|
|
3
|
+
MEDIA_URL_PREFIX,
|
|
4
|
+
type MediaContent,
|
|
5
|
+
MediaStore,
|
|
6
|
+
type StoredMedia,
|
|
7
|
+
} from "./media-store";
|
|
8
|
+
export {
|
|
9
|
+
ACCEPTED_UPLOAD_HINT,
|
|
10
|
+
extForMime,
|
|
11
|
+
isAllowedUploadMime,
|
|
12
|
+
MAX_UPLOAD_BYTES,
|
|
13
|
+
mimeForExt,
|
|
14
|
+
UPLOAD_ACCEPT_ATTR,
|
|
15
|
+
} from "./mime";
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import type { FileBackend } from "../storage/file-backend";
|
|
4
|
+
import {
|
|
5
|
+
extForMime,
|
|
6
|
+
isAllowedUploadMime,
|
|
7
|
+
MAX_UPLOAD_BYTES,
|
|
8
|
+
mimeForExt,
|
|
9
|
+
} from "./mime";
|
|
10
|
+
|
|
11
|
+
/** Directory (within the repo) where uploaded media is stored. */
|
|
12
|
+
export const MEDIA_DIR = "ocsm/media";
|
|
13
|
+
|
|
14
|
+
/** Public URL prefix the serving route is mounted at. */
|
|
15
|
+
export const MEDIA_URL_PREFIX = "/ocsm-media";
|
|
16
|
+
|
|
17
|
+
export interface StoredMedia {
|
|
18
|
+
/** File name within the media directory (also the URL segment). */
|
|
19
|
+
name: string;
|
|
20
|
+
/** Public URL to reference the file (served by the media route). */
|
|
21
|
+
url: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MediaContent {
|
|
25
|
+
data: Buffer;
|
|
26
|
+
contentType: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stores and retrieves uploaded media through a {@link FileBackend}. Files live
|
|
31
|
+
* under {@link MEDIA_DIR} and are served via {@link MEDIA_URL_PREFIX} (a route
|
|
32
|
+
* handler reading from the backend), so uploads are available immediately —
|
|
33
|
+
* even in production, before any rebuild.
|
|
34
|
+
*/
|
|
35
|
+
export class MediaStore {
|
|
36
|
+
constructor(private readonly backend: FileBackend) {}
|
|
37
|
+
|
|
38
|
+
/** Validates and stores an uploaded file, returning its public URL. */
|
|
39
|
+
async save(input: {
|
|
40
|
+
data: Buffer;
|
|
41
|
+
mime: string;
|
|
42
|
+
originalName?: string;
|
|
43
|
+
}): Promise<StoredMedia> {
|
|
44
|
+
if (!isAllowedUploadMime(input.mime)) {
|
|
45
|
+
throw new Error("OCSM: desteklenmeyen dosya türü");
|
|
46
|
+
}
|
|
47
|
+
if (input.data.byteLength > MAX_UPLOAD_BYTES) {
|
|
48
|
+
throw new Error("OCSM: dosya çok büyük (en fazla 5 MB)");
|
|
49
|
+
}
|
|
50
|
+
const ext = extForMime(input.mime);
|
|
51
|
+
if (!ext) throw new Error("OCSM: desteklenmeyen dosya türü");
|
|
52
|
+
|
|
53
|
+
const name = `${baseName(input.originalName)}-${randomBytes(4).toString("hex")}.${ext}`;
|
|
54
|
+
await this.backend.writeBinary(
|
|
55
|
+
`${MEDIA_DIR}/${name}`,
|
|
56
|
+
input.data,
|
|
57
|
+
`ocsm: upload media ${name}`,
|
|
58
|
+
);
|
|
59
|
+
return { name, url: `${MEDIA_URL_PREFIX}/${name}` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Reads a stored file by name, inferring its content type. */
|
|
63
|
+
async read(name: string): Promise<MediaContent | null> {
|
|
64
|
+
if (!isSafeName(name)) return null;
|
|
65
|
+
const data = await this.backend.readBinary(`${MEDIA_DIR}/${name}`);
|
|
66
|
+
if (!data) return null;
|
|
67
|
+
return { data, contentType: mimeForExt(extension(name)) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Lists stored media file names. */
|
|
71
|
+
async list(): Promise<string[]> {
|
|
72
|
+
return this.backend.listFiles(MEDIA_DIR);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Slugified base of the original name (without extension), capped in length. */
|
|
77
|
+
function baseName(original?: string): string {
|
|
78
|
+
const stem = (original ?? "dosya").replace(/\.[^.]+$/, "");
|
|
79
|
+
const slug = stem
|
|
80
|
+
.toLowerCase()
|
|
81
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
82
|
+
.replace(/^-+|-+$/g, "")
|
|
83
|
+
.slice(0, 40);
|
|
84
|
+
return slug || "dosya";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Rejects names with path separators or traversal. */
|
|
88
|
+
function isSafeName(name: string): boolean {
|
|
89
|
+
return /^[a-zA-Z0-9._-]+$/.test(name) && !name.includes("..");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function extension(name: string): string {
|
|
93
|
+
const match = name.match(/\.([^.]+)$/);
|
|
94
|
+
return match?.[1] ?? "";
|
|
95
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Allowed image upload types, mapped to their canonical file extension. */
|
|
2
|
+
const MIME_TO_EXT: Record<string, string> = {
|
|
3
|
+
"image/png": "png",
|
|
4
|
+
"image/jpeg": "jpg",
|
|
5
|
+
"image/gif": "gif",
|
|
6
|
+
"image/webp": "webp",
|
|
7
|
+
"image/avif": "avif",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const EXT_TO_MIME: Record<string, string> = {
|
|
11
|
+
png: "image/png",
|
|
12
|
+
jpg: "image/jpeg",
|
|
13
|
+
jpeg: "image/jpeg",
|
|
14
|
+
gif: "image/gif",
|
|
15
|
+
webp: "image/webp",
|
|
16
|
+
avif: "image/avif",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Largest accepted upload, in bytes (5 MB). */
|
|
20
|
+
export const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
/** Human-readable list of accepted formats, for UI hints. */
|
|
23
|
+
export const ACCEPTED_UPLOAD_HINT = "PNG, JPG, GIF, WEBP, AVIF · en fazla 5 MB";
|
|
24
|
+
|
|
25
|
+
/** The `accept` attribute value for a file input. */
|
|
26
|
+
export const UPLOAD_ACCEPT_ATTR = Object.keys(MIME_TO_EXT).join(",");
|
|
27
|
+
|
|
28
|
+
/** Returns the canonical extension for an upload MIME type, or `null` if not allowed. */
|
|
29
|
+
export function extForMime(mime: string): string | null {
|
|
30
|
+
return MIME_TO_EXT[mime.toLowerCase()] ?? null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Returns the MIME type for a file extension, or a generic fallback. */
|
|
34
|
+
export function mimeForExt(ext: string): string {
|
|
35
|
+
return EXT_TO_MIME[ext.toLowerCase()] ?? "application/octet-stream";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Whether a MIME type is an accepted upload format. */
|
|
39
|
+
export function isAllowedUploadMime(mime: string): boolean {
|
|
40
|
+
return mime.toLowerCase() in MIME_TO_EXT;
|
|
41
|
+
}
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
} from "../content/content.types";
|
|
8
8
|
import { createContentStore } from "../content/create-content-store";
|
|
9
9
|
import { LayoutStore } from "../layout/layout-store";
|
|
10
|
+
import { MediaStore } from "../media/media-store";
|
|
10
11
|
import { RoleStore } from "../roles/role-store";
|
|
11
12
|
import { createFileBackend } from "../storage/create-file-backend";
|
|
12
13
|
import { JsonStore } from "../storage/json-store";
|
|
@@ -27,6 +28,8 @@ export interface Ocsm {
|
|
|
27
28
|
readonly theme: ThemeStore;
|
|
28
29
|
/** Global header/footer sections shared across every page. */
|
|
29
30
|
readonly layout: LayoutStore;
|
|
31
|
+
/** Uploaded media (images) storage. */
|
|
32
|
+
readonly media: MediaStore;
|
|
30
33
|
/** Lists documents in a collection. */
|
|
31
34
|
listDocuments(collection: string): Promise<ContentDocumentMeta[]>;
|
|
32
35
|
/** Reads a single document, or `null` if it does not exist. */
|
|
@@ -61,6 +64,7 @@ export function createOcsm(config: OcsmConfig): Ocsm {
|
|
|
61
64
|
roles: new RoleStore(json),
|
|
62
65
|
theme: new ThemeStore(json),
|
|
63
66
|
layout: new LayoutStore(json),
|
|
67
|
+
media: new MediaStore(backend),
|
|
64
68
|
listDocuments: (collection) => store.list(collection),
|
|
65
69
|
getDocument: (collection, slug) => store.read(collection, slug),
|
|
66
70
|
};
|
package/src/server/index.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
export { createOcsm, type Ocsm } from "./create-ocsm";
|
|
2
2
|
export { deleteDocument, writeDocument } from "./documents";
|
|
3
|
+
export { handleMediaRequest } from "./media-route";
|
|
3
4
|
export { OcsmContent, type OcsmContentProps } from "./render-mdx";
|
|
4
5
|
|
|
6
|
+
// Media
|
|
7
|
+
export {
|
|
8
|
+
ACCEPTED_UPLOAD_HINT,
|
|
9
|
+
MAX_UPLOAD_BYTES,
|
|
10
|
+
type MediaContent,
|
|
11
|
+
MediaStore,
|
|
12
|
+
type StoredMedia,
|
|
13
|
+
UPLOAD_ACCEPT_ATTR,
|
|
14
|
+
} from "../media";
|
|
15
|
+
|
|
5
16
|
// Auth, users & roles
|
|
6
17
|
export { authenticate, setupFirstAdmin } from "../auth/authenticate";
|
|
7
18
|
export { hashPassword, verifyPassword } from "../auth/password";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Ocsm } from "./create-ocsm";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Serves an uploaded media file as a web `Response`. Mount this in a Route
|
|
5
|
+
* Handler at `app/ocsm-media/[...path]/route.ts`:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { handleMediaRequest } from "@orhancodestudio/ocsm-core/server";
|
|
9
|
+
* import { ocsm } from "@/lib/ocsm";
|
|
10
|
+
*
|
|
11
|
+
* export async function GET(_req: Request, ctx: { params: Promise<{ path: string[] }> }) {
|
|
12
|
+
* const { path } = await ctx.params;
|
|
13
|
+
* return handleMediaRequest(ocsm, path.join("/"));
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export async function handleMediaRequest(
|
|
18
|
+
ocsm: Ocsm,
|
|
19
|
+
name: string,
|
|
20
|
+
): Promise<Response> {
|
|
21
|
+
const file = await ocsm.media.read(name);
|
|
22
|
+
if (!file) {
|
|
23
|
+
return new Response("Not found", { status: 404 });
|
|
24
|
+
}
|
|
25
|
+
return new Response(new Uint8Array(file.data), {
|
|
26
|
+
headers: {
|
|
27
|
+
"content-type": file.contentType,
|
|
28
|
+
// Filenames are unique per upload, so they are safe to cache forever.
|
|
29
|
+
"cache-control": "public, max-age=31536000, immutable",
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -8,6 +8,10 @@ export interface FileBackend {
|
|
|
8
8
|
read(path: string): Promise<string | null>;
|
|
9
9
|
/** Creates or overwrites a file. `message` is used as the commit message by git backends. */
|
|
10
10
|
write(path: string, content: string, message?: string): Promise<void>;
|
|
11
|
+
/** Reads a binary file, or returns `null` if it does not exist. */
|
|
12
|
+
readBinary(path: string): Promise<Buffer | null>;
|
|
13
|
+
/** Creates or overwrites a binary file. */
|
|
14
|
+
writeBinary(path: string, data: Buffer, message?: string): Promise<void>;
|
|
11
15
|
/** Deletes a file. No-op if it does not exist. */
|
|
12
16
|
remove(path: string, message?: string): Promise<void>;
|
|
13
17
|
/** Lists file names (not directories) directly inside `dir`. Returns `[]` if missing. */
|
|
@@ -21,6 +21,21 @@ export class FsFileBackend implements FileBackend {
|
|
|
21
21
|
await fs.writeFile(absolute, content, "utf8");
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
async readBinary(filePath: string): Promise<Buffer | null> {
|
|
25
|
+
try {
|
|
26
|
+
return await fs.readFile(this.resolve(filePath));
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (isNotFoundError(error)) return null;
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async writeBinary(filePath: string, data: Buffer): Promise<void> {
|
|
34
|
+
const absolute = this.resolve(filePath);
|
|
35
|
+
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
|
36
|
+
await fs.writeFile(absolute, data);
|
|
37
|
+
}
|
|
38
|
+
|
|
24
39
|
async remove(filePath: string): Promise<void> {
|
|
25
40
|
await fs.rm(this.resolve(filePath), { force: true });
|
|
26
41
|
}
|
|
@@ -37,12 +37,44 @@ export class GitHubFileBackend implements FileBackend {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
async write(filePath: string, content: string, message?: string): Promise<void> {
|
|
40
|
+
await this.writeBinary(filePath, Buffer.from(content, "utf8"), message);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async readBinary(filePath: string): Promise<Buffer | null> {
|
|
44
|
+
try {
|
|
45
|
+
const { data } = await this.octokit.repos.getContent({
|
|
46
|
+
...this.base(),
|
|
47
|
+
ref: this.options.branch,
|
|
48
|
+
path: filePath,
|
|
49
|
+
});
|
|
50
|
+
if (Array.isArray(data) || data.type !== "file") return null;
|
|
51
|
+
if (data.content) {
|
|
52
|
+
return Buffer.from(data.content, "base64");
|
|
53
|
+
}
|
|
54
|
+
// Files larger than ~1MB return empty content; fetch the raw blob instead.
|
|
55
|
+
if (data.download_url) {
|
|
56
|
+
const res = await fetch(data.download_url);
|
|
57
|
+
if (!res.ok) return null;
|
|
58
|
+
return Buffer.from(await res.arrayBuffer());
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (isNotFoundError(error)) return null;
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async writeBinary(
|
|
68
|
+
filePath: string,
|
|
69
|
+
data: Buffer,
|
|
70
|
+
message?: string,
|
|
71
|
+
): Promise<void> {
|
|
40
72
|
await this.octokit.repos.createOrUpdateFileContents({
|
|
41
73
|
...this.base(),
|
|
42
74
|
branch: this.options.branch,
|
|
43
75
|
path: filePath,
|
|
44
76
|
message: message ?? `ocsm: update ${filePath}`,
|
|
45
|
-
content:
|
|
77
|
+
content: data.toString("base64"),
|
|
46
78
|
sha: await this.shaFor(filePath),
|
|
47
79
|
});
|
|
48
80
|
}
|
package/src/version.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* update checker ({@link ./update/check-for-updates}) and the admin "update available"
|
|
6
6
|
* notice.
|
|
7
7
|
*/
|
|
8
|
-
export const OCSM_VERSION = "0.1.0-alpha.
|
|
8
|
+
export const OCSM_VERSION = "0.1.0-alpha.2";
|
|
9
9
|
|
|
10
10
|
/** The published npm package name for the OCS Management core. */
|
|
11
11
|
export const OCSM_PACKAGE_NAME = "@orhancodestudio/ocsm-core";
|