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.
- 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,132 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
@layer base {
|
|
6
|
+
:root {
|
|
7
|
+
--background: 0 0% 100%;
|
|
8
|
+
--foreground: 240 10% 3.9%;
|
|
9
|
+
--card: 0 0% 100%;
|
|
10
|
+
--card-foreground: 240 10% 3.9%;
|
|
11
|
+
--popover: 0 0% 100%;
|
|
12
|
+
--popover-foreground: 240 10% 3.9%;
|
|
13
|
+
--primary: 240 5.9% 10%;
|
|
14
|
+
--primary-foreground: 0 0% 98%;
|
|
15
|
+
--secondary: 240 4.8% 95.9%;
|
|
16
|
+
--secondary-foreground: 240 5.9% 10%;
|
|
17
|
+
--muted: 240 4.8% 95.9%;
|
|
18
|
+
--muted-foreground: 240 3.8% 46.1%;
|
|
19
|
+
--accent: 240 4.8% 95.9%;
|
|
20
|
+
--accent-foreground: 240 5.9% 10%;
|
|
21
|
+
--destructive: 0 84.2% 60.2%;
|
|
22
|
+
--destructive-foreground: 0 0% 98%;
|
|
23
|
+
--border: 240 5.9% 90%;
|
|
24
|
+
--input: 240 5.9% 90%;
|
|
25
|
+
--ring: 240 5.9% 10%;
|
|
26
|
+
--radius: 0.5rem;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.dark {
|
|
30
|
+
--background: 240 10% 3.9%;
|
|
31
|
+
--foreground: 0 0% 98%;
|
|
32
|
+
--card: 240 10% 3.9%;
|
|
33
|
+
--card-foreground: 0 0% 98%;
|
|
34
|
+
--popover: 240 10% 3.9%;
|
|
35
|
+
--popover-foreground: 0 0% 98%;
|
|
36
|
+
--primary: 0 0% 98%;
|
|
37
|
+
--primary-foreground: 240 5.9% 10%;
|
|
38
|
+
--secondary: 240 3.7% 15.9%;
|
|
39
|
+
--secondary-foreground: 0 0% 98%;
|
|
40
|
+
--muted: 240 3.7% 15.9%;
|
|
41
|
+
--muted-foreground: 240 5% 64.9%;
|
|
42
|
+
--accent: 240 3.7% 15.9%;
|
|
43
|
+
--accent-foreground: 0 0% 98%;
|
|
44
|
+
--destructive: 0 62.8% 30.6%;
|
|
45
|
+
--destructive-foreground: 0 0% 98%;
|
|
46
|
+
--border: 240 3.7% 15.9%;
|
|
47
|
+
--input: 240 3.7% 15.9%;
|
|
48
|
+
--ring: 240 4.9% 83.9%;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@layer base {
|
|
53
|
+
* {
|
|
54
|
+
@apply border-border;
|
|
55
|
+
}
|
|
56
|
+
body {
|
|
57
|
+
@apply bg-background text-foreground;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* Custom scrollbar */
|
|
62
|
+
::-webkit-scrollbar {
|
|
63
|
+
width: 8px;
|
|
64
|
+
height: 8px;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
::-webkit-scrollbar-track {
|
|
68
|
+
background: hsl(var(--muted));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
::-webkit-scrollbar-thumb {
|
|
72
|
+
background: hsl(var(--muted-foreground) / 0.3);
|
|
73
|
+
border-radius: 4px;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
::-webkit-scrollbar-thumb:hover {
|
|
77
|
+
background: hsl(var(--muted-foreground) / 0.5);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* TipTap editor styles */
|
|
81
|
+
.tiptap {
|
|
82
|
+
@apply outline-none min-h-[200px] p-4;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.tiptap p.is-editor-empty:first-child::before {
|
|
86
|
+
@apply text-muted-foreground pointer-events-none float-left h-0;
|
|
87
|
+
content: attr(data-placeholder);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.tiptap h1 {
|
|
91
|
+
@apply text-3xl font-bold mt-6 mb-4;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.tiptap h2 {
|
|
95
|
+
@apply text-2xl font-bold mt-5 mb-3;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.tiptap h3 {
|
|
99
|
+
@apply text-xl font-bold mt-4 mb-2;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.tiptap p {
|
|
103
|
+
@apply my-2;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.tiptap ul {
|
|
107
|
+
@apply list-disc pl-6 my-2;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.tiptap ol {
|
|
111
|
+
@apply list-decimal pl-6 my-2;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.tiptap blockquote {
|
|
115
|
+
@apply border-l-4 border-muted-foreground/30 pl-4 italic my-4;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.tiptap code {
|
|
119
|
+
@apply bg-muted px-1.5 py-0.5 rounded text-sm;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.tiptap pre {
|
|
123
|
+
@apply bg-muted p-4 rounded-lg overflow-x-auto my-4;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.tiptap img {
|
|
127
|
+
@apply max-w-full h-auto rounded-lg my-4;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.tiptap a {
|
|
131
|
+
@apply text-primary underline underline-offset-4;
|
|
132
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { Inter } from 'next/font/google';
|
|
3
|
+
import './globals.css';
|
|
4
|
+
import { Providers } from '@/components/providers';
|
|
5
|
+
|
|
6
|
+
const inter = Inter({ subsets: ['latin', 'cyrillic'] });
|
|
7
|
+
|
|
8
|
+
export const metadata: Metadata = {
|
|
9
|
+
title: 'PXLR CMS',
|
|
10
|
+
description: 'Self-hosted Headless CMS',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default function RootLayout({
|
|
14
|
+
children,
|
|
15
|
+
}: {
|
|
16
|
+
children: React.ReactNode;
|
|
17
|
+
}) {
|
|
18
|
+
return (
|
|
19
|
+
<html lang="en" suppressHydrationWarning>
|
|
20
|
+
<body className={inter.className}>
|
|
21
|
+
<Providers>{children}</Providers>
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { useForm } from 'react-hook-form';
|
|
6
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { useAuthStore } from '@/lib/store/auth';
|
|
9
|
+
import { api } from '@/lib/api';
|
|
10
|
+
import { Button } from '@/components/ui/button';
|
|
11
|
+
import { Input } from '@/components/ui/input';
|
|
12
|
+
import { Label } from '@/components/ui/label';
|
|
13
|
+
import { Loader2, Box } from 'lucide-react';
|
|
14
|
+
|
|
15
|
+
const loginSchema = z.object({
|
|
16
|
+
email: z.string().email('Invalid email address'),
|
|
17
|
+
password: z.string().min(6, 'Password must be at least 6 characters'),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
type LoginForm = z.infer<typeof loginSchema>;
|
|
21
|
+
|
|
22
|
+
export default function LoginPage() {
|
|
23
|
+
const router = useRouter();
|
|
24
|
+
const { setAuth } = useAuthStore();
|
|
25
|
+
const [error, setError] = useState<string | null>(null);
|
|
26
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
27
|
+
|
|
28
|
+
const {
|
|
29
|
+
register,
|
|
30
|
+
handleSubmit,
|
|
31
|
+
formState: { errors },
|
|
32
|
+
} = useForm<LoginForm>({
|
|
33
|
+
resolver: zodResolver(loginSchema),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const onSubmit = async (data: LoginForm) => {
|
|
37
|
+
setIsLoading(true);
|
|
38
|
+
setError(null);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const response = await api.post('/auth/login', data);
|
|
42
|
+
setAuth(response.token, response.user);
|
|
43
|
+
router.push('/dashboard');
|
|
44
|
+
} catch (err: any) {
|
|
45
|
+
setError(err.message || 'Failed to login');
|
|
46
|
+
} finally {
|
|
47
|
+
setIsLoading(false);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background to-muted">
|
|
53
|
+
<div className="w-full max-w-md space-y-8 p-8">
|
|
54
|
+
<div className="text-center">
|
|
55
|
+
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
|
|
56
|
+
<Box className="h-8 w-8" />
|
|
57
|
+
</div>
|
|
58
|
+
<h1 className="mt-6 text-3xl font-bold tracking-tight">PXLR CMS</h1>
|
|
59
|
+
<p className="mt-2 text-muted-foreground">
|
|
60
|
+
Sign in to your admin panel
|
|
61
|
+
</p>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<form onSubmit={handleSubmit(onSubmit)} className="mt-8 space-y-6">
|
|
65
|
+
<div className="space-y-4 rounded-lg border bg-card p-6 shadow-sm">
|
|
66
|
+
<div className="space-y-2">
|
|
67
|
+
<Label htmlFor="email">Email</Label>
|
|
68
|
+
<Input
|
|
69
|
+
id="email"
|
|
70
|
+
type="email"
|
|
71
|
+
placeholder="admin@pxlr.local"
|
|
72
|
+
{...register('email')}
|
|
73
|
+
className={errors.email ? 'border-destructive' : ''}
|
|
74
|
+
/>
|
|
75
|
+
{errors.email && (
|
|
76
|
+
<p className="text-sm text-destructive">{errors.email.message}</p>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div className="space-y-2">
|
|
81
|
+
<Label htmlFor="password">Password</Label>
|
|
82
|
+
<Input
|
|
83
|
+
id="password"
|
|
84
|
+
type="password"
|
|
85
|
+
placeholder="••••••••"
|
|
86
|
+
{...register('password')}
|
|
87
|
+
className={errors.password ? 'border-destructive' : ''}
|
|
88
|
+
/>
|
|
89
|
+
{errors.password && (
|
|
90
|
+
<p className="text-sm text-destructive">{errors.password.message}</p>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{error && (
|
|
95
|
+
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
|
96
|
+
{error}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
101
|
+
{isLoading ? (
|
|
102
|
+
<>
|
|
103
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
104
|
+
Signing in...
|
|
105
|
+
</>
|
|
106
|
+
) : (
|
|
107
|
+
'Sign in'
|
|
108
|
+
)}
|
|
109
|
+
</Button>
|
|
110
|
+
</div>
|
|
111
|
+
</form>
|
|
112
|
+
|
|
113
|
+
<p className="text-center text-sm text-muted-foreground">
|
|
114
|
+
Default credentials: admin@pxlr.local / admin123
|
|
115
|
+
</p>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback } from 'react';
|
|
4
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
5
|
+
import { useDropzone } from 'react-dropzone';
|
|
6
|
+
import { api } from '@/lib/api';
|
|
7
|
+
import { formatDate, formatBytes } from '@/lib/utils';
|
|
8
|
+
import { useI18n } from '@/lib/i18n/context';
|
|
9
|
+
import { Button } from '@/components/ui/button';
|
|
10
|
+
import { Input } from '@/components/ui/input';
|
|
11
|
+
import { toast } from '@/components/ui/use-toast';
|
|
12
|
+
import {
|
|
13
|
+
Upload,
|
|
14
|
+
Images,
|
|
15
|
+
Trash2,
|
|
16
|
+
Loader2,
|
|
17
|
+
Search,
|
|
18
|
+
Grid,
|
|
19
|
+
List,
|
|
20
|
+
Copy,
|
|
21
|
+
Check,
|
|
22
|
+
X,
|
|
23
|
+
Code,
|
|
24
|
+
ExternalLink
|
|
25
|
+
} from 'lucide-react';
|
|
26
|
+
|
|
27
|
+
interface MediaFile {
|
|
28
|
+
id: string;
|
|
29
|
+
filename: string;
|
|
30
|
+
original_filename: string;
|
|
31
|
+
mime_type: string;
|
|
32
|
+
size_bytes: number;
|
|
33
|
+
width?: number;
|
|
34
|
+
height?: number;
|
|
35
|
+
url: string;
|
|
36
|
+
thumbnail_url?: string;
|
|
37
|
+
alt_text?: string;
|
|
38
|
+
created_at: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Fix URL for browser access (convert internal Docker URLs to external)
|
|
42
|
+
function getPublicUrl(url: string): string {
|
|
43
|
+
if (!url) return '';
|
|
44
|
+
// Replace internal Docker minio URL with external
|
|
45
|
+
return url
|
|
46
|
+
.replace('http://minio:9000', 'http://localhost:9010')
|
|
47
|
+
.replace('http://localhost:9000', 'http://localhost:9010');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default function MediaPage() {
|
|
51
|
+
const queryClient = useQueryClient();
|
|
52
|
+
const { t, locale } = useI18n();
|
|
53
|
+
const [search, setSearch] = useState('');
|
|
54
|
+
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
|
55
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
56
|
+
const [copiedId, setCopiedId] = useState<string | null>(null);
|
|
57
|
+
const [selectedFile, setSelectedFile] = useState<MediaFile | null>(null);
|
|
58
|
+
|
|
59
|
+
const { data, isLoading } = useQuery({
|
|
60
|
+
queryKey: ['media', search],
|
|
61
|
+
queryFn: () =>
|
|
62
|
+
api.get(`/media?${search ? `search=${search}&` : ''}limit=50`),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const deleteMutation = useMutation({
|
|
66
|
+
mutationFn: (id: string) => api.delete(`/media/${id}`),
|
|
67
|
+
onSuccess: () => {
|
|
68
|
+
queryClient.invalidateQueries({ queryKey: ['media'] });
|
|
69
|
+
toast({ title: locale === 'ru' ? 'Файл удалён' : 'File deleted successfully' });
|
|
70
|
+
setSelectedFile(null);
|
|
71
|
+
},
|
|
72
|
+
onError: (error: any) => {
|
|
73
|
+
toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
|
78
|
+
setIsUploading(true);
|
|
79
|
+
try {
|
|
80
|
+
for (const file of acceptedFiles) {
|
|
81
|
+
await api.upload('/media/upload', file);
|
|
82
|
+
}
|
|
83
|
+
queryClient.invalidateQueries({ queryKey: ['media'] });
|
|
84
|
+
toast({
|
|
85
|
+
title: locale === 'ru'
|
|
86
|
+
? `${acceptedFiles.length} файл(ов) загружено`
|
|
87
|
+
: `${acceptedFiles.length} file(s) uploaded successfully`
|
|
88
|
+
});
|
|
89
|
+
} catch (error: any) {
|
|
90
|
+
toast({ title: locale === 'ru' ? 'Ошибка загрузки' : 'Upload failed', description: error.message, variant: 'destructive' });
|
|
91
|
+
} finally {
|
|
92
|
+
setIsUploading(false);
|
|
93
|
+
}
|
|
94
|
+
}, [queryClient, locale]);
|
|
95
|
+
|
|
96
|
+
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
97
|
+
onDrop,
|
|
98
|
+
accept: {
|
|
99
|
+
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'],
|
|
100
|
+
'video/*': ['.mp4', '.webm'],
|
|
101
|
+
'application/pdf': ['.pdf'],
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const copyUrl = (url: string, id: string) => {
|
|
106
|
+
const publicUrl = getPublicUrl(url);
|
|
107
|
+
navigator.clipboard.writeText(publicUrl);
|
|
108
|
+
setCopiedId(id);
|
|
109
|
+
toast({ title: locale === 'ru' ? 'URL скопирован!' : 'URL copied!' });
|
|
110
|
+
setTimeout(() => setCopiedId(null), 2000);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const files: MediaFile[] = data?.files || [];
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="space-y-6">
|
|
117
|
+
<div className="flex items-center justify-between">
|
|
118
|
+
<div>
|
|
119
|
+
<h1 className="text-2xl font-bold tracking-tight">{t('media.title')}</h1>
|
|
120
|
+
<p className="text-muted-foreground">
|
|
121
|
+
{t('media.subtitle')}
|
|
122
|
+
</p>
|
|
123
|
+
</div>
|
|
124
|
+
<div className="flex gap-2">
|
|
125
|
+
<Button
|
|
126
|
+
variant={viewMode === 'grid' ? 'default' : 'outline'}
|
|
127
|
+
size="icon"
|
|
128
|
+
onClick={() => setViewMode('grid')}
|
|
129
|
+
>
|
|
130
|
+
<Grid className="h-4 w-4" />
|
|
131
|
+
</Button>
|
|
132
|
+
<Button
|
|
133
|
+
variant={viewMode === 'list' ? 'default' : 'outline'}
|
|
134
|
+
size="icon"
|
|
135
|
+
onClick={() => setViewMode('list')}
|
|
136
|
+
>
|
|
137
|
+
<List className="h-4 w-4" />
|
|
138
|
+
</Button>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<div
|
|
143
|
+
{...getRootProps()}
|
|
144
|
+
className={`flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors ${
|
|
145
|
+
isDragActive ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'
|
|
146
|
+
}`}
|
|
147
|
+
>
|
|
148
|
+
<input {...getInputProps()} />
|
|
149
|
+
{isUploading ? (
|
|
150
|
+
<Loader2 className="h-10 w-10 animate-spin text-muted-foreground" />
|
|
151
|
+
) : (
|
|
152
|
+
<Upload className="h-10 w-10 text-muted-foreground" />
|
|
153
|
+
)}
|
|
154
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
155
|
+
{isDragActive
|
|
156
|
+
? (locale === 'ru' ? 'Отпустите файлы...' : 'Drop files here...')
|
|
157
|
+
: t('media.dragDrop')}
|
|
158
|
+
</p>
|
|
159
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
160
|
+
{locale === 'ru' ? 'Изображения, видео и PDF до 100MB' : 'Images, videos, and PDFs up to 100MB'}
|
|
161
|
+
</p>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<div className="relative">
|
|
165
|
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
166
|
+
<Input
|
|
167
|
+
placeholder={locale === 'ru' ? 'Поиск файлов...' : 'Search files...'}
|
|
168
|
+
value={search}
|
|
169
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
170
|
+
className="pl-10"
|
|
171
|
+
/>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
{isLoading ? (
|
|
175
|
+
<div className="flex items-center justify-center py-12">
|
|
176
|
+
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
177
|
+
</div>
|
|
178
|
+
) : files.length === 0 ? (
|
|
179
|
+
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
|
|
180
|
+
<Images className="h-12 w-12 text-muted-foreground" />
|
|
181
|
+
<h3 className="mt-4 text-lg font-semibold">{t('media.noFiles')}</h3>
|
|
182
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
183
|
+
{t('media.uploadFirst')}
|
|
184
|
+
</p>
|
|
185
|
+
</div>
|
|
186
|
+
) : (
|
|
187
|
+
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
|
188
|
+
{files.map((file) => (
|
|
189
|
+
<div
|
|
190
|
+
key={file.id}
|
|
191
|
+
className="group relative rounded-lg border bg-card overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary"
|
|
192
|
+
onClick={() => setSelectedFile(file)}
|
|
193
|
+
>
|
|
194
|
+
<div className="aspect-square bg-muted">
|
|
195
|
+
{file.mime_type.startsWith('image/') ? (
|
|
196
|
+
<img
|
|
197
|
+
src={getPublicUrl(file.thumbnail_url || file.url)}
|
|
198
|
+
alt={file.alt_text || file.original_filename}
|
|
199
|
+
className="h-full w-full object-cover"
|
|
200
|
+
onError={(e) => {
|
|
201
|
+
// Fallback if image fails to load
|
|
202
|
+
(e.target as HTMLImageElement).style.display = 'none';
|
|
203
|
+
}}
|
|
204
|
+
/>
|
|
205
|
+
) : (
|
|
206
|
+
<div className="flex h-full items-center justify-center">
|
|
207
|
+
<Images className="h-12 w-12 text-muted-foreground" />
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
<div className="p-2">
|
|
212
|
+
<p className="truncate text-sm font-medium">
|
|
213
|
+
{file.original_filename}
|
|
214
|
+
</p>
|
|
215
|
+
<p className="text-xs text-muted-foreground">
|
|
216
|
+
{formatBytes(file.size_bytes)}
|
|
217
|
+
</p>
|
|
218
|
+
</div>
|
|
219
|
+
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
|
220
|
+
<Button
|
|
221
|
+
variant="secondary"
|
|
222
|
+
size="icon"
|
|
223
|
+
className="h-8 w-8"
|
|
224
|
+
onClick={(e) => {
|
|
225
|
+
e.stopPropagation();
|
|
226
|
+
copyUrl(file.url, file.id);
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
{copiedId === file.id ? (
|
|
230
|
+
<Check className="h-4 w-4" />
|
|
231
|
+
) : (
|
|
232
|
+
<Copy className="h-4 w-4" />
|
|
233
|
+
)}
|
|
234
|
+
</Button>
|
|
235
|
+
<Button
|
|
236
|
+
variant="destructive"
|
|
237
|
+
size="icon"
|
|
238
|
+
className="h-8 w-8"
|
|
239
|
+
onClick={(e) => {
|
|
240
|
+
e.stopPropagation();
|
|
241
|
+
deleteMutation.mutate(file.id);
|
|
242
|
+
}}
|
|
243
|
+
>
|
|
244
|
+
<Trash2 className="h-4 w-4" />
|
|
245
|
+
</Button>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
))}
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
|
|
252
|
+
{/* Selected File Modal */}
|
|
253
|
+
{selectedFile && (
|
|
254
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setSelectedFile(null)}>
|
|
255
|
+
<div className="bg-card rounded-lg shadow-xl max-w-3xl w-full mx-4 max-h-[90vh] overflow-auto" onClick={(e) => e.stopPropagation()}>
|
|
256
|
+
<div className="flex items-center justify-between p-4 border-b">
|
|
257
|
+
<h2 className="text-lg font-semibold">{selectedFile.original_filename}</h2>
|
|
258
|
+
<Button variant="ghost" size="icon" onClick={() => setSelectedFile(null)}>
|
|
259
|
+
<X className="h-4 w-4" />
|
|
260
|
+
</Button>
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<div className="p-4 space-y-4">
|
|
264
|
+
{/* Preview */}
|
|
265
|
+
{selectedFile.mime_type.startsWith('image/') && (
|
|
266
|
+
<div className="bg-muted rounded-lg overflow-hidden">
|
|
267
|
+
<img
|
|
268
|
+
src={getPublicUrl(selectedFile.url)}
|
|
269
|
+
alt={selectedFile.alt_text || selectedFile.original_filename}
|
|
270
|
+
className="max-h-64 w-full object-contain"
|
|
271
|
+
/>
|
|
272
|
+
</div>
|
|
273
|
+
)}
|
|
274
|
+
|
|
275
|
+
{/* Info */}
|
|
276
|
+
<div className="grid gap-2 text-sm">
|
|
277
|
+
<div className="flex justify-between py-2 border-b">
|
|
278
|
+
<span className="text-muted-foreground">{locale === 'ru' ? 'Размер' : 'Size'}</span>
|
|
279
|
+
<span>{formatBytes(selectedFile.size_bytes)}</span>
|
|
280
|
+
</div>
|
|
281
|
+
<div className="flex justify-between py-2 border-b">
|
|
282
|
+
<span className="text-muted-foreground">{locale === 'ru' ? 'Тип' : 'Type'}</span>
|
|
283
|
+
<span>{selectedFile.mime_type}</span>
|
|
284
|
+
</div>
|
|
285
|
+
{selectedFile.width && selectedFile.height && (
|
|
286
|
+
<div className="flex justify-between py-2 border-b">
|
|
287
|
+
<span className="text-muted-foreground">{locale === 'ru' ? 'Размеры' : 'Dimensions'}</span>
|
|
288
|
+
<span>{selectedFile.width} x {selectedFile.height}</span>
|
|
289
|
+
</div>
|
|
290
|
+
)}
|
|
291
|
+
<div className="flex justify-between py-2 border-b">
|
|
292
|
+
<span className="text-muted-foreground">{locale === 'ru' ? 'Загружен' : 'Uploaded'}</span>
|
|
293
|
+
<span>{formatDate(selectedFile.created_at)}</span>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
{/* URL */}
|
|
298
|
+
<div className="space-y-2">
|
|
299
|
+
<label className="text-sm font-medium">URL</label>
|
|
300
|
+
<div className="flex gap-2">
|
|
301
|
+
<Input
|
|
302
|
+
value={getPublicUrl(selectedFile.url)}
|
|
303
|
+
readOnly
|
|
304
|
+
className="font-mono text-xs"
|
|
305
|
+
/>
|
|
306
|
+
<Button
|
|
307
|
+
variant="outline"
|
|
308
|
+
onClick={() => copyUrl(selectedFile.url, selectedFile.id)}
|
|
309
|
+
>
|
|
310
|
+
{copiedId === selectedFile.id ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
|
311
|
+
</Button>
|
|
312
|
+
<Button
|
|
313
|
+
variant="outline"
|
|
314
|
+
onClick={() => window.open(getPublicUrl(selectedFile.url), '_blank')}
|
|
315
|
+
>
|
|
316
|
+
<ExternalLink className="h-4 w-4" />
|
|
317
|
+
</Button>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
{/* Code Usage */}
|
|
322
|
+
<div className="rounded-lg border bg-muted/30 p-4">
|
|
323
|
+
<div className="flex items-center gap-2 mb-3">
|
|
324
|
+
<Code className="h-4 w-4" />
|
|
325
|
+
<span className="font-medium">
|
|
326
|
+
{locale === 'ru' ? 'Использование в Next.js' : 'Usage in Next.js'}
|
|
327
|
+
</span>
|
|
328
|
+
</div>
|
|
329
|
+
<pre className="text-xs bg-muted p-3 rounded overflow-x-auto">
|
|
330
|
+
{`// В компоненте React/Next.js
|
|
331
|
+
<Image
|
|
332
|
+
src="${getPublicUrl(selectedFile.url)}"
|
|
333
|
+
alt="${selectedFile.alt_text || selectedFile.original_filename}"
|
|
334
|
+
width={${selectedFile.width || 800}}
|
|
335
|
+
height={${selectedFile.height || 600}}
|
|
336
|
+
/>
|
|
337
|
+
|
|
338
|
+
// Или как background
|
|
339
|
+
<div style={{ backgroundImage: 'url(${getPublicUrl(selectedFile.url)})' }} />
|
|
340
|
+
|
|
341
|
+
// В inline style
|
|
342
|
+
style={{ backgroundImage: \`url(\${imageUrl})\` }}`}
|
|
343
|
+
</pre>
|
|
344
|
+
</div>
|
|
345
|
+
|
|
346
|
+
{/* Actions */}
|
|
347
|
+
<div className="flex gap-2 pt-4 border-t">
|
|
348
|
+
<Button
|
|
349
|
+
variant="destructive"
|
|
350
|
+
onClick={() => deleteMutation.mutate(selectedFile.id)}
|
|
351
|
+
>
|
|
352
|
+
<Trash2 className="mr-2 h-4 w-4" />
|
|
353
|
+
{locale === 'ru' ? 'Удалить' : 'Delete'}
|
|
354
|
+
</Button>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
)}
|
|
360
|
+
</div>
|
|
361
|
+
);
|
|
362
|
+
}
|