@setzkasten-cms/ui 0.4.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/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@setzkasten-cms/ui",
3
+ "version": "0.4.2",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "import": "./src/index.ts",
8
+ "types": "./src/index.ts"
9
+ },
10
+ "./styles/admin.css": "./src/styles/admin.css"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "src"
15
+ ],
16
+ "dependencies": {
17
+ "@tiptap/extension-link": "^3.20.1",
18
+ "@tiptap/extension-placeholder": "^3.20.1",
19
+ "@tiptap/extension-text-align": "^3.20.1",
20
+ "@tiptap/pm": "^3.20.1",
21
+ "@tiptap/react": "^3.20.1",
22
+ "@tiptap/starter-kit": "^3.20.1",
23
+ "immer": "^10.2.0",
24
+ "lucide-react": "^0.577.0",
25
+ "react": "^19.1.0",
26
+ "react-dom": "^19.1.0",
27
+ "zustand": "^5.0.0",
28
+ "@setzkasten-cms/core": "0.4.2"
29
+ },
30
+ "devDependencies": {
31
+ "@types/react": "^19.1.0",
32
+ "@types/react-dom": "^19.1.0",
33
+ "vitest": "^3.2.0"
34
+ },
35
+ "scripts": {
36
+ "build": "tsup",
37
+ "test": "vitest run --passWithNoTests",
38
+ "test:watch": "vitest",
39
+ "typecheck": "tsc --noEmit"
40
+ }
41
+ }
@@ -0,0 +1,210 @@
1
+ import {
2
+ ok,
3
+ err,
4
+ networkError,
5
+ type AssetStore,
6
+ type AssetMetadata,
7
+ type Result,
8
+ } from '@setzkasten-cms/core'
9
+
10
+ export interface ProxyAssetConfig {
11
+ /** Base URL of the Setzkasten GitHub proxy */
12
+ proxyBaseUrl: string
13
+ /** GitHub owner */
14
+ owner: string
15
+ /** GitHub repo */
16
+ repo: string
17
+ /** Branch */
18
+ branch: string
19
+ /** Base path for assets in the repo, e.g. 'public/images' */
20
+ assetsPath?: string
21
+ /** Public URL prefix for assets, e.g. '/images' */
22
+ publicUrlPrefix?: string
23
+ }
24
+
25
+ /**
26
+ * Asset store that uploads files to GitHub via the server-side proxy.
27
+ * Preserves original filenames (Setzkasten principle).
28
+ */
29
+ export class ProxyAssetStore implements AssetStore {
30
+ private baseUrl: string
31
+ private owner: string
32
+ private repo: string
33
+ private branch: string
34
+ private assetsPath: string
35
+ private publicUrlPrefix: string
36
+
37
+ constructor(config: ProxyAssetConfig) {
38
+ this.baseUrl = config.proxyBaseUrl.replace(/\/$/, '')
39
+ this.owner = config.owner
40
+ this.repo = config.repo
41
+ this.branch = config.branch
42
+ this.assetsPath = config.assetsPath ?? 'public/images'
43
+ this.publicUrlPrefix = config.publicUrlPrefix ?? '/images'
44
+ }
45
+
46
+ private apiUrl(path: string): string {
47
+ return `${this.baseUrl}/repos/${this.owner}/${this.repo}/${path}`
48
+ }
49
+
50
+ async upload(
51
+ directory: string,
52
+ filename: string,
53
+ content: Uint8Array,
54
+ mimeType: string,
55
+ ): Promise<Result<AssetMetadata>> {
56
+ try {
57
+ const dir = directory.replace(/^\/+|\/+$/g, '')
58
+ const repoPath = `${this.assetsPath}/${dir}/${filename}`
59
+ const base64Content = this.uint8ToBase64(content)
60
+
61
+ const response = await fetch(this.apiUrl(`contents/${repoPath}`), {
62
+ method: 'PUT',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify({
65
+ message: `Upload ${filename}`,
66
+ content: base64Content,
67
+ branch: this.branch,
68
+ }),
69
+ })
70
+
71
+ if (!response.ok) {
72
+ const text = await response.text()
73
+ return err(networkError(`Upload failed: HTTP ${response.status} - ${text}`, null))
74
+ }
75
+
76
+ return ok({
77
+ path: repoPath,
78
+ size: content.byteLength,
79
+ mimeType,
80
+ })
81
+ } catch (error) {
82
+ return err(networkError(error instanceof Error ? error.message : 'Upload failed', error))
83
+ }
84
+ }
85
+
86
+ async delete(path: string): Promise<Result<void>> {
87
+ try {
88
+ // Get file SHA first
89
+ const fileResponse = await fetch(
90
+ this.apiUrl(`contents/${path}?ref=${this.branch}`),
91
+ )
92
+
93
+ if (!fileResponse.ok) {
94
+ return err(networkError(`File not found: ${path}`, null))
95
+ }
96
+
97
+ const fileData = (await fileResponse.json()) as { sha: string }
98
+
99
+ const response = await fetch(this.apiUrl(`contents/${path}`), {
100
+ method: 'DELETE',
101
+ headers: { 'Content-Type': 'application/json' },
102
+ body: JSON.stringify({
103
+ message: `Delete ${path}`,
104
+ sha: fileData.sha,
105
+ branch: this.branch,
106
+ }),
107
+ })
108
+
109
+ if (!response.ok) {
110
+ return err(networkError(`Delete failed: HTTP ${response.status}`, null))
111
+ }
112
+
113
+ return ok(undefined)
114
+ } catch (error) {
115
+ return err(networkError(error instanceof Error ? error.message : 'Delete failed', error))
116
+ }
117
+ }
118
+
119
+ async list(directory: string): Promise<Result<AssetMetadata[]>> {
120
+ try {
121
+ const dirPath = `${this.assetsPath}/${directory}`.replace(/\/+$/, '')
122
+ return await this.listRecursive(dirPath)
123
+ } catch (error) {
124
+ return err(networkError(error instanceof Error ? error.message : 'List failed', error))
125
+ }
126
+ }
127
+
128
+ private async listRecursive(dirPath: string): Promise<Result<AssetMetadata[]>> {
129
+ const response = await fetch(
130
+ this.apiUrl(`contents/${dirPath}?ref=${this.branch}`),
131
+ )
132
+
133
+ if (!response.ok) {
134
+ if (response.status === 404) return ok([])
135
+ return err(networkError(`HTTP ${response.status}`, null))
136
+ }
137
+
138
+ const data = (await response.json()) as Array<{
139
+ name: string
140
+ path: string
141
+ size: number
142
+ type: string
143
+ }>
144
+
145
+ const files: AssetMetadata[] = data
146
+ .filter((item) => item.type === 'file' && this.isImageFile(item.name))
147
+ .map((item) => ({
148
+ path: item.path,
149
+ size: item.size,
150
+ mimeType: this.guessMimeType(item.name),
151
+ }))
152
+
153
+ // Recurse into subdirectories
154
+ const dirs = data.filter((item) => item.type === 'dir')
155
+ for (const dir of dirs) {
156
+ const subResult = await this.listRecursive(dir.path)
157
+ if (subResult.ok) {
158
+ files.push(...subResult.value)
159
+ }
160
+ }
161
+
162
+ return ok(files)
163
+ }
164
+
165
+ private isImageFile(name: string): boolean {
166
+ const ext = name.split('.').pop()?.toLowerCase() ?? ''
167
+ return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg'].includes(ext)
168
+ }
169
+
170
+ getUrl(path: string): string {
171
+ // Convert repo path to public URL
172
+ // e.g. 'public/images/hero/bg.jpg' → '/images/hero/bg.jpg'
173
+ if (path.startsWith(this.assetsPath)) {
174
+ return this.publicUrlPrefix + path.slice(this.assetsPath.length)
175
+ }
176
+ return path
177
+ }
178
+
179
+ getPreviewUrl(path: string): string {
180
+ // If path is a public URL (e.g. /images/about/pic.jpg), convert to repo path first
181
+ const repoPath = path.startsWith(this.publicUrlPrefix)
182
+ ? this.assetsPath + path.slice(this.publicUrlPrefix.length)
183
+ : path
184
+ // Proxy through our authenticated asset endpoint (works for private repos)
185
+ return `/api/setzkasten/asset/${repoPath}`
186
+ }
187
+
188
+ private uint8ToBase64(bytes: Uint8Array): string {
189
+ let binary = ''
190
+ for (let i = 0; i < bytes.length; i++) {
191
+ binary += String.fromCharCode(bytes[i]!)
192
+ }
193
+ return btoa(binary)
194
+ }
195
+
196
+ private guessMimeType(filename: string): string {
197
+ const ext = filename.split('.').pop()?.toLowerCase()
198
+ const mimeTypes: Record<string, string> = {
199
+ jpg: 'image/jpeg',
200
+ jpeg: 'image/jpeg',
201
+ png: 'image/png',
202
+ gif: 'image/gif',
203
+ webp: 'image/webp',
204
+ avif: 'image/avif',
205
+ svg: 'image/svg+xml',
206
+ pdf: 'application/pdf',
207
+ }
208
+ return mimeTypes[ext ?? ''] ?? 'application/octet-stream'
209
+ }
210
+ }
@@ -0,0 +1,259 @@
1
+ import {
2
+ ok,
3
+ err,
4
+ networkError,
5
+ conflictError,
6
+ type ContentRepository,
7
+ type EntryData,
8
+ type EntryListItem,
9
+ type Asset,
10
+ type CommitResult,
11
+ type TreeNode,
12
+ type Result,
13
+ } from '@setzkasten-cms/core'
14
+
15
+ export interface ProxyConfig {
16
+ /** Base URL of the Setzkasten API proxy, e.g. '/api/setzkasten/github' */
17
+ proxyBaseUrl: string
18
+ /** GitHub owner */
19
+ owner: string
20
+ /** GitHub repo name */
21
+ repo: string
22
+ /** Branch to operate on */
23
+ branch: string
24
+ /** Base path within the repo for content files, e.g. 'src/content' */
25
+ contentPath?: string
26
+ }
27
+
28
+ /**
29
+ * Client-side ContentRepository that calls the server-side GitHub proxy.
30
+ * The GitHub token never leaves the server – all API calls go through
31
+ * /api/setzkasten/github/[...path].
32
+ */
33
+ export class ProxyContentRepository implements ContentRepository {
34
+ private baseUrl: string
35
+ private owner: string
36
+ private repo: string
37
+ private branch: string
38
+ private contentPath: string
39
+
40
+ constructor(config: ProxyConfig) {
41
+ this.baseUrl = config.proxyBaseUrl.replace(/\/$/, '')
42
+ this.owner = config.owner
43
+ this.repo = config.repo
44
+ this.branch = config.branch
45
+ this.contentPath = config.contentPath ?? 'src/content'
46
+ }
47
+
48
+ private apiUrl(path: string): string {
49
+ return `${this.baseUrl}/repos/${this.owner}/${this.repo}/${path}`
50
+ }
51
+
52
+ private contentFilePath(collection: string, slug: string): string {
53
+ return `${this.contentPath}/${collection}/${slug}.json`
54
+ }
55
+
56
+ async listEntries(collection: string): Promise<Result<EntryListItem[]>> {
57
+ try {
58
+ const dirPath = `${this.contentPath}/${collection}`
59
+ const response = await fetch(
60
+ this.apiUrl(`contents/${dirPath}?ref=${this.branch}`),
61
+ )
62
+
63
+ if (!response.ok) {
64
+ if (response.status === 404) return ok([])
65
+ return err(networkError(`HTTP ${response.status}`, null))
66
+ }
67
+
68
+ const data = (await response.json()) as Array<{ name: string; sha: string; type: string }>
69
+
70
+ return ok(
71
+ data
72
+ .filter((item) => item.name.endsWith('.json'))
73
+ .map((item) => ({
74
+ slug: item.name.replace(/\.json$/, ''),
75
+ name: item.name.replace(/\.json$/, ''),
76
+ })),
77
+ )
78
+ } catch (error) {
79
+ return err(networkError(error instanceof Error ? error.message : 'Network error', error))
80
+ }
81
+ }
82
+
83
+ async getEntry(collection: string, slug: string): Promise<Result<EntryData>> {
84
+ try {
85
+ const filePath = this.contentFilePath(collection, slug)
86
+ const response = await fetch(
87
+ this.apiUrl(`contents/${filePath}?ref=${this.branch}`),
88
+ )
89
+
90
+ if (!response.ok) {
91
+ return err(networkError(`HTTP ${response.status}`, null))
92
+ }
93
+
94
+ const data = (await response.json()) as { content: string; sha: string; encoding: string }
95
+
96
+ const binary = atob(data.content.replace(/\n/g, ''))
97
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0))
98
+ const decoded = new TextDecoder().decode(bytes)
99
+ const content = JSON.parse(decoded) as Record<string, unknown>
100
+
101
+ return ok({ content, sha: data.sha })
102
+ } catch (error) {
103
+ return err(networkError(error instanceof Error ? error.message : 'Network error', error))
104
+ }
105
+ }
106
+
107
+ async saveEntry(
108
+ collection: string,
109
+ slug: string,
110
+ data: EntryData,
111
+ assets?: Asset[],
112
+ ): Promise<Result<CommitResult>> {
113
+ try {
114
+ // Upload assets first (if any)
115
+ if (assets && assets.length > 0) {
116
+ for (const asset of assets) {
117
+ const assetResult = await this.uploadAsset(asset)
118
+ if (!assetResult.ok) return assetResult as Result<never>
119
+ }
120
+ }
121
+
122
+ // Save the JSON content file
123
+ const filePath = this.contentFilePath(collection, slug)
124
+ const jsonContent = JSON.stringify(data.content, null, 2)
125
+ const base64Content = btoa(unescape(encodeURIComponent(jsonContent)))
126
+
127
+ const body: Record<string, unknown> = {
128
+ message: `Update ${collection}/${slug}`,
129
+ content: base64Content,
130
+ branch: this.branch,
131
+ }
132
+
133
+ // Include SHA for conflict detection (update existing file)
134
+ if (data.sha) {
135
+ body.sha = data.sha
136
+ }
137
+
138
+ const response = await fetch(this.apiUrl(`contents/${filePath}`), {
139
+ method: 'PUT',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify(body),
142
+ })
143
+
144
+ if (!response.ok) {
145
+ if (response.status === 409) {
146
+ return err(conflictError('Concurrent edit detected. The file was modified since you loaded it.'))
147
+ }
148
+ const text = await response.text()
149
+ return err(networkError(`Save failed: HTTP ${response.status} - ${text}`, null))
150
+ }
151
+
152
+ const result = (await response.json()) as {
153
+ content: { sha: string }
154
+ commit: { sha: string; html_url: string }
155
+ }
156
+
157
+ return ok({
158
+ sha: result.content.sha,
159
+ message: `Update ${collection}/${slug}`,
160
+ url: result.commit.html_url,
161
+ })
162
+ } catch (error) {
163
+ return err(networkError(error instanceof Error ? error.message : 'Save failed', error))
164
+ }
165
+ }
166
+
167
+ async deleteEntry(collection: string, slug: string): Promise<Result<CommitResult>> {
168
+ try {
169
+ const entryResult = await this.getEntry(collection, slug)
170
+ if (!entryResult.ok) return entryResult as Result<never>
171
+
172
+ const filePath = this.contentFilePath(collection, slug)
173
+
174
+ const response = await fetch(this.apiUrl(`contents/${filePath}`), {
175
+ method: 'DELETE',
176
+ headers: { 'Content-Type': 'application/json' },
177
+ body: JSON.stringify({
178
+ message: `Delete ${collection}/${slug}`,
179
+ sha: entryResult.value.sha,
180
+ branch: this.branch,
181
+ }),
182
+ })
183
+
184
+ if (!response.ok) {
185
+ return err(networkError(`Delete failed: HTTP ${response.status}`, null))
186
+ }
187
+
188
+ const result = (await response.json()) as { commit: { sha: string } }
189
+
190
+ return ok({
191
+ sha: result.commit.sha,
192
+ message: `Delete ${collection}/${slug}`,
193
+ })
194
+ } catch (error) {
195
+ return err(networkError(error instanceof Error ? error.message : 'Delete failed', error))
196
+ }
197
+ }
198
+
199
+ async getTree(ref?: string): Promise<Result<TreeNode[]>> {
200
+ try {
201
+ const response = await fetch(
202
+ this.apiUrl(`git/trees/${ref ?? this.branch}?recursive=1`),
203
+ )
204
+
205
+ if (!response.ok) {
206
+ return err(networkError(`HTTP ${response.status}`, null))
207
+ }
208
+
209
+ const data = (await response.json()) as {
210
+ tree: Array<{ path: string; type: string; sha: string }>
211
+ }
212
+
213
+ return ok(
214
+ data.tree
215
+ .filter((item) => item.path.startsWith(this.contentPath))
216
+ .map((item) => ({
217
+ path: item.path,
218
+ type: item.type === 'tree' ? 'dir' as const : 'file' as const,
219
+ sha: item.sha,
220
+ })),
221
+ )
222
+ } catch (error) {
223
+ return err(networkError(error instanceof Error ? error.message : 'Network error', error))
224
+ }
225
+ }
226
+
227
+ private async uploadAsset(asset: Asset): Promise<Result<{ sha: string }>> {
228
+ try {
229
+ const base64Content = this.uint8ToBase64(asset.content)
230
+
231
+ const response = await fetch(this.apiUrl(`contents/${asset.path}`), {
232
+ method: 'PUT',
233
+ headers: { 'Content-Type': 'application/json' },
234
+ body: JSON.stringify({
235
+ message: `Upload ${asset.path}`,
236
+ content: base64Content,
237
+ branch: this.branch,
238
+ }),
239
+ })
240
+
241
+ if (!response.ok) {
242
+ return err(networkError(`Asset upload failed: HTTP ${response.status}`, null))
243
+ }
244
+
245
+ const result = (await response.json()) as { content: { sha: string } }
246
+ return ok({ sha: result.content.sha })
247
+ } catch (error) {
248
+ return err(networkError(error instanceof Error ? error.message : 'Upload failed', error))
249
+ }
250
+ }
251
+
252
+ private uint8ToBase64(bytes: Uint8Array): string {
253
+ let binary = ''
254
+ for (let i = 0; i < bytes.length; i++) {
255
+ binary += String.fromCharCode(bytes[i]!)
256
+ }
257
+ return btoa(binary)
258
+ }
259
+ }