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