mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-22 04:03:42 +08:00
feat: Add pagination to the IPTV channel grid and enable editing for IPTV sources.
This commit is contained in:
@@ -2,12 +2,15 @@
|
||||
|
||||
/**
|
||||
* IPTVChannelGrid - Displays IPTV channels grouped by category with search
|
||||
* Uses pagination to handle large playlists without freezing.
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import type { M3UChannel } from '@/lib/utils/m3u-parser';
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
interface IPTVChannelGridProps {
|
||||
channels: M3UChannel[];
|
||||
groups: string[];
|
||||
@@ -18,6 +21,7 @@ interface IPTVChannelGridProps {
|
||||
export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: IPTVChannelGridProps) {
|
||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
|
||||
const filteredChannels = useMemo(() => {
|
||||
let result = channels;
|
||||
@@ -34,6 +38,17 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: I
|
||||
return result;
|
||||
}, [channels, selectedGroup, search]);
|
||||
|
||||
// Reset visible count when filter changes
|
||||
const filterKey = `${selectedGroup}-${search}`;
|
||||
const [lastFilterKey, setLastFilterKey] = useState(filterKey);
|
||||
if (filterKey !== lastFilterKey) {
|
||||
setLastFilterKey(filterKey);
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
}
|
||||
|
||||
const visibleChannels = filteredChannels.slice(0, visibleCount);
|
||||
const hasMore = visibleCount < filteredChannels.length;
|
||||
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-16 text-[var(--text-color-secondary)]">
|
||||
@@ -60,6 +75,13 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: I
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel count */}
|
||||
<div className="text-xs text-[var(--text-color-secondary)]">
|
||||
{filteredChannels.length === channels.length
|
||||
? `共 ${channels.length} 个频道`
|
||||
: `${filteredChannels.length} / ${channels.length} 个频道`}
|
||||
</div>
|
||||
|
||||
{/* Group Tabs */}
|
||||
{groups.length > 0 && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
@@ -94,7 +116,7 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: I
|
||||
|
||||
{/* Channel Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
|
||||
{filteredChannels.map((channel, index) => (
|
||||
{visibleChannels.map((channel, index) => (
|
||||
<button
|
||||
key={`${channel.name}-${index}`}
|
||||
onClick={() => onSelect(channel)}
|
||||
@@ -140,6 +162,18 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: I
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Load More */}
|
||||
{hasMore && (
|
||||
<div className="text-center py-4">
|
||||
<button
|
||||
onClick={() => setVisibleCount(prev => prev + PAGE_SIZE)}
|
||||
className="px-6 py-2 text-xs bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] hover:border-[var(--accent-color)]/30 transition-all cursor-pointer"
|
||||
>
|
||||
加载更多 ({filteredChannels.length - visibleCount} 个剩余)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredChannels.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-[var(--text-color-secondary)]">
|
||||
未找到匹配的频道
|
||||
|
||||
@@ -8,11 +8,25 @@ import { useState } from 'react';
|
||||
import { useIPTVStore, type IPTVSource } from '@/lib/store/iptv-store';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
const inputProps = {
|
||||
spellCheck: false,
|
||||
autoCorrect: 'off' as const,
|
||||
autoCapitalize: 'off' as const,
|
||||
autoComplete: 'off' as const,
|
||||
'data-form-type': 'other',
|
||||
'data-lpignore': 'true',
|
||||
lang: 'en',
|
||||
translate: 'no' as const,
|
||||
};
|
||||
|
||||
export function IPTVSourceManager() {
|
||||
const { sources, addSource, removeSource, refreshSources, isLoading } = useIPTVStore();
|
||||
const { sources, addSource, removeSource, updateSource, refreshSources, isLoading } = useIPTVStore();
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editUrl, setEditUrl] = useState('');
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!name.trim() || !url.trim()) return;
|
||||
@@ -26,6 +40,30 @@ export function IPTVSourceManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (source: IPTVSource) => {
|
||||
setEditingId(source.id);
|
||||
setEditName(source.name);
|
||||
setEditUrl(source.url);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditName('');
|
||||
setEditUrl('');
|
||||
};
|
||||
|
||||
const saveEdit = () => {
|
||||
if (!editingId || !editName.trim() || !editUrl.trim()) return;
|
||||
updateSource(editingId, { name: editName.trim(), url: editUrl.trim() });
|
||||
setEditingId(null);
|
||||
setEditName('');
|
||||
setEditUrl('');
|
||||
// Auto-refresh after editing
|
||||
if (!isLoading) {
|
||||
setTimeout(() => refreshSources(), 100);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -59,19 +97,15 @@ export function IPTVSourceManager() {
|
||||
placeholder="源名称(如:我的IPTV)"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
{...inputProps}
|
||||
className="w-full px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)]"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
type="text"
|
||||
placeholder="M3U 链接地址"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
{...inputProps}
|
||||
className="w-full px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)]"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
@@ -102,18 +136,66 @@ export function IPTVSourceManager() {
|
||||
{sources.map((source) => (
|
||||
<div
|
||||
key={source.id}
|
||||
className="flex items-center justify-between p-3 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]"
|
||||
className="p-3 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-[var(--text-color)] truncate">{source.name}</p>
|
||||
<p className="text-xs text-[var(--text-color-secondary)] truncate">{source.url}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeSource(source.id)}
|
||||
className="ml-2 p-1.5 text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer flex-shrink-0"
|
||||
>
|
||||
<Icons.Trash size={14} />
|
||||
</button>
|
||||
{editingId === source.id ? (
|
||||
/* Edit Mode */
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
{...inputProps}
|
||||
className="w-full px-3 py-1.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] focus:outline-none focus:border-[var(--accent-color)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editUrl}
|
||||
onChange={(e) => setEditUrl(e.target.value)}
|
||||
{...inputProps}
|
||||
className="w-full px-3 py-1.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] focus:outline-none focus:border-[var(--accent-color)]"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="px-3 py-1 text-xs text-[var(--text-color-secondary)] hover:text-[var(--text-color)] transition-colors cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={saveEdit}
|
||||
disabled={!editName.trim() || !editUrl.trim()}
|
||||
className="px-3 py-1 text-xs bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] hover:opacity-90 transition-all cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Display Mode */
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-[var(--text-color)] truncate">{source.name}</p>
|
||||
<p className="text-xs text-[var(--text-color-secondary)] truncate">{source.url}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => startEdit(source)}
|
||||
className="p-1.5 text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] transition-colors cursor-pointer"
|
||||
title="编辑"
|
||||
>
|
||||
<Icons.Edit size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeSource(source.id)}
|
||||
className="p-1.5 text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer"
|
||||
title="删除"
|
||||
>
|
||||
<Icons.Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -212,6 +212,13 @@ export const UtilityIcons = {
|
||||
</svg>
|
||||
),
|
||||
|
||||
Edit: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
),
|
||||
|
||||
Users: ({ className = "", size = 24 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
|
||||
Reference in New Issue
Block a user