mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
Fix issue #72 follow-ups and IPTV source handling
This commit is contained in:
@@ -13,7 +13,6 @@ import { Lock } from 'lucide-react';
|
||||
*/
|
||||
function syncIPTVSources(rawValue: string) {
|
||||
const iptvStore = useIPTVStore.getState();
|
||||
const existingUrls = new Set(iptvStore.sources.map(s => s.url));
|
||||
|
||||
let entries: { name: string; url: string }[] = [];
|
||||
|
||||
@@ -34,12 +33,7 @@ function syncIPTVSources(rawValue: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Add new sources that don't already exist
|
||||
for (const entry of entries) {
|
||||
if (!existingUrls.has(entry.url)) {
|
||||
iptvStore.addSource(entry.name || '直播源', entry.url);
|
||||
}
|
||||
}
|
||||
iptvStore.syncBuiltinSources(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,7 @@ interface FavoriteButtonProps {
|
||||
type?: string;
|
||||
year?: string;
|
||||
remarks?: string;
|
||||
sourceMap?: Record<string, string | number>;
|
||||
className?: string;
|
||||
size?: number;
|
||||
showTooltip?: boolean;
|
||||
@@ -33,6 +34,7 @@ export const FavoriteButton = memo<FavoriteButtonProps>(({
|
||||
type,
|
||||
year,
|
||||
remarks,
|
||||
sourceMap,
|
||||
className = '',
|
||||
size = 20,
|
||||
showTooltip = true,
|
||||
@@ -61,11 +63,12 @@ export const FavoriteButton = memo<FavoriteButtonProps>(({
|
||||
type,
|
||||
year,
|
||||
remarks,
|
||||
sourceMap,
|
||||
});
|
||||
setIsFav(newState);
|
||||
|
||||
setTimeout(() => setIsAnimating(false), 300);
|
||||
}, [videoId, source, title, poster, sourceName, type, year, remarks, toggleFavorite]);
|
||||
}, [videoId, source, title, poster, sourceName, type, year, remarks, sourceMap, toggleFavorite]);
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { formatDate } from '@/lib/utils/format-utils';
|
||||
import { getSourceName } from '@/lib/utils/source-names';
|
||||
import { storeGroupedSources } from '@/lib/utils/grouped-sources-cache';
|
||||
import type { FavoriteItem } from '@/lib/types';
|
||||
|
||||
interface FavoritesItemProps {
|
||||
@@ -20,6 +22,18 @@ export function FavoritesItem({ item, onRemove, isPremium = false }: FavoritesIt
|
||||
source: item.source,
|
||||
title: item.title,
|
||||
});
|
||||
if (item.sourceMap && Object.keys(item.sourceMap).length > 1) {
|
||||
const groupData = Object.entries(item.sourceMap).map(([sourceName, videoId]) => ({
|
||||
id: videoId,
|
||||
source: sourceName,
|
||||
sourceName: getSourceName(sourceName),
|
||||
pic: item.poster,
|
||||
}));
|
||||
const cacheKey = storeGroupedSources(groupData);
|
||||
if (cacheKey) {
|
||||
params.set('gs', cacheKey);
|
||||
}
|
||||
}
|
||||
if (isPremium) {
|
||||
params.set('premium', '1');
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export function HistoryItem({ item, onRemove, isPremium = false }: HistoryItemPr
|
||||
id: videoId,
|
||||
source: sourceName,
|
||||
sourceName: getSourceName(sourceName),
|
||||
pic: item.poster,
|
||||
}));
|
||||
const cacheKey = storeGroupedSources(groupData);
|
||||
if (cacheKey) {
|
||||
@@ -101,6 +102,7 @@ export function HistoryItem({ item, onRemove, isPremium = false }: HistoryItemPr
|
||||
title={item.title}
|
||||
poster={item.poster}
|
||||
remarks={episodeText}
|
||||
sourceMap={item.sourceMap}
|
||||
size={14}
|
||||
className="!p-1.5 !bg-transparent !border-0 !shadow-none hover:!bg-[var(--glass-bg)]"
|
||||
showTooltip={false}
|
||||
|
||||
@@ -71,6 +71,15 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel, cha
|
||||
|
||||
const visibleChannels = filteredChannels.slice(0, visibleCount);
|
||||
const hasMore = visibleCount < filteredChannels.length;
|
||||
const orderedSources = useMemo(() => {
|
||||
if (!sources || !selectedSourceId) return sources || [];
|
||||
const selected = sources.find((source) => source.id === selectedSourceId);
|
||||
return selected ? [selected, ...sources.filter((source) => source.id !== selectedSourceId)] : sources;
|
||||
}, [sources, selectedSourceId]);
|
||||
const orderedGroups = useMemo(() => {
|
||||
if (!selectedGroup) return effectiveGroups;
|
||||
return [selectedGroup, ...effectiveGroups.filter((group) => group !== selectedGroup)];
|
||||
}, [effectiveGroups, selectedGroup]);
|
||||
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
@@ -84,92 +93,90 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel, cha
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search + Group Filter */}
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Icons.Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-color-secondary)]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索频道..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-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="sticky top-4 z-10 -mx-2 px-2 py-2 rounded-[var(--radius-2xl)] border border-[var(--glass-border)] bg-[color-mix(in_srgb,var(--bg-color)_88%,transparent)] backdrop-blur-md">
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Icons.Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-color-secondary)]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索频道..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-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>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-[var(--text-color-secondary)]">
|
||||
{filteredChannels.length === effectiveChannels.length
|
||||
? `共 ${effectiveChannels.length} 个频道`
|
||||
: `${filteredChannels.length} / ${effectiveChannels.length} 个频道`}
|
||||
</div>
|
||||
|
||||
{hasMultipleSources && sources && (
|
||||
<div className="mt-3 flex gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => setSelectedSourceId(null)}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
|
||||
selectedSourceId === null
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{orderedSources.map((source) => {
|
||||
const sourceData = channelsBySource![source.id];
|
||||
if (!sourceData) return null;
|
||||
return (
|
||||
<button
|
||||
key={source.id}
|
||||
onClick={() => setSelectedSourceId(source.id === selectedSourceId ? null : source.id)}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
|
||||
selectedSourceId === source.id
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
|
||||
}`}
|
||||
>
|
||||
{source.name} ({sourceData.channels.length})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectiveGroups.length > 0 && (
|
||||
<div className="mt-3 flex gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => setSelectedGroup(null)}
|
||||
className={`px-3 py-1 text-xs rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
|
||||
selectedGroup === null
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
|
||||
}`}
|
||||
>
|
||||
全部 ({effectiveChannels.length})
|
||||
</button>
|
||||
{orderedGroups.map((group) => {
|
||||
const count = effectiveChannels.filter((channel) => channel.group === group).length;
|
||||
return (
|
||||
<button
|
||||
key={group}
|
||||
onClick={() => setSelectedGroup(group === selectedGroup ? null : group)}
|
||||
className={`px-3 py-1 text-xs rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
|
||||
selectedGroup === group
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
|
||||
}`}
|
||||
>
|
||||
{group} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Channel count */}
|
||||
<div className="text-xs text-[var(--text-color-secondary)]">
|
||||
{filteredChannels.length === effectiveChannels.length
|
||||
? `共 ${effectiveChannels.length} 个频道`
|
||||
: `${filteredChannels.length} / ${effectiveChannels.length} 个频道`}
|
||||
</div>
|
||||
|
||||
{/* Source Tabs (only when multiple sources) */}
|
||||
{hasMultipleSources && sources && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => setSelectedSourceId(null)}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
|
||||
selectedSourceId === null
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{sources.map((source) => {
|
||||
const sourceData = channelsBySource![source.id];
|
||||
if (!sourceData) return null;
|
||||
return (
|
||||
<button
|
||||
key={source.id}
|
||||
onClick={() => setSelectedSourceId(source.id === selectedSourceId ? null : source.id)}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
|
||||
selectedSourceId === source.id
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
|
||||
}`}
|
||||
>
|
||||
{source.name} ({sourceData.channels.length})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group Tabs */}
|
||||
{effectiveGroups.length > 0 && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => setSelectedGroup(null)}
|
||||
className={`px-3 py-1 text-xs rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
|
||||
selectedGroup === null
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
|
||||
}`}
|
||||
>
|
||||
全部 ({effectiveChannels.length})
|
||||
</button>
|
||||
{effectiveGroups.map((group) => {
|
||||
const count = effectiveChannels.filter((c) => c.group === group).length;
|
||||
return (
|
||||
<button
|
||||
key={group}
|
||||
onClick={() => setSelectedGroup(group === selectedGroup ? null : group)}
|
||||
className={`px-3 py-1 text-xs rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
|
||||
selectedGroup === group
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
|
||||
}`}
|
||||
>
|
||||
{group} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
|
||||
{visibleChannels.map((channel, index) => (
|
||||
|
||||
@@ -54,6 +54,22 @@ function formatTime(seconds: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getSeekRange(video: HTMLVideoElement): { start: number; end: number; duration: number } | null {
|
||||
if (video.seekable.length > 0) {
|
||||
const start = video.seekable.start(0);
|
||||
const end = video.seekable.end(video.seekable.length - 1);
|
||||
if (isFinite(start) && isFinite(end) && end > start) {
|
||||
return { start, end, duration: end - start };
|
||||
}
|
||||
}
|
||||
|
||||
if (isFinite(video.duration) && video.duration > 0) {
|
||||
return { start: 0, end: video.duration, duration: video.duration };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channelsBySource, sources }: IPTVPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
@@ -82,8 +98,6 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
const [showAllRoutes, setShowAllRoutes] = useState(false);
|
||||
|
||||
// Multi-level sidebar state
|
||||
const [activeSourceId, setActiveSourceId] = useState<string | null>(null);
|
||||
const [activeGroup, setActiveGroup] = useState<string | null>(null);
|
||||
const [expandedSources, setExpandedSources] = useState<Set<string>>(new Set());
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -434,12 +448,24 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
if (isLive) return;
|
||||
const video = videoRef.current;
|
||||
const bar = progressRef.current;
|
||||
if (!video || !duration || !bar) return;
|
||||
if (!video || !bar) return;
|
||||
const seekRange = getSeekRange(video);
|
||||
if (!seekRange) return;
|
||||
const rect = bar.getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
video.currentTime = ratio * duration;
|
||||
video.currentTime = seekRange.start + ratio * seekRange.duration;
|
||||
};
|
||||
|
||||
const progressPercent = useMemo(() => {
|
||||
const video = videoRef.current;
|
||||
const seekRange = video ? getSeekRange(video) : null;
|
||||
if (seekRange) {
|
||||
return Math.max(0, Math.min(100, ((currentTime - seekRange.start) / seekRange.duration) * 100));
|
||||
}
|
||||
if (!duration) return 0;
|
||||
return Math.max(0, Math.min(100, (currentTime / duration) * 100));
|
||||
}, [currentTime, duration]);
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
if (!containerRef.current) return;
|
||||
if (document.fullscreenElement) {
|
||||
@@ -789,7 +815,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
>
|
||||
<div
|
||||
className="h-full bg-[var(--accent-color)] rounded-full relative pointer-events-none"
|
||||
style={{ width: `${(currentTime / duration) * 100}%` }}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useIPTVStore, type IPTVSource } from '@/lib/store/iptv-store';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { hasPermission } from '@/lib/store/auth-store';
|
||||
|
||||
const inputProps = {
|
||||
spellCheck: false,
|
||||
@@ -21,12 +22,14 @@ const inputProps = {
|
||||
|
||||
export function IPTVSourceManager() {
|
||||
const { sources, addSource, removeSource, updateSource, refreshSources, isLoading } = useIPTVStore();
|
||||
const canUseBuiltinSources = hasPermission('iptv_builtin_sources');
|
||||
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 visibleSources = sources.filter((source) => canUseBuiltinSources || source.kind !== 'builtin');
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!name.trim() || !url.trim()) return;
|
||||
@@ -73,7 +76,7 @@ export function IPTVSourceManager() {
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => refreshSources()}
|
||||
disabled={isLoading || sources.length === 0}
|
||||
disabled={isLoading || visibleSources.length === 0}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 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 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Icons.RefreshCw size={12} className={isLoading ? 'animate-spin' : ''} />
|
||||
@@ -127,13 +130,13 @@ export function IPTVSourceManager() {
|
||||
)}
|
||||
|
||||
{/* Source List */}
|
||||
{sources.length === 0 ? (
|
||||
{visibleSources.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-[var(--text-color-secondary)]">
|
||||
暂无直播源,请添加 M3U 或 JSON 播放列表链接
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sources.map((source) => (
|
||||
{visibleSources.map((source) => (
|
||||
<div
|
||||
key={source.id}
|
||||
className="p-3 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]"
|
||||
@@ -175,24 +178,39 @@ export function IPTVSourceManager() {
|
||||
/* 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>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-[var(--text-color)] truncate">{source.name}</p>
|
||||
{source.kind === 'builtin' && (
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] font-medium bg-[var(--accent-color)]/10 text-[var(--accent-color)]">
|
||||
内置
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
{source.kind !== 'builtin' ? (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[10px] text-[var(--text-color-secondary)] px-2">
|
||||
环境变量提供
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -31,6 +31,7 @@ export function SourceBadgeItem({
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={innerRef}
|
||||
onClick={handleClick}
|
||||
onFocus={onFocus}
|
||||
|
||||
@@ -161,6 +161,7 @@ export function SourceBadgeList({ sources, selectedSources, onToggleSource }: So
|
||||
|
||||
{hasOverflow && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpanded}
|
||||
className="mt-2 text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)]
|
||||
flex items-center gap-1 transition-colors self-start cursor-pointer"
|
||||
|
||||
@@ -29,6 +29,7 @@ export function TypeBadgeItem({
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={innerRef}
|
||||
onClick={handleClick}
|
||||
onFocus={onFocus}
|
||||
|
||||
@@ -116,6 +116,7 @@ export function TypeBadgeList({ badges, selectedTypes, onToggleType }: TypeBadge
|
||||
</div>
|
||||
{hasOverflow && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpanded}
|
||||
className="mt-2 text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)]
|
||||
flex items-center gap-1 transition-colors self-start cursor-pointer"
|
||||
|
||||
@@ -168,6 +168,7 @@ export const VideoGroupCard = memo<VideoGroupCardProps>(({
|
||||
type={representative.type_name}
|
||||
year={representative.vod_year}
|
||||
remarks={representative.vod_remarks}
|
||||
sourceMap={Object.fromEntries(videos.map((video) => [video.source, video.vod_id]))}
|
||||
size={16}
|
||||
className="shadow-md"
|
||||
isPremium={isPremium}
|
||||
|
||||
@@ -27,11 +27,13 @@ const ALL_PERMISSIONS: { key: Permission; label: string }[] = [
|
||||
{ key: 'player_settings', label: '播放器设置' },
|
||||
{ key: 'danmaku_appearance', label: '弹幕外观' },
|
||||
{ key: 'iptv_access', label: 'IPTV 访问' },
|
||||
{ key: 'iptv_source_management', label: 'IPTV 自定义源管理' },
|
||||
{ key: 'iptv_builtin_sources', label: 'IPTV 内置源' },
|
||||
];
|
||||
|
||||
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
super_admin: ['source_management', 'account_management', 'danmaku_api', 'data_management', 'player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access'],
|
||||
admin: ['player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access'],
|
||||
super_admin: ['source_management', 'account_management', 'danmaku_api', 'data_management', 'player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access', 'iptv_source_management', 'iptv_builtin_sources'],
|
||||
admin: ['player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access', 'iptv_source_management', 'iptv_builtin_sources'],
|
||||
viewer: ['view_settings'],
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,17 @@ import { ModalBackdrop } from '@/components/ui/ModalBackdrop';
|
||||
import { ModalHeader } from '@/components/ui/ModalHeader';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
interface AddSourceModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -49,6 +60,7 @@ export function AddSourceModal({ isOpen, onClose, onAdd, existingIds, initialVal
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例如:新视频源"
|
||||
{...inputProps}
|
||||
className="w-full bg-[var(--glass-bg)] backdrop-blur-md border border-[var(--glass-border)] rounded-[var(--radius-2xl)] px-4 py-3 text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)] focus:outline-none focus:border-[var(--accent-color)] focus:ring-4 focus:ring-[color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s]"
|
||||
/>
|
||||
</div>
|
||||
@@ -64,6 +76,7 @@ export function AddSourceModal({ isOpen, onClose, onAdd, existingIds, initialVal
|
||||
onChange={(e) => setCustomId(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||
placeholder="自动生成,可手动修改"
|
||||
disabled={isEditing}
|
||||
{...inputProps}
|
||||
className="w-full bg-[var(--glass-bg)] backdrop-blur-md border border-[var(--glass-border)] rounded-[var(--radius-2xl)] px-4 py-3 text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)] focus:outline-none focus:border-[var(--accent-color)] focus:ring-4 focus:ring-[color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s] disabled:opacity-50"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[var(--text-color-secondary)]">
|
||||
@@ -81,6 +94,7 @@ export function AddSourceModal({ isOpen, onClose, onAdd, existingIds, initialVal
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com/api.php/provide/vod"
|
||||
{...inputProps}
|
||||
className="w-full bg-[var(--glass-bg)] backdrop-blur-md border border-[var(--glass-border)] rounded-[var(--radius-2xl)] px-4 py-3 text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)] focus:outline-none focus:border-[var(--accent-color)] focus:ring-4 focus:ring-[color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user