pxlr-cms 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/README.md +160 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +264 -0
  4. package/package.json +51 -0
  5. package/templates/blog/frontend/app/blog/[slug]/page.tsx +175 -0
  6. package/templates/blog/frontend/app/blog/page.tsx +102 -0
  7. package/templates/blog/frontend/app/components/footer.tsx +21 -0
  8. package/templates/blog/frontend/app/components/header.tsx +45 -0
  9. package/templates/blog/frontend/app/globals.css +30 -0
  10. package/templates/blog/frontend/app/layout.tsx +38 -0
  11. package/templates/blog/frontend/app/lib/cms.ts +71 -0
  12. package/templates/blog/frontend/app/page.tsx +155 -0
  13. package/templates/blog/frontend/next.config.ts +16 -0
  14. package/templates/blog/frontend/package.json +24 -0
  15. package/templates/blog/frontend/postcss.config.mjs +7 -0
  16. package/templates/blog/frontend/tsconfig.json +23 -0
  17. package/templates/blog/pxlr-cms/README.md +188 -0
  18. package/templates/blog/pxlr-cms/docker-compose.yml +132 -0
  19. package/templates/blog/pxlr-cms/nginx/nginx.conf +107 -0
  20. package/templates/blog/pxlr-cms/packages/admin/.dockerignore +4 -0
  21. package/templates/blog/pxlr-cms/packages/admin/.env.example +2 -0
  22. package/templates/blog/pxlr-cms/packages/admin/Dockerfile +19 -0
  23. package/templates/blog/pxlr-cms/packages/admin/next-env.d.ts +6 -0
  24. package/templates/blog/pxlr-cms/packages/admin/next.config.ts +22 -0
  25. package/templates/blog/pxlr-cms/packages/admin/package.json +63 -0
  26. package/templates/blog/pxlr-cms/packages/admin/pnpm-lock.yaml +5748 -0
  27. package/templates/blog/pxlr-cms/packages/admin/postcss.config.mjs +9 -0
  28. package/templates/blog/pxlr-cms/packages/admin/src/app/content/[id]/page.tsx +503 -0
  29. package/templates/blog/pxlr-cms/packages/admin/src/app/content/layout.tsx +7 -0
  30. package/templates/blog/pxlr-cms/packages/admin/src/app/content/new/page.tsx +424 -0
  31. package/templates/blog/pxlr-cms/packages/admin/src/app/content/page.tsx +191 -0
  32. package/templates/blog/pxlr-cms/packages/admin/src/app/globals.css +132 -0
  33. package/templates/blog/pxlr-cms/packages/admin/src/app/layout.tsx +25 -0
  34. package/templates/blog/pxlr-cms/packages/admin/src/app/login/page.tsx +119 -0
  35. package/templates/blog/pxlr-cms/packages/admin/src/app/media/layout.tsx +7 -0
  36. package/templates/blog/pxlr-cms/packages/admin/src/app/media/page.tsx +362 -0
  37. package/templates/blog/pxlr-cms/packages/admin/src/app/page.tsx +184 -0
  38. package/templates/blog/pxlr-cms/packages/admin/src/app/profile/layout.tsx +7 -0
  39. package/templates/blog/pxlr-cms/packages/admin/src/app/profile/page.tsx +206 -0
  40. package/templates/blog/pxlr-cms/packages/admin/src/app/schemas/[name]/page.tsx +312 -0
  41. package/templates/blog/pxlr-cms/packages/admin/src/app/schemas/layout.tsx +7 -0
  42. package/templates/blog/pxlr-cms/packages/admin/src/app/schemas/page.tsx +210 -0
  43. package/templates/blog/pxlr-cms/packages/admin/src/app/settings/layout.tsx +7 -0
  44. package/templates/blog/pxlr-cms/packages/admin/src/app/settings/page.tsx +178 -0
  45. package/templates/blog/pxlr-cms/packages/admin/src/components/editor/media-picker.tsx +202 -0
  46. package/templates/blog/pxlr-cms/packages/admin/src/components/editor/rich-text-editor.tsx +387 -0
  47. package/templates/blog/pxlr-cms/packages/admin/src/components/layout/auth-layout.tsx +43 -0
  48. package/templates/blog/pxlr-cms/packages/admin/src/components/layout/header.tsx +79 -0
  49. package/templates/blog/pxlr-cms/packages/admin/src/components/layout/sidebar.tsx +68 -0
  50. package/templates/blog/pxlr-cms/packages/admin/src/components/providers.tsx +29 -0
  51. package/templates/blog/pxlr-cms/packages/admin/src/components/schema-code-generator.tsx +326 -0
  52. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/avatar.tsx +49 -0
  53. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/button.tsx +55 -0
  54. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/dropdown-menu.tsx +194 -0
  55. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/input.tsx +24 -0
  56. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/label.tsx +25 -0
  57. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/toast.tsx +127 -0
  58. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/toaster.tsx +35 -0
  59. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/use-toast.ts +187 -0
  60. package/templates/blog/pxlr-cms/packages/admin/src/lib/api.ts +96 -0
  61. package/templates/blog/pxlr-cms/packages/admin/src/lib/i18n/context.tsx +60 -0
  62. package/templates/blog/pxlr-cms/packages/admin/src/lib/i18n/translations.ts +317 -0
  63. package/templates/blog/pxlr-cms/packages/admin/src/lib/store/auth.ts +51 -0
  64. package/templates/blog/pxlr-cms/packages/admin/src/lib/utils.ts +29 -0
  65. package/templates/blog/pxlr-cms/packages/admin/tailwind.config.ts +57 -0
  66. package/templates/blog/pxlr-cms/packages/admin/tsconfig.json +27 -0
  67. package/templates/blog/pxlr-cms/packages/api/.env.example +23 -0
  68. package/templates/blog/pxlr-cms/packages/api/Dockerfile +26 -0
  69. package/templates/blog/pxlr-cms/packages/api/package.json +42 -0
  70. package/templates/blog/pxlr-cms/packages/api/src/config.ts +39 -0
  71. package/templates/blog/pxlr-cms/packages/api/src/database/index.ts +60 -0
  72. package/templates/blog/pxlr-cms/packages/api/src/database/init.sql +258 -0
  73. package/templates/blog/pxlr-cms/packages/api/src/database/redis.ts +95 -0
  74. package/templates/blog/pxlr-cms/packages/api/src/database/seed.sql +78 -0
  75. package/templates/blog/pxlr-cms/packages/api/src/index.ts +157 -0
  76. package/templates/blog/pxlr-cms/packages/api/src/modules/auth/routes.ts +256 -0
  77. package/templates/blog/pxlr-cms/packages/api/src/modules/content/routes.ts +385 -0
  78. package/templates/blog/pxlr-cms/packages/api/src/modules/media/routes.ts +312 -0
  79. package/templates/blog/pxlr-cms/packages/api/src/modules/realtime/handler.ts +228 -0
  80. package/templates/blog/pxlr-cms/packages/api/src/modules/schema/routes.ts +284 -0
  81. package/templates/blog/pxlr-cms/packages/api/src/modules/versions/routes.ts +70 -0
  82. package/templates/blog/pxlr-cms/packages/api/tsconfig.json +24 -0
  83. package/templates/blog/pxlr-cms/packages/shared/package.json +14 -0
  84. package/templates/blog/pxlr-cms/packages/shared/src/types/index.ts +139 -0
  85. package/templates/blog/pxlr-cms/packages/shared/tsconfig.json +18 -0
  86. package/templates/clean/pxlr-cms/README.md +188 -0
  87. package/templates/clean/pxlr-cms/docker-compose.yml +132 -0
  88. package/templates/clean/pxlr-cms/nginx/nginx.conf +107 -0
  89. package/templates/clean/pxlr-cms/packages/admin/.dockerignore +4 -0
  90. package/templates/clean/pxlr-cms/packages/admin/.env.example +2 -0
  91. package/templates/clean/pxlr-cms/packages/admin/Dockerfile +19 -0
  92. package/templates/clean/pxlr-cms/packages/admin/next-env.d.ts +6 -0
  93. package/templates/clean/pxlr-cms/packages/admin/next.config.ts +22 -0
  94. package/templates/clean/pxlr-cms/packages/admin/package.json +63 -0
  95. package/templates/clean/pxlr-cms/packages/admin/pnpm-lock.yaml +5748 -0
  96. package/templates/clean/pxlr-cms/packages/admin/postcss.config.mjs +9 -0
  97. package/templates/clean/pxlr-cms/packages/admin/src/app/content/[id]/page.tsx +503 -0
  98. package/templates/clean/pxlr-cms/packages/admin/src/app/content/layout.tsx +7 -0
  99. package/templates/clean/pxlr-cms/packages/admin/src/app/content/new/page.tsx +424 -0
  100. package/templates/clean/pxlr-cms/packages/admin/src/app/content/page.tsx +191 -0
  101. package/templates/clean/pxlr-cms/packages/admin/src/app/globals.css +132 -0
  102. package/templates/clean/pxlr-cms/packages/admin/src/app/layout.tsx +25 -0
  103. package/templates/clean/pxlr-cms/packages/admin/src/app/login/page.tsx +119 -0
  104. package/templates/clean/pxlr-cms/packages/admin/src/app/media/layout.tsx +7 -0
  105. package/templates/clean/pxlr-cms/packages/admin/src/app/media/page.tsx +362 -0
  106. package/templates/clean/pxlr-cms/packages/admin/src/app/page.tsx +184 -0
  107. package/templates/clean/pxlr-cms/packages/admin/src/app/profile/layout.tsx +7 -0
  108. package/templates/clean/pxlr-cms/packages/admin/src/app/profile/page.tsx +206 -0
  109. package/templates/clean/pxlr-cms/packages/admin/src/app/schemas/[name]/page.tsx +312 -0
  110. package/templates/clean/pxlr-cms/packages/admin/src/app/schemas/layout.tsx +7 -0
  111. package/templates/clean/pxlr-cms/packages/admin/src/app/schemas/page.tsx +210 -0
  112. package/templates/clean/pxlr-cms/packages/admin/src/app/settings/layout.tsx +7 -0
  113. package/templates/clean/pxlr-cms/packages/admin/src/app/settings/page.tsx +178 -0
  114. package/templates/clean/pxlr-cms/packages/admin/src/components/editor/media-picker.tsx +202 -0
  115. package/templates/clean/pxlr-cms/packages/admin/src/components/editor/rich-text-editor.tsx +387 -0
  116. package/templates/clean/pxlr-cms/packages/admin/src/components/layout/auth-layout.tsx +43 -0
  117. package/templates/clean/pxlr-cms/packages/admin/src/components/layout/header.tsx +79 -0
  118. package/templates/clean/pxlr-cms/packages/admin/src/components/layout/sidebar.tsx +68 -0
  119. package/templates/clean/pxlr-cms/packages/admin/src/components/providers.tsx +29 -0
  120. package/templates/clean/pxlr-cms/packages/admin/src/components/schema-code-generator.tsx +326 -0
  121. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/avatar.tsx +49 -0
  122. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/button.tsx +55 -0
  123. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/dropdown-menu.tsx +194 -0
  124. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/input.tsx +24 -0
  125. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/label.tsx +25 -0
  126. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/toast.tsx +127 -0
  127. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/toaster.tsx +35 -0
  128. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/use-toast.ts +187 -0
  129. package/templates/clean/pxlr-cms/packages/admin/src/lib/api.ts +96 -0
  130. package/templates/clean/pxlr-cms/packages/admin/src/lib/i18n/context.tsx +60 -0
  131. package/templates/clean/pxlr-cms/packages/admin/src/lib/i18n/translations.ts +317 -0
  132. package/templates/clean/pxlr-cms/packages/admin/src/lib/store/auth.ts +51 -0
  133. package/templates/clean/pxlr-cms/packages/admin/src/lib/utils.ts +29 -0
  134. package/templates/clean/pxlr-cms/packages/admin/tailwind.config.ts +57 -0
  135. package/templates/clean/pxlr-cms/packages/admin/tsconfig.json +27 -0
  136. package/templates/clean/pxlr-cms/packages/api/.env.example +23 -0
  137. package/templates/clean/pxlr-cms/packages/api/Dockerfile +26 -0
  138. package/templates/clean/pxlr-cms/packages/api/package.json +42 -0
  139. package/templates/clean/pxlr-cms/packages/api/src/config.ts +39 -0
  140. package/templates/clean/pxlr-cms/packages/api/src/database/index.ts +60 -0
  141. package/templates/clean/pxlr-cms/packages/api/src/database/init.sql +178 -0
  142. package/templates/clean/pxlr-cms/packages/api/src/database/redis.ts +95 -0
  143. package/templates/clean/pxlr-cms/packages/api/src/index.ts +157 -0
  144. package/templates/clean/pxlr-cms/packages/api/src/modules/auth/routes.ts +256 -0
  145. package/templates/clean/pxlr-cms/packages/api/src/modules/content/routes.ts +385 -0
  146. package/templates/clean/pxlr-cms/packages/api/src/modules/media/routes.ts +312 -0
  147. package/templates/clean/pxlr-cms/packages/api/src/modules/realtime/handler.ts +228 -0
  148. package/templates/clean/pxlr-cms/packages/api/src/modules/schema/routes.ts +284 -0
  149. package/templates/clean/pxlr-cms/packages/api/src/modules/versions/routes.ts +70 -0
  150. package/templates/clean/pxlr-cms/packages/api/tsconfig.json +24 -0
  151. package/templates/clean/pxlr-cms/packages/shared/package.json +14 -0
  152. package/templates/clean/pxlr-cms/packages/shared/src/types/index.ts +139 -0
  153. package/templates/clean/pxlr-cms/packages/shared/tsconfig.json +18 -0
