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,9 @@
1
+ /** @type {import('postcss-load-config').Config} */
2
+ const config = {
3
+ plugins: {
4
+ tailwindcss: {},
5
+ autoprefixer: {},
6
+ },
7
+ };
8
+
9
+ export default config;
@@ -0,0 +1,503 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { useRouter, useParams } 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
+ Trash2,
19
+ Globe,
20
+ Calendar,
21
+ Eye,
22
+ EyeOff,
23
+ ImageIcon,
24
+ X,
25
+ Check,
26
+ Clock
27
+ } from 'lucide-react';
28
+ import Link from 'next/link';
29
+
30
+ interface Document {
31
+ id: string;
32
+ schema_name: string;
33
+ schema_title?: string;
34
+ schema_definition?: {
35
+ fields: Array<{
36
+ name: string;
37
+ type: string;
38
+ title?: string;
39
+ required?: boolean;
40
+ description?: string;
41
+ }>;
42
+ };
43
+ data: Record<string, any>;
44
+ status: string;
45
+ locale: string;
46
+ created_at: string;
47
+ updated_at: string;
48
+ }
49
+
50
+ function getPublicUrl(url: string): string {
51
+ if (!url) return '';
52
+ return url
53
+ .replace('http://minio:9000', 'http://localhost:9010')
54
+ .replace('http://localhost:9000', 'http://localhost:9010');
55
+ }
56
+
57
+ export default function EditContentPage() {
58
+ const router = useRouter();
59
+ const params = useParams();
60
+ const queryClient = useQueryClient();
61
+ const { locale } = useI18n();
62
+ const id = params.id as string;
63
+
64
+ const [formData, setFormData] = useState<Record<string, any>>({});
65
+ const [status, setStatus] = useState<string>('draft');
66
+ const [mediaPickerField, setMediaPickerField] = useState<string | null>(null);
67
+
68
+ const { data, isLoading, error } = useQuery({
69
+ queryKey: ['document', id],
70
+ queryFn: () => api.get(`/content/${id}`),
71
+ enabled: !!id,
72
+ });
73
+
74
+ const document: Document | null = data?.document;
75
+
76
+ useEffect(() => {
77
+ if (document) {
78
+ setFormData(document.data || {});
79
+ setStatus(document.status);
80
+ }
81
+ }, [document]);
82
+
83
+ const updateMutation = useMutation({
84
+ mutationFn: (data: any) => api.put(`/content/${id}`, data),
85
+ onSuccess: () => {
86
+ queryClient.invalidateQueries({ queryKey: ['document', id] });
87
+ queryClient.invalidateQueries({ queryKey: ['documents'] });
88
+ toast({ title: locale === 'ru' ? 'Документ сохранён' : 'Document saved' });
89
+ },
90
+ onError: (error: any) => {
91
+ toast({ title: locale === 'ru' ? 'Ошибка' : 'Error', description: error.message, variant: 'destructive' });
92
+ },
93
+ });
94
+
95
+ const deleteMutation = useMutation({
96
+ mutationFn: () => api.delete(`/content/${id}`),
97
+ onSuccess: () => {
98
+ toast({ title: locale === 'ru' ? 'Документ удалён' : 'Document deleted' });
99
+ router.push('/content');
100
+ },
101
+ onError: (error: any) => {
102
+ toast({ title: locale === 'ru' ? 'Ошибка' : 'Error', description: error.message, variant: 'destructive' });
103
+ },
104
+ });
105
+
106
+ const handleSubmit = (e: React.FormEvent) => {
107
+ e.preventDefault();
108
+ updateMutation.mutate({ data: formData, status });
109
+ };
110
+
111
+ const handlePublish = () => {
112
+ updateMutation.mutate({ data: formData, status: 'published' });
113
+ setStatus('published');
114
+ };
115
+
116
+ const handleUnpublish = () => {
117
+ updateMutation.mutate({ data: formData, status: 'draft' });
118
+ setStatus('draft');
119
+ };
120
+
121
+ const renderField = (field: Document['schema_definition']['fields'][0]) => {
122
+ const value = formData[field.name] ?? '';
123
+
124
+ switch (field.type) {
125
+ case 'string':
126
+ return (
127
+ <Input
128
+ value={value}
129
+ onChange={(e) => setFormData({ ...formData, [field.name]: e.target.value })}
130
+ placeholder={field.title || field.name}
131
+ className="text-base"
132
+ />
133
+ );
134
+
135
+ case 'slug':
136
+ return (
137
+ <div className="flex gap-2">
138
+ <Input
139
+ value={value}
140
+ onChange={(e) => setFormData({
141
+ ...formData,
142
+ [field.name]: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-')
143
+ })}
144
+ placeholder="url-slug"
145
+ className="font-mono text-sm"
146
+ />
147
+ <Button
148
+ type="button"
149
+ variant="outline"
150
+ size="sm"
151
+ onClick={() => {
152
+ const title = formData.title || formData.name || '';
153
+ const slug = title.toLowerCase()
154
+ .replace(/[а-яё]/g, (c: string) => {
155
+ const map: Record<string, string> = {
156
+ 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo',
157
+ 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
158
+ 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
159
+ 'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch', 'ъ': '',
160
+ 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya'
161
+ };
162
+ return map[c] || c;
163
+ })
164
+ .replace(/[^a-z0-9]+/g, '-')
165
+ .replace(/-+/g, '-')
166
+ .replace(/^-|-$/g, '');
167
+ setFormData({ ...formData, [field.name]: slug });
168
+ }}
169
+ >
170
+ {locale === 'ru' ? 'Сгенерировать' : 'Generate'}
171
+ </Button>
172
+ </div>
173
+ );
174
+
175
+ case 'text':
176
+ return (
177
+ <textarea
178
+ 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"
179
+ value={value}
180
+ onChange={(e) => setFormData({ ...formData, [field.name]: e.target.value })}
181
+ placeholder={field.title || field.name}
182
+ />
183
+ );
184
+
185
+ case 'number':
186
+ return (
187
+ <Input
188
+ type="number"
189
+ value={value}
190
+ onChange={(e) => setFormData({ ...formData, [field.name]: parseFloat(e.target.value) || 0 })}
191
+ className="text-base"
192
+ />
193
+ );
194
+
195
+ case 'boolean':
196
+ return (
197
+ <label className="flex items-center gap-3 cursor-pointer">
198
+ <div className={`relative w-11 h-6 rounded-full transition-colors ${value ? 'bg-green-500' : 'bg-gray-300'}`}>
199
+ <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'}`} />
200
+ <input
201
+ type="checkbox"
202
+ checked={value || false}
203
+ onChange={(e) => setFormData({ ...formData, [field.name]: e.target.checked })}
204
+ className="sr-only"
205
+ />
206
+ </div>
207
+ <span className="text-sm text-muted-foreground">
208
+ {value ? (locale === 'ru' ? 'Да' : 'Yes') : (locale === 'ru' ? 'Нет' : 'No')}
209
+ </span>
210
+ </label>
211
+ );
212
+
213
+ case 'date':
214
+ return (
215
+ <Input
216
+ type="date"
217
+ value={value ? value.split('T')[0] : ''}
218
+ onChange={(e) => setFormData({ ...formData, [field.name]: e.target.value })}
219
+ className="text-base"
220
+ />
221
+ );
222
+
223
+ case 'datetime':
224
+ return (
225
+ <Input
226
+ type="datetime-local"
227
+ value={value ? value.slice(0, 16) : ''}
228
+ onChange={(e) => setFormData({ ...formData, [field.name]: new Date(e.target.value).toISOString() })}
229
+ className="text-base"
230
+ />
231
+ );
232
+
233
+ case 'richText':
234
+ return (
235
+ <RichTextEditor
236
+ value={value}
237
+ onChange={(content) => setFormData({ ...formData, [field.name]: content })}
238
+ placeholder={locale === 'ru' ? 'Начните писать...' : 'Start writing...'}
239
+ onImageUpload={() => setMediaPickerField(field.name)}
240
+ />
241
+ );
242
+
243
+ case 'image':
244
+ const imageValue = typeof value === 'object' ? value : (value ? { url: value } : null);
245
+ return (
246
+ <div className="space-y-3">
247
+ {imageValue?.url ? (
248
+ <div className="relative inline-block">
249
+ <img
250
+ src={getPublicUrl(imageValue.url)}
251
+ alt={imageValue.alt || ''}
252
+ className="max-h-48 rounded-lg border"
253
+ />
254
+ <button
255
+ type="button"
256
+ onClick={() => setFormData({ ...formData, [field.name]: null })}
257
+ className="absolute -top-2 -right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600"
258
+ >
259
+ <X className="h-4 w-4" />
260
+ </button>
261
+ </div>
262
+ ) : (
263
+ <button
264
+ type="button"
265
+ onClick={() => setMediaPickerField(field.name)}
266
+ 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"
267
+ >
268
+ <ImageIcon className="h-10 w-10 text-muted-foreground mb-2" />
269
+ <span className="text-sm text-muted-foreground">
270
+ {locale === 'ru' ? 'Нажмите для выбора изображения' : 'Click to select image'}
271
+ </span>
272
+ </button>
273
+ )}
274
+ {imageValue?.url && (
275
+ <Button
276
+ type="button"
277
+ variant="outline"
278
+ size="sm"
279
+ onClick={() => setMediaPickerField(field.name)}
280
+ >
281
+ <ImageIcon className="h-4 w-4 mr-2" />
282
+ {locale === 'ru' ? 'Заменить' : 'Replace'}
283
+ </Button>
284
+ )}
285
+ </div>
286
+ );
287
+
288
+ case 'url':
289
+ case 'email':
290
+ return (
291
+ <Input
292
+ type={field.type === 'email' ? 'email' : 'url'}
293
+ value={value}
294
+ onChange={(e) => setFormData({ ...formData, [field.name]: e.target.value })}
295
+ placeholder={field.type === 'email' ? 'email@example.com' : 'https://'}
296
+ className="text-base"
297
+ />
298
+ );
299
+
300
+ default:
301
+ return (
302
+ <Input
303
+ value={typeof value === 'object' ? JSON.stringify(value) : value}
304
+ onChange={(e) => setFormData({ ...formData, [field.name]: e.target.value })}
305
+ placeholder={field.title || field.name}
306
+ className="text-base"
307
+ />
308
+ );
309
+ }
310
+ };
311
+
312
+ if (isLoading) {
313
+ return (
314
+ <div className="flex items-center justify-center py-12">
315
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
316
+ </div>
317
+ );
318
+ }
319
+
320
+ if (error || !document) {
321
+ return (
322
+ <div className="space-y-6">
323
+ <div className="flex items-center gap-4">
324
+ <Link href="/content">
325
+ <Button variant="ghost" size="icon">
326
+ <ArrowLeft className="h-4 w-4" />
327
+ </Button>
328
+ </Link>
329
+ <div>
330
+ <h1 className="text-2xl font-bold tracking-tight">
331
+ {locale === 'ru' ? 'Документ не найден' : 'Document Not Found'}
332
+ </h1>
333
+ </div>
334
+ </div>
335
+ </div>
336
+ );
337
+ }
338
+
339
+ const fields = document.schema_definition?.fields || [];
340
+
341
+ return (
342
+ <div className="max-w-4xl mx-auto">
343
+ {/* Header */}
344
+ <div className="flex items-center justify-between mb-6">
345
+ <div className="flex items-center gap-4">
346
+ <Link href="/content">
347
+ <Button variant="ghost" size="icon">
348
+ <ArrowLeft className="h-5 w-5" />
349
+ </Button>
350
+ </Link>
351
+ <div>
352
+ <div className="flex items-center gap-3">
353
+ <h1 className="text-2xl font-bold tracking-tight">
354
+ {formData.title || document.schema_title || document.schema_name}
355
+ </h1>
356
+ <span
357
+ className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ${
358
+ status === 'published'
359
+ ? 'bg-green-100 text-green-700'
360
+ : 'bg-amber-100 text-amber-700'
361
+ }`}
362
+ >
363
+ {status === 'published' ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3" />}
364
+ {status === 'published'
365
+ ? (locale === 'ru' ? 'Опубликовано' : 'Published')
366
+ : (locale === 'ru' ? 'Черновик' : 'Draft')}
367
+ </span>
368
+ </div>
369
+ <p className="text-sm text-muted-foreground mt-1">
370
+ {document.schema_title || document.schema_name} • ID: {document.id.slice(0, 8)}
371
+ </p>
372
+ </div>
373
+ </div>
374
+ </div>
375
+
376
+ <form onSubmit={handleSubmit}>
377
+ <div className="grid grid-cols-3 gap-6">
378
+ {/* Main content */}
379
+ <div className="col-span-2 space-y-6">
380
+ {fields.map((field) => (
381
+ <div key={field.name} className="rounded-lg border bg-card p-5">
382
+ <Label className="text-sm font-medium mb-3 block">
383
+ {field.title || field.name}
384
+ {field.required && <span className="text-red-500 ml-1">*</span>}
385
+ </Label>
386
+ {field.description && (
387
+ <p className="text-xs text-muted-foreground mb-3">{field.description}</p>
388
+ )}
389
+ {renderField(field)}
390
+ </div>
391
+ ))}
392
+
393
+ {fields.length === 0 && Object.keys(formData).length > 0 && (
394
+ Object.entries(formData).map(([key, value]) => (
395
+ <div key={key} className="rounded-lg border bg-card p-5">
396
+ <Label className="text-sm font-medium mb-3 block">{key}</Label>
397
+ <Input
398
+ value={typeof value === 'object' ? JSON.stringify(value) : String(value)}
399
+ onChange={(e) => setFormData({ ...formData, [key]: e.target.value })}
400
+ />
401
+ </div>
402
+ ))
403
+ )}
404
+ </div>
405
+
406
+ {/* Sidebar */}
407
+ <div className="space-y-4">
408
+ {/* Actions */}
409
+ <div className="rounded-lg border bg-card p-4 space-y-3">
410
+ <h3 className="font-medium text-sm">{locale === 'ru' ? 'Действия' : 'Actions'}</h3>
411
+ <div className="space-y-2">
412
+ <Button type="submit" className="w-full" disabled={updateMutation.isPending}>
413
+ {updateMutation.isPending ? (
414
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
415
+ ) : (
416
+ <Save className="mr-2 h-4 w-4" />
417
+ )}
418
+ {locale === 'ru' ? 'Сохранить' : 'Save'}
419
+ </Button>
420
+ {status !== 'published' ? (
421
+ <Button type="button" variant="secondary" className="w-full" onClick={handlePublish}>
422
+ <Check className="mr-2 h-4 w-4" />
423
+ {locale === 'ru' ? 'Опубликовать' : 'Publish'}
424
+ </Button>
425
+ ) : (
426
+ <Button type="button" variant="outline" className="w-full" onClick={handleUnpublish}>
427
+ <EyeOff className="mr-2 h-4 w-4" />
428
+ {locale === 'ru' ? 'Снять с публикации' : 'Unpublish'}
429
+ </Button>
430
+ )}
431
+ </div>
432
+ </div>
433
+
434
+ {/* Info */}
435
+ <div className="rounded-lg border bg-card p-4 space-y-3">
436
+ <h3 className="font-medium text-sm">{locale === 'ru' ? 'Информация' : 'Info'}</h3>
437
+ <div className="space-y-2 text-sm">
438
+ <div className="flex items-center gap-2 text-muted-foreground">
439
+ <Globe className="h-4 w-4" />
440
+ <span>{document.locale}</span>
441
+ </div>
442
+ <div className="flex items-center gap-2 text-muted-foreground">
443
+ <Calendar className="h-4 w-4" />
444
+ <span>{new Date(document.created_at).toLocaleDateString()}</span>
445
+ </div>
446
+ <div className="flex items-center gap-2 text-muted-foreground">
447
+ <Clock className="h-4 w-4" />
448
+ <span>{new Date(document.updated_at).toLocaleTimeString()}</span>
449
+ </div>
450
+ </div>
451
+ </div>
452
+
453
+ {/* Danger zone */}
454
+ <div className="rounded-lg border border-red-200 bg-red-50 p-4">
455
+ <h3 className="font-medium text-sm text-red-700 mb-3">
456
+ {locale === 'ru' ? 'Опасная зона' : 'Danger Zone'}
457
+ </h3>
458
+ <Button
459
+ type="button"
460
+ variant="destructive"
461
+ size="sm"
462
+ className="w-full"
463
+ onClick={() => {
464
+ if (confirm(locale === 'ru' ? 'Удалить документ?' : 'Delete this document?')) {
465
+ deleteMutation.mutate();
466
+ }
467
+ }}
468
+ >
469
+ <Trash2 className="mr-2 h-4 w-4" />
470
+ {locale === 'ru' ? 'Удалить' : 'Delete'}
471
+ </Button>
472
+ </div>
473
+ </div>
474
+ </div>
475
+ </form>
476
+
477
+ {/* Media Picker */}
478
+ <MediaPicker
479
+ isOpen={!!mediaPickerField}
480
+ onClose={() => setMediaPickerField(null)}
481
+ onSelect={(url, file) => {
482
+ if (mediaPickerField) {
483
+ const field = fields.find(f => f.name === mediaPickerField);
484
+ if (field?.type === 'image') {
485
+ setFormData({
486
+ ...formData,
487
+ [mediaPickerField]: { url, alt: file.original_filename }
488
+ });
489
+ } else if (field?.type === 'richText') {
490
+ // For rich text, we'd need to inject into the editor
491
+ // This is handled by the editor's onImageUpload callback
492
+ setFormData({
493
+ ...formData,
494
+ [mediaPickerField]: (formData[mediaPickerField] || '') + `\n<img src="${url}" alt="${file.original_filename}" />\n`
495
+ });
496
+ }
497
+ }
498
+ setMediaPickerField(null);
499
+ }}
500
+ />
501
+ </div>
502
+ );
503
+ }
@@ -0,0 +1,7 @@
1
+ 'use client';
2
+
3
+ import { AuthLayout } from '@/components/layout/auth-layout';
4
+
5
+ export default function ContentLayout({ children }: { children: React.ReactNode }) {
6
+ return <AuthLayout>{children}</AuthLayout>;
7
+ }