anisav: electron anime app, anilibria via api.savsis.xyz

This commit is contained in:
ImSavsis 2026-07-19 06:03:21 +05:00
commit 27526ca489
50 changed files with 11600 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules/
out/
dist/
_research/
.claude/
*.log
.DS_Store

22
README.md Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

38
electron.vite.config.ts Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

5
favicon.svg Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

47
package.json Normal file
View 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
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

196
src/main/index.ts Normal file
View 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
View file

@ -0,0 +1,7 @@
import type { AniSavApi } from './index'
declare global {
interface Window {
anisav: AniSavApi
}
}

33
src/preload/index.ts Normal file
View 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
View 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
View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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;
}

View 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
}

View 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'),
}

View 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)
}

View 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))
}

View 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[]
}

View 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
View 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>
)

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.web.json" }
]
}

13
tsconfig.node.json Normal file
View 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
View 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/*"]
}
}
}