@djangocfg/layouts 1.4.28 → 1.4.29
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/package.json +8 -8
- package/src/auth/context/AuthContext.tsx +4 -1
- package/src/auth/hooks/index.ts +2 -0
- package/src/auth/hooks/useAuthForm.ts +2 -0
- package/src/auth/hooks/useAuthGuard.ts +2 -0
- package/src/auth/hooks/useAutoAuth.ts +16 -11
- package/src/auth/hooks/useLocalStorage.ts +2 -0
- package/src/auth/hooks/useProfileCache.ts +2 -0
- package/src/auth/hooks/useSessionStorage.ts +2 -0
- package/src/layouts/AppLayout/AppLayout.tsx +9 -7
- package/src/layouts/AppLayout/components/ErrorBoundary.tsx +6 -3
- package/src/layouts/AppLayout/components/PageProgress.tsx +2 -0
- package/src/layouts/AppLayout/components/Seo.tsx +2 -0
- package/src/layouts/AppLayout/components/UpdateNotifier/UpdateNotifier.tsx +3 -2
- package/src/layouts/AppLayout/hooks/index.ts +2 -0
- package/src/layouts/AppLayout/hooks/useNavigation.ts +3 -1
- package/src/layouts/AppLayout/layouts/AdminLayout/AdminLayout.tsx +1 -0
- package/src/layouts/AppLayout/layouts/AuthLayout/AuthContext.tsx +2 -0
- package/src/layouts/AppLayout/layouts/AuthLayout/IdentifierForm.tsx +2 -0
- package/src/layouts/AppLayout/layouts/AuthLayout/OTPForm.tsx +4 -0
- package/src/layouts/AppLayout/layouts/AuthLayout/index.ts +2 -0
- package/src/layouts/AppLayout/layouts/PrivateLayout/PrivateLayout.tsx +1 -0
- package/src/layouts/AppLayout/providers/CoreProviders.tsx +1 -0
- package/src/layouts/PaymentsLayout/PaymentsLayout.tsx +1 -0
- package/src/layouts/PaymentsLayout/components/CreatePaymentDialog.tsx +1 -0
- package/src/layouts/PaymentsLayout/events.ts +2 -0
- package/src/layouts/ProfileLayout/ProfileLayout.tsx +1 -0
- package/src/layouts/ProfileLayout/components/ProfileForm.tsx +1 -0
- package/src/layouts/SimpleLayout/SimpleLayout.tsx +72 -0
- package/src/layouts/SimpleLayout/index.ts +3 -0
- package/src/layouts/SupportLayout/SupportLayout.tsx +1 -0
- package/src/layouts/SupportLayout/components/CreateTicketDialog.tsx +1 -0
- package/src/layouts/SupportLayout/components/TicketList.tsx +6 -5
- package/src/layouts/SupportLayout/events.ts +2 -0
- package/src/layouts/index.ts +1 -0
- package/src/snippets/AuthDialog/useAuthDialog.ts +2 -0
- package/src/snippets/Chat/components/MessageList.tsx +12 -11
- package/src/snippets/Chat/index.tsx +1 -0
- package/src/snippets/ContactForm/ContactForm.tsx +7 -2
- package/src/snippets/ContactForm/ContactPage.tsx +9 -9
- package/src/snippets/VideoPlayer/README.md +35 -0
- package/src/snippets/VideoPlayer/VideoControls.tsx +13 -9
- package/src/snippets/VideoPlayer/VideoPlayer.tsx +159 -25
- package/src/snippets/VideoPlayer/index.ts +1 -1
- package/src/validation/utils/curl-generator.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.29",
|
|
4
4
|
"description": "Pre-built dashboard layouts, authentication pages, and admin templates for Next.js applications with Tailwind CSS",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -85,9 +85,9 @@
|
|
|
85
85
|
"check": "tsc --noEmit"
|
|
86
86
|
},
|
|
87
87
|
"peerDependencies": {
|
|
88
|
-
"@djangocfg/api": "^1.4.
|
|
89
|
-
"@djangocfg/og-image": "^1.4.
|
|
90
|
-
"@djangocfg/ui": "^1.4.
|
|
88
|
+
"@djangocfg/api": "^1.4.29",
|
|
89
|
+
"@djangocfg/og-image": "^1.4.29",
|
|
90
|
+
"@djangocfg/ui": "^1.4.29",
|
|
91
91
|
"@hookform/resolvers": "^5.2.0",
|
|
92
92
|
"consola": "^3.4.2",
|
|
93
93
|
"lucide-react": "^0.468.0",
|
|
@@ -103,13 +103,13 @@
|
|
|
103
103
|
"zod": "^4.0.10"
|
|
104
104
|
},
|
|
105
105
|
"dependencies": {
|
|
106
|
-
"@vidstack/react": "
|
|
107
|
-
"
|
|
106
|
+
"@vidstack/react": "next",
|
|
107
|
+
"media-icons": "next",
|
|
108
108
|
"react-ga4": "^2.1.0",
|
|
109
|
-
"vidstack": "
|
|
109
|
+
"vidstack": "next"
|
|
110
110
|
},
|
|
111
111
|
"devDependencies": {
|
|
112
|
-
"@djangocfg/typescript-config": "^1.4.
|
|
112
|
+
"@djangocfg/typescript-config": "^1.4.29",
|
|
113
113
|
"@types/node": "^24.7.2",
|
|
114
114
|
"@types/react": "19.2.2",
|
|
115
115
|
"@types/react-dom": "19.2.1",
|
package/src/auth/hooks/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
2
3
|
import { useEffect } from 'react';
|
|
4
|
+
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
3
5
|
import { authLogger } from '../../utils/logger';
|
|
4
6
|
|
|
5
7
|
export interface UseAutoAuthOptions {
|
|
@@ -13,12 +15,17 @@ export interface UseAutoAuthOptions {
|
|
|
13
15
|
*/
|
|
14
16
|
export const useAutoAuth = (options: UseAutoAuthOptions = {}) => {
|
|
15
17
|
const { onOTPDetected, cleanupUrl = true } = options;
|
|
18
|
+
const searchParams = useSearchParams();
|
|
19
|
+
const pathname = usePathname();
|
|
16
20
|
const router = useRouter();
|
|
17
21
|
|
|
22
|
+
const isReady = !!pathname && !!searchParams.get('otp');
|
|
23
|
+
const hasOTP = !!(searchParams.get('otp'));
|
|
24
|
+
|
|
18
25
|
useEffect(() => {
|
|
19
|
-
if (!
|
|
26
|
+
if (!isReady) return;
|
|
20
27
|
|
|
21
|
-
const queryOtp =
|
|
28
|
+
const queryOtp = searchParams.get('otp') as string;
|
|
22
29
|
|
|
23
30
|
// Handle OTP detection
|
|
24
31
|
if (queryOtp && typeof queryOtp === 'string' && queryOtp.length === 6) {
|
|
@@ -28,16 +35,14 @@ export const useAutoAuth = (options: UseAutoAuthOptions = {}) => {
|
|
|
28
35
|
|
|
29
36
|
// Clean up URL to remove sensitive params for security
|
|
30
37
|
if (cleanupUrl && queryOtp) {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
query: cleanQuery
|
|
35
|
-
}, undefined, { shallow: true });
|
|
38
|
+
const cleanQuery = Object.fromEntries(searchParams.entries());
|
|
39
|
+
delete cleanQuery.otp;
|
|
40
|
+
router.push(`${pathname}?${new URLSearchParams(cleanQuery).toString()}`);
|
|
36
41
|
}
|
|
37
|
-
}, [
|
|
42
|
+
}, [pathname, searchParams, onOTPDetected, cleanupUrl]);
|
|
38
43
|
|
|
39
44
|
return {
|
|
40
|
-
isReady
|
|
41
|
-
hasOTP
|
|
45
|
+
isReady,
|
|
46
|
+
hasOTP,
|
|
42
47
|
};
|
|
43
48
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
1
2
|
/**
|
|
2
3
|
* AppLayout - Unified Application Layout System
|
|
3
4
|
*
|
|
@@ -342,13 +343,14 @@ export function AppLayout({ children, config, component, pageProps, fontFamily,
|
|
|
342
343
|
// Wrap with ErrorBoundary if enabled
|
|
343
344
|
if (enableErrorBoundary) {
|
|
344
345
|
return (
|
|
345
|
-
<
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
346
|
+
<React.Fragment key={router.pathname}>
|
|
347
|
+
<ErrorBoundary
|
|
348
|
+
supportEmail={supportEmail}
|
|
349
|
+
onError={onError}
|
|
350
|
+
>
|
|
351
|
+
{content}
|
|
352
|
+
</ErrorBoundary>
|
|
353
|
+
</React.Fragment>
|
|
352
354
|
);
|
|
353
355
|
}
|
|
354
356
|
|
|
@@ -37,6 +37,9 @@ interface ErrorBoundaryState {
|
|
|
37
37
|
* Catches React errors and shows ErrorLayout
|
|
38
38
|
*/
|
|
39
39
|
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
40
|
+
declare state: ErrorBoundaryState;
|
|
41
|
+
declare props: Readonly<ErrorBoundaryProps>;
|
|
42
|
+
|
|
40
43
|
constructor(props: ErrorBoundaryProps) {
|
|
41
44
|
super(props);
|
|
42
45
|
this.state = { hasError: false };
|
|
@@ -62,12 +65,12 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|
|
62
65
|
// Call optional callback
|
|
63
66
|
this.props.onError?.(error, errorInfo);
|
|
64
67
|
|
|
65
|
-
// Store error info in state
|
|
66
|
-
|
|
68
|
+
// Store error info in state using base class method
|
|
69
|
+
Component.prototype.setState.call(this, { errorInfo });
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
resetError = () => {
|
|
70
|
-
|
|
73
|
+
Component.prototype.setState.call(this, { hasError: false, error: undefined, errorInfo: undefined });
|
|
71
74
|
};
|
|
72
75
|
|
|
73
76
|
render() {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
'use client';
|
|
10
10
|
|
|
11
11
|
import React, { useEffect, useState } from 'react';
|
|
12
|
+
import consola from 'consola';
|
|
12
13
|
import { toast } from '@djangocfg/ui/hooks';
|
|
13
14
|
|
|
14
15
|
const PACKAGE_NAME = '@djangocfg/layouts';
|
|
@@ -63,7 +64,7 @@ async function fetchLatestVersion(): Promise<string | null> {
|
|
|
63
64
|
const data = await response.json();
|
|
64
65
|
return data.version || null;
|
|
65
66
|
} catch (error) {
|
|
66
|
-
|
|
67
|
+
consola.warn('[UpdateNotifier] Failed to check for updates:', error);
|
|
67
68
|
return null;
|
|
68
69
|
}
|
|
69
70
|
}
|
|
@@ -94,7 +95,7 @@ function setCache(data: UpdateCheckCache): void {
|
|
|
94
95
|
try {
|
|
95
96
|
localStorage.setItem(CACHE_KEY, JSON.stringify(data));
|
|
96
97
|
} catch (error) {
|
|
97
|
-
|
|
98
|
+
consola.warn('[UpdateNotifier] Failed to cache update check:', error);
|
|
98
99
|
}
|
|
99
100
|
}
|
|
100
101
|
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
// @ts-nocheck
|
|
1
4
|
import React from 'react';
|
|
2
5
|
import { Mail, MessageCircle, ArrowLeft, RotateCw, ShieldCheck } from 'lucide-react';
|
|
3
6
|
|
|
@@ -70,6 +73,7 @@ export const OTPForm: React.FC = () => {
|
|
|
70
73
|
Enter verification code
|
|
71
74
|
</label>
|
|
72
75
|
<div className="flex justify-center">
|
|
76
|
+
{/* @ts-expect-error - TypeScript doesn't recognize children in JSX props for discriminated union */}
|
|
73
77
|
<InputOTP
|
|
74
78
|
value={otp}
|
|
75
79
|
onChange={setOtp}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/**
|
|
3
|
+
* SimpleLayout - Lightweight provider for docs and marketing sites
|
|
4
|
+
*
|
|
5
|
+
* Provides essential UI infrastructure without the overhead of full AppLayout:
|
|
6
|
+
* - TooltipProvider for all tooltip components
|
|
7
|
+
* - Toaster for notifications
|
|
8
|
+
* - Basic theme support
|
|
9
|
+
*
|
|
10
|
+
* Perfect for documentation sites, landing pages, and simple apps.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use client';
|
|
14
|
+
|
|
15
|
+
import React from 'react';
|
|
16
|
+
import { TooltipProvider, Toaster } from '@djangocfg/ui';
|
|
17
|
+
|
|
18
|
+
export interface SimpleLayoutProps {
|
|
19
|
+
children: React.ReactNode;
|
|
20
|
+
/**
|
|
21
|
+
* Delay before tooltips appear (in milliseconds)
|
|
22
|
+
* @default 200
|
|
23
|
+
*/
|
|
24
|
+
tooltipDelayDuration?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Skip delay when moving between tooltips
|
|
27
|
+
* @default 300
|
|
28
|
+
*/
|
|
29
|
+
tooltipSkipDelayDuration?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Lightweight layout provider for documentation and marketing sites.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```tsx
|
|
37
|
+
* // In your root layout.tsx
|
|
38
|
+
* import { SimpleLayout } from '@djangocfg/layouts';
|
|
39
|
+
*
|
|
40
|
+
* export default function RootLayout({ children }) {
|
|
41
|
+
* return (
|
|
42
|
+
* <html>
|
|
43
|
+
* <body>
|
|
44
|
+
* <SimpleLayout>
|
|
45
|
+
* {children}
|
|
46
|
+
* </SimpleLayout>
|
|
47
|
+
* </body>
|
|
48
|
+
* </html>
|
|
49
|
+
* );
|
|
50
|
+
* }
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function SimpleLayout({
|
|
54
|
+
children,
|
|
55
|
+
tooltipDelayDuration = 200,
|
|
56
|
+
tooltipSkipDelayDuration = 300,
|
|
57
|
+
}: SimpleLayoutProps) {
|
|
58
|
+
return (
|
|
59
|
+
<>
|
|
60
|
+
<TooltipProvider
|
|
61
|
+
delayDuration={tooltipDelayDuration}
|
|
62
|
+
skipDelayDuration={tooltipSkipDelayDuration}
|
|
63
|
+
>
|
|
64
|
+
{children}
|
|
65
|
+
</TooltipProvider>
|
|
66
|
+
<Toaster />
|
|
67
|
+
</>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
SimpleLayout.displayName = 'SimpleLayout';
|
|
72
|
+
|
|
@@ -73,11 +73,12 @@ export const TicketList: React.FC = () => {
|
|
|
73
73
|
return (
|
|
74
74
|
<div className="p-4 space-y-2">
|
|
75
75
|
{[1, 2, 3, 4, 5].map((i) => (
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
76
|
+
<div key={i}>
|
|
77
|
+
<Skeleton
|
|
78
|
+
className="h-24 w-full animate-pulse"
|
|
79
|
+
style={{ animationDelay: `${i * 100}ms` }}
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
81
82
|
))}
|
|
82
83
|
</div>
|
|
83
84
|
);
|
package/src/layouts/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from './SupportLayout';
|
|
|
8
8
|
export * from './PaymentsLayout';
|
|
9
9
|
export * from './AppLayout';
|
|
10
10
|
export * from './ErrorLayout';
|
|
11
|
+
export * from './SimpleLayout';
|
|
11
12
|
|
|
12
13
|
// Note: CfgLayout is now part of AppLayout exports
|
|
13
14
|
// Import it via: import { CfgLayout, useCfgApp } from '@djangocfg/layouts';
|
|
@@ -111,17 +111,18 @@ export const MessageList: React.FC<MessageListProps> = ({
|
|
|
111
111
|
message.sources.length > 0 && (
|
|
112
112
|
<div className="flex flex-wrap gap-2 px-1 animate-in fade-in slide-in-from-left-2 duration-300 delay-100">
|
|
113
113
|
{message.sources.map((source, idx) => (
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
114
|
+
<div key={idx}>
|
|
115
|
+
<Badge
|
|
116
|
+
variant="secondary"
|
|
117
|
+
className="text-xs flex items-center gap-1 cursor-pointer
|
|
118
|
+
hover:bg-secondary/80 hover:scale-105 active:scale-95
|
|
119
|
+
transition-all duration-200 animate-in fade-in zoom-in-95"
|
|
120
|
+
style={{ animationDelay: `${(idx + 1) * 100}ms` }}
|
|
121
|
+
>
|
|
122
|
+
{source.document_title || `Source ${idx + 1}`}
|
|
123
|
+
<ExternalLink className="h-3 w-3" />
|
|
124
|
+
</Badge>
|
|
125
|
+
</div>
|
|
125
126
|
))}
|
|
126
127
|
</div>
|
|
127
128
|
)}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
1
2
|
'use client';
|
|
2
3
|
|
|
3
4
|
import React, { useEffect, useState } from 'react';
|
|
@@ -131,12 +132,13 @@ function ContactFormInner({
|
|
|
131
132
|
// Apply draft from localStorage and set site_url
|
|
132
133
|
const currentValues = form.getValues();
|
|
133
134
|
const hasDraftData = draft.name || draft.email || draft.company || draft.subject || draft.message;
|
|
135
|
+
const currentSiteUrl = typeof window !== 'undefined' ? window.location.href : '';
|
|
134
136
|
|
|
135
137
|
if (hasDraftData || !currentValues.site_url) {
|
|
136
138
|
form.reset({
|
|
137
139
|
...emptyDraft,
|
|
138
140
|
...draft,
|
|
139
|
-
site_url:
|
|
141
|
+
site_url: currentSiteUrl,
|
|
140
142
|
});
|
|
141
143
|
}
|
|
142
144
|
}, [draft, form, isHydrated]);
|
|
@@ -165,14 +167,17 @@ function ContactFormInner({
|
|
|
165
167
|
const result = await submit(data);
|
|
166
168
|
if (resetOnSuccess) {
|
|
167
169
|
// Keep contact info (name, email, company), clear only message content
|
|
170
|
+
// Store current site_url before reset to avoid re-reading window.location
|
|
168
171
|
const currentValues = form.getValues();
|
|
172
|
+
const currentSiteUrl = currentValues.site_url || (typeof window !== 'undefined' ? window.location.href : '');
|
|
173
|
+
|
|
169
174
|
form.reset({
|
|
170
175
|
name: currentValues.name,
|
|
171
176
|
email: currentValues.email,
|
|
172
177
|
company: currentValues.company,
|
|
173
178
|
subject: '',
|
|
174
179
|
message: '',
|
|
175
|
-
site_url:
|
|
180
|
+
site_url: currentSiteUrl, // Keep the original site_url
|
|
176
181
|
});
|
|
177
182
|
// Update draft to keep contact info
|
|
178
183
|
setDraft({
|
|
@@ -15,7 +15,7 @@ const isDev = process.env.NODE_ENV === 'development';
|
|
|
15
15
|
const DEFAULT_CONFIG = {
|
|
16
16
|
apiUrl: isDev ? 'http://localhost:8000' : 'https://api.reforms.ai',
|
|
17
17
|
email: 'markolofsen@gmail.com',
|
|
18
|
-
|
|
18
|
+
whatsapp: '+62 813 39646301',
|
|
19
19
|
calendly: 'https://calendly.com/markolofsen/meeting',
|
|
20
20
|
};
|
|
21
21
|
|
|
@@ -28,8 +28,8 @@ export interface ContactPageProps {
|
|
|
28
28
|
apiUrl?: string;
|
|
29
29
|
/** Override email */
|
|
30
30
|
email?: string;
|
|
31
|
-
/** Override
|
|
32
|
-
|
|
31
|
+
/** Override whatsapp */
|
|
32
|
+
whatsapp?: string;
|
|
33
33
|
/** Override calendly link */
|
|
34
34
|
calendlyUrl?: string;
|
|
35
35
|
/** Page title */
|
|
@@ -64,7 +64,7 @@ export interface ContactPageProps {
|
|
|
64
64
|
export function ContactPageBase({
|
|
65
65
|
apiUrl = DEFAULT_CONFIG.apiUrl,
|
|
66
66
|
email = DEFAULT_CONFIG.email,
|
|
67
|
-
|
|
67
|
+
whatsapp = DEFAULT_CONFIG.whatsapp,
|
|
68
68
|
calendlyUrl = DEFAULT_CONFIG.calendly,
|
|
69
69
|
title = 'Get in Touch',
|
|
70
70
|
subtitle = "Have a question or want to work together? We'd love to hear from you.",
|
|
@@ -73,8 +73,8 @@ export function ContactPageBase({
|
|
|
73
73
|
className,
|
|
74
74
|
onSuccess,
|
|
75
75
|
}: ContactPageProps) {
|
|
76
|
-
// Format phone for
|
|
77
|
-
const
|
|
76
|
+
// Format phone for WhatsApp link (remove spaces and special chars)
|
|
77
|
+
const whatsappPhone = whatsapp.replace(/[\s\-\(\)]/g, '');
|
|
78
78
|
|
|
79
79
|
const contactDetails: ContactDetail[] = [
|
|
80
80
|
{
|
|
@@ -85,9 +85,9 @@ export function ContactPageBase({
|
|
|
85
85
|
},
|
|
86
86
|
{
|
|
87
87
|
icon: <MessageCircle className="h-5 w-5" />,
|
|
88
|
-
label: '
|
|
89
|
-
value:
|
|
90
|
-
href: `https://
|
|
88
|
+
label: 'WhatsApp',
|
|
89
|
+
value: whatsapp,
|
|
90
|
+
href: `https://wa.me/${whatsappPhone}`,
|
|
91
91
|
},
|
|
92
92
|
{
|
|
93
93
|
icon: <MapPin className="h-5 w-5" />,
|
|
@@ -79,6 +79,41 @@ function AdvancedPlayer() {
|
|
|
79
79
|
|
|
80
80
|
## Supported Video Sources
|
|
81
81
|
|
|
82
|
+
### YouTube
|
|
83
|
+
- **URL Format**: `https://www.youtube.com/watch?v=VIDEO_ID` or `youtube/VIDEO_ID`
|
|
84
|
+
- **Auto-conversion**: Full YouTube URLs are automatically converted to `youtube/ID` format
|
|
85
|
+
- **Poster**: ⚠️ YouTube iframe ignores custom poster images and always shows YouTube's thumbnail
|
|
86
|
+
- **Examples**:
|
|
87
|
+
```tsx
|
|
88
|
+
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
|
89
|
+
url: 'https://youtu.be/dQw4w9WgXcQ'
|
|
90
|
+
url: 'youtube/dQw4w9WgXcQ'
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Vimeo
|
|
94
|
+
- **URL Format**: `https://vimeo.com/VIDEO_ID` or `vimeo/VIDEO_ID`
|
|
95
|
+
- **Auto-conversion**: Full Vimeo URLs are automatically converted to `vimeo/ID` format
|
|
96
|
+
- **Poster**: ⚠️ Vimeo may ignore custom poster and use their own thumbnail
|
|
97
|
+
- **Example**: `url: 'vimeo/76979871'`
|
|
98
|
+
|
|
99
|
+
### Direct Video Files (MP4, WebM, OGG)
|
|
100
|
+
- **Poster**: ✅ **Works perfectly!** Custom poster images are fully supported
|
|
101
|
+
- **Examples**:
|
|
102
|
+
```tsx
|
|
103
|
+
url: 'https://example.com/video.mp4',
|
|
104
|
+
poster: '/images/video-poster.jpg' // This works!
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### HLS Streams
|
|
108
|
+
- **Poster**: ✅ Custom poster supported
|
|
109
|
+
- **Example**: `url: 'https://example.com/stream.m3u8'`
|
|
110
|
+
|
|
111
|
+
### DASH Streams
|
|
112
|
+
- **Poster**: ✅ Custom poster supported
|
|
113
|
+
- **Example**: `url: 'https://example.com/stream.mpd'`
|
|
114
|
+
|
|
115
|
+
> **Note**: The `poster` prop works for direct video files, HLS, and DASH streams. For YouTube and Vimeo, the platform's own thumbnail is displayed regardless of the `poster` prop due to iframe limitations.
|
|
116
|
+
|
|
82
117
|
### YouTube
|
|
83
118
|
```tsx
|
|
84
119
|
<VideoPlayer
|
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
import React from 'react';
|
|
8
8
|
import { useMediaStore, useMediaRemote } from '@vidstack/react';
|
|
9
|
-
import type {
|
|
9
|
+
import type { MediaPlayerInstance } from '@vidstack/react';
|
|
10
10
|
import { Play, Pause, Volume2, VolumeX, Maximize, Minimize } from 'lucide-react';
|
|
11
11
|
import { cn } from '@djangocfg/ui';
|
|
12
12
|
|
|
13
13
|
interface VideoControlsProps {
|
|
14
|
-
player: React.RefObject<
|
|
14
|
+
player: React.RefObject<MediaPlayerInstance | null>;
|
|
15
15
|
className?: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -55,13 +55,17 @@ export function VideoControls({ player, className }: VideoControlsProps) {
|
|
|
55
55
|
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
|
56
56
|
|
|
57
57
|
return (
|
|
58
|
-
<div
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
<div
|
|
59
|
+
className={cn(
|
|
60
|
+
"absolute inset-0 flex flex-col justify-end transition-opacity duration-300",
|
|
61
|
+
"bg-gradient-to-t from-black/80 via-black/20 to-transparent",
|
|
62
|
+
"opacity-0 group-hover:opacity-100 focus-within:opacity-100",
|
|
63
|
+
"pointer-events-none group-hover:pointer-events-auto",
|
|
64
|
+
className
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
63
67
|
{/* Progress Bar */}
|
|
64
|
-
<div className="px-4 pb-2">
|
|
68
|
+
<div className="px-4 pb-2 pointer-events-auto">
|
|
65
69
|
<div
|
|
66
70
|
className="h-1.5 bg-white/20 rounded-full cursor-pointer hover:h-2 transition-all group"
|
|
67
71
|
onClick={handleProgressClick}
|
|
@@ -76,7 +80,7 @@ export function VideoControls({ player, className }: VideoControlsProps) {
|
|
|
76
80
|
</div>
|
|
77
81
|
|
|
78
82
|
{/* Control Bar */}
|
|
79
|
-
<div className="flex items-center gap-4 px-4 pb-4">
|
|
83
|
+
<div className="flex items-center gap-4 px-4 pb-4 pointer-events-auto">
|
|
80
84
|
{/* Play/Pause */}
|
|
81
85
|
<button
|
|
82
86
|
onClick={() => remote.togglePaused()}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
1
2
|
/**
|
|
2
3
|
* Professional VideoPlayer - Vidstack Implementation
|
|
3
4
|
* Supports YouTube, Vimeo, MP4, HLS and more with custom controls
|
|
@@ -5,12 +6,103 @@
|
|
|
5
6
|
|
|
6
7
|
'use client';
|
|
7
8
|
|
|
8
|
-
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
|
|
9
|
-
import { MediaPlayer,
|
|
10
|
-
import {
|
|
9
|
+
import React, { forwardRef, useImperativeHandle, useRef, useMemo } from 'react';
|
|
10
|
+
import { MediaPlayer, MediaProvider, Poster } from '@vidstack/react';
|
|
11
|
+
import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default';
|
|
12
|
+
import type { MediaPlayerInstance } from '@vidstack/react';
|
|
13
|
+
import consola from 'consola';
|
|
11
14
|
import { cn } from '@djangocfg/ui';
|
|
12
15
|
import { type VideoPlayerProps, type VideoPlayerRef } from './types';
|
|
13
|
-
|
|
16
|
+
|
|
17
|
+
// Import Vidstack base styles
|
|
18
|
+
import '@vidstack/react/player/styles/base.css';
|
|
19
|
+
// Import default theme
|
|
20
|
+
import '@vidstack/react/player/styles/default/theme.css';
|
|
21
|
+
import '@vidstack/react/player/styles/default/layouts/video.css';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Custom error class for invalid video URLs
|
|
25
|
+
*/
|
|
26
|
+
export class VideoUrlError extends Error {
|
|
27
|
+
constructor(
|
|
28
|
+
message: string,
|
|
29
|
+
public readonly url: string,
|
|
30
|
+
public readonly suggestion?: string
|
|
31
|
+
) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = 'VideoUrlError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validates and normalizes video URL for Vidstack
|
|
39
|
+
* @throws {VideoUrlError} If URL format is invalid for the detected provider
|
|
40
|
+
*/
|
|
41
|
+
function normalizeVideoUrl(url: string): string {
|
|
42
|
+
if (!url || typeof url !== 'string') {
|
|
43
|
+
throw new VideoUrlError('Video URL is required', url || '');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const trimmedUrl = url.trim();
|
|
47
|
+
|
|
48
|
+
// Already in correct format (youtube/ID, vimeo/ID, or direct URL)
|
|
49
|
+
if (trimmedUrl.startsWith('youtube/') || trimmedUrl.startsWith('vimeo/')) {
|
|
50
|
+
return trimmedUrl;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// YouTube URL patterns - auto-convert to youtube/ID format
|
|
54
|
+
const youtubePatterns = [
|
|
55
|
+
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
|
|
56
|
+
/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
for (const pattern of youtubePatterns) {
|
|
60
|
+
const match = trimmedUrl.match(pattern);
|
|
61
|
+
if (match) {
|
|
62
|
+
// Auto-convert YouTube URL to youtube/ID format
|
|
63
|
+
const videoId = match[1];
|
|
64
|
+
if (process.env.NODE_ENV === 'development') {
|
|
65
|
+
consola.info(
|
|
66
|
+
`[VideoPlayer] Auto-converted YouTube URL to "youtube/${videoId}" format`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return `youtube/${videoId}`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Vimeo URL patterns - auto-convert to vimeo/ID format
|
|
74
|
+
const vimeoPattern = /vimeo\.com\/(\d+)/;
|
|
75
|
+
const vimeoMatch = trimmedUrl.match(vimeoPattern);
|
|
76
|
+
if (vimeoMatch) {
|
|
77
|
+
const videoId = vimeoMatch[1];
|
|
78
|
+
if (process.env.NODE_ENV === 'development') {
|
|
79
|
+
consola.info(
|
|
80
|
+
`[VideoPlayer] Auto-converted Vimeo URL to "vimeo/${videoId}" format`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return `vimeo/${videoId}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Direct video URLs (mp4, webm, etc.) - allow as-is
|
|
87
|
+
if (/\.(mp4|webm|ogg|m3u8|mpd)(\?.*)?$/i.test(trimmedUrl)) {
|
|
88
|
+
return trimmedUrl;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// HLS/DASH streams
|
|
92
|
+
if (trimmedUrl.includes('.m3u8') || trimmedUrl.includes('.mpd')) {
|
|
93
|
+
return trimmedUrl;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Unknown format - return as-is but warn in dev
|
|
97
|
+
if (process.env.NODE_ENV === 'development') {
|
|
98
|
+
consola.warn(
|
|
99
|
+
`[VideoPlayer] Unknown URL format: "${trimmedUrl}". ` +
|
|
100
|
+
`Supported formats: youtube/{id}, vimeo/{id}, or direct video URLs (.mp4, .webm, .m3u8)`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return trimmedUrl;
|
|
105
|
+
}
|
|
14
106
|
|
|
15
107
|
export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
|
|
16
108
|
source,
|
|
@@ -27,26 +119,54 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
|
|
|
27
119
|
onEnded,
|
|
28
120
|
onError,
|
|
29
121
|
}, ref) => {
|
|
30
|
-
const playerRef = useRef<
|
|
122
|
+
const playerRef = useRef<MediaPlayerInstance | null>(null);
|
|
123
|
+
|
|
124
|
+
// Debug log
|
|
125
|
+
if (process.env.NODE_ENV === 'development') {
|
|
126
|
+
consola.info('[VideoPlayer] Received props:', {
|
|
127
|
+
url: source.url,
|
|
128
|
+
poster: source.poster,
|
|
129
|
+
title: source.title,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Validate and normalize URL - throws VideoUrlError if invalid
|
|
134
|
+
const normalizedUrl = useMemo(() => {
|
|
135
|
+
try {
|
|
136
|
+
return normalizeVideoUrl(source.url);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error instanceof VideoUrlError) {
|
|
139
|
+
// Call onError callback and re-throw
|
|
140
|
+
onError?.(error.message + (error.suggestion ? ` Use: "${error.suggestion}"` : ''));
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}, [source.url, onError]);
|
|
31
146
|
|
|
32
147
|
// Expose player methods via ref
|
|
33
148
|
useImperativeHandle(ref, () => {
|
|
34
|
-
const
|
|
35
|
-
if (!playerRef.current) return null;
|
|
36
|
-
const remote = new MediaRemoteControl();
|
|
37
|
-
remote.setTarget(playerRef.current as unknown as EventTarget);
|
|
38
|
-
return remote;
|
|
39
|
-
};
|
|
149
|
+
const player = playerRef.current;
|
|
40
150
|
|
|
41
151
|
return {
|
|
42
|
-
play: () =>
|
|
43
|
-
pause: () =>
|
|
44
|
-
togglePlay: () =>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
152
|
+
play: () => player?.play(),
|
|
153
|
+
pause: () => player?.pause(),
|
|
154
|
+
togglePlay: () => {
|
|
155
|
+
if (player) {
|
|
156
|
+
player.paused ? player.play() : player.pause();
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
seekTo: (time: number) => {
|
|
160
|
+
if (player) player.currentTime = time;
|
|
161
|
+
},
|
|
162
|
+
setVolume: (volume: number) => {
|
|
163
|
+
if (player) player.volume = Math.max(0, Math.min(1, volume));
|
|
164
|
+
},
|
|
165
|
+
toggleMute: () => {
|
|
166
|
+
if (player) player.muted = !player.muted;
|
|
167
|
+
},
|
|
168
|
+
enterFullscreen: () => player?.enterFullscreen(),
|
|
169
|
+
exitFullscreen: () => player?.exitFullscreen(),
|
|
50
170
|
};
|
|
51
171
|
}, []);
|
|
52
172
|
|
|
@@ -71,7 +191,7 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
|
|
|
71
191
|
{/* Video Player */}
|
|
72
192
|
<div
|
|
73
193
|
className={cn(
|
|
74
|
-
"relative w-full
|
|
194
|
+
"relative w-full rounded-sm bg-black overflow-hidden",
|
|
75
195
|
theme === 'minimal' && "rounded-none",
|
|
76
196
|
theme === 'modern' && "rounded-xl shadow-2xl"
|
|
77
197
|
)}
|
|
@@ -80,8 +200,7 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
|
|
|
80
200
|
<MediaPlayer
|
|
81
201
|
ref={playerRef}
|
|
82
202
|
title={source.title || 'Video'}
|
|
83
|
-
src={
|
|
84
|
-
poster={source.poster}
|
|
203
|
+
src={normalizedUrl}
|
|
85
204
|
autoPlay={autoplay}
|
|
86
205
|
muted={muted}
|
|
87
206
|
playsInline={playsInline}
|
|
@@ -91,10 +210,25 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
|
|
|
91
210
|
onError={handleError}
|
|
92
211
|
className="w-full h-full"
|
|
93
212
|
>
|
|
94
|
-
<
|
|
213
|
+
<MediaProvider />
|
|
95
214
|
|
|
96
|
-
{/*
|
|
97
|
-
{
|
|
215
|
+
{/* Poster with proper aspect ratio handling */}
|
|
216
|
+
{source.poster && (
|
|
217
|
+
<Poster
|
|
218
|
+
className="vds-poster"
|
|
219
|
+
src={source.poster}
|
|
220
|
+
alt={source.title || 'Video poster'}
|
|
221
|
+
style={{ objectFit: 'cover' }}
|
|
222
|
+
/>
|
|
223
|
+
)}
|
|
224
|
+
|
|
225
|
+
{/* Use Vidstack's built-in default layout */}
|
|
226
|
+
{controls && (
|
|
227
|
+
<DefaultVideoLayout
|
|
228
|
+
icons={defaultLayoutIcons}
|
|
229
|
+
thumbnails={source.poster}
|
|
230
|
+
/>
|
|
231
|
+
)}
|
|
98
232
|
</MediaPlayer>
|
|
99
233
|
</div>
|
|
100
234
|
|
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
* Export all components and types
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
export { VideoPlayer } from './VideoPlayer';
|
|
6
|
+
export { VideoPlayer, VideoUrlError } from './VideoPlayer';
|
|
7
7
|
export { VideoControls } from './VideoControls';
|
|
8
8
|
export type { VideoSource, VideoPlayerProps, VideoPlayerRef } from './types';
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* cURL Generator
|
|
3
5
|
*
|
|
4
6
|
* Generates cURL commands from API request details with authentication token
|
|
5
7
|
*/
|
|
6
8
|
|
|
9
|
+
import consola from 'consola';
|
|
10
|
+
|
|
7
11
|
export interface CurlOptions {
|
|
8
12
|
method: string;
|
|
9
13
|
path: string;
|
|
@@ -28,7 +32,7 @@ export function getAuthToken(): string | null {
|
|
|
28
32
|
|
|
29
33
|
return token;
|
|
30
34
|
} catch (error) {
|
|
31
|
-
|
|
35
|
+
consola.error('Failed to get auth token:', error);
|
|
32
36
|
return null;
|
|
33
37
|
}
|
|
34
38
|
}
|