Compare commits

..

2 commits

21 changed files with 146 additions and 115 deletions

View file

@ -1,3 +1,5 @@
[![ci](https://github.com/devsavsis/anisav/actions/workflows/ci.yml/badge.svg)](https://github.com/devsavsis/anisav/actions/workflows/ci.yml)
Я че умный? Я че умный?
# anisav # anisav

View file

@ -11,7 +11,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Mono:wght@400;700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
</head> </head>

View file

@ -94,8 +94,8 @@ export default function AddToListButton({ releaseId }: { releaseId: number }) {
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
disabled={busy} disabled={busy}
whileTap={{ scale: 0.96 }} whileTap={{ scale: 0.96 }}
className={`rounded-full px-4 py-1.5 text-sm font-semibold transition-colors ${ className={`rounded-full px-4 py-1.5 text-sm font-semibold transition-transform ${
collectionType ? 'bg-white/10 hover:bg-white/15' : 'bg-accent hover:bg-accent-hover' collectionType ? 'bg-white/10 hover:bg-white/15' : 'bg-accent-gradient hover:scale-105'
}`} }`}
> >
{collectionType ? LABELS[collectionType] : '+ Добавить в список'} {collectionType ? LABELS[collectionType] : '+ Добавить в список'}
@ -103,12 +103,12 @@ export default function AddToListButton({ releaseId }: { releaseId: number }) {
<AnimatePresence> <AnimatePresence>
{open && ( {open && (
<motion.div <motion.div
initial={{ opacity: 0, y: -6, scale: 0.96 }} initial={{ opacity: 0, y: -6, filter: 'blur(4px)' }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
exit={{ opacity: 0, y: -6, scale: 0.96 }} exit={{ opacity: 0, y: -4, filter: 'blur(3px)' }}
transition={{ duration: 0.15 }} transition={{ type: 'spring', duration: 0.3, bounce: 0 }}
onMouseLeave={() => setOpen(false)} 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" className="glass absolute left-0 top-full z-10 mt-1 w-44 origin-top overflow-hidden rounded-xl border border-white/10 shadow-xl"
> >
{(Object.keys(LABELS) as CollectionType[]).map((t) => ( {(Object.keys(LABELS) as CollectionType[]).map((t) => (
<button <button
@ -142,7 +142,7 @@ export default function AddToListButton({ releaseId }: { releaseId: number }) {
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
title={isFavorite ? 'Убрать из избранного' : 'В избранное'} title={isFavorite ? 'Убрать из избранного' : 'В избранное'}
className={`flex h-8 w-8 items-center justify-center rounded-full transition-colors ${ 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' isFavorite ? 'bg-accent-soft 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"> <svg width="16" height="16" viewBox="0 0 24 24" fill={isFavorite ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">

View file

@ -14,9 +14,9 @@ export default function AnimeCard({ release }: { release: Release }) {
const title = release.name.main const title = release.name.main
return ( return (
<motion.div variants={cardVariants} whileHover={{ y: -6 }} transition={{ type: 'spring', stiffness: 300, damping: 22 }}> <motion.div variants={cardVariants}>
<Link to={`/title/${release.alias || release.id}`} className="poster-card group block"> <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"> <div className="aspect-[2/3] w-full overflow-hidden rounded-xl bg-surface-raised ring-1 ring-white/5">
{poster && ( {poster && (
<motion.img <motion.img
src={poster} src={poster}
@ -33,19 +33,19 @@ export default function AnimeCard({ release }: { release: Release }) {
<div className="absolute left-1.5 top-1.5 flex flex-col gap-1"> <div className="absolute left-1.5 top-1.5 flex flex-col gap-1">
{release.is_ongoing && ( {release.is_ongoing && (
<span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide shadow"> <span className="mono-label rounded bg-accent-gradient px-1.5 py-0.5 text-white shadow">
Онгоинг Онгоинг
</span> </span>
)} )}
{release.age_rating?.is_adult && ( {release.age_rating?.is_adult && (
<span className="rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-semibold"> <span className="mono-label rounded bg-black/70 px-1.5 py-0.5">
{release.age_rating.label} {release.age_rating.label}
</span> </span>
)} )}
</div> </div>
{release.episodes_total != null && ( {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"> <span className="mono-label absolute right-1.5 top-1.5 rounded bg-black/70 px-1.5 py-0.5">
{release.episodes_total} эп. {release.episodes_total} эп.
</span> </span>
)} )}

View file

@ -1,5 +1,5 @@
import { useState, FormEvent } from 'react' import { useState, FormEvent } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import Logo from './Logo' import Logo from './Logo'
import LoginModal from './LoginModal' import LoginModal from './LoginModal'
@ -19,6 +19,7 @@ export default function Header() {
const [showLogin, setShowLogin] = useState(false) const [showLogin, setShowLogin] = useState(false)
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const { user, logout } = useAuth() const { user, logout } = useAuth()
function onSubmit(e: FormEvent) { function onSubmit(e: FormEvent) {
@ -28,27 +29,34 @@ export default function Header() {
} }
return ( return (
<header className="sticky top-0 z-40 border-b border-white/5 bg-surface/85 backdrop-blur"> <header className="glass sticky top-0 z-40 border-b border-white/5">
<div className="mx-auto flex max-w-7xl items-center gap-6 px-6 py-3"> <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"> <Link to="/" className="flex shrink-0 items-center gap-2">
<Logo /> <Logo />
<span className="text-lg font-extrabold tracking-tight"> <span className="font-mono text-sm font-bold tracking-widest">
Ani<span className="text-accent">Sav</span> ANI<span className="text-accent">SAV</span>
</span> </span>
</Link> </Link>
<nav className="hidden gap-5 text-sm font-medium text-white/70 md:flex"> <nav className="hidden gap-1 text-sm font-medium text-white/60 md:flex">
{NAV.map((n) => ( {NAV.map((n) => (
<Link key={n.to} to={n.to} className="transition-colors hover:text-white"> <Link key={n.to} to={n.to} className="relative px-3 py-1.5 transition-colors hover:text-white">
{n.label} {location.pathname === n.to && (
<motion.span
layoutId="nav-active"
transition={{ type: 'spring', stiffness: 380, damping: 32 }}
className="absolute inset-0 rounded-full bg-white/8"
/>
)}
<span className={`relative ${location.pathname === n.to ? 'text-white' : ''}`}>{n.label}</span>
</Link> </Link>
))} ))}
{user && ( {user && (
<> <>
<Link to="/lists" className="transition-colors hover:text-white"> <Link to="/lists" className="px-3 py-1.5 transition-colors hover:text-white">
Мои списки Мои списки
</Link> </Link>
<Link to="/history" className="transition-colors hover:text-white"> <Link to="/history" className="px-3 py-1.5 transition-colors hover:text-white">
История История
</Link> </Link>
</> </>
@ -77,7 +85,7 @@ export default function Header() {
className="h-6 w-6 rounded-full object-cover" 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"> <span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent-gradient text-xs font-bold">
{user.nickname[0]?.toUpperCase()} {user.nickname[0]?.toUpperCase()}
</span> </span>
)} )}
@ -86,12 +94,12 @@ export default function Header() {
<AnimatePresence> <AnimatePresence>
{menuOpen && ( {menuOpen && (
<motion.div <motion.div
initial={{ opacity: 0, y: -6, scale: 0.96 }} initial={{ opacity: 0, y: -6, filter: 'blur(4px)' }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
exit={{ opacity: 0, y: -6, scale: 0.96 }} exit={{ opacity: 0, y: -4, filter: 'blur(3px)' }}
transition={{ duration: 0.15 }} transition={{ type: 'spring', duration: 0.35, bounce: 0 }}
onMouseLeave={() => setMenuOpen(false)} 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" className="glass absolute right-0 top-full z-10 mt-2 w-44 origin-top-right overflow-hidden rounded-xl border border-white/10 shadow-2xl"
> >
<Link <Link
to="/lists" to="/lists"
@ -112,7 +120,7 @@ export default function Header() {
setMenuOpen(false) setMenuOpen(false)
logout() logout()
}} }}
className="block w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-white/5" className="block w-full px-4 py-2 text-left text-sm text-accent hover:bg-white/5"
> >
Выйти Выйти
</button> </button>
@ -123,7 +131,7 @@ export default function Header() {
) : ( ) : (
<button <button
onClick={() => setShowLogin(true)} onClick={() => setShowLogin(true)}
className="shrink-0 rounded-full bg-accent px-4 py-1.5 text-sm font-semibold hover:bg-accent-hover" className="shrink-0 rounded-full bg-accent-gradient px-4 py-1.5 text-sm font-semibold transition-transform hover:scale-105"
> >
Войти Войти
</button> </button>

View file

@ -17,7 +17,6 @@ export default function LoginModal({ onClose }: { onClose: () => void }) {
await login(loginValue, password, remember) await login(loginValue, password, remember)
onClose() onClose()
} catch { } catch {
// error is surfaced via context
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@ -33,15 +32,15 @@ export default function LoginModal({ onClose }: { onClose: () => void }) {
onClick={onClose} onClick={onClose}
> >
<motion.form <motion.form
initial={{ opacity: 0, scale: 0.95, y: 10 }} initial={{ opacity: 0, scale: 0.96, y: 10, filter: 'blur(6px)' }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0, filter: 'blur(0px)' }}
exit={{ opacity: 0, scale: 0.95, y: 10 }} exit={{ opacity: 0, scale: 0.97, y: 6, filter: 'blur(4px)' }}
transition={{ type: 'spring', duration: 0.35, bounce: 0.25 }} transition={{ type: 'spring', duration: 0.4, bounce: 0.1 }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onSubmit={onSubmit} 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" className="my-auto max-h-[90vh] w-full max-w-sm overflow-y-auto rounded-2xl border border-white/10 bg-surface-raised p-6 shadow-2xl"
> >
<h2 className="text-lg font-bold">Вход в AniLiberty</h2> <h2 className="text-lg font-bold tracking-tight">Вход в AniLiberty</h2>
<p className="mt-1 text-xs text-white/40"> <p className="mt-1 text-xs text-white/40">
Используй логин и пароль своего аккаунта на aniliberty.top Используй логин и пароль своего аккаунта на aniliberty.top
</p> </p>
@ -72,13 +71,13 @@ export default function LoginModal({ onClose }: { onClose: () => void }) {
type="checkbox" type="checkbox"
checked={remember} checked={remember}
onChange={(e) => setRemember(e.target.checked)} onChange={(e) => setRemember(e.target.checked)}
className="h-3.5 w-3.5 accent-[#fe3635]" className="h-3.5 w-3.5 accent-[#FF3D5A]"
/> />
Запомнить меня Запомнить меня
</label> </label>
</div> </div>
{error && <p className="mt-3 text-xs text-red-400">{error}</p>} {error && <p className="mt-3 text-xs text-accent">{error}</p>}
<div className="mt-5 flex gap-2"> <div className="mt-5 flex gap-2">
<button <button
@ -91,7 +90,7 @@ export default function LoginModal({ onClose }: { onClose: () => void }) {
<button <button
type="submit" type="submit"
disabled={submitting || !loginValue || !password} disabled={submitting || !loginValue || !password}
className="flex-1 rounded-full bg-accent py-2 text-sm font-semibold hover:bg-accent-hover disabled:opacity-40" className="flex-1 rounded-full bg-accent-gradient py-2 text-sm font-semibold transition-transform hover:scale-[1.02] disabled:opacity-40 disabled:hover:scale-100"
> >
{submitting ? '...' : 'Войти'} {submitting ? '...' : 'Войти'}
</button> </button>

View file

@ -1,9 +1,17 @@
export default function Logo({ size = 28 }: { size?: number }) { export default function Logo({ size = 28 }: { size?: number }) {
return ( return (
<svg width={size} height={size} viewBox="0 0 1174 1174" fill="#fe3635" xmlns="http://www.w3.org/2000/svg"> <svg width={size} height={size} viewBox="0 0 1174 1174" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="anisav-logo-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#FF3D5A" />
<stop offset="100%" stopColor="#FF8A4C" />
</linearGradient>
</defs>
<g fill="url(#anisav-logo-grad)">
<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="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="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" /> <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" />
</g>
</svg> </svg>
) )
} }

View file

@ -55,7 +55,6 @@ export default function Player({ episode, resumeAt, onProgress, onEnded }: Props
setQuality(available[0]?.key) setQuality(available[0]?.key)
skippedRanges.current = new Set() skippedRanges.current = new Set()
didAutoFullscreen.current = false didAutoFullscreen.current = false
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [episode.id]) }, [episode.id])
useEffect(() => { useEffect(() => {
@ -73,10 +72,6 @@ export default function Player({ episode, resumeAt, onProgress, onEnded }: Props
}) })
hls.loadSource(src) hls.loadSource(src)
hls.attachMedia(video) 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) => { hls.on(Hls.Events.ERROR, (_event, data) => {
if (!data.fatal) return if (!data.fatal) return
switch (data.type) { switch (data.type) {
@ -106,7 +101,6 @@ export default function Player({ episode, resumeAt, onProgress, onEnded }: Props
hlsRef.current?.destroy() hlsRef.current?.destroy()
hlsRef.current = null hlsRef.current = null
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [quality, episode.id]) }, [quality, episode.id])
const reportProgress = useCallback(() => { const reportProgress = useCallback(() => {
@ -222,12 +216,10 @@ export default function Player({ episode, resumeAt, onProgress, onEnded }: Props
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [duration]) }, [duration])
useEffect(() => { useEffect(() => {
return () => reportProgress() return () => reportProgress()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [episode.id]) }, [episode.id])
return ( return (
@ -253,7 +245,7 @@ export default function Player({ episode, resumeAt, onProgress, onEnded }: Props
{showSkip && ( {showSkip && (
<button <button
onClick={skip} 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" className="absolute bottom-20 right-4 flex items-center gap-1.5 rounded-lg bg-black/70 px-4 py-2 text-sm font-semibold backdrop-blur-md transition-colors hover:bg-accent-gradient"
> >
<SkipIcon /> <SkipIcon />
Пропустить {showSkip === 'opening' ? 'опенинг' : 'эндинг'} Пропустить {showSkip === 'opening' ? 'опенинг' : 'эндинг'}
@ -320,7 +312,7 @@ export default function Player({ episode, resumeAt, onProgress, onEnded }: Props
{showSettings && ( {showSettings && (
<div <div
onMouseLeave={() => setShowSettings(false)} 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" className="glass absolute bottom-full right-0 mb-2 w-64 space-y-3 rounded-xl border border-white/10 p-3 text-sm shadow-2xl"
> >
{available.length > 1 && ( {available.length > 1 && (
<SettingsRow label="Качество"> <SettingsRow label="Качество">
@ -329,8 +321,8 @@ export default function Player({ episode, resumeAt, onProgress, onEnded }: Props
<button <button
key={q.key} key={q.key}
onClick={() => setQuality(q.key)} onClick={() => setQuality(q.key)}
className={`rounded px-1.5 py-0.5 text-xs ${ className={`rounded px-1.5 py-0.5 text-xs font-semibold ${
quality === q.key ? 'bg-accent' : 'bg-white/10 hover:bg-white/20' quality === q.key ? 'bg-accent-gradient' : 'bg-white/10 hover:bg-white/20'
}`} }`}
> >
{q.label} {q.label}
@ -340,8 +332,8 @@ export default function Player({ episode, resumeAt, onProgress, onEnded }: Props
</SettingsRow> </SettingsRow>
)} )}
<div className="border-t border-white/10 pt-2 text-xs text-white/40"> <div className="border-t border-white/10 pt-2 font-mono text-[11px] text-white/40">
Горячие клавиши: Пробел — пауза, ←/→ — перемотка 10с, F — экран, M — звук Пробел — пауза, ←/→ — перемотка 10с, F — экран, M — звук
</div> </div>
<div className="space-y-2 border-t border-white/10 pt-2"> <div className="space-y-2 border-t border-white/10 pt-2">
@ -406,7 +398,7 @@ function ToggleRow({
<span className="text-white/70">{label}</span> <span className="text-white/70">{label}</span>
<span <span
className={`relative h-4 w-8 shrink-0 rounded-full transition-colors ${ className={`relative h-4 w-8 shrink-0 rounded-full transition-colors ${
checked ? 'bg-accent' : 'bg-white/15' checked ? 'bg-accent-gradient' : 'bg-white/15'
}`} }`}
> >
<span <span

View file

@ -11,7 +11,7 @@ export default function Row({ title, releases }: { title: string; releases: Rele
if (!releases.length) return null if (!releases.length) return null
return ( return (
<section className="mx-auto max-w-7xl px-6 py-5"> <section className="mx-auto max-w-7xl px-6 py-5">
<h2 className="mb-3 text-lg font-bold">{title}</h2> <h2 className="mb-3 text-lg font-bold tracking-tight">{title}</h2>
<motion.div <motion.div
variants={container} variants={container}
initial="hidden" initial="hidden"

View file

@ -1,7 +1,7 @@
export function CardSkeleton() { export function CardSkeleton() {
return ( return (
<div className="animate-pulse"> <div className="animate-pulse">
<div className="aspect-[2/3] w-full rounded-lg bg-white/5" /> <div className="aspect-[2/3] w-full rounded-xl bg-white/5" />
<div className="mt-2 h-3 w-4/5 rounded 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 className="mt-1.5 h-2.5 w-2/5 rounded bg-white/5" />
</div> </div>

View file

@ -31,7 +31,7 @@ export default function WishlistButton({
transition={{ duration: 0.35 }} transition={{ duration: 0.35 }}
title={saved ? 'Убрать из понравившегося' : 'Добавить в понравившееся'} title={saved ? 'Убрать из понравившегося' : 'Добавить в понравившееся'}
className={`flex ${dims} items-center justify-center rounded-full transition-colors ${ 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' saved ? 'bg-accent-2/20 text-accent-2' : 'bg-black/60 text-white/70 hover:bg-black/80'
}`} }`}
> >
<svg <svg

View file

@ -19,18 +19,27 @@ body {
background: transparent; background: transparent;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #333; background: #2a2a3a;
border-radius: 6px; border-radius: 6px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #444; background: #383850;
} }
.poster-card { .poster-card {
@apply relative overflow-hidden rounded-lg bg-surface-card transition-transform duration-200 ease-out; @apply relative overflow-hidden rounded-xl bg-surface-card transition-all duration-200 ease-out;
} }
.poster-card:hover { .poster-card:hover {
transform: translateY(-4px); transform: translateY(-5px);
box-shadow: 0 16px 32px -14px rgba(255, 61, 90, 0.35);
}
.mono-label {
@apply font-mono text-[10px] font-bold uppercase tracking-widest;
}
.glass {
@apply bg-surface/80 backdrop-blur-xl;
} }
.line-clamp-2 { .line-clamp-2 {
@ -47,7 +56,7 @@ body {
border-radius: 2px; border-radius: 2px;
background: linear-gradient( background: linear-gradient(
to right, to right,
#fe3635 var(--progress, 0%), #ff3d5a var(--progress, 0%),
rgba(255, 255, 255, 0.25) var(--progress, 0%) rgba(255, 255, 255, 0.25) var(--progress, 0%)
); );
outline: none; outline: none;
@ -59,7 +68,7 @@ body {
width: 12px; width: 12px;
height: 12px; height: 12px;
border-radius: 50%; border-radius: 50%;
background: #fe3635; background: #ff3d5a;
cursor: pointer; cursor: pointer;
transition: transform 0.15s ease; transition: transform 0.15s ease;
} }
@ -71,11 +80,11 @@ body {
height: 12px; height: 12px;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
background: #fe3635; background: #ff3d5a;
cursor: pointer; cursor: pointer;
} }
.player-seek::-moz-range-progress { .player-seek::-moz-range-progress {
background: #fe3635; background: #ff3d5a;
height: 4px; height: 4px;
border-radius: 2px; border-radius: 2px;
} }

View file

@ -87,8 +87,8 @@ export default function Catalog() {
return ( return (
<div className="mx-auto max-w-7xl px-6 py-6"> <div className="mx-auto max-w-7xl px-6 py-6">
<h1 className="mb-4 text-xl font-bold"> <h1 className="mb-4 text-xl font-bold tracking-tight">
{searchQ ? `Поиск: "${searchQ}"` : 'Каталог'} {searchQ ? `Поиск: «${searchQ}»` : 'Каталог'}
</h1> </h1>
<div className="flex gap-6"> <div className="flex gap-6">
@ -198,17 +198,17 @@ export default function Catalog() {
<button <button
disabled={page <= 1} disabled={page <= 1}
onClick={() => setPage((p) => p - 1)} onClick={() => setPage((p) => p - 1)}
className="rounded bg-white/5 px-3 py-1.5 disabled:opacity-30" className="rounded-full bg-white/5 px-4 py-1.5 transition-colors hover:bg-white/10 disabled:opacity-30"
> >
Назад Назад
</button> </button>
<span className="text-white/50"> <span className="font-mono text-xs tabular-nums text-white/50">
{page} / {totalPages} {page} / {totalPages}
</span> </span>
<button <button
disabled={page >= totalPages} disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)} onClick={() => setPage((p) => p + 1)}
className="rounded bg-white/5 px-3 py-1.5 disabled:opacity-30" className="rounded-full bg-white/5 px-4 py-1.5 transition-colors hover:bg-white/10 disabled:opacity-30"
> >
Далее Далее
</button> </button>
@ -225,7 +225,7 @@ export default function Catalog() {
function FilterGroup({ title, children }: { title: string; children: React.ReactNode }) { function FilterGroup({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<div> <div>
<h3 className="mb-2 text-xs font-bold uppercase tracking-wide text-white/40">{title}</h3> <h3 className="mono-label mb-2 text-white/40">{title}</h3>
{children} {children}
</div> </div>
) )
@ -246,7 +246,7 @@ function Check({
type="checkbox" type="checkbox"
checked={checked} checked={checked}
onChange={onChange} onChange={onChange}
className="h-3.5 w-3.5 accent-[#fe3635]" className="h-3.5 w-3.5 accent-[#FF3D5A]"
/> />
<span className="text-xs">{label}</span> <span className="text-xs">{label}</span>
</label> </label>

View file

@ -22,7 +22,7 @@ export default function Genres() {
return ( return (
<div className="mx-auto max-w-7xl px-6 py-6"> <div className="mx-auto max-w-7xl px-6 py-6">
<h1 className="mb-4 text-xl font-bold">Жанры</h1> <h1 className="mb-4 text-xl font-bold tracking-tight">Жанры</h1>
<motion.div <motion.div
variants={container} variants={container}
initial="hidden" initial="hidden"
@ -48,9 +48,9 @@ export default function Genres() {
)} )}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent" /> <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"> <div className="relative flex h-full flex-col justify-end p-3">
<p className="font-semibold">{g.name}</p> <p className="font-semibold tracking-tight">{g.name}</p>
{g.total_releases != null && ( {g.total_releases != null && (
<p className="text-xs text-white/50">{g.total_releases} тайтлов</p> <p className="mono-label text-white/50">{g.total_releases} тайтлов</p>
)} )}
</div> </div>
</Link> </Link>

View file

@ -32,19 +32,19 @@ export default function History() {
return ( return (
<div className="mx-auto max-w-3xl px-6 py-6"> <div className="mx-auto max-w-3xl px-6 py-6">
<h1 className="mb-4 text-xl font-bold">История просмотра</h1> <h1 className="mb-4 text-xl font-bold tracking-tight">История просмотра</h1>
{items.length === 0 && <p className="py-16 text-center text-white/40">История пуста</p>} {items.length === 0 && <p className="py-16 text-center text-white/40">История пуста</p>}
<div className="space-y-2"> <div className="space-y-2">
{items.filter((item) => item.release && item.release_episode).map((item) => ( {items.filter((item) => item.release && item.release_episode).map((item) => (
<Link <Link
key={item.id} key={item.id}
to={`/title/${item.release.alias || item.release.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" className="flex items-center gap-3 rounded-xl bg-white/5 p-2.5 transition-colors hover:bg-white/10"
> >
<img <img
src={imageUrl(item.release.poster?.optimized?.thumbnail || item.release.poster?.thumbnail)} src={imageUrl(item.release.poster?.optimized?.thumbnail || item.release.poster?.thumbnail)}
alt="" alt=""
className="h-16 w-12 shrink-0 rounded object-cover" className="h-16 w-12 shrink-0 rounded-lg object-cover"
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.release.name.main}</p> <p className="truncate text-sm font-medium">{item.release.name.main}</p>
@ -52,14 +52,14 @@ export default function History() {
Серия {item.release_episode.ordinal} Серия {item.release_episode.ordinal}
{item.release_episode.name ? ` — ${item.release_episode.name}` : ''} {item.release_episode.name ? ` — ${item.release_episode.name}` : ''}
</p> </p>
<p className="text-xs text-white/30">{formatDate(item.updated_at)}</p> <p className="mt-0.5 font-mono text-[10px] text-white/30">{formatDate(item.updated_at)}</p>
</div> </div>
{item.is_watched ? ( {item.is_watched ? (
<span className="shrink-0 rounded bg-white/10 px-2 py-0.5 text-[10px] font-semibold text-white/50"> <span className="mono-label shrink-0 rounded bg-white/10 px-2 py-1 text-white/50">
Просмотрено Просмотрено
</span> </span>
) : ( ) : (
<span className="shrink-0 rounded bg-accent/20 px-2 py-0.5 text-[10px] font-semibold text-accent"> <span className="mono-label shrink-0 rounded bg-accent-soft px-2 py-1 text-accent">
В процессе В процессе
</span> </span>
)} )}

View file

@ -50,16 +50,16 @@ export default function Home() {
transition={{ duration: 0.5, delay: 0.15 }} 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" 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 className="mono-label mb-2 w-fit rounded bg-accent-gradient px-2 py-1 text-white">
Новинка Новинка
</span> </span>
<h1 className="max-w-xl text-3xl font-extrabold leading-tight drop-shadow"> <h1 className="max-w-xl text-3xl font-extrabold leading-tight tracking-tight drop-shadow">
{hero.name.main} {hero.name.main}
</h1> </h1>
<p className="mt-2 max-w-xl line-clamp-2 text-sm text-white/60">{hero.description}</p> <p className="mt-2 max-w-xl line-clamp-2 text-sm text-white/60">{hero.description}</p>
<Link <Link
to={`/title/${hero.alias || hero.id}`} 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" className="mt-4 w-fit rounded-full bg-accent-gradient px-5 py-2 text-sm font-semibold transition-transform hover:scale-105"
> >
Смотреть Смотреть
</Link> </Link>
@ -90,13 +90,13 @@ export default function Home() {
/> />
<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="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 inset-x-0 bottom-0 p-2"> <div className="absolute inset-x-0 bottom-0 p-2">
<p className="mb-1 truncate text-[10px] font-medium text-white/70"> <p className="mono-label mb-1 truncate text-white/80">
Серия {c.episode.ordinal} Серия {c.episode.ordinal}
</p> </p>
{c.episode.duration && ( {c.episode.duration && (
<div className="h-0.5 w-full overflow-hidden rounded bg-black/40"> <div className="h-0.5 w-full overflow-hidden rounded bg-black/40">
<div <div
className="h-full bg-accent" className="h-full bg-accent-gradient"
style={{ width: `${Math.min(100, (c.time / c.episode.duration) * 100)}%` }} style={{ width: `${Math.min(100, (c.time / c.episode.duration) * 100)}%` }}
/> />
</div> </div>

View file

@ -34,7 +34,7 @@ export default function MyLists() {
return ( return (
<div className="mx-auto max-w-7xl px-6 py-6"> <div className="mx-auto max-w-7xl px-6 py-6">
<h1 className="mb-4 text-xl font-bold">Мои списки</h1> <h1 className="mb-4 text-xl font-bold tracking-tight">Мои списки</h1>
<div className="mb-6 flex flex-wrap gap-2 border-b border-white/5 pb-3"> <div className="mb-6 flex flex-wrap gap-2 border-b border-white/5 pb-3">
{TABS.map((t) => ( {TABS.map((t) => (
@ -49,7 +49,7 @@ export default function MyLists() {
<motion.span <motion.span
layoutId="mylists-tab-pill" layoutId="mylists-tab-pill"
transition={{ type: 'spring', stiffness: 400, damping: 30 }} transition={{ type: 'spring', stiffness: 400, damping: 30 }}
className="absolute inset-0 rounded-full bg-accent" className="absolute inset-0 rounded-full bg-accent-gradient"
/> />
)} )}
<span className="relative">{t.label}</span> <span className="relative">{t.label}</span>

View file

@ -25,11 +25,11 @@ export default function Schedule() {
return ( return (
<div className="mx-auto max-w-7xl px-6 py-6"> <div className="mx-auto max-w-7xl px-6 py-6">
<h1 className="mb-4 text-xl font-bold">Расписание выхода серий</h1> <h1 className="mb-4 text-xl font-bold tracking-tight">Расписание выхода серий</h1>
<div className="space-y-8"> <div className="space-y-8">
{DAY_ORDER.filter((d) => byDay.has(d)).map((day) => ( {DAY_ORDER.filter((d) => byDay.has(d)).map((day) => (
<div key={day}> <div key={day}>
<h2 className="mb-3 text-sm font-bold uppercase tracking-wide text-accent"> <h2 className="mono-label mb-3 text-accent-2">
{byDay.get(day)![0].release.publish_day?.description} {byDay.get(day)![0].release.publish_day?.description}
</h2> </h2>
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-7"> <div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-7">

View file

@ -43,8 +43,6 @@ export default function TitleDetail() {
}, [idOrAlias]) }, [idOrAlias])
useEffect(() => { 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 local = getAllLocalProgress()
const merged = new Map<string, TimecodeItem>( const merged = new Map<string, TimecodeItem>(
Object.entries(local).map(([id, p]) => [ Object.entries(local).map(([id, p]) => [
@ -116,18 +114,18 @@ export default function TitleDetail() {
transition={{ duration: 0.35 }} transition={{ duration: 0.35 }}
className="min-w-0" className="min-w-0"
> >
<h1 className="text-2xl font-extrabold">{release.name.main}</h1> <h1 className="text-2xl font-extrabold tracking-tight">{release.name.main}</h1>
{release.name.english && ( {release.name.english && (
<p className="mt-0.5 text-sm text-white/40">{release.name.english}</p> <p className="mt-0.5 font-mono text-xs text-white/40">{release.name.english}</p>
)} )}
<div className="mt-3 flex flex-wrap gap-2 text-xs"> <div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
<Chip>{release.type?.description}</Chip> <Chip>{release.type?.description}</Chip>
<Chip>{release.year}</Chip> <Chip>{release.year}</Chip>
<Chip>{release.season?.description}</Chip> <Chip>{release.season?.description}</Chip>
<Chip>{release.age_rating?.label}</Chip> <Chip>{release.age_rating?.label}</Chip>
{release.is_ongoing && <Chip accent>Онгоинг</Chip>} {release.is_ongoing && <Chip accent mono>Онгоинг</Chip>}
{release.episodes_total != null && <Chip>{release.episodes_total} эп.</Chip>} {release.episodes_total != null && <Chip mono>{release.episodes_total} эп.</Chip>}
</div> </div>
<div className="mt-3 flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
@ -203,7 +201,7 @@ export default function TitleDetail() {
<motion.div <motion.div
layoutId="episode-active-bg" layoutId="episode-active-bg"
transition={{ type: 'spring', stiffness: 400, damping: 32 }} transition={{ type: 'spring', stiffness: 400, damping: 32 }}
className="absolute inset-0 bg-accent" className="absolute inset-0 bg-accent-gradient"
/> />
)} )}
<div className="relative flex items-center gap-1.5 font-semibold"> <div className="relative flex items-center gap-1.5 font-semibold">
@ -249,7 +247,7 @@ export default function TitleDetail() {
</div> </div>
<button <button
onClick={() => window.anisav.openExternal(t.magnet)} 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" className="shrink-0 rounded-full bg-accent-gradient px-3 py-1 text-xs font-semibold transition-transform hover:scale-105"
> >
Magnet Magnet
</button> </button>
@ -262,10 +260,20 @@ export default function TitleDetail() {
) )
} }
function Chip({ children, accent }: { children: React.ReactNode; accent?: boolean }) { function Chip({
children,
accent,
mono,
}: {
children: React.ReactNode
accent?: boolean
mono?: boolean
}) {
return ( return (
<span <span
className={`rounded px-2 py-0.5 font-semibold ${accent ? 'bg-accent' : 'bg-white/10 text-white/70'}`} className={`rounded-full px-2.5 py-1 ${mono ? 'font-mono text-[10px] uppercase tracking-widest' : 'font-semibold'} ${
accent ? 'bg-accent-gradient text-white' : 'bg-white/10 text-white/70'
}`}
> >
{children} {children}
</span> </span>

View file

@ -13,8 +13,8 @@ export default function Wishlist() {
return ( return (
<div className="mx-auto max-w-7xl px-6 py-6"> <div className="mx-auto max-w-7xl px-6 py-6">
<h1 className="mb-1 text-xl font-bold">Понравилось</h1> <h1 className="mb-1 text-xl font-bold tracking-tight">Понравилось</h1>
<p className="mb-4 text-xs text-white/40"> <p className="mb-4 font-mono text-[11px] text-white/40">
Локальный список на этом устройстве — не требует входа в аккаунт AniLiberty Локальный список на этом устройстве — не требует входа в аккаунт AniLiberty
</p> </p>

View file

@ -5,17 +5,22 @@ export default {
extend: { extend: {
colors: { colors: {
accent: { accent: {
DEFAULT: '#fe3635', DEFAULT: '#FF3D5A',
hover: '#ff5352', 2: '#FF8A4C',
soft: 'rgba(255,61,90,0.16)',
}, },
surface: { surface: {
DEFAULT: '#121212', DEFAULT: '#0A0A13',
raised: '#1a1a1a', raised: '#1C1C2C',
card: '#1e1e1e', card: '#161623',
}, },
}, },
fontFamily: { fontFamily: {
sans: ['Inter', 'Roboto', 'sans-serif'], sans: ['Inter', 'Roboto', 'sans-serif'],
mono: ['"Space Mono"', 'ui-monospace', 'monospace'],
},
backgroundImage: {
'accent-gradient': 'linear-gradient(135deg, #FF3D5A, #FF8A4C)',
}, },
}, },
}, },