Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/components/AddTorrentModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useState, useRef } from 'react'
import { Plus, X, Upload, CheckCircle, Check } from 'lucide-react'
import { useAddTorrent, useCategories } from '../hooks/useTorrents'
import { PathInput } from './ui/PathInput'
import { usePathHistory } from '../hooks/usePathHistory'

interface Props {
open: boolean
Expand All @@ -22,6 +24,7 @@ export function AddTorrentModal({ open, onClose }: Props) {

const { data: categories = {} } = useCategories()
const addMutation = useAddTorrent()
const { addPath } = usePathHistory()

if (!open) return null

Expand All @@ -44,6 +47,7 @@ export function AddTorrentModal({ open, onClose }: Props) {
},
{
onSuccess: () => {
if (savepath.trim()) addPath(savepath.trim())
setUrl('')
setFiles([])
setCategory('')
Expand Down Expand Up @@ -257,10 +261,9 @@ export function AddTorrentModal({ open, onClose }: Props) {
<label className="block text-xs font-medium mb-2" style={{ color: 'var(--text-muted)' }}>
Save path
</label>
<input
type="text"
<PathInput
value={savepath}
onChange={(e) => setSavepath(e.target.value)}
onChange={setSavepath}
placeholder="Default"
className="w-full px-3 py-2.5 rounded-xl border text-sm focus:outline-none transition-colors"
style={{
Expand Down Expand Up @@ -348,3 +351,4 @@ export function AddTorrentModal({ open, onClose }: Props) {
</div>
)
}

51 changes: 36 additions & 15 deletions src/components/ContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useState, useRef, useEffect } from 'react'
import { ChevronRight } from 'lucide-react'
import { PathInput } from './ui/PathInput'
import { usePathHistory } from '../hooks/usePathHistory'
import {
useCategories,
useTags,
Expand Down Expand Up @@ -34,6 +36,7 @@ export function ContextMenu({ x, y, torrents, onClose }: Props) {
const [inputValue, setInputValue] = useState('')
const ref = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const { addPath } = usePathHistory()

const { data: categories = {} } = useCategories()
const { data: tags = [] } = useTags()
Expand Down Expand Up @@ -135,13 +138,13 @@ export function ContextMenu({ x, y, torrents, onClose }: Props) {
}

if (editorMode === 'savePath') {
setLocationMutation.mutate({ hashes, location: value })
setLocationMutation.mutate({ hashes, location: value }, { onSuccess: () => addPath(value) })
onClose()
return
}

if (editorMode === 'downloadPath') {
setDownloadPathMutation.mutate({ hashes, downloadPath: value })
setDownloadPathMutation.mutate({ hashes, downloadPath: value }, { onSuccess: () => addPath(value) })
onClose()
}
}
Expand Down Expand Up @@ -198,19 +201,36 @@ export function ContextMenu({ x, y, torrents, onClose }: Props) {
<div className="text-xs font-medium mb-2" style={{ color: 'var(--text-muted)' }}>
{editorTitle}
</div>
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder={editorPlaceholder}
onKeyDown={(e) => {
if (e.key === 'Enter') handleEditorSubmit()
if (e.key === 'Escape') onClose()
}}
className="w-full px-3 py-2 rounded-lg border text-sm mb-2"
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)', color: 'var(--text-primary)' }}
/>
{editorMode === 'rename' ? (
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder={editorPlaceholder}
onKeyDown={(e) => {
if (e.key === 'Enter') handleEditorSubmit()
if (e.key === 'Escape') onClose()
}}
className="w-full px-3 py-2 rounded-lg border text-sm mb-2"
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)', color: 'var(--text-primary)' }}
/>
) : (
<div className="mb-2">
<PathInput
inputRef={inputRef}
value={inputValue}
onChange={setInputValue}
placeholder={editorPlaceholder}
onKeyDown={(e) => {
if (e.key === 'Enter') handleEditorSubmit()
if (e.key === 'Escape') onClose()
}}
className="w-full px-3 py-2 rounded-lg border text-sm"
style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)', color: 'var(--text-primary)' }}
/>
</div>
)}
<div className="flex gap-2">
<button
onClick={onClose}
Expand Down Expand Up @@ -338,3 +358,4 @@ function MenuItem({
)
}


24 changes: 16 additions & 8 deletions src/components/SearchPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useState, useEffect, Fragment } from 'react'
import { Plus, Trash2, ChevronDown, Filter, X } from 'lucide-react'
import { PathInput } from './ui/PathInput'
import { usePathHistory } from '../hooks/usePathHistory'
import {
getIntegrations,
createIntegration,
Expand Down Expand Up @@ -34,6 +36,7 @@ function formatAge(dateStr: string): string {
export function SearchPanel() {
const [integrations, setIntegrations] = useState<Integration[]>([])
const [instances, setInstances] = useState<Instance[]>([])
const { addPath } = usePathHistory()
const [selectedIntegration, setSelectedIntegration] = useState<Integration | null>(null)
const [indexers, setIndexers] = useState<Indexer[]>([])
const [prowlarrCategories, setProwlarrCategories] = useState<ProwlarrCategory[]>([])
Expand Down Expand Up @@ -212,8 +215,12 @@ export function SearchPanel() {
setGrabResult(null)
const options: { category?: string; savepath?: string; downloadPath?: string } = {}
if (grabCategory) options.category = grabCategory
if (grabSavepath.trim()) options.savepath = grabSavepath.trim()
if (grabDownloadPath.trim()) options.downloadPath = grabDownloadPath.trim()
if (grabSavepath.trim()) {
options.savepath = grabSavepath.trim()
}
if (grabDownloadPath.trim()) {
options.downloadPath = grabDownloadPath.trim()
}
try {
await grabRelease(
selectedIntegration.id,
Expand All @@ -226,6 +233,8 @@ export function SearchPanel() {
grabInstance,
Object.keys(options).length > 0 ? options : undefined
)
if (grabSavepath.trim()) addPath(grabSavepath.trim())
if (grabDownloadPath.trim()) addPath(grabDownloadPath.trim())
setGrabResult({ guid: grabModal.guid, success: true })
closeGrabModal()
setTimeout(() => setGrabResult(null), 3000)
Expand Down Expand Up @@ -1053,10 +1062,9 @@ export function SearchPanel() {
>
Save Path
</label>
<input
type="text"
<PathInput
value={grabSavepath}
onChange={(e) => setGrabSavepath(e.target.value)}
onChange={setGrabSavepath}
disabled={!grabInstance}
placeholder="Default"
className="w-full px-3 py-2 rounded-lg border text-sm disabled:opacity-50"
Expand All @@ -1074,10 +1082,9 @@ export function SearchPanel() {
>
Download Path
</label>
<input
type="text"
<PathInput
value={grabDownloadPath}
onChange={(e) => setGrabDownloadPath(e.target.value)}
onChange={setGrabDownloadPath}
disabled={!grabInstance}
placeholder="Default"
className="w-full px-3 py-2 rounded-lg border text-sm disabled:opacity-50"
Expand Down Expand Up @@ -1113,3 +1120,4 @@ export function SearchPanel() {
)
}


12 changes: 7 additions & 5 deletions src/components/TorrentDetailsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
useRemoveTrackers,
} from '../hooks/useTorrentDetails'
import { useSetTorrentDownloadPath, useSetTorrentLocation } from '../hooks/useTorrents'
import { usePathHistory } from '../hooks/usePathHistory'
import { PathInput } from './ui/PathInput'
import { formatSize, formatSpeed, formatDate, formatDuration, formatEta } from '../utils/format'
import type { Tracker, Peer } from '../types/torrentDetails'
import { buildFileTree, flattenVisibleNodes, getInitialExpanded } from '../utils/fileTree'
Expand Down Expand Up @@ -148,6 +150,7 @@ function GeneralTab({ hash, category, tags }: { hash: string; category: string;
const { data: p, isLoading } = useTorrentProperties(hash)
const setLocationMutation = useSetTorrentLocation()
const setDownloadPathMutation = useSetTorrentDownloadPath()
const { addPath } = usePathHistory()
if (isLoading) return <LoadingSkeleton />
if (!p) return <EmptyState message="Failed to load" />
const properties = p
Expand All @@ -173,13 +176,13 @@ function GeneralTab({ hash, category, tags }: { hash: string; category: string;
if (!trimmed) return

if (editorMode === 'savePath') {
setLocationMutation.mutate({ hashes: [hash], location: trimmed })
setLocationMutation.mutate({ hashes: [hash], location: trimmed }, { onSuccess: () => addPath(trimmed) })
setEditorMode(null)
return
}

if (editorMode === 'downloadPath') {
setDownloadPathMutation.mutate({ hashes: [hash], downloadPath: trimmed })
setDownloadPathMutation.mutate({ hashes: [hash], downloadPath: trimmed }, { onSuccess: () => addPath(trimmed) })
setEditorMode(null)
}
}
Expand Down Expand Up @@ -296,10 +299,9 @@ function GeneralTab({ hash, category, tags }: { hash: string; category: string;
<div className="text-[9px] uppercase tracking-widest" style={{ color: 'var(--text-muted)' }}>
{editorMode === 'savePath' ? 'Change Save Path' : 'Change Download Path'}
</div>
<input
type="text"
<PathInput
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onChange={setInputValue}
onKeyDown={(e) => {
if (e.key === 'Enter') handlePathSave()
if (e.key === 'Escape') setEditorMode(null)
Expand Down
148 changes: 148 additions & 0 deletions src/components/ui/PathInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { usePathHistory } from '../../hooks/usePathHistory'

interface PathInputProps {
value: string
onChange: (value: string) => void
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
placeholder?: string
className?: string
style?: React.CSSProperties
autoFocus?: boolean
disabled?: boolean
inputRef?: React.Ref<HTMLInputElement>
onTouchEnd?: (e: React.TouchEvent<HTMLInputElement>) => void
}

export function PathInput({
value,
onChange,
onKeyDown,
placeholder,
className,
style,
autoFocus,
disabled,
inputRef,
onTouchEnd,
}: PathInputProps) {
const [showSuggestions, setShowSuggestions] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const { getSuggestions } = usePathHistory()
const suggestions = getSuggestions(value)
const containerRef = useRef<HTMLDivElement>(null)
const internalRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLUListElement>(null)

const resolvedRef = (inputRef ?? internalRef) as React.RefObject<HTMLInputElement>

useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setShowSuggestions(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])

useEffect(() => {
if (selectedIndex >= 0 && listRef.current) {
const item = listRef.current.children[selectedIndex] as HTMLElement
item?.scrollIntoView({ block: 'nearest' })
}
}, [selectedIndex])

const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (showSuggestions && suggestions.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0))
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1))
return
}
if (e.key === 'Enter' && selectedIndex >= 0) {
e.preventDefault()
onChange(suggestions[selectedIndex])
setShowSuggestions(false)
return
}
if (e.key === 'Escape') {
setShowSuggestions(false)
return
}
}
onKeyDown?.(e)
},
[showSuggestions, suggestions, selectedIndex, onChange, onKeyDown]
)

function handleSelect(path: string) {
onChange(path)
setShowSuggestions(false)
resolvedRef.current?.focus()
}

const hasSuggestions = suggestions.length > 0

return (
<div ref={containerRef} className="relative">
<input
ref={resolvedRef as React.RefObject<HTMLInputElement>}
type="text"
value={value}
onChange={(e) => {
onChange(e.target.value)
setSelectedIndex(-1)
setShowSuggestions(true)
}}
onFocus={() => setShowSuggestions(true)}
onKeyDown={handleKeyDown}
onTouchEnd={onTouchEnd}
placeholder={placeholder}
className={className}
style={style}
autoFocus={autoFocus}
disabled={disabled}
/>
{showSuggestions && hasSuggestions && (
<ul
ref={listRef}
className="absolute left-0 right-0 z-[300] max-h-40 overflow-y-auto rounded-lg border shadow-lg"
style={{
top: '100%',
marginTop: '2px',
backgroundColor: 'var(--bg-tertiary)',
borderColor: 'var(--border)',
}}
>
{suggestions.map((path: string, i: number) => (
<li
key={path}
onMouseDown={(e) => {
e.preventDefault()
handleSelect(path)
}}
className="px-3 py-1.5 text-xs cursor-pointer truncate"
style={{
color: 'var(--text-primary)',
backgroundColor: i === selectedIndex ? 'var(--bg-hover)' : undefined,
}}
onMouseEnter={() => setSelectedIndex(i)}
>
{path}
</li>
))}
</ul>
)}
</div>
)
}



Loading
Loading