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,178 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useAuthStore } from '@/lib/store/auth';
5
+ import { useI18n } from '@/lib/i18n/context';
6
+ import { Button } from '@/components/ui/button';
7
+ import { Input } from '@/components/ui/input';
8
+ import { Label } from '@/components/ui/label';
9
+ import { Settings, User, Globe, Database, Languages, Check } from 'lucide-react';
10
+
11
+ export default function SettingsPage() {
12
+ const { user } = useAuthStore();
13
+ const { t, locale, setLocale } = useI18n();
14
+ const [apiUrl] = useState(process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000');
15
+
16
+ return (
17
+ <div className="space-y-6">
18
+ <div>
19
+ <h1 className="text-2xl font-bold tracking-tight">{t('settings.title')}</h1>
20
+ <p className="text-muted-foreground">
21
+ {t('settings.subtitle')}
22
+ </p>
23
+ </div>
24
+
25
+ <div className="grid gap-6">
26
+ {/* Language Switcher */}
27
+ <div className="rounded-lg border bg-card p-6">
28
+ <div className="flex items-center gap-2 mb-4">
29
+ <Languages className="h-5 w-5" />
30
+ <h2 className="text-lg font-semibold">{t('settings.language')}</h2>
31
+ </div>
32
+ <div className="grid gap-2">
33
+ <button
34
+ onClick={() => setLocale('en')}
35
+ className={`flex items-center justify-between p-4 rounded-lg border-2 transition-colors ${
36
+ locale === 'en'
37
+ ? 'border-primary bg-primary/5'
38
+ : 'border-transparent bg-muted hover:border-muted-foreground/20'
39
+ }`}
40
+ >
41
+ <div className="flex items-center gap-3">
42
+ <span className="text-2xl">🇬🇧</span>
43
+ <div className="text-left">
44
+ <p className="font-medium">English</p>
45
+ <p className="text-xs text-muted-foreground">Interface in English</p>
46
+ </div>
47
+ </div>
48
+ {locale === 'en' && <Check className="h-5 w-5 text-primary" />}
49
+ </button>
50
+ <button
51
+ onClick={() => setLocale('ru')}
52
+ className={`flex items-center justify-between p-4 rounded-lg border-2 transition-colors ${
53
+ locale === 'ru'
54
+ ? 'border-primary bg-primary/5'
55
+ : 'border-transparent bg-muted hover:border-muted-foreground/20'
56
+ }`}
57
+ >
58
+ <div className="flex items-center gap-3">
59
+ <span className="text-2xl">🇷🇺</span>
60
+ <div className="text-left">
61
+ <p className="font-medium">Русский</p>
62
+ <p className="text-xs text-muted-foreground">Интерфейс на русском</p>
63
+ </div>
64
+ </div>
65
+ {locale === 'ru' && <Check className="h-5 w-5 text-primary" />}
66
+ </button>
67
+ </div>
68
+ </div>
69
+
70
+ {/* Profile Section */}
71
+ <div className="rounded-lg border bg-card p-6">
72
+ <div className="flex items-center gap-2 mb-4">
73
+ <User className="h-5 w-5" />
74
+ <h2 className="text-lg font-semibold">{t('header.profile')}</h2>
75
+ </div>
76
+ <div className="grid gap-4 md:grid-cols-2">
77
+ <div className="space-y-2">
78
+ <Label>{t('profile.displayName')}</Label>
79
+ <Input value={user?.name || ''} disabled />
80
+ </div>
81
+ <div className="space-y-2">
82
+ <Label>{t('profile.email')}</Label>
83
+ <Input value={user?.email || ''} disabled />
84
+ </div>
85
+ <div className="space-y-2">
86
+ <Label>Role</Label>
87
+ <Input value={user?.role || ''} disabled />
88
+ </div>
89
+ </div>
90
+ </div>
91
+
92
+ {/* API Configuration */}
93
+ <div className="rounded-lg border bg-card p-6">
94
+ <div className="flex items-center gap-2 mb-4">
95
+ <Database className="h-5 w-5" />
96
+ <h2 className="text-lg font-semibold">{t('dashboard.apiAccess')}</h2>
97
+ </div>
98
+ <div className="grid gap-4">
99
+ <div className="space-y-2">
100
+ <Label>{t('dashboard.apiEndpoint')}</Label>
101
+ <Input value={apiUrl} disabled />
102
+ </div>
103
+ <div className="space-y-2">
104
+ <Label>{t('dashboard.documentation')}</Label>
105
+ <a
106
+ href={`${apiUrl}/docs`}
107
+ target="_blank"
108
+ rel="noopener noreferrer"
109
+ className="text-sm text-primary hover:underline"
110
+ >
111
+ {t('dashboard.viewDocs')}
112
+ </a>
113
+ </div>
114
+ </div>
115
+ </div>
116
+
117
+ {/* Locales (Content Localization) */}
118
+ <div className="rounded-lg border bg-card p-6">
119
+ <div className="flex items-center gap-2 mb-4">
120
+ <Globe className="h-5 w-5" />
121
+ <h2 className="text-lg font-semibold">{t('settings.localization')}</h2>
122
+ </div>
123
+ <p className="text-sm text-muted-foreground mb-4">
124
+ {locale === 'ru'
125
+ ? 'Языки контента (не интерфейса)'
126
+ : 'Content languages (not interface)'}
127
+ </p>
128
+ <div className="grid gap-4">
129
+ <div className="flex items-center justify-between p-3 rounded-md bg-muted">
130
+ <div>
131
+ <p className="font-medium">English</p>
132
+ <p className="text-xs text-muted-foreground">en - {t('settings.defaultLocale')}</p>
133
+ </div>
134
+ <span className="text-xs bg-primary text-primary-foreground px-2 py-1 rounded">Default</span>
135
+ </div>
136
+ <div className="flex items-center justify-between p-3 rounded-md bg-muted">
137
+ <div>
138
+ <p className="font-medium">Русский</p>
139
+ <p className="text-xs text-muted-foreground">ru</p>
140
+ </div>
141
+ <span className="text-xs bg-secondary text-secondary-foreground px-2 py-1 rounded">Active</span>
142
+ </div>
143
+ </div>
144
+ </div>
145
+
146
+ {/* System Info */}
147
+ <div className="rounded-lg border bg-card p-6">
148
+ <div className="flex items-center gap-2 mb-4">
149
+ <Settings className="h-5 w-5" />
150
+ <h2 className="text-lg font-semibold">{t('settings.systemInfo')}</h2>
151
+ </div>
152
+ <div className="grid gap-2 text-sm">
153
+ <div className="flex justify-between py-2 border-b">
154
+ <span className="text-muted-foreground">{t('settings.version')}</span>
155
+ <span>1.0.0</span>
156
+ </div>
157
+ <div className="flex justify-between py-2 border-b">
158
+ <span className="text-muted-foreground">{t('settings.environment')}</span>
159
+ <span>Development</span>
160
+ </div>
161
+ <div className="flex justify-between py-2 border-b">
162
+ <span className="text-muted-foreground">Database</span>
163
+ <span>PostgreSQL</span>
164
+ </div>
165
+ <div className="flex justify-between py-2 border-b">
166
+ <span className="text-muted-foreground">Cache</span>
167
+ <span>Redis</span>
168
+ </div>
169
+ <div className="flex justify-between py-2">
170
+ <span className="text-muted-foreground">Storage</span>
171
+ <span>MinIO (S3)</span>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ </div>
177
+ );
178
+ }
@@ -0,0 +1,202 @@
1
+ 'use client';
2
+
3
+ import { useState, useCallback } from 'react';
4
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
5
+ import { useDropzone } from 'react-dropzone';
6
+ import { api } from '@/lib/api';
7
+ import { formatBytes } from '@/lib/utils';
8
+ import { Button } from '@/components/ui/button';
9
+ import { Input } from '@/components/ui/input';
10
+ import {
11
+ X,
12
+ Upload,
13
+ Search,
14
+ Loader2,
15
+ Images,
16
+ Check
17
+ } from 'lucide-react';
18
+
19
+ interface MediaFile {
20
+ id: string;
21
+ filename: string;
22
+ original_filename: string;
23
+ mime_type: string;
24
+ size_bytes: number;
25
+ url: string;
26
+ thumbnail_url?: string;
27
+ }
28
+
29
+ interface MediaPickerProps {
30
+ isOpen: boolean;
31
+ onClose: () => void;
32
+ onSelect: (url: string, file: MediaFile) => void;
33
+ allowMultiple?: boolean;
34
+ }
35
+
36
+ function getPublicUrl(url: string): string {
37
+ if (!url) return '';
38
+ return url
39
+ .replace('http://minio:9000', 'http://localhost:9010')
40
+ .replace('http://localhost:9000', 'http://localhost:9010');
41
+ }
42
+
43
+ export function MediaPicker({ isOpen, onClose, onSelect, allowMultiple = false }: MediaPickerProps) {
44
+ const queryClient = useQueryClient();
45
+ const [search, setSearch] = useState('');
46
+ const [isUploading, setIsUploading] = useState(false);
47
+ const [selectedFile, setSelectedFile] = useState<MediaFile | null>(null);
48
+
49
+ const { data, isLoading } = useQuery({
50
+ queryKey: ['media', search],
51
+ queryFn: () => api.get(`/media?${search ? `search=${search}&` : ''}limit=50`),
52
+ enabled: isOpen,
53
+ });
54
+
55
+ const onDrop = useCallback(async (acceptedFiles: File[]) => {
56
+ setIsUploading(true);
57
+ try {
58
+ for (const file of acceptedFiles) {
59
+ const result = await api.upload('/media/upload', file);
60
+ if (!allowMultiple && result.file) {
61
+ onSelect(getPublicUrl(result.file.url), result.file);
62
+ onClose();
63
+ return;
64
+ }
65
+ }
66
+ queryClient.invalidateQueries({ queryKey: ['media'] });
67
+ } catch (error) {
68
+ console.error('Upload failed:', error);
69
+ } finally {
70
+ setIsUploading(false);
71
+ }
72
+ }, [queryClient, allowMultiple, onSelect, onClose]);
73
+
74
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
75
+ onDrop,
76
+ accept: {
77
+ 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'],
78
+ },
79
+ });
80
+
81
+ const handleSelect = () => {
82
+ if (selectedFile) {
83
+ onSelect(getPublicUrl(selectedFile.url), selectedFile);
84
+ onClose();
85
+ }
86
+ };
87
+
88
+ const files: MediaFile[] = data?.files || [];
89
+
90
+ if (!isOpen) return null;
91
+
92
+ return (
93
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
94
+ <div
95
+ className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col"
96
+ onClick={(e) => e.stopPropagation()}
97
+ >
98
+ {/* Header */}
99
+ <div className="flex items-center justify-between p-4 border-b">
100
+ <h2 className="text-lg font-semibold">Выберите изображение</h2>
101
+ <Button variant="ghost" size="icon" onClick={onClose}>
102
+ <X className="h-4 w-4" />
103
+ </Button>
104
+ </div>
105
+
106
+ {/* Upload area */}
107
+ <div className="p-4 border-b">
108
+ <div
109
+ {...getRootProps()}
110
+ className={`flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors ${
111
+ isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'
112
+ }`}
113
+ >
114
+ <input {...getInputProps()} />
115
+ {isUploading ? (
116
+ <Loader2 className="h-8 w-8 animate-spin text-gray-400" />
117
+ ) : (
118
+ <Upload className="h-8 w-8 text-gray-400" />
119
+ )}
120
+ <p className="mt-2 text-sm text-gray-500">
121
+ {isDragActive
122
+ ? 'Отпустите для загрузки'
123
+ : 'Перетащите изображение или нажмите для выбора'}
124
+ </p>
125
+ </div>
126
+ </div>
127
+
128
+ {/* Search */}
129
+ <div className="p-4 border-b">
130
+ <div className="relative">
131
+ <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
132
+ <Input
133
+ placeholder="Поиск..."
134
+ value={search}
135
+ onChange={(e) => setSearch(e.target.value)}
136
+ className="pl-10"
137
+ />
138
+ </div>
139
+ </div>
140
+
141
+ {/* Media grid */}
142
+ <div className="flex-1 overflow-auto p-4">
143
+ {isLoading ? (
144
+ <div className="flex items-center justify-center py-12">
145
+ <Loader2 className="h-8 w-8 animate-spin text-gray-400" />
146
+ </div>
147
+ ) : files.length === 0 ? (
148
+ <div className="flex flex-col items-center justify-center py-12 text-gray-500">
149
+ <Images className="h-12 w-12 mb-2" />
150
+ <p>Нет изображений</p>
151
+ </div>
152
+ ) : (
153
+ <div className="grid grid-cols-4 gap-4">
154
+ {files.filter(f => f.mime_type.startsWith('image/')).map((file) => (
155
+ <button
156
+ key={file.id}
157
+ onClick={() => setSelectedFile(file)}
158
+ className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${
159
+ selectedFile?.id === file.id
160
+ ? 'border-blue-500 ring-2 ring-blue-200'
161
+ : 'border-transparent hover:border-gray-300'
162
+ }`}
163
+ >
164
+ <img
165
+ src={getPublicUrl(file.thumbnail_url || file.url)}
166
+ alt={file.original_filename}
167
+ className="w-full h-full object-cover"
168
+ />
169
+ {selectedFile?.id === file.id && (
170
+ <div className="absolute top-2 right-2 bg-blue-500 text-white rounded-full p-1">
171
+ <Check className="h-3 w-3" />
172
+ </div>
173
+ )}
174
+ <div className="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs p-1 truncate">
175
+ {file.original_filename}
176
+ </div>
177
+ </button>
178
+ ))}
179
+ </div>
180
+ )}
181
+ </div>
182
+
183
+ {/* Footer */}
184
+ <div className="flex items-center justify-between p-4 border-t bg-gray-50">
185
+ <div className="text-sm text-gray-500">
186
+ {selectedFile && (
187
+ <span>{selectedFile.original_filename} • {formatBytes(selectedFile.size_bytes)}</span>
188
+ )}
189
+ </div>
190
+ <div className="flex gap-2">
191
+ <Button variant="outline" onClick={onClose}>
192
+ Отмена
193
+ </Button>
194
+ <Button onClick={handleSelect} disabled={!selectedFile}>
195
+ Выбрать
196
+ </Button>
197
+ </div>
198
+ </div>
199
+ </div>
200
+ </div>
201
+ );
202
+ }