Them trang Admin sinh JSON va chong Cache
This commit is contained in:
@@ -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
|
||||||
@@ -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
194
src/Admin.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
← 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 mã 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/App.tsx
19
src/App.tsx
@@ -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() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
export default App;
|
||||||
Reference in New Issue
Block a user