feat: seamless auto-update via electron-updater, multi-select filters, session restore
- electron-updater wired with Gitea API discovery: setFeedURL dynamically
per release (Gitea 1.24.7 lacks /releases/latest/download/ shortcut).
Differential download via .blockmap saves ~70 MB per patch. Renderer
banner shows states: available → downloading X% → ready. User clicks
"Установить и перезапустить" → quitAndInstall replaces files + relaunches.
- Multi-select filters per user spec: genres AND (TMDB with_genres comma-
joined), countries OR (pipe-joined into with_origin_country /
with_original_language), years OR (fan-out one request per year, merge
by id since TMDB has no discrete-year OR). Rating stays single threshold.
- Session persistence: openedSession {tabs, activeName} saved to config
on tab create/show/hide/remove/in-app navigation, plus before-quit.
Restored after did-finish-load via ipcMain.emit('create-view',...) per
tab. Survives auto-update relaunch — bring user back to the same page.
- electron-builder publish config (generic provider) so latest.yml is
generated during build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -110,12 +110,24 @@ const Header: React.FC<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange,
|
||||
const showSearchIcon = activeApp === 'home' || activeApp === 'movie-search'
|
||||
|
||||
const [isKiosk, setIsKiosk] = useState(true)
|
||||
const [updateInfo, setUpdateInfo] = useState<{ version: string; currentVersion?: string; releaseUrl: string; installerUrl: string; installerName?: string } | null>(null)
|
||||
type UpdateStatus =
|
||||
| { state: 'available'; version: string; currentVersion?: string }
|
||||
| { state: 'downloading'; percent: number; bytesPerSecond?: number; transferred?: number; total?: number; version?: string; currentVersion?: string }
|
||||
| { state: 'ready'; version: string; currentVersion?: string }
|
||||
| { state: 'manual'; version: string; currentVersion?: string; installerUrl: string; installerName?: string }
|
||||
| { state: 'error'; message: string }
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electron) return
|
||||
const off = window.electron.on('update-available', (info: { version: string; currentVersion?: string; releaseUrl: string; installerUrl: string; installerName?: string }) => {
|
||||
setUpdateInfo(info)
|
||||
const off = window.electron.on('update-status', (info: UpdateStatus) => {
|
||||
setUpdateStatus(prev => {
|
||||
// Preserve version across state transitions when payload omits it (download-progress)
|
||||
if (info.state === 'downloading' && prev && 'version' in prev && prev.version) {
|
||||
return { ...info, version: info.version || prev.version, currentVersion: info.currentVersion || ('currentVersion' in prev ? prev.currentVersion : undefined) }
|
||||
}
|
||||
return info
|
||||
})
|
||||
})
|
||||
return off
|
||||
}, [])
|
||||
@@ -278,13 +290,39 @@ const Header: React.FC<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{updateInfo && (
|
||||
{updateStatus && updateStatus.state !== 'error' && (
|
||||
<div className="update-banner">
|
||||
<span>Доступна версия {updateInfo.version}{updateInfo.currentVersion ? ` (текущая ${updateInfo.currentVersion})` : ''}</span>
|
||||
<button className="update-banner-btn" onClick={() => window.electron?.createView('Обновление', updateInfo.installerUrl, '', 1.0, false)}>
|
||||
{updateInfo.installerName ? 'Скачать установщик' : 'Открыть релиз'}
|
||||
</button>
|
||||
<button className="update-banner-close" onClick={() => setUpdateInfo(null)}>✕</button>
|
||||
{updateStatus.state === 'available' && (
|
||||
<>
|
||||
<span className="update-banner-spinner" />
|
||||
<span>Загружается обновление {updateStatus.version}{updateStatus.currentVersion ? ` (текущая ${updateStatus.currentVersion})` : ''}…</span>
|
||||
</>
|
||||
)}
|
||||
{updateStatus.state === 'downloading' && (
|
||||
<>
|
||||
<span>Скачивается {updateStatus.version || 'обновление'}: {updateStatus.percent}%</span>
|
||||
<div className="update-banner-progress">
|
||||
<div className="update-banner-progress-bar" style={{ width: `${updateStatus.percent}%` }} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{updateStatus.state === 'ready' && (
|
||||
<>
|
||||
<span>Версия {updateStatus.version} готова к установке</span>
|
||||
<button className="update-banner-btn" onClick={() => window.electron?.installUpdate?.()}>
|
||||
Установить и перезапустить
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{updateStatus.state === 'manual' && (
|
||||
<>
|
||||
<span>Доступна {updateStatus.version}{updateStatus.currentVersion ? ` (текущая ${updateStatus.currentVersion})` : ''}</span>
|
||||
<button className="update-banner-btn" onClick={() => window.electron?.createView('Обновление', updateStatus.installerUrl, '', 1.0, false)}>
|
||||
Скачать установщик
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button className="update-banner-close" onClick={() => setUpdateStatus(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -45,6 +45,72 @@ const Select: React.FC<{
|
||||
)
|
||||
}
|
||||
|
||||
// Multi-select dropdown with checkboxes. Trigger shows N selected or placeholder.
|
||||
const MultiSelect: React.FC<{
|
||||
values: string[]
|
||||
onChange: (v: string[]) => void
|
||||
options: { value: string; label: string }[]
|
||||
placeholder: string
|
||||
maxHeight?: number
|
||||
}> = ({ values, onChange, options, placeholder, maxHeight = 260 }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
const toggle = (v: string) => {
|
||||
onChange(values.includes(v) ? values.filter(x => x !== v) : [...values, v])
|
||||
}
|
||||
|
||||
const label = values.length === 0
|
||||
? placeholder
|
||||
: values.length === 1
|
||||
? (options.find(o => o.value === values[0])?.label ?? values[0])
|
||||
: `${placeholder}: ${values.length}`
|
||||
|
||||
return (
|
||||
<div ref={ref} className={`ms-select${open ? ' open' : ''}`}>
|
||||
<div className="ms-select-trigger" onClick={() => setOpen(o => !o)}>
|
||||
<span className={values.length > 0 ? 'ms-select-active' : 'ms-select-placeholder'}>{label}</span>
|
||||
{values.length > 0 && (
|
||||
<button className="ms-multi-clear" onClick={e => { e.stopPropagation(); onChange([]) }} title="Сбросить">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="ms-select-dropdown ms-multi-dropdown" style={{ maxHeight }} onClick={e => e.stopPropagation()}>
|
||||
{options.map(o => {
|
||||
const on = values.includes(o.value)
|
||||
return (
|
||||
<div key={o.value} className={`ms-select-opt ms-multi-opt${on ? ' active' : ''}`} onClick={() => toggle(o.value)}>
|
||||
<span className={`ms-checkbox${on ? ' on' : ''}`}>{on && (
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}</span>
|
||||
{o.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TmdbMovie {
|
||||
id: number
|
||||
mediaType: 'movie' | 'tv'
|
||||
@@ -159,10 +225,10 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
|
||||
const [query, setQuery] = useState(initialQuery)
|
||||
const [mediaType, setMediaType] = useState<'movie' | 'tv'>('movie')
|
||||
const [sortBy, setSortBy] = useState('popularity.desc')
|
||||
const [genreId, setGenreId] = useState<number | null>(null)
|
||||
const [year, setYear] = useState('')
|
||||
const [genreIds, setGenreIds] = useState<number[]>([])
|
||||
const [years, setYears] = useState<string[]>([])
|
||||
const [minRating, setMinRating] = useState('')
|
||||
const [country, setCountry] = useState('')
|
||||
const [countries, setCountries] = useState<string[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
|
||||
@@ -214,7 +280,7 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
|
||||
if (append) setLoadingMore(true)
|
||||
else { setTmdbLoading(true); setMessage('') }
|
||||
try {
|
||||
const res = await window.electron!.discoverTmdb({ apiKey: key, mediaType, sortBy, genreId, year, minRating, country, page: pg })
|
||||
const res = await window.electron!.discoverTmdb({ apiKey: key, mediaType, sortBy, genreIds, years, minRating, countries, page: pg })
|
||||
if (token !== discoverRef.current) return
|
||||
if (res.error) { setMessage(`Ошибка: ${res.error}`); return }
|
||||
setTmdbResults(prev => append ? [...prev, ...res.results] : res.results)
|
||||
@@ -225,14 +291,14 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
|
||||
} finally {
|
||||
if (token === discoverRef.current) { setTmdbLoading(false); setLoadingMore(false) }
|
||||
}
|
||||
}, [mediaType, sortBy, genreId, year, minRating, country])
|
||||
}, [mediaType, sortBy, genreIds, years, minRating, countries])
|
||||
|
||||
useEffect(() => {
|
||||
if (!configLoaded || !apiKey || isSearchMode) return
|
||||
setPage(1)
|
||||
setTmdbResults([])
|
||||
doDiscover(apiKey, 1, false)
|
||||
}, [configLoaded, apiKey, mediaType, sortBy, genreId, year, minRating, country, isSearchMode])
|
||||
}, [configLoaded, apiKey, mediaType, sortBy, genreIds, years, minRating, countries, isSearchMode])
|
||||
|
||||
const searchRef = useRef(0)
|
||||
const doTmdbSearch = async (q: string, key: string) => {
|
||||
@@ -377,11 +443,11 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
|
||||
<div className="ms-type-toggle">
|
||||
<button
|
||||
className={`ms-type-btn${mediaType === 'movie' ? ' active' : ''}`}
|
||||
onClick={() => handleFilterChange(() => { setMediaType('movie'); setGenreId(null) })}
|
||||
onClick={() => handleFilterChange(() => { setMediaType('movie'); setGenreIds([]) })}
|
||||
>Фильмы</button>
|
||||
<button
|
||||
className={`ms-type-btn${mediaType === 'tv' ? ' active' : ''}`}
|
||||
onClick={() => handleFilterChange(() => { setMediaType('tv'); setGenreId(null) })}
|
||||
onClick={() => handleFilterChange(() => { setMediaType('tv'); setGenreIds([]) })}
|
||||
>Сериалы</button>
|
||||
</div>
|
||||
|
||||
@@ -390,7 +456,7 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
|
||||
<Select value={sortBy} onChange={v => handleFilterChange(() => setSortBy(v))} options={SORTS} />
|
||||
)}
|
||||
|
||||
{/* Min rating */}
|
||||
{/* Min rating (single, threshold) */}
|
||||
{!isSearchMode && (
|
||||
<Select
|
||||
value={minRating}
|
||||
@@ -400,34 +466,45 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Year */}
|
||||
<Select
|
||||
value={year}
|
||||
onChange={v => handleFilterChange(() => setYear(v))}
|
||||
options={[{ value: '', label: 'Год' }, ...YEARS.map(y => ({ value: String(y), label: String(y) }))]}
|
||||
{/* Years (multi, OR) */}
|
||||
<MultiSelect
|
||||
values={years}
|
||||
onChange={v => handleFilterChange(() => setYears(v))}
|
||||
options={YEARS.map(y => ({ value: String(y), label: String(y) }))}
|
||||
placeholder="Год"
|
||||
/>
|
||||
|
||||
{/* Country */}
|
||||
{/* Countries (multi, OR) */}
|
||||
{!isSearchMode && (
|
||||
<Select value={country} onChange={v => handleFilterChange(() => setCountry(v))} options={COUNTRIES} placeholder="Страна" />
|
||||
<MultiSelect
|
||||
values={countries}
|
||||
onChange={v => handleFilterChange(() => setCountries(v))}
|
||||
options={COUNTRIES.filter(c => c.value).map(c => ({ value: c.value, label: c.label }))}
|
||||
placeholder="Страна"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Genres */}
|
||||
{/* Genres (multi, AND — фильм должен соответствовать ВСЕМ выбранным жанрам) */}
|
||||
{!isSearchMode && (
|
||||
<div className="ms-genres">
|
||||
<button
|
||||
className={`ms-genre-chip${genreId === null ? ' active' : ''}`}
|
||||
onClick={() => handleFilterChange(() => setGenreId(null))}
|
||||
className={`ms-genre-chip${genreIds.length === 0 ? ' active' : ''}`}
|
||||
onClick={() => handleFilterChange(() => setGenreIds([]))}
|
||||
title="Сбросить жанры"
|
||||
>Все</button>
|
||||
{genres.map(g => (
|
||||
<button
|
||||
key={g.id}
|
||||
className={`ms-genre-chip${genreId === g.id ? ' active' : ''}`}
|
||||
onClick={() => handleFilterChange(() => setGenreId(g.id))}
|
||||
className={`ms-genre-chip${genreIds.includes(g.id) ? ' active' : ''}`}
|
||||
onClick={() => handleFilterChange(() =>
|
||||
setGenreIds(prev => prev.includes(g.id) ? prev.filter(x => x !== g.id) : [...prev, g.id])
|
||||
)}
|
||||
>{g.name}</button>
|
||||
))}
|
||||
{genreIds.length > 1 && (
|
||||
<span className="ms-genres-hint">все выбранные одновременно</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,10 @@ declare global {
|
||||
searchMovies: (query: string, sites: any[]) => Promise<any[]>
|
||||
searchTmdb: (query: string, apiKey: string) => Promise<{ results: any[]; error?: string }>
|
||||
discoverTmdb: (params: any) => Promise<{ results: any[]; totalPages: number; error?: string }>
|
||||
toggleKiosk: () => void
|
||||
getPageMeta: () => Promise<{ poster: string; title: string; url: string } | null>
|
||||
installUpdate: () => Promise<boolean>
|
||||
checkUpdateNow: () => Promise<boolean>
|
||||
toggleKiosk: () => Promise<boolean>
|
||||
isKiosk: () => Promise<boolean>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,6 +788,44 @@ body {
|
||||
.ms-genre-chip:hover { background: rgba(255,255,255,0.1); color: #ccc; }
|
||||
.ms-genre-chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
.ms-genres-hint {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
align-self: center;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* Multi-select dropdown */
|
||||
.ms-multi-dropdown { overflow-y: auto; }
|
||||
.ms-multi-opt { display: flex; align-items: center; gap: 8px; }
|
||||
.ms-multi-opt.active { color: #eee; font-weight: 500; }
|
||||
.ms-checkbox {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
border: 1.5px solid #555;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ms-checkbox.on { background: var(--accent); border-color: var(--accent); }
|
||||
|
||||
.ms-multi-clear {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #777;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.ms-multi-clear:hover { color: #fff; }
|
||||
|
||||
.ms-load-more {
|
||||
display: block;
|
||||
margin: 20px auto 8px;
|
||||
@@ -1442,6 +1480,32 @@ body {
|
||||
}
|
||||
.update-banner-close:hover { color: #fff; }
|
||||
|
||||
.update-banner-progress {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
min-width: 80px;
|
||||
max-width: 200px;
|
||||
}
|
||||
.update-banner-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #E50914, #ff5252);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.update-banner-spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid rgba(255,255,255,0.15);
|
||||
border-top-color: #E50914;
|
||||
border-radius: 50%;
|
||||
animation: ub-spin 0.8s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
@keyframes ub-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ---- Retry btn ---- */
|
||||
.ms-retry-btn {
|
||||
background: none;
|
||||
|
||||
Reference in New Issue
Block a user