anisav: electron anime app, anilibria via api.savsis.xyz
This commit is contained in:
commit
27526ca489
50 changed files with 11600 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
out/
|
||||
dist/
|
||||
_research/
|
||||
.claude/
|
||||
*.log
|
||||
.DS_Store
|
||||
22
README.md
Normal file
22
README.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# anisav
|
||||
|
||||
десктопное приложение для аниме, дергает AniLiberty API. electron + react + ts.
|
||||
|
||||
каталог с фильтрами, поиск, расписание, плеер с автопропуском опенинга/эндинга и продолжением просмотра, локальный вишлист без аккаунта, вход в аккаунт если надо синк списков.
|
||||
|
||||
бекенд тут не при чем, все запросы идут через `api.savsis.xyz`.
|
||||
|
||||
## запуск
|
||||
|
||||
```
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## сборка
|
||||
|
||||
```
|
||||
npm run build:win
|
||||
```
|
||||
|
||||
сделано im.savsis.xyz
|
||||
BIN
apple-touch-icon.png
Normal file
BIN
apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
BIN
build/icon.ico
Normal file
BIN
build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
38
electron.vite.config.ts
Normal file
38
electron.vite.config.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { resolve } from 'path'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
minify: true,
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
minify: true,
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@renderer': resolve('src/renderer/src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
plugins: [react()],
|
||||
build: {
|
||||
minify: 'terser',
|
||||
terserOptions: {
|
||||
mangle: true,
|
||||
compress: { drop_console: true, drop_debugger: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
BIN
favicon-16.png
Normal file
BIN
favicon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
favicon-32.png
Normal file
BIN
favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2 KiB |
5
favicon.svg
Normal file
5
favicon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="#fe3635" version="1.2" viewBox="0 0 1174 1174">
|
||||
<path d="M698 245c0-7 8-12 14-9l169 77c4 2 6 6 6 9v395c0 6 4 10 10 10h248c6 0 11 6 9 12-25.3 94.2-73.6 180.7-140.7 251.5-67.1 70.9-150.8 123.9-243.5 154.2-92.7 30.4-191.5 37.2-287.5 19.8-96-17.4-186.2-58.5-262.3-119.5-3-3-4-8-2-12l34-58c3-5 10-7 15-3 51.5 43.1 111.1 75.4 175.4 94.9 64.2 19.6 131.7 25.9 198.5 18.7 66.7-7.2 131.4-27.8 190-60.5 58.6-32.8 110-77 151.1-130.1 5-7 1-16-8-16h-93c-4 0-7-2-9-5L699 574l-1-5z"/>
|
||||
<path d="M422 240c4-7 14-7 18 0l360 624c4 7-1 15-8 15H628c-4 0-7-2-9-5l-43-75c-2-3-5-5-9-5H373c-7 0-12-8-8-15l65-113c1-3 5-5 8-5h41c7 0 12-8 8-15l-47-83c-4-7-14-7-18 0L211 929v1l-23 39-1 1-10 19c-4 6-12 7-16 2-36-38-67-82-92-128-2-3-2-7 0-10l10-19 2-2v-1z"/>
|
||||
<path d="M587 0c83.2.1 165.5 17.8 241.3 52.1 75.8 34.3 143.5 84.2 198.5 146.7 55.1 62.4 96.2 135.8 120.7 215.3s31.8 163.3 21.5 245.9c0 5-4 9-9 9h-67c-6 0-11-6-10-12 20.8-146.3-24-294.3-122.5-404.5C862.1 142.4 720 81.3 572.3 85.6 424.6 90 286.3 159.3 194.5 275 102.7 390.8 66.6 541.2 96 686l-1 7-47 81c-5 7-15 6-18-2C.5 683.8-7.6 589.8 6.4 497.8c14-91.9 49.6-179.2 104.1-254.7 54.4-75.4 126-136.8 208.9-179.1C402.2 21.7 494-.2 587 0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
19
make-icon.mjs
Normal file
19
make-icon.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import sharp from 'sharp'
|
||||
import pngToIco from 'png-to-ico'
|
||||
import { writeFileSync } from 'fs'
|
||||
|
||||
const svgPath = 'favicon.svg'
|
||||
const sizes = [16, 32, 48, 64, 128, 256]
|
||||
|
||||
const buffers = await Promise.all(
|
||||
sizes.map((s) =>
|
||||
sharp(svgPath, { density: 384 })
|
||||
.resize(s, s)
|
||||
.png()
|
||||
.toBuffer()
|
||||
)
|
||||
)
|
||||
|
||||
const ico = await pngToIco(buffers)
|
||||
writeFileSync('build/icon.ico', ico)
|
||||
console.log('icon.ico written')
|
||||
8594
package-lock.json
generated
Normal file
8594
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
47
package.json
Normal file
47
package.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "anisav",
|
||||
"version": "1.0.0",
|
||||
"description": "AniSav — desktop anime client powered by AniLiberty, via api.savsis.xyz",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "savsis",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"build:win": "electron-vite build && electron-builder --win",
|
||||
"icon": "node make-icon.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"electron": "^33.2.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"electron-vite": "^2.3.0",
|
||||
"framer-motion": "^11.11.17",
|
||||
"hls.js": "^1.5.17",
|
||||
"png-to-ico": "^2.1.8",
|
||||
"postcss": "^8.4.49",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"sharp": "^0.33.5",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"terser": "^5.49.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
},
|
||||
"build": {
|
||||
"appId": "xyz.savsis.anisav",
|
||||
"productName": "AniSav",
|
||||
"asar": true,
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"icon": "build/icon.ico"
|
||||
},
|
||||
"files": [
|
||||
"out/**/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
196
src/main/index.ts
Normal file
196
src/main/index.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { app, BrowserWindow, ipcMain, safeStorage, shell } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
|
||||
|
||||
app.setName('AniSav')
|
||||
|
||||
const isDev = !app.isPackaged
|
||||
const API_BASE = 'https://api.savsis.xyz/api/anilibria/api/v1'
|
||||
|
||||
const tokenPath = join(app.getPath('userData'), 'token.bin')
|
||||
const PLAIN_PREFIX = 'plain:'
|
||||
|
||||
// The Bearer token lives only here in the main process — the renderer
|
||||
// never touches it directly, mirroring the savsis-mail credential model.
|
||||
let token: string | null = null
|
||||
|
||||
function saveToken(t: string): void {
|
||||
mkdirSync(app.getPath('userData'), { recursive: true })
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
writeFileSync(tokenPath, safeStorage.encryptString(t))
|
||||
} else {
|
||||
// No OS keychain/DPAPI available on this machine — fall back to a plain
|
||||
// on-disk token rather than silently failing to remember the session.
|
||||
writeFileSync(tokenPath, PLAIN_PREFIX + t, 'utf-8')
|
||||
}
|
||||
}
|
||||
|
||||
function loadToken(): string | null {
|
||||
if (!existsSync(tokenPath)) return null
|
||||
try {
|
||||
const raw = readFileSync(tokenPath)
|
||||
const text = raw.toString('utf-8')
|
||||
if (text.startsWith(PLAIN_PREFIX)) return text.slice(PLAIN_PREFIX.length)
|
||||
if (!safeStorage.isEncryptionAvailable()) return null
|
||||
return safeStorage.decryptString(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function clearToken(): void {
|
||||
if (existsSync(tokenPath)) writeFileSync(tokenPath, '')
|
||||
}
|
||||
|
||||
async function api(path: string, opts: { method?: string; body?: unknown } = {}) {
|
||||
if (!token) throw new Error('Не авторизован')
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: opts.method || 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw new Error(data.error || data.message || `Ошибка ${res.status}`)
|
||||
return data
|
||||
}
|
||||
|
||||
async function fetchProfile() {
|
||||
return api('/accounts/users/me/profile')
|
||||
}
|
||||
|
||||
function registerIpc(): void {
|
||||
ipcMain.handle('shell:openExternal', async (_e, url: string) => {
|
||||
if (/^https?:\/\//.test(url) || /^magnet:/.test(url)) {
|
||||
await shell.openExternal(url)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('auth:login', async (_e, login: string, password: string, remember: boolean) => {
|
||||
const res = await fetch(`${API_BASE}/accounts/users/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ login, password }),
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok || !data.token) throw new Error(data.error || 'Неверный логин или пароль')
|
||||
token = data.token
|
||||
if (remember) saveToken(data.token)
|
||||
return fetchProfile()
|
||||
})
|
||||
|
||||
ipcMain.handle('auth:loadSaved', async () => {
|
||||
const saved = loadToken()
|
||||
if (!saved) return null
|
||||
token = saved
|
||||
try {
|
||||
return await fetchProfile()
|
||||
} catch {
|
||||
token = null
|
||||
clearToken()
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('auth:logout', async () => {
|
||||
try {
|
||||
if (token) await api('/accounts/users/auth/logout', { method: 'POST' })
|
||||
} catch {
|
||||
// best-effort — clear local state regardless
|
||||
}
|
||||
token = null
|
||||
clearToken()
|
||||
})
|
||||
|
||||
ipcMain.handle('account:favoritesList', async (_e, page: number, limit: number) =>
|
||||
api(`/accounts/users/me/favorites/releases?page=${page}&limit=${limit}`, {
|
||||
method: 'POST',
|
||||
body: { page, limit },
|
||||
})
|
||||
)
|
||||
ipcMain.handle('account:favoriteAdd', async (_e, releaseId: number) =>
|
||||
api('/accounts/users/me/favorites', { method: 'POST', body: [{ release_id: releaseId }] })
|
||||
)
|
||||
ipcMain.handle('account:favoriteRemove', async (_e, releaseId: number) =>
|
||||
api('/accounts/users/me/favorites', { method: 'DELETE', body: [{ release_id: releaseId }] })
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'account:collectionList',
|
||||
async (_e, typeOfCollection: string, page: number, limit: number) =>
|
||||
api(
|
||||
`/accounts/users/me/collections/releases?type_of_collection=${typeOfCollection}&page=${page}&limit=${limit}`
|
||||
)
|
||||
)
|
||||
ipcMain.handle('account:collectionAdd', async (_e, releaseId: number, typeOfCollection: string) =>
|
||||
api('/accounts/users/me/collections', {
|
||||
method: 'POST',
|
||||
body: [{ release_id: releaseId, type_of_collection: typeOfCollection }],
|
||||
})
|
||||
)
|
||||
ipcMain.handle('account:collectionRemove', async (_e, releaseId: number) =>
|
||||
api('/accounts/users/me/collections', { method: 'DELETE', body: [{ release_id: releaseId }] })
|
||||
)
|
||||
ipcMain.handle('account:collectionIds', async () => api('/accounts/users/me/collections/ids'))
|
||||
ipcMain.handle('account:favoriteIds', async () => api('/accounts/users/me/favorites/ids'))
|
||||
|
||||
ipcMain.handle('account:historyList', async () => api('/accounts/users/me/views/history'))
|
||||
|
||||
ipcMain.handle('account:timecodes', async () => api('/accounts/users/me/views/timecodes'))
|
||||
ipcMain.handle(
|
||||
'account:timecodeSave',
|
||||
async (_e, releaseEpisodeId: string, time: number, isWatched: boolean) =>
|
||||
api('/accounts/users/me/views/timecodes', {
|
||||
method: 'POST',
|
||||
body: [{ release_episode_id: releaseEpisodeId, time, is_watched: isWatched }],
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
const win = new BrowserWindow({
|
||||
width: 1360,
|
||||
height: 860,
|
||||
minWidth: 960,
|
||||
minHeight: 600,
|
||||
backgroundColor: '#121212',
|
||||
autoHideMenuBar: true,
|
||||
title: 'AniSav',
|
||||
icon: join(__dirname, '../../build/icon.ico'),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
devTools: isDev,
|
||||
},
|
||||
})
|
||||
|
||||
win.setMenuBarVisibility(false)
|
||||
|
||||
if (isDev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
win.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerIpc()
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
|
||||
app.on('web-contents-created', (_e, contents) => {
|
||||
contents.on('will-navigate', (e) => e.preventDefault())
|
||||
contents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
})
|
||||
7
src/preload/index.d.ts
vendored
Normal file
7
src/preload/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { AniSavApi } from './index'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
anisav: AniSavApi
|
||||
}
|
||||
}
|
||||
33
src/preload/index.ts
Normal file
33
src/preload/index.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
const api = {
|
||||
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
|
||||
|
||||
login: (login: string, password: string, remember: boolean) =>
|
||||
ipcRenderer.invoke('auth:login', login, password, remember),
|
||||
loadSavedSession: () => ipcRenderer.invoke('auth:loadSaved'),
|
||||
logout: () => ipcRenderer.invoke('auth:logout'),
|
||||
|
||||
favoritesList: (page: number, limit: number) =>
|
||||
ipcRenderer.invoke('account:favoritesList', page, limit),
|
||||
favoriteAdd: (releaseId: number) => ipcRenderer.invoke('account:favoriteAdd', releaseId),
|
||||
favoriteRemove: (releaseId: number) => ipcRenderer.invoke('account:favoriteRemove', releaseId),
|
||||
|
||||
collectionList: (typeOfCollection: string, page: number, limit: number) =>
|
||||
ipcRenderer.invoke('account:collectionList', typeOfCollection, page, limit),
|
||||
collectionAdd: (releaseId: number, typeOfCollection: string) =>
|
||||
ipcRenderer.invoke('account:collectionAdd', releaseId, typeOfCollection),
|
||||
collectionRemove: (releaseId: number) => ipcRenderer.invoke('account:collectionRemove', releaseId),
|
||||
collectionIds: () => ipcRenderer.invoke('account:collectionIds'),
|
||||
favoriteIds: () => ipcRenderer.invoke('account:favoriteIds'),
|
||||
|
||||
historyList: () => ipcRenderer.invoke('account:historyList'),
|
||||
|
||||
timecodes: () => ipcRenderer.invoke('account:timecodes'),
|
||||
timecodeSave: (releaseEpisodeId: string, time: number, isWatched: boolean) =>
|
||||
ipcRenderer.invoke('account:timecodeSave', releaseEpisodeId, time, isWatched),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('anisav', api)
|
||||
|
||||
export type AniSavApi = typeof api
|
||||
22
src/renderer/index.html
Normal file
22
src/renderer/index.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; img-src 'self' https://api.savsis.xyz data:; media-src 'self' blob: https://*.libria.fun; connect-src 'self' https://api.savsis.xyz https://*.libria.fun; worker-src 'self' blob:; script-src 'self'"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AniSav</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
34
src/renderer/src/App.tsx
Normal file
34
src/renderer/src/App.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { Route, Routes } from 'react-router-dom'
|
||||
import Header from './components/Header'
|
||||
import Footer from './components/Footer'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
import PageTransition from './components/PageTransition'
|
||||
import Home from './pages/Home'
|
||||
import Catalog from './pages/Catalog'
|
||||
import Genres from './pages/Genres'
|
||||
import Schedule from './pages/Schedule'
|
||||
import TitleDetail from './pages/TitleDetail'
|
||||
import MyLists from './pages/MyLists'
|
||||
import History from './pages/History'
|
||||
import Wishlist from './pages/Wishlist'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface">
|
||||
<Header />
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
<Route path="/" element={<PageTransition><Home /></PageTransition>} />
|
||||
<Route path="/catalog" element={<PageTransition><Catalog /></PageTransition>} />
|
||||
<Route path="/genres" element={<PageTransition><Genres /></PageTransition>} />
|
||||
<Route path="/schedule" element={<PageTransition><Schedule /></PageTransition>} />
|
||||
<Route path="/title/:idOrAlias" element={<PageTransition><TitleDetail /></PageTransition>} />
|
||||
<Route path="/lists" element={<PageTransition><MyLists /></PageTransition>} />
|
||||
<Route path="/history" element={<PageTransition><History /></PageTransition>} />
|
||||
<Route path="/wishlist" element={<PageTransition><Wishlist /></PageTransition>} />
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
154
src/renderer/src/components/AddToListButton.tsx
Normal file
154
src/renderer/src/components/AddToListButton.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useAuth } from '../lib/AuthContext'
|
||||
import type { CollectionType } from '../lib/types'
|
||||
|
||||
const LABELS: Record<CollectionType, string> = {
|
||||
WATCHING: 'Смотрю',
|
||||
PLANNED: 'Запланировано',
|
||||
WATCHED: 'Просмотрено',
|
||||
POSTPONED: 'Отложено',
|
||||
ABANDONED: 'Брошено',
|
||||
}
|
||||
|
||||
function extractCollectionType(items: unknown, releaseId: number): CollectionType | null {
|
||||
if (!Array.isArray(items)) return null
|
||||
for (const item of items) {
|
||||
if (item && typeof item === 'object' && 'release_id' in item && 'type_of_collection' in item) {
|
||||
const o = item as { release_id: number; type_of_collection: CollectionType }
|
||||
if (o.release_id === releaseId) return o.type_of_collection
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractIsFavorite(items: unknown, releaseId: number): boolean {
|
||||
if (!Array.isArray(items)) return false
|
||||
return items.some((item) => {
|
||||
if (typeof item === 'number') return item === releaseId
|
||||
if (item && typeof item === 'object' && 'release_id' in item) {
|
||||
return (item as { release_id: number }).release_id === releaseId
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
export default function AddToListButton({ releaseId }: { releaseId: number }) {
|
||||
const { user } = useAuth()
|
||||
const [collectionType, setCollectionType] = useState<CollectionType | null>(null)
|
||||
const [isFavorite, setIsFavorite] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
window.anisav
|
||||
.collectionIds()
|
||||
.then((ids) => setCollectionType(extractCollectionType(ids, releaseId)))
|
||||
.catch(() => {})
|
||||
window.anisav
|
||||
.favoriteIds()
|
||||
.then((ids) => setIsFavorite(extractIsFavorite(ids, releaseId)))
|
||||
.catch(() => {})
|
||||
}, [user, releaseId])
|
||||
|
||||
if (!user) return null
|
||||
|
||||
async function pick(type: CollectionType) {
|
||||
setBusy(true)
|
||||
try {
|
||||
await window.anisav.collectionAdd(releaseId, type)
|
||||
setCollectionType(type)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFromList() {
|
||||
setBusy(true)
|
||||
try {
|
||||
await window.anisav.collectionRemove(releaseId)
|
||||
setCollectionType(null)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFavorite() {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (isFavorite) await window.anisav.favoriteRemove(releaseId)
|
||||
else await window.anisav.favoriteAdd(releaseId)
|
||||
setIsFavorite(!isFavorite)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<motion.button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
disabled={busy}
|
||||
whileTap={{ scale: 0.96 }}
|
||||
className={`rounded-full px-4 py-1.5 text-sm font-semibold transition-colors ${
|
||||
collectionType ? 'bg-white/10 hover:bg-white/15' : 'bg-accent hover:bg-accent-hover'
|
||||
}`}
|
||||
>
|
||||
{collectionType ? LABELS[collectionType] : '+ Добавить в список'}
|
||||
</motion.button>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -6, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -6, scale: 0.96 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
className="absolute left-0 top-full z-10 mt-1 w-44 origin-top overflow-hidden rounded-lg bg-surface-card shadow-xl"
|
||||
>
|
||||
{(Object.keys(LABELS) as CollectionType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => pick(t)}
|
||||
className={`block w-full px-3 py-2 text-left text-sm hover:bg-white/5 ${
|
||||
collectionType === t ? 'text-accent' : ''
|
||||
}`}
|
||||
>
|
||||
{LABELS[t]}
|
||||
</button>
|
||||
))}
|
||||
{collectionType && (
|
||||
<button
|
||||
onClick={removeFromList}
|
||||
className="block w-full border-t border-white/5 px-3 py-2 text-left text-sm text-red-400 hover:bg-white/5"
|
||||
>
|
||||
Убрать из списка
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
onClick={toggleFavorite}
|
||||
disabled={busy}
|
||||
whileTap={{ scale: 0.85 }}
|
||||
animate={isFavorite ? { scale: [1, 1.25, 1] } : { scale: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
title={isFavorite ? 'Убрать из избранного' : 'В избранное'}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full transition-colors ${
|
||||
isFavorite ? 'bg-accent/20 text-accent' : 'bg-white/5 text-white/50 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill={isFavorite ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||
</svg>
|
||||
</motion.button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
70
src/renderer/src/components/AnimeCard.tsx
Normal file
70
src/renderer/src/components/AnimeCard.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import type { Release } from '../lib/types'
|
||||
import { imageUrl } from '../lib/api'
|
||||
import WishlistButton from './WishlistButton'
|
||||
|
||||
const cardVariants = {
|
||||
hidden: { opacity: 0, y: 16 },
|
||||
show: { opacity: 1, y: 0, transition: { duration: 0.35, ease: 'easeOut' } },
|
||||
}
|
||||
|
||||
export default function AnimeCard({ release }: { release: Release }) {
|
||||
const poster = imageUrl(release.poster?.optimized?.preview || release.poster?.preview)
|
||||
const title = release.name.main
|
||||
|
||||
return (
|
||||
<motion.div variants={cardVariants} whileHover={{ y: -6 }} transition={{ type: 'spring', stiffness: 300, damping: 22 }}>
|
||||
<Link to={`/title/${release.alias || release.id}`} className="poster-card group block">
|
||||
<div className="aspect-[2/3] w-full overflow-hidden rounded-xl bg-surface-raised shadow-md shadow-black/30 ring-1 ring-white/5">
|
||||
{poster && (
|
||||
<motion.img
|
||||
src={poster}
|
||||
alt={title}
|
||||
loading="lazy"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
whileHover={{ scale: 1.06 }}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/90 via-black/10 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
|
||||
|
||||
<div className="absolute left-1.5 top-1.5 flex flex-col gap-1">
|
||||
{release.is_ongoing && (
|
||||
<span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide shadow">
|
||||
Онгоинг
|
||||
</span>
|
||||
)}
|
||||
{release.age_rating?.is_adult && (
|
||||
<span className="rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-semibold">
|
||||
{release.age_rating.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{release.episodes_total != null && (
|
||||
<span className="absolute right-1.5 top-1.5 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{release.episodes_total} эп.
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="absolute right-1.5 top-8 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<WishlistButton release={release} size="sm" />
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 translate-y-2 p-2.5 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100">
|
||||
<p className="line-clamp-2 text-xs font-semibold leading-tight text-white">{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-2 transition-opacity duration-150 group-hover:opacity-0">
|
||||
<p className="line-clamp-2 text-sm font-medium leading-tight text-white/90">{title}</p>
|
||||
<p className="mt-0.5 text-xs text-white/40">
|
||||
{release.season?.description} {release.year} · {release.type?.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
25
src/renderer/src/components/ErrorBoundary.tsx
Normal file
25
src/renderer/src/components/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Component, ReactNode } from 'react'
|
||||
|
||||
export default class ErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ error: Error | null }
|
||||
> {
|
||||
state = { error: null as Error | null }
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<pre className="max-w-full overflow-auto whitespace-pre-wrap p-6 text-xs text-red-400">
|
||||
{this.state.error.message}
|
||||
{'\n'}
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
14
src/renderer/src/components/Footer.tsx
Normal file
14
src/renderer/src/components/Footer.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export default function Footer() {
|
||||
return (
|
||||
<footer className="py-6 text-center text-xs text-white/30">
|
||||
made by{' '}
|
||||
<button
|
||||
onClick={() => window.anisav.openExternal('https://im.savsis.xyz')}
|
||||
className="text-white/50 hover:text-white/80"
|
||||
>
|
||||
im.savsis.xyz
|
||||
</button>{' '}
|
||||
with <span className="text-accent">♥</span>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
136
src/renderer/src/components/Header.tsx
Normal file
136
src/renderer/src/components/Header.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { useState, FormEvent } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import Logo from './Logo'
|
||||
import LoginModal from './LoginModal'
|
||||
import { useAuth } from '../lib/AuthContext'
|
||||
import { imageUrl } from '../lib/api'
|
||||
|
||||
const NAV = [
|
||||
{ to: '/', label: 'Главная' },
|
||||
{ to: '/catalog', label: 'Каталог' },
|
||||
{ to: '/schedule', label: 'Расписание' },
|
||||
{ to: '/genres', label: 'Жанры' },
|
||||
{ to: '/wishlist', label: 'Понравилось' },
|
||||
]
|
||||
|
||||
export default function Header() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [showLogin, setShowLogin] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuth()
|
||||
|
||||
function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
const q = query.trim()
|
||||
if (q) navigate(`/catalog?search=${encodeURIComponent(q)}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b border-white/5 bg-surface/85 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-7xl items-center gap-6 px-6 py-3">
|
||||
<Link to="/" className="flex items-center gap-2 shrink-0">
|
||||
<Logo />
|
||||
<span className="text-lg font-extrabold tracking-tight">
|
||||
Ani<span className="text-accent">Sav</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden gap-5 text-sm font-medium text-white/70 md:flex">
|
||||
{NAV.map((n) => (
|
||||
<Link key={n.to} to={n.to} className="transition-colors hover:text-white">
|
||||
{n.label}
|
||||
</Link>
|
||||
))}
|
||||
{user && (
|
||||
<>
|
||||
<Link to="/lists" className="transition-colors hover:text-white">
|
||||
Мои списки
|
||||
</Link>
|
||||
<Link to="/history" className="transition-colors hover:text-white">
|
||||
История
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<form onSubmit={onSubmit} className="ml-auto w-full max-w-xs">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Поиск аниме..."
|
||||
className="w-full rounded-full border border-white/10 bg-white/5 px-4 py-1.5 text-sm outline-none placeholder:text-white/30 focus:border-accent/60"
|
||||
/>
|
||||
</form>
|
||||
|
||||
{user ? (
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
className="flex items-center gap-2 rounded-full bg-white/5 py-1 pl-1 pr-3 hover:bg-white/10"
|
||||
>
|
||||
{user.avatar?.preview ? (
|
||||
<img
|
||||
src={imageUrl(user.avatar.optimized?.preview || user.avatar.preview)}
|
||||
alt=""
|
||||
className="h-6 w-6 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-bold">
|
||||
{user.nickname[0]?.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-medium">{user.nickname}</span>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -6, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -6, scale: 0.96 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onMouseLeave={() => setMenuOpen(false)}
|
||||
className="absolute right-0 top-full z-10 mt-2 w-44 origin-top-right overflow-hidden rounded-lg bg-surface-card shadow-xl"
|
||||
>
|
||||
<Link
|
||||
to="/lists"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="block px-4 py-2 text-sm hover:bg-white/5 md:hidden"
|
||||
>
|
||||
Мои списки
|
||||
</Link>
|
||||
<Link
|
||||
to="/history"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="block px-4 py-2 text-sm hover:bg-white/5 md:hidden"
|
||||
>
|
||||
История
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
logout()
|
||||
}}
|
||||
className="block w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-white/5"
|
||||
>
|
||||
Выйти
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowLogin(true)}
|
||||
className="shrink-0 rounded-full bg-accent px-4 py-1.5 text-sm font-semibold hover:bg-accent-hover"
|
||||
>
|
||||
Войти
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showLogin && <LoginModal onClose={() => setShowLogin(false)} />}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
8
src/renderer/src/components/Loader.tsx
Normal file
8
src/renderer/src/components/Loader.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export default function Loader({ label }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-white/50">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-white/15 border-t-accent" />
|
||||
{label && <span className="text-sm">{label}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
src/renderer/src/components/LoginModal.tsx
Normal file
104
src/renderer/src/components/LoginModal.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { useState, FormEvent } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useAuth } from '../lib/AuthContext'
|
||||
|
||||
export default function LoginModal({ onClose }: { onClose: () => void }) {
|
||||
const { login, error, clearError } = useAuth()
|
||||
const [loginValue, setLoginValue] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [remember, setRemember] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await login(loginValue, password, remember)
|
||||
onClose()
|
||||
} catch {
|
||||
// error is surfaced via context
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex justify-center overflow-y-auto bg-black/70 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.form
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
transition={{ type: 'spring', duration: 0.35, bounce: 0.25 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSubmit={onSubmit}
|
||||
className="my-auto max-h-[90vh] w-full max-w-sm overflow-y-auto rounded-2xl bg-surface-raised p-6 shadow-2xl"
|
||||
>
|
||||
<h2 className="text-lg font-bold">Вход в AniLiberty</h2>
|
||||
<p className="mt-1 text-xs text-white/40">
|
||||
Используй логин и пароль своего аккаунта на aniliberty.top
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<input
|
||||
value={loginValue}
|
||||
onChange={(e) => {
|
||||
setLoginValue(e.target.value)
|
||||
clearError()
|
||||
}}
|
||||
placeholder="Логин или email"
|
||||
autoFocus
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm outline-none focus:border-accent/60"
|
||||
/>
|
||||
<input
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
clearError()
|
||||
}}
|
||||
type="password"
|
||||
placeholder="Пароль"
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm outline-none focus:border-accent/60"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-xs text-white/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
className="h-3.5 w-3.5 accent-[#fe3635]"
|
||||
/>
|
||||
Запомнить меня
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-3 text-xs text-red-400">{error}</p>}
|
||||
|
||||
<div className="mt-5 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-full bg-white/5 py-2 text-sm font-semibold hover:bg-white/10"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !loginValue || !password}
|
||||
className="flex-1 rounded-full bg-accent py-2 text-sm font-semibold hover:bg-accent-hover disabled:opacity-40"
|
||||
>
|
||||
{submitting ? '...' : 'Войти'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.form>
|
||||
</motion.div>
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
9
src/renderer/src/components/Logo.tsx
Normal file
9
src/renderer/src/components/Logo.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export default function Logo({ size = 28 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 1174 1174" fill="#fe3635" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M698 245c0-7 8-12 14-9l169 77c4 2 6 6 6 9v395c0 6 4 10 10 10h248c6 0 11 6 9 12-25.3 94.2-73.6 180.7-140.7 251.5-67.1 70.9-150.8 123.9-243.5 154.2-92.7 30.4-191.5 37.2-287.5 19.8-96-17.4-186.2-58.5-262.3-119.5-3-3-4-8-2-12l34-58c3-5 10-7 15-3 51.5 43.1 111.1 75.4 175.4 94.9 64.2 19.6 131.7 25.9 198.5 18.7 66.7-7.2 131.4-27.8 190-60.5 58.6-32.8 110-77 151.1-130.1 5-7 1-16-8-16h-93c-4 0-7-2-9-5L699 574l-1-5z" />
|
||||
<path d="M422 240c4-7 14-7 18 0l360 624c4 7-1 15-8 15H628c-4 0-7-2-9-5l-43-75c-2-3-5-5-9-5H373c-7 0-12-8-8-15l65-113c1-3 5-5 8-5h41c7 0 12-8 8-15l-47-83c-4-7-14-7-18 0L211 929v1l-23 39-1 1-10 19c-4 6-12 7-16 2-36-38-67-82-92-128-2-3-2-7 0-10l10-19 2-2v-1z" />
|
||||
<path d="M587 0c83.2.1 165.5 17.8 241.3 52.1 75.8 34.3 143.5 84.2 198.5 146.7 55.1 62.4 96.2 135.8 120.7 215.3s31.8 163.3 21.5 245.9c0 5-4 9-9 9h-67c-6 0-11-6-10-12 20.8-146.3-24-294.3-122.5-404.5C862.1 142.4 720 81.3 572.3 85.6 424.6 90 286.3 159.3 194.5 275 102.7 390.8 66.6 541.2 96 686l-1 7-47 81c-5 7-15 6-18-2C.5 683.8-7.6 589.8 6.4 497.8c14-91.9 49.6-179.2 104.1-254.7 54.4-75.4 126-136.8 208.9-179.1C402.2 21.7 494-.2 587 0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
15
src/renderer/src/components/PageTransition.tsx
Normal file
15
src/renderer/src/components/PageTransition.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { motion } from 'framer-motion'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export default function PageTransition({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.22, ease: 'easeOut' }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
470
src/renderer/src/components/Player.tsx
Normal file
470
src/renderer/src/components/Player.tsx
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import Hls from 'hls.js'
|
||||
import type { Episode } from '../lib/types'
|
||||
import { loadPlayerPrefs, savePlayerPrefs, PlayerPrefs } from '../lib/playerPrefs'
|
||||
|
||||
const QUALITIES = [
|
||||
{ key: 'hls_1080', label: '1080p' },
|
||||
{ key: 'hls_720', label: '720p' },
|
||||
{ key: 'hls_480', label: '480p' },
|
||||
] as const
|
||||
|
||||
interface Props {
|
||||
episode: Episode
|
||||
resumeAt?: number
|
||||
onProgress?: (time: number) => void
|
||||
onEnded?: () => void
|
||||
}
|
||||
|
||||
function fmt(t: number) {
|
||||
if (!Number.isFinite(t)) return '0:00'
|
||||
const m = Math.floor(t / 60)
|
||||
const s = Math.floor(t % 60)
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export default function Player({ episode, resumeAt, onProgress, onEnded }: Props) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const hlsRef = useRef<Hls | null>(null)
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const skippedRanges = useRef<Set<string>>(new Set())
|
||||
const didAutoFullscreen = useRef(false)
|
||||
|
||||
const available = QUALITIES.filter((q) => episode[q.key])
|
||||
const [quality, setQuality] = useState(available[0]?.key)
|
||||
const [prefs, setPrefs] = useState<PlayerPrefs>(loadPlayerPrefs)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [current, setCurrent] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [muted, setMuted] = useState(false)
|
||||
const [showSkip, setShowSkip] = useState<'opening' | 'ending' | null>(null)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [controlsVisible, setControlsVisible] = useState(true)
|
||||
|
||||
function updatePrefs(patch: Partial<PlayerPrefs>) {
|
||||
setPrefs((p) => {
|
||||
const next = { ...p, ...patch }
|
||||
savePlayerPrefs(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setQuality(available[0]?.key)
|
||||
skippedRanges.current = new Set()
|
||||
didAutoFullscreen.current = false
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [episode.id])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
const src = quality ? episode[quality] : undefined
|
||||
if (!video || !src) return
|
||||
|
||||
hlsRef.current?.destroy()
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({
|
||||
manifestLoadingMaxRetry: 6,
|
||||
levelLoadingMaxRetry: 6,
|
||||
fragLoadingMaxRetry: 8,
|
||||
})
|
||||
hls.loadSource(src)
|
||||
hls.attachMedia(video)
|
||||
// Network/media hiccups (CDN edge blip, transient segment fetch failure)
|
||||
// are fatal by default in hls.js unless the app recovers them — without
|
||||
// this, a single dropped segment can stall playback or force a reload
|
||||
// that jumps back to position 0 instead of resuming where it was.
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (!data.fatal) return
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
hls.startLoad(video.currentTime)
|
||||
break
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
hls.recoverMediaError()
|
||||
break
|
||||
default:
|
||||
hls.destroy()
|
||||
break
|
||||
}
|
||||
})
|
||||
hlsRef.current = hls
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = src
|
||||
}
|
||||
|
||||
const onLoaded = () => {
|
||||
if (resumeAt && resumeAt > 5) video.currentTime = resumeAt
|
||||
}
|
||||
video.addEventListener('loadedmetadata', onLoaded)
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', onLoaded)
|
||||
hlsRef.current?.destroy()
|
||||
hlsRef.current = null
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [quality, episode.id])
|
||||
|
||||
const reportProgress = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (video && onProgress && video.currentTime > 3) onProgress(video.currentTime)
|
||||
}, [onProgress])
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(reportProgress, 10_000)
|
||||
return () => clearInterval(id)
|
||||
}, [reportProgress])
|
||||
|
||||
function onTimeUpdate() {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
setCurrent(video.currentTime)
|
||||
|
||||
const t = video.currentTime
|
||||
const checkSkip = (range: Episode['opening'], key: string, prefKey: keyof PlayerPrefs) => {
|
||||
if (!range || t < range.start || t >= range.stop) return false
|
||||
if (prefs[prefKey] && !skippedRanges.current.has(key)) {
|
||||
skippedRanges.current.add(key)
|
||||
video.currentTime = range.stop
|
||||
return false
|
||||
}
|
||||
return !prefs[prefKey]
|
||||
}
|
||||
|
||||
if (checkSkip(episode.opening, 'opening', 'autoSkipOpening')) setShowSkip('opening')
|
||||
else if (checkSkip(episode.ending, 'ending', 'autoSkipEnding')) setShowSkip('ending')
|
||||
else setShowSkip(null)
|
||||
}
|
||||
|
||||
function skip() {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (showSkip === 'opening' && episode.opening) video.currentTime = episode.opening.stop
|
||||
if (showSkip === 'ending' && episode.ending) video.currentTime = episode.ending.stop
|
||||
setShowSkip(null)
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (video.paused) video.play()
|
||||
else video.pause()
|
||||
}
|
||||
|
||||
function onPlay() {
|
||||
setPlaying(true)
|
||||
if (prefs.autoFullscreen && !didAutoFullscreen.current && !document.fullscreenElement) {
|
||||
didAutoFullscreen.current = true
|
||||
wrapperRef.current?.requestFullscreen().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function onEndedInternal() {
|
||||
setPlaying(false)
|
||||
reportProgress()
|
||||
if (prefs.autoPlayNext) onEnded?.()
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (document.fullscreenElement) document.exitFullscreen()
|
||||
else wrapperRef.current?.requestFullscreen().catch(() => {})
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
video.muted = !video.muted
|
||||
setMuted(video.muted)
|
||||
}
|
||||
|
||||
function seekBy(delta: number) {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
video.currentTime = Math.max(0, Math.min(duration, video.currentTime + delta))
|
||||
}
|
||||
|
||||
function wakeControls() {
|
||||
setControlsVisible(true)
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current)
|
||||
hideTimer.current = setTimeout(() => {
|
||||
if (playing) setControlsVisible(false)
|
||||
}, 2800)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (!wrapperRef.current?.contains(document.activeElement) && document.activeElement !== document.body)
|
||||
return
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
togglePlay()
|
||||
break
|
||||
case 'ArrowRight':
|
||||
seekBy(10)
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
seekBy(-10)
|
||||
break
|
||||
case 'f':
|
||||
case 'F':
|
||||
toggleFullscreen()
|
||||
break
|
||||
case 'm':
|
||||
case 'M':
|
||||
toggleMute()
|
||||
break
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [duration])
|
||||
|
||||
useEffect(() => {
|
||||
return () => reportProgress()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [episode.id])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
onMouseMove={wakeControls}
|
||||
className="group relative overflow-hidden rounded-lg bg-black"
|
||||
tabIndex={0}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
onClick={togglePlay}
|
||||
onPlay={onPlay}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={onEndedInternal}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onDurationChange={(e) => setDuration(e.currentTarget.duration)}
|
||||
onVolumeChange={(e) => setVolume(e.currentTarget.volume)}
|
||||
className="aspect-video w-full cursor-pointer bg-black"
|
||||
/>
|
||||
|
||||
{showSkip && (
|
||||
<button
|
||||
onClick={skip}
|
||||
className="absolute bottom-20 right-4 flex items-center gap-1.5 rounded bg-black/80 px-4 py-2 text-sm font-semibold backdrop-blur transition-colors hover:bg-accent"
|
||||
>
|
||||
<SkipIcon />
|
||||
Пропустить {showSkip === 'opening' ? 'опенинг' : 'эндинг'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent px-3 pb-2 pt-8 transition-opacity duration-300 ${
|
||||
controlsVisible || !playing ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
value={current}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value)
|
||||
if (videoRef.current) videoRef.current.currentTime = v
|
||||
setCurrent(v)
|
||||
}}
|
||||
className="player-seek w-full"
|
||||
style={{ ['--progress' as string]: `${duration ? (current / duration) * 100 : 0}%` }}
|
||||
/>
|
||||
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<button onClick={togglePlay} className="text-white/90 hover:text-white">
|
||||
{playing ? <PauseIcon /> : <PlayIcon />}
|
||||
</button>
|
||||
<button onClick={toggleMute} className="text-white/90 hover:text-white">
|
||||
{muted || volume === 0 ? <MuteIcon /> : <VolumeIcon />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value)
|
||||
if (videoRef.current) {
|
||||
videoRef.current.volume = v
|
||||
videoRef.current.muted = v === 0
|
||||
}
|
||||
setVolume(v)
|
||||
setMuted(v === 0)
|
||||
}}
|
||||
className="player-seek w-16"
|
||||
style={{ ['--progress' as string]: `${(muted ? 0 : volume) * 100}%` }}
|
||||
/>
|
||||
|
||||
<span className="text-xs tabular-nums text-white/70">
|
||||
{fmt(current)} / {fmt(duration)}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowSettings((v) => !v)}
|
||||
className="text-white/90 hover:text-white"
|
||||
>
|
||||
<GearIcon />
|
||||
</button>
|
||||
{showSettings && (
|
||||
<div
|
||||
onMouseLeave={() => setShowSettings(false)}
|
||||
className="absolute bottom-full right-0 mb-2 w-64 space-y-3 rounded-xl bg-surface-raised p-3 text-sm shadow-2xl"
|
||||
>
|
||||
{available.length > 1 && (
|
||||
<SettingsRow label="Качество">
|
||||
<div className="flex gap-1">
|
||||
{available.map((q) => (
|
||||
<button
|
||||
key={q.key}
|
||||
onClick={() => setQuality(q.key)}
|
||||
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||
quality === q.key ? 'bg-accent' : 'bg-white/10 hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
{q.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
)}
|
||||
|
||||
<div className="border-t border-white/10 pt-2 text-xs text-white/40">
|
||||
Горячие клавиши: Пробел — пауза, ←/→ — перемотка 10с, F — экран, M — звук
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-white/10 pt-2">
|
||||
<ToggleRow
|
||||
label="Пропускать опенинг"
|
||||
checked={prefs.autoSkipOpening}
|
||||
onChange={(v) => updatePrefs({ autoSkipOpening: v })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Пропускать эндинг"
|
||||
checked={prefs.autoSkipEnding}
|
||||
onChange={(v) => updatePrefs({ autoSkipEnding: v })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Авто-воспроизведение"
|
||||
checked={prefs.autoPlayNext}
|
||||
onChange={(v) => updatePrefs({ autoPlayNext: v })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Авто-полноэкранный режим"
|
||||
checked={prefs.autoFullscreen}
|
||||
onChange={(v) => updatePrefs({ autoFullscreen: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button onClick={toggleFullscreen} className="text-white/90 hover:text-white">
|
||||
<FullscreenIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/60">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onChange(!checked)}
|
||||
className="flex w-full items-center justify-between text-left"
|
||||
>
|
||||
<span className="text-white/70">{label}</span>
|
||||
<span
|
||||
className={`relative h-4 w-8 shrink-0 rounded-full transition-colors ${
|
||||
checked ? 'bg-accent' : 'bg-white/15'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-transform ${
|
||||
checked ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PlayIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function PauseIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function VolumeIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function MuteIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.7 3 2.1-2.1-1.4-1.4L15.3 10l-2.1-2.1-1.4 1.4L14 11l-2.1 2.1 1.4 1.4 2.1-2.1 2.1 2.1 1.4-1.4L16.7 12z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function FullscreenIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function GearIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.4 13a7.6 7.6 0 0 0 .1-1 7.6 7.6 0 0 0-.1-1l2.1-1.6a.5.5 0 0 0 .1-.7l-2-3.4a.5.5 0 0 0-.6-.2l-2.5 1a7.4 7.4 0 0 0-1.7-1l-.4-2.6a.5.5 0 0 0-.5-.4h-4a.5.5 0 0 0-.5.4l-.4 2.6a7.4 7.4 0 0 0-1.7 1l-2.5-1a.5.5 0 0 0-.6.2l-2 3.4a.5.5 0 0 0 .1.7L4.6 11a7.6 7.6 0 0 0 0 2l-2.1 1.6a.5.5 0 0 0-.1.7l2 3.4a.5.5 0 0 0 .6.2l2.5-1a7.4 7.4 0 0 0 1.7 1l.4 2.6a.5.5 0 0 0 .5.4h4a.5.5 0 0 0 .5-.4l.4-2.6a7.4 7.4 0 0 0 1.7-1l2.5 1a.5.5 0 0 0 .6-.2l2-3.4a.5.5 0 0 0-.1-.7L19.4 13zM12 15.5a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function SkipIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5 4v16l12-8L5 4zm13 0v16h2V4h-2z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
27
src/renderer/src/components/Row.tsx
Normal file
27
src/renderer/src/components/Row.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { motion } from 'framer-motion'
|
||||
import type { Release } from '../lib/types'
|
||||
import AnimeCard from './AnimeCard'
|
||||
|
||||
const container = {
|
||||
hidden: {},
|
||||
show: { transition: { staggerChildren: 0.05 } },
|
||||
}
|
||||
|
||||
export default function Row({ title, releases }: { title: string; releases: Release[] }) {
|
||||
if (!releases.length) return null
|
||||
return (
|
||||
<section className="mx-auto max-w-7xl px-6 py-5">
|
||||
<h2 className="mb-3 text-lg font-bold">{title}</h2>
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-6"
|
||||
>
|
||||
{releases.map((r) => (
|
||||
<AnimeCard key={r.id} release={r} />
|
||||
))}
|
||||
</motion.div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
19
src/renderer/src/components/Skeleton.tsx
Normal file
19
src/renderer/src/components/Skeleton.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export function CardSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="aspect-[2/3] w-full rounded-lg bg-white/5" />
|
||||
<div className="mt-2 h-3 w-4/5 rounded bg-white/5" />
|
||||
<div className="mt-1.5 h-2.5 w-2/5 rounded bg-white/5" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function GridSkeleton({ count = 12 }: { count?: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<CardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
src/renderer/src/components/WishlistButton.tsx
Normal file
50
src/renderer/src/components/WishlistButton.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { isInWishlist, toggleWishlist, onWishlistChange } from '../lib/wishlist'
|
||||
import type { Release } from '../lib/types'
|
||||
|
||||
export default function WishlistButton({
|
||||
release,
|
||||
size = 'md',
|
||||
}: {
|
||||
release: Release
|
||||
size?: 'sm' | 'md'
|
||||
}) {
|
||||
const [saved, setSaved] = useState(() => isInWishlist(release.id))
|
||||
|
||||
useEffect(() => onWishlistChange(() => setSaved(isInWishlist(release.id))), [release.id])
|
||||
|
||||
function onClick(e: React.MouseEvent) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setSaved(toggleWishlist(release))
|
||||
}
|
||||
|
||||
const dims = size === 'sm' ? 'h-7 w-7' : 'h-8 w-8'
|
||||
const iconSize = size === 'sm' ? 14 : 16
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
onClick={onClick}
|
||||
whileTap={{ scale: 0.8 }}
|
||||
animate={saved ? { scale: [1, 1.3, 1], rotate: [0, -15, 15, 0] } : { scale: 1, rotate: 0 }}
|
||||
transition={{ duration: 0.35 }}
|
||||
title={saved ? 'Убрать из понравившегося' : 'Добавить в понравившееся'}
|
||||
className={`flex ${dims} items-center justify-center rounded-full transition-colors ${
|
||||
saved ? 'bg-yellow-400/20 text-yellow-400' : 'bg-black/60 text-white/70 hover:bg-black/80'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
viewBox="0 0 24 24"
|
||||
fill={saved ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 2.5l2.9 6.6 7.1.6-5.4 4.7 1.6 7-6.2-3.8-6.2 3.8 1.6-7-5.4-4.7 7.1-.6z" />
|
||||
</svg>
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
81
src/renderer/src/index.css
Normal file
81
src/renderer/src/index.css
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-surface text-white font-sans antialiased;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #333;
|
||||
border-radius: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.poster-card {
|
||||
@apply relative overflow-hidden rounded-lg bg-surface-card transition-transform duration-200 ease-out;
|
||||
}
|
||||
.poster-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.player-seek {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#fe3635 var(--progress, 0%),
|
||||
rgba(255, 255, 255, 0.25) var(--progress, 0%)
|
||||
);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.player-seek::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #fe3635;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.player-seek:hover::-webkit-slider-thumb {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
.player-seek::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: #fe3635;
|
||||
cursor: pointer;
|
||||
}
|
||||
.player-seek::-moz-range-progress {
|
||||
background: #fe3635;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
59
src/renderer/src/lib/AuthContext.tsx
Normal file
59
src/renderer/src/lib/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
|
||||
import type { UserProfile } from './types'
|
||||
|
||||
interface AuthState {
|
||||
user: UserProfile | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
login: (login: string, password: string, remember: boolean) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<UserProfile | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window.anisav === 'undefined') {
|
||||
// No preload bridge (e.g. plain browser dev preview) — behave as logged out.
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
window.anisav
|
||||
.loadSavedSession()
|
||||
.then((profile) => setUser(profile))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function login(loginValue: string, password: string, remember: boolean) {
|
||||
setError(null)
|
||||
try {
|
||||
const profile = await window.anisav.login(loginValue, password, remember)
|
||||
setUser(profile)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Ошибка входа')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await window.anisav.logout()
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, error, login, logout, clearError: () => setError(null) }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||
return ctx
|
||||
}
|
||||
62
src/renderer/src/lib/api.ts
Normal file
62
src/renderer/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type {
|
||||
CatalogFilters,
|
||||
CatalogResponse,
|
||||
Genre,
|
||||
Release,
|
||||
ScheduleDay,
|
||||
ScheduleNow,
|
||||
ValueDescription,
|
||||
} from './types'
|
||||
|
||||
const GATEWAY = 'https://api.savsis.xyz/api/anilibria'
|
||||
const API_BASE = `${GATEWAY}/api/v1`
|
||||
|
||||
export function imageUrl(path?: string | null): string | undefined {
|
||||
if (!path) return undefined
|
||||
if (path.startsWith('http')) return path
|
||||
return `${GATEWAY}${path}`
|
||||
}
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, init)
|
||||
if (!res.ok) {
|
||||
throw new Error(`AniLiberty API ${res.status}: ${path}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export const api = {
|
||||
latest: (limit = 14) => req<Release[]>(`/anime/releases/latest?limit=${limit}`),
|
||||
random: (limit = 5) => req<Release[]>(`/anime/releases/random?limit=${limit}`),
|
||||
recommended: (limit = 10, releaseId?: number) =>
|
||||
req<Release[]>(
|
||||
`/anime/releases/recommended?limit=${limit}${releaseId ? `&release_id=${releaseId}` : ''}`
|
||||
),
|
||||
scheduleWeek: () => req<ScheduleDay[]>('/anime/schedule/week'),
|
||||
scheduleNow: () => req<ScheduleNow>('/anime/schedule/now'),
|
||||
|
||||
genres: () => req<Genre[]>('/anime/genres'),
|
||||
genreReleases: (genreId: number, page = 1, limit = 24) =>
|
||||
req<CatalogResponse>(`/anime/genres/${genreId}/releases?page=${page}&limit=${limit}`),
|
||||
|
||||
release: (idOrAlias: string | number) => req<Release>(`/anime/releases/${idOrAlias}`),
|
||||
|
||||
search: (query: string) =>
|
||||
req<Release[]>(`/app/search/releases?query=${encodeURIComponent(query)}`),
|
||||
|
||||
catalog: (filters: CatalogFilters, page = 1, limit = 24) =>
|
||||
req<CatalogResponse>('/anime/catalog/releases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ page, limit, f: filters }),
|
||||
}),
|
||||
|
||||
refGenres: () => req<Genre[]>('/anime/catalog/references/genres'),
|
||||
refTypes: () => req<ValueDescription[]>('/anime/catalog/references/types'),
|
||||
refYears: () => req<number[]>('/anime/catalog/references/years'),
|
||||
refSorting: () =>
|
||||
req<Array<ValueDescription & { label: string }>>('/anime/catalog/references/sorting'),
|
||||
refAgeRatings: () =>
|
||||
req<Array<ValueDescription & { label: string }>>('/anime/catalog/references/age-ratings'),
|
||||
refSeasons: () => req<ValueDescription[]>('/anime/catalog/references/seasons'),
|
||||
}
|
||||
33
src/renderer/src/lib/localProgress.ts
Normal file
33
src/renderer/src/lib/localProgress.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
const KEY = 'anisav:progress'
|
||||
|
||||
interface ProgressEntry {
|
||||
time: number
|
||||
is_watched: boolean
|
||||
}
|
||||
|
||||
function readAll(): Record<string, ProgressEntry> {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
return raw ? JSON.parse(raw) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeAll(data: Record<string, ProgressEntry>): void {
|
||||
localStorage.setItem(KEY, JSON.stringify(data))
|
||||
}
|
||||
|
||||
export function getLocalProgress(episodeId: string): ProgressEntry | undefined {
|
||||
return readAll()[episodeId]
|
||||
}
|
||||
|
||||
export function getAllLocalProgress(): Record<string, ProgressEntry> {
|
||||
return readAll()
|
||||
}
|
||||
|
||||
export function saveLocalProgress(episodeId: string, time: number, isWatched: boolean): void {
|
||||
const all = readAll()
|
||||
all[episodeId] = { time, is_watched: isWatched }
|
||||
writeAll(all)
|
||||
}
|
||||
29
src/renderer/src/lib/playerPrefs.ts
Normal file
29
src/renderer/src/lib/playerPrefs.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export interface PlayerPrefs {
|
||||
autoSkipOpening: boolean
|
||||
autoSkipEnding: boolean
|
||||
autoPlayNext: boolean
|
||||
autoFullscreen: boolean
|
||||
}
|
||||
|
||||
const KEY = 'anisav:playerPrefs'
|
||||
|
||||
const DEFAULTS: PlayerPrefs = {
|
||||
autoSkipOpening: false,
|
||||
autoSkipEnding: false,
|
||||
autoPlayNext: true,
|
||||
autoFullscreen: false,
|
||||
}
|
||||
|
||||
export function loadPlayerPrefs(): PlayerPrefs {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
if (!raw) return DEFAULTS
|
||||
return { ...DEFAULTS, ...JSON.parse(raw) }
|
||||
} catch {
|
||||
return DEFAULTS
|
||||
}
|
||||
}
|
||||
|
||||
export function savePlayerPrefs(prefs: PlayerPrefs): void {
|
||||
localStorage.setItem(KEY, JSON.stringify(prefs))
|
||||
}
|
||||
167
src/renderer/src/lib/types.ts
Normal file
167
src/renderer/src/lib/types.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
export interface ImageObj {
|
||||
src?: string
|
||||
preview?: string
|
||||
thumbnail?: string
|
||||
optimized?: {
|
||||
src?: string
|
||||
preview?: string
|
||||
thumbnail?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ValueDescription {
|
||||
value: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface Genre {
|
||||
id: number
|
||||
name: string
|
||||
image?: ImageObj
|
||||
total_releases?: number
|
||||
}
|
||||
|
||||
export interface AgeRating {
|
||||
value: string
|
||||
label: string
|
||||
is_adult?: boolean
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface EpisodeSkip {
|
||||
start: number
|
||||
stop: number
|
||||
}
|
||||
|
||||
export interface Episode {
|
||||
id: string
|
||||
name: string | null
|
||||
name_english?: string | null
|
||||
ordinal: number
|
||||
opening?: EpisodeSkip | null
|
||||
ending?: EpisodeSkip | null
|
||||
preview?: ImageObj
|
||||
hls_480?: string | null
|
||||
hls_720?: string | null
|
||||
hls_1080?: string | null
|
||||
duration?: number | null
|
||||
rutube_id?: string | null
|
||||
youtube_id?: string | null
|
||||
sort_order?: number
|
||||
release_id: number
|
||||
}
|
||||
|
||||
export interface Torrent {
|
||||
id: number
|
||||
hash: string
|
||||
size: number
|
||||
type?: ValueDescription
|
||||
color?: { value: string; description: string }
|
||||
codec?: { value: string; label: string; description: string }
|
||||
label: string
|
||||
quality?: { value: string; description: string }
|
||||
magnet: string
|
||||
filename: string
|
||||
seeders: number
|
||||
leechers: number
|
||||
bitrate?: number
|
||||
is_hardsub?: boolean
|
||||
description?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
completed_times?: number
|
||||
}
|
||||
|
||||
export interface Release {
|
||||
id: number
|
||||
type: ValueDescription
|
||||
year: number
|
||||
name: { main: string; english?: string | null; alternative?: string | null }
|
||||
alias: string
|
||||
season: ValueDescription
|
||||
poster: ImageObj
|
||||
fresh_at?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
is_ongoing: boolean
|
||||
age_rating: AgeRating
|
||||
publish_day?: { value: number; description: string }
|
||||
description?: string | null
|
||||
notification?: string | null
|
||||
episodes_total: number | null
|
||||
is_in_production: boolean
|
||||
is_blocked_by_geo: boolean
|
||||
is_blocked_by_copyrights: boolean
|
||||
added_in_users_favorites?: number
|
||||
average_duration_of_episode?: number
|
||||
genres?: Genre[]
|
||||
episodes?: Episode[]
|
||||
torrents?: Torrent[]
|
||||
latest_episode?: Episode
|
||||
}
|
||||
|
||||
export interface ScheduleDay {
|
||||
release: Release
|
||||
full_season_is_released: boolean
|
||||
published_release_episode?: Episode | null
|
||||
next_release_episode_number?: number | null
|
||||
}
|
||||
|
||||
export interface ScheduleNow {
|
||||
today: ScheduleDay[]
|
||||
tomorrow: ScheduleDay[]
|
||||
yesterday: ScheduleDay[]
|
||||
}
|
||||
|
||||
export interface Pagination {
|
||||
total: number
|
||||
count: number
|
||||
per_page: number
|
||||
current_page: number
|
||||
total_pages: number
|
||||
}
|
||||
|
||||
export interface CatalogResponse {
|
||||
data: Release[]
|
||||
meta: { pagination: Pagination }
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: number
|
||||
login: string
|
||||
email?: string
|
||||
nickname: string
|
||||
avatar?: ImageObj
|
||||
is_banned?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export type CollectionType = 'WATCHING' | 'PLANNED' | 'WATCHED' | 'POSTPONED' | 'ABANDONED'
|
||||
|
||||
export interface TimecodeItem {
|
||||
release_episode_id: string
|
||||
time: number
|
||||
is_watched: boolean
|
||||
}
|
||||
|
||||
export interface HistoryItem {
|
||||
id: number
|
||||
time: number
|
||||
is_watched: boolean
|
||||
updated_at: string
|
||||
release_episode_id: string
|
||||
release_episode: Episode
|
||||
release: Release
|
||||
}
|
||||
|
||||
export interface CatalogFilters {
|
||||
genres?: number[]
|
||||
types?: string[]
|
||||
seasons?: string[]
|
||||
years?: { from_year?: number; to_year?: number }
|
||||
search?: string
|
||||
sorting?: string
|
||||
age_ratings?: string[]
|
||||
publish_statuses?: string[]
|
||||
production_statuses?: string[]
|
||||
}
|
||||
48
src/renderer/src/lib/wishlist.ts
Normal file
48
src/renderer/src/lib/wishlist.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { Release } from './types'
|
||||
|
||||
const KEY = 'anisav:wishlist'
|
||||
const EVENT = 'anisav:wishlist-changed'
|
||||
|
||||
function read(): Release[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
return raw ? JSON.parse(raw) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function write(items: Release[]): void {
|
||||
localStorage.setItem(KEY, JSON.stringify(items))
|
||||
window.dispatchEvent(new Event(EVENT))
|
||||
}
|
||||
|
||||
export function getWishlist(): Release[] {
|
||||
return read()
|
||||
}
|
||||
|
||||
export function isInWishlist(releaseId: number): boolean {
|
||||
return read().some((r) => r.id === releaseId)
|
||||
}
|
||||
|
||||
export function addToWishlist(release: Release): void {
|
||||
const items = read()
|
||||
if (items.some((r) => r.id === release.id)) return
|
||||
write([release, ...items])
|
||||
}
|
||||
|
||||
export function removeFromWishlist(releaseId: number): void {
|
||||
write(read().filter((r) => r.id !== releaseId))
|
||||
}
|
||||
|
||||
export function toggleWishlist(release: Release): boolean {
|
||||
const inList = isInWishlist(release.id)
|
||||
if (inList) removeFromWishlist(release.id)
|
||||
else addToWishlist(release)
|
||||
return !inList
|
||||
}
|
||||
|
||||
export function onWishlistChange(cb: () => void): () => void {
|
||||
window.addEventListener(EVENT, cb)
|
||||
return () => window.removeEventListener(EVENT, cb)
|
||||
}
|
||||
16
src/renderer/src/main.tsx
Normal file
16
src/renderer/src/main.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { HashRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import { AuthProvider } from './lib/AuthContext'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<HashRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</HashRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
254
src/renderer/src/pages/Catalog.tsx
Normal file
254
src/renderer/src/pages/Catalog.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { api } from '../lib/api'
|
||||
import type { CatalogFilters, Genre, Release, ValueDescription } from '../lib/types'
|
||||
import AnimeCard from '../components/AnimeCard'
|
||||
import { GridSkeleton } from '../components/Skeleton'
|
||||
|
||||
const container = { hidden: {}, show: { transition: { staggerChildren: 0.04 } } }
|
||||
|
||||
type SortOpt = ValueDescription & { label: string }
|
||||
|
||||
export default function Catalog() {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const searchQ = params.get('search') || ''
|
||||
const genreQ = params.get('genre')
|
||||
|
||||
const [refGenres, setRefGenres] = useState<Genre[]>([])
|
||||
const [refTypes, setRefTypes] = useState<ValueDescription[]>([])
|
||||
const [refSeasons, setRefSeasons] = useState<ValueDescription[]>([])
|
||||
const [refSorting, setRefSorting] = useState<SortOpt[]>([])
|
||||
|
||||
const [selectedGenres, setSelectedGenres] = useState<number[]>(genreQ ? [Number(genreQ)] : [])
|
||||
const [selectedTypes, setSelectedTypes] = useState<string[]>([])
|
||||
const [selectedSeasons, setSelectedSeasons] = useState<string[]>([])
|
||||
const [sorting, setSorting] = useState('FRESH_AT_DESC')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const [results, setResults] = useState<Release[] | null>(null)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.refGenres().then(setRefGenres).catch(() => {})
|
||||
api.refTypes().then(setRefTypes).catch(() => {})
|
||||
api.refSeasons().then(setRefSeasons).catch(() => {})
|
||||
api.refSorting().then(setRefSorting).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (genreQ) setSelectedGenres([Number(genreQ)])
|
||||
}, [genreQ])
|
||||
|
||||
const filters: CatalogFilters = useMemo(
|
||||
() => ({
|
||||
genres: selectedGenres.length ? selectedGenres : undefined,
|
||||
types: selectedTypes.length ? selectedTypes : undefined,
|
||||
seasons: selectedSeasons.length ? selectedSeasons : undefined,
|
||||
search: searchQ || undefined,
|
||||
sorting,
|
||||
}),
|
||||
[selectedGenres, selectedTypes, selectedSeasons, searchQ, sorting]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
setResults(null)
|
||||
if (searchQ) {
|
||||
api
|
||||
.search(searchQ)
|
||||
.then((data) => {
|
||||
setResults(data)
|
||||
setTotalPages(1)
|
||||
})
|
||||
.catch(() => setResults([]))
|
||||
.finally(() => setLoading(false))
|
||||
return
|
||||
}
|
||||
api
|
||||
.catalog(filters, page, 24)
|
||||
.then((res) => {
|
||||
setResults(res.data)
|
||||
setTotalPages(res.meta.pagination.total_pages)
|
||||
})
|
||||
.catch(() => setResults([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [filters, page, searchQ])
|
||||
|
||||
function toggle(list: number[], value: number, set: (v: number[]) => void) {
|
||||
set(list.includes(value) ? list.filter((v) => v !== value) : [...list, value])
|
||||
setPage(1)
|
||||
}
|
||||
function toggleStr(list: string[], value: string, set: (v: string[]) => void) {
|
||||
set(list.includes(value) ? list.filter((v) => v !== value) : [...list, value])
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-6">
|
||||
<h1 className="mb-4 text-xl font-bold">
|
||||
{searchQ ? `Поиск: "${searchQ}"` : 'Каталог'}
|
||||
</h1>
|
||||
|
||||
<div className="flex gap-6">
|
||||
{!searchQ && (
|
||||
<motion.aside
|
||||
initial={{ opacity: 0, x: -12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="w-56 shrink-0 space-y-5 text-sm"
|
||||
>
|
||||
<FilterGroup title="Сортировка">
|
||||
<select
|
||||
value={sorting}
|
||||
onChange={(e) => {
|
||||
setSorting(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className="w-full rounded bg-white/5 px-2 py-1.5 text-xs outline-none"
|
||||
>
|
||||
{refSorting.map((s) => (
|
||||
<option key={s.value} value={s.value} className="bg-surface">
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FilterGroup>
|
||||
|
||||
<FilterGroup title="Тип">
|
||||
{refTypes.map((t) => (
|
||||
<Check
|
||||
key={t.value}
|
||||
label={t.description}
|
||||
checked={selectedTypes.includes(t.value)}
|
||||
onChange={() => toggleStr(selectedTypes, t.value, setSelectedTypes)}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
|
||||
<FilterGroup title="Сезон">
|
||||
{refSeasons.map((s) => (
|
||||
<Check
|
||||
key={s.value}
|
||||
label={s.description}
|
||||
checked={selectedSeasons.includes(s.value)}
|
||||
onChange={() => toggleStr(selectedSeasons, s.value, setSelectedSeasons)}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
|
||||
<FilterGroup title="Жанры">
|
||||
<div className="max-h-64 space-y-1 overflow-y-auto pr-1">
|
||||
{refGenres.map((g) => (
|
||||
<Check
|
||||
key={g.id}
|
||||
label={g.name}
|
||||
checked={selectedGenres.includes(g.id)}
|
||||
onChange={() => toggle(selectedGenres, g.id, setSelectedGenres)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</FilterGroup>
|
||||
|
||||
<AnimatePresence>
|
||||
{(selectedGenres.length > 0 || selectedTypes.length > 0 || selectedSeasons.length > 0) && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={() => {
|
||||
setSelectedGenres([])
|
||||
setSelectedTypes([])
|
||||
setSelectedSeasons([])
|
||||
setParams({})
|
||||
setPage(1)
|
||||
}}
|
||||
className="text-xs text-white/40 hover:text-white"
|
||||
>
|
||||
Сбросить фильтры
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.aside>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
{loading && <GridSkeleton count={18} />}
|
||||
{!loading && results && results.length === 0 && (
|
||||
<p className="py-16 text-center text-white/40">Ничего не найдено</p>
|
||||
)}
|
||||
{!loading && results && results.length > 0 && (
|
||||
<>
|
||||
<motion.div
|
||||
key={`${JSON.stringify(filters)}-${page}`}
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6"
|
||||
>
|
||||
{results.map((r) => (
|
||||
<AnimeCard key={r.id} release={r} />
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{!searchQ && totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-center gap-2 text-sm">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
className="rounded bg-white/5 px-3 py-1.5 disabled:opacity-30"
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<span className="text-white/50">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="rounded bg-white/5 px-3 py-1.5 disabled:opacity-30"
|
||||
>
|
||||
Далее
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterGroup({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 text-xs font-bold uppercase tracking-wide text-white/40">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Check({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
checked: boolean
|
||||
onChange: () => void
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center gap-2 py-0.5 text-white/70 hover:text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
className="h-3.5 w-3.5 accent-[#fe3635]"
|
||||
/>
|
||||
<span className="text-xs">{label}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
62
src/renderer/src/pages/Genres.tsx
Normal file
62
src/renderer/src/pages/Genres.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { api, imageUrl } from '../lib/api'
|
||||
import type { Genre } from '../lib/types'
|
||||
import Loader from '../components/Loader'
|
||||
|
||||
const container = { hidden: {}, show: { transition: { staggerChildren: 0.035 } } }
|
||||
const tile = {
|
||||
hidden: { opacity: 0, y: 14, scale: 0.97 },
|
||||
show: { opacity: 1, y: 0, scale: 1, transition: { duration: 0.3, ease: 'easeOut' } },
|
||||
}
|
||||
|
||||
export default function Genres() {
|
||||
const [genres, setGenres] = useState<Genre[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.genres().then(setGenres).catch(() => setGenres([]))
|
||||
}, [])
|
||||
|
||||
if (!genres) return <Loader label="Загружаем жанры..." />
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-6">
|
||||
<h1 className="mb-4 text-xl font-bold">Жанры</h1>
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4"
|
||||
>
|
||||
{genres.map((g) => (
|
||||
<motion.div key={g.id} variants={tile} whileHover={{ y: -4, scale: 1.02 }} transition={{ type: 'spring', stiffness: 320, damping: 20 }}>
|
||||
<Link
|
||||
to={`/catalog?genre=${g.id}`}
|
||||
className="group relative block h-24 overflow-hidden rounded-lg bg-surface-card"
|
||||
>
|
||||
{g.image?.preview && (
|
||||
<motion.img
|
||||
src={imageUrl(g.image.optimized?.preview || g.image.preview)}
|
||||
alt=""
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.4 }}
|
||||
whileHover={{ opacity: 0.6, scale: 1.08 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent" />
|
||||
<div className="relative flex h-full flex-col justify-end p-3">
|
||||
<p className="font-semibold">{g.name}</p>
|
||||
{g.total_releases != null && (
|
||||
<p className="text-xs text-white/50">{g.total_releases} тайтлов</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
src/renderer/src/pages/History.tsx
Normal file
71
src/renderer/src/pages/History.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuth } from '../lib/AuthContext'
|
||||
import type { HistoryItem } from '../lib/types'
|
||||
import { imageUrl } from '../lib/api'
|
||||
import Loader from '../components/Loader'
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export default function History() {
|
||||
const { user, loading: authLoading } = useAuth()
|
||||
const [items, setItems] = useState<HistoryItem[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
window.anisav
|
||||
.historyList()
|
||||
.then((res) => setItems(res.data))
|
||||
.catch(() => setItems([]))
|
||||
}, [user])
|
||||
|
||||
if (authLoading) return <Loader />
|
||||
if (!user) return <p className="py-24 text-center text-white/40">Войдите, чтобы видеть историю просмотра</p>
|
||||
if (!items) return <Loader />
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-6">
|
||||
<h1 className="mb-4 text-xl font-bold">История просмотра</h1>
|
||||
{items.length === 0 && <p className="py-16 text-center text-white/40">История пуста</p>}
|
||||
<div className="space-y-2">
|
||||
{items.filter((item) => item.release && item.release_episode).map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={`/title/${item.release.alias || item.release.id}`}
|
||||
className="flex items-center gap-3 rounded-lg bg-white/5 p-2.5 hover:bg-white/10"
|
||||
>
|
||||
<img
|
||||
src={imageUrl(item.release.poster?.optimized?.thumbnail || item.release.poster?.thumbnail)}
|
||||
alt=""
|
||||
className="h-16 w-12 shrink-0 rounded object-cover"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{item.release.name.main}</p>
|
||||
<p className="text-xs text-white/40">
|
||||
Серия {item.release_episode.ordinal}
|
||||
{item.release_episode.name ? ` — ${item.release_episode.name}` : ''}
|
||||
</p>
|
||||
<p className="text-xs text-white/30">{formatDate(item.updated_at)}</p>
|
||||
</div>
|
||||
{item.is_watched ? (
|
||||
<span className="shrink-0 rounded bg-white/10 px-2 py-0.5 text-[10px] font-semibold text-white/50">
|
||||
Просмотрено
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 rounded bg-accent/20 px-2 py-0.5 text-[10px] font-semibold text-accent">
|
||||
В процессе
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
72
src/renderer/src/pages/Home.tsx
Normal file
72
src/renderer/src/pages/Home.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { api, imageUrl } from '../lib/api'
|
||||
import type { Release, ScheduleDay } from '../lib/types'
|
||||
import Row from '../components/Row'
|
||||
import { GridSkeleton } from '../components/Skeleton'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function Home() {
|
||||
const [latest, setLatest] = useState<Release[] | null>(null)
|
||||
const [recommended, setRecommended] = useState<Release[] | null>(null)
|
||||
const [today, setToday] = useState<ScheduleDay[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.latest(21).then(setLatest).catch(() => setLatest([]))
|
||||
api.recommended(14).then(setRecommended).catch(() => setRecommended([]))
|
||||
api.scheduleNow().then((s) => setToday(s.today)).catch(() => setToday([]))
|
||||
}, [])
|
||||
|
||||
const hero = latest?.[0]
|
||||
|
||||
if (!latest) {
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-6">
|
||||
<div className="mb-6 h-[380px] w-full animate-pulse rounded-xl bg-white/5" />
|
||||
<GridSkeleton count={12} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{hero && (
|
||||
<div className="relative h-[380px] w-full overflow-hidden">
|
||||
<motion.img
|
||||
src={imageUrl(hero.poster?.optimized?.preview || hero.poster?.preview)}
|
||||
alt=""
|
||||
initial={{ opacity: 0, scale: 1.08 }}
|
||||
animate={{ opacity: 0.45, scale: 1 }}
|
||||
transition={{ duration: 1.2, ease: 'easeOut' }}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-surface via-surface/50 to-transparent" />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.15 }}
|
||||
className="relative mx-auto flex h-full max-w-7xl flex-col justify-end px-6 pb-8"
|
||||
>
|
||||
<span className="mb-2 w-fit rounded bg-accent px-2 py-0.5 text-xs font-bold uppercase">
|
||||
Новинка
|
||||
</span>
|
||||
<h1 className="max-w-xl text-3xl font-extrabold leading-tight drop-shadow">
|
||||
{hero.name.main}
|
||||
</h1>
|
||||
<p className="mt-2 max-w-xl line-clamp-2 text-sm text-white/60">{hero.description}</p>
|
||||
<Link
|
||||
to={`/title/${hero.alias || hero.id}`}
|
||||
className="mt-4 w-fit rounded-full bg-accent px-5 py-2 text-sm font-semibold transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
Смотреть
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Row title="Онгоинги сегодня" releases={today?.map((s) => s.release).filter(Boolean) ?? []} />
|
||||
<Row title="Последние обновления" releases={latest} />
|
||||
<Row title="Рекомендуем" releases={recommended ?? []} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
94
src/renderer/src/pages/MyLists.tsx
Normal file
94
src/renderer/src/pages/MyLists.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useAuth } from '../lib/AuthContext'
|
||||
import type { CollectionType, Release } from '../lib/types'
|
||||
import AnimeCard from '../components/AnimeCard'
|
||||
import Loader from '../components/Loader'
|
||||
|
||||
const TABS: Array<{ key: CollectionType | 'FAVORITES'; label: string }> = [
|
||||
{ key: 'WATCHING', label: 'Смотрю' },
|
||||
{ key: 'PLANNED', label: 'Запланировано' },
|
||||
{ key: 'WATCHED', label: 'Просмотрено' },
|
||||
{ key: 'POSTPONED', label: 'Отложено' },
|
||||
{ key: 'ABANDONED', label: 'Брошено' },
|
||||
{ key: 'FAVORITES', label: 'Избранное' },
|
||||
]
|
||||
|
||||
export default function MyLists() {
|
||||
const { user, loading: authLoading } = useAuth()
|
||||
const [tab, setTab] = useState<(typeof TABS)[number]['key']>('WATCHING')
|
||||
const [releases, setReleases] = useState<Release[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
setReleases(null)
|
||||
const req =
|
||||
tab === 'FAVORITES'
|
||||
? window.anisav.favoritesList(1, 48)
|
||||
: window.anisav.collectionList(tab, 1, 48)
|
||||
req.then((res) => setReleases(res.data)).catch(() => setReleases([]))
|
||||
}, [tab, user])
|
||||
|
||||
if (authLoading) return <Loader />
|
||||
if (!user) return <p className="py-24 text-center text-white/40">Войдите, чтобы видеть свои списки</p>
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-6">
|
||||
<h1 className="mb-4 text-xl font-bold">Мои списки</h1>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-2 border-b border-white/5 pb-3">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`relative rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors ${
|
||||
tab === t.key ? 'text-white' : 'text-white/60 hover:text-white/90'
|
||||
}`}
|
||||
>
|
||||
{tab === t.key && (
|
||||
<motion.span
|
||||
layoutId="mylists-tab-pill"
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
className="absolute inset-0 rounded-full bg-accent"
|
||||
/>
|
||||
)}
|
||||
<span className="relative">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{!releases && <Loader key="loader" />}
|
||||
{releases && releases.length === 0 && (
|
||||
<motion.p
|
||||
key="empty"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="py-16 text-center text-white/40"
|
||||
>
|
||||
Список пуст
|
||||
</motion.p>
|
||||
)}
|
||||
{releases && releases.length > 0 && (
|
||||
<motion.div
|
||||
key={tab}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
variants={{ show: { transition: { staggerChildren: 0.03 } } }}
|
||||
className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6"
|
||||
>
|
||||
{releases.map((r) => (
|
||||
<motion.div
|
||||
key={r.id}
|
||||
variants={{ hidden: { opacity: 0, y: 12 }, show: { opacity: 1, y: 0 } }}
|
||||
>
|
||||
<AnimeCard release={r} />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/renderer/src/pages/Schedule.tsx
Normal file
45
src/renderer/src/pages/Schedule.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../lib/api'
|
||||
import type { ScheduleDay } from '../lib/types'
|
||||
import AnimeCard from '../components/AnimeCard'
|
||||
import Loader from '../components/Loader'
|
||||
|
||||
const DAY_ORDER = [1, 2, 3, 4, 5, 6, 7]
|
||||
|
||||
export default function Schedule() {
|
||||
const [week, setWeek] = useState<ScheduleDay[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.scheduleWeek().then(setWeek).catch(() => setWeek([]))
|
||||
}, [])
|
||||
|
||||
if (!week) return <Loader label="Загружаем расписание..." />
|
||||
|
||||
const byDay = new Map<number, ScheduleDay[]>()
|
||||
for (const item of week) {
|
||||
if (!item.release) continue
|
||||
const day = item.release.publish_day?.value ?? 0
|
||||
if (!byDay.has(day)) byDay.set(day, [])
|
||||
byDay.get(day)!.push(item)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-6">
|
||||
<h1 className="mb-4 text-xl font-bold">Расписание выхода серий</h1>
|
||||
<div className="space-y-8">
|
||||
{DAY_ORDER.filter((d) => byDay.has(d)).map((day) => (
|
||||
<div key={day}>
|
||||
<h2 className="mb-3 text-sm font-bold uppercase tracking-wide text-accent">
|
||||
{byDay.get(day)![0].release.publish_day?.description}
|
||||
</h2>
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-7">
|
||||
{byDay.get(day)!.map((item) => (
|
||||
<AnimeCard key={item.release.id} release={item.release} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
273
src/renderer/src/pages/TitleDetail.tsx
Normal file
273
src/renderer/src/pages/TitleDetail.tsx
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { api, imageUrl } from '../lib/api'
|
||||
import type { Episode, Release, TimecodeItem } from '../lib/types'
|
||||
import { useAuth } from '../lib/AuthContext'
|
||||
import { getAllLocalProgress, saveLocalProgress } from '../lib/localProgress'
|
||||
import Loader from '../components/Loader'
|
||||
import Player from '../components/Player'
|
||||
import AddToListButton from '../components/AddToListButton'
|
||||
import WishlistButton from '../components/WishlistButton'
|
||||
|
||||
function formatBytes(n: number) {
|
||||
const units = ['Б', 'КБ', 'МБ', 'ГБ']
|
||||
let i = 0
|
||||
let v = n
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
export default function TitleDetail() {
|
||||
const { idOrAlias } = useParams()
|
||||
const { user } = useAuth()
|
||||
const [release, setRelease] = useState<Release | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [selectedEp, setSelectedEp] = useState<Episode | null>(null)
|
||||
const [timecodes, setTimecodes] = useState<Map<string, TimecodeItem>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
if (!idOrAlias) return
|
||||
setRelease(null)
|
||||
setSelectedEp(null)
|
||||
api
|
||||
.release(idOrAlias)
|
||||
.then((r) => {
|
||||
setRelease(r)
|
||||
setSelectedEp(r.episodes?.[0] ?? null)
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
}, [idOrAlias])
|
||||
|
||||
useEffect(() => {
|
||||
// Local progress always applies (no account needed) — account timecodes
|
||||
// (when logged in) are layered on top since they may be synced elsewhere.
|
||||
const local = getAllLocalProgress()
|
||||
const merged = new Map<string, TimecodeItem>(
|
||||
Object.entries(local).map(([id, p]) => [
|
||||
id,
|
||||
{ release_episode_id: id, time: p.time, is_watched: p.is_watched },
|
||||
])
|
||||
)
|
||||
setTimecodes(merged)
|
||||
|
||||
if (!user) return
|
||||
window.anisav
|
||||
.timecodes()
|
||||
.then((items: TimecodeItem[]) => {
|
||||
setTimecodes((prev) => {
|
||||
const next = new Map(prev)
|
||||
for (const i of items) next.set(i.release_episode_id, i)
|
||||
return next
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [user, idOrAlias])
|
||||
|
||||
const poster = useMemo(
|
||||
() => imageUrl(release?.poster?.optimized?.preview || release?.poster?.preview),
|
||||
[release]
|
||||
)
|
||||
|
||||
const saveTimecode = useCallback(
|
||||
(episodeId: string, time: number, isWatched: boolean) => {
|
||||
saveLocalProgress(episodeId, time, isWatched)
|
||||
if (user) window.anisav.timecodeSave(episodeId, time, isWatched).catch(() => {})
|
||||
setTimecodes((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(episodeId, { release_episode_id: episodeId, time, is_watched: isWatched })
|
||||
return next
|
||||
})
|
||||
},
|
||||
[user]
|
||||
)
|
||||
|
||||
function goToNextEpisode() {
|
||||
if (!release?.episodes || !selectedEp) return
|
||||
const idx = release.episodes.findIndex((e) => e.id === selectedEp.id)
|
||||
const next = release.episodes[idx + 1]
|
||||
if (next) setSelectedEp(next)
|
||||
}
|
||||
|
||||
if (error) return <p className="py-24 text-center text-white/40">Тайтл не найден</p>
|
||||
if (!release) return <Loader label="Загружаем..." />
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-6">
|
||||
<motion.div
|
||||
key={release.id}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
variants={{ show: { transition: { staggerChildren: 0.06 } } }}
|
||||
className="flex flex-col gap-6 md:flex-row"
|
||||
>
|
||||
<motion.img
|
||||
variants={{ hidden: { opacity: 0, x: -16 }, show: { opacity: 1, x: 0 } }}
|
||||
transition={{ duration: 0.35 }}
|
||||
src={poster}
|
||||
alt={release.name.main}
|
||||
className="h-fit w-48 shrink-0 rounded-lg object-cover shadow-lg"
|
||||
/>
|
||||
<motion.div
|
||||
variants={{ hidden: { opacity: 0, y: 12 }, show: { opacity: 1, y: 0 } }}
|
||||
transition={{ duration: 0.35 }}
|
||||
className="min-w-0"
|
||||
>
|
||||
<h1 className="text-2xl font-extrabold">{release.name.main}</h1>
|
||||
{release.name.english && (
|
||||
<p className="mt-0.5 text-sm text-white/40">{release.name.english}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs">
|
||||
<Chip>{release.type?.description}</Chip>
|
||||
<Chip>{release.year}</Chip>
|
||||
<Chip>{release.season?.description}</Chip>
|
||||
<Chip>{release.age_rating?.label}</Chip>
|
||||
{release.is_ongoing && <Chip accent>Онгоинг</Chip>}
|
||||
{release.episodes_total != null && <Chip>{release.episodes_total} эп.</Chip>}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<AddToListButton releaseId={release.id} />
|
||||
<WishlistButton release={release} />
|
||||
</div>
|
||||
|
||||
{!!release.genres?.length && (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
variants={{ show: { transition: { staggerChildren: 0.03, delayChildren: 0.2 } } }}
|
||||
className="mt-3 flex flex-wrap gap-1.5"
|
||||
>
|
||||
{release.genres.map((g) => (
|
||||
<motion.span
|
||||
key={g.id}
|
||||
variants={{ hidden: { opacity: 0, scale: 0.8 }, show: { opacity: 1, scale: 1 } }}
|
||||
className="rounded-full bg-white/5 px-2.5 py-1 text-xs text-white/60"
|
||||
>
|
||||
{g.name}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{release.description && (
|
||||
<p className="mt-4 max-w-3xl whitespace-pre-line text-sm leading-relaxed text-white/70">
|
||||
{release.description}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{selectedEp && (
|
||||
<div className="mt-8">
|
||||
<Player
|
||||
episode={selectedEp}
|
||||
key={selectedEp.id}
|
||||
resumeAt={timecodes.get(selectedEp.id)?.time}
|
||||
onProgress={(t) => saveTimecode(selectedEp.id, t, false)}
|
||||
onEnded={() => {
|
||||
saveTimecode(selectedEp.id, 0, true)
|
||||
goToNextEpisode()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!release.episodes?.length && (
|
||||
<div className="mt-6">
|
||||
<h2 className="mb-3 text-lg font-bold">Серии</h2>
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
variants={{ show: { transition: { staggerChildren: 0.015 } } }}
|
||||
className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6"
|
||||
>
|
||||
{release.episodes.map((ep) => {
|
||||
const tc = timecodes.get(ep.id)
|
||||
const isSelected = selectedEp?.id === ep.id
|
||||
return (
|
||||
<motion.button
|
||||
key={ep.id}
|
||||
variants={{ hidden: { opacity: 0, y: 8 }, show: { opacity: 1, y: 0 } }}
|
||||
whileTap={{ scale: 0.96 }}
|
||||
onClick={() => setSelectedEp(ep)}
|
||||
className={`relative overflow-hidden rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||
isSelected ? 'font-semibold text-white' : 'bg-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<motion.div
|
||||
layoutId="episode-active-bg"
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 32 }}
|
||||
className="absolute inset-0 bg-accent"
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex items-center gap-1.5 font-semibold">
|
||||
Серия {ep.ordinal}
|
||||
{tc?.is_watched && <span className="text-[10px] text-white/60">✓</span>}
|
||||
</div>
|
||||
{ep.name && <div className="relative truncate text-xs text-white/50">{ep.name}</div>}
|
||||
{tc && !tc.is_watched && ep.duration && (
|
||||
<div className="relative mt-1 h-0.5 w-full overflow-hidden rounded bg-black/30">
|
||||
<div
|
||||
className="h-full bg-white/60"
|
||||
style={{ width: `${Math.min(100, (tc.time / ep.duration) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.button>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!release.torrents?.length && (
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-3 text-lg font-bold">Торренты</h2>
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
variants={{ show: { transition: { staggerChildren: 0.03 } } }}
|
||||
className="space-y-2"
|
||||
>
|
||||
{release.torrents.map((t) => (
|
||||
<motion.div
|
||||
key={t.id}
|
||||
variants={{ hidden: { opacity: 0, x: -8 }, show: { opacity: 1, x: 0 } }}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-white/5 px-4 py-2.5 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{t.label}</p>
|
||||
<p className="text-xs text-white/40">
|
||||
{formatBytes(t.size)} · {t.quality?.description} · {t.seeders} сидов
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.anisav.openExternal(t.magnet)}
|
||||
className="shrink-0 rounded-full bg-accent px-3 py-1 text-xs font-semibold hover:bg-accent-hover"
|
||||
>
|
||||
Magnet
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Chip({ children, accent }: { children: React.ReactNode; accent?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 font-semibold ${accent ? 'bg-accent' : 'bg-white/10 text-white/70'}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
41
src/renderer/src/pages/Wishlist.tsx
Normal file
41
src/renderer/src/pages/Wishlist.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { getWishlist, onWishlistChange } from '../lib/wishlist'
|
||||
import type { Release } from '../lib/types'
|
||||
import AnimeCard from '../components/AnimeCard'
|
||||
|
||||
const container = { hidden: {}, show: { transition: { staggerChildren: 0.04 } } }
|
||||
|
||||
export default function Wishlist() {
|
||||
const [items, setItems] = useState<Release[]>(() => getWishlist())
|
||||
|
||||
useEffect(() => onWishlistChange(() => setItems(getWishlist())), [])
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-6">
|
||||
<h1 className="mb-1 text-xl font-bold">Понравилось</h1>
|
||||
<p className="mb-4 text-xs text-white/40">
|
||||
Локальный список на этом устройстве — не требует входа в аккаунт AniLiberty
|
||||
</p>
|
||||
|
||||
{items.length === 0 && (
|
||||
<p className="py-16 text-center text-white/40">
|
||||
Пока пусто — нажми на звёздочку у тайтла, чтобы добавить сюда
|
||||
</p>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6"
|
||||
>
|
||||
{items.map((r) => (
|
||||
<AnimeCard key={r.id} release={r} />
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
tailwind.config.js
Normal file
23
tailwind.config.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/renderer/index.html', './src/renderer/src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
accent: {
|
||||
DEFAULT: '#fe3635',
|
||||
hover: '#ff5352',
|
||||
},
|
||||
surface: {
|
||||
DEFAULT: '#121212',
|
||||
raised: '#1a1a1a',
|
||||
card: '#1e1e1e',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'Roboto', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.web.json" }
|
||||
]
|
||||
}
|
||||
13
tsconfig.node.json
Normal file
13
tsconfig.node.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["electron-vite/node", "node"],
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
19
tsconfig.web.json
Normal file
19
tsconfig.web.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"include": ["src/renderer/src/**/*", "src/preload/*.d.ts"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@renderer/*": ["src/renderer/src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue