feat: fix m3u8 native playback in LivePlayer & add webhooks API
This commit is contained in:
parent
8993ab3293
commit
1f718afec9
@ -23,14 +23,14 @@
|
||||
"@tiptap/extension-youtube": "^3.21.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/video.js": "^7.3.58",
|
||||
"appwrite": "^23.0.0",
|
||||
"appwrite": "^1.7.0",
|
||||
"axios": "^1.13.6",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.3.3",
|
||||
"isomorphic-dompurify": "^2.20.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.1.6",
|
||||
"node-appwrite": "^22.1.3",
|
||||
"node-appwrite": "^1.7.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
||||
86
src/app/api/webhooks/publications/route.ts
Normal file
86
src/app/api/webhooks/publications/route.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { ID } from 'node-appwrite';
|
||||
import { createAdminClient } from '@/lib/appwrite.server';
|
||||
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
|
||||
import { PostSchema, generateSlug } from '@/lib/validators/publications';
|
||||
import type { ApiResponse } from '@/types/publications';
|
||||
|
||||
const DB = APPWRITE_ALMEIRA_CONFIG.databaseId;
|
||||
const COLLECTION = 'posts';
|
||||
|
||||
/**
|
||||
* @file app/api/webhooks/publications/route.ts
|
||||
* @description Secure Bridge API for n8n/Automation.
|
||||
*
|
||||
* Features:
|
||||
* 1. X-Webhook-Token Authentication.
|
||||
* 2. Automatic slug generation from title.
|
||||
* 3. Default values for automated posts.
|
||||
* 4. Full Zod validation.
|
||||
*/
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// 1. Authentication (Shared Secret)
|
||||
const authHeader = request.headers.get('X-Webhook-Token');
|
||||
const WEBHOOK_TOKEN = process.env.WEBHOOK_TOKEN || 'almeira_n8n_secret_2024'; // FALLBACK FOR DEV
|
||||
|
||||
if (!authHeader || authHeader !== WEBHOOK_TOKEN) {
|
||||
return NextResponse.json<ApiResponse>(
|
||||
{ success: false, error: 'Unauthorized. Invalid Webhook Token.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Parse Body
|
||||
const body = await request.json();
|
||||
|
||||
// 3. Pre-process Data (Slug & Defaults)
|
||||
const processedData = {
|
||||
...body,
|
||||
// Default to "Borrador" if not specified
|
||||
estado: body.estado || 'Borrador',
|
||||
// Auto-generate slug if missing
|
||||
slug: body.slug || (body.titulo ? generateSlug(body.titulo) : undefined),
|
||||
// Current ISO date for publication
|
||||
fecha_publicacion: body.fecha_publicacion || new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 4. Validate with Zod
|
||||
const parseResult = PostSchema.safeParse(processedData);
|
||||
if (!parseResult.success) {
|
||||
const validationErrors: Record<string, string[]> = {};
|
||||
parseResult.error.issues.forEach((err) => {
|
||||
const key = String(err.path[0]);
|
||||
if (!validationErrors[key]) validationErrors[key] = [];
|
||||
validationErrors[key].push(err.message);
|
||||
});
|
||||
return NextResponse.json<ApiResponse>(
|
||||
{ success: false, error: 'Invalid publication data.', validationErrors },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Create Document in Appwrite
|
||||
const admin = await createAdminClient();
|
||||
const document = await admin.databases.createDocument(
|
||||
DB,
|
||||
COLLECTION,
|
||||
ID.unique(),
|
||||
parseResult.data
|
||||
);
|
||||
|
||||
return NextResponse.json<ApiResponse>(
|
||||
{ success: true, data: document },
|
||||
{ status: 201 }
|
||||
);
|
||||
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error processing webhook.';
|
||||
console.error('[WEBHOOK_ERROR]', error);
|
||||
return NextResponse.json<ApiResponse>(
|
||||
{ success: false, error: message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { Tv, PlayCircle, RadioTower, WifiOff, PauseCircle, Volume2 } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { usePlayerStore } from '@/store/usePlayerStore';
|
||||
import videojs from 'video.js';
|
||||
import 'video.js/dist/video-js.css';
|
||||
|
||||
interface LivePlayerProps {
|
||||
url?: string;
|
||||
@ -52,6 +54,40 @@ export function LivePlayer({ url, type, title }: LivePlayerProps) {
|
||||
}
|
||||
}, [url, type, setAudioUrl, setVideoUrl, setVideoType]);
|
||||
|
||||
// Handle inner Video Player Initialization
|
||||
const innerVideoRef = useRef<HTMLDivElement>(null);
|
||||
const innerPlayerRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (type === 'tv' && !isYouTube && processedUrl && innerVideoRef.current) {
|
||||
if (innerPlayerRef.current) {
|
||||
innerPlayerRef.current.dispose();
|
||||
innerPlayerRef.current = null;
|
||||
}
|
||||
const videoElement = document.createElement('video-js');
|
||||
videoElement.classList.add('vjs-fill', 'vjs-almeira');
|
||||
innerVideoRef.current.innerHTML = '';
|
||||
innerVideoRef.current.appendChild(videoElement);
|
||||
|
||||
innerPlayerRef.current = videojs(videoElement, {
|
||||
autoplay: 'muted',
|
||||
controls: false,
|
||||
fluid: true,
|
||||
responsive: true,
|
||||
sources: [{
|
||||
src: processedUrl,
|
||||
type: 'application/x-mpegURL'
|
||||
}]
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
if (innerPlayerRef.current) {
|
||||
innerPlayerRef.current.dispose();
|
||||
innerPlayerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [type, isYouTube, processedUrl]);
|
||||
|
||||
if (!processedUrl) {
|
||||
return (
|
||||
<div className={`relative w-full rounded-3xl overflow-hidden border border-slate-800 shadow-2xl bg-slate-900 flex flex-col items-center justify-center p-12 transition-all ${type === 'tv' ? 'aspect-video' : 'h-64'}`}>
|
||||
@ -78,18 +114,26 @@ export function LivePlayer({ url, type, title }: LivePlayerProps) {
|
||||
<div className={`relative w-full rounded-3xl overflow-hidden border border-slate-800 shadow-2xl bg-black transition-all ${type === 'tv' ? 'aspect-video' : 'h-48'}`}>
|
||||
{type === 'tv' ? (
|
||||
<div className="w-full h-full relative group">
|
||||
<iframe
|
||||
src={processedUrl}
|
||||
className="w-full h-full border-0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
title={title || "Live TV Stream"}
|
||||
/>
|
||||
{isYouTube ? (
|
||||
<iframe
|
||||
src={processedUrl}
|
||||
className="w-full h-full border-0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
title={title || "Live TV Stream"}
|
||||
/>
|
||||
) : (
|
||||
<div ref={innerVideoRef} className="w-full h-full pointer-events-none" />
|
||||
)}
|
||||
{/* Overlay to catch interaction and sync state */}
|
||||
<div
|
||||
className="absolute inset-0 bg-transparent cursor-pointer"
|
||||
className="absolute inset-0 bg-transparent cursor-pointer flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-black/40"
|
||||
onClick={() => setVideoActive(true)}
|
||||
></div>
|
||||
>
|
||||
<div className="w-20 h-20 bg-pink-600 rounded-full flex items-center justify-center shadow-[0_0_30px_rgba(219,39,119,0.5)] animate-pulse">
|
||||
<PlayCircle size={40} className="text-white translate-x-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center relative p-8">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user