pxlr-cms 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +160 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +264 -0
- package/package.json +51 -0
- package/templates/blog/frontend/app/blog/[slug]/page.tsx +175 -0
- package/templates/blog/frontend/app/blog/page.tsx +102 -0
- package/templates/blog/frontend/app/components/footer.tsx +21 -0
- package/templates/blog/frontend/app/components/header.tsx +45 -0
- package/templates/blog/frontend/app/globals.css +30 -0
- package/templates/blog/frontend/app/layout.tsx +38 -0
- package/templates/blog/frontend/app/lib/cms.ts +71 -0
- package/templates/blog/frontend/app/page.tsx +155 -0
- package/templates/blog/frontend/next.config.ts +16 -0
- package/templates/blog/frontend/package.json +24 -0
- package/templates/blog/frontend/postcss.config.mjs +7 -0
- package/templates/blog/frontend/tsconfig.json +23 -0
- package/templates/blog/pxlr-cms/README.md +188 -0
- package/templates/blog/pxlr-cms/docker-compose.yml +132 -0
- package/templates/blog/pxlr-cms/nginx/nginx.conf +107 -0
- package/templates/blog/pxlr-cms/packages/admin/.dockerignore +4 -0
- package/templates/blog/pxlr-cms/packages/admin/.env.example +2 -0
- package/templates/blog/pxlr-cms/packages/admin/Dockerfile +19 -0
- package/templates/blog/pxlr-cms/packages/admin/next-env.d.ts +6 -0
- package/templates/blog/pxlr-cms/packages/admin/next.config.ts +22 -0
- package/templates/blog/pxlr-cms/packages/admin/package.json +63 -0
- package/templates/blog/pxlr-cms/packages/admin/pnpm-lock.yaml +5748 -0
- package/templates/blog/pxlr-cms/packages/admin/postcss.config.mjs +9 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/content/[id]/page.tsx +503 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/content/layout.tsx +7 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/content/new/page.tsx +424 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/content/page.tsx +191 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/globals.css +132 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/layout.tsx +25 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/login/page.tsx +119 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/media/layout.tsx +7 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/media/page.tsx +362 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/page.tsx +184 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/profile/layout.tsx +7 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/profile/page.tsx +206 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/schemas/[name]/page.tsx +312 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/schemas/layout.tsx +7 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/schemas/page.tsx +210 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/settings/layout.tsx +7 -0
- package/templates/blog/pxlr-cms/packages/admin/src/app/settings/page.tsx +178 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/editor/media-picker.tsx +202 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/editor/rich-text-editor.tsx +387 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/layout/auth-layout.tsx +43 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/layout/header.tsx +79 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/layout/sidebar.tsx +68 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/providers.tsx +29 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/schema-code-generator.tsx +326 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/avatar.tsx +49 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/button.tsx +55 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/dropdown-menu.tsx +194 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/input.tsx +24 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/label.tsx +25 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/toast.tsx +127 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/toaster.tsx +35 -0
- package/templates/blog/pxlr-cms/packages/admin/src/components/ui/use-toast.ts +187 -0
- package/templates/blog/pxlr-cms/packages/admin/src/lib/api.ts +96 -0
- package/templates/blog/pxlr-cms/packages/admin/src/lib/i18n/context.tsx +60 -0
- package/templates/blog/pxlr-cms/packages/admin/src/lib/i18n/translations.ts +317 -0
- package/templates/blog/pxlr-cms/packages/admin/src/lib/store/auth.ts +51 -0
- package/templates/blog/pxlr-cms/packages/admin/src/lib/utils.ts +29 -0
- package/templates/blog/pxlr-cms/packages/admin/tailwind.config.ts +57 -0
- package/templates/blog/pxlr-cms/packages/admin/tsconfig.json +27 -0
- package/templates/blog/pxlr-cms/packages/api/.env.example +23 -0
- package/templates/blog/pxlr-cms/packages/api/Dockerfile +26 -0
- package/templates/blog/pxlr-cms/packages/api/package.json +42 -0
- package/templates/blog/pxlr-cms/packages/api/src/config.ts +39 -0
- package/templates/blog/pxlr-cms/packages/api/src/database/index.ts +60 -0
- package/templates/blog/pxlr-cms/packages/api/src/database/init.sql +258 -0
- package/templates/blog/pxlr-cms/packages/api/src/database/redis.ts +95 -0
- package/templates/blog/pxlr-cms/packages/api/src/database/seed.sql +78 -0
- package/templates/blog/pxlr-cms/packages/api/src/index.ts +157 -0
- package/templates/blog/pxlr-cms/packages/api/src/modules/auth/routes.ts +256 -0
- package/templates/blog/pxlr-cms/packages/api/src/modules/content/routes.ts +385 -0
- package/templates/blog/pxlr-cms/packages/api/src/modules/media/routes.ts +312 -0
- package/templates/blog/pxlr-cms/packages/api/src/modules/realtime/handler.ts +228 -0
- package/templates/blog/pxlr-cms/packages/api/src/modules/schema/routes.ts +284 -0
- package/templates/blog/pxlr-cms/packages/api/src/modules/versions/routes.ts +70 -0
- package/templates/blog/pxlr-cms/packages/api/tsconfig.json +24 -0
- package/templates/blog/pxlr-cms/packages/shared/package.json +14 -0
- package/templates/blog/pxlr-cms/packages/shared/src/types/index.ts +139 -0
- package/templates/blog/pxlr-cms/packages/shared/tsconfig.json +18 -0
- package/templates/clean/pxlr-cms/README.md +188 -0
- package/templates/clean/pxlr-cms/docker-compose.yml +132 -0
- package/templates/clean/pxlr-cms/nginx/nginx.conf +107 -0
- package/templates/clean/pxlr-cms/packages/admin/.dockerignore +4 -0
- package/templates/clean/pxlr-cms/packages/admin/.env.example +2 -0
- package/templates/clean/pxlr-cms/packages/admin/Dockerfile +19 -0
- package/templates/clean/pxlr-cms/packages/admin/next-env.d.ts +6 -0
- package/templates/clean/pxlr-cms/packages/admin/next.config.ts +22 -0
- package/templates/clean/pxlr-cms/packages/admin/package.json +63 -0
- package/templates/clean/pxlr-cms/packages/admin/pnpm-lock.yaml +5748 -0
- package/templates/clean/pxlr-cms/packages/admin/postcss.config.mjs +9 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/content/[id]/page.tsx +503 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/content/layout.tsx +7 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/content/new/page.tsx +424 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/content/page.tsx +191 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/globals.css +132 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/layout.tsx +25 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/login/page.tsx +119 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/media/layout.tsx +7 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/media/page.tsx +362 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/page.tsx +184 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/profile/layout.tsx +7 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/profile/page.tsx +206 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/schemas/[name]/page.tsx +312 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/schemas/layout.tsx +7 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/schemas/page.tsx +210 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/settings/layout.tsx +7 -0
- package/templates/clean/pxlr-cms/packages/admin/src/app/settings/page.tsx +178 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/editor/media-picker.tsx +202 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/editor/rich-text-editor.tsx +387 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/layout/auth-layout.tsx +43 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/layout/header.tsx +79 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/layout/sidebar.tsx +68 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/providers.tsx +29 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/schema-code-generator.tsx +326 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/avatar.tsx +49 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/button.tsx +55 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/dropdown-menu.tsx +194 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/input.tsx +24 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/label.tsx +25 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/toast.tsx +127 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/toaster.tsx +35 -0
- package/templates/clean/pxlr-cms/packages/admin/src/components/ui/use-toast.ts +187 -0
- package/templates/clean/pxlr-cms/packages/admin/src/lib/api.ts +96 -0
- package/templates/clean/pxlr-cms/packages/admin/src/lib/i18n/context.tsx +60 -0
- package/templates/clean/pxlr-cms/packages/admin/src/lib/i18n/translations.ts +317 -0
- package/templates/clean/pxlr-cms/packages/admin/src/lib/store/auth.ts +51 -0
- package/templates/clean/pxlr-cms/packages/admin/src/lib/utils.ts +29 -0
- package/templates/clean/pxlr-cms/packages/admin/tailwind.config.ts +57 -0
- package/templates/clean/pxlr-cms/packages/admin/tsconfig.json +27 -0
- package/templates/clean/pxlr-cms/packages/api/.env.example +23 -0
- package/templates/clean/pxlr-cms/packages/api/Dockerfile +26 -0
- package/templates/clean/pxlr-cms/packages/api/package.json +42 -0
- package/templates/clean/pxlr-cms/packages/api/src/config.ts +39 -0
- package/templates/clean/pxlr-cms/packages/api/src/database/index.ts +60 -0
- package/templates/clean/pxlr-cms/packages/api/src/database/init.sql +178 -0
- package/templates/clean/pxlr-cms/packages/api/src/database/redis.ts +95 -0
- package/templates/clean/pxlr-cms/packages/api/src/index.ts +157 -0
- package/templates/clean/pxlr-cms/packages/api/src/modules/auth/routes.ts +256 -0
- package/templates/clean/pxlr-cms/packages/api/src/modules/content/routes.ts +385 -0
- package/templates/clean/pxlr-cms/packages/api/src/modules/media/routes.ts +312 -0
- package/templates/clean/pxlr-cms/packages/api/src/modules/realtime/handler.ts +228 -0
- package/templates/clean/pxlr-cms/packages/api/src/modules/schema/routes.ts +284 -0
- package/templates/clean/pxlr-cms/packages/api/src/modules/versions/routes.ts +70 -0
- package/templates/clean/pxlr-cms/packages/api/tsconfig.json +24 -0
- package/templates/clean/pxlr-cms/packages/shared/package.json +14 -0
- package/templates/clean/pxlr-cms/packages/shared/src/types/index.ts +139 -0
- package/templates/clean/pxlr-cms/packages/shared/tsconfig.json +18 -0
|
@@ -0,0 +1,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
|
+
}
|