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.
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,387 @@
1
+ 'use client';
2
+
3
+ import { useEditor, EditorContent, BubbleMenu } from '@tiptap/react';
4
+ import StarterKit from '@tiptap/starter-kit';
5
+ import Image from '@tiptap/extension-image';
6
+ import Link from '@tiptap/extension-link';
7
+ import Placeholder from '@tiptap/extension-placeholder';
8
+ import TextAlign from '@tiptap/extension-text-align';
9
+ import Underline from '@tiptap/extension-underline';
10
+ import Highlight from '@tiptap/extension-highlight';
11
+ import { useState, useCallback } from 'react';
12
+ import {
13
+ Bold,
14
+ Italic,
15
+ Underline as UnderlineIcon,
16
+ Strikethrough,
17
+ Code,
18
+ Heading1,
19
+ Heading2,
20
+ Heading3,
21
+ List,
22
+ ListOrdered,
23
+ Quote,
24
+ Undo,
25
+ Redo,
26
+ Link as LinkIcon,
27
+ Image as ImageIcon,
28
+ AlignLeft,
29
+ AlignCenter,
30
+ AlignRight,
31
+ Highlighter,
32
+ Minus,
33
+ } from 'lucide-react';
34
+ import { Button } from '@/components/ui/button';
35
+ import { cn } from '@/lib/utils';
36
+
37
+ interface RichTextEditorProps {
38
+ value: string;
39
+ onChange: (value: string) => void;
40
+ placeholder?: string;
41
+ onImageUpload?: () => void;
42
+ }
43
+
44
+ export function RichTextEditor({
45
+ value,
46
+ onChange,
47
+ placeholder = 'Начните писать...',
48
+ onImageUpload
49
+ }: RichTextEditorProps) {
50
+ const [isLinkMenuOpen, setIsLinkMenuOpen] = useState(false);
51
+ const [linkUrl, setLinkUrl] = useState('');
52
+
53
+ const editor = useEditor({
54
+ extensions: [
55
+ StarterKit.configure({
56
+ heading: {
57
+ levels: [1, 2, 3],
58
+ },
59
+ }),
60
+ Image.configure({
61
+ HTMLAttributes: {
62
+ class: 'rounded-lg max-w-full h-auto',
63
+ },
64
+ }),
65
+ Link.configure({
66
+ openOnClick: false,
67
+ HTMLAttributes: {
68
+ class: 'text-blue-600 underline cursor-pointer',
69
+ },
70
+ }),
71
+ Placeholder.configure({
72
+ placeholder,
73
+ }),
74
+ TextAlign.configure({
75
+ types: ['heading', 'paragraph'],
76
+ }),
77
+ Underline,
78
+ Highlight.configure({
79
+ multicolor: true,
80
+ }),
81
+ ],
82
+ content: value,
83
+ onUpdate: ({ editor }) => {
84
+ onChange(editor.getHTML());
85
+ },
86
+ editorProps: {
87
+ attributes: {
88
+ class: 'prose prose-sm sm:prose-base max-w-none focus:outline-none min-h-[200px] px-4 py-3',
89
+ },
90
+ },
91
+ });
92
+
93
+ const addImage = useCallback((url: string) => {
94
+ if (editor && url) {
95
+ editor.chain().focus().setImage({ src: url }).run();
96
+ }
97
+ }, [editor]);
98
+
99
+ const setLink = useCallback(() => {
100
+ if (!editor) return;
101
+
102
+ if (linkUrl) {
103
+ editor.chain().focus().setLink({ href: linkUrl }).run();
104
+ } else {
105
+ editor.chain().focus().unsetLink().run();
106
+ }
107
+ setIsLinkMenuOpen(false);
108
+ setLinkUrl('');
109
+ }, [editor, linkUrl]);
110
+
111
+ if (!editor) {
112
+ return null;
113
+ }
114
+
115
+ const ToolbarButton = ({
116
+ onClick,
117
+ isActive,
118
+ children,
119
+ title
120
+ }: {
121
+ onClick: () => void;
122
+ isActive?: boolean;
123
+ children: React.ReactNode;
124
+ title?: string;
125
+ }) => (
126
+ <button
127
+ type="button"
128
+ onClick={onClick}
129
+ title={title}
130
+ className={cn(
131
+ 'p-2 rounded hover:bg-gray-100 transition-colors',
132
+ isActive && 'bg-gray-200 text-blue-600'
133
+ )}
134
+ >
135
+ {children}
136
+ </button>
137
+ );
138
+
139
+ return (
140
+ <div className="border rounded-lg overflow-hidden bg-white">
141
+ {/* Toolbar */}
142
+ <div className="border-b bg-gray-50 p-2 flex flex-wrap gap-1">
143
+ {/* Text formatting */}
144
+ <div className="flex items-center gap-0.5 border-r pr-2 mr-2">
145
+ <ToolbarButton
146
+ onClick={() => editor.chain().focus().toggleBold().run()}
147
+ isActive={editor.isActive('bold')}
148
+ title="Жирный"
149
+ >
150
+ <Bold className="h-4 w-4" />
151
+ </ToolbarButton>
152
+ <ToolbarButton
153
+ onClick={() => editor.chain().focus().toggleItalic().run()}
154
+ isActive={editor.isActive('italic')}
155
+ title="Курсив"
156
+ >
157
+ <Italic className="h-4 w-4" />
158
+ </ToolbarButton>
159
+ <ToolbarButton
160
+ onClick={() => editor.chain().focus().toggleUnderline().run()}
161
+ isActive={editor.isActive('underline')}
162
+ title="Подчёркнутый"
163
+ >
164
+ <UnderlineIcon className="h-4 w-4" />
165
+ </ToolbarButton>
166
+ <ToolbarButton
167
+ onClick={() => editor.chain().focus().toggleStrike().run()}
168
+ isActive={editor.isActive('strike')}
169
+ title="Зачёркнутый"
170
+ >
171
+ <Strikethrough className="h-4 w-4" />
172
+ </ToolbarButton>
173
+ <ToolbarButton
174
+ onClick={() => editor.chain().focus().toggleHighlight().run()}
175
+ isActive={editor.isActive('highlight')}
176
+ title="Выделение"
177
+ >
178
+ <Highlighter className="h-4 w-4" />
179
+ </ToolbarButton>
180
+ <ToolbarButton
181
+ onClick={() => editor.chain().focus().toggleCode().run()}
182
+ isActive={editor.isActive('code')}
183
+ title="Код"
184
+ >
185
+ <Code className="h-4 w-4" />
186
+ </ToolbarButton>
187
+ </div>
188
+
189
+ {/* Headings */}
190
+ <div className="flex items-center gap-0.5 border-r pr-2 mr-2">
191
+ <ToolbarButton
192
+ onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
193
+ isActive={editor.isActive('heading', { level: 1 })}
194
+ title="Заголовок 1"
195
+ >
196
+ <Heading1 className="h-4 w-4" />
197
+ </ToolbarButton>
198
+ <ToolbarButton
199
+ onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
200
+ isActive={editor.isActive('heading', { level: 2 })}
201
+ title="Заголовок 2"
202
+ >
203
+ <Heading2 className="h-4 w-4" />
204
+ </ToolbarButton>
205
+ <ToolbarButton
206
+ onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
207
+ isActive={editor.isActive('heading', { level: 3 })}
208
+ title="Заголовок 3"
209
+ >
210
+ <Heading3 className="h-4 w-4" />
211
+ </ToolbarButton>
212
+ </div>
213
+
214
+ {/* Lists */}
215
+ <div className="flex items-center gap-0.5 border-r pr-2 mr-2">
216
+ <ToolbarButton
217
+ onClick={() => editor.chain().focus().toggleBulletList().run()}
218
+ isActive={editor.isActive('bulletList')}
219
+ title="Маркированный список"
220
+ >
221
+ <List className="h-4 w-4" />
222
+ </ToolbarButton>
223
+ <ToolbarButton
224
+ onClick={() => editor.chain().focus().toggleOrderedList().run()}
225
+ isActive={editor.isActive('orderedList')}
226
+ title="Нумерованный список"
227
+ >
228
+ <ListOrdered className="h-4 w-4" />
229
+ </ToolbarButton>
230
+ <ToolbarButton
231
+ onClick={() => editor.chain().focus().toggleBlockquote().run()}
232
+ isActive={editor.isActive('blockquote')}
233
+ title="Цитата"
234
+ >
235
+ <Quote className="h-4 w-4" />
236
+ </ToolbarButton>
237
+ <ToolbarButton
238
+ onClick={() => editor.chain().focus().setHorizontalRule().run()}
239
+ title="Горизонтальная линия"
240
+ >
241
+ <Minus className="h-4 w-4" />
242
+ </ToolbarButton>
243
+ </div>
244
+
245
+ {/* Alignment */}
246
+ <div className="flex items-center gap-0.5 border-r pr-2 mr-2">
247
+ <ToolbarButton
248
+ onClick={() => editor.chain().focus().setTextAlign('left').run()}
249
+ isActive={editor.isActive({ textAlign: 'left' })}
250
+ title="По левому краю"
251
+ >
252
+ <AlignLeft className="h-4 w-4" />
253
+ </ToolbarButton>
254
+ <ToolbarButton
255
+ onClick={() => editor.chain().focus().setTextAlign('center').run()}
256
+ isActive={editor.isActive({ textAlign: 'center' })}
257
+ title="По центру"
258
+ >
259
+ <AlignCenter className="h-4 w-4" />
260
+ </ToolbarButton>
261
+ <ToolbarButton
262
+ onClick={() => editor.chain().focus().setTextAlign('right').run()}
263
+ isActive={editor.isActive({ textAlign: 'right' })}
264
+ title="По правому краю"
265
+ >
266
+ <AlignRight className="h-4 w-4" />
267
+ </ToolbarButton>
268
+ </div>
269
+
270
+ {/* Insert */}
271
+ <div className="flex items-center gap-0.5 border-r pr-2 mr-2">
272
+ <ToolbarButton
273
+ onClick={() => setIsLinkMenuOpen(!isLinkMenuOpen)}
274
+ isActive={editor.isActive('link')}
275
+ title="Ссылка"
276
+ >
277
+ <LinkIcon className="h-4 w-4" />
278
+ </ToolbarButton>
279
+ {onImageUpload && (
280
+ <ToolbarButton
281
+ onClick={onImageUpload}
282
+ title="Вставить изображение"
283
+ >
284
+ <ImageIcon className="h-4 w-4" />
285
+ </ToolbarButton>
286
+ )}
287
+ </div>
288
+
289
+ {/* Undo/Redo */}
290
+ <div className="flex items-center gap-0.5">
291
+ <ToolbarButton
292
+ onClick={() => editor.chain().focus().undo().run()}
293
+ title="Отменить"
294
+ >
295
+ <Undo className="h-4 w-4" />
296
+ </ToolbarButton>
297
+ <ToolbarButton
298
+ onClick={() => editor.chain().focus().redo().run()}
299
+ title="Повторить"
300
+ >
301
+ <Redo className="h-4 w-4" />
302
+ </ToolbarButton>
303
+ </div>
304
+ </div>
305
+
306
+ {/* Link input */}
307
+ {isLinkMenuOpen && (
308
+ <div className="border-b bg-gray-50 p-2 flex items-center gap-2">
309
+ <input
310
+ type="url"
311
+ value={linkUrl}
312
+ onChange={(e) => setLinkUrl(e.target.value)}
313
+ placeholder="https://example.com"
314
+ className="flex-1 px-3 py-1.5 text-sm border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
315
+ onKeyDown={(e) => e.key === 'Enter' && setLink()}
316
+ />
317
+ <Button size="sm" onClick={setLink}>
318
+ {editor.isActive('link') ? 'Обновить' : 'Добавить'}
319
+ </Button>
320
+ {editor.isActive('link') && (
321
+ <Button
322
+ size="sm"
323
+ variant="outline"
324
+ onClick={() => {
325
+ editor.chain().focus().unsetLink().run();
326
+ setIsLinkMenuOpen(false);
327
+ }}
328
+ >
329
+ Удалить
330
+ </Button>
331
+ )}
332
+ </div>
333
+ )}
334
+
335
+ {/* Editor content */}
336
+ <EditorContent editor={editor} />
337
+
338
+ {/* Bubble menu for selection */}
339
+ {editor && (
340
+ <BubbleMenu
341
+ editor={editor}
342
+ tippyOptions={{ duration: 100 }}
343
+ className="bg-gray-900 rounded-lg shadow-lg p-1 flex gap-0.5"
344
+ >
345
+ <button
346
+ onClick={() => editor.chain().focus().toggleBold().run()}
347
+ className={cn(
348
+ 'p-1.5 rounded text-white hover:bg-gray-700',
349
+ editor.isActive('bold') && 'bg-gray-700'
350
+ )}
351
+ >
352
+ <Bold className="h-4 w-4" />
353
+ </button>
354
+ <button
355
+ onClick={() => editor.chain().focus().toggleItalic().run()}
356
+ className={cn(
357
+ 'p-1.5 rounded text-white hover:bg-gray-700',
358
+ editor.isActive('italic') && 'bg-gray-700'
359
+ )}
360
+ >
361
+ <Italic className="h-4 w-4" />
362
+ </button>
363
+ <button
364
+ onClick={() => {
365
+ const url = editor.getAttributes('link').href;
366
+ setLinkUrl(url || '');
367
+ setIsLinkMenuOpen(true);
368
+ }}
369
+ className={cn(
370
+ 'p-1.5 rounded text-white hover:bg-gray-700',
371
+ editor.isActive('link') && 'bg-gray-700'
372
+ )}
373
+ >
374
+ <LinkIcon className="h-4 w-4" />
375
+ </button>
376
+ </BubbleMenu>
377
+ )}
378
+ </div>
379
+ );
380
+ }
381
+
382
+ // Export function to insert image into editor
383
+ export function insertImageToEditor(editor: any, url: string) {
384
+ if (editor) {
385
+ editor.chain().focus().setImage({ src: url }).run();
386
+ }
387
+ }
@@ -0,0 +1,43 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { useAuthStore } from '@/lib/store/auth';
6
+ import { Sidebar } from '@/components/layout/sidebar';
7
+ import { Header } from '@/components/layout/header';
8
+ import { Loader2 } from 'lucide-react';
9
+
10
+ export function AuthLayout({ children }: { children: React.ReactNode }) {
11
+ const router = useRouter();
12
+ const { isAuthenticated, isLoading } = useAuthStore();
13
+
14
+ useEffect(() => {
15
+ if (!isLoading && !isAuthenticated) {
16
+ router.replace('/login');
17
+ }
18
+ }, [isAuthenticated, isLoading, router]);
19
+
20
+ if (isLoading) {
21
+ return (
22
+ <div className="flex items-center justify-center min-h-screen">
23
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
24
+ </div>
25
+ );
26
+ }
27
+
28
+ if (!isAuthenticated) {
29
+ return null;
30
+ }
31
+
32
+ return (
33
+ <div className="flex h-screen overflow-hidden">
34
+ <Sidebar />
35
+ <div className="flex flex-1 flex-col overflow-hidden">
36
+ <Header />
37
+ <main className="flex-1 overflow-auto bg-muted/30 p-6">
38
+ {children}
39
+ </main>
40
+ </div>
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1,79 @@
1
+ 'use client';
2
+
3
+ import { useRouter } from 'next/navigation';
4
+ import { useAuthStore } from '@/lib/store/auth';
5
+ import { useI18n } from '@/lib/i18n/context';
6
+ import { Button } from '@/components/ui/button';
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuLabel,
12
+ DropdownMenuSeparator,
13
+ DropdownMenuTrigger,
14
+ } from '@/components/ui/dropdown-menu';
15
+ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
16
+ import { LogOut, User, Bell } from 'lucide-react';
17
+
18
+ export function Header() {
19
+ const router = useRouter();
20
+ const { user, logout } = useAuthStore();
21
+ const { t } = useI18n();
22
+
23
+ const handleLogout = () => {
24
+ logout();
25
+ router.push('/login');
26
+ };
27
+
28
+ const initials = user?.name
29
+ ? user.name
30
+ .split(' ')
31
+ .map((n) => n[0])
32
+ .join('')
33
+ .toUpperCase()
34
+ : 'U';
35
+
36
+ return (
37
+ <header className="flex h-16 items-center justify-between border-b bg-card px-6">
38
+ <div className="flex items-center gap-4">
39
+ <h2 className="text-lg font-medium">{t('header.contentManagement')}</h2>
40
+ </div>
41
+
42
+ <div className="flex items-center gap-4">
43
+ <Button variant="ghost" size="icon">
44
+ <Bell className="h-5 w-5" />
45
+ </Button>
46
+
47
+ <DropdownMenu>
48
+ <DropdownMenuTrigger asChild>
49
+ <Button variant="ghost" className="relative h-10 w-10 rounded-full">
50
+ <Avatar className="h-10 w-10">
51
+ <AvatarFallback>{initials}</AvatarFallback>
52
+ </Avatar>
53
+ </Button>
54
+ </DropdownMenuTrigger>
55
+ <DropdownMenuContent className="w-56" align="end" forceMount>
56
+ <DropdownMenuLabel className="font-normal">
57
+ <div className="flex flex-col space-y-1">
58
+ <p className="text-sm font-medium leading-none">{user?.name}</p>
59
+ <p className="text-xs leading-none text-muted-foreground">
60
+ {user?.email}
61
+ </p>
62
+ </div>
63
+ </DropdownMenuLabel>
64
+ <DropdownMenuSeparator />
65
+ <DropdownMenuItem onClick={() => router.push('/profile')}>
66
+ <User className="mr-2 h-4 w-4" />
67
+ <span>{t('header.profile')}</span>
68
+ </DropdownMenuItem>
69
+ <DropdownMenuSeparator />
70
+ <DropdownMenuItem onClick={handleLogout}>
71
+ <LogOut className="mr-2 h-4 w-4" />
72
+ <span>{t('header.logout')}</span>
73
+ </DropdownMenuItem>
74
+ </DropdownMenuContent>
75
+ </DropdownMenu>
76
+ </div>
77
+ </header>
78
+ );
79
+ }
@@ -0,0 +1,68 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { usePathname } from 'next/navigation';
5
+ import { cn } from '@/lib/utils';
6
+ import { useI18n } from '@/lib/i18n/context';
7
+ import {
8
+ LayoutDashboard,
9
+ FileText,
10
+ Database,
11
+ Images,
12
+ Settings,
13
+ Box,
14
+ } from 'lucide-react';
15
+ import type { TranslationKey } from '@/lib/i18n/translations';
16
+
17
+ const navigation: { key: TranslationKey; href: string; icon: any }[] = [
18
+ { key: 'nav.dashboard', href: '/', icon: LayoutDashboard },
19
+ { key: 'nav.content', href: '/content', icon: FileText },
20
+ { key: 'nav.schemas', href: '/schemas', icon: Database },
21
+ { key: 'nav.media', href: '/media', icon: Images },
22
+ { key: 'nav.settings', href: '/settings', icon: Settings },
23
+ ];
24
+
25
+ export function Sidebar() {
26
+ const pathname = usePathname();
27
+ const { t } = useI18n();
28
+
29
+ return (
30
+ <div className="flex h-full w-64 flex-col border-r bg-card">
31
+ <div className="flex h-16 items-center gap-2 border-b px-6">
32
+ <Box className="h-6 w-6 text-primary" />
33
+ <span className="text-lg font-semibold">PXLR CMS</span>
34
+ </div>
35
+
36
+ <nav className="flex-1 space-y-1 p-4">
37
+ {navigation.map((item) => {
38
+ const isActive = pathname === item.href ||
39
+ (item.href !== '/' && pathname.startsWith(`${item.href}/`));
40
+ return (
41
+ <Link
42
+ key={item.key}
43
+ href={item.href}
44
+ className={cn(
45
+ 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
46
+ isActive
47
+ ? 'bg-primary text-primary-foreground'
48
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground'
49
+ )}
50
+ >
51
+ <item.icon className="h-5 w-5" />
52
+ {t(item.key)}
53
+ </Link>
54
+ );
55
+ })}
56
+ </nav>
57
+
58
+ <div className="border-t p-4">
59
+ <div className="rounded-lg bg-muted p-4">
60
+ <p className="text-xs text-muted-foreground">Version 1.0.0</p>
61
+ <p className="mt-1 text-xs text-muted-foreground">
62
+ Self-hosted CMS
63
+ </p>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ );
68
+ }
@@ -0,0 +1,29 @@
1
+ 'use client';
2
+
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4
+ import { useState } from 'react';
5
+ import { Toaster } from '@/components/ui/toaster';
6
+ import { I18nProvider } from '@/lib/i18n/context';
7
+
8
+ export function Providers({ children }: { children: React.ReactNode }) {
9
+ const [queryClient] = useState(
10
+ () =>
11
+ new QueryClient({
12
+ defaultOptions: {
13
+ queries: {
14
+ staleTime: 60 * 1000, // 1 minute
15
+ refetchOnWindowFocus: false,
16
+ },
17
+ },
18
+ })
19
+ );
20
+
21
+ return (
22
+ <QueryClientProvider client={queryClient}>
23
+ <I18nProvider>
24
+ {children}
25
+ <Toaster />
26
+ </I18nProvider>
27
+ </QueryClientProvider>
28
+ );
29
+ }