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.
- package/README.md +160 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +264 -0
- package/package.json +51 -0
- package/templates/blog/frontend/app/blog/[slug]/page.tsx +175 -0
- package/templates/blog/frontend/app/blog/page.tsx +102 -0
- package/templates/blog/frontend/app/components/footer.tsx +21 -0
- package/templates/blog/frontend/app/components/header.tsx +45 -0
- package/templates/blog/frontend/app/globals.css +30 -0
- package/templates/blog/frontend/app/layout.tsx +38 -0
- package/templates/blog/frontend/app/lib/cms.ts +71 -0
- package/templates/blog/frontend/app/page.tsx +155 -0
- package/templates/blog/frontend/next.config.ts +16 -0
- package/templates/blog/frontend/package.json +24 -0
- package/templates/blog/frontend/postcss.config.mjs +7 -0
- package/templates/blog/frontend/tsconfig.json +23 -0
- package/templates/blog/pxlr-cms/README.md +188 -0
- package/templates/blog/pxlr-cms/docker-compose.yml +132 -0
- package/templates/blog/pxlr-cms/nginx/nginx.conf +107 -0
- package/templates/blog/pxlr-cms/packages/admin/.dockerignore +4 -0
- package/templates/blog/pxlr-cms/packages/admin/.env.example +2 -0
- package/templates/blog/pxlr-cms/packages/admin/Dockerfile +19 -0
- package/templates/blog/pxlr-cms/packages/admin/next-env.d.ts +6 -0
- package/templates/blog/pxlr-cms/packages/admin/next.config.ts +22 -0
- package/templates/blog/pxlr-cms/packages/admin/package.json +63 -0
- package/templates/blog/pxlr-cms/packages/admin/pnpm-lock.yaml +5748 -0
- package/templates/blog/pxlr-cms/packages/admin/postcss.config.mjs +9 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/content/[id]/page.tsx +503 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/content/layout.tsx +7 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/content/new/page.tsx +424 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/content/page.tsx +191 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/globals.css +132 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/layout.tsx +25 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/login/page.tsx +119 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/media/layout.tsx +7 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/media/page.tsx +362 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/page.tsx +184 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/profile/layout.tsx +7 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/profile/page.tsx +206 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/schemas/[name]/page.tsx +312 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/schemas/layout.tsx +7 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/schemas/page.tsx +210 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/settings/layout.tsx +7 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/settings/page.tsx +178 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/editor/media-picker.tsx +202 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/editor/rich-text-editor.tsx +387 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/layout/auth-layout.tsx +43 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/layout/header.tsx +79 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/layout/sidebar.tsx +68 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/providers.tsx +29 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/schema-code-generator.tsx +326 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/avatar.tsx +49 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/button.tsx +55 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/dropdown-menu.tsx +194 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/input.tsx +24 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/label.tsx +25 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/toast.tsx +127 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/toaster.tsx +35 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/use-toast.ts +187 -0
- package/templates/blog/pxlr-cms/packages/admin/src/lib/api.ts +96 -0
- package/templates/blog/pxlr-cms/packages/admin/src/lib/i18n/context.tsx +60 -0
- package/templates/blog/pxlr-cms/packages/admin/src/lib/i18n/translations.ts +317 -0
- package/templates/blog/pxlr-cms/packages/admin/src/lib/store/auth.ts +51 -0
- package/templates/blog/pxlr-cms/packages/admin/src/lib/utils.ts +29 -0
- package/templates/blog/pxlr-cms/packages/admin/tailwind.config.ts +57 -0
- package/templates/blog/pxlr-cms/packages/admin/tsconfig.json +27 -0
- package/templates/blog/pxlr-cms/packages/api/.env.example +23 -0
- package/templates/blog/pxlr-cms/packages/api/Dockerfile +26 -0
- package/templates/blog/pxlr-cms/packages/api/package.json +42 -0
- package/templates/blog/pxlr-cms/packages/api/src/config.ts +39 -0
- package/templates/blog/pxlr-cms/packages/api/src/database/index.ts +60 -0
- package/templates/blog/pxlr-cms/packages/api/src/database/init.sql +258 -0
- package/templates/blog/pxlr-cms/packages/api/src/database/redis.ts +95 -0
- package/templates/blog/pxlr-cms/packages/api/src/database/seed.sql +78 -0
- package/templates/blog/pxlr-cms/packages/api/src/index.ts +157 -0
- package/templates/blog/pxlr-cms/packages/api/src/modules/auth/routes.ts +256 -0
- package/templates/blog/pxlr-cms/packages/api/src/modules/content/routes.ts +385 -0
- package/templates/blog/pxlr-cms/packages/api/src/modules/media/routes.ts +312 -0
- package/templates/blog/pxlr-cms/packages/api/src/modules/realtime/handler.ts +228 -0
- package/templates/blog/pxlr-cms/packages/api/src/modules/schema/routes.ts +284 -0
- package/templates/blog/pxlr-cms/packages/api/src/modules/versions/routes.ts +70 -0
- package/templates/blog/pxlr-cms/packages/api/tsconfig.json +24 -0
- package/templates/blog/pxlr-cms/packages/shared/package.json +14 -0
- package/templates/blog/pxlr-cms/packages/shared/src/types/index.ts +139 -0
- package/templates/blog/pxlr-cms/packages/shared/tsconfig.json +18 -0
- package/templates/clean/pxlr-cms/README.md +188 -0
- package/templates/clean/pxlr-cms/docker-compose.yml +132 -0
- package/templates/clean/pxlr-cms/nginx/nginx.conf +107 -0
- package/templates/clean/pxlr-cms/packages/admin/.dockerignore +4 -0
- package/templates/clean/pxlr-cms/packages/admin/.env.example +2 -0
- package/templates/clean/pxlr-cms/packages/admin/Dockerfile +19 -0
- package/templates/clean/pxlr-cms/packages/admin/next-env.d.ts +6 -0
- package/templates/clean/pxlr-cms/packages/admin/next.config.ts +22 -0
- package/templates/clean/pxlr-cms/packages/admin/package.json +63 -0
- package/templates/clean/pxlr-cms/packages/admin/pnpm-lock.yaml +5748 -0
- package/templates/clean/pxlr-cms/packages/admin/postcss.config.mjs +9 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/content/[id]/page.tsx +503 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/content/layout.tsx +7 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/content/new/page.tsx +424 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/content/page.tsx +191 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/globals.css +132 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/layout.tsx +25 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/login/page.tsx +119 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/media/layout.tsx +7 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/media/page.tsx +362 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/page.tsx +184 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/profile/layout.tsx +7 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/profile/page.tsx +206 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/schemas/[name]/page.tsx +312 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/schemas/layout.tsx +7 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/schemas/page.tsx +210 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/settings/layout.tsx +7 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/settings/page.tsx +178 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/editor/media-picker.tsx +202 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/editor/rich-text-editor.tsx +387 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/layout/auth-layout.tsx +43 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/layout/header.tsx +79 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/layout/sidebar.tsx +68 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/providers.tsx +29 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/schema-code-generator.tsx +326 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/avatar.tsx +49 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/button.tsx +55 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/dropdown-menu.tsx +194 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/input.tsx +24 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/label.tsx +25 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/toast.tsx +127 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/toaster.tsx +35 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/use-toast.ts +187 -0
- package/templates/clean/pxlr-cms/packages/admin/src/lib/api.ts +96 -0
- package/templates/clean/pxlr-cms/packages/admin/src/lib/i18n/context.tsx +60 -0
- package/templates/clean/pxlr-cms/packages/admin/src/lib/i18n/translations.ts +317 -0
- package/templates/clean/pxlr-cms/packages/admin/src/lib/store/auth.ts +51 -0
- package/templates/clean/pxlr-cms/packages/admin/src/lib/utils.ts +29 -0
- package/templates/clean/pxlr-cms/packages/admin/tailwind.config.ts +57 -0
- package/templates/clean/pxlr-cms/packages/admin/tsconfig.json +27 -0
- package/templates/clean/pxlr-cms/packages/api/.env.example +23 -0
- package/templates/clean/pxlr-cms/packages/api/Dockerfile +26 -0
- package/templates/clean/pxlr-cms/packages/api/package.json +42 -0
- package/templates/clean/pxlr-cms/packages/api/src/config.ts +39 -0
- package/templates/clean/pxlr-cms/packages/api/src/database/index.ts +60 -0
- package/templates/clean/pxlr-cms/packages/api/src/database/init.sql +178 -0
- package/templates/clean/pxlr-cms/packages/api/src/database/redis.ts +95 -0
- package/templates/clean/pxlr-cms/packages/api/src/index.ts +157 -0
- package/templates/clean/pxlr-cms/packages/api/src/modules/auth/routes.ts +256 -0
- package/templates/clean/pxlr-cms/packages/api/src/modules/content/routes.ts +385 -0
- package/templates/clean/pxlr-cms/packages/api/src/modules/media/routes.ts +312 -0
- package/templates/clean/pxlr-cms/packages/api/src/modules/realtime/handler.ts +228 -0
- package/templates/clean/pxlr-cms/packages/api/src/modules/schema/routes.ts +284 -0
- package/templates/clean/pxlr-cms/packages/api/src/modules/versions/routes.ts +70 -0
- package/templates/clean/pxlr-cms/packages/api/tsconfig.json +24 -0
- package/templates/clean/pxlr-cms/packages/shared/package.json +14 -0
- package/templates/clean/pxlr-cms/packages/shared/src/types/index.ts +139 -0
- 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
|
+
}
|