Them trang Admin sinh JSON va chong Cache

This commit is contained in:
phuongtc
2026-04-06 11:09:21 +07:00
parent 5bc6803772
commit b883338d48
4 changed files with 222 additions and 6 deletions

View File

@@ -7,5 +7,5 @@ services:
ports: ports:
- "3005:80" - "3005:80"
volumes: volumes:
# Đường dẫn đã được chuẩn hóa theo tên dự án mới # Nối toàn bộ thư mục data vào trong nginx
- /opt/exercise-app/data.json:/usr/share/nginx/html/data.json - /opt/exercise-app/data:/usr/share/nginx/html/data

View File

@@ -19,5 +19,14 @@
"category": "Core", "category": "Core",
"level": "Nâng cao", "level": "Nâng cao",
"youtube_id": "3p8EBPvz2ZI" "youtube_id": "3p8EBPvz2ZI"
},
{
"id": "4",
"youtube_id": "ovUtlbbKb_4",
"title": "30 Minute Beginner Mobility Workout",
"category": "Mobility",
"level": "Vừa"
} }
] ]

194
src/Admin.tsx Normal file
View File

@@ -0,0 +1,194 @@
import { useState } from 'react';
interface VideoEntry {
uid: number; // ID nội bộ để quản lý danh sách
link: string;
id: string; // YouTube ID sau khi tách
title: string;
category: string;
customCategory: string;
level: string;
}
const CATEGORIES = ["Cardio", "Cơ Bụng", "Yoga", "HIIT", "Ngực", "Chân", "Toàn thân", "Khác"];
const LEVELS = ["Dễ", "Vừa", "Khó"];
export default function Admin() {
const [entries, setEntries] = useState<VideoEntry[]>([
{ uid: Date.now(), link: '', id: '', title: '', category: 'Cardio', customCategory: '', level: 'Vừa' }
]);
const [jsonOutput, setJsonOutput] = useState<string>('');
const [copied, setCopied] = useState(false);
// Hàm tự động tách ID Youtube từ Link
const extractYouTubeID = (url: string) => {
const regExp = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/i;
const match = url.match(regExp);
return match ? match[1] : '';
};
const handleUpdate = (index: number, field: keyof VideoEntry, value: string) => {
const newEntries = [...entries];
newEntries[index][field] = value as never;
// Nếu đang dán link, tự động tách ID luôn
if (field === 'link') {
newEntries[index].id = extractYouTubeID(value);
}
setEntries(newEntries);
};
const handleAddRow = () => {
setEntries([
...entries,
{ uid: Date.now(), link: '', id: '', title: '', category: 'Cardio', customCategory: '', level: 'Vừa' }
]);
};
const handleRemoveRow = (index: number) => {
if (entries.length === 1) return; // Giữ lại ít nhất 1 dòng
const newEntries = [...entries];
newEntries.splice(index, 1);
setEntries(newEntries);
};
const handleGenerate = () => {
// Lọc bỏ những dòng chưa có ID
const validEntries = entries.filter(e => e.id.trim() !== '');
const finalData = validEntries.map((e, index) => ({
id: `vid_${Date.now()}_${index}`, // Sinh ra ID ngẫu nhiên cho video
youtube_id: e.id, // Gán mã Youtube ID vào đúng trường youtube_id
title: e.title.trim() || "Chưa có tiêu đề",
category: e.category === 'Khác' ? e.customCategory.trim() : e.category,
level: e.level
}));
setJsonOutput(JSON.stringify(finalData, null, 2));
setCopied(false);
};
const handleCopy = () => {
if (!jsonOutput) return;
navigator.clipboard.writeText(jsonOutput);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="max-w-4xl mx-auto p-4 md:p-8 bg-gray-50 min-h-screen">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-800">🛠 Trạm Tạo Dữ Liệu</h1>
<button
onClick={() => window.location.href = '/'}
className="text-sm text-blue-600 hover:underline"
>
&larr; Về App
</button>
</div>
<div className="space-y-4 mb-6">
{entries.map((entry, index) => (
<div key={entry.uid} className="bg-white p-4 rounded-xl shadow-sm border border-gray-200 relative">
<button
onClick={() => handleRemoveRow(index)}
className="absolute top-2 right-2 text-red-500 hover:bg-red-50 p-1 rounded-md"
title="Xóa dòng này"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
{/* Cột Link */}
<div className="md:col-span-5">
<label className="block text-xs font-medium text-gray-500 mb-1">Link YouTube</label>
<input
type="text"
value={entry.link}
onChange={(e) => handleUpdate(index, 'link', e.target.value)}
placeholder="https://youtu.be/..."
className="w-full border border-gray-300 rounded-md p-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
/>
{entry.id && <p className="text-xs text-green-600 mt-1">ID: {entry.id}</p>}
</div>
{/* Cột Tiêu đề */}
<div className="md:col-span-3">
<label className="block text-xs font-medium text-gray-500 mb-1">Tiêu đ</label>
<input
type="text"
value={entry.title}
onChange={(e) => handleUpdate(index, 'title', e.target.value)}
placeholder="Vd: 10 Phút Đốt Mỡ"
className="w-full border border-gray-300 rounded-md p-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
{/* Cột Phân loại */}
<div className="md:col-span-2">
<label className="block text-xs font-medium text-gray-500 mb-1">Danh mục</label>
<select
value={entry.category}
onChange={(e) => handleUpdate(index, 'category', e.target.value)}
className="w-full border border-gray-300 rounded-md p-2 text-sm bg-white"
>
{CATEGORIES.map(cat => <option key={cat} value={cat}>{cat}</option>)}
</select>
{entry.category === 'Khác' && (
<input
type="text"
value={entry.customCategory}
onChange={(e) => handleUpdate(index, 'customCategory', e.target.value)}
placeholder="Nhập tag..."
className="w-full border border-gray-300 rounded-md p-1 mt-2 text-sm"
/>
)}
</div>
{/* Cột Mức độ */}
<div className="md:col-span-2">
<label className="block text-xs font-medium text-gray-500 mb-1">Mức đ</label>
<select
value={entry.level}
onChange={(e) => handleUpdate(index, 'level', e.target.value)}
className="w-full border border-gray-300 rounded-md p-2 text-sm bg-white"
>
{LEVELS.map(lvl => <option key={lvl} value={lvl}>{lvl}</option>)}
</select>
</div>
</div>
</div>
))}
</div>
<div className="flex gap-3 mb-8">
<button
onClick={handleAddRow}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg text-sm font-medium hover:bg-gray-300 transition"
>
+ Thêm Video
</button>
<button
onClick={handleGenerate}
className="px-6 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition"
>
Sinh JSON
</button>
</div>
{jsonOutput && (
<div className="bg-gray-800 rounded-xl p-4 relative">
<button
onClick={handleCopy}
className={`absolute top-4 right-4 px-3 py-1 rounded text-xs font-medium transition ${copied ? 'bg-green-500 text-white' : 'bg-gray-600 text-gray-200 hover:bg-gray-500'}`}
>
{copied ? 'Đã Copy!' : 'Copy JSON'}
</button>
<pre className="text-green-400 text-sm overflow-x-auto pt-8">
<code>{jsonOutput}</code>
</pre>
</div>
)}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import Admin from './Admin'; // <-- THÊM DÒNG IMPORT NÀY
// Định nghĩa kiểu dữ liệu cho Video bài tập // Định nghĩa kiểu dữ liệu cho Video bài tập
interface ExerciseVideo { interface ExerciseVideo {
@@ -9,15 +10,16 @@ interface ExerciseVideo {
youtube_id: string; youtube_id: string;
} }
function App() { // BƯỚC 1: Đổi tên App cũ của bạn thành MainApp
function MainApp() {
const [videos, setVideos] = useState<ExerciseVideo[]>([]); const [videos, setVideos] = useState<ExerciseVideo[]>([]);
const [selectedVideo, setSelectedVideo] = useState<ExerciseVideo | null>(null); const [selectedVideo, setSelectedVideo] = useState<ExerciseVideo | null>(null);
const [activeCategory, setActiveCategory] = useState<string>('All'); const [activeCategory, setActiveCategory] = useState<string>('All');
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
// Lấy dữ liệu từ file data.json // Lấy dữ liệu từ thư mục con /data/ và chống Cache
useEffect(() => { useEffect(() => {
fetch('/data.json') fetch('/data/data.json?t=' + new Date().getTime())
.then((res) => res.json()) .then((res) => res.json())
.then((data: ExerciseVideo[]) => { .then((data: ExerciseVideo[]) => {
setVideos(data); setVideos(data);
@@ -140,4 +142,15 @@ function App() {
); );
} }
export default App; // BƯỚC 2: Tạo App mới làm người gác cổng kiểm tra URL
function App() {
const isAdmin = window.location.search.includes('admin=true');
if (isAdmin) {
return <Admin />;
}
return <MainApp />;
}
export default App;