create-pxlr 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,385 @@
|
|
|
1
|
+
import { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { v4 as uuid } from 'uuid';
|
|
4
|
+
import { db } from '../../database/index.js';
|
|
5
|
+
import { redis } from '../../database/redis.js';
|
|
6
|
+
|
|
7
|
+
const createDocumentSchema = z.object({
|
|
8
|
+
schemaName: z.string().min(1),
|
|
9
|
+
data: z.record(z.any()),
|
|
10
|
+
locale: z.string().default('en'),
|
|
11
|
+
status: z.enum(['draft', 'published']).default('draft'),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const updateDocumentSchema = z.object({
|
|
15
|
+
data: z.record(z.any()).optional(),
|
|
16
|
+
locale: z.string().optional(),
|
|
17
|
+
status: z.enum(['draft', 'published', 'archived']).optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const querySchema = z.object({
|
|
21
|
+
schemaName: z.string().optional(),
|
|
22
|
+
status: z.enum(['draft', 'published', 'archived']).optional(),
|
|
23
|
+
locale: z.string().optional(),
|
|
24
|
+
page: z.coerce.number().min(1).default(1),
|
|
25
|
+
limit: z.coerce.number().min(1).max(100).default(20),
|
|
26
|
+
orderBy: z.string().default('created_at'),
|
|
27
|
+
order: z.enum(['asc', 'desc']).default('desc'),
|
|
28
|
+
search: z.string().optional(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const contentRoutes: FastifyPluginAsync = async (fastify) => {
|
|
32
|
+
// List documents with filtering
|
|
33
|
+
fastify.get('/', {
|
|
34
|
+
schema: {
|
|
35
|
+
tags: ['Content'],
|
|
36
|
+
summary: 'List documents with filtering and pagination',
|
|
37
|
+
querystring: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
schemaName: { type: 'string' },
|
|
41
|
+
status: { type: 'string' },
|
|
42
|
+
locale: { type: 'string' },
|
|
43
|
+
page: { type: 'number' },
|
|
44
|
+
limit: { type: 'number' },
|
|
45
|
+
orderBy: { type: 'string' },
|
|
46
|
+
order: { type: 'string' },
|
|
47
|
+
search: { type: 'string' },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
}, async (request) => {
|
|
52
|
+
const query = querySchema.parse(request.query);
|
|
53
|
+
const offset = (query.page - 1) * query.limit;
|
|
54
|
+
|
|
55
|
+
let whereClause = 'WHERE 1=1';
|
|
56
|
+
const params: any[] = [];
|
|
57
|
+
let paramIndex = 1;
|
|
58
|
+
|
|
59
|
+
if (query.schemaName) {
|
|
60
|
+
whereClause += ` AND schema_name = $${paramIndex++}`;
|
|
61
|
+
params.push(query.schemaName);
|
|
62
|
+
}
|
|
63
|
+
if (query.status) {
|
|
64
|
+
whereClause += ` AND status = $${paramIndex++}`;
|
|
65
|
+
params.push(query.status);
|
|
66
|
+
}
|
|
67
|
+
if (query.locale) {
|
|
68
|
+
whereClause += ` AND locale = $${paramIndex++}`;
|
|
69
|
+
params.push(query.locale);
|
|
70
|
+
}
|
|
71
|
+
if (query.search) {
|
|
72
|
+
whereClause += ` AND data::text ILIKE $${paramIndex++}`;
|
|
73
|
+
params.push(`%${query.search}%`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Count total
|
|
77
|
+
const countResult = await db.query(
|
|
78
|
+
`SELECT COUNT(*) FROM documents ${whereClause}`,
|
|
79
|
+
params
|
|
80
|
+
);
|
|
81
|
+
const total = parseInt(countResult.rows[0].count);
|
|
82
|
+
|
|
83
|
+
// Get documents
|
|
84
|
+
const allowedOrderBy = ['created_at', 'updated_at', 'published_at'];
|
|
85
|
+
const orderByColumn = allowedOrderBy.includes(query.orderBy) ? query.orderBy : 'created_at';
|
|
86
|
+
const orderDirection = query.order === 'asc' ? 'ASC' : 'DESC';
|
|
87
|
+
|
|
88
|
+
params.push(query.limit, offset);
|
|
89
|
+
|
|
90
|
+
const result = await db.query(
|
|
91
|
+
`SELECT d.*, s.title as schema_title, u.name as created_by_name
|
|
92
|
+
FROM documents d
|
|
93
|
+
LEFT JOIN schemas s ON d.schema_name = s.name
|
|
94
|
+
LEFT JOIN users u ON d.created_by = u.id
|
|
95
|
+
${whereClause}
|
|
96
|
+
ORDER BY d.${orderByColumn} ${orderDirection}
|
|
97
|
+
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
|
|
98
|
+
params
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
documents: result.rows,
|
|
103
|
+
pagination: {
|
|
104
|
+
page: query.page,
|
|
105
|
+
limit: query.limit,
|
|
106
|
+
total,
|
|
107
|
+
totalPages: Math.ceil(total / query.limit),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Get single document
|
|
113
|
+
fastify.get('/:id', {
|
|
114
|
+
schema: {
|
|
115
|
+
tags: ['Content'],
|
|
116
|
+
summary: 'Get document by ID',
|
|
117
|
+
},
|
|
118
|
+
}, async (request, reply) => {
|
|
119
|
+
const { id } = request.params as { id: string };
|
|
120
|
+
|
|
121
|
+
const result = await db.query(
|
|
122
|
+
`SELECT d.*, s.title as schema_title, s.definition as schema_definition,
|
|
123
|
+
u1.name as created_by_name, u2.name as updated_by_name
|
|
124
|
+
FROM documents d
|
|
125
|
+
LEFT JOIN schemas s ON d.schema_name = s.name
|
|
126
|
+
LEFT JOIN users u1 ON d.created_by = u1.id
|
|
127
|
+
LEFT JOIN users u2 ON d.updated_by = u2.id
|
|
128
|
+
WHERE d.id = $1`,
|
|
129
|
+
[id]
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
if (result.rows.length === 0) {
|
|
133
|
+
return reply.status(404).send({ error: true, message: 'Document not found' });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { document: result.rows[0] };
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Create document
|
|
140
|
+
fastify.post('/', {
|
|
141
|
+
schema: {
|
|
142
|
+
tags: ['Content'],
|
|
143
|
+
summary: 'Create a new document',
|
|
144
|
+
security: [{ bearerAuth: [] }],
|
|
145
|
+
},
|
|
146
|
+
preHandler: [fastify.authenticate],
|
|
147
|
+
}, async (request, reply) => {
|
|
148
|
+
const body = createDocumentSchema.parse(request.body);
|
|
149
|
+
const user = request.user as { id: string };
|
|
150
|
+
|
|
151
|
+
// Verify schema exists
|
|
152
|
+
const schemaResult = await db.query(
|
|
153
|
+
'SELECT id, is_singleton FROM schemas WHERE name = $1',
|
|
154
|
+
[body.schemaName]
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
if (schemaResult.rows.length === 0) {
|
|
158
|
+
return reply.status(400).send({ error: true, message: 'Schema not found' });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const schema = schemaResult.rows[0];
|
|
162
|
+
|
|
163
|
+
// Check singleton
|
|
164
|
+
if (schema.is_singleton) {
|
|
165
|
+
const existingDoc = await db.query(
|
|
166
|
+
'SELECT id FROM documents WHERE schema_name = $1 AND locale = $2',
|
|
167
|
+
[body.schemaName, body.locale]
|
|
168
|
+
);
|
|
169
|
+
if (existingDoc.rows.length > 0) {
|
|
170
|
+
return reply.status(400).send({
|
|
171
|
+
error: true,
|
|
172
|
+
message: 'Singleton document already exists for this locale'
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const documentId = uuid();
|
|
178
|
+
const publishedAt = body.status === 'published' ? new Date() : null;
|
|
179
|
+
|
|
180
|
+
const result = await db.query(
|
|
181
|
+
`INSERT INTO documents (id, schema_name, data, locale, status, published_at, created_by, updated_by)
|
|
182
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
|
|
183
|
+
RETURNING *`,
|
|
184
|
+
[documentId, body.schemaName, body.data, body.locale, body.status, publishedAt, user.id]
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// Create initial version
|
|
188
|
+
await db.query(
|
|
189
|
+
`INSERT INTO document_versions (document_id, version, data, locale, created_by)
|
|
190
|
+
VALUES ($1, 1, $2, $3, $4)`,
|
|
191
|
+
[documentId, body.data, body.locale, user.id]
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// Publish event for real-time
|
|
195
|
+
await redis.publish('content:created', {
|
|
196
|
+
documentId,
|
|
197
|
+
schemaName: body.schemaName,
|
|
198
|
+
userId: user.id,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return { document: result.rows[0] };
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Update document
|
|
205
|
+
fastify.put('/:id', {
|
|
206
|
+
schema: {
|
|
207
|
+
tags: ['Content'],
|
|
208
|
+
summary: 'Update a document',
|
|
209
|
+
security: [{ bearerAuth: [] }],
|
|
210
|
+
},
|
|
211
|
+
preHandler: [fastify.authenticate],
|
|
212
|
+
}, async (request, reply) => {
|
|
213
|
+
const { id } = request.params as { id: string };
|
|
214
|
+
const body = updateDocumentSchema.parse(request.body);
|
|
215
|
+
const user = request.user as { id: string };
|
|
216
|
+
|
|
217
|
+
// Get current document
|
|
218
|
+
const currentDoc = await db.query(
|
|
219
|
+
'SELECT * FROM documents WHERE id = $1',
|
|
220
|
+
[id]
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
if (currentDoc.rows.length === 0) {
|
|
224
|
+
return reply.status(404).send({ error: true, message: 'Document not found' });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const doc = currentDoc.rows[0];
|
|
228
|
+
|
|
229
|
+
// Build update query
|
|
230
|
+
const updates: string[] = ['updated_by = $1'];
|
|
231
|
+
const values: any[] = [user.id];
|
|
232
|
+
let paramIndex = 2;
|
|
233
|
+
|
|
234
|
+
if (body.data) {
|
|
235
|
+
updates.push(`data = $${paramIndex++}`);
|
|
236
|
+
values.push(body.data);
|
|
237
|
+
}
|
|
238
|
+
if (body.locale) {
|
|
239
|
+
updates.push(`locale = $${paramIndex++}`);
|
|
240
|
+
values.push(body.locale);
|
|
241
|
+
}
|
|
242
|
+
if (body.status) {
|
|
243
|
+
updates.push(`status = $${paramIndex++}`);
|
|
244
|
+
values.push(body.status);
|
|
245
|
+
if (body.status === 'published' && doc.status !== 'published') {
|
|
246
|
+
updates.push(`published_at = CURRENT_TIMESTAMP`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
values.push(id);
|
|
251
|
+
|
|
252
|
+
const result = await db.query(
|
|
253
|
+
`UPDATE documents SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
|
|
254
|
+
values
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
// Create new version if data changed
|
|
258
|
+
if (body.data) {
|
|
259
|
+
const versionResult = await db.query(
|
|
260
|
+
'SELECT MAX(version) as max_version FROM document_versions WHERE document_id = $1',
|
|
261
|
+
[id]
|
|
262
|
+
);
|
|
263
|
+
const newVersion = (versionResult.rows[0].max_version || 0) + 1;
|
|
264
|
+
|
|
265
|
+
await db.query(
|
|
266
|
+
`INSERT INTO document_versions (document_id, version, data, locale, created_by)
|
|
267
|
+
VALUES ($1, $2, $3, $4, $5)`,
|
|
268
|
+
[id, newVersion, body.data, body.locale || doc.locale, user.id]
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Publish event for real-time
|
|
273
|
+
await redis.publish('content:updated', {
|
|
274
|
+
documentId: id,
|
|
275
|
+
schemaName: doc.schema_name,
|
|
276
|
+
userId: user.id,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return { document: result.rows[0] };
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Delete document
|
|
283
|
+
fastify.delete('/:id', {
|
|
284
|
+
schema: {
|
|
285
|
+
tags: ['Content'],
|
|
286
|
+
summary: 'Delete a document',
|
|
287
|
+
security: [{ bearerAuth: [] }],
|
|
288
|
+
},
|
|
289
|
+
preHandler: [fastify.authenticate],
|
|
290
|
+
}, async (request, reply) => {
|
|
291
|
+
const { id } = request.params as { id: string };
|
|
292
|
+
const user = request.user as { id: string };
|
|
293
|
+
|
|
294
|
+
const doc = await db.query(
|
|
295
|
+
'SELECT schema_name FROM documents WHERE id = $1',
|
|
296
|
+
[id]
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
if (doc.rows.length === 0) {
|
|
300
|
+
return reply.status(404).send({ error: true, message: 'Document not found' });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
await db.query('DELETE FROM documents WHERE id = $1', [id]);
|
|
304
|
+
|
|
305
|
+
// Publish event for real-time
|
|
306
|
+
await redis.publish('content:deleted', {
|
|
307
|
+
documentId: id,
|
|
308
|
+
schemaName: doc.rows[0].schema_name,
|
|
309
|
+
userId: user.id,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
return { success: true, message: 'Document deleted' };
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Get document versions
|
|
316
|
+
fastify.get('/:id/versions', {
|
|
317
|
+
schema: {
|
|
318
|
+
tags: ['Content'],
|
|
319
|
+
summary: 'Get document version history',
|
|
320
|
+
},
|
|
321
|
+
}, async (request, reply) => {
|
|
322
|
+
const { id } = request.params as { id: string };
|
|
323
|
+
|
|
324
|
+
const doc = await db.query('SELECT id FROM documents WHERE id = $1', [id]);
|
|
325
|
+
if (doc.rows.length === 0) {
|
|
326
|
+
return reply.status(404).send({ error: true, message: 'Document not found' });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const result = await db.query(
|
|
330
|
+
`SELECT v.*, u.name as created_by_name
|
|
331
|
+
FROM document_versions v
|
|
332
|
+
LEFT JOIN users u ON v.created_by = u.id
|
|
333
|
+
WHERE v.document_id = $1
|
|
334
|
+
ORDER BY v.version DESC`,
|
|
335
|
+
[id]
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
return { versions: result.rows };
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Restore document version
|
|
342
|
+
fastify.post('/:id/versions/:version/restore', {
|
|
343
|
+
schema: {
|
|
344
|
+
tags: ['Content'],
|
|
345
|
+
summary: 'Restore document to a specific version',
|
|
346
|
+
security: [{ bearerAuth: [] }],
|
|
347
|
+
},
|
|
348
|
+
preHandler: [fastify.authenticate],
|
|
349
|
+
}, async (request, reply) => {
|
|
350
|
+
const { id, version } = request.params as { id: string; version: string };
|
|
351
|
+
const user = request.user as { id: string };
|
|
352
|
+
|
|
353
|
+
const versionResult = await db.query(
|
|
354
|
+
'SELECT * FROM document_versions WHERE document_id = $1 AND version = $2',
|
|
355
|
+
[id, parseInt(version)]
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
if (versionResult.rows.length === 0) {
|
|
359
|
+
return reply.status(404).send({ error: true, message: 'Version not found' });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const versionData = versionResult.rows[0];
|
|
363
|
+
|
|
364
|
+
// Update document with version data
|
|
365
|
+
const result = await db.query(
|
|
366
|
+
`UPDATE documents SET data = $1, updated_by = $2 WHERE id = $3 RETURNING *`,
|
|
367
|
+
[versionData.data, user.id, id]
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
// Create new version for the restore action
|
|
371
|
+
const maxVersionResult = await db.query(
|
|
372
|
+
'SELECT MAX(version) as max_version FROM document_versions WHERE document_id = $1',
|
|
373
|
+
[id]
|
|
374
|
+
);
|
|
375
|
+
const newVersion = (maxVersionResult.rows[0].max_version || 0) + 1;
|
|
376
|
+
|
|
377
|
+
await db.query(
|
|
378
|
+
`INSERT INTO document_versions (document_id, version, data, locale, change_summary, created_by)
|
|
379
|
+
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
380
|
+
[id, newVersion, versionData.data, versionData.locale, `Restored to version ${version}`, user.id]
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
return { document: result.rows[0] };
|
|
384
|
+
});
|
|
385
|
+
};
|
|
@@ -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
|
+
};
|