fix: kiosk toggle sync, load more animation, remove nsis target
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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}>
|
||||
|
||||
40
src/entries/dialog-confirm.tsx
Normal file
40
src/entries/dialog-confirm.tsx
Normal 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 />)
|
||||
39
src/entries/dialog-error.tsx
Normal file
39
src/entries/dialog-error.tsx
Normal 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
40
src/entries/loader.tsx
Normal 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
99
src/entries/sidebar.tsx
Normal 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 />)
|
||||
@@ -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
52
src/styles/dialogs.css
Normal 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; }
|
||||
Reference in New Issue
Block a user