ملف آخر
Signed-off-by: Mohamed-Abdelhalim2 <codepen.io.dropper985@passinbox.com>
هذا الالتزام موجود في:
178
src/ChannelBrowser.tsx
Normal file
178
src/ChannelBrowser.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../store';
|
||||
import { channels, formatNumber, getCategoryColor, getChannelPlaylists } from '../data';
|
||||
import { Search, Plus, Check, Filter, ArrowRight, List } from 'lucide-react';
|
||||
|
||||
const allCategories = ['الكل', 'تعليم', 'تقنية', 'برمجة', 'تاريخ', 'إسلام', 'حروب', 'اختراعات', 'دين', 'شريعة', 'لغة عربية', 'ريادة', 'أعمال', 'تصميم', 'ثقافة', 'سياسة', 'ابتكار', 'مشاريع'];
|
||||
|
||||
export default function ChannelBrowser() {
|
||||
const { followedChannelIds, followChannel, unfollowChannel, setView } = useApp();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('الكل');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
const filteredChannels = channels.filter(ch => {
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
ch.name.includes(searchQuery) ||
|
||||
ch.description.includes(searchQuery) ||
|
||||
ch.categories.some(c => c.includes(searchQuery));
|
||||
|
||||
const matchesCategory = selectedCategory === 'الكل' || ch.categories.includes(selectedCategory);
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-y-auto custom-scrollbar p-6 animate-slide-up">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-black text-gray-800 mb-2">🔍 استكشف القنوات</h1>
|
||||
<p className="text-gray-400 text-sm">اكتشف قنوات تعليمية متميزة وابدأ متابعتها</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute right-4 top-1/2 -translate-y-1/2 w-4.5 h-4.5 text-gray-400" />
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="ابحث عن قناة أو موضوع..."
|
||||
className="w-full bg-white rounded-xl pr-11 pl-4 py-3 text-sm border border-gray-200 focus:border-purple-400 focus:ring-2 focus:ring-purple-100 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`px-4 py-3 rounded-xl font-bold text-sm flex items-center gap-2 transition-all ${
|
||||
showFilters ? 'bg-purple-500 text-white' : 'bg-white text-gray-600 border border-gray-200 hover:border-purple-300'
|
||||
}`}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
تصفية
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Category Filters */}
|
||||
{showFilters && (
|
||||
<div className="flex gap-2 flex-wrap mb-6 animate-slide-up">
|
||||
{allCategories.map(cat => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
className={`px-4 py-1.5 rounded-full text-xs font-bold transition-all ${
|
||||
selectedCategory === cat
|
||||
? 'bg-purple-500 text-white shadow-lg shadow-purple-500/25'
|
||||
: 'bg-white text-gray-500 border border-gray-200 hover:border-purple-300 hover:text-purple-600'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Filter Notice */}
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-3 mb-6 flex items-center gap-2">
|
||||
<span className="text-lg">🛡️</span>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-emerald-700">فلترة المحتوى نشطة</p>
|
||||
<p className="text-[10px] text-emerald-500">يتم فلترة المحتوى غير المناسب تلقائيًا</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channels Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredChannels.map(ch => {
|
||||
const isFollowed = followedChannelIds.includes(ch.id);
|
||||
const chPlaylists = getChannelPlaylists(ch.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ch.id}
|
||||
className="bg-white rounded-2xl shadow-sm hover:shadow-xl transition-all duration-300 overflow-hidden border border-gray-100 hover:border-purple-200"
|
||||
>
|
||||
{/* Channel Banner */}
|
||||
<div className={`h-24 bg-gradient-to-l ${ch.gradient} relative`}>
|
||||
<div className="absolute inset-0 bg-black/10" />
|
||||
<div className="absolute -bottom-6 right-4">
|
||||
<div className={`w-14 h-14 rounded-2xl bg-gradient-to-br ${ch.gradient} flex items-center justify-center text-3xl shadow-lg border-4 border-white`}>
|
||||
{ch.emoji}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 pt-8">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-gray-800 text-base truncate">{ch.name}</h3>
|
||||
<p className="text-[11px] text-gray-400">{formatNumber(ch.subscriberCount)} مشترك · {ch.videoCount} فيديو</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => isFollowed ? unfollowChannel(ch.id) : followChannel(ch.id)}
|
||||
className={`px-4 py-1.5 rounded-xl font-bold text-xs flex items-center gap-1 transition-all shrink-0 ${
|
||||
isFollowed
|
||||
? 'bg-emerald-50 text-emerald-600 hover:bg-red-50 hover:text-red-500'
|
||||
: 'bg-gradient-to-l from-purple-500 to-indigo-500 text-white hover:from-purple-600 hover:to-indigo-600 shadow-lg shadow-purple-500/25'
|
||||
}`}
|
||||
>
|
||||
{isFollowed ? <><Check className="w-3.5 h-3.5" /> متابَع</> : <><Plus className="w-3.5 h-3.5" /> متابعة</>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-gray-400 mb-3 line-clamp-2">{ch.description}</p>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="flex gap-1.5 flex-wrap mb-3">
|
||||
{ch.categories.slice(0, 3).map(cat => (
|
||||
<span key={cat} className={`text-[9px] px-2 py-0.5 rounded-full ${getCategoryColor(cat)}`}>
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Playlists Preview */}
|
||||
<div className="space-y-1.5">
|
||||
{chPlaylists.slice(0, 2).map(pl => (
|
||||
<button
|
||||
key={pl.id}
|
||||
onClick={() => {
|
||||
if (isFollowed) {
|
||||
setView({ type: 'playlist', playlistId: pl.id });
|
||||
} else {
|
||||
followChannel(ch.id);
|
||||
setView({ type: 'playlist', playlistId: pl.id });
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center gap-2 bg-gray-50 hover:bg-orange-50 rounded-lg px-3 py-2 text-right transition-colors group"
|
||||
>
|
||||
<span className="text-sm">{pl.emoji}</span>
|
||||
<span className="text-[11px] text-gray-600 group-hover:text-orange-700 flex-1 truncate">{pl.title}</span>
|
||||
<List className="w-3 h-3 text-gray-300" />
|
||||
<span className="text-[10px] text-gray-400">{pl.videoCount}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setView({ type: 'channel', channelId: ch.id })}
|
||||
className="w-full mt-3 flex items-center justify-center gap-1 text-purple-500 hover:text-purple-700 text-xs font-bold transition-colors"
|
||||
>
|
||||
عرض القناة
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredChannels.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-5xl mb-4">🔍</div>
|
||||
<h3 className="text-lg font-bold text-gray-400">لم يتم العثور على نتائج</h3>
|
||||
<p className="text-sm text-gray-300 mt-1">جرّب البحث بكلمات مختلفة</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
193
src/ChannelView.tsx
Normal file
193
src/ChannelView.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useApp } from '../store';
|
||||
import { getChannel, getChannelPlaylists, getPlaylistVideos, standaloneVideos, formatNumber, getCategoryColor } from '../data';
|
||||
import { ArrowRight, Play, List, CheckCircle, Users, Video } from 'lucide-react';
|
||||
|
||||
export default function ChannelView({ channelId }: { channelId: string }) {
|
||||
const { setView, progress, followedChannelIds, followChannel, unfollowChannel } = useApp();
|
||||
const channel = getChannel(channelId);
|
||||
|
||||
if (!channel) {
|
||||
return <div className="p-8 text-center text-gray-400">القناة غير موجودة</div>;
|
||||
}
|
||||
|
||||
const isFollowed = followedChannelIds.includes(channelId);
|
||||
const channelPlaylists = getChannelPlaylists(channelId);
|
||||
const channelStandalone = standaloneVideos.filter(v => v.channelId === channelId);
|
||||
|
||||
const totalVideos = channelPlaylists.reduce((sum, pl) => sum + (getPlaylistVideos(pl.id)?.length || 0), 0) + channelStandalone.length;
|
||||
const watchedCount = [
|
||||
...channelStandalone,
|
||||
...channelPlaylists.flatMap(pl => getPlaylistVideos(pl.id) || [])
|
||||
].filter(v => progress.watchedVideos.includes(v.id)).length;
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-y-auto custom-scrollbar animate-slide-up">
|
||||
{/* Channel Header */}
|
||||
<div className={`bg-gradient-to-l ${channel.gradient} p-8 relative overflow-hidden`}>
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48Y2lyY2xlIGN4PSIzMCIgY3k9IjMwIiByPSIxLjUiIGZpbGw9InJnYmEoMjU1LDI1NSwyNTUsMC4wOCkiLz48L3N2Zz4=')] " />
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setView({ type: 'dashboard' })}
|
||||
className="flex items-center gap-1 text-white/80 hover:text-white text-sm mb-4 transition-colors"
|
||||
>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
العودة للرئيسية
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-20 h-20 rounded-2xl bg-white/20 backdrop-blur-sm flex items-center justify-center text-5xl shadow-2xl">
|
||||
{channel.emoji}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-black text-white mb-1">{channel.name}</h1>
|
||||
<p className="text-white/70 text-sm mb-3 max-w-lg">{channel.description}</p>
|
||||
<div className="flex items-center gap-4 text-white/60 text-xs">
|
||||
<span className="flex items-center gap-1"><Users className="w-3.5 h-3.5" /> {formatNumber(channel.subscriberCount)} مشترك</span>
|
||||
<span className="flex items-center gap-1"><Video className="w-3.5 h-3.5" /> {channel.videoCount} فيديو</span>
|
||||
<span className="flex items-center gap-1"><List className="w-3.5 h-3.5" /> {channelPlaylists.length} قائمة تشغيل</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => isFollowed ? unfollowChannel(channelId) : followChannel(channelId)}
|
||||
className={`px-6 py-2.5 rounded-xl font-bold text-sm transition-all ${
|
||||
isFollowed
|
||||
? 'bg-white/20 text-white hover:bg-white/30 backdrop-blur-sm'
|
||||
: 'bg-white text-gray-800 hover:bg-gray-100 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
{isFollowed ? '✓ متابَع' : '+ متابعة'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="flex gap-2 mt-4 flex-wrap">
|
||||
{channel.categories.map(cat => (
|
||||
<span key={cat} className={`text-[10px] px-2.5 py-1 rounded-full ${getCategoryColor(cat)}`}>
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="px-6 py-4 flex gap-3">
|
||||
<div className="bg-white rounded-xl px-4 py-2.5 flex items-center gap-2 shadow-sm border border-gray-100">
|
||||
<div className="text-sm font-black text-orange-500">{watchedCount}/{totalVideos}</div>
|
||||
<div className="text-[10px] text-gray-400">فيديو مُشاهد</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl px-4 py-2.5 flex items-center gap-2 shadow-sm border border-gray-100">
|
||||
<div className="text-sm font-black text-emerald-500">
|
||||
{channelPlaylists.filter(pl => progress.completedPlaylists.includes(pl.id)).length}/{channelPlaylists.length}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400">قوائم مكتملة</div>
|
||||
</div>
|
||||
{totalVideos > 0 && (
|
||||
<div className="flex-1 bg-white rounded-xl px-4 py-2.5 flex items-center gap-3 shadow-sm border border-gray-100">
|
||||
<div className="text-[10px] text-gray-400">التقدم الكلي</div>
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-l from-orange-400 to-emerald-400 rounded-full" style={{ width: `${(watchedCount / totalVideos) * 100}%` }} />
|
||||
</div>
|
||||
<div className="text-xs font-bold text-gray-600">{Math.round((watchedCount / totalVideos) * 100)}%</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Playlists */}
|
||||
<div className="px-6 pb-6">
|
||||
<h3 className="text-lg font-bold text-gray-800 flex items-center gap-2 mb-4">
|
||||
<List className="w-5 h-5 text-purple-500" />
|
||||
قوائم التشغيل ({channelPlaylists.length})
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||
{channelPlaylists.map(pl => {
|
||||
const plVideos = getPlaylistVideos(pl.id);
|
||||
const completedCount = plVideos.filter(v => progress.watchedVideos.includes(v.id)).length;
|
||||
const percentage = plVideos.length > 0 ? Math.round((completedCount / plVideos.length) * 100) : 0;
|
||||
const isComplete = progress.completedPlaylists.includes(pl.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pl.id}
|
||||
onClick={() => setView({ type: 'playlist', playlistId: pl.id })}
|
||||
className="group bg-white rounded-2xl shadow-sm hover:shadow-xl transition-all duration-300 overflow-hidden text-right border border-gray-100 hover:border-purple-200"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className={`h-28 bg-gradient-to-br ${pl.gradient} flex items-center justify-center`}>
|
||||
<span className="text-5xl opacity-40 group-hover:opacity-70 transition-opacity">{pl.emoji}</span>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
|
||||
<div className="absolute bottom-2 right-3 left-3 flex justify-between items-end">
|
||||
<div className="flex items-center gap-1 bg-black/50 rounded-lg px-2 py-1 text-white text-[10px]">
|
||||
<Play className="w-3 h-3" />
|
||||
{plVideos.length} فيديو
|
||||
</div>
|
||||
{isComplete && (
|
||||
<div className="bg-emerald-500 rounded-lg px-2 py-1 text-white text-[10px] font-bold flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
مكتملة
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h4 className="font-bold text-gray-800 text-sm mb-1 group-hover:text-purple-600 transition-colors">{pl.title}</h4>
|
||||
<p className="text-[11px] text-gray-400 mb-3 line-clamp-2">{pl.description}</p>
|
||||
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-700 ${percentage === 100 ? 'bg-emerald-400' : 'bg-gradient-to-l from-purple-400 to-pink-400'}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1.5 text-[10px]">
|
||||
<span className="text-gray-400">{completedCount}/{plVideos.length}</span>
|
||||
<span className={`font-bold ${percentage === 100 ? 'text-emerald-500' : 'text-purple-500'}`}>{percentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Standalone Videos */}
|
||||
{channelStandalone.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-lg font-bold text-gray-800 flex items-center gap-2 mb-4">
|
||||
<Video className="w-5 h-5 text-orange-500" />
|
||||
فيديوهات حديثة
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{channelStandalone.map(video => {
|
||||
const isWatched = progress.watchedVideos.includes(video.id);
|
||||
return (
|
||||
<button
|
||||
key={video.id}
|
||||
onClick={() => {
|
||||
setView({ type: 'video', videoId: video.id });
|
||||
}}
|
||||
className="group bg-white rounded-2xl shadow-sm hover:shadow-xl transition-all duration-300 overflow-hidden text-right border border-gray-100 hover:border-orange-200 flex items-center gap-3 p-3"
|
||||
>
|
||||
<div className={`w-24 h-16 rounded-xl bg-gradient-to-br ${channel.gradient} flex items-center justify-center shrink-0 relative`}>
|
||||
<Play className="w-6 h-6 text-white/70 group-hover:text-white transition-colors" />
|
||||
<span className="absolute bottom-1 left-1 bg-black/60 text-white text-[9px] px-1 rounded">
|
||||
{video.duration}
|
||||
</span>
|
||||
{isWatched && <CheckCircle className="w-3.5 h-3.5 text-emerald-400 absolute top-1 right-1" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-xs font-bold text-gray-800 line-clamp-2 group-hover:text-orange-600 transition-colors">{video.title}</h4>
|
||||
<div className="text-[10px] text-gray-400 mt-1">{formatNumber(video.viewCount)} مشاهدة</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
297
src/Dashboard.tsx
Normal file
297
src/Dashboard.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import { useApp } from '../store';
|
||||
import { channels, playlists, videos, standaloneVideos, formatNumber, getChannel, getPlaylistVideos } from '../data';
|
||||
import { Play, Clock, CheckCircle, Trophy, Flame, Star, BookOpen } from 'lucide-react';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { progress, setView, followedChannelIds, markVideoWatched } = useApp();
|
||||
|
||||
const followedChannels = channels.filter(c => followedChannelIds.includes(c.id));
|
||||
const followedPlaylists = playlists.filter(p => followedChannelIds.includes(p.channelId));
|
||||
|
||||
// Get playlists with progress
|
||||
const playlistsInProgress = followedPlaylists
|
||||
.map(pl => ({
|
||||
playlist: pl,
|
||||
channel: getChannel(pl.channelId),
|
||||
plVideos: getPlaylistVideos(pl.id),
|
||||
currentIndex: progress.playlistProgress[pl.id] || 0,
|
||||
completedCount: getPlaylistVideos(pl.id).filter(v => progress.watchedVideos.includes(v.id)).length,
|
||||
}))
|
||||
.filter(p => p.plVideos.length > 0)
|
||||
.sort((a, b) => {
|
||||
// Prioritize playlists in progress
|
||||
const aProgress = a.completedCount / a.plVideos.length;
|
||||
const bProgress = b.completedCount / b.plVideos.length;
|
||||
if (aProgress > 0 && aProgress < 1 && bProgress > 0 && bProgress < 1) return bProgress - aProgress;
|
||||
if (aProgress > 0 && aProgress < 1) return -1;
|
||||
if (bProgress > 0 && bProgress < 1) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Get recent videos from followed channels
|
||||
const recentVideos = standaloneVideos
|
||||
.filter(v => followedChannelIds.includes(v.channelId))
|
||||
.concat(
|
||||
followedPlaylists.flatMap(pl =>
|
||||
(videos[pl.id] || []).slice(-2)
|
||||
)
|
||||
)
|
||||
.sort(() => Math.random() - 0.5)
|
||||
.slice(0, 8);
|
||||
|
||||
const totalVideosAvailable = followedPlaylists.reduce((sum, pl) => sum + (videos[pl.id]?.length || 0), 0) + standaloneVideos.filter(v => followedChannelIds.includes(v.channelId)).length;
|
||||
const overallProgress = totalVideosAvailable > 0 ? Math.round((progress.watchedVideos.length / totalVideosAvailable) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-y-auto custom-scrollbar p-6 space-y-6 animate-slide-up">
|
||||
{/* Hero Stats */}
|
||||
<div className="bg-gradient-to-l from-orange-500 via-rose-500 to-purple-600 rounded-2xl p-6 text-white relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48Y2lyY2xlIGN4PSIyMCIgY3k9IjIwIiByPSIxIiBmaWxsPSJyZ2JhKDI1NSwyNTUsMjU1LDAuMSkiLz48L3N2Zz4=')] opacity-50" />
|
||||
<div className="relative">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black mb-1">مرحبًا بك في مساري! 🎯</h2>
|
||||
<p className="text-white/80 text-sm">واصل رحلة التعلم من حيث توقفت</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-white/20 rounded-xl px-4 py-2">
|
||||
<Flame className="w-5 h-5 text-yellow-300" />
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-black">{progress.streak}</div>
|
||||
<div className="text-[10px] text-white/70">أيام متتالية</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="bg-white/15 backdrop-blur-sm rounded-xl p-3 text-center">
|
||||
<div className="text-2xl font-black">{progress.watchedVideos.length}</div>
|
||||
<div className="text-[10px] text-white/70">فيديو مُشاهد</div>
|
||||
</div>
|
||||
<div className="bg-white/15 backdrop-blur-sm rounded-xl p-3 text-center">
|
||||
<div className="text-2xl font-black">{progress.completedPlaylists.length}</div>
|
||||
<div className="text-[10px] text-white/70">قائمة مكتملة</div>
|
||||
</div>
|
||||
<div className="bg-white/15 backdrop-blur-sm rounded-xl p-3 text-center">
|
||||
<div className="text-2xl font-black">{followedChannels.length}</div>
|
||||
<div className="text-[10px] text-white/70">قناة متابعة</div>
|
||||
</div>
|
||||
<div className="bg-white/15 backdrop-blur-sm rounded-xl p-3 text-center">
|
||||
<div className="text-2xl font-black">{overallProgress}%</div>
|
||||
<div className="text-[10px] text-white/70">نسبة الإنجاز</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playlists Progress */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-800 flex items-center gap-2">
|
||||
<BookOpen className="w-5 h-5 text-purple-500" />
|
||||
قوائم التشغيل الخاصة بك
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400">{playlistsInProgress.length} قائمة</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{playlistsInProgress.slice(0, 9).map(({ playlist, channel, plVideos, completedCount }) => {
|
||||
const percentage = plVideos.length > 0 ? Math.round((completedCount / plVideos.length) * 100) : 0;
|
||||
const isComplete = progress.completedPlaylists.includes(playlist.id);
|
||||
const nextVideoIndex = completedCount;
|
||||
const nextVideo = plVideos[nextVideoIndex];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={playlist.id}
|
||||
onClick={() => setView({ type: 'playlist', playlistId: playlist.id })}
|
||||
className="group bg-white rounded-2xl shadow-sm hover:shadow-xl transition-all duration-300 overflow-hidden text-right border border-gray-100 hover:border-orange-200"
|
||||
>
|
||||
<div className={`h-2 bg-gradient-to-l ${playlist.gradient}`} />
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${playlist.gradient} flex items-center justify-center text-2xl shrink-0 shadow-lg`}>
|
||||
{playlist.emoji}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-bold text-gray-800 text-sm truncate group-hover:text-orange-600 transition-colors">
|
||||
{playlist.title}
|
||||
</h4>
|
||||
<p className="text-[11px] text-gray-400 mt-0.5">{channel?.name}</p>
|
||||
</div>
|
||||
{isComplete && (
|
||||
<div className="bg-emerald-100 rounded-lg px-2 py-1">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="text-gray-400">{completedCount} من {plVideos.length} فيديو</span>
|
||||
<span className={`font-bold ${percentage === 100 ? 'text-emerald-500' : percentage > 0 ? 'text-orange-500' : 'text-gray-400'}`}>
|
||||
{percentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-1000 ${
|
||||
percentage === 100 ? 'bg-gradient-to-l from-emerald-400 to-green-500' : 'bg-gradient-to-l from-orange-400 to-rose-500'
|
||||
}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
{nextVideo && !isComplete && (
|
||||
<div className="flex items-center gap-2 bg-orange-50 rounded-lg p-2 mt-2">
|
||||
<Play className="w-3.5 h-3.5 text-orange-500 shrink-0" />
|
||||
<span className="text-[11px] text-orange-700 truncate">التالي: {nextVideo.title}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Channel Grid */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-800 flex items-center gap-2">
|
||||
<Star className="w-5 h-5 text-amber-500" />
|
||||
قنواتك المتابعة
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{followedChannels.map(ch => {
|
||||
const chPlaylists = playlists.filter(p => p.channelId === ch.id);
|
||||
const chVideosWatched = [...standaloneVideos, ...Object.values(videos).flat()].filter(
|
||||
v => v.channelId === ch.id && progress.watchedVideos.includes(v.id)
|
||||
).length;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={ch.id}
|
||||
onClick={() => setView({ type: 'channel', channelId: ch.id })}
|
||||
className="group bg-white rounded-2xl p-4 shadow-sm hover:shadow-xl transition-all duration-300 text-right border border-gray-100 hover:border-purple-200"
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-2xl bg-gradient-to-br ${ch.gradient} flex items-center justify-center text-3xl mx-auto mb-3 shadow-lg group-hover:scale-110 transition-transform`}>
|
||||
{ch.emoji}
|
||||
</div>
|
||||
<h4 className="font-bold text-gray-800 text-sm truncate mb-1">{ch.name}</h4>
|
||||
<p className="text-[10px] text-gray-400 mb-2">{formatNumber(ch.subscriberCount)} مشترك</p>
|
||||
<div className="flex items-center justify-center gap-2 text-[10px]">
|
||||
<span className="bg-blue-50 text-blue-600 px-2 py-0.5 rounded-full">{chPlaylists.length} دورة</span>
|
||||
<span className="bg-emerald-50 text-emerald-600 px-2 py-0.5 rounded-full">{chVideosWatched} مُشاهد</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={() => setView({ type: 'browser' })}
|
||||
className="bg-gradient-to-br from-purple-50 to-pink-50 rounded-2xl p-4 shadow-sm hover:shadow-xl transition-all duration-300 text-center border-2 border-dashed border-purple-200 hover:border-purple-400 group"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-purple-400 to-pink-400 flex items-center justify-center text-3xl mx-auto mb-3 shadow-lg group-hover:scale-110 transition-transform">
|
||||
➕
|
||||
</div>
|
||||
<h4 className="font-bold text-purple-700 text-sm">استكشف قنوات جديدة</h4>
|
||||
<p className="text-[10px] text-purple-400 mt-1">أضف قنوات لبدء التعلم</p>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recent Videos */}
|
||||
{recentVideos.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-800 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-blue-500" />
|
||||
جديد من قنواتك
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{recentVideos.map(video => {
|
||||
const channel = getChannel(video.channelId);
|
||||
const isWatched = progress.watchedVideos.includes(video.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={video.id}
|
||||
onClick={() => {
|
||||
markVideoWatched(video.id);
|
||||
setView({ type: 'video', videoId: video.id, playlistId: video.playlistId || undefined });
|
||||
}}
|
||||
className="group bg-white rounded-2xl shadow-sm hover:shadow-xl transition-all duration-300 overflow-hidden text-right border border-gray-100 hover:border-blue-200 video-card"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className={`h-32 bg-gradient-to-br ${channel?.gradient || 'from-gray-400 to-gray-500'} flex items-center justify-center`}>
|
||||
<span className="text-5xl opacity-50 group-hover:opacity-80 transition-opacity">{channel?.emoji || '🎬'}</span>
|
||||
<div className="video-overlay absolute inset-0 bg-black/30 flex items-center justify-center opacity-0 transition-opacity">
|
||||
<Play className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="absolute bottom-2 left-2 bg-black/70 text-white text-[10px] px-1.5 py-0.5 rounded-md">
|
||||
{video.duration}
|
||||
</span>
|
||||
{isWatched && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<CheckCircle className="w-5 h-5 text-emerald-400 drop-shadow-lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h4 className="text-xs font-bold text-gray-800 line-clamp-2 mb-1.5 group-hover:text-orange-600 transition-colors">{video.title}</h4>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`w-5 h-5 rounded-md bg-gradient-to-br ${channel?.gradient || 'from-gray-400 to-gray-500'} flex items-center justify-center text-[10px]`}>
|
||||
{channel?.emoji}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">{channel?.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Achievements */}
|
||||
<section className="pb-6">
|
||||
<h3 className="text-lg font-bold text-gray-800 flex items-center gap-2 mb-4">
|
||||
<Trophy className="w-5 h-5 text-amber-500" />
|
||||
الإنجازات
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ emoji: '🌟', title: 'أول فيديو', desc: 'شاهد أول فيديو', done: progress.watchedVideos.length >= 1 },
|
||||
{ emoji: '📚', title: 'طالب مجتهد', desc: 'شاهد 10 فيديوهات', done: progress.watchedVideos.length >= 10 },
|
||||
{ emoji: '🔥', title: 'متسلسل', desc: '3 أيام متتالية', done: progress.streak >= 3 },
|
||||
{ emoji: '🏆', title: 'منتهٍ', desc: 'أكمل أول قائمة', done: progress.completedPlaylists.length >= 1 },
|
||||
{ emoji: '💎', title: 'محترف', desc: 'أكمل 3 قوائم', done: progress.completedPlaylists.length >= 3 },
|
||||
{ emoji: '🎯', title: 'هادف', desc: 'شاهد 50 فيديو', done: progress.watchedVideos.length >= 50 },
|
||||
{ emoji: '🚀', title: 'صاروخي', desc: '7 أيام متتالية', done: progress.streak >= 7 },
|
||||
{ emoji: '👑', title: 'ملك التعلم', desc: 'أكمل 10 قوائم', done: progress.completedPlaylists.length >= 10 },
|
||||
].map((ach, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`rounded-2xl p-4 text-center transition-all ${
|
||||
ach.done
|
||||
? 'bg-gradient-to-br from-amber-50 to-orange-50 border-2 border-amber-200 shadow-lg'
|
||||
: 'bg-gray-50 border-2 border-gray-100 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="text-3xl mb-2">{ach.emoji}</div>
|
||||
<div className="text-sm font-bold text-gray-800">{ach.title}</div>
|
||||
<div className="text-[10px] text-gray-400 mt-0.5">{ach.desc}</div>
|
||||
{ach.done && <div className="text-[10px] text-emerald-500 font-bold mt-1">✅ مُحقق</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
src/FolderView.tsx
Normal file
213
src/FolderView.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../store';
|
||||
import { Channel } from '../types';
|
||||
import { channels, getChannel, getChannelPlaylists, getPlaylistVideos } from '../data';
|
||||
import { ArrowRight, CheckCircle, Plus, X, FolderOpen, List } from 'lucide-react';
|
||||
|
||||
export default function FolderView({ folderId }: { folderId: string }) {
|
||||
const { folders, progress, setView, followedChannelIds, addChannelToFolder, removeChannelFromFolder } = useApp();
|
||||
const [showAddChannel, setShowAddChannel] = useState(false);
|
||||
|
||||
const folder = folders.find(f => f.id === folderId);
|
||||
if (!folder) {
|
||||
return <div className="p-8 text-center text-gray-400">المجلد غير موجود</div>;
|
||||
}
|
||||
|
||||
const folderChannels = folder.channelIds.map(id => getChannel(id)).filter((c): c is Channel => c !== undefined);
|
||||
const folderPlaylists = folder.channelIds.flatMap(chId => getChannelPlaylists(chId));
|
||||
|
||||
// Available channels not in folder
|
||||
const availableChannels = channels.filter(ch =>
|
||||
followedChannelIds.includes(ch.id) && !folder.channelIds.includes(ch.id)
|
||||
);
|
||||
|
||||
const totalVideos = folderPlaylists.reduce((sum, pl) => sum + (getPlaylistVideos(pl.id)?.length || 0), 0);
|
||||
const watchedVideos = folderPlaylists.reduce((sum, pl) => {
|
||||
return sum + getPlaylistVideos(pl.id).filter(v => progress.watchedVideos.includes(v.id)).length;
|
||||
}, 0);
|
||||
const overallPercentage = totalVideos > 0 ? Math.round((watchedVideos / totalVideos) * 100) : 0;
|
||||
|
||||
const folderColorMap: Record<string, string> = {
|
||||
blue: 'from-blue-500 to-cyan-400',
|
||||
purple: 'from-purple-500 to-indigo-400',
|
||||
amber: 'from-amber-500 to-orange-400',
|
||||
rose: 'from-rose-500 to-pink-400',
|
||||
orange: 'from-orange-500 to-yellow-400',
|
||||
green: 'from-green-500 to-emerald-400',
|
||||
red: 'from-red-500 to-rose-400',
|
||||
teal: 'from-teal-500 to-cyan-400',
|
||||
};
|
||||
|
||||
const gradient = folderColorMap[folder.color] || 'from-gray-500 to-gray-400';
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-y-auto custom-scrollbar animate-slide-up">
|
||||
{/* Folder Header */}
|
||||
<div className={`bg-gradient-to-l ${gradient} p-8 relative overflow-hidden`}>
|
||||
<div className="absolute inset-0 bg-black/15" />
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setView({ type: 'dashboard' })}
|
||||
className="flex items-center gap-1 text-white/80 hover:text-white text-sm mb-4 transition-colors"
|
||||
>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
العودة للرئيسية
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-20 h-20 rounded-2xl bg-white/20 backdrop-blur-sm flex items-center justify-center text-5xl shadow-2xl">
|
||||
{folder.emoji}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-black text-white mb-1">{folder.name}</h1>
|
||||
<p className="text-white/70 text-sm mb-3">{folderChannels.length} قناة · {folderPlaylists.length} قائمة تشغيل</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-white/20 backdrop-blur-sm rounded-lg px-3 py-1.5 text-white text-xs">
|
||||
<span className="font-bold">{watchedVideos}</span> من <span className="font-bold">{totalVideos}</span> فيديو
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-white/20 backdrop-blur-sm rounded-lg px-3 py-1.5">
|
||||
<div className="h-2 w-24 bg-white/20 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-white/80 rounded-full" style={{ width: `${overallPercentage}%` }} />
|
||||
</div>
|
||||
<span className="text-white text-xs font-bold">{overallPercentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddChannel(!showAddChannel)}
|
||||
className="bg-white/20 backdrop-blur-sm text-white px-4 py-2 rounded-xl font-bold text-sm hover:bg-white/30 transition-all flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
إضافة قناة
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Channel Panel */}
|
||||
{showAddChannel && (
|
||||
<div className="bg-purple-50 border-b border-purple-200 p-4 animate-slide-up">
|
||||
<h3 className="text-sm font-bold text-purple-800 mb-3">إضافة قناة للمجلد</h3>
|
||||
{availableChannels.length > 0 ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{availableChannels.map(ch => (
|
||||
<button
|
||||
key={ch.id}
|
||||
onClick={() => addChannelToFolder(folderId, ch.id)}
|
||||
className="flex items-center gap-2 bg-white rounded-xl px-3 py-2 text-sm border border-purple-200 hover:border-purple-400 transition-all"
|
||||
>
|
||||
<span>{ch.emoji}</span>
|
||||
<span className="text-gray-700 font-bold text-xs">{ch.name}</span>
|
||||
<Plus className="w-3 h-3 text-purple-500" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-purple-400">جميع القنوات المتابعة مضافة بالفعل</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channels in Folder */}
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-bold text-gray-800 flex items-center gap-2 mb-4">
|
||||
<FolderOpen className="w-5 h-5 text-purple-500" />
|
||||
القنوات في هذا المجلد
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 mb-8">
|
||||
{folderChannels.map(ch => {
|
||||
const chPlaylists = getChannelPlaylists(ch.id);
|
||||
return (
|
||||
<div key={ch.id} className="group relative">
|
||||
<button
|
||||
onClick={() => setView({ type: 'channel', channelId: ch.id })}
|
||||
className="w-full bg-white rounded-2xl p-4 shadow-sm hover:shadow-xl transition-all duration-300 text-center border border-gray-100 hover:border-purple-200"
|
||||
>
|
||||
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${ch.gradient} flex items-center justify-center text-2xl mx-auto mb-2 shadow-lg group-hover:scale-110 transition-transform`}>
|
||||
{ch.emoji}
|
||||
</div>
|
||||
<h4 className="font-bold text-gray-800 text-sm truncate">{ch.name}</h4>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">{chPlaylists.length} قائمة</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeChannelFromFolder(folderId, ch.id)}
|
||||
className="absolute top-2 left-2 opacity-0 group-hover:opacity-100 bg-red-100 text-red-500 p-1 rounded-lg hover:bg-red-200 transition-all"
|
||||
title="إزالة من المجلد"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{availableChannels.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowAddChannel(true)}
|
||||
className="bg-gray-50 rounded-2xl p-4 shadow-sm hover:shadow-xl transition-all duration-300 text-center border-2 border-dashed border-gray-200 hover:border-purple-300"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-xl bg-gray-200 flex items-center justify-center text-2xl mx-auto mb-2">
|
||||
<Plus className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
<h4 className="font-bold text-gray-400 text-sm">إضافة قناة</h4>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Playlists */}
|
||||
<h3 className="text-lg font-bold text-gray-800 flex items-center gap-2 mb-4">
|
||||
<List className="w-5 h-5 text-orange-500" />
|
||||
جميع قوائم التشغيل
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{folderPlaylists.map(pl => {
|
||||
const plVideos = getPlaylistVideos(pl.id);
|
||||
const ch = getChannel(pl.channelId);
|
||||
if (!ch) return null;
|
||||
const completedCount = plVideos.filter(v => progress.watchedVideos.includes(v.id)).length;
|
||||
const percentage = plVideos.length > 0 ? Math.round((completedCount / plVideos.length) * 100) : 0;
|
||||
const isComplete = progress.completedPlaylists.includes(pl.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pl.id}
|
||||
onClick={() => setView({ type: 'playlist', playlistId: pl.id })}
|
||||
className="group bg-white rounded-2xl shadow-sm hover:shadow-xl transition-all duration-300 overflow-hidden text-right border border-gray-100 hover:border-orange-200"
|
||||
>
|
||||
<div className={`h-24 bg-gradient-to-br ${pl.gradient} flex items-center justify-center relative`}>
|
||||
<span className="text-4xl opacity-40 group-hover:opacity-70 transition-opacity">{pl.emoji}</span>
|
||||
{isComplete && (
|
||||
<div className="absolute top-2 right-2 bg-emerald-500 text-white text-[10px] px-2 py-0.5 rounded-lg flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
مكتملة
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h4 className="font-bold text-gray-800 text-sm mb-1 group-hover:text-orange-600 transition-colors truncate">{pl.title}</h4>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span className={`w-4 h-4 rounded bg-gradient-to-br ${ch.gradient} flex items-center justify-center text-[8px]`}>
|
||||
{ch.emoji}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">{ch.name}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden mb-1.5">
|
||||
<div
|
||||
className={`h-full rounded-full ${percentage === 100 ? 'bg-emerald-400' : 'bg-gradient-to-l from-orange-400 to-rose-500'}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-gray-400">{completedCount}/{plVideos.length}</span>
|
||||
<span className={`font-bold ${percentage === 100 ? 'text-emerald-500' : 'text-orange-500'}`}>{percentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
320
src/PlaylistView.tsx
Normal file
320
src/PlaylistView.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useApp } from '../store';
|
||||
import { getPlaylist, getPlaylistVideos, getChannel } from '../data';
|
||||
import { ArrowRight, Play, Pause, SkipForward, SkipBack, CheckCircle, RotateCcw } from 'lucide-react';
|
||||
|
||||
export default function PlaylistView({ playlistId }: { playlistId: string }) {
|
||||
const { setView, progress, markVideoWatched, updatePlaylistProgress, completePlaylist } = useApp();
|
||||
const playlist = getPlaylist(playlistId);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [showCelebration, setShowCelebration] = useState(false);
|
||||
const [progressPercent, setProgressPercent] = useState(0);
|
||||
const [autoPlay, setAutoPlay] = useState(true);
|
||||
const [, setElapsed] = useState(0);
|
||||
|
||||
const plVideos = getPlaylistVideos(playlistId);
|
||||
const channel = playlist ? getChannel(playlist.channelId) : null;
|
||||
|
||||
// Set initial index based on progress
|
||||
useEffect(() => {
|
||||
if (plVideos.length > 0) {
|
||||
const lastWatched = progress.playlistProgress[playlistId];
|
||||
if (lastWatched !== undefined) {
|
||||
// Check if all videos before are watched
|
||||
let startFrom = 0;
|
||||
for (let i = 0; i < plVideos.length; i++) {
|
||||
if (!progress.watchedVideos.includes(plVideos[i].id)) {
|
||||
startFrom = i;
|
||||
break;
|
||||
}
|
||||
startFrom = i + 1;
|
||||
}
|
||||
setCurrentIndex(Math.min(startFrom, plVideos.length - 1));
|
||||
}
|
||||
}
|
||||
}, [playlistId]);
|
||||
|
||||
const currentVideo = plVideos[currentIndex];
|
||||
const completedCount = plVideos.filter(v => progress.watchedVideos.includes(v.id)).length;
|
||||
const totalVideos = plVideos.length;
|
||||
const percentage = totalVideos > 0 ? Math.round((completedCount / totalVideos) * 100) : 0;
|
||||
|
||||
// Simulate video playback progress
|
||||
useEffect(() => {
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
if (isPlaying && currentVideo) {
|
||||
interval = setInterval(() => {
|
||||
setElapsed(prev => {
|
||||
const newElapsed = prev + 1;
|
||||
const newPercent = Math.min(100, (newElapsed / currentVideo.durationSeconds) * 100);
|
||||
setProgressPercent(newPercent);
|
||||
|
||||
if (newPercent >= 100) {
|
||||
handleVideoComplete();
|
||||
return 0;
|
||||
}
|
||||
return newElapsed;
|
||||
});
|
||||
}, 100); // Speed up for demo
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [isPlaying, currentIndex]);
|
||||
|
||||
const handleVideoComplete = useCallback(() => {
|
||||
if (!currentVideo) return;
|
||||
|
||||
markVideoWatched(currentVideo.id);
|
||||
updatePlaylistProgress(playlistId, currentIndex);
|
||||
setIsPlaying(false);
|
||||
setProgressPercent(100);
|
||||
|
||||
// Check if playlist is complete
|
||||
const newCompletedCount = plVideos.filter(v =>
|
||||
progress.watchedVideos.includes(v.id) || v.id === currentVideo.id
|
||||
).length;
|
||||
|
||||
if (newCompletedCount >= totalVideos) {
|
||||
completePlaylist(playlistId);
|
||||
setShowCelebration(true);
|
||||
} else if (autoPlay) {
|
||||
// Auto-play next video
|
||||
setTimeout(() => {
|
||||
if (currentIndex < totalVideos - 1) {
|
||||
setCurrentIndex(prev => prev + 1);
|
||||
setElapsed(0);
|
||||
setProgressPercent(0);
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
}, [currentVideo, currentIndex, plVideos, totalVideos, autoPlay]);
|
||||
|
||||
const goToVideo = (index: number) => {
|
||||
if (index >= 0 && index < totalVideos) {
|
||||
setCurrentIndex(index);
|
||||
setElapsed(0);
|
||||
setProgressPercent(0);
|
||||
setIsPlaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const nextVideo = () => {
|
||||
if (currentIndex < totalVideos - 1) {
|
||||
goToVideo(currentIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const prevVideo = () => {
|
||||
if (currentIndex > 0) {
|
||||
goToVideo(currentIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (!playlist || !channel || !currentVideo) {
|
||||
return <div className="p-8 text-center text-gray-400">قائمة التشغيل غير موجودة</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
{/* Celebration Overlay */}
|
||||
{showCelebration && (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center animate-slide-up" onClick={() => setShowCelebration(false)}>
|
||||
<div className="bg-white rounded-3xl p-8 text-center max-w-md mx-4 shadow-2xl">
|
||||
<div className="text-6xl mb-4 animate-float">🎉</div>
|
||||
<h2 className="text-2xl font-black text-gray-800 mb-2">تهانينا!</h2>
|
||||
<p className="text-gray-500 mb-4">لقد أكملت قائمة التشغيل</p>
|
||||
<div className="bg-gradient-to-l from-orange-500 to-rose-500 rounded-xl p-4 text-white mb-4">
|
||||
<div className="text-lg font-bold">{playlist.title}</div>
|
||||
<div className="text-sm text-white/80">{channel.name}</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => { setShowCelebration(false); setView({ type: 'dashboard' }); }} className="flex-1 bg-orange-500 text-white font-bold py-3 rounded-xl hover:bg-orange-600 transition-colors">
|
||||
العودة للرئيسية
|
||||
</button>
|
||||
<button onClick={() => setShowCelebration(false)} className="flex-1 bg-gray-100 text-gray-600 font-bold py-3 rounded-xl hover:bg-gray-200 transition-colors">
|
||||
إغلاق
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Bar */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-white border-b border-gray-100 shrink-0">
|
||||
<button
|
||||
onClick={() => setView({ type: 'channel', channelId: channel.id })}
|
||||
className="flex items-center gap-1 text-gray-400 hover:text-gray-600 text-sm transition-colors"
|
||||
>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
{channel.name}
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 bg-gray-50 rounded-lg px-3 py-1.5">
|
||||
<span className="text-xs text-gray-400">الفيديو</span>
|
||||
<span className="text-sm font-bold text-gray-700">{currentIndex + 1}/{totalVideos}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-32 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${percentage === 100 ? 'bg-emerald-400' : 'bg-gradient-to-l from-orange-400 to-rose-500'}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-gray-500">{percentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Video Player Area */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Video Display */}
|
||||
<div className="flex-1 bg-black relative flex items-center justify-center">
|
||||
<div className={`absolute inset-0 bg-gradient-to-br ${playlist.gradient} opacity-30`} />
|
||||
|
||||
{isPlaying ? (
|
||||
<div className="relative text-center">
|
||||
<div className="text-8xl mb-4 animate-pulse">{playlist.emoji}</div>
|
||||
<h3 className="text-white text-xl font-bold max-w-lg mx-auto">{currentVideo.title}</h3>
|
||||
<p className="text-white/50 text-sm mt-2">جاري التشغيل...</p>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsPlaying(true)}
|
||||
className="relative text-center group cursor-pointer"
|
||||
>
|
||||
<div className="w-24 h-24 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center mx-auto mb-4 group-hover:bg-white/30 transition-all group-hover:scale-110">
|
||||
<Play className="w-10 h-10 text-white mr-[-3px]" />
|
||||
</div>
|
||||
<h3 className="text-white text-xl font-bold max-w-lg mx-auto">{currentVideo.title}</h3>
|
||||
<p className="text-white/50 text-sm mt-2">اضغط للتشغيل</p>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Video Progress Bar */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1.5 bg-white/20">
|
||||
<div
|
||||
className="h-full bg-gradient-to-l from-orange-400 to-rose-400 transition-all"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="bg-[#1a1a2e] px-6 py-3 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={prevVideo} disabled={currentIndex === 0} className="text-white/60 hover:text-white disabled:opacity-30 transition-colors">
|
||||
<SkipForward className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isPlaying) {
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
setIsPlaying(true);
|
||||
markVideoWatched(currentVideo.id);
|
||||
}
|
||||
}}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center transition-all ${
|
||||
isPlaying ? 'bg-white/10 hover:bg-white/20' : 'bg-orange-500 hover:bg-orange-600 shadow-lg shadow-orange-500/30'
|
||||
}`}
|
||||
>
|
||||
{isPlaying ? <Pause className="w-5 h-5 text-white" /> : <Play className="w-5 h-5 text-white mr-[-2px]" />}
|
||||
</button>
|
||||
<button onClick={nextVideo} disabled={currentIndex === totalVideos - 1} className="text-white/60 hover:text-white disabled:opacity-30 transition-colors">
|
||||
<SkipBack className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-white/50 text-xs">
|
||||
{currentVideo.duration}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => { setElapsed(0); setProgressPercent(0); }} className="text-white/40 hover:text-white/70 transition-colors">
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
<label className="flex items-center gap-1.5 text-white/40 text-[10px] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoPlay}
|
||||
onChange={e => setAutoPlay(e.target.checked)}
|
||||
className="w-3 h-3 accent-orange-500"
|
||||
/>
|
||||
تشغيل تلقائي
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Info */}
|
||||
<div className="bg-white px-6 py-4 border-b border-gray-100 shrink-0">
|
||||
<h2 className="text-lg font-bold text-gray-800">{currentVideo.title}</h2>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<button
|
||||
onClick={() => setView({ type: 'channel', channelId: channel.id })}
|
||||
className={`flex items-center gap-2 bg-gradient-to-l ${channel.gradient} text-white px-3 py-1.5 rounded-lg text-xs font-bold hover:opacity-90 transition-opacity`}
|
||||
>
|
||||
{channel.emoji} {channel.name}
|
||||
</button>
|
||||
<span className="text-xs text-gray-400">{currentVideo.viewCount.toLocaleString()} مشاهدة</span>
|
||||
<span className="text-xs text-gray-400">{currentVideo.publishedAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playlist Sidebar */}
|
||||
<div className="w-80 bg-gray-50 border-r border-gray-200 flex flex-col shrink-0">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h3 className="font-bold text-gray-800 text-sm">{playlist.title}</h3>
|
||||
<p className="text-[11px] text-gray-400 mt-1">{completedCount} من {totalVideos} مكتمل</p>
|
||||
<div className="h-1.5 bg-gray-200 rounded-full mt-2 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${percentage === 100 ? 'bg-emerald-400' : 'bg-gradient-to-l from-orange-400 to-rose-500'}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{plVideos.map((video, index) => {
|
||||
const isWatched = progress.watchedVideos.includes(video.id);
|
||||
const isCurrent = index === currentIndex;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={video.id}
|
||||
onClick={() => goToVideo(index)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-right transition-all border-b border-gray-100 ${
|
||||
isCurrent
|
||||
? 'bg-orange-50 border-r-4 border-r-orange-500'
|
||||
: isWatched
|
||||
? 'bg-emerald-50/50'
|
||||
: 'hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 text-xs font-bold ${
|
||||
isCurrent
|
||||
? 'bg-orange-500 text-white shadow-lg shadow-orange-500/30'
|
||||
: isWatched
|
||||
? 'bg-emerald-100 text-emerald-600'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
}`}>
|
||||
{isCurrent ? <Play className="w-3.5 h-3.5" /> : isWatched ? <CheckCircle className="w-3.5 h-3.5" /> : index + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className={`text-xs font-bold truncate ${isCurrent ? 'text-orange-700' : isWatched ? 'text-emerald-700' : 'text-gray-700'}`}>
|
||||
{video.title}
|
||||
</h4>
|
||||
<span className="text-[10px] text-gray-400">{video.duration}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
المرجع في مشكلة جديدة
حظر مستخدم