@@ -0,0 +1,312 @@
1
+ import { FastifyPluginAsync } from 'fastify';
2
+ import { z } from 'zod';
3
+ import { v4 as uuid } from 'uuid';
4
+ import * as Minio from 'minio';
5
+ import sharp from 'sharp';
6
+ import { db } from '../../database/index.js';
7
+ import { config } from '../../config.js';
8
+
9
+ // Initialize MinIO client
10
+ const minioClient = new Minio.Client({
11
+ endPoint: config.minio.endpoint,
12
+ port: config.minio.port,
13
+ useSSL: config.minio.useSSL,
14
+ accessKey: config.minio.accessKey,
15
+ secretKey: config.minio.secretKey,
16
+ });
17
+
18
+ const updateMediaSchema = z.object({
19
+ altText: z.string().optional(),
20
+ caption: z.string().optional(),
21
+ folder: z.string().optional(),
22
+ });
23
+
24
+ const querySchema = z.object({
25
+ folder: z.string().optional(),
26
+ mimeType: z.string().optional(),
27
+ page: z.coerce.number().min(1).default(1),
28
+ limit: z.coerce.number().min(1).max(100).default(20),
29
+ search: z.string().optional(),
30
+ });
31
+
32
+ // Ensure bucket exists with public read policy
33
+ async function ensureBucket() {
34
+ const exists = await minioClient.bucketExists(config.minio.bucket);
35
+ if (!exists) {
36
+ await minioClient.makeBucket(config.minio.bucket);
37
+ }
38
+
39
+ // Always ensure public read policy is set
40
+ const policy = {
41
+ Version: '2012-10-17',
42
+ Statement: [
43
+ {
44
+ Effect: 'Allow',
45
+ Principal: { AWS: ['*'] },
46
+ Action: ['s3:GetObject'],
47
+ Resource: [`arn:aws:s3:::${config.minio.bucket}/*`],
48
+ },
49
+ ],
50
+ };
51
+
52
+ try {
53
+ await minioClient.setBucketPolicy(config.minio.bucket, JSON.stringify(policy));
54
+ } catch (err) {
55
+ console.log('Bucket policy already set or error:', err);
56
+ }
57
+ }
58
+
59
+ export const mediaRoutes: FastifyPluginAsync = async (fastify) => {
60
+ // Ensure bucket on startup
61
+ await ensureBucket();
62
+
63
+ // List media files
64
+ fastify.get('/', {
65
+ schema: {
66
+ tags: ['Media'],
67
+ summary: 'List media files with filtering',
68
+ },
69
+ }, async (request) => {
70
+ const query = querySchema.parse(request.query);
71
+ const offset = (query.page - 1) * query.limit;
72
+
73
+ let whereClause = 'WHERE 1=1';
74
+ const params: any[] = [];
75
+ let paramIndex = 1;
76
+
77
+ if (query.folder) {
78
+ whereClause += ` AND folder = $${paramIndex++}`;
79
+ params.push(query.folder);
80
+ }
81
+ if (query.mimeType) {
82
+ whereClause += ` AND mime_type LIKE $${paramIndex++}`;
83
+ params.push(`${query.mimeType}%`);
84
+ }
85
+ if (query.search) {
86
+ whereClause += ` AND (original_filename ILIKE $${paramIndex} OR alt_text ILIKE $${paramIndex})`;
87
+ params.push(`%${query.search}%`);
88
+ paramIndex++;
89
+ }
90
+
91
+ // Count total
92
+ const countResult = await db.query(
93
+ `SELECT COUNT(*) FROM media ${whereClause}`,
94
+ params
95
+ );
96
+ const total = parseInt(countResult.rows[0].count);
97
+
98
+ // Get files
99
+ params.push(query.limit, offset);
100
+ const result = await db.query(
101
+ `SELECT m.*, u.name as uploaded_by_name
102
+ FROM media m
103
+ LEFT JOIN users u ON m.uploaded_by = u.id
104
+ ${whereClause}
105
+ ORDER BY m.created_at DESC
106
+ LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
107
+ params
108
+ );
109
+
110
+ return {
111
+ files: result.rows,
112
+ pagination: {
113
+ page: query.page,
114
+ limit: query.limit,
115
+ total,
116
+ totalPages: Math.ceil(total / query.limit),
117
+ },
118
+ };
119
+ });
120
+
121
+ // Get single media file
122
+ fastify.get('/:id', {
123
+ schema: {
124
+ tags: ['Media'],
125
+ summary: 'Get media file by ID',
126
+ },
127
+ }, async (request, reply) => {
128
+ const { id } = request.params as { id: string };
129
+
130
+ const result = await db.query(
131
+ `SELECT m.*, u.name as uploaded_by_name
132
+ FROM media m
133
+ LEFT JOIN users u ON m.uploaded_by = u.id
134
+ WHERE m.id = $1`,
135
+ [id]
136
+ );
137
+
138
+ if (result.rows.length === 0) {
139
+ return reply.status(404).send({ error: true, message: 'File not found' });
140
+ }
141
+
142
+ return { file: result.rows[0] };
143
+ });
144
+
145
+ // Upload media file
146
+ fastify.post('/upload', {
147
+ schema: {
148
+ tags: ['Media'],
149
+ summary: 'Upload a media file',
150
+ security: [{ bearerAuth: [] }],
151
+ },
152
+ preHandler: [fastify.authenticate],
153
+ }, async (request, reply) => {
154
+ const user = request.user as { id: string };
155
+ const data = await request.file();
156
+
157
+ if (!data) {
158
+ return reply.status(400).send({ error: true, message: 'No file uploaded' });
159
+ }
160
+
161
+ const buffer = await data.toBuffer();
162
+ const fileId = uuid();
163
+ const ext = data.filename.split('.').pop() || '';
164
+ const filename = `${fileId}.${ext}`;
165
+ const mimeType = data.mimetype;
166
+
167
+ let width: number | undefined;
168
+ let height: number | undefined;
169
+ let thumbnailUrl: string | undefined;
170
+
171
+ // Process images
172
+ if (mimeType.startsWith('image/')) {
173
+ try {
174
+ const metadata = await sharp(buffer).metadata();
175
+ width = metadata.width;
176
+ height = metadata.height;
177
+
178
+ // Create thumbnail
179
+ const thumbnail = await sharp(buffer)
180
+ .resize(300, 300, { fit: 'inside' })
181
+ .jpeg({ quality: 80 })
182
+ .toBuffer();
183
+
184
+ const thumbnailFilename = `thumbnails/${fileId}.jpg`;
185
+ await minioClient.putObject(config.minio.bucket, thumbnailFilename, thumbnail, {
186
+ 'Content-Type': 'image/jpeg',
187
+ });
188
+ thumbnailUrl = `${config.minio.publicUrl}/${config.minio.bucket}/${thumbnailFilename}`;
189
+ } catch (err) {
190
+ fastify.log.warn('Failed to process image:', err);
191
+ }
192
+ }
193
+
194
+ // Upload original file
195
+ await minioClient.putObject(config.minio.bucket, filename, buffer, {
196
+ 'Content-Type': mimeType,
197
+ });
198
+
199
+ const url = `${config.minio.publicUrl}/${config.minio.bucket}/${filename}`;
200
+
201
+ // Save to database
202
+ const result = await db.query(
203
+ `INSERT INTO media (id, filename, original_filename, mime_type, size_bytes, width, height, url, thumbnail_url, uploaded_by)
204
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
205
+ RETURNING *`,
206
+ [fileId, filename, data.filename, mimeType, buffer.length, width, height, url, thumbnailUrl, user.id]
207
+ );
208
+
209
+ return { file: result.rows[0] };
210
+ });
211
+
212
+ // Update media metadata
213
+ fastify.put('/:id', {
214
+ schema: {
215
+ tags: ['Media'],
216
+ summary: 'Update media file metadata',
217
+ security: [{ bearerAuth: [] }],
218
+ },
219
+ preHandler: [fastify.authenticate],
220
+ }, async (request, reply) => {
221
+ const { id } = request.params as { id: string };
222
+ const body = updateMediaSchema.parse(request.body);
223
+
224
+ const existing = await db.query('SELECT id FROM media WHERE id = $1', [id]);
225
+ if (existing.rows.length === 0) {
226
+ return reply.status(404).send({ error: true, message: 'File not found' });
227
+ }
228
+
229
+ const updates: string[] = [];
230
+ const values: any[] = [];
231
+ let paramIndex = 1;
232
+
233
+ if (body.altText !== undefined) {
234
+ updates.push(`alt_text = $${paramIndex++}`);
235
+ values.push(body.altText);
236
+ }
237
+ if (body.caption !== undefined) {
238
+ updates.push(`caption = $${paramIndex++}`);
239
+ values.push(body.caption);
240
+ }
241
+ if (body.folder !== undefined) {
242
+ updates.push(`folder = $${paramIndex++}`);
243
+ values.push(body.folder);
244
+ }
245
+
246
+ if (updates.length === 0) {
247
+ return reply.status(400).send({ error: true, message: 'No updates provided' });
248
+ }
249
+
250
+ values.push(id);
251
+ const result = await db.query(
252
+ `UPDATE media SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
253
+ values
254
+ );
255
+
256
+ return { file: result.rows[0] };
257
+ });
258
+
259
+ // Delete media file
260
+ fastify.delete('/:id', {
261
+ schema: {
262
+ tags: ['Media'],
263
+ summary: 'Delete a media file',
264
+ security: [{ bearerAuth: [] }],
265
+ },
266
+ preHandler: [fastify.authenticate],
267
+ }, async (request, reply) => {
268
+ const { id } = request.params as { id: string };
269
+
270
+ const result = await db.query(
271
+ 'SELECT filename FROM media WHERE id = $1',
272
+ [id]
273
+ );
274
+
275
+ if (result.rows.length === 0) {
276
+ return reply.status(404).send({ error: true, message: 'File not found' });
277
+ }
278
+
279
+ const { filename } = result.rows[0];
280
+
281
+ // Delete from MinIO
282
+ try {
283
+ await minioClient.removeObject(config.minio.bucket, filename);
284
+ // Try to delete thumbnail
285
+ await minioClient.removeObject(config.minio.bucket, `thumbnails/${id}.jpg`).catch(() => {});
286
+ } catch (err) {
287
+ fastify.log.warn('Failed to delete file from storage:', err);
288
+ }
289
+
290
+ // Delete from database
291
+ await db.query('DELETE FROM media WHERE id = $1', [id]);
292
+
293
+ return { success: true, message: 'File deleted' };
294
+ });
295
+
296
+ // Get folders
297
+ fastify.get('/folders', {
298
+ schema: {
299
+ tags: ['Media'],
300
+ summary: 'Get list of media folders',
301
+ },
302
+ }, async () => {
303
+ const result = await db.query(
304
+ `SELECT DISTINCT folder, COUNT(*) as count
305
+ FROM media
306
+ GROUP BY folder
307
+ ORDER BY folder`
308
+ );
309
+
310
+ return { folders: result.rows };
311
+ });
312
+ };
@@ -0,0 +1,228 @@
1
+ import { SocketStream } from '@fastify/websocket';
2
+ import { FastifyRequest } from 'fastify';
3
+ import { redis } from '../../database/redis.js';
4
+ import { db } from '../../database/index.js';
5
+ import { v4 as uuid } from 'uuid';
6
+
7
+ interface WebSocketMessage {
8
+ type: string;
9
+ payload: any;
10
+ }
11
+
12
+ interface ConnectedClient {
13
+ id: string;
14
+ userId?: string;
15
+ documentId?: string;
16
+ userName?: string;
17
+ }
18
+
19
+ const connectedClients = new Map<string, ConnectedClient>();
20
+
21
+ export async function realtimeHandler(socket: SocketStream, request: FastifyRequest) {
22
+ const clientId = uuid();
23
+ const client: ConnectedClient = { id: clientId };
24
+ connectedClients.set(clientId, client);
25
+
26
+ console.log(`Client connected: ${clientId}`);
27
+
28
+ // Subscribe to Redis channels for real-time updates
29
+ const subscriber = redis.getSubscriber();
30
+
31
+ const channels = ['content:created', 'content:updated', 'content:deleted', 'presence:update'];
32
+
33
+ for (const channel of channels) {
34
+ await subscriber.subscribe(channel);
35
+ }
36
+
37
+ subscriber.on('message', (channel, message) => {
38
+ try {
39
+ const data = JSON.parse(message);
40
+
41
+ // Only send relevant updates to this client
42
+ if (channel.startsWith('content:')) {
43
+ // If client is editing a document, send updates for that document
44
+ if (client.documentId && data.documentId === client.documentId) {
45
+ socket.send(JSON.stringify({ type: channel, payload: data }));
46
+ }
47
+ } else if (channel === 'presence:update') {
48
+ // Send presence updates for the same document
49
+ if (client.documentId && data.documentId === client.documentId) {
50
+ socket.send(JSON.stringify({ type: 'presence', payload: data }));
51
+ }
52
+ }
53
+ } catch (err) {
54
+ console.error('Failed to process Redis message:', err);
55
+ }
56
+ });
57
+
58
+ // Handle incoming messages
59
+ socket.on('message', async (rawMessage) => {
60
+ try {
61
+ const message: WebSocketMessage = JSON.parse(rawMessage.toString());
62
+
63
+ switch (message.type) {
64
+ case 'auth': {
65
+ // Authenticate client
66
+ const { userId, userName } = message.payload;
67
+ client.userId = userId;
68
+ client.userName = userName;
69
+
70
+ socket.send(JSON.stringify({
71
+ type: 'auth:success',
72
+ payload: { clientId },
73
+ }));
74
+ break;
75
+ }
76
+
77
+ case 'join:document': {
78
+ // Join a document for collaborative editing
79
+ const { documentId } = message.payload;
80
+ client.documentId = documentId;
81
+
82
+ // Record active session
83
+ if (client.userId) {
84
+ await db.query(
85
+ `INSERT INTO active_sessions (user_id, document_id, socket_id)
86
+ VALUES ($1, $2, $3)
87
+ ON CONFLICT (user_id, document_id) DO UPDATE SET
88
+ socket_id = $3, last_active_at = CURRENT_TIMESTAMP`,
89
+ [client.userId, documentId, clientId]
90
+ );
91
+ }
92
+
93
+ // Get other users editing this document
94
+ const sessionsResult = await db.query(
95
+ `SELECT s.user_id, u.name, u.avatar_url, s.cursor_position
96
+ FROM active_sessions s
97
+ JOIN users u ON s.user_id = u.id
98
+ WHERE s.document_id = $1 AND s.socket_id != $2`,
99
+ [documentId, clientId]
100
+ );
101
+
102
+ // Notify others that this user joined
103
+ await redis.publish('presence:update', {
104
+ type: 'user:joined',
105
+ documentId,
106
+ user: {
107
+ id: client.userId,
108
+ name: client.userName,
109
+ clientId,
110
+ },
111
+ });
112
+
113
+ socket.send(JSON.stringify({
114
+ type: 'document:joined',
115
+ payload: {
116
+ documentId,
117
+ activeUsers: sessionsResult.rows,
118
+ },
119
+ }));
120
+ break;
121
+ }
122
+
123
+ case 'leave:document': {
124
+ // Leave document editing
125
+ if (client.documentId && client.userId) {
126
+ await db.query(
127
+ 'DELETE FROM active_sessions WHERE user_id = $1 AND document_id = $2',
128
+ [client.userId, client.documentId]
129
+ );
130
+
131
+ await redis.publish('presence:update', {
132
+ type: 'user:left',
133
+ documentId: client.documentId,
134
+ user: {
135
+ id: client.userId,
136
+ name: client.userName,
137
+ clientId,
138
+ },
139
+ });
140
+ }
141
+ client.documentId = undefined;
142
+ break;
143
+ }
144
+
145
+ case 'cursor:update': {
146
+ // Update cursor position for collaborative editing
147
+ const { position } = message.payload;
148
+
149
+ if (client.documentId && client.userId) {
150
+ await db.query(
151
+ `UPDATE active_sessions SET cursor_position = $1, last_active_at = CURRENT_TIMESTAMP
152
+ WHERE user_id = $2 AND document_id = $3`,
153
+ [position, client.userId, client.documentId]
154
+ );
155
+
156
+ await redis.publish('presence:update', {
157
+ type: 'cursor:moved',
158
+ documentId: client.documentId,
159
+ user: {
160
+ id: client.userId,
161
+ name: client.userName,
162
+ clientId,
163
+ },
164
+ position,
165
+ });
166
+ }
167
+ break;
168
+ }
169
+
170
+ case 'content:change': {
171
+ // Broadcast content changes for real-time sync
172
+ const { changes, documentId } = message.payload;
173
+
174
+ await redis.publish('content:updated', {
175
+ documentId,
176
+ changes,
177
+ userId: client.userId,
178
+ clientId,
179
+ });
180
+ break;
181
+ }
182
+
183
+ case 'ping': {
184
+ socket.send(JSON.stringify({ type: 'pong', payload: { timestamp: Date.now() } }));
185
+ break;
186
+ }
187
+
188
+ default:
189
+ console.log('Unknown message type:', message.type);
190
+ }
191
+ } catch (err) {
192
+ console.error('Failed to handle WebSocket message:', err);
193
+ socket.send(JSON.stringify({
194
+ type: 'error',
195
+ payload: { message: 'Failed to process message' },
196
+ }));
197
+ }
198
+ });
199
+
200
+ // Handle disconnect
201
+ socket.on('close', async () => {
202
+ console.log(`Client disconnected: ${clientId}`);
203
+
204
+ // Clean up session
205
+ if (client.userId && client.documentId) {
206
+ await db.query(
207
+ 'DELETE FROM active_sessions WHERE user_id = $1 AND document_id = $2',
208
+ [client.userId, client.documentId]
209
+ );
210
+
211
+ await redis.publish('presence:update', {
212
+ type: 'user:left',
213
+ documentId: client.documentId,
214
+ user: {
215
+ id: client.userId,
216
+ name: client.userName,
217
+ clientId,
218
+ },
219
+ });
220
+ }
221
+
222
+ connectedClients.delete(clientId);
223
+ });
224
+
225
+ socket.on('error', (err) => {
226
+ console.error('WebSocket error:', err);
227
+ });
228
+ }