@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/LICENSE +37 -0
- package/dist/index.d.ts +271 -0
- package/dist/index.js +2936 -0
- package/package.json +41 -0
- package/src/adapters/proxy-asset-store.ts +210 -0
- package/src/adapters/proxy-content-repository.ts +259 -0
- package/src/components/admin-app.tsx +275 -0
- package/src/components/collection-view.tsx +103 -0
- package/src/components/entry-form.tsx +76 -0
- package/src/components/entry-list.tsx +119 -0
- package/src/components/page-builder.tsx +1134 -0
- package/src/components/toast.tsx +48 -0
- package/src/fields/array-field-renderer.tsx +101 -0
- package/src/fields/boolean-field-renderer.tsx +28 -0
- package/src/fields/field-renderer.tsx +60 -0
- package/src/fields/icon-field-renderer.tsx +130 -0
- package/src/fields/image-field-renderer.tsx +266 -0
- package/src/fields/number-field-renderer.tsx +38 -0
- package/src/fields/object-field-renderer.tsx +41 -0
- package/src/fields/override-field-renderer.tsx +48 -0
- package/src/fields/select-field-renderer.tsx +42 -0
- package/src/fields/text-field-renderer.tsx +313 -0
- package/src/hooks/use-field.ts +82 -0
- package/src/hooks/use-save.ts +46 -0
- package/src/index.ts +34 -0
- package/src/providers/setzkasten-provider.tsx +80 -0
- package/src/stores/app-store.ts +61 -0
- package/src/stores/form-store.test.ts +111 -0
- package/src/stores/form-store.ts +298 -0
- package/src/styles/admin.css +2017 -0
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
|
+
}
|