feat: Introduce tag input/list, video player empty/error states, and URL copy utility.

This commit is contained in:
kuekhaoyang
2025-11-29 13:09:07 +08:00
parent 76ca595a98
commit 9691cf5e97
8 changed files with 279 additions and 174 deletions
+34
View File
@@ -0,0 +1,34 @@
'use client';
import { Icons } from '@/components/ui/Icon';
interface TagInputProps {
newTagInput: string;
onNewTagInputChange: (value: string) => void;
onAddTag: () => void;
}
export function TagInput({
newTagInput,
onNewTagInputChange,
onAddTag,
}: TagInputProps) {
return (
<div className="mb-6 flex gap-2">
<input
type="text"
value={newTagInput}
onChange={(e) => onNewTagInputChange(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onAddTag()}
placeholder="添加自定义标签..."
className="flex-1 bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] text-[var(--text-color)] px-4 py-2 focus:outline-none focus:border-[var(--accent-color)] transition-colors rounded-[var(--radius-2xl)]"
/>
<button
onClick={onAddTag}
className="px-6 py-2 bg-[var(--accent-color)] text-white font-semibold hover:opacity-90 transition-opacity rounded-[var(--radius-2xl)] cursor-pointer"
>
</button>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
'use client';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragStartEvent,
DragOverlay,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
horizontalListSortingStrategy,
} from '@dnd-kit/sortable';
import { SortableTag, Tag } from './SortableTag';
import { useState, useRef, useEffect } from 'react';
interface TagListProps {
tags: Tag[];
selectedTag: string;
showTagManager: boolean;
justAddedTag: boolean;
onTagSelect: (tagId: string) => void;
onTagDelete: (tagId: string) => void;
onDragEnd: (event: DragEndEvent) => void;
onJustAddedTagHandled: () => void;
}
export function TagList({
tags,
selectedTag,
showTagManager,
justAddedTag,
onTagSelect,
onTagDelete,
onDragEnd,
onJustAddedTagHandled,
}: TagListProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// Auto-scroll to end when new tag is added
useEffect(() => {
if (justAddedTag && scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
left: scrollContainerRef.current.scrollWidth,
behavior: 'smooth',
});
onJustAddedTagHandled();
}
}, [justAddedTag, onJustAddedTagHandled]);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
setActiveId(null);
onDragEnd(event);
};
const activeTag = tags.find((t) => t.id === activeId);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div
ref={scrollContainerRef}
className="mb-8 flex items-center gap-3 overflow-x-auto pb-3 pt-2 px-1 scrollbar-hide"
>
<SortableContext
items={tags.map((t) => t.id)}
strategy={horizontalListSortingStrategy}
>
{tags.map((tag) => (
<SortableTag
key={tag.id}
tag={tag}
selectedTag={selectedTag}
showTagManager={showTagManager}
onTagSelect={onTagSelect}
onTagDelete={onTagDelete}
/>
))}
</SortableContext>
</div>
<DragOverlay>
{activeId && activeTag ? (
<div className="relative flex-shrink-0 animate-jiggle">
<button className="px-6 py-2.5 text-sm font-semibold whitespace-nowrap rounded-[var(--radius-full)] bg-[var(--accent-color)] text-white shadow-xl scale-110 cursor-grabbing border border-transparent">
{activeTag.label}
</button>
</div>
) : null}
</DragOverlay>
</DndContext>
);
}
+19 -109
View File
@@ -1,24 +1,8 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Icons } from '@/components/ui/Icon';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragStartEvent,
DragOverlay,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
horizontalListSortingStrategy,
} from '@dnd-kit/sortable';
import { SortableTag, Tag } from './SortableTag';
import { DragEndEvent } from '@dnd-kit/core';
import { TagInput } from './TagInput';
import { TagList } from './TagList';
import { Tag } from './SortableTag';
interface TagManagerProps {
tags: Tag[];
@@ -51,42 +35,6 @@ export function TagManager({
onDragEnd,
onJustAddedTagHandled,
}: TagManagerProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// Auto-scroll to end when new tag is added
useEffect(() => {
if (justAddedTag && scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
left: scrollContainerRef.current.scrollWidth,
behavior: 'smooth',
});
onJustAddedTagHandled();
}
}, [justAddedTag, onJustAddedTagHandled]);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
setActiveId(null);
onDragEnd(event);
};
const activeTag = tags.find((t) => t.id === activeId);
return (
<>
{/* Management Controls */}
@@ -111,62 +59,24 @@ export function TagManager({
{/* Add Custom Tag */}
{showTagManager && (
<div className="mb-6 flex gap-2">
<input
type="text"
value={newTagInput}
onChange={(e) => onNewTagInputChange(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onAddTag()}
placeholder="添加自定义标签..."
className="flex-1 bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] text-[var(--text-color)] px-4 py-2 focus:outline-none focus:border-[var(--accent-color)] transition-colors rounded-[var(--radius-2xl)]"
/>
<button
onClick={onAddTag}
className="px-6 py-2 bg-[var(--accent-color)] text-white font-semibold hover:opacity-90 transition-opacity rounded-[var(--radius-2xl)] cursor-pointer"
>
</button>
</div>
<TagInput
newTagInput={newTagInput}
onNewTagInputChange={onNewTagInputChange}
onAddTag={onAddTag}
/>
)}
{/* Tag Filter */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div
ref={scrollContainerRef}
className="mb-8 flex items-center gap-3 overflow-x-auto pb-3 pt-2 px-1 scrollbar-hide"
>
<SortableContext
items={tags.map(t => t.id)}
strategy={horizontalListSortingStrategy}
>
{tags.map((tag) => (
<SortableTag
key={tag.id}
tag={tag}
selectedTag={selectedTag}
showTagManager={showTagManager}
onTagSelect={onTagSelect}
onTagDelete={onTagDelete}
/>
))}
</SortableContext>
</div>
<DragOverlay>
{activeId && activeTag ? (
<div className="relative flex-shrink-0 animate-jiggle">
<button className="px-6 py-2.5 text-sm font-semibold whitespace-nowrap rounded-[var(--radius-full)] bg-[var(--accent-color)] text-white shadow-xl scale-110 cursor-grabbing border border-transparent">
{activeTag.label}
</button>
</div>
) : null}
</DragOverlay>
</DndContext>
<TagList
tags={tags}
selectedTag={selectedTag}
showTagManager={showTagManager}
justAddedTag={justAddedTag}
onTagSelect={onTagSelect}
onTagDelete={onTagDelete}
onDragEnd={onDragEnd}
onJustAddedTagHandled={onJustAddedTagHandled}
/>
</>
);
}
+10 -44
View File
@@ -3,10 +3,10 @@
import { useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Icons } from '@/components/ui/Icon';
import { useHistoryStore } from '@/lib/store/history-store';
import { CustomVideoPlayer } from './CustomVideoPlayer';
import { VideoPlayerError } from './VideoPlayerError';
import { VideoPlayerEmpty } from './VideoPlayerEmpty';
interface VideoPlayerProps {
playUrl: string;
@@ -111,53 +111,19 @@ export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack }: VideoP
: playUrl;
if (!playUrl) {
return (
<Card hover={false} className="p-0 overflow-hidden">
<div className="aspect-video bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] flex items-center justify-center border border-[var(--glass-border)]">
<div className="text-center text-[var(--text-secondary)]">
<Icons.TV size={64} className="text-[var(--text-color-secondary)] mx-auto mb-4" />
<p></p>
</div>
</div>
</Card>
);
return <VideoPlayerEmpty />;
}
return (
<Card hover={false} className="p-0 overflow-hidden">
{videoError ? (
<div className="aspect-video bg-black rounded-[var(--radius-2xl)] flex items-center justify-center">
<div
className="text-center text-white max-w-md px-4"
role="alert"
aria-live="assertive"
aria-atomic="true"
>
<Icons.AlertTriangle size={48} className="mx-auto mb-4 text-red-500" />
<p className="text-lg font-semibold mb-2"></p>
<p className="text-sm text-gray-300 mb-4">{videoError}</p>
<div className="flex gap-2 justify-center flex-wrap">
<Button
variant="secondary"
onClick={onBack}
className="flex items-center gap-2"
>
<Icons.ChevronLeft size={16} />
<span></span>
</Button>
{retryCount < MAX_MANUAL_RETRIES && (
<Button
variant="primary"
onClick={handleRetry}
className="flex items-center gap-2"
>
<Icons.RefreshCw size={16} />
<span> ({retryCount}/{MAX_MANUAL_RETRIES})</span>
</Button>
)}
</div>
</div>
</div>
<VideoPlayerError
error={videoError}
onBack={onBack}
onRetry={handleRetry}
retryCount={retryCount}
maxRetries={MAX_MANUAL_RETRIES}
/>
) : (
<CustomVideoPlayer
key={`${useProxy ? 'proxy' : 'direct'}-${retryCount}`} // Force remount when switching modes or retrying
+17
View File
@@ -0,0 +1,17 @@
'use client';
import { Card } from '@/components/ui/Card';
import { Icons } from '@/components/ui/Icon';
export function VideoPlayerEmpty() {
return (
<Card hover={false} className="p-0 overflow-hidden">
<div className="aspect-video bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] flex items-center justify-center border border-[var(--glass-border)]">
<div className="text-center text-[var(--text-secondary)]">
<Icons.TV size={64} className="text-[var(--text-color-secondary)] mx-auto mb-4" />
<p></p>
</div>
</div>
</Card>
);
}
+55
View File
@@ -0,0 +1,55 @@
'use client';
import { Button } from '@/components/ui/Button';
import { Icons } from '@/components/ui/Icon';
interface VideoPlayerErrorProps {
error: string;
onBack: () => void;
onRetry: () => void;
retryCount: number;
maxRetries: number;
}
export function VideoPlayerError({
error,
onBack,
onRetry,
retryCount,
maxRetries,
}: VideoPlayerErrorProps) {
return (
<div className="aspect-video bg-black rounded-[var(--radius-2xl)] flex items-center justify-center">
<div
className="text-center text-white max-w-md px-4"
role="alert"
aria-live="assertive"
aria-atomic="true"
>
<Icons.AlertTriangle size={48} className="mx-auto mb-4 text-red-500" />
<p className="text-lg font-semibold mb-2"></p>
<p className="text-sm text-gray-300 mb-4">{error}</p>
<div className="flex gap-2 justify-center flex-wrap">
<Button
variant="secondary"
onClick={onBack}
className="flex items-center gap-2"
>
<Icons.ChevronLeft size={16} />
<span></span>
</Button>
{retryCount < maxRetries && (
<Button
variant="primary"
onClick={onRetry}
className="flex items-center gap-2"
>
<Icons.RefreshCw size={16} />
<span> ({retryCount}/{maxRetries})</span>
</Button>
)}
</div>
</div>
</div>
);
}
@@ -7,6 +7,7 @@ import { useControlsVisibility } from './desktop/useControlsVisibility';
import { useUtilities } from './desktop/useUtilities';
import { useDesktopShortcuts } from './desktop/useDesktopShortcuts';
import { useDesktopPlayerState } from './useDesktopPlayerState';
import { getCopyUrl } from '../utils/urlUtils';
type DesktopPlayerState = ReturnType<typeof useDesktopPlayerState>;
@@ -132,27 +133,7 @@ export function useDesktopPlayerLogic({
skipBackward: skipControls.skipBackward,
changePlaybackSpeed: playbackControls.changePlaybackSpeed,
handleCopyLink: (type: 'original' | 'proxy' = 'original') => {
let urlToCopy = src;
// If user wants original link, strip proxy prefix if present
if (type === 'original') {
if (urlToCopy.includes('/api/proxy?url=')) {
const match = urlToCopy.match(/url=([^&]*)/);
if (match && match[1]) {
urlToCopy = decodeURIComponent(match[1]);
}
}
}
// If user wants proxy link, ensure it has proxy prefix
else if (type === 'proxy') {
if (!urlToCopy.includes('/api/proxy?url=')) {
urlToCopy = `${window.location.origin}/api/proxy?url=${encodeURIComponent(urlToCopy)}`;
} else if (urlToCopy.startsWith('/')) {
// Ensure absolute URL for copy
urlToCopy = `${window.location.origin}${urlToCopy}`;
}
}
const urlToCopy = getCopyUrl(src, type);
utilities.handleCopyLink(urlToCopy);
},
startSpeedMenuTimeout: controlsVisibility.startSpeedMenuTimeout,
+24
View File
@@ -0,0 +1,24 @@
export function getCopyUrl(src: string, type: 'original' | 'proxy' = 'original'): string {
let urlToCopy = src;
// If user wants original link, strip proxy prefix if present
if (type === 'original') {
if (urlToCopy.includes('/api/proxy?url=')) {
const match = urlToCopy.match(/url=([^&]*)/);
if (match && match[1]) {
urlToCopy = decodeURIComponent(match[1]);
}
}
}
// If user wants proxy link, ensure it has proxy prefix
else if (type === 'proxy') {
if (!urlToCopy.includes('/api/proxy?url=')) {
urlToCopy = `${window.location.origin}/api/proxy?url=${encodeURIComponent(urlToCopy)}`;
} else if (urlToCopy.startsWith('/')) {
// Ensure absolute URL for copy
urlToCopy = `${window.location.origin}${urlToCopy}`;
}
}
return urlToCopy;
}