fix: kiosk toggle sync, load more animation, remove nsis target

This commit is contained in:
2026-03-14 12:48:29 +03:00
parent c31e4a304d
commit 14da54f204
77 changed files with 485 additions and 1424 deletions

View File

@@ -105,6 +105,16 @@ const Header: React.FC<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange,
const appOpen = activeApp !== 'home' && activeApp !== 'movie-search'
const showSearchIcon = activeApp === 'home' || activeApp === 'movie-search'
const [isKiosk, setIsKiosk] = useState(true)
useEffect(() => {
window.electron?.isKiosk().then(k => setIsKiosk(k))
}, [])
const toggleKiosk = () => {
window.electron?.toggleKiosk().then((newState: boolean) => setIsKiosk(newState))
}
const [isBookmarked, setIsBookmarked] = useState(false)
const handleBookmark = () => {
@@ -147,6 +157,25 @@ const Header: React.FC<HeaderProps> = ({ activeApp, setActiveApp, onAppsChange,
{(!isCollapsed || isHovered) && (
<>
<div className="header-left">
<div className="header-btn" onClick={toggleKiosk} title={isKiosk ? 'Выйти из режима киоска' : 'Режим киоска'}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#aaa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{isKiosk ? (
<>
<polyline points="4 14 10 14 10 20" />
<polyline points="20 10 14 10 14 4" />
<line x1="10" y1="14" x2="3" y2="21" />
<line x1="21" y1="3" x2="14" y2="10" />
</>
) : (
<>
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</>
)}
</svg>
</div>
<div className="header-btn" onClick={openSettings} title="Настройки">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#aaa" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />

View File

@@ -129,6 +129,32 @@ const StarIcon = () => (
</svg>
)
const MovieCard: React.FC<{ movie: TmdbMovie; idx: number; baseIdx: number; onSelect: (m: TmdbMovie) => void }> = ({ movie, idx, baseIdx, onSelect }) => (
<div
className="movie-result-card"
style={{ animationDelay: `${Math.max(0, idx - baseIdx) * 30}ms` }}
onClick={() => onSelect(movie)}
>
<div className="movie-result-poster">
{movie.poster
? <img src={movie.poster} alt={movie.title} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style') }} />
: null
}
<div className="movie-result-poster-placeholder" style={movie.poster ? { display: 'none' } : undefined}>
{movie.title.charAt(0).toUpperCase()}
</div>
</div>
<div className="movie-result-info">
<span className="movie-result-title">{movie.title}</span>
<div className="ms-card-meta">
{movie.year && <span className="movie-result-year">{movie.year}</span>}
{movie.rating && <span className="ms-card-rating"><StarIcon /> {movie.rating}</span>}
</div>
{movie.mediaType === 'tv' && <span className="ms-card-type">Сериал</span>}
</div>
</div>
)
const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initialQuery = '' }) => {
const [query, setQuery] = useState(initialQuery)
const [mediaType, setMediaType] = useState<'movie' | 'tv'>('movie')
@@ -147,6 +173,7 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
const [tmdbLoading, setTmdbLoading] = useState(false)
const [sitesLoading, setSitesLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [cardBase, setCardBase] = useState(0)
const [message, setMessage] = useState('')
const [apiKey, setApiKey] = useState('')
const [sites, setSites] = useState<MovieSite[]>([])
@@ -295,6 +322,7 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
const handleLoadMore = () => {
const nextPage = page + 1
setPage(nextPage)
setCardBase(tmdbResults.length)
doDiscover(apiKey, nextPage, true)
}
@@ -313,31 +341,6 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
const loading = tmdbLoading || sitesLoading
const MovieCard = ({ movie, idx }: { movie: TmdbMovie; idx: number }) => (
<div
className="movie-result-card"
style={{ animationDelay: `${idx * 30}ms` }}
onClick={() => handleSelectMovie(movie)}
>
<div className="movie-result-poster">
{movie.poster
? <img src={movie.poster} alt={movie.title} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style') }} />
: null
}
<div className="movie-result-poster-placeholder" style={movie.poster ? { display: 'none' } : undefined}>
{movie.title.charAt(0).toUpperCase()}
</div>
</div>
<div className="movie-result-info">
<span className="movie-result-title">{movie.title}</span>
<div className="ms-card-meta">
{movie.year && <span className="movie-result-year">{movie.year}</span>}
{movie.rating && <span className="ms-card-rating"><StarIcon /> {movie.rating}</span>}
</div>
{movie.mediaType === 'tv' && <span className="ms-card-type">Сериал</span>}
</div>
</div>
)
return (
<div className="movie-search">
@@ -495,7 +498,7 @@ const MovieSearch: React.FC<MovieSearchProps> = ({ onOpenUrl, onBookmark, initia
{tmdbResults.length > 0 && (
<>
<div className="movie-results">
{tmdbResults.map((movie, i) => <MovieCard key={`${movie.id}-${i}`} movie={movie} idx={i} />)}
{tmdbResults.map((movie, i) => <MovieCard key={movie.id} movie={movie} idx={i} baseIdx={cardBase} onSelect={handleSelectMovie} />)}
</div>
{!isSearchMode && page < totalPages && (
<button className="ms-load-more" onClick={handleLoadMore} disabled={loadingMore}>

View File

@@ -0,0 +1,40 @@
import React, { useEffect, useState } from 'react'
import ReactDOM from 'react-dom/client'
import '../styles/dialogs.css'
declare global {
interface Window {
electron?: { handleAction: (action: string) => void }
__dialogData?: { text?: string }
}
}
const ConfirmDialog = () => {
const [visible, setVisible] = useState(false)
const params = new URLSearchParams(window.location.search)
const text = params.get('text') || window.__dialogData?.text || ''
useEffect(() => {
requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)))
}, [])
useEffect(() => {
if (visible) document.body.classList.add('visible')
}, [visible])
return (
<div className="card">
{text && <div className="msg">{text}</div>}
<div className="btns">
<button className="btn-yes" onClick={() => window.electron?.handleAction('confirmYes')}>
Да
</button>
<button className="btn-no" onClick={() => window.electron?.handleAction('confirmNo')}>
Нет
</button>
</div>
</div>
)
}
ReactDOM.createRoot(document.getElementById('root')!).render(<ConfirmDialog />)

View File

@@ -0,0 +1,39 @@
import React, { useEffect, useState } from 'react'
import ReactDOM from 'react-dom/client'
import '../styles/dialogs.css'
declare global {
interface Window {
electron?: { handleAction: (action: string) => void }
__dialogData?: { title?: string; text?: string }
}
}
const ErrorDialog = () => {
const [visible, setVisible] = useState(false)
const params = new URLSearchParams(window.location.search)
const title = params.get('title') || window.__dialogData?.title || 'Ошибка'
const text = params.get('text') || window.__dialogData?.text || ''
useEffect(() => {
requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)))
}, [])
useEffect(() => {
if (visible) document.body.classList.add('visible')
}, [visible])
return (
<div className="card">
{title && <div className="title">{title}</div>}
{text && <div className="msg">{text}</div>}
<div className="btns">
<button className="btn-ok" onClick={() => window.electron?.handleAction('error')}>
Закрыть
</button>
</div>
</div>
)
}
ReactDOM.createRoot(document.getElementById('root')!).render(<ErrorDialog />)

40
src/entries/loader.tsx Normal file
View File

@@ -0,0 +1,40 @@
import React, { useEffect } from 'react'
import ReactDOM from 'react-dom/client'
const css = `
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #111;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
transition: opacity 0.25s ease;
opacity: 0;
}
body.visible { opacity: 1; }
.spinner {
width: 36px;
height: 36px;
border: 3px solid rgba(255,255,255,0.1);
border-top-color: #E50914;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
`
const Loader = () => {
useEffect(() => {
const style = document.createElement('style')
style.textContent = css
document.head.appendChild(style)
requestAnimationFrame(() => requestAnimationFrame(() => {
document.body.classList.add('visible')
}))
}, [])
return <div className="spinner" />
}
ReactDOM.createRoot(document.getElementById('root')!).render(<Loader />)

99
src/entries/sidebar.tsx Normal file
View File

@@ -0,0 +1,99 @@
import React, { useState, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom/client'
import '../styles/main.css'
interface OpenedApp {
name: string
imageUrl: string
url: string
}
declare global {
interface Window {
electron?: {
on: (channel: string, fn: (...args: any[]) => void) => () => void
hideView: () => void
showView: (name: string) => void
adjustView: (expanded: boolean) => void
getSidebarState: () => Promise<{ openedApps: OpenedApp[]; activeApp: string }>
}
}
}
const SidebarApp = () => {
const [openedApps, setOpenedApps] = useState<OpenedApp[]>([])
const [activeApp, setActiveApp] = useState('home')
const [expanded, setExpanded] = useState(false)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
window.electron?.getSidebarState().then(data => {
if (!data) return
setOpenedApps(data.openedApps || [])
setActiveApp(data.activeApp || 'home')
})
const off = window.electron?.on('sidebar-update', (data: { openedApps: OpenedApp[]; activeApp: string }) => {
setOpenedApps(data.openedApps || [])
setActiveApp(data.activeApp || 'home')
})
return () => off?.()
}, [])
const handleMouseEnter = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
setExpanded(true)
window.electron?.adjustView(true)
}, 150)
}
const handleMouseLeave = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
setExpanded(false)
window.electron?.adjustView(false)
}, 150)
}
const goHome = () => {
window.electron?.hideView()
}
return (
<div
className={`sidebar ${expanded ? 'expanded' : 'collapsed'}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div
className={`sidebar-item ${activeApp === 'home' ? 'active' : ''}`}
onClick={goHome}
>
<svg className="sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
<span>Домой</span>
</div>
{openedApps.map(app => (
<div
key={app.name}
className={`sidebar-item ${activeApp === app.name ? 'active' : ''}`}
onClick={() => window.electron?.showView(app.name)}
>
{app.imageUrl ? (
<img src={app.imageUrl} alt={app.name} className="sidebar-app-icon" />
) : (
<div className="sidebar-app-icon sidebar-app-icon-placeholder">
{app.name.charAt(0).toUpperCase()}
</div>
)}
<span>{app.name}</span>
</div>
))}
</div>
)
}
ReactDOM.createRoot(document.getElementById('root')!).render(<SidebarApp />)

View File

@@ -25,6 +25,8 @@ 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
isKiosk: () => Promise<boolean>
}
}
}

52
src/styles/dialogs.css Normal file
View File

@@ -0,0 +1,52 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: rgba(0, 0, 0, 0);
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
transition: background 0.22s ease;
}
body.visible { background: rgba(0, 0, 0, 0.78); }
body.hiding { background: rgba(0, 0, 0, 0); }
.card {
background: #1c1c1c;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 32px 36px;
text-align: center;
min-width: 300px;
max-width: 420px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8);
opacity: 0;
transform: scale(0.92) translateY(10px);
transition: opacity 0.22s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}
body.visible .card { opacity: 1; transform: scale(1) translateY(0); }
body.hiding .card { opacity: 0; transform: scale(0.95) translateY(6px); }
.title { font-size: 17px; font-weight: 700; color: #fff; margin-bottom: 10px; }
.msg { font-size: 13px; color: #999; line-height: 1.5; margin-bottom: 26px; }
.btns { display: flex; gap: 10px; justify-content: center; }
button {
padding: 10px 26px;
border: none;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
}
button:hover { opacity: 0.85; }
button:active { transform: scale(0.97); }
.btn-yes { background: #E50914; color: #fff; }
.btn-no,
.btn-ok { background: rgba(255, 255, 255, 0.1); color: #ccc; }