feat(ui): add one-click texts, file existence checks, clipboard copy (#15)

* feat(ui): add one-click texts, file existence checks, clipboard copy

* refactor(ui): remove unused clearCompleted and adjust card bg
This commit is contained in:
Nexmoe
2025-11-08 13:44:28 +08:00
committed by GitHub
parent 2f5776d1e0
commit 7c2e526c49
7 changed files with 138 additions and 92 deletions

View File

@@ -229,6 +229,25 @@ class FileSystemService extends IpcService {
.replace(/'/g, ''')
}
@IpcMethod()
async fileExists(_context: IpcContext, filePath: string): Promise<boolean> {
try {
if (!filePath) {
return false
}
const sanitizedPath = this.sanitizePath(filePath)
const normalizedPath = path.normalize(sanitizedPath)
const stats = await fs.stat(normalizedPath).catch(() => null)
return stats?.isFile() ?? false
} catch (error) {
console.error('Failed to check file existence:', error)
return false
}
}
@IpcMethod()
async deleteFile(_context: IpcContext, filePath: string): Promise<boolean> {
try {

View File

@@ -153,7 +153,12 @@ function AppContent() {
const renderPage = () => {
switch (currentPage) {
case 'home':
return <Home onOpenSupportedSites={() => setCurrentPage('sites')} />
return (
<Home
onOpenSupportedSites={() => setCurrentPage('sites')}
onOpenSettings={() => setCurrentPage('settings')}
/>
)
case 'settings':
return <Settings />
case 'about':
@@ -161,7 +166,12 @@ function AppContent() {
case 'sites':
return <SupportedSites />
default:
return <Home onOpenSupportedSites={() => setCurrentPage('sites')} />
return (
<Home
onOpenSupportedSites={() => setCurrentPage('sites')}
onOpenSettings={() => setCurrentPage('settings')}
/>
)
}
}

View File

@@ -5,6 +5,7 @@ import { Progress } from '@renderer/components/ui/progress'
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
import { useAtomValue, useSetAtom } from 'jotai'
import { AlertCircle, CheckCircle2, Copy, FolderOpen, Loader2, Play, Trash2, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
@@ -67,6 +68,30 @@ export function DownloadItem({ download }: DownloadItemProps) {
? actionsContainerBaseClass
: `${actionsContainerBaseClass} sm:opacity-0 sm:group-hover:opacity-100`
// Track if the file exists
const [fileExists, setFileExists] = useState(false)
// Check if file exists when download data changes
useEffect(() => {
const checkFileExists = async () => {
if (!download.title || !download.downloadPath || !download.format) {
setFileExists(false)
return
}
try {
const filePath = generateFilePath(download.downloadPath, download.title, download.format)
const exists = await ipcServices.fs.fileExists(filePath)
setFileExists(exists)
} catch (error) {
console.error('Failed to check file existence:', error)
setFileExists(false)
}
}
checkFileExists()
}, [download.title, download.downloadPath, download.format])
const handleCancel = async () => {
if (isHistory) return
try {
@@ -93,18 +118,31 @@ export function DownloadItem({ download }: DownloadItemProps) {
toast.error(t('notifications.openFolderFailed'))
}
}
// Check if copy to clipboard is available
const canCopyToClipboard = () => {
return !!(download.title && download.downloadPath && download.format && fileExists)
}
// need title, downloadPath, format
const handleCopyToClipboard = async () => {
if (!download.title || !download.downloadPath || !download.format) {
if (!canCopyToClipboard()) {
toast.error(t('notifications.copyFailed'))
return
}
// Type guard: these values are guaranteed to exist after canCopyToClipboard() check
const downloadPath = download.downloadPath
const format = download.format
const title = download.title
if (!downloadPath || !format || !title) {
toast.error(t('notifications.copyFailed'))
return
}
try {
// Generate file path using downloadPath + title + ext
const downloadPath = download.downloadPath
const format = download.format
const filePath = generateFilePath(downloadPath, download.title, format)
const filePath = generateFilePath(downloadPath, title, format)
const success = await ipcServices.fs.copyFileToClipboard(filePath)
if (!success) {
@@ -320,7 +358,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleCopyToClipboard}
disabled={!download.title || !download.downloadPath || !download.format}
disabled={!canCopyToClipboard()}
>
<Copy className="h-4 w-4" />
</Button>
@@ -373,6 +411,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleCopyToClipboard}
disabled={!canCopyToClipboard()}
>
<Copy className="h-4 w-4" />
</Button>

View File

@@ -1,13 +1,13 @@
import { Button } from '@renderer/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
import { CardContent, CardHeader } from '@renderer/components/ui/card'
import { cn } from '@renderer/lib/utils'
import { useAtomValue, useSetAtom } from 'jotai'
import { useAtomValue } from 'jotai'
import { History as HistoryIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useHistorySync } from '../../hooks/use-history-sync'
import type { DownloadRecord } from '../../store/downloads'
import { clearCompletedAtom, downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
import { downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
import { DownloadItem } from './DownloadItem'
import { PlaylistDownloadGroup } from './PlaylistDownloadGroup'
@@ -17,7 +17,6 @@ export function UnifiedDownloadHistory() {
const { t } = useTranslation()
const allRecords = useAtomValue(downloadsArrayAtom)
const downloadStats = useAtomValue(downloadStatsAtom)
const clearCompleted = useSetAtom(clearCompletedAtom)
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
useHistorySync()
@@ -99,33 +98,9 @@ export function UnifiedDownloadHistory() {
return { order, groups }
}, [filteredRecords])
const hasCompletedActive = allRecords.some(
(item) => item.entryType === 'active' && item.status === 'completed'
)
const handleClearCompleted = () => {
clearCompleted()
}
return (
<Card className="border border-border/60 bg-background max-w-full shadow-sm backdrop-blur-sm">
<CardHeader className="gap-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-1">
<CardTitle>{t('download.downloadQueue')}</CardTitle>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
{hasCompletedActive && (
<Button
variant="ghost"
size="sm"
className="h-8 border border-border/60 px-3"
onClick={handleClearCompleted}
>
{t('download.clearCompleted')}
</Button>
)}
</div>
</div>
<div className="space-y-4">
<CardHeader className="gap-4 p-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
{filters.map((filter) => {
const isActive = statusFilter === filter.key
@@ -155,7 +130,7 @@ export function UnifiedDownloadHistory() {
})}
</div>
</CardHeader>
<CardContent className="space-y-3 overflow-hidden w-full">
<CardContent className="space-y-3 p-0 overflow-hidden w-full">
{filteredRecords.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border/60 px-6 py-10 text-center text-muted-foreground">
<HistoryIcon className="h-10 w-10 opacity-50" />
@@ -191,6 +166,6 @@ export function UnifiedDownloadHistory() {
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -139,8 +139,10 @@
"noAudio": "No Audio",
"noHistory": "No download history",
"noItems": "No items found",
"goToSettings": "Go to Settings",
"oneClickDownload": "One-Click Download",
"oneClickDownloadDescription": "Download directly with default settings without confirmation",
"oneClickDownloadEnabled": "One-Click Download is enabled. Downloads will start directly with default settings.",
"oneClickDownloadNow": "Download Now",
"oneClickDownloadStarted": "Download started with default settings",
"paste": "Paste",

View File

@@ -319,49 +319,49 @@ export function About() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('about.followAuthorTitle')}</CardTitle>
<CardDescription>{t('about.followAuthorDescription')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-muted-foreground md:max-w-md">
{t('about.followAuthorSupport')}
</p>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => openShareUrl('https://x.com/nexmoex')}
className="gap-2"
>
<Twitter className="h-4 w-4" />
{t('about.followAuthorActions.follow')}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('about.shareTitle')}</CardTitle>
<CardDescription>{t('about.shareDescription')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-muted-foreground md:max-w-md">{t('about.shareSupport')}</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={handleShareTwitter} className="gap-2">
<Twitter className="h-4 w-4" />
{t('about.shareActions.twitter')}
</Button>
<Button variant="outline" size="sm" onClick={handleShareFacebook} className="gap-2">
<Facebook className="h-4 w-4" />
{t('about.shareActions.facebook')}
</Button>
<Button variant="secondary" size="sm" onClick={handleCopyShareLink} className="gap-2">
<LinkIcon className="h-4 w-4" />
{t('about.shareActions.copy')}
</Button>
<CardContent className="space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-muted-foreground md:max-w-md">{t('about.shareSupport')}</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={handleShareTwitter} className="gap-2">
<Twitter className="h-4 w-4" />
{t('about.shareActions.twitter')}
</Button>
<Button variant="outline" size="sm" onClick={handleShareFacebook} className="gap-2">
<Facebook className="h-4 w-4" />
{t('about.shareActions.facebook')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleCopyShareLink}
className="gap-2"
>
<LinkIcon className="h-4 w-4" />
{t('about.shareActions.copy')}
</Button>
</div>
</div>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-muted-foreground md:max-w-md">
{t('about.followAuthorSupport')}
</p>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => openShareUrl('https://x.com/nexmoex')}
className="gap-2"
>
<Twitter className="h-4 w-4" />
{t('about.followAuthorActions.follow')}
</Button>
</div>
</div>
</CardContent>
</Card>

View File

@@ -19,7 +19,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u
import { popularSites } from '@renderer/data/popularSites'
import type { AppSettings, OneClickQualityPreset, PlaylistInfo } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai'
import { AlertCircle, Download, ListVideo, Loader2, Search } from 'lucide-react'
import { AlertCircle, Download, Loader2, Search } from 'lucide-react'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -124,9 +124,10 @@ const buildAudioFormatPreference = (settings: AppSettings): string => {
interface HomeProps {
onOpenSupportedSites?: () => void
onOpenSettings?: () => void
}
export function Home({ onOpenSupportedSites }: HomeProps) {
export function Home({ onOpenSupportedSites, onOpenSettings }: HomeProps) {
const { t } = useTranslation()
const [videoInfo, _setVideoInfo] = useAtom(currentVideoInfoAtom)
const [loading] = useAtom(videoInfoLoadingAtom)
@@ -538,11 +539,9 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
<Tabs defaultValue="single" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="single" className="flex items-center gap-2">
<Download className="h-4 w-4" />
{t('download.singleVideo')}
</TabsTrigger>
<TabsTrigger value="playlist" className="flex items-center gap-2">
<ListVideo className="h-4 w-4" />
{t('playlist.title')}
</TabsTrigger>
</TabsList>
@@ -622,18 +621,20 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
{/* One-Click Download Info */}
{settings.oneClickDownload && (
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/10 p-4">
<div className="flex items-start gap-3">
<Download className="h-5 w-5 text-blue-600 mt-0.5 dark:text-blue-400" />
<div className="flex-1 space-y-1">
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
{t('download.oneClickDownload')}
</p>
<p className="text-sm text-blue-700">
{t('download.oneClickDownloadDescription')}
</p>
</div>
<div className="flex items-center justify-between gap-2 rounded-lg bg-card px-4 py-3 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span>{t('download.oneClickDownloadEnabled')}</span>
</div>
{onOpenSettings && (
<Button
type="button"
variant="link"
className="h-auto px-2 py-0 text-xs"
onClick={onOpenSettings}
>
{t('download.goToSettings')}
</Button>
)}
</div>
)}