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,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
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { db } from '../../database/index.js';
|
|
4
|
+
import { redis } from '../../database/redis.js';
|
|
5
|
+
|
|
6
|
+
// Schema field types (like Sanity)
|
|
7
|
+
export type FieldType =
|
|
8
|
+
| 'string'
|
|
9
|
+
| 'text'
|
|
10
|
+
| 'number'
|
|
11
|
+
| 'boolean'
|
|
12
|
+
| 'date'
|
|
13
|
+
| 'datetime'
|
|
14
|
+
| 'richText'
|
|
15
|
+
| 'image'
|
|
16
|
+
| 'file'
|
|
17
|
+
| 'reference'
|
|
18
|
+
| 'array'
|
|
19
|
+
| 'object'
|
|
20
|
+
| 'slug'
|
|
21
|
+
| 'url'
|
|
22
|
+
| 'email'
|
|
23
|
+
| 'color';
|
|
24
|
+
|
|
25
|
+
export interface SchemaField {
|
|
26
|
+
name: string;
|
|
27
|
+
type: FieldType;
|
|
28
|
+
title?: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
required?: boolean;
|
|
31
|
+
localized?: boolean;
|
|
32
|
+
hidden?: boolean;
|
|
33
|
+
readOnly?: boolean;
|
|
34
|
+
options?: Record<string, any>;
|
|
35
|
+
validation?: any[];
|
|
36
|
+
of?: SchemaField[]; // For array type
|
|
37
|
+
fields?: SchemaField[]; // For object type
|
|
38
|
+
to?: string | string[]; // For reference type
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SchemaDefinition {
|
|
42
|
+
name: string;
|
|
43
|
+
title: string;
|
|
44
|
+
description?: string;
|
|
45
|
+
icon?: string;
|
|
46
|
+
fields: SchemaField[];
|
|
47
|
+
preview?: {
|
|
48
|
+
select: Record<string, string>;
|
|
49
|
+
prepare?: string;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const schemaFieldSchema = z.object({
|
|
54
|
+
name: z.string().min(1),
|
|
55
|
+
type: z.string(),
|
|
56
|
+
title: z.string().optional(),
|
|
57
|
+
description: z.string().optional(),
|
|
58
|
+
required: z.boolean().optional(),
|
|
59
|
+
localized: z.boolean().optional(),
|
|
60
|
+
hidden: z.boolean().optional(),
|
|
61
|
+
readOnly: z.boolean().optional(),
|
|
62
|
+
options: z.record(z.any()).optional(),
|
|
63
|
+
validation: z.array(z.any()).optional(),
|
|
64
|
+
of: z.array(z.any()).optional(),
|
|
65
|
+
fields: z.array(z.any()).optional(),
|
|
66
|
+
to: z.union([z.string(), z.array(z.string())]).optional(),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const createSchemaSchema = z.object({
|
|
70
|
+
name: z.string().min(1).regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, {
|
|
71
|
+
message: 'Name must start with a letter and contain only letters, numbers, and underscores (e.g. blogPost, my_page)',
|
|
72
|
+
}),
|
|
73
|
+
title: z.string().min(1),
|
|
74
|
+
description: z.string().optional(),
|
|
75
|
+
icon: z.string().optional(),
|
|
76
|
+
fields: z.array(schemaFieldSchema),
|
|
77
|
+
isSingleton: z.boolean().optional(),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const schemaRoutes: FastifyPluginAsync = async (fastify) => {
|
|
81
|
+
// List all schemas
|
|
82
|
+
fastify.get('/', {
|
|
83
|
+
schema: {
|
|
84
|
+
tags: ['Schemas'],
|
|
85
|
+
summary: 'List all content schemas',
|
|
86
|
+
},
|
|
87
|
+
}, async () => {
|
|
88
|
+
// Try cache first
|
|
89
|
+
const cached = await redis.get<any[]>('schemas:all');
|
|
90
|
+
if (cached) {
|
|
91
|
+
return { schemas: cached };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const result = await db.query(
|
|
95
|
+
`SELECT id, name, title, description, icon, is_singleton, sort_order,
|
|
96
|
+
definition, version, created_at, updated_at
|
|
97
|
+
FROM schemas
|
|
98
|
+
ORDER BY sort_order, title`
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const schemas = result.rows.map(row => ({
|
|
102
|
+
...row,
|
|
103
|
+
definition: row.definition,
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
// Cache for 5 minutes
|
|
107
|
+
await redis.set('schemas:all', schemas, 300);
|
|
108
|
+
|
|
109
|
+
return { schemas };
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Get single schema
|
|
113
|
+
fastify.get('/:name', {
|
|
114
|
+
schema: {
|
|
115
|
+
tags: ['Schemas'],
|
|
116
|
+
summary: 'Get schema by name',
|
|
117
|
+
params: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
name: { type: 'string' },
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
}, async (request, reply) => {
|
|
125
|
+
const { name } = request.params as { name: string };
|
|
126
|
+
|
|
127
|
+
const result = await db.query(
|
|
128
|
+
`SELECT id, name, title, description, icon, is_singleton,
|
|
129
|
+
definition, version, created_at, updated_at
|
|
130
|
+
FROM schemas WHERE name = $1`,
|
|
131
|
+
[name]
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
if (result.rows.length === 0) {
|
|
135
|
+
return reply.status(404).send({ error: true, message: 'Schema not found' });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { schema: result.rows[0] };
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Create schema
|
|
142
|
+
fastify.post('/', {
|
|
143
|
+
schema: {
|
|
144
|
+
tags: ['Schemas'],
|
|
145
|
+
summary: 'Create a new content schema',
|
|
146
|
+
security: [{ bearerAuth: [] }],
|
|
147
|
+
},
|
|
148
|
+
preHandler: [fastify.authenticate],
|
|
149
|
+
}, async (request, reply) => {
|
|
150
|
+
const body = createSchemaSchema.parse(request.body);
|
|
151
|
+
|
|
152
|
+
// Check if schema exists
|
|
153
|
+
const existing = await db.query(
|
|
154
|
+
'SELECT id FROM schemas WHERE name = $1',
|
|
155
|
+
[body.name]
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
if (existing.rows.length > 0) {
|
|
159
|
+
return reply.status(400).send({ error: true, message: 'Schema already exists' });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const definition: SchemaDefinition = {
|
|
163
|
+
name: body.name,
|
|
164
|
+
title: body.title,
|
|
165
|
+
description: body.description,
|
|
166
|
+
icon: body.icon,
|
|
167
|
+
fields: body.fields as SchemaField[],
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const result = await db.query(
|
|
171
|
+
`INSERT INTO schemas (name, title, description, icon, is_singleton, definition)
|
|
172
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
173
|
+
RETURNING id, name, title, description, icon, is_singleton, definition, created_at`,
|
|
174
|
+
[body.name, body.title, body.description, body.icon, body.isSingleton || false, definition]
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Invalidate cache
|
|
178
|
+
await redis.del('schemas:all');
|
|
179
|
+
|
|
180
|
+
return { schema: result.rows[0] };
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Update schema
|
|
184
|
+
fastify.put('/:name', {
|
|
185
|
+
schema: {
|
|
186
|
+
tags: ['Schemas'],
|
|
187
|
+
summary: 'Update a content schema',
|
|
188
|
+
security: [{ bearerAuth: [] }],
|
|
189
|
+
},
|
|
190
|
+
preHandler: [fastify.authenticate],
|
|
191
|
+
}, async (request, reply) => {
|
|
192
|
+
const { name } = request.params as { name: string };
|
|
193
|
+
const body = createSchemaSchema.partial().parse(request.body);
|
|
194
|
+
|
|
195
|
+
const existing = await db.query(
|
|
196
|
+
'SELECT id, version FROM schemas WHERE name = $1',
|
|
197
|
+
[name]
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
if (existing.rows.length === 0) {
|
|
201
|
+
return reply.status(404).send({ error: true, message: 'Schema not found' });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const updates: string[] = [];
|
|
205
|
+
const values: any[] = [];
|
|
206
|
+
let paramIndex = 1;
|
|
207
|
+
|
|
208
|
+
if (body.title) {
|
|
209
|
+
updates.push(`title = $${paramIndex++}`);
|
|
210
|
+
values.push(body.title);
|
|
211
|
+
}
|
|
212
|
+
if (body.description !== undefined) {
|
|
213
|
+
updates.push(`description = $${paramIndex++}`);
|
|
214
|
+
values.push(body.description);
|
|
215
|
+
}
|
|
216
|
+
if (body.icon !== undefined) {
|
|
217
|
+
updates.push(`icon = $${paramIndex++}`);
|
|
218
|
+
values.push(body.icon);
|
|
219
|
+
}
|
|
220
|
+
if (body.isSingleton !== undefined) {
|
|
221
|
+
updates.push(`is_singleton = $${paramIndex++}`);
|
|
222
|
+
values.push(body.isSingleton);
|
|
223
|
+
}
|
|
224
|
+
if (body.fields) {
|
|
225
|
+
const definition: SchemaDefinition = {
|
|
226
|
+
name: body.name || name,
|
|
227
|
+
title: body.title || '',
|
|
228
|
+
description: body.description,
|
|
229
|
+
fields: body.fields as SchemaField[],
|
|
230
|
+
};
|
|
231
|
+
updates.push(`definition = $${paramIndex++}`);
|
|
232
|
+
values.push(definition);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Increment version
|
|
236
|
+
updates.push(`version = version + 1`);
|
|
237
|
+
|
|
238
|
+
values.push(name);
|
|
239
|
+
|
|
240
|
+
const result = await db.query(
|
|
241
|
+
`UPDATE schemas SET ${updates.join(', ')}
|
|
242
|
+
WHERE name = $${paramIndex}
|
|
243
|
+
RETURNING *`,
|
|
244
|
+
values
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// Invalidate cache
|
|
248
|
+
await redis.del('schemas:all');
|
|
249
|
+
|
|
250
|
+
return { schema: result.rows[0] };
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Delete schema
|
|
254
|
+
fastify.delete('/:name', {
|
|
255
|
+
schema: {
|
|
256
|
+
tags: ['Schemas'],
|
|
257
|
+
summary: 'Delete a content schema',
|
|
258
|
+
security: [{ bearerAuth: [] }],
|
|
259
|
+
},
|
|
260
|
+
preHandler: [fastify.authenticate],
|
|
261
|
+
}, async (request, reply) => {
|
|
262
|
+
const { name } = request.params as { name: string };
|
|
263
|
+
|
|
264
|
+
// Check if documents exist
|
|
265
|
+
const docsCount = await db.query(
|
|
266
|
+
'SELECT COUNT(*) FROM documents WHERE schema_name = $1',
|
|
267
|
+
[name]
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
if (parseInt(docsCount.rows[0].count) > 0) {
|
|
271
|
+
return reply.status(400).send({
|
|
272
|
+
error: true,
|
|
273
|
+
message: 'Cannot delete schema with existing documents'
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
await db.query('DELETE FROM schemas WHERE name = $1', [name]);
|
|
278
|
+
|
|
279
|
+
// Invalidate cache
|
|
280
|
+
await redis.del('schemas:all');
|
|
281
|
+
|
|
282
|
+
return { success: true, message: 'Schema deleted' };
|
|
283
|
+
});
|
|
284
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Version management is integrated into content routes
|
|
2
|
+
// This file exports types and utilities for versioning
|
|
3
|
+
|
|
4
|
+
export interface DocumentVersion {
|
|
5
|
+
id: string;
|
|
6
|
+
documentId: string;
|
|
7
|
+
version: number;
|
|
8
|
+
data: Record<string, any>;
|
|
9
|
+
locale: string;
|
|
10
|
+
changeSummary?: string;
|
|
11
|
+
createdBy: string;
|
|
12
|
+
createdAt: Date;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface VersionDiff {
|
|
16
|
+
field: string;
|
|
17
|
+
oldValue: any;
|
|
18
|
+
newValue: any;
|
|
19
|
+
type: 'added' | 'removed' | 'changed';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Compare two versions and return the differences
|
|
24
|
+
*/
|
|
25
|
+
export function compareVersions(oldData: Record<string, any>, newData: Record<string, any>): VersionDiff[] {
|
|
26
|
+
const diffs: VersionDiff[] = [];
|
|
27
|
+
const allKeys = new Set([...Object.keys(oldData), ...Object.keys(newData)]);
|
|
28
|
+
|
|
29
|
+
for (const key of allKeys) {
|
|
30
|
+
const oldValue = oldData[key];
|
|
31
|
+
const newValue = newData[key];
|
|
32
|
+
|
|
33
|
+
if (oldValue === undefined && newValue !== undefined) {
|
|
34
|
+
diffs.push({ field: key, oldValue, newValue, type: 'added' });
|
|
35
|
+
} else if (oldValue !== undefined && newValue === undefined) {
|
|
36
|
+
diffs.push({ field: key, oldValue, newValue, type: 'removed' });
|
|
37
|
+
} else if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
|
|
38
|
+
diffs.push({ field: key, oldValue, newValue, type: 'changed' });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return diffs;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Generate a human-readable summary of changes
|
|
47
|
+
*/
|
|
48
|
+
export function generateChangeSummary(diffs: VersionDiff[]): string {
|
|
49
|
+
if (diffs.length === 0) {
|
|
50
|
+
return 'No changes';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const parts: string[] = [];
|
|
54
|
+
|
|
55
|
+
const added = diffs.filter(d => d.type === 'added');
|
|
56
|
+
const removed = diffs.filter(d => d.type === 'removed');
|
|
57
|
+
const changed = diffs.filter(d => d.type === 'changed');
|
|
58
|
+
|
|
59
|
+
if (added.length > 0) {
|
|
60
|
+
parts.push(`Added: ${added.map(d => d.field).join(', ')}`);
|
|
61
|
+
}
|
|
62
|
+
if (removed.length > 0) {
|
|
63
|
+
parts.push(`Removed: ${removed.map(d => d.field).join(', ')}`);
|
|
64
|
+
}
|
|
65
|
+
if (changed.length > 0) {
|
|
66
|
+
parts.push(`Changed: ${changed.map(d => d.field).join(', ')}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return parts.join('; ');
|
|
70
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"declaration": true,
|
|
15
|
+
"declarationMap": true,
|
|
16
|
+
"sourceMap": true,
|
|
17
|
+
"baseUrl": ".",
|
|
18
|
+
"paths": {
|
|
19
|
+
"@/*": ["src/*"]
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"include": ["src/**/*"],
|
|
23
|
+
"exclude": ["node_modules", "dist"]
|
|
24
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pxlr/shared",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Shared types and utilities for PXLR CMS",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsc --watch"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"typescript": "^5.6.0"
|
|
13
|
+
}
|
|
14
|
+
}
|