@fnd-platform/cms 1.0.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.
- package/LICENSE +21 -0
- package/README.md +283 -0
- package/lib/cms-project.d.ts +127 -0
- package/lib/cms-project.d.ts.map +1 -0
- package/lib/cms-project.js +343 -0
- package/lib/cms-project.js.map +1 -0
- package/lib/index.d.ts +11 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +20 -0
- package/lib/index.js.map +1 -0
- package/lib/options.d.ts +59 -0
- package/lib/options.d.ts.map +1 -0
- package/lib/options.js +3 -0
- package/lib/options.js.map +1 -0
- package/lib/templates/admin-breadcrumbs.d.ts +13 -0
- package/lib/templates/admin-breadcrumbs.d.ts.map +1 -0
- package/lib/templates/admin-breadcrumbs.js +80 -0
- package/lib/templates/admin-breadcrumbs.js.map +1 -0
- package/lib/templates/admin-content-route.d.ts +18 -0
- package/lib/templates/admin-content-route.d.ts.map +1 -0
- package/lib/templates/admin-content-route.js +100 -0
- package/lib/templates/admin-content-route.js.map +1 -0
- package/lib/templates/admin-content-type-route.d.ts +9 -0
- package/lib/templates/admin-content-type-route.d.ts.map +1 -0
- package/lib/templates/admin-content-type-route.js +96 -0
- package/lib/templates/admin-content-type-route.js.map +1 -0
- package/lib/templates/admin-header.d.ts +13 -0
- package/lib/templates/admin-header.d.ts.map +1 -0
- package/lib/templates/admin-header.js +123 -0
- package/lib/templates/admin-header.js.map +1 -0
- package/lib/templates/admin-index.d.ts +9 -0
- package/lib/templates/admin-index.d.ts.map +1 -0
- package/lib/templates/admin-index.js +60 -0
- package/lib/templates/admin-index.js.map +1 -0
- package/lib/templates/admin-layout.d.ts +10 -0
- package/lib/templates/admin-layout.d.ts.map +1 -0
- package/lib/templates/admin-layout.js +46 -0
- package/lib/templates/admin-layout.js.map +1 -0
- package/lib/templates/admin-sidebar.d.ts +13 -0
- package/lib/templates/admin-sidebar.d.ts.map +1 -0
- package/lib/templates/admin-sidebar.js +149 -0
- package/lib/templates/admin-sidebar.js.map +1 -0
- package/lib/templates/content-editor.d.ts +10 -0
- package/lib/templates/content-editor.d.ts.map +1 -0
- package/lib/templates/content-editor.js +354 -0
- package/lib/templates/content-editor.js.map +1 -0
- package/lib/templates/content-schema.d.ts +10 -0
- package/lib/templates/content-schema.d.ts.map +1 -0
- package/lib/templates/content-schema.js +274 -0
- package/lib/templates/content-schema.js.map +1 -0
- package/lib/templates/content-table.d.ts +13 -0
- package/lib/templates/content-table.d.ts.map +1 -0
- package/lib/templates/content-table.js +177 -0
- package/lib/templates/content-table.js.map +1 -0
- package/lib/templates/content-types-examples.d.ts +19 -0
- package/lib/templates/content-types-examples.d.ts.map +1 -0
- package/lib/templates/content-types-examples.js +275 -0
- package/lib/templates/content-types-examples.js.map +1 -0
- package/lib/templates/content-types-registry.d.ts +10 -0
- package/lib/templates/content-types-registry.d.ts.map +1 -0
- package/lib/templates/content-types-registry.js +87 -0
- package/lib/templates/content-types-registry.js.map +1 -0
- package/lib/templates/content-types.d.ts +10 -0
- package/lib/templates/content-types.d.ts.map +1 -0
- package/lib/templates/content-types.js +384 -0
- package/lib/templates/content-types.js.map +1 -0
- package/lib/templates/dashboard-stats.d.ts +13 -0
- package/lib/templates/dashboard-stats.d.ts.map +1 -0
- package/lib/templates/dashboard-stats.js +117 -0
- package/lib/templates/dashboard-stats.js.map +1 -0
- package/lib/templates/editor/index.d.ts +6 -0
- package/lib/templates/editor/index.d.ts.map +1 -0
- package/lib/templates/editor/index.js +21 -0
- package/lib/templates/editor/index.js.map +1 -0
- package/lib/templates/editor/rich-text-editor.d.ts +7 -0
- package/lib/templates/editor/rich-text-editor.d.ts.map +1 -0
- package/lib/templates/editor/rich-text-editor.js +115 -0
- package/lib/templates/editor/rich-text-editor.js.map +1 -0
- package/lib/templates/editor/toolbar.d.ts +7 -0
- package/lib/templates/editor/toolbar.d.ts.map +1 -0
- package/lib/templates/editor/toolbar.js +272 -0
- package/lib/templates/editor/toolbar.js.map +1 -0
- package/lib/templates/form-fields/boolean-field.d.ts +7 -0
- package/lib/templates/form-fields/boolean-field.d.ts.map +1 -0
- package/lib/templates/form-fields/boolean-field.js +76 -0
- package/lib/templates/form-fields/boolean-field.js.map +1 -0
- package/lib/templates/form-fields/date-field.d.ts +7 -0
- package/lib/templates/form-fields/date-field.d.ts.map +1 -0
- package/lib/templates/form-fields/date-field.js +61 -0
- package/lib/templates/form-fields/date-field.js.map +1 -0
- package/lib/templates/form-fields/datetime-field.d.ts +7 -0
- package/lib/templates/form-fields/datetime-field.d.ts.map +1 -0
- package/lib/templates/form-fields/datetime-field.js +87 -0
- package/lib/templates/form-fields/datetime-field.js.map +1 -0
- package/lib/templates/form-fields/index.d.ts +23 -0
- package/lib/templates/form-fields/index.d.ts.map +1 -0
- package/lib/templates/form-fields/index.js +275 -0
- package/lib/templates/form-fields/index.js.map +1 -0
- package/lib/templates/form-fields/media-field.d.ts +10 -0
- package/lib/templates/form-fields/media-field.d.ts.map +1 -0
- package/lib/templates/form-fields/media-field.js +225 -0
- package/lib/templates/form-fields/media-field.js.map +1 -0
- package/lib/templates/form-fields/multiselect-field.d.ts +7 -0
- package/lib/templates/form-fields/multiselect-field.d.ts.map +1 -0
- package/lib/templates/form-fields/multiselect-field.js +121 -0
- package/lib/templates/form-fields/multiselect-field.js.map +1 -0
- package/lib/templates/form-fields/number-field.d.ts +7 -0
- package/lib/templates/form-fields/number-field.d.ts.map +1 -0
- package/lib/templates/form-fields/number-field.js +87 -0
- package/lib/templates/form-fields/number-field.js.map +1 -0
- package/lib/templates/form-fields/reference-field.d.ts +9 -0
- package/lib/templates/form-fields/reference-field.d.ts.map +1 -0
- package/lib/templates/form-fields/reference-field.js +145 -0
- package/lib/templates/form-fields/reference-field.js.map +1 -0
- package/lib/templates/form-fields/richtext-field.d.ts +9 -0
- package/lib/templates/form-fields/richtext-field.d.ts.map +1 -0
- package/lib/templates/form-fields/richtext-field.js +60 -0
- package/lib/templates/form-fields/richtext-field.js.map +1 -0
- package/lib/templates/form-fields/select-field.d.ts +7 -0
- package/lib/templates/form-fields/select-field.d.ts.map +1 -0
- package/lib/templates/form-fields/select-field.js +70 -0
- package/lib/templates/form-fields/select-field.js.map +1 -0
- package/lib/templates/form-fields/slug-field.d.ts +7 -0
- package/lib/templates/form-fields/slug-field.d.ts.map +1 -0
- package/lib/templates/form-fields/slug-field.js +143 -0
- package/lib/templates/form-fields/slug-field.js.map +1 -0
- package/lib/templates/form-fields/tags-field.d.ts +7 -0
- package/lib/templates/form-fields/tags-field.d.ts.map +1 -0
- package/lib/templates/form-fields/tags-field.js +172 -0
- package/lib/templates/form-fields/tags-field.js.map +1 -0
- package/lib/templates/form-fields/text-field.d.ts +7 -0
- package/lib/templates/form-fields/text-field.d.ts.map +1 -0
- package/lib/templates/form-fields/text-field.js +63 -0
- package/lib/templates/form-fields/text-field.js.map +1 -0
- package/lib/templates/form-fields/textarea-field.d.ts +7 -0
- package/lib/templates/form-fields/textarea-field.d.ts.map +1 -0
- package/lib/templates/form-fields/textarea-field.js +64 -0
- package/lib/templates/form-fields/textarea-field.js.map +1 -0
- package/lib/templates/index.d.ts +34 -0
- package/lib/templates/index.d.ts.map +1 -0
- package/lib/templates/index.js +92 -0
- package/lib/templates/index.js.map +1 -0
- package/lib/templates/media/index.d.ts +12 -0
- package/lib/templates/media/index.d.ts.map +1 -0
- package/lib/templates/media/index.js +50 -0
- package/lib/templates/media/index.js.map +1 -0
- package/lib/templates/media/media-api.d.ts +13 -0
- package/lib/templates/media/media-api.d.ts.map +1 -0
- package/lib/templates/media/media-api.js +274 -0
- package/lib/templates/media/media-api.js.map +1 -0
- package/lib/templates/media/media-grid.d.ts +14 -0
- package/lib/templates/media/media-grid.d.ts.map +1 -0
- package/lib/templates/media/media-grid.js +314 -0
- package/lib/templates/media/media-grid.js.map +1 -0
- package/lib/templates/media/media-library-route.d.ts +13 -0
- package/lib/templates/media/media-library-route.d.ts.map +1 -0
- package/lib/templates/media/media-library-route.js +105 -0
- package/lib/templates/media/media-library-route.js.map +1 -0
- package/lib/templates/media/media-picker.d.ts +13 -0
- package/lib/templates/media/media-picker.d.ts.map +1 -0
- package/lib/templates/media/media-picker.js +152 -0
- package/lib/templates/media/media-picker.js.map +1 -0
- package/lib/templates/media/media-uploader.d.ts +14 -0
- package/lib/templates/media/media-uploader.d.ts.map +1 -0
- package/lib/templates/media/media-uploader.js +318 -0
- package/lib/templates/media/media-uploader.js.map +1 -0
- package/lib/templates/recent-content.d.ts +13 -0
- package/lib/templates/recent-content.d.ts.map +1 -0
- package/lib/templates/recent-content.js +138 -0
- package/lib/templates/recent-content.js.map +1 -0
- package/lib/templates/slug-utils.d.ts +10 -0
- package/lib/templates/slug-utils.d.ts.map +1 -0
- package/lib/templates/slug-utils.js +194 -0
- package/lib/templates/slug-utils.js.map +1 -0
- package/lib/templates/ui-avatar.d.ts +8 -0
- package/lib/templates/ui-avatar.d.ts.map +1 -0
- package/lib/templates/ui-avatar.js +60 -0
- package/lib/templates/ui-avatar.js.map +1 -0
- package/lib/templates/ui-badge.d.ts +8 -0
- package/lib/templates/ui-badge.d.ts.map +1 -0
- package/lib/templates/ui-badge.js +52 -0
- package/lib/templates/ui-badge.js.map +1 -0
- package/lib/templates/ui-dialog.d.ts +10 -0
- package/lib/templates/ui-dialog.d.ts.map +1 -0
- package/lib/templates/ui-dialog.js +134 -0
- package/lib/templates/ui-dialog.js.map +1 -0
- package/lib/templates/ui-dropdown-menu.d.ts +8 -0
- package/lib/templates/ui-dropdown-menu.d.ts.map +1 -0
- package/lib/templates/ui-dropdown-menu.js +210 -0
- package/lib/templates/ui-dropdown-menu.js.map +1 -0
- package/lib/templates/ui-popover.d.ts +8 -0
- package/lib/templates/ui-popover.d.ts.map +1 -0
- package/lib/templates/ui-popover.js +43 -0
- package/lib/templates/ui-popover.js.map +1 -0
- package/lib/templates/ui-progress.d.ts +10 -0
- package/lib/templates/ui-progress.d.ts.map +1 -0
- package/lib/templates/ui-progress.js +40 -0
- package/lib/templates/ui-progress.js.map +1 -0
- package/lib/templates/ui-table.d.ts +8 -0
- package/lib/templates/ui-table.d.ts.map +1 -0
- package/lib/templates/ui-table.js +129 -0
- package/lib/templates/ui-table.js.map +1 -0
- package/lib/templates/ui-tabs.d.ts +10 -0
- package/lib/templates/ui-tabs.d.ts.map +1 -0
- package/lib/templates/ui-tabs.js +67 -0
- package/lib/templates/ui-tabs.js.map +1 -0
- package/package.json +52 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
3
|
+
exports.getMediaApiTemplate = getMediaApiTemplate;
|
|
4
|
+
/**
|
|
5
|
+
* Generates the media API client template.
|
|
6
|
+
*
|
|
7
|
+
* This template provides client-side helpers for:
|
|
8
|
+
* - Requesting presigned URLs
|
|
9
|
+
* - Uploading files directly to S3
|
|
10
|
+
* - Creating media records
|
|
11
|
+
* - Listing and deleting media
|
|
12
|
+
*
|
|
13
|
+
* @returns Template string for app/lib/media-api.ts
|
|
14
|
+
*/
|
|
15
|
+
function getMediaApiTemplate() {
|
|
16
|
+
return `/**
|
|
17
|
+
* Media API client for presigned URL uploads.
|
|
18
|
+
*
|
|
19
|
+
* This module handles the upload flow:
|
|
20
|
+
* 1. Request presigned URL from API
|
|
21
|
+
* 2. Upload file directly to S3
|
|
22
|
+
* 3. Create media record in API
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface MediaItem {
|
|
26
|
+
id: string;
|
|
27
|
+
key: string;
|
|
28
|
+
filename: string;
|
|
29
|
+
mimeType: string;
|
|
30
|
+
size: number;
|
|
31
|
+
url: string;
|
|
32
|
+
uploadedAt: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface UploadProgress {
|
|
36
|
+
loaded: number;
|
|
37
|
+
total: number;
|
|
38
|
+
percentage: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface UploadOptions {
|
|
42
|
+
onProgress?: (progress: UploadProgress) => void;
|
|
43
|
+
signal?: AbortSignal;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Allowed media MIME types.
|
|
48
|
+
*/
|
|
49
|
+
export const ALLOWED_MIME_TYPES = [
|
|
50
|
+
'image/jpeg',
|
|
51
|
+
'image/png',
|
|
52
|
+
'image/gif',
|
|
53
|
+
'image/webp',
|
|
54
|
+
'image/svg+xml',
|
|
55
|
+
'application/pdf',
|
|
56
|
+
'video/mp4',
|
|
57
|
+
'video/webm',
|
|
58
|
+
'audio/mpeg',
|
|
59
|
+
'audio/wav',
|
|
60
|
+
'audio/ogg',
|
|
61
|
+
] as const;
|
|
62
|
+
|
|
63
|
+
export type AllowedMimeType = typeof ALLOWED_MIME_TYPES[number];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Validates if a MIME type is allowed.
|
|
67
|
+
*/
|
|
68
|
+
export function isAllowedMimeType(mimeType: string): mimeType is AllowedMimeType {
|
|
69
|
+
return ALLOWED_MIME_TYPES.includes(mimeType as AllowedMimeType);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Gets the media type category from a MIME type.
|
|
74
|
+
*/
|
|
75
|
+
export function getMediaType(mimeType: string): 'image' | 'video' | 'audio' | 'document' | 'unknown' {
|
|
76
|
+
if (mimeType.startsWith('image/')) return 'image';
|
|
77
|
+
if (mimeType.startsWith('video/')) return 'video';
|
|
78
|
+
if (mimeType.startsWith('audio/')) return 'audio';
|
|
79
|
+
if (mimeType === 'application/pdf') return 'document';
|
|
80
|
+
return 'unknown';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Request a presigned URL for file upload.
|
|
85
|
+
*/
|
|
86
|
+
export async function getUploadUrl(file: File): Promise<{
|
|
87
|
+
uploadUrl: string;
|
|
88
|
+
key: string;
|
|
89
|
+
id: string;
|
|
90
|
+
expiresIn: number;
|
|
91
|
+
}> {
|
|
92
|
+
const response = await fetch('/api/media/upload-url', {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: {
|
|
95
|
+
'Content-Type': 'application/json',
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
filename: file.name,
|
|
99
|
+
contentType: file.type,
|
|
100
|
+
size: file.size,
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
const error = await response.json();
|
|
106
|
+
throw new Error(error.error?.message || 'Failed to get upload URL');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const result = await response.json();
|
|
110
|
+
return result.data;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Upload a file directly to S3 using a presigned URL.
|
|
115
|
+
* Uses XMLHttpRequest for progress tracking.
|
|
116
|
+
*/
|
|
117
|
+
export function uploadToS3(
|
|
118
|
+
file: File,
|
|
119
|
+
uploadUrl: string,
|
|
120
|
+
options: UploadOptions = {}
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
const xhr = new XMLHttpRequest();
|
|
124
|
+
|
|
125
|
+
// Handle abort signal
|
|
126
|
+
if (options.signal) {
|
|
127
|
+
options.signal.addEventListener('abort', () => {
|
|
128
|
+
xhr.abort();
|
|
129
|
+
reject(new DOMException('Upload aborted', 'AbortError'));
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Progress tracking
|
|
134
|
+
xhr.upload.onprogress = (event) => {
|
|
135
|
+
if (event.lengthComputable && options.onProgress) {
|
|
136
|
+
options.onProgress({
|
|
137
|
+
loaded: event.loaded,
|
|
138
|
+
total: event.total,
|
|
139
|
+
percentage: Math.round((event.loaded / event.total) * 100),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
xhr.onload = () => {
|
|
145
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
146
|
+
resolve();
|
|
147
|
+
} else {
|
|
148
|
+
reject(new Error(\`Upload failed with status \${xhr.status}\`));
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
xhr.onerror = () => {
|
|
153
|
+
reject(new Error('Upload failed'));
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
xhr.open('PUT', uploadUrl);
|
|
157
|
+
xhr.setRequestHeader('Content-Type', file.type);
|
|
158
|
+
xhr.send(file);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Create a media record after successful S3 upload.
|
|
164
|
+
*/
|
|
165
|
+
export async function createMediaRecord(params: {
|
|
166
|
+
id: string;
|
|
167
|
+
key: string;
|
|
168
|
+
filename: string;
|
|
169
|
+
mimeType: string;
|
|
170
|
+
size: number;
|
|
171
|
+
}): Promise<MediaItem> {
|
|
172
|
+
const response = await fetch('/api/media', {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers: {
|
|
175
|
+
'Content-Type': 'application/json',
|
|
176
|
+
},
|
|
177
|
+
body: JSON.stringify(params),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (!response.ok) {
|
|
181
|
+
const error = await response.json();
|
|
182
|
+
throw new Error(error.error?.message || 'Failed to create media record');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const result = await response.json();
|
|
186
|
+
return result.data;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Upload a file with the complete presigned URL flow.
|
|
191
|
+
*/
|
|
192
|
+
export async function uploadFile(
|
|
193
|
+
file: File,
|
|
194
|
+
options: UploadOptions = {}
|
|
195
|
+
): Promise<MediaItem> {
|
|
196
|
+
// Validate file type
|
|
197
|
+
if (!isAllowedMimeType(file.type)) {
|
|
198
|
+
throw new Error(\`File type \${file.type} is not allowed\`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Step 1: Get presigned URL
|
|
202
|
+
const { uploadUrl, key, id } = await getUploadUrl(file);
|
|
203
|
+
|
|
204
|
+
// Step 2: Upload to S3
|
|
205
|
+
await uploadToS3(file, uploadUrl, options);
|
|
206
|
+
|
|
207
|
+
// Step 3: Create media record
|
|
208
|
+
const media = await createMediaRecord({
|
|
209
|
+
id,
|
|
210
|
+
key,
|
|
211
|
+
filename: file.name,
|
|
212
|
+
mimeType: file.type,
|
|
213
|
+
size: file.size,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return media;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* List media items with optional filtering.
|
|
221
|
+
*/
|
|
222
|
+
export async function listMedia(params: {
|
|
223
|
+
limit?: number;
|
|
224
|
+
cursor?: string;
|
|
225
|
+
type?: 'image' | 'video' | 'audio' | 'document';
|
|
226
|
+
} = {}): Promise<{
|
|
227
|
+
items: MediaItem[];
|
|
228
|
+
nextCursor?: string;
|
|
229
|
+
total?: number;
|
|
230
|
+
}> {
|
|
231
|
+
const searchParams = new URLSearchParams();
|
|
232
|
+
if (params.limit) searchParams.set('limit', params.limit.toString());
|
|
233
|
+
if (params.cursor) searchParams.set('cursor', params.cursor);
|
|
234
|
+
if (params.type) searchParams.set('type', params.type);
|
|
235
|
+
|
|
236
|
+
const url = \`/api/media?\${searchParams.toString()}\`;
|
|
237
|
+
const response = await fetch(url);
|
|
238
|
+
|
|
239
|
+
if (!response.ok) {
|
|
240
|
+
const error = await response.json();
|
|
241
|
+
throw new Error(error.error?.message || 'Failed to list media');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const result = await response.json();
|
|
245
|
+
return result.data;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Delete a media item.
|
|
250
|
+
*/
|
|
251
|
+
export async function deleteMedia(id: string): Promise<void> {
|
|
252
|
+
const response = await fetch(\`/api/media/\${id}\`, {
|
|
253
|
+
method: 'DELETE',
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
if (!response.ok) {
|
|
257
|
+
const error = await response.json();
|
|
258
|
+
throw new Error(error.error?.message || 'Failed to delete media');
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Format file size for display.
|
|
264
|
+
*/
|
|
265
|
+
export function formatFileSize(bytes: number): string {
|
|
266
|
+
if (bytes === 0) return '0 Bytes';
|
|
267
|
+
const k = 1024;
|
|
268
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
269
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
270
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
271
|
+
}
|
|
272
|
+
`;
|
|
273
|
+
}
|
|
274
|
+
//# sourceMappingURL=media-api.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media-api.js","sourceRoot":"","sources":["../../../src/templates/media/media-api.ts"],"names":[],"mappings":";;AAWA,kDAkQC;AA7QD;;;;;;;;;;GAUG;AACH,SAAgB,mBAAmB;IACjC,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgQR,CAAC;AACF,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates the media grid component template.
|
|
3
|
+
*
|
|
4
|
+
* This component provides:
|
|
5
|
+
* - Grid display of media items
|
|
6
|
+
* - Selection support (single/multiple)
|
|
7
|
+
* - Type filtering
|
|
8
|
+
* - Pagination
|
|
9
|
+
* - Delete functionality
|
|
10
|
+
*
|
|
11
|
+
* @returns Template string for app/components/media/media-grid.tsx
|
|
12
|
+
*/
|
|
13
|
+
export declare function getMediaGridTemplate(): string;
|
|
14
|
+
//# sourceMappingURL=media-grid.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media-grid.d.ts","sourceRoot":"","sources":["../../../src/templates/media/media-grid.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,IAAI,MAAM,CAyS7C"}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
3
|
+
exports.getMediaGridTemplate = getMediaGridTemplate;
|
|
4
|
+
/**
|
|
5
|
+
* Generates the media grid component template.
|
|
6
|
+
*
|
|
7
|
+
* This component provides:
|
|
8
|
+
* - Grid display of media items
|
|
9
|
+
* - Selection support (single/multiple)
|
|
10
|
+
* - Type filtering
|
|
11
|
+
* - Pagination
|
|
12
|
+
* - Delete functionality
|
|
13
|
+
*
|
|
14
|
+
* @returns Template string for app/components/media/media-grid.tsx
|
|
15
|
+
*/
|
|
16
|
+
function getMediaGridTemplate() {
|
|
17
|
+
return `import { useState, useEffect, useCallback } from 'react';
|
|
18
|
+
import {
|
|
19
|
+
Image,
|
|
20
|
+
FileVideo,
|
|
21
|
+
FileAudio,
|
|
22
|
+
File,
|
|
23
|
+
Trash2,
|
|
24
|
+
Check,
|
|
25
|
+
Loader2,
|
|
26
|
+
ChevronLeft,
|
|
27
|
+
ChevronRight,
|
|
28
|
+
} from 'lucide-react';
|
|
29
|
+
import { cn } from '~/lib/utils';
|
|
30
|
+
import { Button } from '~/components/ui/button';
|
|
31
|
+
import {
|
|
32
|
+
Select,
|
|
33
|
+
SelectContent,
|
|
34
|
+
SelectItem,
|
|
35
|
+
SelectTrigger,
|
|
36
|
+
SelectValue,
|
|
37
|
+
} from '~/components/ui/select';
|
|
38
|
+
import {
|
|
39
|
+
listMedia,
|
|
40
|
+
deleteMedia,
|
|
41
|
+
getMediaType,
|
|
42
|
+
formatFileSize,
|
|
43
|
+
type MediaItem,
|
|
44
|
+
} from '~/lib/media-api';
|
|
45
|
+
|
|
46
|
+
interface MediaGridProps {
|
|
47
|
+
onSelect?: (media: MediaItem) => void;
|
|
48
|
+
onSelectionChange?: (media: MediaItem[]) => void;
|
|
49
|
+
selectedIds?: string[];
|
|
50
|
+
multiple?: boolean;
|
|
51
|
+
accept?: string[];
|
|
52
|
+
className?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get icon for file type.
|
|
57
|
+
*/
|
|
58
|
+
function getMediaIcon(mimeType: string) {
|
|
59
|
+
const type = getMediaType(mimeType);
|
|
60
|
+
switch (type) {
|
|
61
|
+
case 'image':
|
|
62
|
+
return <Image className="h-8 w-8 text-muted-foreground" />;
|
|
63
|
+
case 'video':
|
|
64
|
+
return <FileVideo className="h-8 w-8 text-muted-foreground" />;
|
|
65
|
+
case 'audio':
|
|
66
|
+
return <FileAudio className="h-8 w-8 text-muted-foreground" />;
|
|
67
|
+
default:
|
|
68
|
+
return <File className="h-8 w-8 text-muted-foreground" />;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check if media item is an image.
|
|
74
|
+
*/
|
|
75
|
+
function isImage(mimeType: string): boolean {
|
|
76
|
+
return mimeType.startsWith('image/') && mimeType !== 'image/svg+xml';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function MediaGrid({
|
|
80
|
+
onSelect,
|
|
81
|
+
onSelectionChange,
|
|
82
|
+
selectedIds = [],
|
|
83
|
+
multiple = false,
|
|
84
|
+
accept,
|
|
85
|
+
className,
|
|
86
|
+
}: MediaGridProps) {
|
|
87
|
+
const [media, setMedia] = useState<MediaItem[]>([]);
|
|
88
|
+
const [loading, setLoading] = useState(true);
|
|
89
|
+
const [error, setError] = useState<string | null>(null);
|
|
90
|
+
const [typeFilter, setTypeFilter] = useState<string>('all');
|
|
91
|
+
const [cursor, setCursor] = useState<string | undefined>();
|
|
92
|
+
const [hasMore, setHasMore] = useState(false);
|
|
93
|
+
const [deleting, setDeleting] = useState<string | null>(null);
|
|
94
|
+
|
|
95
|
+
const loadMedia = useCallback(async (reset = false) => {
|
|
96
|
+
setLoading(true);
|
|
97
|
+
setError(null);
|
|
98
|
+
try {
|
|
99
|
+
const type = typeFilter !== 'all' ? typeFilter as 'image' | 'video' | 'audio' | 'document' : undefined;
|
|
100
|
+
const result = await listMedia({
|
|
101
|
+
limit: 20,
|
|
102
|
+
cursor: reset ? undefined : cursor,
|
|
103
|
+
type,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (reset) {
|
|
107
|
+
setMedia(result.items);
|
|
108
|
+
} else {
|
|
109
|
+
setMedia((prev) => [...prev, ...result.items]);
|
|
110
|
+
}
|
|
111
|
+
setCursor(result.nextCursor);
|
|
112
|
+
setHasMore(!!result.nextCursor);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
setError((err as Error).message);
|
|
115
|
+
} finally {
|
|
116
|
+
setLoading(false);
|
|
117
|
+
}
|
|
118
|
+
}, [typeFilter, cursor]);
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
loadMedia(true);
|
|
122
|
+
}, [typeFilter]);
|
|
123
|
+
|
|
124
|
+
const handleSelect = (item: MediaItem) => {
|
|
125
|
+
// Check if item type is accepted
|
|
126
|
+
if (accept && accept.length > 0) {
|
|
127
|
+
const isAccepted = accept.some((acceptType) => {
|
|
128
|
+
if (acceptType.endsWith('/*')) {
|
|
129
|
+
return item.mimeType.startsWith(acceptType.replace('/*', '/'));
|
|
130
|
+
}
|
|
131
|
+
return item.mimeType === acceptType;
|
|
132
|
+
});
|
|
133
|
+
if (!isAccepted) return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (multiple) {
|
|
137
|
+
const isSelected = selectedIds.includes(item.id);
|
|
138
|
+
const newSelection = isSelected
|
|
139
|
+
? selectedIds.filter((id) => id !== item.id)
|
|
140
|
+
: [...selectedIds, item.id];
|
|
141
|
+
|
|
142
|
+
const selectedItems = media.filter((m) => newSelection.includes(m.id));
|
|
143
|
+
onSelectionChange?.(selectedItems);
|
|
144
|
+
} else {
|
|
145
|
+
onSelect?.(item);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const handleDelete = async (item: MediaItem, e: React.MouseEvent) => {
|
|
150
|
+
e.stopPropagation();
|
|
151
|
+
if (!confirm(\`Delete "\${item.filename}"?\`)) return;
|
|
152
|
+
|
|
153
|
+
setDeleting(item.id);
|
|
154
|
+
try {
|
|
155
|
+
await deleteMedia(item.id);
|
|
156
|
+
setMedia((prev) => prev.filter((m) => m.id !== item.id));
|
|
157
|
+
} catch (err) {
|
|
158
|
+
setError((err as Error).message);
|
|
159
|
+
} finally {
|
|
160
|
+
setDeleting(null);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const isSelected = (id: string) => selectedIds.includes(id);
|
|
165
|
+
|
|
166
|
+
const isAccepted = (mimeType: string) => {
|
|
167
|
+
if (!accept || accept.length === 0) return true;
|
|
168
|
+
return accept.some((acceptType) => {
|
|
169
|
+
if (acceptType.endsWith('/*')) {
|
|
170
|
+
return mimeType.startsWith(acceptType.replace('/*', '/'));
|
|
171
|
+
}
|
|
172
|
+
return mimeType === acceptType;
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div className={cn('space-y-4', className)}>
|
|
178
|
+
{/* Filters */}
|
|
179
|
+
<div className="flex items-center gap-4">
|
|
180
|
+
<Select value={typeFilter} onValueChange={(v) => setTypeFilter(v)}>
|
|
181
|
+
<SelectTrigger className="w-[180px]">
|
|
182
|
+
<SelectValue placeholder="Filter by type" />
|
|
183
|
+
</SelectTrigger>
|
|
184
|
+
<SelectContent>
|
|
185
|
+
<SelectItem value="all">All Types</SelectItem>
|
|
186
|
+
<SelectItem value="image">Images</SelectItem>
|
|
187
|
+
<SelectItem value="video">Videos</SelectItem>
|
|
188
|
+
<SelectItem value="audio">Audio</SelectItem>
|
|
189
|
+
<SelectItem value="document">Documents</SelectItem>
|
|
190
|
+
</SelectContent>
|
|
191
|
+
</Select>
|
|
192
|
+
|
|
193
|
+
{selectedIds.length > 0 && (
|
|
194
|
+
<span className="text-sm text-muted-foreground">
|
|
195
|
+
{selectedIds.length} selected
|
|
196
|
+
</span>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{/* Error message */}
|
|
201
|
+
{error && (
|
|
202
|
+
<div className="rounded-lg bg-destructive/10 p-4 text-sm text-destructive">
|
|
203
|
+
{error}
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{/* Loading state */}
|
|
208
|
+
{loading && media.length === 0 && (
|
|
209
|
+
<div className="flex items-center justify-center py-12">
|
|
210
|
+
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{/* Empty state */}
|
|
215
|
+
{!loading && media.length === 0 && (
|
|
216
|
+
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
217
|
+
<Image className="h-12 w-12 text-muted-foreground/50" />
|
|
218
|
+
<p className="mt-4 text-sm text-muted-foreground">
|
|
219
|
+
No media files found
|
|
220
|
+
</p>
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
|
|
224
|
+
{/* Grid */}
|
|
225
|
+
{media.length > 0 && (
|
|
226
|
+
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
|
227
|
+
{media.map((item) => {
|
|
228
|
+
const accepted = isAccepted(item.mimeType);
|
|
229
|
+
const selected = isSelected(item.id);
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<div
|
|
233
|
+
key={item.id}
|
|
234
|
+
onClick={() => accepted && handleSelect(item)}
|
|
235
|
+
className={cn(
|
|
236
|
+
'group relative aspect-square cursor-pointer overflow-hidden rounded-lg border bg-muted transition-all',
|
|
237
|
+
selected && 'ring-2 ring-primary ring-offset-2',
|
|
238
|
+
!accepted && 'cursor-not-allowed opacity-50',
|
|
239
|
+
accepted && 'hover:border-primary/50'
|
|
240
|
+
)}
|
|
241
|
+
>
|
|
242
|
+
{/* Preview */}
|
|
243
|
+
{isImage(item.mimeType) ? (
|
|
244
|
+
<img
|
|
245
|
+
src={item.url}
|
|
246
|
+
alt={item.filename}
|
|
247
|
+
className="h-full w-full object-cover"
|
|
248
|
+
/>
|
|
249
|
+
) : (
|
|
250
|
+
<div className="flex h-full w-full items-center justify-center">
|
|
251
|
+
{getMediaIcon(item.mimeType)}
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
|
|
255
|
+
{/* Selection indicator */}
|
|
256
|
+
{selected && (
|
|
257
|
+
<div className="absolute left-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
|
258
|
+
<Check className="h-4 w-4" />
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
|
|
262
|
+
{/* Hover overlay */}
|
|
263
|
+
<div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/60 to-transparent p-2 opacity-0 transition-opacity group-hover:opacity-100">
|
|
264
|
+
<p className="truncate text-xs font-medium text-white">
|
|
265
|
+
{item.filename}
|
|
266
|
+
</p>
|
|
267
|
+
<p className="text-xs text-white/70">
|
|
268
|
+
{formatFileSize(item.size)}
|
|
269
|
+
</p>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
{/* Delete button */}
|
|
273
|
+
<button
|
|
274
|
+
onClick={(e) => handleDelete(item, e)}
|
|
275
|
+
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-destructive text-destructive-foreground opacity-0 transition-opacity hover:bg-destructive/90 group-hover:opacity-100"
|
|
276
|
+
disabled={deleting === item.id}
|
|
277
|
+
>
|
|
278
|
+
{deleting === item.id ? (
|
|
279
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
280
|
+
) : (
|
|
281
|
+
<Trash2 className="h-4 w-4" />
|
|
282
|
+
)}
|
|
283
|
+
</button>
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
286
|
+
})}
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
|
|
290
|
+
{/* Load more */}
|
|
291
|
+
{hasMore && (
|
|
292
|
+
<div className="flex justify-center pt-4">
|
|
293
|
+
<Button
|
|
294
|
+
variant="outline"
|
|
295
|
+
onClick={() => loadMedia(false)}
|
|
296
|
+
disabled={loading}
|
|
297
|
+
>
|
|
298
|
+
{loading ? (
|
|
299
|
+
<>
|
|
300
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
301
|
+
Loading...
|
|
302
|
+
</>
|
|
303
|
+
) : (
|
|
304
|
+
'Load More'
|
|
305
|
+
)}
|
|
306
|
+
</Button>
|
|
307
|
+
</div>
|
|
308
|
+
)}
|
|
309
|
+
</div>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
`;
|
|
313
|
+
}
|
|
314
|
+
//# sourceMappingURL=media-grid.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media-grid.js","sourceRoot":"","sources":["../../../src/templates/media/media-grid.ts"],"names":[],"mappings":";;AAYA,oDAySC;AArTD;;;;;;;;;;;GAWG;AACH,SAAgB,oBAAoB;IAClC,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuSR,CAAC;AACF,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates the media library route template.
|
|
3
|
+
*
|
|
4
|
+
* This is the main media library page in the admin interface.
|
|
5
|
+
* It provides:
|
|
6
|
+
* - Full media upload functionality
|
|
7
|
+
* - Grid view of all media
|
|
8
|
+
* - Filtering and pagination
|
|
9
|
+
*
|
|
10
|
+
* @returns Template string for app/routes/_admin.media.tsx
|
|
11
|
+
*/
|
|
12
|
+
export declare function getMediaLibraryRouteTemplate(): string;
|
|
13
|
+
//# sourceMappingURL=media-library-route.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media-library-route.d.ts","sourceRoot":"","sources":["../../../src/templates/media/media-library-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,wBAAgB,4BAA4B,IAAI,MAAM,CAyFrD"}
|