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,424 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useRouter, useSearchParams } from 'next/navigation';
5
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
6
+ import { api } from '@/lib/api';
7
+ import { Button } from '@/components/ui/button';
8
+ import { Input } from '@/components/ui/input';
9
+ import { Label } from '@/components/ui/label';
10
+ import { toast } from '@/components/ui/use-toast';
11
+ import { RichTextEditor } from '@/components/editor/rich-text-editor';
12
+ import { MediaPicker } from '@/components/editor/media-picker';
13
+ import { useI18n } from '@/lib/i18n/context';
14
+ import {
15
+ ArrowLeft,
16
+ Loader2,
17
+ Save,
18
+ ImageIcon,
19
+ X,
20
+ FileText
21
+ } from 'lucide-react';
22
+ import Link from 'next/link';
23
+
24
+ interface Schema {
25
+ id: string;
26
+ name: string;
27
+ title: string;
28
+ definition: {
29
+ fields: Array<{
30
+ name: string;
31
+ type: string;
32
+ title?: string;
33
+ required?: boolean;
34
+ description?: string;
35
+ }>;
36
+ };
37
+ }
38
+
39
+ function getPublicUrl(url: string): string {
40
+ if (!url) return '';
41
+ return url
42
+ .replace('http://minio:9000', 'http://localhost:9010')
43
+ .replace('http://localhost:9000', 'http://localhost:9010');
44
+ }
45
+
46
+ export default function NewContentPage() {
47
+ const router = useRouter();
48
+ const searchParams = useSearchParams();
49
+ const queryClient = useQueryClient();
50
+ const { locale } = useI18n();
51
+
52
+ const preselectedSchema = searchParams.get('schema');
53
+ const [selectedSchema, setSelectedSchema] = useState<string>(preselectedSchema || '');
54
+ const [formData, setFormData] = useState<Record<string, any>>({});
55
+ const [mediaPickerField, setMediaPickerField] = useState<string | null>(null);
56
+
57
+ const { data: schemasData, isLoading: schemasLoading } = useQuery({
58
+ queryKey: ['schemas'],
59
+ queryFn: () => api.get('/schemas'),
60
+ });
61
+
62
+ const schemas: Schema[] = schemasData?.schemas || [];
63
+ const currentSchema = schemas.find(s => s.name === selectedSchema);
64
+
65
+ const createMutation = useMutation({
66
+ mutationFn: (data: any) => api.post('/content', data),
67
+ onSuccess: (response) => {
68
+ queryClient.invalidateQueries({ queryKey: ['documents'] });
69
+ toast({ title: locale === 'ru' ? 'Документ создан' : 'Document created' });
70
+ router.push(`/content/${response.document.id}`);
71
+ },
72
+ onError: (error: any) => {
73
+ toast({ title: locale === 'ru' ? 'Ошибка' : 'Error', description: error.message, variant: 'destructive' });
74
+ },
75
+ });
76
+
77
+ const handleSubmit = (e: React.FormEvent) => {
78
+ e.preventDefault();
79
+ if (!selectedSchema) {
80
+ toast({ title: locale === 'ru' ? 'Выберите тип контента' : 'Select content type', variant: 'destructive' });
81
+ return;
82
+ }
83
+ createMutation.mutate({
84
+ schemaName: selectedSchema,
85
+ data: formData,
86
+ });
87
+ };
88
+
89
+ const renderField = (field: Schema['definition']['fields'][0]) => {
90
+ const value = formData[field.name] ?? '';
91
+
92
+ switch (field.type) {
93
+ case 'string':
94
+ return (
95
+ <Input
96
+ value={value}
97
+ onChange={(e) => setFormData({ ...formData, [field.name]: e.target.value })}
98
+ placeholder={field.title || field.name}
99
+ className="text-base"
100
+ />
101
+ );
102
+
103
+ case 'slug':
104
+ return (
105
+ <div className="flex gap-2">
106
+ <Input
107
+ value={value}
108
+ onChange={(e) => setFormData({
109
+ ...formData,
110
+ [field.name]: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-')
111
+ })}
112
+ placeholder="url-slug"
113
+ className="font-mono text-sm"
114
+ />
115
+ <Button
116
+ type="button"
117
+ variant="outline"
118
+ size="sm"
119
+ onClick={() => {
120
+ const title = formData.title || formData.name || '';
121
+ const slug = title.toLowerCase()
122
+ .replace(/[а-яё]/g, (c: string) => {
123
+ const map: Record<string, string> = {
124
+ 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo',
125
+ 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
126
+ 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
127
+ 'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch', 'ъ': '',
128
+ 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya'
129
+ };
130
+ return map[c] || c;
131
+ })
132
+ .replace(/[^a-z0-9]+/g, '-')
133
+ .replace(/-+/g, '-')
134
+ .replace(/^-|-$/g, '');
135
+ setFormData({ ...formData, [field.name]: slug });
136
+ }}
137
+ >
138
+ {locale === 'ru' ? 'Сгенерировать' : 'Generate'}
139
+ </Button>
140
+ </div>
141
+ );
142
+
143
+ case 'text':
144
+ return (
145
+ <textarea
146
+ className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-y"
147
+ value={value}
148
+ onChange={(e) => setFormData({ ...formData, [field.name]: e.target.value })}
149
+ placeholder={field.title || field.name}
150
+ />
151
+ );
152
+
153
+ case 'number':
154
+ return (
155
+ <Input
156
+ type="number"
157
+ value={value}
158
+ onChange={(e) => setFormData({ ...formData, [field.name]: parseFloat(e.target.value) || 0 })}
159
+ className="text-base"
160
+ />
161
+ );
162
+
163
+ case 'boolean':
164
+ return (
165
+ <label className="flex items-center gap-3 cursor-pointer">
166
+ <div className={`relative w-11 h-6 rounded-full transition-colors ${value ? 'bg-green-500' : 'bg-gray-300'}`}>
167
+ <div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${value ? 'translate-x-5' : 'translate-x-0.5'}`} />
168
+ <input
169
+ type="checkbox"
170
+ checked={value || false}
171
+ onChange={(e) => setFormData({ ...formData, [field.name]: e.target.checked })}
172
+ className="sr-only"
173
+ />
174
+ </div>
175
+ <span className="text-sm text-muted-foreground">
176
+ {value ? (locale === 'ru' ? 'Да' : 'Yes') : (locale === 'ru' ? 'Нет' : 'No')}
177
+ </span>
178
+ </label>
179
+ );
180
+
181
+ case 'date':
182
+ return (
183
+ <Input
184
+ type="date"
185
+ value={value ? value.split('T')[0] : ''}
186
+ onChange={(e) => setFormData({ ...formData, [field.name]: e.target.value })}
187
+ className="text-base"
188
+ />
189
+ );
190
+
191
+ case 'datetime':
192
+ return (
193
+ <Input
194
+ type="datetime-local"
195
+ value={value ? value.slice(0, 16) : ''}
196
+ onChange={(e) => setFormData({ ...formData, [field.name]: new Date(e.target.value).toISOString() })}
197
+ className="text-base"
198
+ />
199
+ );
200
+
201
+ case 'richText':
202
+ return (
203
+ <RichTextEditor
204
+ value={value}
205
+ onChange={(content) => setFormData({ ...formData, [field.name]: content })}
206
+ placeholder={locale === 'ru' ? 'Начните писать...' : 'Start writing...'}
207
+ onImageUpload={() => setMediaPickerField(field.name)}
208
+ />
209
+ );
210
+
211
+ case 'image':
212
+ const imageValue = typeof value === 'object' ? value : (value ? { url: value } : null);
213
+ return (
214
+ <div className="space-y-3">
215
+ {imageValue?.url ? (
216
+ <div className="relative inline-block">
217
+ <img
218
+ src={getPublicUrl(imageValue.url)}
219
+ alt={imageValue.alt || ''}
220
+ className="max-h-48 rounded-lg border"
221
+ />
222
+ <button
223
+ type="button"
224
+ onClick={() => setFormData({ ...formData, [field.name]: null })}
225
+ className="absolute -top-2 -right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600"
226
+ >
227
+ <X className="h-4 w-4" />
228
+ </button>
229
+ </div>
230
+ ) : (
231
+ <button
232
+ type="button"
233
+ onClick={() => setMediaPickerField(field.name)}
234
+ className="flex flex-col items-center justify-center w-full h-40 border-2 border-dashed rounded-lg hover:border-primary hover:bg-muted/50 transition-colors"
235
+ >
236
+ <ImageIcon className="h-10 w-10 text-muted-foreground mb-2" />
237
+ <span className="text-sm text-muted-foreground">
238
+ {locale === 'ru' ? 'Нажмите для выбора изображения' : 'Click to select image'}
239
+ </span>
240
+ </button>
241
+ )}
242
+ </div>
243
+ );
244
+
245
+ case 'url':
246
+ case 'email':
247
+ return (
248
+ <Input
249
+ type={field.type === 'email' ? 'email' : 'url'}
250
+ value={value}
251
+ onChange={(e) => setFormData({ ...formData, [field.name]: e.target.value })}
252
+ placeholder={field.type === 'email' ? 'email@example.com' : 'https://'}
253
+ className="text-base"
254
+ />
255
+ );
256
+
257
+ default:
258
+ return (
259
+ <Input
260
+ value={typeof value === 'object' ? JSON.stringify(value) : value}
261
+ onChange={(e) => setFormData({ ...formData, [field.name]: e.target.value })}
262
+ placeholder={field.title || field.name}
263
+ className="text-base"
264
+ />
265
+ );
266
+ }
267
+ };
268
+
269
+ if (schemasLoading) {
270
+ return (
271
+ <div className="flex items-center justify-center py-12">
272
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
273
+ </div>
274
+ );
275
+ }
276
+
277
+ return (
278
+ <div className="max-w-4xl mx-auto">
279
+ {/* Header */}
280
+ <div className="flex items-center gap-4 mb-6">
281
+ <Link href="/content">
282
+ <Button variant="ghost" size="icon">
283
+ <ArrowLeft className="h-5 w-5" />
284
+ </Button>
285
+ </Link>
286
+ <div>
287
+ <h1 className="text-2xl font-bold tracking-tight">
288
+ {locale === 'ru' ? 'Новый документ' : 'New Document'}
289
+ </h1>
290
+ {currentSchema && (
291
+ <p className="text-sm text-muted-foreground mt-1">
292
+ {currentSchema.title}
293
+ </p>
294
+ )}
295
+ </div>
296
+ </div>
297
+
298
+ {/* Schema selector */}
299
+ {!selectedSchema && schemas.length > 0 && (
300
+ <div className="rounded-lg border bg-card p-6 mb-6">
301
+ <h2 className="font-semibold mb-4">
302
+ {locale === 'ru' ? 'Выберите тип контента' : 'Select Content Type'}
303
+ </h2>
304
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
305
+ {schemas.map((schema) => (
306
+ <button
307
+ key={schema.id}
308
+ onClick={() => setSelectedSchema(schema.name)}
309
+ className="flex items-center gap-3 p-4 rounded-lg border hover:border-primary hover:bg-muted/50 transition-colors text-left"
310
+ >
311
+ <div className="p-2 rounded-lg bg-primary/10">
312
+ <FileText className="h-5 w-5 text-primary" />
313
+ </div>
314
+ <div>
315
+ <div className="font-medium">{schema.title}</div>
316
+ <div className="text-xs text-muted-foreground">
317
+ {schema.definition?.fields?.length || 0} {locale === 'ru' ? 'полей' : 'fields'}
318
+ </div>
319
+ </div>
320
+ </button>
321
+ ))}
322
+ </div>
323
+ </div>
324
+ )}
325
+
326
+ {schemas.length === 0 && (
327
+ <div className="rounded-lg border border-dashed p-12 text-center">
328
+ <FileText className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
329
+ <h2 className="text-lg font-semibold mb-2">
330
+ {locale === 'ru' ? 'Нет схем контента' : 'No Content Schemas'}
331
+ </h2>
332
+ <p className="text-muted-foreground mb-4">
333
+ {locale === 'ru'
334
+ ? 'Сначала создайте схему контента'
335
+ : 'Create a content schema first'}
336
+ </p>
337
+ <Link href="/schemas">
338
+ <Button>{locale === 'ru' ? 'Перейти к схемам' : 'Go to Schemas'}</Button>
339
+ </Link>
340
+ </div>
341
+ )}
342
+
343
+ {/* Form */}
344
+ {selectedSchema && currentSchema && (
345
+ <form onSubmit={handleSubmit}>
346
+ <div className="grid grid-cols-3 gap-6">
347
+ {/* Main content */}
348
+ <div className="col-span-2 space-y-6">
349
+ {currentSchema.definition?.fields?.map((field) => (
350
+ <div key={field.name} className="rounded-lg border bg-card p-5">
351
+ <Label className="text-sm font-medium mb-3 block">
352
+ {field.title || field.name}
353
+ {field.required && <span className="text-red-500 ml-1">*</span>}
354
+ </Label>
355
+ {field.description && (
356
+ <p className="text-xs text-muted-foreground mb-3">{field.description}</p>
357
+ )}
358
+ {renderField(field)}
359
+ </div>
360
+ ))}
361
+ </div>
362
+
363
+ {/* Sidebar */}
364
+ <div className="space-y-4">
365
+ <div className="rounded-lg border bg-card p-4 space-y-3">
366
+ <h3 className="font-medium text-sm">{locale === 'ru' ? 'Действия' : 'Actions'}</h3>
367
+ <div className="space-y-2">
368
+ <Button type="submit" className="w-full" disabled={createMutation.isPending}>
369
+ {createMutation.isPending ? (
370
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
371
+ ) : (
372
+ <Save className="mr-2 h-4 w-4" />
373
+ )}
374
+ {locale === 'ru' ? 'Создать' : 'Create'}
375
+ </Button>
376
+ <Link href="/content" className="block">
377
+ <Button type="button" variant="outline" className="w-full">
378
+ {locale === 'ru' ? 'Отмена' : 'Cancel'}
379
+ </Button>
380
+ </Link>
381
+ </div>
382
+ </div>
383
+
384
+ <div className="rounded-lg border bg-card p-4">
385
+ <h3 className="font-medium text-sm mb-2">{locale === 'ru' ? 'Тип контента' : 'Content Type'}</h3>
386
+ <p className="text-sm text-muted-foreground">{currentSchema.title}</p>
387
+ <Button
388
+ type="button"
389
+ variant="link"
390
+ size="sm"
391
+ className="px-0 h-auto"
392
+ onClick={() => {
393
+ setSelectedSchema('');
394
+ setFormData({});
395
+ }}
396
+ >
397
+ {locale === 'ru' ? 'Изменить' : 'Change'}
398
+ </Button>
399
+ </div>
400
+ </div>
401
+ </div>
402
+ </form>
403
+ )}
404
+
405
+ {/* Media Picker */}
406
+ <MediaPicker
407
+ isOpen={!!mediaPickerField}
408
+ onClose={() => setMediaPickerField(null)}
409
+ onSelect={(url, file) => {
410
+ if (mediaPickerField) {
411
+ const field = currentSchema?.definition?.fields?.find(f => f.name === mediaPickerField);
412
+ if (field?.type === 'image') {
413
+ setFormData({
414
+ ...formData,
415
+ [mediaPickerField]: { url, alt: file.original_filename }
416
+ });
417
+ }
418
+ }
419
+ setMediaPickerField(null);
420
+ }}
421
+ />
422
+ </div>
423
+ );
424
+ }
@@ -0,0 +1,191 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
5
+ import { api } from '@/lib/api';
6
+ import { formatDate } from '@/lib/utils';
7
+ import { Button } from '@/components/ui/button';
8
+ import { Input } from '@/components/ui/input';
9
+ import { toast } from '@/components/ui/use-toast';
10
+ import { Plus, FileText, Trash2, Edit, Loader2, Search } from 'lucide-react';
11
+ import Link from 'next/link';
12
+
13
+ interface Document {
14
+ id: string;
15
+ schema_name: string;
16
+ schema_title: string;
17
+ data: Record<string, any>;
18
+ status: string;
19
+ locale: string;
20
+ created_at: string;
21
+ updated_at: string;
22
+ created_by_name: string;
23
+ }
24
+
25
+ export default function ContentPage() {
26
+ const queryClient = useQueryClient();
27
+ const [search, setSearch] = useState('');
28
+ const [selectedSchema, setSelectedSchema] = useState<string>('');
29
+
30
+ const { data: schemasData } = useQuery({
31
+ queryKey: ['schemas'],
32
+ queryFn: () => api.get('/schemas'),
33
+ });
34
+
35
+ const { data, isLoading } = useQuery({
36
+ queryKey: ['documents', selectedSchema, search],
37
+ queryFn: () =>
38
+ api.get(
39
+ `/content?${selectedSchema ? `schemaName=${selectedSchema}&` : ''}${search ? `search=${search}&` : ''}limit=50`
40
+ ),
41
+ });
42
+
43
+ const deleteMutation = useMutation({
44
+ mutationFn: (id: string) => api.delete(`/content/${id}`),
45
+ onSuccess: () => {
46
+ queryClient.invalidateQueries({ queryKey: ['documents'] });
47
+ toast({ title: 'Document deleted successfully' });
48
+ },
49
+ onError: (error: any) => {
50
+ toast({ title: 'Error', description: error.message, variant: 'destructive' });
51
+ },
52
+ });
53
+
54
+ const schemas = schemasData?.schemas || [];
55
+ const documents: Document[] = data?.documents || [];
56
+
57
+ return (
58
+ <div className="space-y-6">
59
+ <div className="flex items-center justify-between">
60
+ <div>
61
+ <h1 className="text-2xl font-bold tracking-tight">Content</h1>
62
+ <p className="text-muted-foreground">
63
+ Manage your content documents
64
+ </p>
65
+ </div>
66
+ {schemas.length > 0 && (
67
+ <Link href="/content/new">
68
+ <Button>
69
+ <Plus className="mr-2 h-4 w-4" />
70
+ New Document
71
+ </Button>
72
+ </Link>
73
+ )}
74
+ </div>
75
+
76
+ <div className="flex gap-4">
77
+ <div className="relative flex-1">
78
+ <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
79
+ <Input
80
+ placeholder="Search documents..."
81
+ value={search}
82
+ onChange={(e) => setSearch(e.target.value)}
83
+ className="pl-10"
84
+ />
85
+ </div>
86
+ <select
87
+ value={selectedSchema}
88
+ onChange={(e) => setSelectedSchema(e.target.value)}
89
+ className="rounded-md border bg-background px-3 py-2 text-sm"
90
+ >
91
+ <option value="">All types</option>
92
+ {schemas.map((schema: any) => (
93
+ <option key={schema.name} value={schema.name}>
94
+ {schema.title}
95
+ </option>
96
+ ))}
97
+ </select>
98
+ </div>
99
+
100
+ {schemas.length === 0 ? (
101
+ <div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
102
+ <FileText className="h-12 w-12 text-muted-foreground" />
103
+ <h3 className="mt-4 text-lg font-semibold">No schemas defined</h3>
104
+ <p className="mt-2 text-sm text-muted-foreground">
105
+ Create a content schema first before adding documents
106
+ </p>
107
+ <Link href="/schemas">
108
+ <Button className="mt-4">Go to Schemas</Button>
109
+ </Link>
110
+ </div>
111
+ ) : isLoading ? (
112
+ <div className="flex items-center justify-center py-12">
113
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
114
+ </div>
115
+ ) : documents.length === 0 ? (
116
+ <div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
117
+ <FileText className="h-12 w-12 text-muted-foreground" />
118
+ <h3 className="mt-4 text-lg font-semibold">No documents yet</h3>
119
+ <p className="mt-2 text-sm text-muted-foreground">
120
+ Create your first document to get started
121
+ </p>
122
+ </div>
123
+ ) : (
124
+ <div className="rounded-lg border">
125
+ <table className="w-full">
126
+ <thead>
127
+ <tr className="border-b bg-muted/50">
128
+ <th className="px-4 py-3 text-left text-sm font-medium">Title</th>
129
+ <th className="px-4 py-3 text-left text-sm font-medium">Type</th>
130
+ <th className="px-4 py-3 text-left text-sm font-medium">Status</th>
131
+ <th className="px-4 py-3 text-left text-sm font-medium">Updated</th>
132
+ <th className="px-4 py-3 text-right text-sm font-medium">Actions</th>
133
+ </tr>
134
+ </thead>
135
+ <tbody>
136
+ {documents.map((doc) => (
137
+ <tr key={doc.id} className="border-b">
138
+ <td className="px-4 py-3">
139
+ <div>
140
+ <p className="font-medium">
141
+ {doc.data?.title || 'Untitled'}
142
+ </p>
143
+ <p className="text-xs text-muted-foreground">
144
+ {doc.id.slice(0, 8)}...
145
+ </p>
146
+ </div>
147
+ </td>
148
+ <td className="px-4 py-3 text-sm">
149
+ {doc.schema_title || doc.schema_name}
150
+ </td>
151
+ <td className="px-4 py-3">
152
+ <span
153
+ className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${
154
+ doc.status === 'published'
155
+ ? 'bg-green-100 text-green-700'
156
+ : doc.status === 'draft'
157
+ ? 'bg-yellow-100 text-yellow-700'
158
+ : 'bg-gray-100 text-gray-700'
159
+ }`}
160
+ >
161
+ {doc.status}
162
+ </span>
163
+ </td>
164
+ <td className="px-4 py-3 text-sm text-muted-foreground">
165
+ {formatDate(doc.updated_at)}
166
+ </td>
167
+ <td className="px-4 py-3">
168
+ <div className="flex justify-end gap-1">
169
+ <Link href={`/content/${doc.id}`}>
170
+ <Button variant="ghost" size="icon">
171
+ <Edit className="h-4 w-4" />
172
+ </Button>
173
+ </Link>
174
+ <Button
175
+ variant="ghost"
176
+ size="icon"
177
+ onClick={() => deleteMutation.mutate(doc.id)}
178
+ >
179
+ <Trash2 className="h-4 w-4" />
180
+ </Button>
181
+ </div>
182
+ </td>
183
+ </tr>
184
+ ))}
185
+ </tbody>
186
+ </table>
187
+ </div>
188
+ )}
189
+ </div>
190
+ );
191
+ }