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.
Files changed (153) hide show
  1. package/README.md +160 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +264 -0
  4. package/package.json +51 -0
  5. package/templates/blog/frontend/app/blog/[slug]/page.tsx +175 -0
  6. package/templates/blog/frontend/app/blog/page.tsx +102 -0
  7. package/templates/blog/frontend/app/components/footer.tsx +21 -0
  8. package/templates/blog/frontend/app/components/header.tsx +45 -0
  9. package/templates/blog/frontend/app/globals.css +30 -0
  10. package/templates/blog/frontend/app/layout.tsx +38 -0
  11. package/templates/blog/frontend/app/lib/cms.ts +71 -0
  12. package/templates/blog/frontend/app/page.tsx +155 -0
  13. package/templates/blog/frontend/next.config.ts +16 -0
  14. package/templates/blog/frontend/package.json +24 -0
  15. package/templates/blog/frontend/postcss.config.mjs +7 -0
  16. package/templates/blog/frontend/tsconfig.json +23 -0
  17. package/templates/blog/pxlr-cms/README.md +188 -0
  18. package/templates/blog/pxlr-cms/docker-compose.yml +132 -0
  19. package/templates/blog/pxlr-cms/nginx/nginx.conf +107 -0
  20. package/templates/blog/pxlr-cms/packages/admin/.dockerignore +4 -0
  21. package/templates/blog/pxlr-cms/packages/admin/.env.example +2 -0
  22. package/templates/blog/pxlr-cms/packages/admin/Dockerfile +19 -0
  23. package/templates/blog/pxlr-cms/packages/admin/next-env.d.ts +6 -0
  24. package/templates/blog/pxlr-cms/packages/admin/next.config.ts +22 -0
  25. package/templates/blog/pxlr-cms/packages/admin/package.json +63 -0
  26. package/templates/blog/pxlr-cms/packages/admin/pnpm-lock.yaml +5748 -0
  27. package/templates/blog/pxlr-cms/packages/admin/postcss.config.mjs +9 -0
  28. package/templates/blog/pxlr-cms/packages/admin/src/app/content/[id]/page.tsx +503 -0
  29. package/templates/blog/pxlr-cms/packages/admin/src/app/content/layout.tsx +7 -0
  30. package/templates/blog/pxlr-cms/packages/admin/src/app/content/new/page.tsx +424 -0
  31. package/templates/blog/pxlr-cms/packages/admin/src/app/content/page.tsx +191 -0
  32. package/templates/blog/pxlr-cms/packages/admin/src/app/globals.css +132 -0
  33. package/templates/blog/pxlr-cms/packages/admin/src/app/layout.tsx +25 -0
  34. package/templates/blog/pxlr-cms/packages/admin/src/app/login/page.tsx +119 -0
  35. package/templates/blog/pxlr-cms/packages/admin/src/app/media/layout.tsx +7 -0
  36. package/templates/blog/pxlr-cms/packages/admin/src/app/media/page.tsx +362 -0
  37. package/templates/blog/pxlr-cms/packages/admin/src/app/page.tsx +184 -0
  38. package/templates/blog/pxlr-cms/packages/admin/src/app/profile/layout.tsx +7 -0
  39. package/templates/blog/pxlr-cms/packages/admin/src/app/profile/page.tsx +206 -0
  40. package/templates/blog/pxlr-cms/packages/admin/src/app/schemas/[name]/page.tsx +312 -0
  41. package/templates/blog/pxlr-cms/packages/admin/src/app/schemas/layout.tsx +7 -0
  42. package/templates/blog/pxlr-cms/packages/admin/src/app/schemas/page.tsx +210 -0
  43. package/templates/blog/pxlr-cms/packages/admin/src/app/settings/layout.tsx +7 -0
  44. package/templates/blog/pxlr-cms/packages/admin/src/app/settings/page.tsx +178 -0
  45. package/templates/blog/pxlr-cms/packages/admin/src/components/editor/media-picker.tsx +202 -0
  46. package/templates/blog/pxlr-cms/packages/admin/src/components/editor/rich-text-editor.tsx +387 -0
  47. package/templates/blog/pxlr-cms/packages/admin/src/components/layout/auth-layout.tsx +43 -0
  48. package/templates/blog/pxlr-cms/packages/admin/src/components/layout/header.tsx +79 -0
  49. package/templates/blog/pxlr-cms/packages/admin/src/components/layout/sidebar.tsx +68 -0
  50. package/templates/blog/pxlr-cms/packages/admin/src/components/providers.tsx +29 -0
  51. package/templates/blog/pxlr-cms/packages/admin/src/components/schema-code-generator.tsx +326 -0
  52. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/avatar.tsx +49 -0
  53. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/button.tsx +55 -0
  54. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/dropdown-menu.tsx +194 -0
  55. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/input.tsx +24 -0
  56. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/label.tsx +25 -0
  57. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/toast.tsx +127 -0
  58. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/toaster.tsx +35 -0
  59. package/templates/blog/pxlr-cms/packages/admin/src/components/ui/use-toast.ts +187 -0
  60. package/templates/blog/pxlr-cms/packages/admin/src/lib/api.ts +96 -0
  61. package/templates/blog/pxlr-cms/packages/admin/src/lib/i18n/context.tsx +60 -0
  62. package/templates/blog/pxlr-cms/packages/admin/src/lib/i18n/translations.ts +317 -0
  63. package/templates/blog/pxlr-cms/packages/admin/src/lib/store/auth.ts +51 -0
  64. package/templates/blog/pxlr-cms/packages/admin/src/lib/utils.ts +29 -0
  65. package/templates/blog/pxlr-cms/packages/admin/tailwind.config.ts +57 -0
  66. package/templates/blog/pxlr-cms/packages/admin/tsconfig.json +27 -0
  67. package/templates/blog/pxlr-cms/packages/api/.env.example +23 -0
  68. package/templates/blog/pxlr-cms/packages/api/Dockerfile +26 -0
  69. package/templates/blog/pxlr-cms/packages/api/package.json +42 -0
  70. package/templates/blog/pxlr-cms/packages/api/src/config.ts +39 -0
  71. package/templates/blog/pxlr-cms/packages/api/src/database/index.ts +60 -0
  72. package/templates/blog/pxlr-cms/packages/api/src/database/init.sql +258 -0
  73. package/templates/blog/pxlr-cms/packages/api/src/database/redis.ts +95 -0
  74. package/templates/blog/pxlr-cms/packages/api/src/database/seed.sql +78 -0
  75. package/templates/blog/pxlr-cms/packages/api/src/index.ts +157 -0
  76. package/templates/blog/pxlr-cms/packages/api/src/modules/auth/routes.ts +256 -0
  77. package/templates/blog/pxlr-cms/packages/api/src/modules/content/routes.ts +385 -0
  78. package/templates/blog/pxlr-cms/packages/api/src/modules/media/routes.ts +312 -0
  79. package/templates/blog/pxlr-cms/packages/api/src/modules/realtime/handler.ts +228 -0
  80. package/templates/blog/pxlr-cms/packages/api/src/modules/schema/routes.ts +284 -0
  81. package/templates/blog/pxlr-cms/packages/api/src/modules/versions/routes.ts +70 -0
  82. package/templates/blog/pxlr-cms/packages/api/tsconfig.json +24 -0
  83. package/templates/blog/pxlr-cms/packages/shared/package.json +14 -0
  84. package/templates/blog/pxlr-cms/packages/shared/src/types/index.ts +139 -0
  85. package/templates/blog/pxlr-cms/packages/shared/tsconfig.json +18 -0
  86. package/templates/clean/pxlr-cms/README.md +188 -0
  87. package/templates/clean/pxlr-cms/docker-compose.yml +132 -0
  88. package/templates/clean/pxlr-cms/nginx/nginx.conf +107 -0
  89. package/templates/clean/pxlr-cms/packages/admin/.dockerignore +4 -0
  90. package/templates/clean/pxlr-cms/packages/admin/.env.example +2 -0
  91. package/templates/clean/pxlr-cms/packages/admin/Dockerfile +19 -0
  92. package/templates/clean/pxlr-cms/packages/admin/next-env.d.ts +6 -0
  93. package/templates/clean/pxlr-cms/packages/admin/next.config.ts +22 -0
  94. package/templates/clean/pxlr-cms/packages/admin/package.json +63 -0
  95. package/templates/clean/pxlr-cms/packages/admin/pnpm-lock.yaml +5748 -0
  96. package/templates/clean/pxlr-cms/packages/admin/postcss.config.mjs +9 -0
  97. package/templates/clean/pxlr-cms/packages/admin/src/app/content/[id]/page.tsx +503 -0
  98. package/templates/clean/pxlr-cms/packages/admin/src/app/content/layout.tsx +7 -0
  99. package/templates/clean/pxlr-cms/packages/admin/src/app/content/new/page.tsx +424 -0
  100. package/templates/clean/pxlr-cms/packages/admin/src/app/content/page.tsx +191 -0
  101. package/templates/clean/pxlr-cms/packages/admin/src/app/globals.css +132 -0
  102. package/templates/clean/pxlr-cms/packages/admin/src/app/layout.tsx +25 -0
  103. package/templates/clean/pxlr-cms/packages/admin/src/app/login/page.tsx +119 -0
  104. package/templates/clean/pxlr-cms/packages/admin/src/app/media/layout.tsx +7 -0
  105. package/templates/clean/pxlr-cms/packages/admin/src/app/media/page.tsx +362 -0
  106. package/templates/clean/pxlr-cms/packages/admin/src/app/page.tsx +184 -0
  107. package/templates/clean/pxlr-cms/packages/admin/src/app/profile/layout.tsx +7 -0
  108. package/templates/clean/pxlr-cms/packages/admin/src/app/profile/page.tsx +206 -0
  109. package/templates/clean/pxlr-cms/packages/admin/src/app/schemas/[name]/page.tsx +312 -0
  110. package/templates/clean/pxlr-cms/packages/admin/src/app/schemas/layout.tsx +7 -0
  111. package/templates/clean/pxlr-cms/packages/admin/src/app/schemas/page.tsx +210 -0
  112. package/templates/clean/pxlr-cms/packages/admin/src/app/settings/layout.tsx +7 -0
  113. package/templates/clean/pxlr-cms/packages/admin/src/app/settings/page.tsx +178 -0
  114. package/templates/clean/pxlr-cms/packages/admin/src/components/editor/media-picker.tsx +202 -0
  115. package/templates/clean/pxlr-cms/packages/admin/src/components/editor/rich-text-editor.tsx +387 -0
  116. package/templates/clean/pxlr-cms/packages/admin/src/components/layout/auth-layout.tsx +43 -0
  117. package/templates/clean/pxlr-cms/packages/admin/src/components/layout/header.tsx +79 -0
  118. package/templates/clean/pxlr-cms/packages/admin/src/components/layout/sidebar.tsx +68 -0
  119. package/templates/clean/pxlr-cms/packages/admin/src/components/providers.tsx +29 -0
  120. package/templates/clean/pxlr-cms/packages/admin/src/components/schema-code-generator.tsx +326 -0
  121. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/avatar.tsx +49 -0
  122. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/button.tsx +55 -0
  123. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/dropdown-menu.tsx +194 -0
  124. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/input.tsx +24 -0
  125. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/label.tsx +25 -0
  126. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/toast.tsx +127 -0
  127. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/toaster.tsx +35 -0
  128. package/templates/clean/pxlr-cms/packages/admin/src/components/ui/use-toast.ts +187 -0
  129. package/templates/clean/pxlr-cms/packages/admin/src/lib/api.ts +96 -0
  130. package/templates/clean/pxlr-cms/packages/admin/src/lib/i18n/context.tsx +60 -0
  131. package/templates/clean/pxlr-cms/packages/admin/src/lib/i18n/translations.ts +317 -0
  132. package/templates/clean/pxlr-cms/packages/admin/src/lib/store/auth.ts +51 -0
  133. package/templates/clean/pxlr-cms/packages/admin/src/lib/utils.ts +29 -0
  134. package/templates/clean/pxlr-cms/packages/admin/tailwind.config.ts +57 -0
  135. package/templates/clean/pxlr-cms/packages/admin/tsconfig.json +27 -0
  136. package/templates/clean/pxlr-cms/packages/api/.env.example +23 -0
  137. package/templates/clean/pxlr-cms/packages/api/Dockerfile +26 -0
  138. package/templates/clean/pxlr-cms/packages/api/package.json +42 -0
  139. package/templates/clean/pxlr-cms/packages/api/src/config.ts +39 -0
  140. package/templates/clean/pxlr-cms/packages/api/src/database/index.ts +60 -0
  141. package/templates/clean/pxlr-cms/packages/api/src/database/init.sql +178 -0
  142. package/templates/clean/pxlr-cms/packages/api/src/database/redis.ts +95 -0
  143. package/templates/clean/pxlr-cms/packages/api/src/index.ts +157 -0
  144. package/templates/clean/pxlr-cms/packages/api/src/modules/auth/routes.ts +256 -0
  145. package/templates/clean/pxlr-cms/packages/api/src/modules/content/routes.ts +385 -0
  146. package/templates/clean/pxlr-cms/packages/api/src/modules/media/routes.ts +312 -0
  147. package/templates/clean/pxlr-cms/packages/api/src/modules/realtime/handler.ts +228 -0
  148. package/templates/clean/pxlr-cms/packages/api/src/modules/schema/routes.ts +284 -0
  149. package/templates/clean/pxlr-cms/packages/api/src/modules/versions/routes.ts +70 -0
  150. package/templates/clean/pxlr-cms/packages/api/tsconfig.json +24 -0
  151. package/templates/clean/pxlr-cms/packages/shared/package.json +14 -0
  152. package/templates/clean/pxlr-cms/packages/shared/src/types/index.ts +139 -0
  153. package/templates/clean/pxlr-cms/packages/shared/tsconfig.json +18 -0
@@ -0,0 +1,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
+ }