Repository: akionii/Miruro
Branch: main
Commit: 5986e25ef6d8
Files: 70
Total size: 286.1 KB
Directory structure:
gitextract_c4uzzfr4/
├── .eslintrc.cjs
├── .github/
│ ├── FUNDING.yml
│ ├── SECURITY.md
│ └── dependabot.yml
├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── api/
│ └── exchange-token.ts
├── functions/
│ └── exchange-token.js
├── index.html
├── package.json
├── public/
│ └── manifest.json
├── renovate.json
├── robots.txt
├── server/
│ ├── README.md
│ └── server.ts
├── src/
│ ├── App.tsx
│ ├── client/
│ │ ├── ApolloClient.tsx
│ │ ├── authService.ts
│ │ ├── useAuth.tsx
│ │ └── userInfoTypes.ts
│ ├── components/
│ │ ├── Cards/
│ │ │ ├── CardGrid.tsx
│ │ │ └── CardItem.tsx
│ │ ├── Home/
│ │ │ ├── EpisodeCard.tsx
│ │ │ ├── HomeCarousel.tsx
│ │ │ └── HomeSideBar.tsx
│ │ ├── Navigation/
│ │ │ ├── DropSearch.tsx
│ │ │ ├── Footer.tsx
│ │ │ ├── Navbar.tsx
│ │ │ └── SearchFilters.tsx
│ │ ├── Profile/
│ │ │ ├── Settings.tsx
│ │ │ ├── SettingsProvider.tsx
│ │ │ └── WatchingAnilist.tsx
│ │ ├── ShortcutsPopup.tsx
│ │ ├── Skeletons/
│ │ │ └── Skeletons.tsx
│ │ ├── ThemeContext.tsx
│ │ ├── Watch/
│ │ │ ├── AnimeDataList.tsx
│ │ │ ├── EpisodeList.tsx
│ │ │ ├── Seasons.tsx
│ │ │ ├── Video/
│ │ │ │ ├── EmbedPlayer.tsx
│ │ │ │ ├── MediaSource.tsx
│ │ │ │ ├── Player.tsx
│ │ │ │ └── PlayerStyles.css
│ │ │ └── WatchAnimeData.tsx
│ │ └── shared/
│ │ └── StatusIndicator.tsx
│ ├── hooks/
│ │ ├── animeInterface.ts
│ │ ├── useApi.ts
│ │ ├── useCountdown.ts
│ │ ├── useFilters.ts
│ │ ├── useScroll.ts
│ │ └── useTIme.ts
│ ├── index.ts
│ ├── main.tsx
│ ├── pages/
│ │ ├── 404.tsx
│ │ ├── About.tsx
│ │ ├── Callback.tsx
│ │ ├── Home.tsx
│ │ ├── PolicyTerms.tsx
│ │ ├── Profile.tsx
│ │ ├── Search.tsx
│ │ └── Watch.tsx
│ ├── styles/
│ │ ├── animations.css
│ │ ├── globals.css
│ │ └── themes.css
│ └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
├── vercel.json
└── vite.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintrc.cjs
================================================
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
};
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
# github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
# polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
================================================
FILE: .github/SECURITY.md
================================================
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | --------- |
| 0.2.0 | ❔ |
| 0.1.0 | ✅ |
| < 0.1.0 | ❌ |
## Reporting a Vulnerability
If you discover a security vulnerability, please report it by opening an [issue](https://github.com/Miruro-no-kuon/Miruro-no-Kuon/issues). To help us better understand and address the issue, please follow the template provided when creating a new issue.
### Reporting Process
1. **Open a new issue**: Clearly describe the vulnerability, providing as much detail as possible.
2. **Assessment**: Our team will assess the reported vulnerability and respond when available.
3. **Fix and Release**: If the vulnerability is accepted, we will work on fixing it and release a patch within a reasonable timeframe.
### Expectations
- We will strive to keep you informed about the progress of your reported vulnerability.
- If the vulnerability is accepted, it will be prioritized based on severity.
- If the vulnerability is declined, we will provide a reason for the decision.
- We encourage responsible disclosure, and we appreciate your efforts in keeping our project secure.
## Versioning Scheme
We follow [Semantic Versioning](https://semver.org/) for our releases. Security updates will be applied to the latest minor version of the current major version.
- **Major Version**: Significant changes, possibly breaking backward compatibility.
- **Minor Version**: New features, enhancements, and backward-compatible bug fixes.
- **Patch Version**: Backward-compatible bug fixes only.
## Contact
For any questions or additional information regarding security, please contact miruro@proton.me
================================================
FILE: .github/dependabot.yml
================================================
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: 'npm' # See documentation for possible values
directory: '/' # Location of package manifests
schedule:
interval: 'weekly'
================================================
FILE: .gitignore
================================================
# Dependency directories
node_modules/
jspm_packages/
.pnpm-store/
# Vite and Build outputs
dist/
dist-ssr/
build/
out/
.temp/
# Bun
.bun/
# TypeScript cache
*.tsbuildinfo
# Compiled binary addons (node-gyp)
build/Release/
# Editor directories and files
.idea/
.vscode/
*.sublime-workspace
*.sublime-project
# Operating System generated files
.DS_Store
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Package manager lock files
package-lock.json
yarn.lock
pnpm-lock.yaml
bun.lockb
# dotenv environment variables files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Caches and logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.cache/
.eslintcache
.stylelintcache
# Temporary files
*.tmp
*~
*.bak
*.sw?
*.swo
*.swn
*.swp
*.orig
================================================
FILE: .prettierrc
================================================
{
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"endOfLine": "lf",
"plugins": ["prettier-plugin-tailwindcss"],
"arrowParens": "always",
"bracketSpacing": true,
"jsxSingleQuote": true,
"bracketSameLine": false,
"htmlWhitespaceSensitivity": "css",
"vueIndentScriptAndStyle": false,
"embeddedLanguageFormatting": "auto",
"quoteProps": "as-needed",
"overrides": [
{
"files": "*.{ts,tsx}",
"options": {
"parser": "typescript"
}
},
{
"files": "*.html",
"options": {
"printWidth": 120
}
}
]
}
================================================
FILE: LICENSE
================================================
CUSTOM ATTRIBUTION-NONCOMMERCIAL (CUSTOM BY-NC) LICENSE
© 2024 Miruro no Kuon. All Rights Reserved.
This software, licensed under the Custom BY-NC License, grants users the freedom to
share, copy, distribute, and transmit the code, as well as to adapt, modify, transform,
and build upon it. However, this freedom comes with specific terms:
By using this software, you are required to provide appropriate credit to the original
author(s) by including a visible and clear attribution in any distribution or derivative
work. Attribution is a fundamental condition for utilizing or redistributing the code.
Furthermore, this license explicitly prohibits the commercial use of the code or any
derivative work based on it. Commercial purposes include, but are not limited to, activities
that involve the sale, licensing, or exploitation of the software for financial gain.
If you intend to use the code for commercial use or any purpose not explicitly covered by
this license, please seek explicit permission from the author(s) by contacting them directly.
The author(s) reserves the right to grant or deny permission based on individual circumstances.
It is essential to note that this license does not grant any rights beyond what is explicitly
stated here. Any use of the code not explicitly allowed by this license is strictly prohibited.
For inquiries, additional permissions, or clarification on specific terms, please contact the author(s).
================================================
FILE: README.md
================================================
MIRURO
## What is Miruro?
Welcome to **Miruro**, your premier destination for all things anime! Explore a comprehensive collection of high-definition anime with a seamless and user-friendly interface powered by **[Consumet](https://github.com/consumet)**.
Built using **React** and **Vite**, Miruro offers a cutting-edge, minimalist design that ensures both fast loading times and smooth navigation. Whether you're looking for the latest anime series or classic favorites, Miruro has you covered with an ad-free streaming experience that supports both English subtitles and dubbed versions. Additionally, you can download individual episodes without the hassle of creating an account, making your viewing experience as convenient as possible.
Features [View More]
### General
- Sub/Dub Anime support
- User-friendly & Mobile responsive
- Anilist Sync
- Light/Dark theme
- Continue Watching Section
### Watch Page
- **Player**
- Autoplay next episode
- Skip op/ed button
## Installation and Local Development
### 1. Clone this repository using
```bash
git clone https://github.com/Miruro-no-kuon/Miruro.git
```
```bash
cd Miruro
```
### 2. Installation
### Basic Pre-Requisites
> [!TIP]
> This platform is built on [Node.js](https://nodejs.org/) and utilizes [Bun](https://bun.sh/) to ensure the quickest response times achievable. While `npm` can also be used, the commands for npm would mirror those of Bun, simply substituting the specific commands accordingly.
> Bun is now available on **Windows**, **Linux**, and **macOS**. Below are the installation commands for each operating system.
### Install Bun
- Linux & macOS
```bash
curl -fsSL https://bun.sh/install | bash
```
- Windows
```powershell
powershell -c "irm bun.sh/install.ps1 | iex"
```
### Verify installations
- Check that both Node.js and Bun are correctly installed by running.
```bash
node -v
bun -v
```
### Install Dependencies
- You can use Bun to install dependencies quickly. If you prefer, `npm` can also be used with equivalent commands.
```bash
bun install
```
### Copy `.env.example` into `.env.local` in the root folder
- `.env.local` & `.env` are both viable options, you can also set
`.env.test.local`,
`.env.development.local` or
`.env.production.local`
```bash
cp .env.example .env.local
```
### 3. Run on development &/or production (npm also works)
- Run on development mode
```bash
bun run dev
```
- Run on production mode
```bash
bun start
```
## Self-Hosting Notice
> [!CAUTION]
> Self-hosting this application is **strictly limited to personal use only**. Commercial utilization is **prohibited**, and the inclusion of advertisements on your self-hosted website may lead to serious consequences, including **potential site takedown measures**. Ensure compliance to avoid any legal or operational issues.
## License
This project is governed by a Custom BY-NC License. What does this entail? Simply put, you are permitted to utilize, distribute, and modify the code for non-commercial purposes. However, it is imperative that due credit is accorded to our platform. Any commercial utilization of this code is strictly prohibited. For comprehensive details, please refer to the [LICENSE](LICENSE) file. Should you have inquiries or require special permissions, do not hesitate to contact us.
## Star History
[](https://starchart.cc/Miruro-no-kuon/Miruro)
================================================
FILE: api/exchange-token.ts
================================================
import axios from 'axios';
import type { VercelRequest, VercelResponse } from '@vercel/node';
export default async function exchangeAccessToken(req: VercelRequest, res: VercelResponse) {
if (req.method !== 'POST') {
res.status(405).send('Method Not Allowed');
return;
}
const { code } = req.body;
if (!code) {
return res.status(400).send('Authorization code is required');
}
const payload = {
client_id: process.env.VITE_CLIENT_ID,
client_secret: process.env.VITE_CLIENT_SECRET,
code,
grant_type: 'authorization_code',
redirect_uri: process.env.VITE_REDIRECT_URI,
};
const url = 'https://anilist.co/api/v2/oauth/token';
try {
const response = await axios.post(url, payload, {
headers: {
'Content-Type': 'application/json',
'Accept-Encoding': 'identity',
},
});
if (response.data.access_token) {
res.json({ accessToken: response.data.access_token });
} else {
throw new Error('Access token not found in the response');
}
} catch (error: unknown) {
// First, check if it's an instance of Error
if (error instanceof Error) {
// Now you can safely read the message property
const message = error.message;
// If it's an axios error, it may have a response object
const details = axios.isAxiosError(error) && error.response ? error.response.data : message;
res.status(500).json({
error: 'Failed to exchange token',
details,
});
} else {
// If it's not an Error object, handle it as a generic error
res.status(500).json({
error: 'Failed to exchange token',
details: 'An unknown error occurred',
});
}
}
}
================================================
FILE: functions/exchange-token.js
================================================
export async function onRequest(context) {
const url = new URL(context.request.url);
const path = url.pathname;
if (path === '/exchange-token') {
return handleTokenExchange(context);
} else {
return new Response('Not found', { status: 404 });
}
}
async function handleTokenExchange(context) {
const request = context.request;
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 });
}
try {
const data = await request.json();
const code = data.code;
if (!code) {
return new Response('Authorization code is required', { status: 400 });
}
const payload = {
client_id: context.env.VITE_CLIENT_ID,
client_secret: context.env.VITE_CLIENT_SECRET,
code,
grant_type: 'authorization_code',
redirect_uri: context.env.VITE_REDIRECT_URI,
};
const apiResponse = await fetch('https://anilist.co/api/v2/oauth/token', {
method: 'POST',
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
'Accept-Encoding': 'identity',
},
});
const responseBody = await apiResponse.text();
if (!apiResponse.ok) {
console.error('API response error:', responseBody);
throw new Error(`API responded with status: ${apiResponse.status}`);
}
const responseData = JSON.parse(responseBody);
if (responseData.access_token) {
return new Response(
JSON.stringify({ accessToken: responseData.access_token }),
{
headers: { 'Content-Type': 'application.json' },
},
);
} else {
console.error(
'Access token not found in the API response:',
responseBody,
);
throw new Error('Access token not found in the response');
}
} catch (error) {
console.error(`Error when handling token exchange: ${error}`);
return new Response(
JSON.stringify({
error: 'Failed to exchange token',
details: error.message,
}),
{
status: 500,
headers: { 'Content-Type': 'application.json' },
},
);
}
}
================================================
FILE: index.html
================================================
Miruro | Watch Anime Online, Free Anime Streaming
================================================
FILE: package.json
================================================
{
"name": "miruro_no_kuon",
"private": true,
"version": "0.5.2",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vite build",
"preview": "vite preview",
"start": "vite build && bun run ./server/server.ts",
"lint": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0",
"format": "prettier --write ."
},
"dependencies": {
"@apollo/client": "^3.10.1",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/lodash": "^4.17.0",
"@types/uuid": "^9.0.8",
"@vercel/analytics": "^1.2.2",
"@vidstack/react": "next",
"axios": "^1.6.8",
"body-parser": "^1.20.2",
"eslint": "8.x",
"express": "^4.19.2",
"graphql": "^16.8.1",
"lodash": "^4.17.21",
"lru-cache": "latest",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.14",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-ga4": "^2.1.0",
"react-icons": "^5.0.1",
"react-router-dom": "latest",
"react-select": "^5.8.0",
"styled-components": "^6.1.0",
"swiper": "^11.0.7",
"typescript": "^5.0.0",
"uuid": "^9.0.1",
"wrangler": "^3.52.0"
},
"devDependencies": {
"@types/bun": "latest",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@types/react-slick": "^0.23.13",
"@typescript-eslint/eslint-plugin": "^7.4.0",
"@typescript-eslint/parser": "^7.4.0",
"@vercel/node": "^3.0.27",
"@vitejs/plugin-react": "^4.2.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"vite": "5.x"
},
"module": "index.ts",
"peerDependencies": {
"typescript": "^5.0.0"
}
}
================================================
FILE: public/manifest.json
================================================
{
"name": "Miruro",
"short_name": "Miruro",
"description": "Watch HD Anime for Free",
"lang": "en",
"background_color": "#080808",
"display": "standalone",
"orientation": "standalone",
"scope": "/",
"start_url": "/",
"screenshots": [
{
"src": "/preview.png",
"sizes": "960x540",
"type": "image/png",
"form_factor": "wide",
"label": "Wonder Widgets"
},
{
"src": "/icon-256x256.png",
"sizes": "256x256",
"type": "image/png",
"form_factor": "narrow",
"label": "Icon Widget"
}
],
"icons": [
{
"src": "/favicon-16x16.png",
"sizes": "16x16",
"type": "image/png"
},
{
"src": "/favicon-32x32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "/favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-256x256.png",
"sizes": "256x256",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
================================================
FILE: renovate.json
================================================
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"]
}
================================================
FILE: robots.txt
================================================
User-agent: *
Disallow: /
================================================
FILE: server/README.md
================================================
# Server README
This README provides an overview of the `server.ts` file, which is an Express server designed to serve static files, handle error logging, and provide instructions for running it using the Bun JavaScript runtime.
## `server.ts` Overview ℹ️
The `server.ts` file includes the following features:
- Express server setup
- Static file serving to serve files from the `dist` directory 📂
- Error logging for server-side errors 📝
## Installation and Running 🛠️
To run the server, follow these steps:
1. Clone this repository to your local machine 📦
2. Install project dependencies:
```bash
bun install
```
3. Start the server:
```bash
bun run server.ts
```
- The server will start running on by default. You can modify the `PORT` .env variable to change the port in `server.ts` as needed.
================================================
FILE: server/server.ts
================================================
import express from 'express';
import axios from 'axios';
import path from 'path';
import os from 'os';
import bodyParser from 'body-parser';
const app = express();
// Environment Configuration
const PORT = process.env.VITE_PORT || 5173;
const {
VITE_CLIENT_ID: CLIENT_ID,
VITE_CLIENT_SECRET: CLIENT_SECRET,
VITE_REDIRECT_URI: REDIRECT_URI,
} = process.env;
// Directory paths for static assets
const DIST_DIR = path.join(__dirname, '../dist');
const INDEX_FILE = path.join(DIST_DIR, 'index.html');
// Middleware for static assets and JSON parsing
app.use(express.static(DIST_DIR));
app.use(express.json());
app.use(bodyParser.json());
// API Endpoint for exchanging authorization token
const apiEndpoint = '/api/exchange-token';
app.post(apiEndpoint, async (req, res) => {
const { code } = req.body;
if (!code) {
console.error('Authorization code is missing');
return res.status(400).send('Authorization code is required');
}
const payload = {
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
grant_type: 'authorization_code',
redirect_uri: REDIRECT_URI,
};
const url = 'https://anilist.co/api/v2/oauth/token';
// Logging the request details
console.log('Sending request to AniList API');
console.log('URL:', url);
console.log('Payload:', payload);
try {
const response = await axios.post(url, payload, {
headers: {
'Content-Type': 'application/json',
'Accept-Encoding': 'identity',
},
});
// Logging the response details
console.log('Received response from AniList API');
console.log('Response Status:', response.status);
console.log('Response Data:', response.data);
if (response.data.access_token) {
res.json({ accessToken: response.data.access_token });
} else {
throw new Error('Access token not found in the response');
}
} catch (error) {
console.error('Error during token exchange:', error.message);
if (error.response) {
console.error('Error Status:', error.response.status);
console.error('Error Details:', error.response.data);
}
res.status(500).json({
error: 'Failed to exchange token',
details: error.response?.data || error.message,
});
}
});
// Serve the main index.html for any non-API requests
app.get('*', (req, res) => {
res.sendFile(INDEX_FILE, (err) => {
if (err) {
console.error('Error serving index.html:', err);
res.status(500).send('An error occurred while serving the application');
}
});
});
// Utility to get the first non-internal IPv4 address
function getLocalIpAddress() {
const networkInterfaces = os.networkInterfaces();
for (const networkInterface of Object.values(networkInterfaces)) {
const found = networkInterface?.find(
(net) => net.family === 'IPv4' && !net.internal,
);
if (found) return found.address;
}
return 'localhost';
}
// Starting the server
app.listen(PORT, () => {
const ipAddress = getLocalIpAddress();
console.log(
`Server is running at:\n- Localhost: http://localhost:${PORT}\n- Local IP: http://${ipAddress}:${PORT}`,
);
});
================================================
FILE: src/App.tsx
================================================
import {
BrowserRouter as Router,
Routes,
Route,
useLocation,
} from 'react-router-dom';
import { useEffect } from 'react';
import {
Profile,
Navbar,
ThemeProvider,
Footer,
Home,
Watch,
Search,
Page404,
About,
PolicyTerms,
ShortcutsPopup,
ScrollToTop,
usePreserveScrollOnReload,
Callback,
ApolloClientProvider,
Settings,
SettingsProvider,
} from './index';
import { register } from 'swiper/element/bundle';
import { Analytics } from '@vercel/analytics/react';
import { AuthProvider } from './client/useAuth';
import ReactGA from 'react-ga4';
register();
function App() {
usePreserveScrollOnReload();
const measurementId = import.meta.env.VITE_GA_MEASUREMENT_ID;
useEffect(() => {
if (measurementId) {
ReactGA.initialize(measurementId);
}
}, [measurementId]);
return (
} />
} />
} />
} />
}
/>
} />
} />
} />
} />
} />
} />
);
}
function TrackPageViews() {
const { pathname } = useLocation();
useEffect(() => {
ReactGA.send({ hitType: 'pageview', page: pathname });
}, [pathname]);
return null;
}
export default App;
================================================
FILE: src/client/ApolloClient.tsx
================================================
// apolloClient.ts
import {
ApolloClient,
InMemoryCache,
createHttpLink,
ApolloProvider,
makeVar,
} from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import axios from 'axios';
import { buildAuthUrl, fetchUserData, UserData } from '../index';
import { ReactNode, useEffect } from 'react';
// Reactive variables for user authentication state
const isLoggedInVar = makeVar(false);
const userDataVar = makeVar(null);
const httpLink = createHttpLink({
uri: 'https://graphql.anilist.co', // Update to your GraphQL server URL
});
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('accessToken');
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path }) =>
console.error(
`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`,
),
);
}
if (networkError) console.error(`[Network error]: ${networkError}`);
});
const client = new ApolloClient({
link: errorLink.concat(authLink.concat(httpLink)),
cache: new InMemoryCache(),
});
// Functions for handling authentication
function login() {
axios
.get('/get-csrf-token')
.then((response) => {
const csrfToken = response.data.csrfToken;
const authUrl = buildAuthUrl(csrfToken);
window.location.href = authUrl;
})
.catch((error) => {
console.error('Error fetching CSRF token or building auth URL:', error);
});
}
function logout() {
localStorage.removeItem('accessToken');
isLoggedInVar(false);
userDataVar(null);
window.location.href = '/profile'; // Adjust as necessary
window.dispatchEvent(new CustomEvent('authUpdate'));
}
function handleAuthUpdate() {
const token = localStorage.getItem('accessToken');
if (token) {
fetchUserData(token)
.then((data) => {
userDataVar(data);
isLoggedInVar(true);
})
.catch((err) => {
console.error('Failed to fetch user data:', err);
logout(); // Ensures clean state on failure
});
} else {
isLoggedInVar(false);
userDataVar(null);
}
}
export const ApolloClientProvider = ({ children }: { children: ReactNode }) => {
useEffect(() => {
window.addEventListener('authUpdate', handleAuthUpdate);
handleAuthUpdate();
return () => {
window.removeEventListener('authUpdate', handleAuthUpdate);
};
}, []);
return {children} ;
};
export {
client as defaultApolloClient,
login,
logout,
isLoggedInVar,
userDataVar,
};
================================================
FILE: src/client/authService.ts
================================================
// src/services/authService.ts
import axios from 'axios';
import { v4 as uuidv4 } from 'uuid'; // Ensure uuid is installed via npm or yarn
import { UserData, MediaListStatus } from '../index'; // Assuming this is the correct path
import { useQuery, gql } from '@apollo/client';
// Constants for AniList OAuth, ideally should be loaded from environment variables
const clientId = import.meta.env.VITE_CLIENT_ID || 'default_client_id';
const clientSecret =
import.meta.env.VITE_CLIENT_SECRET || 'default_client_secret';
const redirectUri = import.meta.env.VITE_REDIRECT_URI || 'default_redirect_uri';
/**
* Generates a new CSRF token for each session
* @returns {string} A UUID v4 CSRF token
*/
export const generateCsrfToken = (): string => {
return uuidv4();
};
/**
* Builds the authorization URL with CSRF protection
* @param {string} csrfToken CSRF token for state parameter
* @returns {string} URL to redirect user to AniList OAuth login page
*/
// authService.ts
export const buildAuthUrl = (csrfToken: string): string => {
const scope = encodeURIComponent('');
const state = encodeURIComponent(csrfToken);
const encodedRedirectUri = encodeURIComponent(redirectUri);
return `https://anilist.co/api/v2/oauth/authorize?client_id=${clientId}&scope=${scope}&response_type=code&redirect_uri=${encodedRedirectUri}&state=${state}`;
};
/**
* Requests an access token from AniList using the authorization code
* @param {string} code The authorization code received from AniList after user consent
* @returns {Promise} A promise that resolves to the access token
*/
export const getAccessToken = async (code: string): Promise => {
const url = 'https://anilist.co/api/v2/oauth/token';
const payload = {
client_id: clientId,
client_secret: clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: redirectUri,
};
try {
const response = await axios.post(url, payload);
if (response.data.access_token) {
return response.data.access_token;
} else {
throw new Error('Access token not found in the response');
}
} catch (error) {
console.error('Error obtaining access token:', error);
throw new Error('Failed to obtain access token');
}
};
// src/services/authService.js
export const fetchUserData = async (accessToken: string): Promise => {
try {
const response = await axios.post(
'https://graphql.anilist.co',
{
query: `
query {
Viewer {
id
name
avatar {
large
}
statistics {
anime {
count
episodesWatched
meanScore
minutesWatched
}
}
}
}
`,
},
{
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
},
);
return response.data.data.Viewer; // Ensure the structure matches UserData interface
} catch (error) {
console.error('Error fetching user data:', error);
throw new Error('Failed to fetch user data');
}
};
const GET_USER_ANIME_LIST = gql`
query GetUserAnimeList($username: String!, $status: MediaListStatus!) {
MediaListCollection(
userName: $username
type: ANIME
status: $status
sort: UPDATED_TIME_DESC
) {
lists {
entries {
media {
id
format
title {
romaji
english
}
coverImage {
large
color
}
status
episodes
startDate {
year
month
day
}
averageScore
genres
}
}
}
}
}
`;
export const useUserAnimeList = (username: string, status: MediaListStatus) => {
const { data, loading, error } = useQuery(GET_USER_ANIME_LIST, {
variables: { username, status },
skip: !username || !status, // Ensuring not to proceed without necessary variables
});
return {
animeList: data?.MediaListCollection,
loading,
error,
};
};
================================================
FILE: src/client/useAuth.tsx
================================================
import {
createContext,
useContext,
useState,
useEffect,
ReactNode,
} from 'react';
import axios from 'axios';
import { UserData } from './userInfoTypes'; // Adjust the path as necessary
import { fetchUserData, buildAuthUrl } from './authService'; // Adjust the path as necessary
type AuthContextType = {
isLoggedIn: boolean;
userData: UserData | null;
username: string | null; // This property must be handled
login: () => void;
logout: () => void;
};
const AuthContext = createContext(undefined);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [userData, setUserData] = useState(null);
const [authLoading, setAuthLoading] = useState(true); // Add a loading state for auth status
// Calculate username from userData
const username = userData ? userData.name : null; // Assuming 'username' is a property of UserData
useEffect(() => {
const token = localStorage.getItem('accessToken');
if (token) {
fetchUserData(token)
.then((data) => {
setUserData(data);
setIsLoggedIn(true);
setAuthLoading(false); // Set loading to false once user data is fetched
})
.catch((err) => {
console.error('Failed to fetch user data:', err);
logout(); // Ensures clean state on failure
setAuthLoading(false); // Ensure loading state is handled even in error
});
} else {
setAuthLoading(false); // If no token, ensure loading is set to false
}
}, []);
const login = async () => {
try {
const response = await axios.get('/get-csrf-token');
const csrfToken = response.data.csrfToken;
const authUrl = buildAuthUrl(csrfToken);
window.location.href = authUrl;
} catch (error) {
console.error('Error fetching CSRF token or building auth URL:', error);
}
};
const logout = () => {
localStorage.removeItem('accessToken');
setIsLoggedIn(false);
setUserData(null);
setAuthLoading(true); // Reset auth loading state on logout
window.location.href = '/profile';
window.dispatchEvent(new CustomEvent('authUpdate'));
};
// Prevent rendering of children if authentication status is unknown
if (authLoading) {
return null; // Or you could return a loading spinner or a similar component
}
return (
{children}
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
================================================
FILE: src/client/userInfoTypes.ts
================================================
// Enums or types for sorting, assuming sorting options are known
type UserStatisticsSort =
| 'COUNT_ASC'
| 'COUNT_DESC'
| 'SCORE_ASC'
| 'SCORE_DESC';
export interface UserData {
name: string;
avatar: {
large: string;
};
statistics: UserStatistics;
}
export enum MediaListStatus {
CURRENT = 'CURRENT',
PLANNING = 'PLANNING',
COMPLETED = 'COMPLETED',
REPEATING = 'REPEATING',
PAUSED = 'PAUSED',
DROPPED = 'DROPPED',
}
export interface UserStatistics {
anime: AnimeMangaStatistics;
manga: AnimeMangaStatistics;
}
export interface AnimeMangaStatistics {
count: number;
meanScore: number;
standardDeviation: number;
minutesWatched: number; // For anime
episodesWatched: number; // For anime
chaptersRead: number; // For manga
volumesRead: number; // For manga
formats: UserFormatStatistic[];
statuses: UserStatusStatistic[];
scores: UserScoreStatistic[];
lengths: UserLengthStatistic[];
releaseYears: UserReleaseYearStatistic[];
startYears: UserStartYearStatistic[];
genres: UserGenreStatistic[];
tags: UserTagStatistic[];
countries: UserCountryStatistic[];
voiceActors: UserVoiceActorStatistic[];
staff: UserStaffStatistic[];
studios: UserStudioStatistic[];
}
export interface StatisticLimitSort {
limit: number;
sort: UserStatisticsSort[];
}
export interface UserFormatStatistic {
format: string;
count: number;
}
export interface UserStatusStatistic {
status: string;
count: number;
}
export interface UserScoreStatistic {
score: number;
count: number;
}
export interface UserLengthStatistic {
length: string;
count: number;
}
export interface UserReleaseYearStatistic {
year: number;
count: number;
}
export interface UserStartYearStatistic {
year: number;
count: number;
}
export interface UserGenreStatistic {
genre: string;
count: number;
}
export interface UserTagStatistic {
tag: string;
count: number;
}
export interface UserCountryStatistic {
country: string;
count: number;
}
export interface UserVoiceActorStatistic {
voiceActorId: number;
name: string;
count: number;
language: string;
}
export interface UserStaffStatistic {
staffId: number;
name: string;
role: string;
count: number;
}
export interface UserStudioStatistic {
studioId: number;
name: string;
count: number;
}
================================================
FILE: src/components/Cards/CardGrid.tsx
================================================
import React, { useEffect, useCallback } from 'react';
import styled from 'styled-components';
import { CardItem, Anime } from '../../index';
interface CardGridProps {
animeData: Anime[];
hasNextPage: boolean;
onLoadMore: () => void;
}
export const CardGrid: React.FC = ({
animeData,
hasNextPage,
onLoadMore,
}) => {
const handleLoadMore = useCallback(() => {
if (hasNextPage) {
onLoadMore();
}
}, [hasNextPage, onLoadMore]);
useEffect(() => {
const handleScroll = () => {
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.offsetHeight;
const scrollTop =
document.documentElement.scrollTop || document.body.scrollTop;
let threshold = 0;
if (window.innerWidth <= 450) {
threshold = 1;
}
if (windowHeight + scrollTop >= documentHeight - threshold) {
handleLoadMore();
}
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [handleLoadMore, hasNextPage]);
return (
{animeData.map((anime) => (
))}
);
};
export const StyledCardGrid = styled.div`
margin: 0 auto;
display: grid;
position: relative;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-template-rows: auto;
gap: 2rem;
transition: 0s;
@media (max-width: 1000px) {
gap: 1.5rem;
}
@media (max-width: 800px) {
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
gap: 1rem;
}
@media (max-width: 450px) {
grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr));
gap: 0.8rem;
}
`;
================================================
FILE: src/components/Cards/CardItem.tsx
================================================
import React, { useEffect, useState, useMemo } from 'react';
import styled from 'styled-components';
import { Link } from 'react-router-dom';
import { SkeletonCard, StatusIndicator, type Anime } from '../../index'; // Adjust the import path to correctly point to your index.ts location
import { FaPlay } from 'react-icons/fa'; // For the play icon
import { TbCards } from 'react-icons/tb';
import { FaStar, FaCalendarAlt } from 'react-icons/fa';
const StyledCardWrapper = styled(Link)`
color: var(--global-text);
animation: slideUp 0.4s ease;
text-decoration: none;
&:hover,
&:active,
&:focus {
z-index: 2;
}
`;
const StyledCardItem = styled.div`
width: 100%;
border-radius: var(--global-border-radius);
cursor: pointer;
transform: scale(1);
transition: 0.2s ease-in-out;
`;
const ImageDisplayWrapper = styled.div`
transition: 0.2s ease-in-out;
@media (min-width: 501px) {
&:hover,
&:active,
&:focus {
transform: translateY(-10px);
}
}
`;
const AnimeImage = styled.div`
position: relative;
text-align: left;
overflow: hidden;
border-radius: var(--global-border-radius);
padding-top: calc(100% * 184 / 133);
background: var(--global-card-bg);
box-shadow: 2px 2px 10px var(--global-card-shadow);
transition: background-color 0.2s ease-in-out;
animation: slideUp 0.5s ease-in-out;
`;
const PlayIcon = styled(FaPlay)`
position: absolute;
top: 50%;
left: 50%;
color: #fff;
transform: translate(-50%, -50%);
font-size: 2rem;
opacity: 0;
transition: opacity 0.3s ease;
z-index: 1;
`;
const ImageWrapper = styled.div`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: var(--global-border-radius);
transition: 0.3s ease-in-out;
transition: filter 0.3s ease-in-out; // Ensure the filter transition is smooth
}
&:hover img {
filter: brightness(0.5); // Decrease brightness to 60% on hover
}
&:hover ${PlayIcon} {
opacity: 1;
}
`;
const TitleContainer = styled.div<{ $isHovered: boolean }>`
display: flex;
align-items: center;
padding: 0.5rem;
margin-top: 0.35rem;
gap: 0.4rem;
border-radius: var(--global-border-radius);
cursor: pointer;
transition: background 0.2s ease;
&:hover,
&:active,
&:focus {
background: var(--global-card-title-bg);
}
`;
const Title = styled.h5<{ $isHovered: boolean; color?: string }>`
margin: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: ${(props) => (props.$isHovered ? props.color : 'var(--title-color)')};
transition: 0.2s ease-in-out;
@media (max-width: 500px) {
font-size: 0.7rem;
}
`;
const ImgDetail = React.memo(styled.p<{ $isHovered: boolean; color?: string }>`
animation: slideRight 0.2s ease-in-out;
position: absolute;
bottom: 0;
margin: 0.25rem;
padding: 0.2rem;
font-size: 0.8rem;
font-weight: bold;
color: ${(props) => props.color};
opacity: 0.9;
background-color: var(--global-button-shadow);
border-radius: var(--global-border-radius);
backdrop-filter: blur(10px);
transition: 0.2s ease-in-out;
`);
const CardDetails = styled.div`
animation: slideRight 0.4s ease-in-out;
width: 100%;
font-family: Arial;
font-weight: bold;
font-size: 0.75rem;
color: rgba(102, 102, 102, 0.65);
margin: 0;
display: flex;
align-items: center;
padding: 0.25rem 0rem;
gap: 0.5rem;
white-space: nowrap;
overflow: hidden; // Ensures that overflow text is hidden
text-overflow: ellipsis; // Adds an ellipsis to indicate that text has been cut off
svg {
margin-bottom: 0.12rem;
margin-right: -0.4rem;
}
`;
export const CardItem: React.FC<{ anime: Anime }> = ({ anime }) => {
const [loading, setLoading] = useState(true);
const [isHovered, setIsHovered] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setLoading(false);
}, 0);
return () => clearTimeout(timer);
}, [anime.id]);
const handleMouseEnter = () => {
setIsHovered(true);
};
const handleMouseLeave = () => {
setIsHovered(false);
};
const imageSrc = anime.image || '';
const animeColor = anime.color || '#999999';
const displayTitle = useMemo(
() => anime.title.english || anime.title.romaji || 'No Title',
[anime.title.english, anime.title.romaji],
);
const truncateTitle = useMemo(
() => (title: string, maxLength: number) =>
title.length > maxLength ? `${title.slice(0, maxLength)}...` : title,
[],
);
const handleImageLoad = () => {
setLoading(false); // Set loading to false when image is loaded
};
const displayDetail = useMemo(() => {
// Any complex logic can go here
return (
{anime.type}
);
}, [isHovered, anime.color, anime.type]);
return (
<>
{loading ? (
) : (
{isHovered && displayDetail}
{truncateTitle(displayTitle, 35)}
{truncateTitle(anime.title.romaji || '', 24)}
{anime.releaseDate && (
<>
{anime.releaseDate}
>
)}
{(anime.totalEpisodes || anime.episodes) && (
<>
{anime.totalEpisodes || anime.episodes}
>
)}
{anime.rating && (
<>
{anime.rating}
>
)}
)}
>
);
};
================================================
FILE: src/components/Home/EpisodeCard.tsx
================================================
import React, { useState, useEffect, useMemo } from 'react';
import styled from 'styled-components';
import { Link } from 'react-router-dom';
import { FaPlay } from 'react-icons/fa';
import { Swiper, SwiperSlide } from 'swiper/react';
import 'swiper/swiper-bundle.css';
import { Episode } from '../../index';
import { IoIosCloseCircleOutline } from 'react-icons/io';
const LOCAL_STORAGE_KEYS = {
WATCHED_EPISODES: 'watched-episodes',
LAST_ANIME_VISITED: 'last-anime-visited',
};
interface LastEpisodes {
[key: string]: Episode;
}
interface LastVisitedData {
[key: string]: {
timestamp?: number;
titleEnglish?: string;
titleRomaji?: string;
};
}
const StyledSwiperContainer = styled(Swiper)`
position: relative;
max-width: 100%;
height: auto;
border-radius: var(--global-border-radius);
cursor: grab;
`;
const StyledSwiperSlide = styled(SwiperSlide)``;
const PlayIcon = styled.div`
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #ffffff;
font-size: 2.5rem;
opacity: 0;
z-index: 1;
transition: opacity 0.2s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
`;
const AnimeEpisodeCard = styled(Link)`
position: relative;
display: flex;
flex-direction: column;
margin: 1rem 0;
padding: 0;
border-radius: var(--global-border-radius);
overflow: hidden;
transition: 0.2s ease-in-out;
transition-delay: 0.25s;
&:hover,
&:active,
&:focus {
box-shadow: 2px 2px 10px var(--global-card-hover-shadow);
${PlayIcon} {
opacity: 1;
}
img {
filter: brightness(0.5); // Optional: Slightly darken the image itself
}
}
@media (min-width: 768px) {
&:hover,
&:active,
&:focus {
// transform: translateY(-10px);
}
}
img {
animation: slideDown 0.5s ease-in-out;
height: auto;
aspect-ratio: 16 / 9;
object-fit: cover;
transition: filter 0.2s ease-in-out; // Smooth transition for the filter
}
.episode-info {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
padding: 0.5rem;
background: linear-gradient(
360deg,
rgba(8, 8, 8, 1) -15%,
transparent 100%
);
color: white;
.episode-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.95rem;
font-weight: bold;
margin: 0.25rem 0;
}
.episode-number {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.65);
margin: 0;
}
}
`;
const Section = styled.section`
padding: 0rem;
border-radius: var(--global-border-radius);
`;
const ProgressBar = styled.div`
position: absolute;
bottom: 0;
left: 0;
height: 0.25rem;
border-radius: var(--global-border-radius);
background-color: var(--primary-accent);
transition: width 0.3s ease-in-out;
`;
const ContinueWatchingTitle = styled.h2`
color: var(--global-text);
font-size: 1.25rem;
margin-bottom: 0.25rem;
`;
const CloseButton = styled.button`
position: absolute;
right: 0;
background: transparent;
border: none;
color: #ffffff;
cursor: pointer;
display: none;
animation: slideDown 0.25s ease-in-out;
transition: 0.2s ease-in-out;
padding-right: 0.2rem;
padding-top: 0.2rem;
svg {
transition: 0.2s ease-in-out;
transform: scale(0.95);
font-size: 1.75rem;
&:hover,
&:active,
&:focus {
transform: scale(1);
}
}
${AnimeEpisodeCard}:hover & {
display: block; // Show only on hover
}
`;
const FaCircle = styled(IoIosCloseCircleOutline)`
font-size: 2.25rem;
`;
const calculateSlidesPerView = (windowWidth: number): number => {
if (windowWidth >= 1200) return 5;
if (windowWidth >= 1000) return 4;
if (windowWidth >= 700) return 3;
if (windowWidth >= 500) return 2;
return 2;
};
export const EpisodeCard: React.FC = () => {
const [watchedEpisodesData, setWatchedEpisodesData] = useState(
localStorage.getItem('watched-episodes'),
);
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
const lastVisitedData = useMemo(() => {
const data = localStorage.getItem(LOCAL_STORAGE_KEYS.LAST_ANIME_VISITED);
return data ? JSON.parse(data) : {};
}, []);
useEffect(() => {
const handleResize = () => {
setWindowWidth(window.innerWidth);
};
const debouncedResize = setTimeout(handleResize, 200);
window.addEventListener('resize', handleResize);
return () => {
clearTimeout(debouncedResize);
window.removeEventListener('resize', handleResize);
};
}, []);
const episodesToRender = useMemo(() => {
if (!watchedEpisodesData) return [];
try {
const allEpisodes: Record =
JSON.parse(watchedEpisodesData);
const lastEpisodes = Object.entries(allEpisodes).reduce(
(acc, [animeId, episodes]) => {
const lastEpisode = episodes[episodes.length - 1]; // Assuming the episodes are in order
if (lastEpisode) {
acc[animeId] = lastEpisode;
}
return acc;
},
{},
);
const orderedAnimeIds = Object.keys(lastEpisodes).sort((a, b) => {
const lastVisitedA = lastVisitedData[a]?.timestamp || 0;
const lastVisitedB = lastVisitedData[b]?.timestamp || 0;
return lastVisitedB - lastVisitedA;
});
return orderedAnimeIds.map((animeId) => {
const episode = lastEpisodes[animeId];
const playbackInfo = JSON.parse(
localStorage.getItem('all_episode_times') || '{}',
) as { [key: string]: { playbackPercentage: number } };
const playbackPercentage =
playbackInfo[episode.id]?.playbackPercentage || 0;
// Determine anime title, preferring English, falling back to Romaji, then to "Episode Title"
const animeTitle =
lastVisitedData[animeId]?.titleEnglish ||
lastVisitedData[animeId]?.titleRomaji ||
'';
// Conditional title display
const displayTitle = `${animeTitle}${episode.title ? ` - ${episode.title}` : ''}`;
const handleRemoveAllEpisodes = (animeId: string) => {
const updatedEpisodes = JSON.parse(watchedEpisodesData || '{}');
delete updatedEpisodes[animeId];
const newWatchedEpisodesData = JSON.stringify(updatedEpisodes);
localStorage.setItem('watched-episodes', newWatchedEpisodesData);
setWatchedEpisodesData(newWatchedEpisodesData); // Trigger re-render
};
return (
{displayTitle}
{`Episode ${episode.number}`}
{
e.preventDefault(); // Prevents the default action of the event
e.stopPropagation(); // Prevents the event from bubbling up to any parent elements
handleRemoveAllEpisodes(animeId);
}}
>
);
});
} catch (error) {
console.error('Failed to parse watched episodes data:', error);
return [];
}
}, [watchedEpisodesData, lastVisitedData]);
const swiperSettings = useMemo(
() => ({
spaceBetween: 20,
slidesPerView: calculateSlidesPerView(windowWidth),
loop: true,
freeMode: true,
grabCursor: true,
keyboard: true,
autoplay: {
delay: 6000,
disableOnInteraction: false,
},
}),
[windowWidth],
);
return (
{episodesToRender.length > 0 && (
CONTINUE WATCHING
)}
{episodesToRender}
);
};
================================================
FILE: src/components/Home/HomeCarousel.tsx
================================================
import { FC } from 'react';
import styled from 'styled-components';
import { FaPlay } from 'react-icons/fa';
import { Swiper, SwiperSlide } from 'swiper/react';
import 'swiper/swiper-bundle.css';
import { useNavigate } from 'react-router-dom';
import { SkeletonSlide, Anime } from '../../index';
import { TbCards } from 'react-icons/tb';
import { FaStar } from 'react-icons/fa';
import { FaClock } from 'react-icons/fa6';
const StyledSwiperContainer = styled(Swiper)`
position: relative;
max-width: 100%;
height: 24rem;
border-radius: var(--global-border-radius);
cursor: grab;
@media (max-width: 1000px) {
height: 20rem;
}
@media (max-width: 500px) {
height: 18rem;
}
`;
const StyledSwiperSlide = styled(SwiperSlide)`
position: relative;
display: flex;
justify-content: flex-start;
align-items: center;
animation: fadeIn 0.4s ease-in-out forwards;
`;
const DarkOverlay = styled.div`
content: '';
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
border-radius: var(--global-border-radius);
z-index: 1;
background: linear-gradient(45deg, rgba(8, 8, 8, 1) 0%, transparent 60%);
`;
const SlideImageWrapper = styled.div`
position: relative;
width: 100%;
height: 100%;
border-radius: var(--global-border-radius);
`;
const SlideImage = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
border-radius: var(--global-border-radius);
position: absolute;
`;
const ContentWrapper = styled.div`
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
`;
const SlideContent = styled.div`
position: absolute;
left: 2rem;
bottom: 1.5rem;
z-index: 5;
max-width: 60%;
animation: slideUp 0.4s ease-in-out;
@media (max-width: 1000px) {
left: 1rem;
bottom: 1.5rem;
}
`;
const SlideTitle = styled.h2`
color: var(--white, #fff);
font-size: clamp(1.2rem, 3vw, 2.5rem);
margin: auto;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
@media (min-width: 500px) {
white-space: nowrap;
max-width: 100%;
}
`;
const SlideInfo = styled.div`
display: flex;
gap: 0.75rem;
color: #ffffff;
margin: auto;
margin-top: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
@media (max-width: 1000px) {
font-size: 0.8rem;
gap: 0.5rem;
}
@media (max-width: 500px) {
font-size: 0.7rem;
gap: 0.45rem;
}
`;
const SlideInfoItem = styled.p`
display: flex;
gap: 0.25rem;
`;
const SlideDescription = styled.p<{
$maxLines: boolean;
}>`
color: var(--white, #ccc);
background: transparent;
font-size: clamp(0.9rem, 1.5vw, 0.9rem);
line-height: 1.2;
max-width: 60%;
max-height: 5rem;
overflow: hidden;
-webkit-line-clamp: 3;
margin: 0;
@media (max-width: 1000px) {
line-height: 1.2;
max-width: 70%;
font-size: clamp(0.8rem, 1.2vw, 0.9rem);
max-height: 3rem;
}
@media (max-width: 500px) {
max-width: 100%;
font-size: clamp(0.7rem, 1vw, 0.8rem);
max-height: 2.5rem;
}
/* Add overflow-y: auto if the content exceeds max height */
overflow-y: ${({ $maxLines }) => ($maxLines ? 'auto' : 'hidden')};
`;
const PlayButtonWrapper = styled.div`
position: absolute;
right: 2rem;
bottom: 1.5rem;
z-index: 5;
display: flex;
align-items: center; /* Center vertically */
justify-content: center; /* Center horizontally */
@media (max-width: 1000px) {
right: 1.5rem;
bottom: 1.5rem;
}
`;
const PlayButton = styled.button`
display: flex;
gap: 0.5rem;
background-color: var(--global-button-bg);
color: var(--global-text);
border: none;
border-radius: 0.4rem;
font-size: 1rem; /* Increased font size */
font-weight: bold;
cursor: pointer;
transition: 0.2s ease;
padding: 1.2rem 2rem; /* Increased padding */
display: flex;
align-items: center;
&:hover,
&:active,
&:focus {
background-color: var(--primary-accent-bg);
transform: scale(1.05); /* Slightly larger scale on hover */
}
@media (max-width: 1000px) {
padding: 1rem 2rem; /* Adjusted for medium-sized devices */
}
@media (max-width: 500px) {
border-radius: 50%;
padding: 1.4rem; /* Adjusted for small devices */
padding-right: 1.5rem;
font-size: 1.25rem; /* Adjusted font size for small devices */
span {
display: none;
}
}
`;
const PlayIcon = styled(FaPlay)``;
const PaginationStyle = styled.div`
.swiper-pagination-bullet {
background: var(--global-primary-bg, #007bff);
opacity: 0.7;
margin: 0 3px;
}
.swiper-pagination-bullet-active {
background: var(--global-text);
opacity: 1;
}
`;
// Adjust the Carousel component to use correctly typed props and state
interface HomeCarouselProps {
data: Anime[];
loading: boolean;
error?: string | null;
}
export const HomeCarousel: FC = ({
data = [],
loading,
error,
}) => {
const navigate = useNavigate();
const handlePlayButtonClick = (id: string) => {
navigate(`/watch/${id}`);
};
const truncateTitle = (title: string, maxLength: number = 40): string => {
return title.length > maxLength
? `${title.substring(0, maxLength)}...`
: title;
};
const validData = data.filter(
(item) =>
item.title &&
item.title.english &&
item.description &&
item.cover !== item.image,
);
// const formatGenres = (genres: string[]): string => genres.join(', ');
return (
<>
{loading || error ? (
) : (
{validData.map(
({
id,
cover,
title,
description,
// status,
rating,
// genres,
totalEpisodes,
duration,
type,
}) => (
{truncateTitle(title.english)}
{type && {type} }
{totalEpisodes && (
{totalEpisodes}
)}
{rating && (
{rating}
)}
{duration && (
{duration}mins
)}
200}
/>
handlePlayButtonClick(id)}
title={
'Watch ' + (title.english || title.romaji) + ' Now'
}
>
WATCH NOW
),
)}
)}
>
);
};
================================================
FILE: src/components/Home/HomeSideBar.tsx
================================================
import { useEffect, useState } from 'react';
import styled from 'styled-components';
import { Link } from 'react-router-dom'; // Assuming you're using React Router for navigation
import { TbCards } from 'react-icons/tb';
import { FaStar, FaCalendarAlt } from 'react-icons/fa';
import { Anime, StatusIndicator } from '../../index';
const SidebarStyled = styled.div`
transition: 0.2s ease-in-out;
margin: 0;
padding: 0;
max-width: 24rem;
@media (max-width: 1000px) {
max-width: unset;
}
`;
const TitleWithDot = styled.div`
display: flex;
align-items: center;
padding: 0.5rem;
margin-top: 0.35rem;
gap: 0.4rem;
border-radius: var(--global-border-radius);
cursor: pointer;
transition: background 0.2s ease;
`;
const AnimeCard = styled.div`
display: flex;
background-color: var(--global-div);
border-radius: var(--global-border-radius);
align-items: center;
overflow: hidden;
gap: 0.5rem;
cursor: pointer;
margin-bottom: 0.5rem;
animation: slideUp 0.5s ease-in-out;
animation-fill-mode: backwards;
transition:
background-color 0s ease-in-out,
margin-left 0.2s ease-in-out 0.1s;
box-shadow 0.2s ease-in-out;
&:hover,
&:active,
&:focus {
background-color: var(--global-div-tr);
margin-left: 0.35rem;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.2);
}
@media (max-width: 500px) {
&:hover,
&:active,
&:focus {
margin-left: unset;
}
}
`;
const AnimeImageStyled = styled.img`
width: 4.25rem;
height: 6rem;
object-fit: cover;
border-radius: var(--global-border-radius);
`;
const InfoStyled = styled.div``;
const Title = styled.p`
top: 0;
margin-bottom: 0.5rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 0.9rem;
margin: 0;
`;
const Details = styled.p`
font-size: 0.75rem;
margin: 0;
color: rgba(102, 102, 102, 0.75);
svg {
margin-left: 0.4rem;
}
`;
export const HomeSideBar: React.FC<{ animeData: Anime[] }> = ({
animeData,
}) => {
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => {
setWindowWidth(window.innerWidth);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const displayedAnime = windowWidth <= 500 ? animeData.slice(0, 5) : animeData;
return (
{displayedAnime.map((anime: Anime, index) => (
{anime.title.english || anime.title.romaji}
{anime.type && <>{anime.type}>}
{anime.releaseDate && (
<>
{anime.releaseDate}
>
)}
{anime.currentEpisode !== null &&
anime.currentEpisode !== undefined &&
anime.totalEpisodes !== null &&
anime.totalEpisodes !== undefined &&
anime.totalEpisodes !== 0 &&
anime.totalEpisodes !== 0 && (
<>
{anime.currentEpisode}
{' / '}
{anime.totalEpisodes}
>
)}
{anime.rating && (
<>
{anime.rating}
>
)}
))}
);
};
================================================
FILE: src/components/Navigation/DropSearch.tsx
================================================
import React, { useEffect, useRef } from 'react';
import styled from 'styled-components';
import { useNavigate } from 'react-router-dom';
import { Anime } from '../../index';
import { FaArrowRight, FaStar } from 'react-icons/fa';
import { TbCards } from 'react-icons/tb';
import { BsArrowUpSquare, BsArrowDownSquare } from 'react-icons/bs';
import { PiKeyReturn } from 'react-icons/pi';
const Container = styled.div<{ $isVisible: boolean; width: number }>`
display: ${({ $isVisible }) => ($isVisible ? 'block' : 'none')};
position: absolute;
z-index: -1;
top: 1rem;
width: ${({ width }) => `${width}px`};
margin-left: -0.6rem;
overflow-y: auto;
background-color: var(--global-div);
border-top: none;
border-radius: var(--global-border-radius);
padding-top: 2.5rem;
animation: dropDown 0.5s ease-in-out;
@media (max-width: 500px) {
top: 4rem;
width: 96.4%;
}
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
visibility: ${({ $isVisible }) => ($isVisible ? 'visible' : 'hidden')};
max-height: ${({ $isVisible }) => ($isVisible ? '500px' : '0')};
`;
const Details = styled.p<{ $isSelected: boolean }>`
margin: 0.25rem 0;
animation: slideDropDown 0.5s ease-in-out;
color: ${({ $isSelected }) =>
$isSelected ? 'var(--primary-text)' : 'rgba(102, 102, 102, 0.75)'};
font-size: 0.65rem;
font-weight: bold;
padding: 0 0.5rem;
display: flex;
`;
const Item = styled.div<{ $isSelected: boolean }>`
display: flex;
animation: slideDropDown 0.5s ease-in-out;
padding: 0.5rem;
margin: 0;
cursor: pointer;
background-color: ${({ $isSelected }) =>
$isSelected ? 'var(--primary-accent-bg)' : 'transparent'};
transition: 0.05s ease-in-out;
&:hover,
&:active,
&:focus {
background-color: var(--primary-accent-bg);
${Details} {
color: var(--global-text);
}
}
`;
const ViewAllItem = styled(Item)<{ $isSelected: boolean }>`
font-size: 0.9rem;
font-weight: bold;
display: flex;
justify-content: space-between; // This spreads out the children to the extremes
align-items: center;
color: ${({ $isSelected }) => ($isSelected ? '' : '#666')};
&:hover,
&:active,
&:focus {
color: var(--global-text);
}
svg {
margin-bottom: -0.1rem;
}
`;
const Shorcuts = styled.div`
font-weight: normal;
@media (max-width: 600px) {
display: none;
}
`;
const Image = styled.img`
animation: slideDropDown 0.5s ease-in-out;
width: 2.5rem;
height: 3.5rem;
border-radius: var(--global-border-radius);
object-fit: cover;
@media (max-width: 500px) {
width: 2.5rem;
height: 2.5rem;
}
`;
const Title = styled.p`
margin: 0 0.5rem;
padding: 0.1rem;
animation: slideDropDown 0.5s ease-in-out;
text-align: left;
overflow: hidden;
font-size: 0.9rem;
font-weight: bold;
text-overflow: ellipsis;
white-space: nowrap;
@media (max-width: 500px) {
font-size: 0.8rem;
}
`;
interface Props {
searchResults: Anime[];
onClose: () => void;
isVisible: boolean;
selectedIndex: number | null;
setSelectedIndex: React.Dispatch>;
searchQuery: string;
containerWidth: number;
}
export const DropDownSearch: React.FC = ({
searchResults,
onClose,
isVisible,
selectedIndex,
setSelectedIndex,
searchQuery,
containerWidth,
}) => {
const navigate = useNavigate();
const ref = useRef(null);
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
onClose();
}
};
useEffect(() => {
if (isVisible) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isVisible, onClose]);
useEffect(() => {
if (!isVisible) {
setSelectedIndex(null);
}
}, [isVisible]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isVisible) return;
const total = searchResults.length;
let index = selectedIndex !== null ? selectedIndex : -1;
if (e.key === 'ArrowDown') {
e.preventDefault();
index = (index + 1) % (total + 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
index = (index - 1 + total + 1) % (total + 1);
} else if (e.key === 'Enter' && selectedIndex !== null) {
e.preventDefault();
if (selectedIndex < total) {
onClose();
navigate(`/watch/${searchResults[selectedIndex].id}`);
} else {
navigate(`/search?query=${encodeURIComponent(searchQuery)}`);
onClose();
}
}
setSelectedIndex(index);
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isVisible, searchResults, selectedIndex]);
return (
0}
ref={ref}
role='list'
>
{searchResults.map((result, index) => (
- {
onClose();
navigate(`/watch/${result.id}`);
}}
role='listitem'
>
{result.title?.english || result.title?.romaji || 'n/a'}
{result.type}
{result.totalEpisodes || 'N/A'}
{result.rating ? result.rating / 10 : 'N/A'}
))}
{
navigate(`/search?query=${encodeURIComponent(searchQuery)}`);
onClose();
}}
role='listitem'
tabIndex={0}
>
to navigate{' '}
to select | Esc to exit
<>View All>
);
};
================================================
FILE: src/components/Navigation/Footer.tsx
================================================
import styled from 'styled-components';
import { FaReddit, FaDiscord, FaTwitter, FaGithub } from 'react-icons/fa';
import { Link } from 'react-router-dom';
import { year } from '../../hooks/useTIme';
const PageWrapper = styled.div`
margin-top: 2rem;
@media (max-width: 1000px) {
padding: 0 0.5rem;
}
`;
const FooterBaseContainer = styled.footer<{ $isSub: boolean }>`
color: var(--global-text);
padding: ${({ $isSub }) => ($isSub ? '0' : '0.5rem 0')};
display: flex;
justify-content: space-between;
border-top: ${({ $isSub }) => ($isSub ? '0.125rem solid' : 'none')}
var(--global-secondary-bg);
flex-direction: column;
@media (max-width: 1000px) {
padding: ${({ $isSub }) => ($isSub ? '0 0 1rem 0' : '0.5rem 0')};
}
@media (min-width: 601px) {
flex-direction: row;
}
@media (max-width: 600px) {
padding: ${({ $isSub }) => ($isSub ? '0' : '0.5rem 0')};
}
`;
const StyledLinkList = styled.div`
display: flex;
flex-direction: column;
margin: 0.5rem 0;
margin-top: auto;
`;
const FooterLink = styled(Link)`
align-items: center;
padding: 0.5rem 0;
color: grey;
font-size: 0.9rem;
text-decoration: none;
transition: color 0.1s ease-in-out;
bottom: 0;
align-self: auto;
@media (min-width: 601px) {
align-self: end;
}
&:hover,
&:active,
&:focus {
color: var(--global-button-text);
}
`;
const SocialIconsWrapper = styled.div`
padding-top: 1rem;
display: flex;
gap: 1rem;
`;
const FooterLogoImage = styled.img`
content: var(--logo-transparent);
max-width: 4rem;
height: 4.375rem;
`;
const Text = styled.div<{ $isSub: boolean }>`
color: grey;
font-size: ${({ $isSub }) => ($isSub ? '0.75rem' : '0.65rem')};
margin: ${({ $isSub }) => ($isSub ? '1rem 0 0 0' : '1rem 0')};
max-width: 25rem;
strong {
color: var(--global-text);
}
`;
const ShareButton = styled.a`
display: inline-block;
color: grey;
transition: 0.2s ease-in-out;
svg {
font-size: 1.2rem;
}
&:hover,
&:active,
&:focus {
transform: scale(1.15);
color: var(--global-button-text);
text-decoration: underline;
}
@media (max-width: 600px) {
margin-bottom: 1rem;
}
`;
export function Footer() {
return (
This website does not retain any files on its server. Rather, it
solely provides links to media content hosted by third-party
services.
About
Domains
Privacy & ToS
© {year}{' '}
miruro.com
{' '}
| Website Made by Miruro no Kuon
{[
{
href: 'https://www.reddit.com/r/miruro',
Icon: FaReddit,
label: 'Reddit',
},
{
href: 'https://discord.gg/dubRrtfpFn',
Icon: FaDiscord,
label: 'Discord',
},
{
href: 'https://twitter.com/miruro_official',
Icon: FaTwitter,
label: 'Twitter',
},
].map(({ href, Icon, label }) => (
))}
);
}
================================================
FILE: src/components/Navigation/Navbar.tsx
================================================
import React, { useRef, useEffect, useState, useCallback } from 'react';
import styled from 'styled-components';
import {
useNavigate,
useSearchParams,
Link,
useLocation,
} from 'react-router-dom';
import { DropDownSearch, useAuth } from '../../index';
import { fetchAdvancedSearch, type Anime } from '../..';
import { FiSun, FiMoon, FiX /* FiMenu */ } from 'react-icons/fi';
import { GoCommandPalette } from 'react-icons/go';
import { IoIosSearch } from 'react-icons/io';
import { CgProfile } from 'react-icons/cg';
const StyledNavbar = styled.div<{ $isExtended?: boolean }>`
position: fixed;
top: 0;
left: 0;
right: 0;
text-align: center;
margin: 0;
padding: 1rem;
background-color: var(--global-primary-bg-tr);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
z-index: 100;
animation: fadeIn('var(--global-primary-bg-tr)') 0.5s ease-in-out;
transition: 0.1s ease-in-out;
@media (max-width: 500px) {
padding: 1rem 0.5rem;
}
`;
const NavbarWrapper = styled.div`
max-width: 105rem;
margin: auto;
`;
const TopContainer = styled.div`
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: space-between;
`;
const LogoImg = styled(Link)`
width: 7rem;
font-size: 1.2rem;
font-weight: bold;
text-decoration: none;
color: var(--global-text);
content: var(--logo-text-transparent);
cursor: pointer;
transition:
color 0.2s ease-in-out,
transform 0.2s ease-in-out;
&:hover,
&:active,
&:focus {
color: black;
transform: scale(1.05);
}
&:active {
transform: scale(0.95);
}
@media (max-width: 500px) {
max-width: 6rem;
}
`;
const InputContainer = styled.div<{ $isVisible: boolean }>`
display: flex;
flex: 1;
max-width: 35rem;
height: 1.2rem;
align-items: center;
padding: 0.6rem;
border-radius: var(--global-border-radius);
background-color: var(--global-div);
animation: fadeIn 0.1s ease-in-out;
animation: slideDropDown 0.5s ease;
@media (max-width: 1000px) {
max-width: 30rem;
}
@media (max-width: 500px) {
max-width: 100%;
margin-top: 1rem;
display: ${({ $isVisible }) => ($isVisible ? 'flex' : 'none')};
}
`;
const RightContent = styled.div`
gap: 0.5rem;
display: flex;
align-items: center;
height: 2rem;
`;
const Icon = styled.div<{ $isFocused: boolean }>`
margin: 0;
padding: 0 0.25rem;
color: var(--global-text);
opacity: ${({ $isFocused }) => ($isFocused ? 1 : 0.5)};
font-size: 1.2rem;
transition: opacity 0.2s;
max-height: 100%;
display: flex;
align-items: center;
`;
const SearchInput = styled.input`
background: transparent;
border: none;
color: var(--global-text);
display: inline-block;
font-size: 0.85rem;
outline: 0;
padding: 0;
max-height: 100%;
display: flex;
align-items: center;
padding-top: 0;
width: 100%;
transition:
border-color 0.2s ease-in-out,
box-shadow 0.2s ease-in-out;
`;
const ClearButton = styled.button<{ $query: string }>`
background: transparent;
border: none;
color: var(--global-text);
font-size: 1.2rem;
cursor: pointer;
opacity: ${({ $query }) => ($query ? 0.5 : 0)};
visibility: ${({ $query }) => ($query ? 'visible' : 'hidden')};
transition:
color 0.2s,
opacity 0.2s;
max-height: 100%;
display: flex;
align-items: center;
&:hover,
&:active,
&:focus {
color: var(--global-text);
opacity: 1;
}
`;
const StyledButton = styled.button<{ isInputToggle?: boolean }>`
background: transparent;
background-color: var(--global-div);
color: var(--global-text);
font-size: 1.2rem;
cursor: pointer;
padding: 1.2rem 0.6rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--global-border-radius);
width: 100%;
height: 100%;
transition:
color 0.2s ease-in-out,
transform 0.1s ease-in-out;
border: none;
&:active {
transform: scale(0.9);
}
@media (max-width: 500px) {
display: flex;
margin: ${({ isInputToggle }) => (isInputToggle ? '0' : '0')};
}
`;
const SlashToggleBtn = styled.div<{ $isFocused: boolean }>`
font-size: 1.2rem;
cursor: pointer;
opacity: ${({ $isFocused }) => ($isFocused ? 1 : 0.5)};
&:hover,
&:active,
&:focus {
opacity: 1;
}
@media (max-width: 1000px) {
display: none;
}
`;
const detectUserTheme = () => {
if (
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
return true;
}
return false;
};
const saveThemePreference = (isDarkMode: boolean) => {
localStorage.setItem('themePreference', isDarkMode ? 'dark' : 'light');
};
const getInitialThemePreference = () => {
const storedThemePreference = localStorage.getItem('themePreference');
if (storedThemePreference) {
return storedThemePreference === 'dark';
}
return detectUserTheme();
};
export const Navbar = () => {
const { isLoggedIn, userData } = useAuth();
const [isPaddingExtended, setIsPaddingExtended] = useState(false);
const inputContainerRef = useRef(null);
const navigate = useNavigate();
const location = useLocation();
const [inputContainerWidth, setInputContainerWidth] = useState(0);
const [searchParams, setSearchParams] = useSearchParams();
const inputRef = useRef(null);
const navbarRef = useRef(null);
const dropdownRef = useRef(null); // Ref for the dropdown container
const [searchResults, setSearchResults] = useState([]);
const debounceTimeout = useRef(null);
const [selectedIndex, setSelectedIndex] = useState(null);
const [search, setSearch] = useState({
isSearchFocused: false,
searchQuery: searchParams.get('query') || '',
isDropdownOpen: false,
});
const [isInputVisible, setIsInputVisible] = useState(false); // Default to false
const [isMobileView, setIsMobileView] = useState(window.innerWidth < 500);
const fetchSearchResults = async (query: string) => {
if (!query.trim()) return;
try {
const fetchedData = await fetchAdvancedSearch(query, 1, 5); // Fetch first 5 results for the dropdown
const formattedResults = fetchedData.results.map((anime: Anime) => ({
id: anime.id, // Make sure to include the ID field
title: anime.title,
image: anime.image,
type: anime.type,
totalEpisodes: anime.totalEpisodes,
rating: anime.rating,
}));
setSearchResults(formattedResults);
} catch (error) {
console.error('Failed to fetch search results:', error);
setSearchResults([]);
}
};
const handleCloseDropdown = () => {
setSearch((prevState) => ({
...prevState,
isDropdownOpen: false,
}));
};
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
handleCloseDropdown();
}
};
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
});
const [isDarkMode, setIsDarkMode] = useState(getInitialThemePreference());
useEffect(() => {
document.documentElement.classList.toggle('dark-mode', isDarkMode);
}, [isDarkMode]);
const toggleTheme = useCallback(() => {
const newIsDarkMode = !isDarkMode;
setIsDarkMode(newIsDarkMode);
saveThemePreference(newIsDarkMode);
}, [isDarkMode, setIsDarkMode]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === '/' && inputRef.current) {
e.preventDefault();
inputRef.current.focus();
setSearch((prevState) => ({
...prevState,
isSearchFocused: true,
}));
} else if (e.key === 'Escape' && inputRef.current) {
inputRef.current.blur();
setSearch((prevState) => ({
...prevState,
isSearchFocused: false,
}));
handleCloseDropdown(); // Close dropdown on Escape key
} else if (e.shiftKey && e.key.toLowerCase() === 'd') {
if (document.activeElement !== inputRef.current) {
e.preventDefault();
toggleTheme();
}
}
},
[toggleTheme],
);
useEffect(() => {
const listener = handleKeyDown as EventListener;
document.addEventListener('keydown', listener);
return () => {
document.removeEventListener('keydown', listener);
};
}, [handleKeyDown]);
useEffect(() => {
setSearch({ ...search, searchQuery: searchParams.get('query') || '' });
}, [searchParams]);
const navigateWithQuery = useCallback(
(value: string) => {
if (location.pathname == '/search') {
const params = new URLSearchParams();
params.set('query', value);
setSearchParams(params, { replace: true });
} else {
navigate(value ? `/search?query=${value}` : '/search');
}
},
[navigate, location.pathname, setSearchParams],
);
const handleInputChange = (e: React.ChangeEvent) => {
const newValue = e.target.value;
setSearch({ ...search, searchQuery: newValue });
if (debounceTimeout.current) clearTimeout(debounceTimeout.current);
debounceTimeout.current = setTimeout(() => {
fetchSearchResults(newValue);
setSearch((prevState) => ({
...prevState,
isDropdownOpen: true,
}));
}, 300);
};
const handleKeyDownOnInput = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault(); // Prevent default form submission behavior
if (selectedIndex !== null && searchResults[selectedIndex]) {
// Navigate to the selected search result if it exists
const animeId = searchResults[selectedIndex].id;
navigate(`/watch/${animeId}`);
handleCloseDropdown();
} else {
// Fallback to navigating with the search query if the selected index is not in searchResults
navigateWithQuery(search.searchQuery);
}
if (debounceTimeout.current) {
clearTimeout(debounceTimeout.current);
}
setSearch((prevState) => ({
...prevState,
isDropdownOpen: false,
}));
if (inputRef.current) {
inputRef.current.blur();
}
}
};
useEffect(() => {
// Function to update the width
const updateWidth = () => {
if (inputContainerRef.current) {
setInputContainerWidth(inputContainerRef.current.offsetWidth);
}
};
// Update width on mount
updateWidth();
// Add event listener for window resize
window.addEventListener('resize', updateWidth);
// Cleanup function to remove the event listener
return () => window.removeEventListener('resize', updateWidth);
}, []);
useEffect(() => {
// This effect runs when the location.pathname changes or enter is pressed (Hide the InputContainer)
if (isMobileView) {
setIsInputVisible(false);
}
}, [location.pathname, isMobileView]);
const handleClearSearch = () => {
setSearch((prevState) => ({
...prevState,
searchQuery: '',
}));
setSearchResults([]);
setSearch((prevState) => ({
...prevState,
isDropdownOpen: false, // Close dropdown when search is cleared
}));
if (inputRef.current) {
inputRef.current.focus();
}
};
useEffect(() => {
function handleResize() {
setIsMobileView(window.innerWidth < 500);
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
//navigate to profile
const navigateToProfile = () => {
// Check if the current location's pathname is not '/profile' before navigating
if (location.pathname !== '/profile') {
navigate('/profile');
}
};
return (
<>
window.scrollTo(0, 0)}
>
見るろ の 久遠
{/* Render InputContainer within the navbar for screens larger than 500px */}
{!isMobileView && (
{
setSearch((prevState) => ({
...prevState,
isDropdownOpen: true,
isSearchFocused: true,
}));
}}
ref={inputRef}
aria-label='Search Anime'
/>
)}
{isMobileView && (
{
setIsInputVisible((prev) => !prev);
setIsPaddingExtended((prev) => !prev); // Toggle padding extension when toggling input visibility
}}
aria-label='Toggle Search Input'
>
)}
{isDarkMode ? : }
{isLoggedIn && userData ? (
) : (
)}
{isMobileView && isInputVisible && (
{
setSearch((prevState) => ({
...prevState,
isDropdownOpen: true,
isSearchFocused: true,
}));
}}
ref={inputRef}
/>
)}
{/* Conditionally render InputContainer below the navbar for mobile view when visibility is toggled */}
>
);
};
================================================
FILE: src/components/Navigation/SearchFilters.tsx
================================================
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import Select, { components } from 'react-select';
import makeAnimated from 'react-select/animated';
import {
FaSearch,
FaSortAmountDown,
FaSortAmountDownAlt,
FaCheckCircle,
FaTrashAlt,
} from 'react-icons/fa';
import { FiX } from 'react-icons/fi';
import {
Option,
FilterProps,
genreOptions,
anyOption,
yearOptions,
seasonOptions,
formatOptions,
statusOptions,
sortOptions,
} from '../../index';
interface StateProps {
data: {
label: string;
};
isSelected: boolean;
isFocused: boolean;
}
const selectStyles: any = {
placeholder: (provided: object) => ({
...provided,
color: 'var(--global-text-muted)',
}),
singleValue: (provided: object, state: StateProps) => ({
...provided,
color:
state.data.label === 'Popularity' || state.data.label === 'Any'
? 'var(--global-text-muted)'
: 'var(--primary-accent)',
}),
control: (provided: object) => ({
...provided,
width: '11.5rem',
backgroundColor: 'var(--global-secondary-bg)',
borderColor: 'transparent',
color: 'var(--global-text)',
boxShadow: 'none',
'&:hover': {
borderColor: 'var(--primary-accent)',
},
'@media (max-width: 500px)': {
width: '10rem',
},
}),
menu: (provided: object) => ({
...provided,
zIndex: 5,
padding: '0.25rem',
backgroundColor: 'var(--global-secondary-bg)',
borderColor: 'var(--global-border)',
color: 'var(--global-text)',
}),
option: (provided: object, state: StateProps) => ({
...provided,
backgroundColor:
state.isSelected || state.isFocused
? 'var(--global-tertiary-bg)'
: 'var(--global-secondary-bg)',
color:
state.isSelected || state.isFocused
? 'var(--primary-accent)'
: 'var(--global-text)',
borderRadius: 'var(--global-border-radius)',
'&:hover': {
backgroundColor: 'var(--global-tertiary-bg)',
color: 'var(--primary-accent)',
},
marginBottom: '0.25rem',
}),
multiValue: (provided: object) => ({
...provided,
backgroundColor: 'var(--global-genre-button-bg)',
}),
multiValueLabel: (provided: object) => ({
...provided,
color: 'var(--global-text)',
}),
multiValueRemove: (provided: object) => ({
...provided,
'&:hover': {
backgroundColor: 'var(--primary-accent)',
color: 'var(--global-secondary-bg)',
},
}),
};
const InputContainer = styled.div`
display: flex;
max-width: 10.4rem;
flex: 1;
align-items: center;
padding: 0 0.3rem;
border-radius: var(--global-border-radius);
background-color: var(--global-div);
@media (max-width: 500px) {
max-width: 100%;
}
`;
const Icon = styled.div`
font-size: 0.8rem;
margin: 0;
padding: 0 0.25rem;
color: 'var(--global-text-muted)',
transition: opacity 0.2s;
max-height: 100%;
display: flex;
align-items: center;
`;
const SearchInput = styled.input`
background: transparent;
border: none;
color: var(--global-text);
display: inline-block;
font-size: 0.8rem;
outline: 0;
padding: 0;
max-height: 100%;
display: flex;
align-items: center;
width: 100%;
height: 2.375rem;
transition:
border-color 0.2s ease-in-out,
box-shadow 0.2s ease-in-out;
`;
const FiltersWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 1rem;
`;
const FiltersContainer = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
grid-template-rows: auto;
margin: 0 auto;
position: relative;
gap: 1rem;
justify-content: left;
align-items: center;
font-size: 0.8rem;
font-weight: bold;
flex-wrap: wrap;
@media (max-width: 500px) {
display: flex;
justify-content: center;
}
`;
const FilterSection = styled.div`
display: flex;
flex-direction: column;
align-items: start;
gap: 0.5rem;
`;
const FilterLabel = styled.label`
font-weight: bold;
font-size: 0.9rem;
`;
const ButtonBase = styled.button`
flex: 1;
align-items: center;
justify-content: center;
padding: 0.6rem;
max-width: 4.5rem;
min-width: 4.5rem;
border: none;
font-weight: bold;
border-radius: var(--global-border-radius);
cursor: pointer;
background-color: var(--global-div);
color: var(--global-text);
transition:
background-color 0.2s ease,
transform 0.2s ease-in-out;
text-align: center;
&:active,
&:focus {
transform: scale(1.025);
}
&:active {
transform: scale(0.975);
}
svg {
margin-bottom: -0.1rem;
}
`;
const Button = styled(ButtonBase)`
&.active {
background-color: var(--primary-accent);
}
`;
const ClearFilters = styled(ButtonBase)`
&.active {
background-color: none;
}
&:hover,
&:active,
&:focus {
background-color: red;
opacity: 1;
}
`;
const ButtonContainer = styled.div`
display: flex;
gap: 1rem;
justify-content: flex-end;
@media (max-width: 500px) {
justify-content: center;
}
`;
const ClearButton = styled.button<{ $query: string }>`
background: transparent;
border: none;
color: var(--global-text);
font-size: 1.2rem;
cursor: pointer;
opacity: ${({ $query }) => ($query ? 0.5 : 0)};
visibility: ${({ $query }) => ($query ? 'visible' : 'hidden')};
transition:
color 0.2s,
opacity 0.2s;
max-height: 100%;
display: flex;
align-items: center;
&:hover,
&:active,
&:focus {
color: var(--global-text);
opacity: 1;
}
`;
const animatedComponents = makeAnimated();
const FilterSelect: React.FC = ({
label,
options,
onChange,
value,
isMulti = false,
}) => {
// Local state to handle input value and debounce
const [inputValue, setInputValue] = useState(value);
useEffect(() => {
// Update local state when external value prop changes
setInputValue(value);
}, [value]);
useEffect(() => {
if (label === 'Search') {
// Set up a delay for executing the onChange handler only for the Search input
const handler = setTimeout(() => {
onChange && onChange(inputValue);
}, 300); // 300ms delay for debounce
// Cleanup function to clear the timeout
return () => {
clearTimeout(handler);
};
}
}, [inputValue, onChange, label]);
//Add Check Circle to clicked option
const CustomOption = (props: any) => {
return (
{props.data.label}
{props.isSelected && }
);
};
return (
{label === 'Search'}
{label}
{label === 'Search' ? (
setInputValue(e.target.value)} // Update local state instead of calling onChange directly
placeholder=''
/>
{
setInputValue(''); // Reset the local state
onChange?.(''); // Propagate the change upwards
}}
aria-label='Clear Search'
>
) : (
null,
}}
isMulti={isMulti}
options={options}
onChange={onChange}
value={value}
placeholder='Any'
styles={selectStyles}
isSearchable={false}
/>
)}
);
};
export const SearchFilters: React.FC<{
query: string;
setQuery: React.Dispatch>;
selectedGenres: Option[];
setSelectedGenres: React.Dispatch>;
selectedYear: Option;
setSelectedYear: React.Dispatch>;
selectedSeason: Option;
setSelectedSeason: React.Dispatch>;
selectedFormat: Option;
setSelectedFormat: React.Dispatch>;
selectedStatus: Option;
setSelectedStatus: React.Dispatch>;
selectedSort: Option;
setSelectedSort: React.Dispatch>;
sortDirection: 'DESC' | 'ASC';
setSortDirection: React.Dispatch>;
updateSearchParams: () => void; // Added prop for updating search params
}> = ({
query,
setQuery,
selectedGenres,
setSelectedGenres,
selectedYear,
setSelectedYear,
selectedSeason,
setSelectedSeason,
selectedFormat,
setSelectedFormat,
selectedStatus,
setSelectedStatus,
selectedSort,
setSelectedSort,
sortDirection,
setSortDirection,
updateSearchParams,
}) => {
// State to track if any filter is changed from its default value
const [filtersChanged, setFiltersChanged] = useState(false);
const handleResetFilters = () => {
setSelectedGenres([]);
setSelectedYear(anyOption);
setSelectedSeason(anyOption);
setSelectedFormat(anyOption);
setSelectedStatus(anyOption);
setSelectedSort({ value: 'POPULARITY_DESC', label: 'Popularity' });
setSortDirection('DESC');
setQuery('');
updateSearchParams(); // Also reset URL parameters
};
useEffect(() => {
const hasFiltersChanged =
query !== '' || // Check if query is not default
selectedGenres.length > 0 || // Check if any genres are selected
selectedYear.value !== anyOption.value || // Check if year is not "Any"
selectedSeason.value !== anyOption.value || // Same for season, type, status...
selectedFormat.value !== anyOption.value ||
selectedStatus.value !== anyOption.value ||
selectedSort.value !== 'POPULARITY_DESC' || // Check if sort criteria is not "Popularity"
sortDirection !== 'DESC'; // Check if sort direction is not descending
setFiltersChanged(hasFiltersChanged);
}, [
query,
selectedGenres,
selectedYear,
selectedSeason,
selectedFormat,
selectedStatus,
selectedSort,
sortDirection,
]);
const handleChange =
(
setter:
| React.Dispatch>
| React.Dispatch>
| React.Dispatch>,
) =>
(
newValue: React.SetStateAction &
React.SetStateAction &
React.SetStateAction,
) => {
setter(newValue);
updateSearchParams();
};
return (
{
setSortDirection(sortDirection === 'DESC' ? 'ASC' : 'DESC');
updateSearchParams(); // Ensure sort direction changes also update URL
}}
>
{sortDirection === 'DESC' ? (
) : (
)}
{filtersChanged && (
)}
);
};
================================================
FILE: src/components/Profile/Settings.tsx
================================================
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import { useNavigate } from 'react-router-dom';
import { IoArrowBack } from 'react-icons/io5';
import { useSettings } from '../../index';
interface Preferences {
defaultLanguage: string;
titleLanguage: string;
characterNameLanguage: string;
ratingSource: string;
openKeyboardShortcuts: string;
autoskipIntroOutro: string;
autoPlay: string;
autoNext: string;
defaultServers: string;
restoreDefaultPreferences: string;
clearContinueWatching: string;
openButton: string;
}
const Goback = styled.div`
border-radius: var(--global-border-radius);
display: flex;
cursor: pointer;
justify-content: center;
align-items: center;
background-color: var(--global-div);
color: var(--global-text);
width: 3rem;
margin-right: 0.75rem;
&:active {
transform: scale(0.975);
}
`;
const SettingsDiv = styled.div`
gap: 1rem;
max-width: 45rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: auto; /* This centers the div horizontally */
`;
const PreferencesTable = styled.table`
background-color: var(--global-div-tr);
border-radius: var(--global-border-radius);
border-collapse: collapse;
width: 100%;
`;
const TableRow = styled.tr``;
const TableCell = styled.td`
padding: 1rem;
`;
const Title = styled.h2`
display: flex;
color: var(--global-text);
font-size: 1.5rem;
margin: 0rem;
margin-top: 1rem;
`;
const SectionTitle = styled.h3`
color: var(--global-text);
font-size: 1.25rem;
margin: 1rem;
`;
const Divider = styled.hr`
border: none;
height: 1px;
background-color: var(--global-secondary-bg);
margin-top: 1rem;
margin-bottom: 1rem;
`;
const StyledButton = styled.button<{ isSelected: boolean }>`
background: var(--global-div);
color: var(--global-text);
padding: 0.4rem;
cursor: pointer;
border: none;
border-radius: var(--global-border-radius);
transition: background-color 0.2s ease-in-out;
`;
const StyledSelect = styled.select`
background: var(--global-div);
color: var(--global-text);
padding: 0.25rem;
cursor: pointer;
border: none;
border-radius: var(--global-border-radius);
transition: background-color 0.2s ease-in-out;
`;
export const Settings: React.FC = () => {
const navigate = useNavigate();
const { settings, setSettings } = useSettings();
const [preferences, setPreferences] = useState({
defaultLanguage: settings.defaultLanguage,
titleLanguage: 'Romaji',
characterNameLanguage: 'Romaji',
ratingSource: 'Anilist',
openKeyboardShortcuts: 'Open',
autoskipIntroOutro: settings.autoSkip ? 'Enabled' : 'Disabled',
autoPlay: settings.autoPlay ? 'Enabled' : 'Disabled',
autoNext: settings.autoNext ? 'Enabled' : 'Disabled',
defaultServers: 'Default',
restoreDefaultPreferences: 'Restore',
clearContinueWatching: 'Clear',
openButton: 'Open',
});
useEffect(() => {
setPreferences((prev) => ({
...prev,
defaultLanguage: settings.defaultLanguage,
autoskipIntroOutro: settings.autoSkip ? 'Enabled' : 'Disabled',
autoPlay: settings.autoPlay ? 'Enabled' : 'Disabled',
autoNext: settings.autoNext ? 'Enabled' : 'Disabled',
}));
}, [settings]);
const getOptionsForPreference = (key: string): string[] => {
switch (key) {
case 'defaultLanguage':
return ['Sub', 'Dub'];
case 'titleLanguage':
return [
'English (Attack on Titan)',
'Romaji (Shingeki no Kyojin)',
'Native (進撃の巨人)',
];
case 'characterNameLanguage':
return ['Romaji (Zoldyck Killua)', 'Native (キルア=ゾルディック)'];
case 'ratingSource':
return ['Anilist', 'IMDb', 'MyAnimeList'];
case 'autoskipIntroOutro':
return ['Enabled', 'Disabled'];
case 'autoPlay':
return ['Enabled', 'Disabled'];
case 'defaultServers':
return ['Default', 'Vidstreaming', 'Gogo'];
case 'autoNext':
return ['Enabled', 'Disabled'];
default:
return [];
}
};
const handlePreferenceChange = (
preferenceName: keyof Preferences,
value: string,
) => {
setPreferences((prev) => ({
...prev,
[preferenceName]: value,
}));
switch (preferenceName) {
case 'autoskipIntroOutro':
setSettings({ autoSkip: value === 'Enabled' });
break;
case 'autoPlay':
setSettings({ autoPlay: value === 'Enabled' });
break;
case 'autoNext':
setSettings({ autoNext: value === 'Enabled' });
break;
case 'defaultLanguage':
setSettings({ defaultLanguage: value });
break;
case 'defaultServers':
setSettings({ defaultServers: value });
break;
}
};
const formatPreferenceName = (key: string) => {
return key
.replace(/([A-Z])/g, ' $1')
.trim()
.toLowerCase()
.replace(/^\w/, (c) => c.toUpperCase());
};
const handleGoback = () => {
navigate('/profile');
};
// Profile Page Document Title
useEffect(() => {
document.title = `Settings | Profile`;
});
return (
Settings
General
{[
'titleLanguage',
'characterNameLanguage',
'ratingSource',
'openKeyboardShortcuts',
].map((key) => (
{formatPreferenceName(key)}
{key === 'openKeyboardShortcuts' ? (
{preferences[key as keyof Preferences]}
) : (
handlePreferenceChange(
key as keyof Preferences,
e.target.value,
)
}
>
{getOptionsForPreference(key).map((option) => (
{option}
))}
)}
))}
Media
{[
'defaultLanguage',
'defaultServers',
'autoskipIntroOutro',
'autoPlay',
'autoNext',
].map((key) => (
{formatPreferenceName(key)}
handlePreferenceChange(
key as keyof Preferences,
e.target.value,
)
}
>
{getOptionsForPreference(key).map((option) => (
{option}
))}
))}
Other
{[
{ key: 'restoreDefaultPreferences', text: 'Restore' },
{ key: 'clearContinueWatching', text: 'Clear' },
].map(({ key, text }) => (
{formatPreferenceName(key)}
handlePreferenceChange(key as keyof Preferences, text)
}
>
{text}
))}
);
};
================================================
FILE: src/components/Profile/SettingsProvider.tsx
================================================
import React, {
createContext,
useContext,
useState,
useEffect,
ReactNode,
} from 'react';
// Define the type for the context state
interface SettingsContextType {
settings: {
autoSkip: boolean;
autoPlay: boolean;
autoNext: boolean;
defaultLanguage: string;
defaultServers: string;
};
setSettings: (settings: Partial) => void;
}
// Create the context with a default value
const SettingsContext = createContext(
undefined,
);
export function useSettings() {
const context = useContext(SettingsContext);
if (context === undefined) {
throw new Error('useSettings must be used within a SettingsProvider');
}
return context;
}
interface SettingsProviderProps {
children: ReactNode;
}
export const SettingsProvider: React.FC = ({
children,
}) => {
const [settings, setSettingsState] = useState({
autoSkip: localStorage.getItem('autoSkip') === 'true',
autoPlay: localStorage.getItem('autoPlay') === 'true',
autoNext: localStorage.getItem('autoNext') === 'true',
defaultLanguage: localStorage.getItem('defaultLanguage') || 'sub',
defaultServers: localStorage.getItem('defaultServers') || 'default',
});
useEffect(() => {
// This useEffect will ensure that any changes to the settings state are reflected in local storage
// console.log('Settings updated:', settings);
localStorage.setItem('autoSkip', settings.autoSkip ? 'true' : 'false');
localStorage.setItem('autoPlay', settings.autoPlay ? 'true' : 'false');
localStorage.setItem('autoNext', settings.autoNext ? 'true' : 'false');
localStorage.setItem('defaultLanguage', settings.defaultLanguage);
localStorage.setItem('defaultServers', settings.defaultServers);
}, [settings]);
const setSettings = (
newSettings: Partial,
) => {
setSettingsState((prev) => {
const updatedSettings = { ...prev, ...newSettings };
return updatedSettings;
});
};
return (
{children}
);
};
================================================
FILE: src/components/Profile/WatchingAnilist.tsx
================================================
import { useState, useEffect } from 'react';
import styled from 'styled-components';
import { useAuth, useUserAnimeList, MediaListStatus } from '../../index';
import { CardGrid } from '../../index';
const Container = styled.div`
margin-top: 1rem;
margin-bottom: 1rem;
`;
const NoEntriesMessage = styled.div`
margin: 1.5rem;
display: flex;
justify-content: center;
align-items: center;
font-size: 1.5rem;
font-weight: bold;
`;
const NotLoggedIn = styled.div`
margin: 5rem;
display: flex;
justify-content: center;
align-items: center;
font-size: 1.5rem;
font-weight: bold;
`;
const StatusDropdown = styled.select`
margin-left: 1rem;
margin-bottom: 1.5rem;
padding: 0.75rem;
border-radius: var(--global-border-radius);
background-color: var(--global-secondary-bg);
color: var(--global-text);
border: none;
`;
const statusLabels = {
CURRENT: 'Watching',
PLANNING: 'Plan to Watch',
COMPLETED: 'Completed',
REPEATING: 'Re-watching',
PAUSED: 'Paused',
DROPPED: 'Dropped',
};
const apiStatusToUserFriendly = {
FINISHED: 'Completed',
RELEASING: 'Ongoing',
NOT_YET_RELEASED: 'Not yet aired',
CANCELLED: 'Cancelled',
HIATUS: 'Paused',
};
export const WatchingAnilist = () => {
const { isLoggedIn, userData } = useAuth();
const [selectedStatus, setSelectedStatus] = useState(
localStorage.getItem('selectedStatus') || 'CURRENT',
);
useEffect(() => {
if (isLoggedIn && userData) {
console.log('User is logged in, username:', userData.name);
} else {
console.log('User is not logged in or userData is not available');
}
}, [isLoggedIn, userData]);
const { animeList, loading, error } = useUserAnimeList(
userData?.name,
selectedStatus as MediaListStatus,
);
if (!isLoggedIn)
return Please Log in to view your AniList. ;
if (loading) return Loading... ;
if (error)
return (
Error loading anime list: {error.message}
);
const animeData = animeList.lists.flatMap((list) =>
list.entries.map((entry) => ({
id: entry.media.id,
image: entry.media.coverImage.large,
title: {
romaji: entry.media.title.romaji,
english: entry.media.title.english || entry.media.title.romaji,
},
status: apiStatusToUserFriendly[entry.media.status] || 'Unknown',
rating: entry.media.averageScore,
releaseDate: entry.media.startDate.year,
totalEpisodes: entry.media.episodes,
color: entry.media.coverImage.color,
type: entry.media.format,
})),
);
const handleStatusChange = (e: React.ChangeEvent) => {
const newStatus = e.target.value;
setSelectedStatus(newStatus);
localStorage.setItem('selectedStatus', newStatus);
};
return (
AniList
{Object.values(MediaListStatus).map((status) => (
{statusLabels[status] || status}
))}
{animeData.length > 0 ? (
{}}
/>
) : (
No Results
)}
);
};
================================================
FILE: src/components/ShortcutsPopup.tsx
================================================
import styled from 'styled-components';
import { useState, useEffect } from 'react';
import { FaTimes } from 'react-icons/fa';
const Overlay = styled.table`
font-size: 0.85rem;
animation: fadeIn 0.3s ease-in-out;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
`;
const TableCell = styled.td`
padding: 0.5rem;
border-bottom: 1px solid rgba(128, 128, 128, 0.3);
`;
const Column1 = styled(TableCell)`
padding-right: 15rem;
opacity: 0.7;
`;
const Column2 = styled(TableCell)`
padding-right: 5rem;
`;
const CloseButton = styled.button`
position: absolute;
top: 1.25rem;
right: 1.25rem;
padding: 0.5rem;
padding-left: 0.6rem;
background-color: var(--global-primary-bg-tr);
color: var(--global-text);
border: none;
border-radius: var(--global-border-radius);
cursor: pointer;
outline: none;
transition: 0.1s ease-in-out;
display: flex;
justify-content: center;
align-items: center;
&:hover {
transform: scale(1.06);
svg {
padding-bottom: 0.1rem;
}
}
&:active,
&:focus {
transform: scale(0.94);
}
`;
const PopUp = styled.thead`
display: flex;
flex-direction: column;
gap: 1rem;
animation: slideUp 0.3s ease-in-out;
position: relative;
border-radius: var(--global-border-radius);
padding: 1rem;
line-height: 1.8rem;
background: var(--global-primary-bg);
z-index: 1100;
overflow: auto;
max-height: 90vh;
max-width: 90vw;
`;
const KeyboardShortcutsPopup = ({ onClose }: { onClose: () => void }) => {
return (
e.stopPropagation()}>
Keyboard Shortcuts (shift+/)
Play/Pause Toggle
K / Space
Seek Backward 10 Seconds
J
Seek Forward 10 Seconds
L
Toggle Fullscreen
F
Toggle Mute
M
Previous Episode
(SHIFT+P)
Next Episode
(SHIFT+N)
Increase Volume
Arrow Up
Decrease Volume
Arrow Down
Seek Forward 5 Seconds
Arrow Right
Seek Backward 5 Seconds
Arrow Left
Increase Playback Speed
> (SHIFT+,)
Decrease Playback Speed
< (SHIFT+.)
Jump to Percentage (0-90%)
0-9
);
};
export const ShortcutsPopup = () => {
const [showPopup, setShowPopup] = useState(false);
useEffect(() => {
const togglePopupWithShortcut = (e: KeyboardEvent) => {
if (
e.target &&
['INPUT', 'TEXTAREA', 'SELECT'].includes((e.target as Element).tagName)
) {
return;
}
if (e.shiftKey && e.key === '?') {
e.preventDefault();
setShowPopup(!showPopup);
} else if (e.key === 'Escape') {
setShowPopup(false);
}
};
window.addEventListener('keydown', togglePopupWithShortcut);
return () => {
window.removeEventListener('keydown', togglePopupWithShortcut);
};
}, [showPopup]);
const togglePopup = () => setShowPopup(!showPopup);
return (
{showPopup && }
);
};
================================================
FILE: src/components/Skeletons/Skeletons.tsx
================================================
import React from 'react';
import styled, { keyframes, css } from 'styled-components';
const pulseAnimation = keyframes`
0%, 100% { background-color: var(--global-primary-skeleton); }
50% { background-color: var(--global-secondary-skeleton); }
`;
const popInAnimation = keyframes`
0%, 100% { opacity: 0; transform: scale(0.95); }
50% { opacity: 1; transform: scale(1); }
75% { opacity: 0.5; transform: scale(1); }
`;
const playerPopInAnimation = keyframes`
0% { opacity: 0; transform: scale(0.9); }
100% { opacity: 1; transform: scale(1); }
`;
const SkeletonPulse = keyframes`
0%, 100% { background-color: var(--global-primary-skeleton); }
25%, 75% { background-color: var(--global-secondary-skeleton); }
50% { background-color: var(--global-primary-skeleton); }
`;
const animationMixin = css`
animation:
${pulseAnimation} 1s infinite,
${popInAnimation} 1s infinite;
`;
const BaseSkeleton = styled.div`
background: var(--global-primary-skeleton);
border-radius: var(--global-border-radius);
`;
const SkeletonCards = styled(BaseSkeleton)`
width: 100%;
height: 0;
padding-top: calc(100% * 184 / 133);
margin-bottom: 5.1rem;
${animationMixin};
`;
const SkeletonTitle = styled(BaseSkeleton)`
height: 1.4rem;
margin: 0.5rem 0 0.3rem;
${animationMixin};
`;
const SkeletonDetails = styled(SkeletonTitle)`
height: 1.3rem;
width: 80%;
`;
export const SkeletonCard = React.memo(() => (
));
const SkeletonSlides = styled(BaseSkeleton)<{ loading?: boolean }>`
width: 100%;
height: 24rem;
${({ loading }) => !loading && animationMixin}
@media (max-width: 1000px) {
height: 20rem;
}
@media (max-width: 500px) {
height: 18rem;
}
`;
export const SkeletonSlide: React.FC<{ loading?: boolean }> = React.memo(
({ loading }) => (
),
);
const SkeletonContainer = styled.div`
display: flex;
flex-direction: column;
gap: 0.2rem;
`;
const PlayerSkeleton = styled(BaseSkeleton)`
position: relative;
padding-top: 56.25%;
width: 100%;
height: 0;
animation:
${SkeletonPulse} 2.5s ease-in-out infinite,
${playerPopInAnimation} 0.5s ease-in-out;
`;
const PlayerButtons = styled(BaseSkeleton)`
position: relative;
height: 23px;
width: 100%;
animation:
${SkeletonPulse} 2.5s ease-in-out infinite,
${playerPopInAnimation} 0.5s ease-in-out;
`;
export const SkeletonPlayer = React.memo(() => (
));
const SkeletonImage = styled(BaseSkeleton)`
width: 100%;
height: 100%;
`;
================================================
FILE: src/components/ThemeContext.tsx
================================================
import React, {
createContext,
useContext,
useState,
useEffect,
ReactNode,
} from 'react';
type ThemeContextType = {
isDarkMode: boolean;
toggleTheme: () => void;
};
const ThemeContext = createContext(undefined);
export const useTheme = () => useContext(ThemeContext)!;
type ThemeProviderProps = {
children: ReactNode;
};
export const ThemeProvider = ({ children }: ThemeProviderProps) => {
const [isDarkMode, setIsDarkMode] = useState(() =>
getInitialThemePreference(),
);
useEffect(() => {
document.documentElement.classList.toggle('dark-mode', isDarkMode);
localStorage.setItem('themePreference', isDarkMode ? 'dark' : 'light');
}, [isDarkMode]);
const toggleTheme = () => {
setIsDarkMode(!isDarkMode);
};
return (
{children}
);
};
const getInitialThemePreference = (): boolean => {
const storedThemePreference = localStorage.getItem('themePreference');
if (storedThemePreference) {
return storedThemePreference === 'dark';
}
return (
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
);
};
================================================
FILE: src/components/Watch/AnimeDataList.tsx
================================================
import React from 'react';
import styled from 'styled-components';
import { Link } from 'react-router-dom';
import { TbCards } from 'react-icons/tb';
import { FaStar } from 'react-icons/fa';
import { Anime, StatusIndicator } from '../../index';
const Sidebar = styled.div`
display: flex;
flex-direction: column;
gap: 1rem;
transition: 0.2s ease-in-out;
.Section-Title {
margin: 0;
padding: 0 0 0.5rem 0;
color: var(--global-text);
font-size: 1.25rem;
font-weight: bold;
}
`;
const SidebarContainer = styled.div`
padding: 0.75rem;
background-color: var(--global-div-tr);
border-radius: var(--global-border-radius);
`;
const Card = styled.div`
display: flex;
background-color: var(--global-div);
border-radius: var(--global-border-radius);
align-items: center;
overflow: hidden;
gap: 0.5rem;
cursor: pointer;
margin-bottom: 0.5rem;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
animation: slideUp 0.5s ease-in-out;
animation-fill-mode: backwards;
transition:
background-color 0s ease-in-out,
margin-left 0.2s ease-in-out 0.1s;
&:hover,
&:active,
&:focus {
background-color: var(--global-div-tr);
margin-left: 0.35rem;
@media (max-width: 500px) {
margin-left: unset;
}
`;
const AnimeImage = styled.img`
width: 4.25rem;
height: 6rem;
object-fit: cover;
border-radius: var(--global-border-radius);
`;
const Info = styled.div``;
const TitleWithDot = styled.div`
display: flex;
align-items: center;
padding: 0.5rem;
margin-top: 0.35rem;
gap: 0.4rem;
border-radius: var(--global-border-radius);
cursor: pointer;
transition: background 0.2s ease;
`;
const Title = styled.p`
top: 0;
margin-bottom: 0.5rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 0.9rem;
margin: 0;
`;
const Details = styled.p`
font-size: 0.75rem;
margin: 0;
color: rgba(102, 102, 102, 0.75);
svg {
margin-left: 0.4rem;
}
`;
export const AnimeDataList: React.FC<{ animeData: Anime }> = ({
animeData,
}) => {
const filteredRecommendations = animeData.recommendations.filter((rec) =>
['OVA', 'SPECIAL', 'TV', 'MOVIE', 'ONA', 'NOVEL'].includes(rec.type || ''),
);
const filteredRelations = animeData.relations.filter((rel) =>
['OVA', 'SPECIAL', 'TV', 'MOVIE', 'ONA', 'NOVEL', 'MANGA'].includes(
rel.type || '',
),
);
return (
{filteredRelations.length > 0 && (
<>
RELATED
{filteredRelations
.slice(0, window.innerWidth > 500 ? 5 : 3)
.map((relation, index) => (
{relation.title.english ??
relation.title.romaji ??
relation.title.userPreferred}
{/* Conditionally render each piece of detail only if it's not null or empty */}
{relation.type && `${relation.type} `}
{relation.episodes && (
<>
{' '}
{`${relation.episodes} `}
>
)}
{relation.rating && (
<>
{' '}
{`${relation.rating} `}
>
)}
))}
>
)}
{filteredRecommendations.length > 0 && (
<>
RECOMMENDED
{filteredRecommendations
.slice(0, window.innerWidth > 500 ? 5 : 3)
.map((recommendation, index) => (
{recommendation.title.english ??
recommendation.title.romaji ??
recommendation.title.userPreferred}
{/* Similar conditional rendering for recommendation details */}
{recommendation.type && `${recommendation.type} `}
{recommendation.episodes && (
<>
{' '}
{`${recommendation.episodes} `}
>
)}
{recommendation.rating && (
<>
{' '}
{`${recommendation.rating} `}
>
)}
))}
>
)}
);
};
================================================
FILE: src/components/Watch/EpisodeList.tsx
================================================
import React, {
useState,
useMemo,
useCallback,
useEffect,
useRef,
} from 'react';
import styled from 'styled-components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faPlay,
faThList,
faTh,
faSearch,
faImage,
} from '@fortawesome/free-solid-svg-icons';
import { Episode } from '../../index';
interface Props {
animeId: string | undefined;
episodes: Episode[];
selectedEpisodeId: string;
onEpisodeSelect: (id: string) => void;
maxListHeight: string;
}
// Styled components for the episode list
const ListContainer = styled.div<{ $maxHeight: string }>`
background-color: var(--global-secondary-bg);
color: var(--global-text);
border-radius: var(--global-border-radius);
overflow: hidden;
flex-grow: 1;
display: flex;
flex-direction: column;
max-height: ${({ $maxHeight }) => $maxHeight};
@media (max-width: 1000px) {
max-height: 18rem;
}
@media (max-width: 500px) {
max-height: ${({ $maxHeight }) => $maxHeight};
}
`;
const EpisodeGrid = styled.div<{ $isRowLayout: boolean }>`
display: grid;
grid-template-columns: ${({ $isRowLayout }) =>
$isRowLayout ? '1fr' : 'repeat(auto-fill, minmax(4rem, 1fr))'};
gap: 0.29rem;
padding: 0.4rem;
overflow-y: auto;
flex-grow: 1;
`;
const EpisodeImage = styled.img`
max-width: 250px;
max-height: 150px;
height: auto;
margin-top: 0.5rem;
border-radius: var(--global-border-radius);
@media (max-width: 500px) {
max-width: 125px;
max-height: 80px;
}
`;
const ListItem = styled.button<{
$isSelected: boolean;
$isRowLayout: boolean;
$isWatched: boolean;
}>`
transition:
padding 0.3s ease-in-out,
transform 0.3s ease-in-out;
animation: popIn 0.3s ease-in-out;
background-color: ${({ $isSelected, $isWatched }) =>
$isSelected
? $isWatched
? 'var(--primary-accent)' // Selected and watched
: 'var(--primary-accent-bg)' // Selected but not watched
: $isWatched
? 'var(--primary-accent-bg); filter: brightness(0.8);' // Not selected but watched
: 'var(--global-tertiary-bg)'};
border: none;
border-radius: var(--global-border-radius);
color: ${({ $isSelected, $isWatched }) =>
$isSelected
? $isWatched
? 'var(--global-text)' // Selected and watched
: 'var(--global-text)' // Selected but not watched
: $isWatched
? 'var(--primary-accent); filter: brightness(0.8);' // Not selected but watched
: 'grey'}; // Not selected and not watched
padding: ${({ $isRowLayout }) =>
$isRowLayout ? '0.6rem 0.5rem' : '0.4rem 0'};
text-align: ${({ $isRowLayout }) => ($isRowLayout ? 'left' : 'center')};
cursor: pointer;
justify-content: ${({ $isRowLayout }) =>
$isRowLayout ? 'space-between' : 'center'};
align-items: center;
&:hover,
&:active,
&:focus {
${({ $isSelected, $isWatched }) =>
$isSelected
? $isWatched
? 'filter: brightness(1.1)' // Selected and watched
: 'filter: brightness(1.1)' // Selected but not watched
: $isWatched
? 'filter: brightness(1.1)' // Not selected but watched
: 'background-color: var(--global-button-hover-bg); filter: brightness(1.05); color: #FFFFFF'};
padding-left: ${({ $isRowLayout }) => ($isRowLayout ? '1rem' : '')};
}
`;
const ControlsContainer = styled.div`
display: flex;
align-items: center;
background-color: var(--global-secondary-bg);
border-bottom: 1px solid var(--global-shadow);
padding: 0.25rem 0;
`;
const SelectInterval = styled.select`
padding: 0.5rem;
background-color: var(--global-secondary-bg);
color: var(--global-text);
border: none;
border-radius: var(--global-border-radius);
`;
const LayoutToggle = styled.button`
background-color: var(--global-secondary-bg);
border: 1px solid var(--global-shadow);
padding: 0.5rem;
margin-right: 0.5rem;
cursor: pointer;
color: var(--global-text);
border-radius: var(--global-border-radius);
transition:
background-color 0.15s,
color 0.15s;
&:hover,
&:active,
&:focus {
background-color: var(--global-button-hover-bg);
}
`;
const SearchContainer = styled.div`
display: flex;
align-items: center;
background-color: var(--global-secondary-bg);
border: 1px solid var(--global-shadow);
padding: 0.5rem;
gap: 0.25rem;
margin: 0 0.5rem;
border-radius: var(--global-border-radius);
transition:
background-color 0.15s,
color 0.15s;
&:hover,
&:active,
&:focus {
background-color: var(--global-button-hover-bg);
}
`;
const SearchInput = styled.input`
border: none;
background-color: transparent;
color: var(--global-text);
outline: none;
width: 100%;
&::placeholder {
color: var(--global-placeholder);
}
`;
const Icon = styled.div`
color: var(--global-text);
opacity: 0.5;
font-size: 0.8rem;
transition: opacity 0.2s;
@media (max-width: 768px) {
display: none; /* Hide on mobile */
}
`;
const EpisodeNumber = styled.span``;
const EpisodeTitle = styled.span`
padding: 0.5rem;
`;
// The updated EpisodeList component
export const EpisodeList: React.FC = ({
animeId,
episodes,
selectedEpisodeId,
onEpisodeSelect,
maxListHeight,
}) => {
// State for interval, layout, user layout preference, search term, and watched episodes
const episodeGridRef = useRef(null);
const episodeRefs = useRef<{ [key: string]: HTMLButtonElement | null }>({});
const [interval, setInterval] = useState<[number, number]>([0, 99]);
const [isRowLayout, setIsRowLayout] = useState(true);
const [userLayoutPreference, setUserLayoutPreference] = useState<
boolean | null
>(null);
const [searchTerm, setSearchTerm] = useState('');
const [watchedEpisodes, setWatchedEpisodes] = useState([]);
const defaultLayoutMode = episodes.every((episode) => episode.title)
? 'list'
: 'grid';
const [displayMode, setDisplayMode] = useState<'list' | 'grid' | 'imageList'>(
() => {
const savedMode = animeId
? localStorage.getItem(`listLayout-[${animeId}]`)
: null;
return (savedMode as 'list' | 'grid' | 'imageList') || defaultLayoutMode;
},
);
const [selectionInitiatedByUser, setSelectionInitiatedByUser] =
useState(false);
// Update local storage when watched episodes change
useEffect(() => {
if (animeId && watchedEpisodes.length > 0) {
localStorage.setItem(
`watched-episodes-${animeId}`,
JSON.stringify(watchedEpisodes),
);
}
}, [animeId, watchedEpisodes]);
// Load watched episodes from local storage when animeId changes
useEffect(() => {
if (animeId) {
localStorage.setItem(`listLayout-[${animeId}]`, displayMode);
const watched = localStorage.getItem('watched-episodes');
if (watched) {
const watchedEpisodesObject = JSON.parse(watched);
const watchedEpisodesForAnime = watchedEpisodesObject[animeId];
if (watchedEpisodesForAnime) {
setWatchedEpisodes(watchedEpisodesForAnime);
}
}
}
}, [animeId]);
// Function to handle episode selection
// Function to mark an episode as watched
const markEpisodeAsWatched = useCallback(
(id: string) => {
if (animeId) {
setWatchedEpisodes((prevWatchedEpisodes) => {
const updatedWatchedEpisodes = [...prevWatchedEpisodes];
const selectedEpisodeIndex = updatedWatchedEpisodes.findIndex(
(episode) => episode.id === id,
);
if (selectedEpisodeIndex === -1) {
const selectedEpisode = episodes.find(
(episode) => episode.id === id,
);
if (selectedEpisode) {
updatedWatchedEpisodes.push(selectedEpisode);
// Update the watched episodes object in local storage
localStorage.setItem(
'watched-episodes',
JSON.stringify({
...JSON.parse(
localStorage.getItem('watched-episodes') || '{}',
),
[animeId]: updatedWatchedEpisodes,
}),
);
return updatedWatchedEpisodes;
}
}
return prevWatchedEpisodes;
});
}
},
[episodes, animeId],
);
const handleEpisodeSelect = useCallback(
(id: string) => {
setSelectionInitiatedByUser(true);
markEpisodeAsWatched(id); // Mark the episode as watched
onEpisodeSelect(id);
},
[onEpisodeSelect, markEpisodeAsWatched],
);
// Update watched episodes when a new episode is selected or visited
useEffect(() => {
if (selectedEpisodeId && !selectionInitiatedByUser) {
markEpisodeAsWatched(selectedEpisodeId);
}
}, [selectedEpisodeId, selectionInitiatedByUser, markEpisodeAsWatched]);
// Generate interval options
const intervalOptions = useMemo(() => {
return episodes.reduce<{ start: number; end: number }[]>(
(options, _, index) => {
if (index % 100 === 0) {
const start = index;
const end = Math.min(index + 99, episodes.length - 1);
options.push({ start, end });
}
return options;
},
[],
);
}, [episodes]);
// Handle interval change
const handleIntervalChange = useCallback(
(e: React.ChangeEvent) => {
const [start, end] = e.target.value.split('-').map(Number);
setInterval([start, end]);
},
[],
);
// Toggle layout preference
const toggleLayoutPreference = useCallback(() => {
setDisplayMode((prevMode) => {
const nextMode =
prevMode === 'list'
? 'grid'
: prevMode === 'grid'
? 'imageList'
: 'list';
if (animeId) {
localStorage.setItem(`listLayout-[${animeId}]`, nextMode);
}
return nextMode;
});
}, [animeId]);
// Filter episodes based on search input
const filteredEpisodes = useMemo(() => {
const searchQuery = searchTerm.toLowerCase();
return episodes.filter(
(episode) =>
episode.title?.toLowerCase().includes(searchQuery) ||
episode.number.toString().includes(searchQuery),
);
}, [episodes, searchTerm]);
// Apply the interval to the filtered episodes
const displayedEpisodes = useMemo(() => {
if (!searchTerm) {
// If there's no search term, apply interval to all episodes
return episodes.slice(interval[0], interval[1] + 1);
}
// If there is a search term, display filtered episodes without applying interval
return filteredEpisodes;
}, [episodes, filteredEpisodes, interval, searchTerm]);
// Determine layout based on episodes and user preference
useEffect(() => {
const allTitlesNull = episodes.every((episode) => episode.title === null);
const defaultLayout = episodes.length <= 26 && !allTitlesNull;
setIsRowLayout(
userLayoutPreference !== null ? userLayoutPreference : defaultLayout,
);
// Find the selected episode
if (!selectionInitiatedByUser) {
const selectedEpisode = episodes.find(
(episode) => episode.id === selectedEpisodeId,
);
if (selectedEpisode) {
// Find the interval containing the selected episode
for (let i = 0; i < intervalOptions.length; i++) {
const { start, end } = intervalOptions[i];
if (
selectedEpisode.number >= start + 1 &&
selectedEpisode.number <= end + 1
) {
setInterval([start, end]);
break;
}
}
}
}
}, [
episodes,
userLayoutPreference,
selectedEpisodeId,
intervalOptions,
selectionInitiatedByUser,
]);
useEffect(() => {
const timer = setTimeout(() => {
if (
selectedEpisodeId &&
episodeRefs.current[selectedEpisodeId] &&
episodeGridRef.current &&
!selectionInitiatedByUser
) {
const episodeElement = episodeRefs.current[selectedEpisodeId];
const container = episodeGridRef.current;
// Ensure episodeElement is not null before proceeding
if (episodeElement && container) {
// Calculate episode's top position relative to the container
const episodeTop =
episodeElement.getBoundingClientRect().top -
container.getBoundingClientRect().top;
// Calculate the desired scroll position to center the episode in the container
const episodeHeight = episodeElement.offsetHeight;
const containerHeight = container.offsetHeight;
const desiredScrollPosition =
episodeTop + episodeHeight / 2 - containerHeight / 2;
container.scrollTo({
top: desiredScrollPosition,
behavior: 'smooth',
});
setSelectionInitiatedByUser(false);
}
}
}, 100); // A delay ensures the layout has stabilized, especially after dynamic content loading.
return () => clearTimeout(timer);
}, [selectedEpisodeId, episodes, displayMode, selectionInitiatedByUser]);
// Render the EpisodeList component
return (
{intervalOptions.map(({ start, end }, index) => (
Episodes {start + 1} - {end + 1}
))}
setSearchTerm(e.target.value)}
/>
{displayMode === 'list' && }
{displayMode === 'grid' && }
{displayMode === 'imageList' && }
{displayedEpisodes.map((episode) => {
const $isSelected = episode.id === selectedEpisodeId;
const $isWatched = watchedEpisodes.some((e) => e.id === episode.id);
return (
handleEpisodeSelect(episode.id)}
aria-selected={$isSelected}
ref={(el) => (episodeRefs.current[episode.id] = el)} // Reference to each episode's button
>
{displayMode === 'imageList' ? (
<>
{episode.number}.
{episode.title}
>
) : displayMode === 'grid' ? (
<>
{$isSelected ? (
) : (
{episode.number}
)}
>
) : (
// Render for 'list' layout
<>
{episode.number}.
{episode.title}
{$isSelected && }
>
)}
);
})}
);
};
================================================
FILE: src/components/Watch/Seasons.tsx
================================================
import React from 'react';
import styled from 'styled-components';
import { Link } from 'react-router-dom';
import { Relation } from '../../index';
const SeasonCardContainer = styled.div`
display: flex;
flex-wrap: wrap;
justify-content: left;
gap: 1rem;
margin-top: 1rem;
margin-bottom: 1rem;
@media (max-width: 500px) {
justify-content: center;
}
`;
const SeasonCard = styled(Link)`
background-size: cover;
background-position: center;
padding: 0.9rem;
height: 6rem;
width: 20rem;
@media (max-width: 500px) {
height: 3rem;
width: 8rem;
padding: 1.3rem;
}
position: relative;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
border-radius: 0.3rem;
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
cursor: pointer;
text-decoration: none;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
border-radius: var(--global-border-radius);
z-index: 1;
}
transition: transform 0.2s ease-in-out;
&:hover,
&:active &:focus {
transform: translateY(-5px);
@media (max-width: 500px) {
transform: none;
}
}
`;
const Content = styled.div`
position: relative;
z-index: 2;
`;
const SeasonName = styled.div`
font-size: 0.9rem;
@media (max-width: 500px) {
display: none;
width: 8rem;
font-size: 0.8rem;
}
color: white;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
`;
const RelationType = styled.div`
font-size: 1.3rem;
@media (max-width: 500px) {
font-size: 1.1rem;
width: 8rem;
margin-bottom: 0.25rem;
}
font-weight: bold;
color: white;
border-radius: var(--global-border-radius);
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.5);
margin-bottom: 0.75rem;
`;
export const Seasons: React.FC<{ relations: Relation[] }> = ({ relations }) => {
const sortedRelations = relations.sort((a, b) => {
if (a.relationType === 'PREQUEL' && b.relationType !== 'PREQUEL') {
return -1;
}
if (a.relationType !== 'PREQUEL' && b.relationType === 'PREQUEL') {
return 1;
}
return 0;
});
return (
{sortedRelations.map((relation) => (
{relation.relationType}
{relation.title.english ||
relation.title.romaji ||
relation.title.userPreferred}
))}
);
};
================================================
FILE: src/components/Watch/Video/EmbedPlayer.tsx
================================================
import React from 'react';
import styled from 'styled-components';
const Container = styled.div``;
const Iframe = styled.iframe`
border-radius: var(--global-border-radius);
border: none;
min-height: 16.24rem;
`;
export const EmbedPlayer: React.FC<{ src: string }> = ({ src }) => {
return (
);
};
================================================
FILE: src/components/Watch/Video/MediaSource.tsx
================================================
import React, { useState } from 'react';
import styled from 'styled-components';
import {
FaMicrophone,
FaClosedCaptioning,
FaBell,
FaDownload,
FaShare,
} from 'react-icons/fa';
// Props interface
interface MediaSourceProps {
sourceType: string;
setSourceType: (sourceType: string) => void;
language: string;
setLanguage: (language: string) => void;
downloadLink: string;
episodeId?: string;
airingTime?: string;
nextEpisodenumber?: string;
}
// Adjust the Container for responsive layout
const UpdatedContainer = styled.div`
justify-content: center;
margin-top: 1rem;
gap: 1rem;
display: flex;
@media (max-width: 1000px) {
flex-direction: column;
}
`;
const Table = styled.table`
font-size: 0.9rem;
border-collapse: collapse;
font-weight: bold;
margin-left: auto;
margin-right: auto;
`;
const TableRow = styled.tr``;
const TableCell = styled.td`
padding: 0.35rem;
@media (max-width: 500px) {
text-align: center;
font-size: 0.8rem;
}
svg {
margin-bottom: -0.1rem;
@media (max-width: 500px) {
margin-bottom: 0rem;
}
}
`;
const ButtonWrapper = styled.div`
width: 90px; // Or a specific pixel width, if preferred
display: flex;
justify-content: center;
gap: 0.5rem;
`;
const ButtonBase = styled.button`
flex: 1; // Make the button expand to fill the wrapper
padding: 0.5rem;
border: none;
font-weight: bold;
border-radius: var(--global-border-radius);
cursor: pointer;
background-color: var(--global-div);
color: var(--global-text);
transition:
background-color 0.2s ease,
transform 0.2s ease-in-out;
text-align: center;
&:hover {
background-color: var(--primary-accent);
transform: scale(1.025);
}
&:active {
transform: scale(0.975);
}
`;
const Button = styled(ButtonBase)`
&.active {
background-color: var(--primary-accent);
}
`;
const DownloadLink = styled.a`
display: inline-flex; // Use inline-flex to easily center the icon
align-items: center; // Align the icon vertically center
margin-left: 0.5rem;
padding: 0.5rem;
gap: 0.25rem;
font-size: 0.9rem;
font-weight: bold;
border: none;
border-radius: var(--global-border-radius);
cursor: pointer;
background-color: var(--global-div);
color: var(--global-text);
text-align: center;
text-decoration: none;
transition:
background-color 0.3s ease,
transform 0.2s ease-in-out;
svg {
font-size: 0.85rem; // Adjust icon size
}
&:hover {
background-color: var(--primary-accent);
transform: scale(1.025);
}
&:active {
transform: scale(0.975);
}
`;
const ShareButton = styled(ButtonBase)`
display: inline-flex; // Align items in a row
align-items: center; // Center items vertically
margin-left: 0.5rem;
padding: 0.5rem;
gap: 0.25rem;
font-size: 0.9rem;
border: none;
border-radius: var(--global-border-radius);
cursor: pointer;
background-color: var(--global-div);
color: var(--global-text);
text-decoration: none;
svg {
font-size: 0.85rem; // Adjust icon size
}
`;
const ResponsiveTableContainer = styled.div`
background-color: var(--global-div-tr);
padding: 0.75rem;
border-radius: var(--global-border-radius);
@media (max-width: 500px) {
display: block;
}
`;
const EpisodeInfoColumn = styled.div`
flex-grow: 1;
display: block;
background-color: var(--global-div-tr);
border-radius: var(--global-border-radius);
padding: 0.75rem;
@media (max-width: 1000px) {
display: block;
margin-right: 0rem;
}
p {
font-size: 0.9rem;
margin: 0;
}
h4 {
margin: 0rem;
font-size: 1.15rem;
margin-bottom: 1rem;
}
@media (max-width: 500px) {
p {
font-size: 0.8rem;
margin: 0rem;
}
h4 {
font-size: 1rem;
margin-bottom: 0rem;
}
}
`;
export const MediaSource: React.FC = ({
sourceType,
setSourceType,
language,
setLanguage,
downloadLink,
episodeId,
airingTime,
nextEpisodenumber,
}) => {
const [isCopied, setIsCopied] = useState(false);
const handleShareClick = () => {
navigator.clipboard.writeText(window.location.href);
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, 2000);
};
return (
{episodeId ? (
<>
You're watching Episode {episodeId}
{isCopied && Copied to clipboard!
}
If current servers don't work, please try other servers.
>
) : (
'Loading episode information...'
)}
{airingTime && (
<>
Episode {nextEpisodenumber} will air in{' '}
{airingTime} .
>
)}
Sub
{
setSourceType('default');
setLanguage('sub');
}}
>
Default
{
setSourceType('vidstreaming');
setLanguage('sub');
}}
>
Vidstream
{
setSourceType('gogo');
setLanguage('sub');
}}
>
Gogo
Dub
{
setSourceType('default');
setLanguage('dub');
}}
>
Default
{
setSourceType('vidstreaming');
setLanguage('dub');
}}
>
Vidstream
{
setSourceType('gogo');
setLanguage('dub');
}}
>
Gogo
);
};
================================================
FILE: src/components/Watch/Video/Player.tsx
================================================
import { useEffect, useRef, useState } from 'react';
import './PlayerStyles.css';
import {
isHLSProvider,
MediaPlayer,
MediaProvider,
Poster,
Track,
type MediaProviderAdapter,
type MediaProviderChangeEvent,
type MediaPlayerInstance,
} from '@vidstack/react';
import styled from 'styled-components';
import {
fetchSkipTimes,
fetchAnimeStreamingLinks,
useSettings,
} from '../../../index';
import {
DefaultAudioLayout,
defaultLayoutIcons,
DefaultVideoLayout,
} from '@vidstack/react/player/layouts/default';
import { TbPlayerTrackPrev, TbPlayerTrackNext } from 'react-icons/tb';
import { FaCheck } from 'react-icons/fa6';
import { RiCheckboxBlankFill } from 'react-icons/ri';
const Button = styled.button<{ $autoskip?: boolean }>`
padding: 0.25rem;
font-size: 0.8rem;
border: none;
margin-right: 0.25rem;
border-radius: var(--global-border-radius);
cursor: pointer;
background-color: var(--global-div);
color: var(--global-text);
svg {
margin-bottom: -0.1rem;
color: grey;
}
@media (max-width: 500px) {
font-size: 0.7rem;
}
&.active {
background-color: var(--primary-accent);
}
${({ $autoskip }) =>
$autoskip &&
`
color: #d69e00;
svg {
color: #d69e00;
}
`}
`;
type PlayerProps = {
episodeId: string;
banner?: string;
malId?: string;
updateDownloadLink: (link: string) => void;
onEpisodeEnd: () => Promise;
onPrevEpisode: () => void;
onNextEpisode: () => void;
animeTitle?: string;
};
type StreamingSource = {
url: string;
quality: string;
};
type SkipTime = {
interval: {
startTime: number;
endTime: number;
};
skipType: string;
};
type FetchSkipTimesResponse = {
results: SkipTime[];
};
export function Player({
episodeId,
banner,
malId,
updateDownloadLink,
onEpisodeEnd,
onPrevEpisode,
onNextEpisode,
animeTitle,
}: PlayerProps) {
const player = useRef(null);
const [src, setSrc] = useState('');
const [vttUrl, setVttUrl] = useState('');
const [currentTime, setCurrentTime] = useState(0);
const [skipTimes, setSkipTimes] = useState([]);
const [totalDuration, setTotalDuration] = useState(0);
const [vttGenerated, setVttGenerated] = useState(false);
const episodeNumber = getEpisodeNumber(episodeId);
const animeVideoTitle = animeTitle;
const { settings, setSettings } = useSettings();
const { autoPlay, autoNext, autoSkip } = settings;
useEffect(() => {
setCurrentTime(parseFloat(localStorage.getItem('currentTime') || '0'));
fetchAndSetAnimeSource();
fetchAndProcessSkipTimes();
return () => {
if (vttUrl) URL.revokeObjectURL(vttUrl);
};
}, [episodeId, malId, updateDownloadLink]);
useEffect(() => {
if (autoPlay && player.current) {
player.current
.play()
.catch((e) =>
console.log('Playback failed to start automatically:', e),
);
}
}, [autoPlay, src]);
useEffect(() => {
if (player.current && currentTime) {
player.current.currentTime = currentTime;
}
}, [currentTime]);
function onProviderChange(
provider: MediaProviderAdapter | null,
_nativeEvent: MediaProviderChangeEvent,
) {
if (isHLSProvider(provider)) {
provider.config = {};
}
}
function onLoadedMetadata() {
if (player.current) {
setTotalDuration(player.current.duration);
}
}
function onTimeUpdate() {
if (player.current) {
const currentTime = player.current.currentTime;
const duration = player.current.duration || 1;
const playbackPercentage = (currentTime / duration) * 100;
const playbackInfo = {
currentTime,
playbackPercentage,
};
const allPlaybackInfo = JSON.parse(
localStorage.getItem('all_episode_times') || '{}',
);
allPlaybackInfo[episodeId] = playbackInfo;
localStorage.setItem(
'all_episode_times',
JSON.stringify(allPlaybackInfo),
);
if (autoSkip && skipTimes.length) {
const skipInterval = skipTimes.find(
({ interval }) =>
currentTime >= interval.startTime && currentTime < interval.endTime,
);
if (skipInterval) {
player.current.currentTime = skipInterval.interval.endTime;
}
}
}
}
function generateWebVTTFromSkipTimes(
skipTimes: FetchSkipTimesResponse,
totalDuration: number,
): string {
let vttString = 'WEBVTT\n\n';
let previousEndTime = 0;
const sortedSkipTimes = skipTimes.results.sort(
(a, b) => a.interval.startTime - b.interval.startTime,
);
sortedSkipTimes.forEach((skipTime, index) => {
const { startTime, endTime } = skipTime.interval;
const skipType =
skipTime.skipType.toUpperCase() === 'OP' ? 'Opening' : 'Outro';
// Insert default title chapter before this skip time if there's a gap
if (previousEndTime < startTime) {
vttString += `${formatTime(previousEndTime)} --> ${formatTime(startTime)}\n`;
vttString += `${animeVideoTitle} - Episode ${episodeNumber}\n\n`;
}
// Insert this skip time
vttString += `${formatTime(startTime)} --> ${formatTime(endTime)}\n`;
vttString += `${skipType}\n\n`;
previousEndTime = endTime;
// Insert default title chapter after the last skip time
if (index === sortedSkipTimes.length - 1 && endTime < totalDuration) {
vttString += `${formatTime(endTime)} --> ${formatTime(totalDuration)}\n`;
vttString += `${animeVideoTitle} - Episode ${episodeNumber}\n\n`;
}
});
return vttString;
}
async function fetchAndProcessSkipTimes() {
if (malId && episodeId) {
const episodeNumber = getEpisodeNumber(episodeId);
try {
const response: FetchSkipTimesResponse = await fetchSkipTimes({
malId: malId.toString(),
episodeNumber,
});
const filteredSkipTimes = response.results.filter(
({ skipType }) => skipType === 'op' || skipType === 'ed',
);
if (!vttGenerated) {
const vttContent = generateWebVTTFromSkipTimes(
{ results: filteredSkipTimes },
totalDuration,
);
const blob = new Blob([vttContent], { type: 'text/vtt' });
const vttBlobUrl = URL.createObjectURL(blob);
setVttUrl(vttBlobUrl);
setSkipTimes(filteredSkipTimes);
setVttGenerated(true);
}
} catch (error) {
console.error('Failed to fetch skip times', error);
}
}
}
async function fetchAndSetAnimeSource() {
try {
const response = await fetchAnimeStreamingLinks(episodeId);
const backupSource = response.sources.find(
(source: StreamingSource) => source.quality === 'default',
);
if (backupSource) {
setSrc(backupSource.url);
updateDownloadLink(response.download);
} else {
console.error('Backup source not found');
}
} catch (error) {
console.error('Failed to fetch anime streaming links', error);
}
}
function formatTime(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
}
function getEpisodeNumber(id: string): string {
const parts = id.split('-');
return parts[parts.length - 1];
}
const toggleAutoPlay = () =>
setSettings({ ...settings, autoPlay: !autoPlay });
const toggleAutoNext = () =>
setSettings({ ...settings, autoNext: !autoNext });
const toggleAutoSkip = () =>
setSettings({ ...settings, autoSkip: !autoSkip });
const handlePlaybackEnded = async () => {
if (!autoNext) return;
try {
player.current?.pause();
await new Promise((resolve) => setTimeout(resolve, 200)); // Delay for transition
await onEpisodeEnd();
} catch (error) {
console.error('Error moving to the next episode:', error);
}
};
return (
{vttUrl && (
)}
{autoPlay ? : } Autoplay
{autoSkip ? : } Auto Skip
Prev
Next
{autoNext ? : } Auto Next
);
}
================================================
FILE: src/components/Watch/Video/PlayerStyles.css
================================================
@import '@vidstack/react/player/styles/default/theme.css';
@import '@vidstack/react/player/styles/default/layouts/audio.css';
@import '@vidstack/react/player/styles/default/layouts/video.css';
.player {
--brand-color: #f5f5f5;
--focus-color: #4e9cf6;
--audio-brand: var(--brand-color);
--audio-focus-ring-color: var(--focus-color);
--audio-border-radius: var(--global-border-radius);
--video-brand: var(--brand-color);
--video-focus-ring-color: var(--focus-color);
--video-border-radius: var(--global-border-radius);
--video-border: none;
/* 👉 https://vidstack.io/docs/player/components/layouts/default#css-variables for more. */
}
.player[data-view-type='audio'] .vds-poster {
display: none;
border-radius: 5rem;
}
.player[data-view-type='video'] {
aspect-ratio: 16 /9;
}
.src-buttons {
display: flex;
align-items: center;
justify-content: space-evenly;
margin-top: 40px;
margin-inline: auto;
max-width: 300px;
}
.vds-poster {
object-fit: cover;
}
================================================
FILE: src/components/Watch/WatchAnimeData.tsx
================================================
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import { Seasons, Anime } from '../../index';
import { SiMyanimelist, SiAnilist } from 'react-icons/si';
const AnimeDataContainer = styled.div`
margin-bottom: 1.5rem;
@media (max-width: 1000px) {
margin-bottom: 0rem;
}
`;
const AnimeDataContainerTop = styled.div`
border-radius: var(--global-border-radius);
background-color: var(--global-div-tr);
margin: 1rem 0;
padding: 0.75rem;
color: var(--global-text);
align-items: center;
flex-direction: row;
align-items: flex-start;
display: flex;
`;
const AnimeDataContainerMiddle = styled.div`
border-radius: var(--global-border-radius);
padding-top: 0.6rem;
color: var(--global-text);
align-items: center;
flex-direction: row;
align-items: flex-start;
display: flex;
@media (max-width: 500px) {
padding-top: 0.4rem;
}
`;
const AnimeDataContainerBottom = styled.div`
margin-top: 0.6rem;
@media (max-width: 750px) {
margin-top: 0rem;
}
`;
const ParentContainer = styled.div`
display: grid;
grid-template-columns: 1fr; // Default to single column for narrow screens
@media (min-width: 750px) {
grid-template-columns: 1.2fr 1fr; // Switch to two columns on wider screens
}
@media (min-width: 1500px) {
grid-template-columns: 1.25fr 1fr; // Switch to two columns on wider screens
}
`;
const AnimeDataText = styled.div`
text-align: left;
font-size: 0.8rem;
.anime-title {
line-height: 1.6rem;
font-size: 1.5rem;
font-weight: bold;
color: var(--global-text);
margin-bottom: 0.5rem;
@media (max-width: 500px) {
font-size: 1.25rem;
margin-bottom: 0.2rem;
}
}
.anime-title-romaji {
font-style: italic;
margin-top: 0rem;
line-height: 0.6rem;
margin-bottom: 0.5rem;
@media (max-width: 500px) {
line-height: 1rem;
margin-bottom: 0.25rem;
}
}
p {
color: #828181;
margin-top: 0rem;
margin-bottom: 0.2rem;
line-height: 1.3rem;
@media (max-width: 500px) {
line-height: 1rem;
}
}
.Description {
line-height: 1rem;
max-width: 50rem;
font-size: 0.9rem;
}
strong {
color: var(--global-text);
}
.Seasons-Sections-Titles {
color: var(--global-text);
margin-top: 1rem;
font-size: 1.25rem;
font-weight: bold;
}
`;
const AnimeInfoImage = styled.img`
border-radius: var(--global-border-radius);
max-height: 15rem;
width: 10.5rem;
margin-right: 1rem;
margin-bottom: 0.5rem;
@media (max-width: 500px) {
max-height: 12rem;
width: 8.5rem;
}
`;
const Button = styled.button`
padding: 0.5rem 0.6rem;
background-color: var(--primary-accent);
color: white;
border: none;
border-radius: var(--global-border-radius);
cursor: pointer;
transition: background-color 0.3s ease;
outline: none;
&:hover,
&:active,
&:focus {
background-color: var(--primary-accent-bg);
}
@media (max-width: 1000px) {
display: block;
margin: 0 auto;
margin-bottom: 0.5rem;
}
`;
const ShowTrailerButton = styled(Button)`
margin-right: 1rem;
padding: 0rem;
width: 10.5rem; //same as anime picture width.
background-color: var(--global-div);
transition:
background-color 0.3s ease,
transform 0.2s ease-in-out;
color: var(--global-text);
font-size: 0.85rem;
margin-bottom: 0.5rem;
&:hover,
&:active,
&:focus {
background-color: var(--primary-accent);
z-index: 2;
}
@media (max-width: 500px) {
font-size: 0.8rem;
width: 8.5rem;
}
`;
const MalAniContainer = styled.div`
display: flex; /* or grid */
gap: 0.5rem;
margin-right: 1rem;
`;
const MalAnilistSvg = styled.div`
height: 2.5rem;
width: 5rem;
border-radius: var(--global-border-radius);
display: flex;
justify-content: center;
align-items: center;
background-color: var(--global-div);
color: var(--global-text);
transition: 0.1s ease-in-out;
&:hover,
&:active,
&:focus {
transform: scale(1.05);
}
&:active {
transform: scale(0.975);
}
@media (max-width: 500px) {
width: 4rem;
height: 2rem;
}
`;
const ShowMoreButton = styled.button`
background-color: var(--global-div);
color: #828181;
display: flex;
border: none;
padding: 0.5rem;
border-radius: var(--global-border-radius);
margin: 0.5rem 0;
text-align: left;
&:hover,
&:active,
&:focus {
background-color: var(--global-div);
}
transition:
color 0.3s ease,
transform 0.2s ease-in-out;
@media (max-width: 500px) {
margin: 0rem;
margin-top: 1rem;
}
`;
const IframeTrailer = styled.iframe`
aspect-ratio: 16/9;
margin-bottom: 2rem;
position: relative;
border: none;
top: 0;
left: 0;
width: 100%;
height: 100%;
@media (max-width: 1000px) {
width: 100%;
height: 100%;
}
`;
const TrailerOverlay = styled.div`
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
animation: fadeIn 0.3s ease-in-out;
animation: slideUp 0.3s ease-in-out;
aspect-ratio: 16 / 9; // Maintain a 16:9 aspect ratio
`;
const TrailerOverlayContent = styled.div`
width: 60%; // Adjusted width for better visibility
aspect-ratio: 16 / 9; // Maintain a 16:9 aspect ratio
background: white;
border-radius: var(--global-border-radius);
overflow: hidden;
background-color: var(--global-div);
@media (max-width: 500px) {
width: 95%;
}
`;
export const WatchAnimeData: React.FC<{ animeData: Anime }> = ({
animeData,
}) => {
const [isDescriptionExpanded, setDescriptionExpanded] = useState(false);
const [showTrailer, setShowTrailer] = useState(false);
const getAnimeIdFromUrl = () => {
const pathParts = window.location.pathname.split('/');
return pathParts[2];
};
const toggleDescription = () => {
setDescriptionExpanded(!isDescriptionExpanded);
};
useEffect(() => {
setDescriptionExpanded(false);
}, [getAnimeIdFromUrl()]);
const removeHTMLTags = (description: string): string => {
return description.replace(/<[^>]+>/g, '').replace(/\([^)]*\)/g, '');
};
const toggleTrailer = () => {
setShowTrailer(!showTrailer);
};
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && showTrailer) {
setShowTrailer(false);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [showTrailer]);
function capitalizeFirstLetter(str: string) {
if (!str) return str;
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
}
const isScreenUnder500px = () => window.innerWidth < 500;
return (
<>
{animeData && (
{animeData.trailer && animeData.status !== 'Not yet aired' && (
TRAILER
)}
{showTrailer && (
e.stopPropagation()}>
)}
{animeData.id && (
)}
{animeData.malId && (
)}
<>
{animeData.title.english
? animeData.title.english
: animeData.title.romaji}
{animeData.title.romaji
? animeData.title.romaji
: animeData.title.native}
>
{!isScreenUnder500px() && animeData.description && (
{isDescriptionExpanded
? removeHTMLTags(animeData.description)
: `${removeHTMLTags(animeData.description).substring(0, 100)}...`}
{isDescriptionExpanded ? '[Show Less]' : '[Show More]'}
)}
{animeData.type ? (
Type: {animeData.type}
) : (
Type: Unknown
)}
{animeData.releaseDate ? (
Year: {animeData.releaseDate}
) : (
Year: Unknown
)}
{animeData.status && (
Status:{' '}
{animeData.status === 'Completed'
? 'Finished'
: animeData.status === 'Ongoing'
? 'Airing'
: animeData.status}
)}
{animeData.rating ? (
Rating: {animeData.rating}
) : (
Rating: Unknown
)}
{animeData.studios && animeData.studios.length > 0 ? (
Studios: {animeData.studios}
) : (
Studios: Unknown
)}
{animeData.totalEpisodes !== null ? (
Episodes: {animeData.totalEpisodes}
) : (
Episodes: Unknown
)}
{animeData.duration ? (
Duration: {animeData.duration} min
) : (
Duration: Unknown
)}
{animeData.season ? (
Season:{' '}
{capitalizeFirstLetter(animeData.season)}
) : (
Season: Unknown
)}
{animeData.countryOfOrigin && (
Country: {animeData.countryOfOrigin}
)}
{/* timeUntilAiring */}
{/* {animeData.nextAiringEpisode && (
AiringTime:{" "}
{animeData.nextAiringEpisode.timeUntilAiring}
)} */}
{/* {animeData.startDate && (
Date aired:
{' '}
{getDateString(animeData.startDate)}
{animeData.endDate
? ` to ${
animeData.endDate.month &&
animeData.endDate.year
? getDateString(animeData.endDate)
: '?'
}`
: animeData.status === 'Ongoing'
? ''
: ' to ?'}
)} */}
{animeData.genres && animeData.genres.length > 0 ? (
Genres: {animeData.genres.join(', ')}
) : (
Genres: Unknown
)}
{isScreenUnder500px() && animeData.description && (
Description:
{isDescriptionExpanded
? removeHTMLTags(animeData.description)
: `${removeHTMLTags(animeData.description).substring(0, 150)}...`}
{isDescriptionExpanded ? '[Show Less]' : '[Show More]'}
)}
)}
{animeData.relations &&
animeData.relations.some(
(relation: any) =>
relation.relationType.toUpperCase() === 'PREQUEL' ||
relation.relationType.toUpperCase() === 'SEQUEL',
) && (
<>
SEASONS
relation.relationType.toUpperCase() === 'PREQUEL' ||
relation.relationType.toUpperCase() === 'SEQUEL',
)}
/>
>
)}
>
);
};
================================================
FILE: src/components/shared/StatusIndicator.tsx
================================================
import styled from 'styled-components';
import React, { useMemo } from 'react';
const IndicatorDot = styled.div`
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
margin: 0rem;
flex-shrink: 0;
`;
const OngoingIndicator = styled(IndicatorDot)`
background-color: var(--ongoing-dot-color);
`;
const CompletedIndicator = styled(IndicatorDot)`
background-color: var(--completed-indicator-color);
`;
const CancelledIndicator = styled(IndicatorDot)`
background-color: var(--cancelled-indicator-color);
`;
const NotYetAiredIndicator = styled(IndicatorDot)`
background-color: var(--not-yet-aired-indicator-color);
`;
const DefaultIndicator = styled(IndicatorDot)`
background-color: var(--default-indicator-color);
`;
export const StatusIndicator: React.FC<{ status: string }> = ({ status }) => {
const handleStatusCheck = useMemo(() => {
switch (status) {
case 'Ongoing':
return ;
case 'Completed':
return ;
case 'Cancelled':
return ;
case 'Not yet aired':
return ;
default:
return ;
}
}, [status]); // Ensure all dependencies are correctly listed
return <>{handleStatusCheck}>;
};
================================================
FILE: src/hooks/animeInterface.ts
================================================
export interface Title {
romaji: string;
english: string;
native: string;
userPreferred: string;
}
export interface Trailer {
id: string;
site: string;
thumbnail: string;
thumbnailHash: string;
}
export interface VoiceActor {
id: string;
language: string;
name: Title;
image: string;
imageHash: string;
}
export interface Recommendation {
id: string;
malId: string;
title: Title;
status: string;
episodes: number;
image: string;
imageHash: string;
cover: string;
coverHash: string;
rating: number;
type: string;
}
export interface Character {
id: string;
role: string;
name: Title;
image: string;
imageHash: string;
voiceActors: VoiceActor[];
}
export interface Relation {
id: string;
malId: string;
relationType: string;
title: Title;
status: string;
episodes: number;
image: string;
imageHash: string;
cover: string;
coverHash: string;
rating: number;
type: string;
}
export interface Mapping {
id: string;
providerId: string;
similarity: number;
providerType: string;
}
export interface Artwork {
img: string;
type: string;
providerId: string;
}
export interface Episode {
id: string;
title: string;
description: string | null;
number: number;
image: string;
imageHash: string;
airDate: string | null;
}
export interface Anime {
id: string;
title: Title;
malId: string;
trailer: Trailer;
synonyms: string[];
isLicensed: boolean;
isAdult: boolean;
countryOfOrigin: string;
image: string;
imageHash: string;
cover: string;
coverHash: string;
description: string;
status: string;
releaseDate: number;
totalEpisodes: number;
currentEpisode: number;
rating: number;
duration: number;
genres: string[];
studios: string[];
subOrDub: string;
season: string;
popularity: number;
type: string;
startDate: {
year: number;
month: number;
day: number;
};
endDate: {
year: number;
month: number;
day: number;
};
recommendations: Recommendation[];
characters: Character[];
relations: Relation[];
mappings: Mapping[];
artwork: Artwork[];
episodes: Episode[];
color: string;
}
export interface Paging {
currentPage: number;
hasNextPage: boolean;
totalPages: number;
totalResults: number;
results: Anime[];
}
================================================
FILE: src/hooks/useApi.ts
================================================
import axios from 'axios';
import { year, getCurrentSeason, getNextSeason } from '../index';
// Utility function to ensure URL ends with a slash
function ensureUrlEndsWithSlash(url: string): string {
return url.endsWith('/') ? url : `${url}/`;
}
// Adjusting environment variables to ensure they end with a slash
const BASE_URL = ensureUrlEndsWithSlash(
import.meta.env.VITE_BACKEND_URL as string,
);
const SKIP_TIMES = ensureUrlEndsWithSlash(
import.meta.env.VITE_SKIP_TIMES as string,
);
let PROXY_URL = import.meta.env.VITE_PROXY_URL; // Default to an empty string if no proxy URL is provided
// Check if the proxy URL is provided and ensure it ends with a slash
if (PROXY_URL) {
PROXY_URL = ensureUrlEndsWithSlash(import.meta.env.VITE_PROXY_URL as string);
}
const API_KEY = import.meta.env.VITE_API_KEY as string;
// Axios instance
const axiosInstance = axios.create({
baseURL: PROXY_URL || undefined,
timeout: 10000,
headers: {
'X-API-Key': API_KEY, // Assuming your API expects the key in this header
},
});
// Error handling function
// Function to handle errors and throw appropriately
function handleError(error: any, context: string) {
let errorMessage = 'An error occurred';
// Handling CORS errors (Note: This is a simplification. Real CORS errors are hard to catch in JS)
if (error.message && error.message.includes('Access-Control-Allow-Origin')) {
errorMessage = 'A CORS error occurred';
}
switch (context) {
case 'data':
errorMessage = 'Error fetching data';
break;
case 'anime episodes':
errorMessage = 'Error fetching anime episodes';
break;
// Extend with other cases as needed
}
if (error.response) {
// Extend with more nuanced handling based on HTTP status codes
const status = error.response.status;
if (status >= 500) {
errorMessage += ': Server error';
} else if (status >= 400) {
errorMessage += ': Client error';
}
// Include server-provided error message if available
errorMessage += `: ${error.response.data.message || 'Unknown error'}`;
} else if (error.message) {
errorMessage += `: ${error.message}`;
}
console.error(`${errorMessage}`, error);
throw new Error(errorMessage);
}
// Cache key generator
// Function to generate cache key from arguments
function generateCacheKey(...args: string[]) {
return args.join('-');
}
interface CacheItem {
value: any; // Replace 'any' with a more specific type if possible
timestamp: number;
}
// Session storage cache creation
// Function to create a cache in session storage
function createOptimizedSessionStorageCache(
maxSize: number,
maxAge: number,
cacheKey: string,
) {
const cache = new Map(
JSON.parse(sessionStorage.getItem(cacheKey) || '[]'),
);
const keys = new Set(cache.keys());
function isItemExpired(item: CacheItem) {
return Date.now() - item.timestamp > maxAge;
}
function updateSessionStorage() {
sessionStorage.setItem(
cacheKey,
JSON.stringify(Array.from(cache.entries())),
);
}
return {
get(key: string) {
if (cache.has(key)) {
const item = cache.get(key);
if (!isItemExpired(item!)) {
keys.delete(key);
keys.add(key);
return item!.value;
}
cache.delete(key);
keys.delete(key);
}
return undefined;
},
set(key: string, value: any) {
if (cache.size >= maxSize) {
const oldestKey = keys.values().next().value;
cache.delete(oldestKey);
keys.delete(oldestKey);
}
keys.add(key);
cache.set(key, { value, timestamp: Date.now() });
updateSessionStorage();
},
};
}
// Constants for cache configuration
// Cache size and max age constants
const CACHE_SIZE = 20;
const CACHE_MAX_AGE = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
// Factory function for cache creation
// Function to create cache with given cache key
function createCache(cacheKey: string) {
return createOptimizedSessionStorageCache(
CACHE_SIZE,
CACHE_MAX_AGE,
cacheKey,
);
}
interface FetchOptions {
type?: string;
season?: string;
format?: string;
sort?: string[];
genres?: string[];
id?: string;
year?: string;
status?: string;
}
// Individual caches for different types of data
// Creating caches for anime data, anime info, and video sources
const advancedSearchCache = createCache('Advanced Search');
const animeDataCache = createCache('Data');
const animeInfoCache = createCache('Info');
const animeEpisodesCache = createCache('Episodes');
const fetchAnimeEmbeddedEpisodesCache = createCache('Video Embedded Sources');
const videoSourcesCache = createCache('Video Sources');
// Fetch data from proxy with caching
// Function to fetch data from proxy with caching
async function fetchFromProxy(url: string, cache: any, cacheKey: string) {
try {
// Attempt to retrieve the cached response using the cacheKey
const cachedResponse = cache.get(cacheKey);
if (cachedResponse) {
return cachedResponse; // Return the cached response if available
}
// Adjust request parameters based on PROXY_URL's availability
const requestConfig = PROXY_URL
? { params: { url } } // If PROXY_URL is defined, send the original URL as a parameter
: {}; // If PROXY_URL is not defined, make a direct request
// Proceed with the network request
const response = await axiosInstance.get(PROXY_URL ? '' : url, requestConfig);
// After obtaining the response, verify it for errors or empty data
if (
response.status !== 200 ||
(response.data.statusCode && response.data.statusCode >= 400)
) {
const errorMessage = response.data.message || 'Unknown server error';
throw new Error(
`Server error: ${response.data.statusCode || response.status
} ${errorMessage}`,
);
}
// Assuming response data is valid, store it in the cache
cache.set(cacheKey, response.data);
return response.data; // Return the newly fetched data
} catch (error) {
handleError(error, 'data');
throw error; // Rethrow the error for the caller to handle
}
}
// Function to fetch anime data
export async function fetchAdvancedSearch(
searchQuery: string = '',
page: number = 1,
perPage: number = 20,
options: FetchOptions = {},
) {
const queryParams = new URLSearchParams({
...(searchQuery && { query: searchQuery }),
page: page.toString(),
perPage: perPage.toString(),
type: options.type ?? 'ANIME',
...(options.season && { season: options.season }),
...(options.format && { format: options.format }),
...(options.id && { id: options.id }),
...(options.year && { year: options.year }),
...(options.status && { status: options.status }),
...(options.sort && { sort: JSON.stringify(options.sort) }),
});
if (options.genres && options.genres.length > 0) {
// Correctly encode genres as a JSON array
queryParams.set('genres', JSON.stringify(options.genres));
}
const url = `${BASE_URL}meta/anilist/advanced-search?${queryParams.toString()}`;
const cacheKey = generateCacheKey('advancedSearch', queryParams.toString());
return fetchFromProxy(url, advancedSearchCache, cacheKey);
}
// Fetch Anime DATA Function
export async function fetchAnimeData(
animeId: string,
provider: string = 'gogoanime',
) {
const params = new URLSearchParams({ provider });
const url = `${BASE_URL}meta/anilist/data/${animeId}?${params.toString()}`;
const cacheKey = generateCacheKey('animeData', animeId, provider);
return fetchFromProxy(url, animeDataCache, cacheKey);
}
// Fetch Anime INFO Function
export async function fetchAnimeInfo(
animeId: string,
provider: string = 'gogoanime',
) {
const params = new URLSearchParams({ provider });
const url = `${BASE_URL}meta/anilist/info/${animeId}?${params.toString()}`;
const cacheKey = generateCacheKey('animeInfo', animeId, provider);
return fetchFromProxy(url, animeInfoCache, cacheKey);
}
// Function to fetch list of anime based on type (TopRated, Trending, Popular)
async function fetchList(
type: string,
page: number = 1,
perPage: number = 16,
options: FetchOptions = {},
) {
let cacheKey: string;
let url: string;
const params = new URLSearchParams({
page: page.toString(),
perPage: perPage.toString(),
});
if (
['TopRated', 'Trending', 'Popular', 'TopAiring', 'Upcoming'].includes(type)
) {
cacheKey = generateCacheKey(
`${type}Anime`,
page.toString(),
perPage.toString(),
);
url = `${BASE_URL}meta/anilist/${type.toLowerCase()}`;
if (type === 'TopRated') {
options = {
type: 'ANIME',
sort: ['["SCORE_DESC"]'],
};
url = `${BASE_URL}meta/anilist/advanced-search?type=${options.type}&sort=${options.sort}&`;
} else if (type === 'Popular') {
options = {
type: 'ANIME',
sort: ['["POPULARITY_DESC"]'],
};
url = `${BASE_URL}meta/anilist/advanced-search?type=${options.type}&sort=${options.sort}&`;
} else if (type === 'Upcoming') {
const season = getNextSeason(); // This will set the season based on the current month
options = {
type: 'ANIME',
season: season,
year: year.toString(),
status: 'NOT_YET_RELEASED',
sort: ['["POPULARITY_DESC"]'],
};
url = `${BASE_URL}meta/anilist/advanced-search?type=${options.type}&status=${options.status}&sort=${options.sort}&season=${options.season}&year=${options.year}&`;
} else if (type === 'TopAiring') {
const season = getCurrentSeason(); // This will set the season based on the current month
options = {
type: 'ANIME',
season: season,
year: year.toString(),
status: 'RELEASING',
sort: ['["POPULARITY_DESC"]'],
};
url = `${BASE_URL}meta/anilist/advanced-search?type=${options.type}&status=${options.status}&sort=${options.sort}&season=${options.season}&year=${options.year}&`;
}
} else {
cacheKey = generateCacheKey(
`${type}Anime`,
page.toString(),
perPage.toString(),
);
url = `${BASE_URL}meta/anilist/${type.toLowerCase()}`;
// params already defined above
}
const specificCache = createCache(`${type}`);
return fetchFromProxy(`${url}?${params.toString()}`, specificCache, cacheKey);
}
// Functions to fetch top, trending, and popular anime
export const fetchTopAnime = (page: number, perPage: number) =>
fetchList('TopRated', page, perPage);
export const fetchTrendingAnime = (page: number, perPage: number) =>
fetchList('Trending', page, perPage);
export const fetchPopularAnime = (page: number, perPage: number) =>
fetchList('Popular', page, perPage);
export const fetchTopAiringAnime = (page: number, perPage: number) =>
fetchList('TopAiring', page, perPage);
export const fetchUpcomingSeasons = (page: number, perPage: number) =>
fetchList('Upcoming', page, perPage);
// Fetch Anime Episodes Function
export async function fetchAnimeEpisodes(
animeId: string,
provider: string = 'gogoanime',
dub: boolean = false,
) {
const params = new URLSearchParams({ provider, dub: dub ? 'true' : 'false' });
const url = `${BASE_URL}meta/anilist/episodes/${animeId}?${params.toString()}`;
const cacheKey = generateCacheKey(
'animeEpisodes',
animeId,
provider,
dub ? 'dub' : 'sub',
);
return fetchFromProxy(url, animeEpisodesCache, cacheKey);
}
// Fetch Embedded Anime Episodes Servers
export async function fetchAnimeEmbeddedEpisodes(episodeId: string) {
const url = `${BASE_URL}meta/anilist/servers/${episodeId}`;
const cacheKey = generateCacheKey('animeEmbeddedServers', episodeId);
return fetchFromProxy(url, fetchAnimeEmbeddedEpisodesCache, cacheKey);
}
// Function to fetch anime streaming links
export async function fetchAnimeStreamingLinks(episodeId: string) {
const url = `${BASE_URL}meta/anilist/watch/${episodeId}`;
const cacheKey = generateCacheKey('animeStreamingLinks', episodeId);
return fetchFromProxy(url, videoSourcesCache, cacheKey);
}
// Function to fetch skip times for an anime episode
interface FetchSkipTimesParams {
malId: string;
episodeNumber: string;
episodeLength?: string;
}
// Function to fetch skip times for an anime episode
export async function fetchSkipTimes({
malId,
episodeNumber,
episodeLength = '0',
}: FetchSkipTimesParams) {
// Constructing the URL with query parameters
const types = ['ed', 'mixed-ed', 'mixed-op', 'op', 'recap'];
const url = new URL(`${SKIP_TIMES}v2/skip-times/${malId}/${episodeNumber}`);
url.searchParams.append('episodeLength', episodeLength.toString());
types.forEach((type) => url.searchParams.append('types[]', type));
const cacheKey = generateCacheKey(
'skipTimes',
malId,
episodeNumber,
episodeLength || '',
);
// Use the fetchFromProxy function to make the request and handle caching
return fetchFromProxy(url.toString(), createCache('SkipTimes'), cacheKey);
}
// Fetch Recent Anime Episodes Function
export async function fetchRecentEpisodes(
page: number = 1,
perPage: number = 18,
provider: string = 'gogoanime',
) {
// Construct the URL with query parameters for fetching recent episodes
const params = new URLSearchParams({
page: page.toString(),
perPage: perPage.toString(),
provider: provider, // Default to 'gogoanime' if no provider is specified
});
// Using the BASE_URL defined at the top of your file
const url = `${BASE_URL}meta/anilist/recent-episodes?${params.toString()}`;
const cacheKey = generateCacheKey(
'recentEpisodes',
page.toString(),
perPage.toString(),
provider,
);
// Utilize the existing fetchFromProxy function to handle the request and caching logic
return fetchFromProxy(url, createCache('RecentEpisodes'), cacheKey);
}
================================================
FILE: src/hooks/useCountdown.ts
================================================
// /hooks/useCountdown.ts
import { useState, useEffect } from 'react';
export const useCountdown = (targetDate: number | null): string => {
const [timeLeft, setTimeLeft] = useState('');
useEffect(() => {
if (!targetDate) {
return; // Early exit if targetDate is null or undefined
}
const timer = setInterval(() => {
const now = Date.now();
const distance = targetDate - now;
if (distance < 0) {
clearInterval(timer);
setTimeLeft('Airing now or aired');
return;
}
const days = Math.floor(distance / (1000 * 60 * 60 * 24));
const hours = Math.floor(
(distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60),
);
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
setTimeLeft(
`${days} days, ${hours} hours, ${minutes} minutes, ${seconds} seconds`,
);
}, 1000);
return () => clearInterval(timer);
}, [targetDate]);
return timeLeft;
};
================================================
FILE: src/hooks/useFilters.ts
================================================
import { year as currentYear } from '../index';
export interface Option {
value: string;
label: string;
}
export interface FilterProps {
label: string;
options?: Option[];
onChange?: (value: any) => void;
value?: any;
isMulti?: boolean;
}
export const anyOption: Option = { value: '', label: 'Any' };
export const genreOptions: Option[] = [
{ value: 'Action', label: 'Action' },
{ value: 'Adventure', label: 'Adventure' },
{ value: 'Comedy', label: 'Comedy' },
{ value: 'Drama', label: 'Drama' },
{ value: 'Fantasy', label: 'Fantasy' },
{ value: 'Horror', label: 'Horror' },
{ value: 'Mahou Shoujo', label: 'Mahou Shoujo' },
{ value: 'Mecha', label: 'Mecha' },
{ value: 'Music', label: 'Music' },
{ value: 'Mystery', label: 'Mystery' },
{ value: 'Psychological', label: 'Psychological' },
{ value: 'Romance', label: 'Romance' },
{ value: 'Sci-Fi', label: 'Sci-Fi' },
{ value: 'Slice of Life', label: 'Slice of Life' },
{ value: 'Sports', label: 'Sports' },
{ value: 'Supernatural', label: 'Supernatural' },
{ value: 'Thriller', label: 'Thriller' },
];
export const yearOptions: Option[] = [
anyOption,
{ value: String(currentYear + 1), label: String(currentYear + 1) },
...Array.from({ length: currentYear - 1939 }, (_, i) => ({
value: String(currentYear - i),
label: String(currentYear - i),
})),
];
export const seasonOptions: Option[] = [
anyOption,
{ value: 'WINTER', label: 'Winter' },
{ value: 'SPRING', label: 'Spring' },
{ value: 'SUMMER', label: 'Summer' },
{ value: 'FALL', label: 'Fall' },
];
export const formatOptions: Option[] = [
anyOption,
{ value: 'TV', label: 'TV' },
{ value: 'TV_SHORT', label: 'TV Short' },
{ value: 'OVA', label: 'OVA' },
{ value: 'ONA', label: 'ONA' },
{ value: 'MOVIE', label: 'Movie' },
{ value: 'SPECIAL', label: 'Special' },
{ value: 'MUSIC', label: 'Music' },
];
export const statusOptions: Option[] = [
anyOption,
{ value: 'RELEASING', label: 'Airing' },
{ value: 'NOT_YET_RELEASED', label: 'Not Yet Aired' },
{ value: 'FINISHED', label: 'Finished' },
{ value: 'CANCELLED', label: 'Cancelled' },
];
export const sortOptions: Option[] = [
{ value: 'POPULARITY_DESC', label: 'Popularity' },
{ value: 'TRENDING_DESC', label: 'Trending' },
{ value: 'SCORE_DESC', label: 'Rating' },
{ value: 'FAVOURITES_DESC', label: 'Favorites' },
{ value: 'EPISODES_DESC', label: 'Episodes' },
{ value: 'ID_DESC', label: 'ID' },
{ value: 'UPDATED_AT_DESC', label: 'Last Updated' },
{ value: 'START_DATE_DESC', label: 'Start Date' },
{ value: 'END_DATE_DESC', label: 'End Date' },
{ value: 'TITLE_ROMAJI_DESC', label: 'Title (Romaji)' },
{ value: 'TITLE_ENGLISH_DESC', label: 'Title (English)' },
{ value: 'TITLE_NATIVE_DESC', label: 'Title (Native)' },
];
================================================
FILE: src/hooks/useScroll.ts
================================================
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
export function ScrollToTop() {
const location = useLocation();
const prevPathnameRef = useRef(null);
useEffect(() => {
const restoreScrollPosition = () => {
const savedPosition = sessionStorage.getItem(location.pathname);
if (savedPosition) {
window.scrollTo(0, parseInt(savedPosition, 10));
}
};
const saveScrollPosition = () =>
sessionStorage.setItem(location.pathname, window.scrollY.toString());
window.addEventListener('beforeunload', saveScrollPosition);
window.addEventListener('popstate', restoreScrollPosition);
const ignoreRoutePattern = /^\/watch\/[^/]+\/[^/]+\/[^/]+$/;
if (
prevPathnameRef.current !== location.pathname &&
!ignoreRoutePattern.test(location.pathname)
) {
if (location.state?.preserveScroll) {
restoreScrollPosition();
} else {
window.scrollTo(0, 0);
}
}
prevPathnameRef.current = location.pathname;
return () => {
window.removeEventListener('beforeunload', saveScrollPosition);
window.removeEventListener('popstate', restoreScrollPosition);
};
}, [location]);
return null;
}
export const usePreserveScrollOnReload = () => {
useEffect(() => {
const savedScrollPosition = sessionStorage.getItem('scrollPosition');
if (savedScrollPosition) {
window.scrollTo(0, parseInt(savedScrollPosition, 10));
}
const handleBeforeUnload = () => {
sessionStorage.setItem('scrollPosition', window.scrollY.toString());
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, []);
};
================================================
FILE: src/hooks/useTIme.ts
================================================
export const date = new Date();
export const time = new Date().getTime();
export const year = new Date().getFullYear();
export const month = new Date().getMonth();
export const getCurrentSeason = (): string => {
if (month >= 2 && month <= 4) {
return 'SPRING';
} else if (month >= 5 && month <= 7) {
return 'SUMMER';
} else if (month >= 8 && month <= 10) {
return 'FALL';
} else {
return 'WINTER';
}
};
export const getNextSeason = (): string => {
const currentSeason = getCurrentSeason();
switch (currentSeason) {
case 'SPRING':
return 'SUMMER';
case 'SUMMER':
return 'FALL';
case 'FALL':
return 'WINTER';
case 'WINTER':
return 'SPRING';
default:
return 'UNKNOWN'; // Should never be reached
}
};
================================================
FILE: src/index.ts
================================================
// * ==== Components ====
// TODO Shared components
export { StatusIndicator } from './components/shared/StatusIndicator';
// TODO Basic UI Components
export { Navbar } from './components/Navigation/Navbar';
export { Footer } from './components/Navigation/Footer';
export { DropDownSearch } from './components/Navigation/DropSearch';
export { SearchFilters } from './components/Navigation/SearchFilters';
export { ShortcutsPopup } from './components/ShortcutsPopup';
export { ThemeProvider, useTheme } from './components/ThemeContext';
// TODO Cards
export * from './components/Cards/CardGrid';
export { CardItem } from './components/Cards/CardItem';
// TODO Home Page Specific
export { EpisodeCard } from './components/Home/EpisodeCard';
export { HomeCarousel } from './components/Home/HomeCarousel';
export { HomeSideBar } from './components/Home/HomeSideBar';
// TODO Skeletons for Loading States
export {
SkeletonCard,
SkeletonSlide,
SkeletonPlayer,
} from './components/Skeletons/Skeletons';
// TODO Watching Anime Functionality
export { EpisodeList } from './components/Watch/EpisodeList';
export { EmbedPlayer } from './components/Watch/Video/EmbedPlayer';
export { Player } from './components/Watch/Video/Player'; // Notice: This is not a default export
export { MediaSource } from './components/Watch/Video/MediaSource';
export { WatchAnimeData } from './components/Watch/WatchAnimeData';
export { AnimeDataList } from './components/Watch/AnimeDataList';
export { Seasons } from './components/Watch/Seasons';
// TODO User Components
export { Settings } from './components/Profile/Settings';
export {
SettingsProvider,
useSettings,
} from './components/Profile/SettingsProvider';
export { WatchingAnilist } from './components/Profile/WatchingAnilist';
// * ==== Hooks ====
// TODO Utilizing API and Other Functionalities
export * from './hooks/useApi';
export * from './hooks/animeInterface';
export * from './hooks/useScroll';
export * from './hooks/useTIme';
export * from './hooks/useFilters';
export * from './hooks/useCountdown';
// * ==== Client ====
export { ApolloClientProvider } from './client/ApolloClient';
export * from './client/userInfoTypes';
export * from './client/authService';
export * from './client/useAuth';
// * ==== Pages ====
// TODO Main Pages of the Application
export { default as Home } from './pages/Home';
export { default as Search } from './pages/Search';
export { default as Watch } from './pages/Watch';
export { default as Profile } from './pages/Profile';
export { default as About } from './pages/About';
export { default as PolicyTerms } from './pages/PolicyTerms';
export { default as Page404 } from './pages/404';
export { default as Callback } from './pages/Callback';
================================================
FILE: src/main.tsx
================================================
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Root element not found');
}
const root = ReactDOM.createRoot(rootElement);
root.render(
,
);
================================================
FILE: src/pages/404.tsx
================================================
import React, { useEffect } from 'react';
import styled from 'styled-components';
import Image404URL from '/src/assets/404.webp';
// Styled component for Centered Content
const CenteredContent = styled.div`
display: flex;
padding-top: 5rem;
margin-bottom: 5rem;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
font-size: 1.5rem;
img {
max-width: 100%;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3); /* Add shadow */
border-radius: var(--global-border-radius);
animation: fadeIn 0.5s ease; /* Apply fade-in animation */
}
@media (max-width: 550px) {
img {
max-width: 80%;
}
}
`;
const Idkwhattonamethis = styled.div``;
const NotFound: React.FC = () => {
useEffect(() => {
const previousTitle = document.title;
document.title = '404 | Page Not Found';
return () => {
document.title = previousTitle;
};
}, []);
return (
404 | Page Not Found
);
};
export default NotFound;
================================================
FILE: src/pages/About.tsx
================================================
import styled from 'styled-components';
import { useEffect } from 'react';
import { FaCheckCircle } from 'react-icons/fa';
const SplashContainer = styled.div`
margin-top: -2rem;
`;
const Keyword = styled.span`
font-weight: bold;
color: var(--your-custom-color);
position: relative;
margin-right: 0.2rem;
::before {
content: '\u25A0';
font-size: 0.8rem;
position: absolute;
top: 0;
left: -0.5rem;
color: var(--your-custom-color);
}
`;
const Paragraph = styled.p`
font-size: 1rem;
margin-bottom: 1rem;
line-height: 1.6;
color: var(--global-text);
`;
const MainContent = styled.div`
max-width: 50rem;
margin: 0 auto;
padding: 1rem;
color: var(--global-text);
font-size: 1rem;
line-height: 1.6;
`;
const sections = [
{
title: 'About',
title2: "What's Miruro?",
content: (
Miruro is an anime streaming site where you can watch anime online in HD
quality with English subtitles or dubbing. You can also download any
anime you want without registration.
),
},
{
title2: 'Is Miruro safe?',
content: (
Yes. We started this site to improve UX and are committed to keeping our
users safe. We encourage all our users to notify us if anything looks
suspicious. Please understand that we do have to run advertisements to
maintain the site.
),
},
{
title2: 'Why Miruro?',
content: (
<>
Content Library:
{' '}
We have a vast collection of both old and new anime, making us one of
the largest anime libraries on the web.
Streaming Experience:
{' '}
Enjoy fast and reliable streaming with our{' '}
top-of-the-line servers .
Quality/Resolution:
{' '}
Our videos are available in high resolution , and we
offer quality settings to suit your internet speed.
Frequent Updates:
{' '}
Our content is updated hourly to provide you with the{' '}
latest releases .
User-Friendly Interface:
{' '}
We focus on simplicity and ease of use .
Device Compatibility:
{' '}
Miruro works seamlessly on both{' '}
desktop and mobile devices .
Community:
{' '}
Join our active community of anime lovers .
>
),
},
];
function About() {
useEffect(() => {
const previousTitle = document.title;
document.title = 'About | Miruro'; // Set the title when the component mounts
return () => {
// Reset the title to the previous one when the component unmounts
document.title = previousTitle;
};
}, []);
return (
{sections.map((section, index) => (
{section.title && {section.title} }
{section.title2 && (
{section.title2}
)}
{section.content}
))}
);
}
export default About;
================================================
FILE: src/pages/Callback.tsx
================================================
import { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import axios from 'axios';
import styled from 'styled-components';
const Message = styled.div`
text-align: center;
margin-top: 5rem;
font-size: 1.25rem;
font-weight: bold;
`;
const Callback = () => {
const location = useLocation();
const navigate = useNavigate();
const [errorMessage, setErrorMessage] = useState(''); // State to store the error message
useEffect(() => {
const queryParams = new URLSearchParams(location.search);
const error = queryParams.get('error');
const code = queryParams.get('code');
const PLATFORM = import.meta.env.VITE_DEPLOY_PLATFORM; // This will be set as 'VERCEL' or 'CLOUDFLARE'
// Check if there was an access denied error
if (error === 'access_denied') {
setErrorMessage(
'Authorization revoked. Please click "Authorize" to grant access.',
);
navigate('/callback', { replace: true });
return;
}
// Determine the endpoint based on the platform
const apiEndpoint =
PLATFORM === 'VERCEL' ? '/api/exchange-token' : '/exchange-token';
if (code) {
axios
.post(apiEndpoint, { code })
.then((response) => {
// Store the access token in localStorage
localStorage.setItem('accessToken', response.data.accessToken);
// After setting the token, navigate and force a refresh
navigate('/profile');
window.location.reload(); // Force a full page reload to refresh state
})
.catch((error) => {
const errMsg = error.response?.data?.error || 'Error logging in :(';
console.error('Error in token exchange:', errMsg);
setErrorMessage(errMsg); // Store the error message
navigate('/callback', { replace: true });
});
}
}, [location, navigate]);
return (
{errorMessage ? `${errorMessage}` : 'Logging in...'}
);
};
export default Callback;
================================================
FILE: src/pages/Home.tsx
================================================
import { useState, useEffect } from 'react';
import styled from 'styled-components';
import {
HomeCarousel,
CardGrid,
StyledCardGrid,
SkeletonSlide,
SkeletonCard,
fetchTrendingAnime,
fetchPopularAnime,
fetchTopAnime,
fetchTopAiringAnime,
fetchUpcomingSeasons,
HomeSideBar,
EpisodeCard,
getNextSeason,
time,
Paging,
Anime,
Episode,
} from '../index';
const SimpleLayout = styled.div`
gap: 1rem;
margin: 0 auto;
max-width: 125rem;
border-radius: var(--global-border-radius);
display: flex;
flex-direction: column;
`;
const ContentSidebarLayout = styled.div`
display: flex;
flex-direction: column;
gap: 2rem;
width: 100%;
@media (min-width: 1000px) {
flex-direction: row;
justify-content: space-between;
}
`;
const TabContainer = styled.div`
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
border-radius: var(--global-border-radius);
width: 100%;
`;
const Tab = styled.div<{ $isActive: boolean }>`
background: ${({ $isActive }) =>
$isActive ? 'var(--primary-accent)' : 'transparent'};
border-radius: var(--global-border-radius);
border: none;
cursor: pointer;
font-weight: bold;
color: var(--global-text);
position: relative;
overflow: hidden;
margin: 0;
font-size: 0.8rem;
padding: 1rem;
transition: background-color 0.3s ease;
&:hover,
&:active,
&:focus {
background: var(--primary-accent);
}
@media (max-width: 500px) {
padding: 0.5rem;
}
`;
const Section = styled.section`
padding: 0rem;
border-radius: var(--global-border-radius);
`;
const ErrorMessage = styled.div`
padding: 1rem;
margin: 1rem 0;
background-color: #ffdddd;
border-left: 4px solid #f44336;
color: #f44336;
border-radius: var(--global-border-radius);
p {
margin: 0;
font-weight: bold;
}
`;
const Home = () => {
const [itemsCount, setItemsCount] = useState(
window.innerWidth > 500 ? 24 : 15,
);
// Reduced active time to 5mins
const [activeTab, setActiveTab] = useState(() => {
const time = Date.now();
const savedData = localStorage.getItem('home tab');
if (savedData) {
const { tab, timestamp } = JSON.parse(savedData);
if (time - timestamp < 300000) {
return tab;
} else {
localStorage.removeItem('home tab');
}
}
return 'trending';
});
const [state, setState] = useState({
watchedEpisodes: [] as Episode[],
trendingAnime: [] as Anime[],
popularAnime: [] as Anime[],
topAnime: [] as Anime[],
topAiring: [] as Anime[],
Upcoming: [] as Anime[],
error: null as string | null,
loading: {
trending: true,
popular: true,
topRated: true,
topAiring: true,
Upcoming: true,
},
});
useEffect(() => {
const handleResize = () => {
setItemsCount(window.innerWidth > 500 ? 24 : 15);
};
window.addEventListener('resize', handleResize);
handleResize();
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
useEffect(() => {
const fetchWatchedEpisodes = () => {
const watchedEpisodesData = localStorage.getItem('watched-episodes');
if (watchedEpisodesData) {
const allEpisodes = JSON.parse(watchedEpisodesData);
const latestEpisodes: Episode[] = [];
Object.keys(allEpisodes).forEach((animeId) => {
const episodes = allEpisodes[animeId];
const latestEpisode = episodes[episodes.length - 1];
latestEpisodes.push(latestEpisode);
});
setState((prevState) => ({
...prevState,
watchedEpisodes: latestEpisodes,
}));
}
};
fetchWatchedEpisodes();
}, []);
useEffect(() => {
const fetchCount = Math.ceil(itemsCount * 1.4);
const fetchData = async () => {
try {
setState((prevState) => ({ ...prevState, error: null }));
const [trending, popular, topRated, topAiring, Upcoming] =
await Promise.all([
fetchTrendingAnime(1, fetchCount),
fetchPopularAnime(1, fetchCount),
fetchTopAnime(1, fetchCount),
fetchTopAiringAnime(1, 6),
fetchUpcomingSeasons(1, 6),
]);
setState((prevState) => ({
...prevState,
trendingAnime: filterAndTrimAnime(trending),
popularAnime: filterAndTrimAnime(popular),
topAnime: filterAndTrimAnime(topRated),
topAiring: filterAndTrimAnime(topAiring),
Upcoming: filterAndTrimAnime(Upcoming),
}));
} catch (fetchError) {
setState((prevState) => ({
...prevState,
error: 'An unexpected error occurred',
}));
} finally {
setState((prevState) => ({
...prevState,
loading: {
trending: false,
popular: false,
topRated: false,
topAiring: false,
Upcoming: false,
},
}));
}
};
fetchData();
}, [itemsCount]);
useEffect(() => {
document.title = `Miruro | Watch Anime Online, Free Anime Streaming`;
}, [activeTab]);
useEffect(() => {
const tabData = JSON.stringify({ tab: activeTab, timestamp: time });
localStorage.setItem('home tab', tabData);
}, [activeTab]);
const filterAndTrimAnime = (animeList: Paging) =>
animeList.results
/* .filter(
(anime: Anime) =>
anime.totalEpisodes !== null &&
anime.duration !== null &&
anime.releaseDate !== null,
) */
.slice(0, itemsCount);
const renderCardGrid = (
animeData: Anime[],
isLoading: boolean,
hasError: boolean,
) => (
{isLoading || hasError ? (
{Array.from({ length: itemsCount }, (_, index) => (
))}
) : (
{}}
/>
)}
);
const handleTabClick = (tabName: string) => {
setActiveTab(tabName);
};
const SEASON = getNextSeason();
return (
{state.error && (
ERROR: {state.error}
)}
{state.loading.trending || state.error ? (
) : (
)}
handleTabClick('trending')}
>
TRENDING
handleTabClick('popular')}
>
POPULAR
handleTabClick('topRated')}
>
TOP RATED
{activeTab === 'trending' &&
renderCardGrid(
state.trendingAnime,
state.loading.trending,
!!state.error,
)}
{activeTab === 'popular' &&
renderCardGrid(
state.popularAnime,
state.loading.popular,
!!state.error,
)}
{activeTab === 'topRated' &&
renderCardGrid(
state.topAnime,
state.loading.topRated,
!!state.error,
)}
TOP AIRING
UPCOMING {SEASON}
);
};
export default Home;
================================================
FILE: src/pages/PolicyTerms.tsx
================================================
import styled from 'styled-components';
import { useEffect } from 'react';
const colors = {
textColor: 'var(--global-text)',
buttonBackground: 'var(--global-button-bg)',
buttonText: 'var(--global-button-text)',
buttonHoverBackground: 'var(--global-button-hover-bg)',
adBackground: 'var(--global-div)',
customColor: 'var(--your-custom-color)',
paddingSize: '1rem',
};
const StyledLink = styled.a`
color: #744aff;
text-decoration: none;
font-weight: bold;
&:hover,
&:active,
&:focus {
text-decoration: underline;
}
`;
const SplashContainer = styled.div`
margin-top: -2rem;
`;
const Paragraph = styled.p`
font-size: 1rem;
margin-bottom: ${colors.paddingSize};
line-height: 1.6;
color: ${colors.textColor};
`;
const MainContent = styled.div`
max-width: 50rem;
margin: 0 auto;
padding: ${colors.paddingSize};
color: ${colors.textColor};
font-size: 1rem;
line-height: 1.6;
`;
const sections = [
{
title: 'Privacy Policy',
content: (
Data Collection : We collect minimal user data necessary
for the functioning of Miruro, such as account information and user
preferences.
Use of Data : The data collected is used to improve
service quality and user experience. We do not share personal data with
third parties except as required by law.
Cookies and Tracking : Miruro uses cookies and similar
tracking technologies to enhance the user experience like caching video
timestamps and tracking watched content.
Third-Party Services : Embedded videos from third-party
sites may have their own privacy policies, and we advise users to read
these policies on the respective sites.
Security : We are committed to ensuring your data is
secure but remind users that no method of transmission over the Internet
is 100% secure.
Changes to Privacy Policy : We may update our Privacy
Policy from time to time. We will notify users of any changes by posting
the new policy on this page.
Contact Us : If you have any questions about these
terms, please contact us at{' '}
miruro@proton.me.
),
},
{
title: 'Terms of Service',
content: (
Acceptance of Terms : By using Miruro, you agree to
these Terms of Service and acknowledge that they affect your legal
rights and obligations.
Content : Miruro does not host video content but embeds
videos from various third-party sources. We are not responsible for the
content, quality, or the policies of these external sites.
Use of Site : The service is provided "as is" and is
used at the user’s own risk. Users must not misuse the service in any
way that breaches laws or regulations.
User Content : Users may share content, such as comments
or reviews, responsibly. We reserve the right to remove any content that
violates our policies or is deemed inappropriate.
Intellectual Property : The intellectual property rights
of the embedded videos remain with their respective owners. Miruro
respects these rights and does not claim ownership of this content.
Changes to Terms of Service : We reserve the right to
modify these terms at any time. Continued use of the site after changes
constitutes acceptance of the new terms.
Termination : We may terminate or suspend access to our
service immediately, without prior notice, for any breach of these
Terms.
),
},
];
function PolicyTerms() {
useEffect(() => {
const previousTitle = document.title;
document.title = 'Policy & Terms | Miruro'; // Set the title when the component mounts
return () => {
// Reset the title to the previous one when the component unmounts
document.title = previousTitle;
};
}, []);
return (
{sections.map((section, index) => (
{section.title && {section.title} }
{section.content}
))}
);
}
export default PolicyTerms;
================================================
FILE: src/pages/Profile.tsx
================================================
import React, { useEffect } from 'react';
import styled from 'styled-components';
import { IoLogOutOutline } from 'react-icons/io5';
import { useAuth, EpisodeCard, WatchingAnilist } from '../index';
import { SiAnilist } from 'react-icons/si';
import { CgProfile } from 'react-icons/cg';
import { useNavigate } from 'react-router-dom';
import { FiSettings } from 'react-icons/fi';
const TopContainer = styled.div`
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
gap: 1rem;
@media (min-width: 1000px) {
flex-direction: row;
justify-content: space-between;
}
`;
const UserInfoContainer = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
`;
const ProfileContainer = styled.div`
position: relative;
padding: 0.5rem;
background-color: var(--global-div-tr);
border-radius: var(--global-border-radius);
text-align: center;
font-size: 0.9rem;
flex: 1;
justify-content: center;
align-items: center;
p {
margin: 0.75rem;
}
img {
border-radius: var(--global-border-radius);
width: 100px;
}
`;
const PreferencesContainer = styled.div`
max-width: 80rem;
margin: auto;
padding: 0.25rem;
`;
const Loginbutton = styled.div`
border-radius: var(--global-border-radius);
display: flex;
cursor: pointer;
padding: 0.75rem;
justify-content: center;
align-items: center;
background-color: var(--global-div);
color: var(--global-text);
transition: 0.1s ease-in-out;
width: 10rem; // Fixed width
margin: 0 auto; // Center horizontally
&:hover,
&:active,
&:focus {
transform: scale(1.025);
}
&:active {
transform: scale(0.975);
}
.svg-wrapper {
margin-bottom: -0.2rem;
margin-left: 0.5rem;
font-size: 1.25rem;
}
`;
// Profile component
export const Profile: React.FC = () => {
const navigate = useNavigate();
const { isLoggedIn, userData, login, logout } = useAuth();
// Profile Page Document Title
useEffect(() => {
document.title =
isLoggedIn && userData ? `${userData.name} | Profile` : 'Profile';
}, [isLoggedIn, userData]);
const handleSettingsClick = () => {
navigate('/profile/settings');
};
return (
{/*
*/}
{isLoggedIn && userData ? (
<>
Welcome, {userData.name}
{userData.statistics && (
<>
Anime watched: {userData.statistics.anime.count}
Total episodes watched:{' '}
{userData.statistics.anime.episodesWatched}
Total minutes watched:{' '}
{userData.statistics.anime.minutesWatched}
Average score:{' '}
{userData.statistics.anime.meanScore.toFixed(2)}
>
)}
Log out
>
) : (
Guest
Please log in to view your profile and AniList
Log in with
)}
);
};
export default Profile;
================================================
FILE: src/pages/Search.tsx
================================================
import { useState, useEffect, useRef, useCallback } from 'react';
import styled from 'styled-components';
import { useSearchParams } from 'react-router-dom';
import {
SearchFilters,
CardGrid,
StyledCardGrid,
fetchAdvancedSearch,
SkeletonCard,
} from '../index';
import { Paging } from '../index';
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 1.5rem;
@media (min-width: 1500px) {
margin-left: 8rem;
margin-right: 8rem;
margin-top: 2rem;
}
`;
const Search = () => {
const [searchParams, setSearchParams] = useSearchParams();
const sortParam = searchParams.get('sort');
// Directly initialize state from URL parameters
const initialQuery = searchParams.get('query') || '';
// Adjusting initialization to ensure non-null values
let initialSortDirection: 'DESC' | 'ASC' = 'DESC'; // Default to 'DESC'
if (sortParam) {
initialSortDirection = sortParam.endsWith('_DESC') ? 'DESC' : 'ASC';
}
const initialSortValue = sortParam
? sortParam.replace(/(_DESC|_ASC)$/, '')
: 'POPULARITY_DESC';
const initialSort = {
value: initialSortValue,
label:
initialSortValue.replace('_DESC', '').charAt(0) +
initialSortValue.replace('_DESC', '').slice(1).toLowerCase(),
};
const genresParam = searchParams.get('genres');
const initialGenres = genresParam
? genresParam.split(',').map((value) => ({ value, label: value }))
: [];
const initialYear = {
value: searchParams.get('year') || '',
label: searchParams.get('year') || 'Any',
};
const initialSeason = {
value: searchParams.get('season') || '',
label: searchParams.get('season') || 'Any',
};
const initialFormat = {
value: searchParams.get('format') || '',
label: searchParams.get('format') || 'Any',
};
const initialStatus = {
value: searchParams.get('status') || '',
label: searchParams.get('status') || 'Any',
};
// State hooks
const [query, setQuery] = useState(initialQuery);
const [selectedGenres, setSelectedGenres] = useState(initialGenres);
const [selectedYear, setSelectedYear] = useState(initialYear);
const [selectedSeason, setSelectedSeason] = useState(initialSeason);
const [selectedFormat, setSelectedFormat] = useState(initialFormat);
const [selectedStatus, setSelectedStatus] = useState(initialStatus);
const [selectedSort, setSelectedSort] = useState(initialSort);
const [sortDirection, setSortDirection] = useState<'DESC' | 'ASC'>(
initialSortDirection,
);
//Other logic
const [animeData, setAnimeData] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [hasNextPage, setHasNextPage] = useState(false);
const [page, setPage] = useState(1);
const delayTimeout = useRef(null);
useEffect(() => {
const previousTitle = document.title;
document.title = `${query} | Search Results`;
return () => {
document.title = previousTitle;
};
}, [query]);
const updateSearchParams = () => {
const params = new URLSearchParams();
params.set('query', query);
if (selectedGenres.length > 0) {
params.set('genres', selectedGenres.map((g) => g.value).join(','));
}
if (selectedYear.value) params.set('year', selectedYear.value);
if (selectedSeason.value) params.set('season', selectedSeason.value);
if (selectedFormat.value) params.set('format', selectedFormat.value);
if (selectedStatus.value) params.set('status', selectedStatus.value);
const sortBase = selectedSort.value.replace(/(_DESC|_ASC)$/, '');
const sortParam =
sortDirection === 'DESC' ? `${sortBase}_DESC` : `${sortBase}_ASC`;
params.set('sort', sortParam);
setSearchParams(params, { replace: true });
};
useEffect(() => {
setPage(1);
const scrollToTopWithDelay = () => {
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, 350);
};
scrollToTopWithDelay();
}, [
query,
selectedGenres,
selectedYear,
selectedSeason,
selectedFormat,
selectedStatus,
selectedSort,
sortDirection,
]);
const initiateFetchAdvancedSearch = useCallback(async () => {
setIsLoading(true);
const sortBase = selectedSort.value.replace('_DESC', '');
const sortParam = sortDirection === 'DESC' ? `${sortBase}_DESC` : sortBase;
try {
const fetchedData = await fetchAdvancedSearch(query, page, 17, {
genres: selectedGenres.map((g) => g.value),
year: selectedYear.value,
season: selectedSeason.value,
format: selectedFormat.value,
status: selectedStatus.value,
sort: [sortParam], // Ensure this is correctly formatted
});
setAnimeData(
page === 1
? fetchedData.results
: [...animeData, ...fetchedData.results],
);
setHasNextPage(fetchedData.hasNextPage);
} catch (err) {
console.error('Error fetching data:', err);
} finally {
setIsLoading(false);
}
}, [
query,
page,
selectedGenres,
selectedYear,
selectedSeason,
selectedFormat,
selectedStatus,
selectedSort,
sortDirection,
]);
const handleLoadMore = () => {
setPage((prevPage) => prevPage + 1);
};
useEffect(() => {
const newQuery = searchParams.get('query') || '';
if (newQuery !== query) {
setQuery(newQuery);
}
}, [searchParams]);
useEffect(() => {
// Clear existing timeout to ensure no double fetches
if (delayTimeout.current !== null) clearTimeout(delayTimeout.current);
// Debounce to minimize fetches during rapid state changes
delayTimeout.current = window.setTimeout(() => {
initiateFetchAdvancedSearch();
}, 0);
// Cleanup timeout on unmount or before executing a new fetch
return () => {
if (delayTimeout.current !== null) clearTimeout(delayTimeout.current);
};
}, [initiateFetchAdvancedSearch]); // Include all dependencies here
return (
{(isLoading && page === 1) ||
(isLoading && page === 1 && animeData.length === 0) ? (
{Array.from({ length: 17 }).map((_, index) => (
))}
) : (
)}
{!isLoading && animeData.length === 0 && (
No Results
)}
);
};
export default Search;
================================================
FILE: src/pages/Watch.tsx
================================================
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { FaBell } from 'react-icons/fa';
import styled from 'styled-components';
import Image404URL from '/src/assets/404.webp';
import {
EpisodeList,
Player,
EmbedPlayer,
WatchAnimeData as AnimeData,
AnimeDataList,
MediaSource,
fetchAnimeEmbeddedEpisodes,
fetchAnimeEpisodes,
fetchAnimeData,
fetchAnimeInfo,
SkeletonPlayer,
useCountdown,
} from '../index';
import { Episode } from '../index';
const WatchContainer = styled.div``;
const WatchWrapper = styled.div`
font-size: 0.9rem;
gap: 1rem;
display: flex;
flex-direction: column;
align-items: center;
background-color: var(--global-primary-bg);
color: var(--global-text);
@media (min-width: 1000px) {
flex-direction: row;
align-items: flex-start;
}
`;
const DataWrapper = styled.div`
display: grid;
gap: 1rem;
grid-template-columns: 1fr 1fr; // TODO Aim for a 3:1 ratio
width: 100%; // TODO Make sure this container can expand enough
@media (max-width: 1000px) {
grid-template-columns: auto;
}
`;
const SourceAndData = styled.div<{ $videoPlayerWidth: string }>`
width: ${({ $videoPlayerWidth }) => $videoPlayerWidth};
`;
const RalationsTable = styled.div`
padding: 0;
margin-top: 1rem;
@media (max-width: 1000px) {
margin-top: 0rem;
}
`;
const VideoPlayerContainer = styled.div`
position: relative;
width: 100%;
border-radius: var(--global-border-radius);
@media (min-width: 1000px) {
flex: 1 1 auto;
}
`;
const EpisodeListContainer = styled.div`
width: 100%;
max-height: 100%;
@media (min-width: 1000px) {
flex: 1 1 500px;
max-height: 100%;
}
@media (max-width: 1000px) {
padding-left: 0rem;
}
`;
const NoEpsFoundDiv = styled.div`
text-align: center;
margin-top: 7.5rem;
margin-bottom: 10rem;
@media (max-width: 1000px) {
margin-top: 2.5rem;
margin-bottom: 6rem;
}
`;
const NoEpsImage = styled.div`
margin-bottom: 3rem;
max-width: 100%;
img {
border-radius: var(--global-border-radius);
max-width: 100%;
@media (max-width: 500px) {
max-width: 70%;
}
}
`;
const StyledHomeButton = styled.button`
color: white;
border-radius: var(--global-border-radius);
border: none;
background-color: var(--primary-accent);
margin-top: 0.5rem;
font-weight: bold;
padding: 1rem;
position: absolute;
transform: translate(-50%, -50%);
transition: transform 0.2s ease-in-out;
&:hover,
&:active,
&:focus {
transform: translate(-50%, -50%) scale(1.05);
}
&:active {
transform: translate(-50%, -50%) scale(0.95);
}
`;
const IframeTrailer = styled.iframe`
position: relative;
border-radius: var(--global-border-radius);
border: none;
top: 0;
left: 0;
width: 70%;
height: 100%;
text-items: center;
@media (max-width: 1000px) {
width: 100%;
height: 100%;
}
`;
const LOCAL_STORAGE_KEYS = {
LAST_WATCHED_EPISODE: 'last-watched-',
WATCHED_EPISODES: 'watched-episodes-',
LAST_ANIME_VISITED: 'last-anime-visited',
};
// TODO Main Component
const Watch: React.FC = () => {
const videoPlayerContainerRef = useRef(null);
const [videoPlayerWidth, setVideoPlayerWidth] = useState('100%');
const getSourceTypeKey = (animeId: string | undefined) =>
`source-[${animeId}]`;
const getLanguageKey = (animeId: string | undefined) =>
`subOrDub-[${animeId}]`;
const updateVideoPlayerWidth = useCallback(() => {
if (videoPlayerContainerRef.current) {
const width = `${videoPlayerContainerRef.current.offsetWidth}px`;
setVideoPlayerWidth(width);
}
}, [setVideoPlayerWidth, videoPlayerContainerRef]);
const [maxEpisodeListHeight, setMaxEpisodeListHeight] =
useState('100%');
const { animeId, animeTitle, episodeNumber } = useParams<{
animeId?: string;
animeTitle?: string;
episodeNumber?: string;
}>();
const STORAGE_KEYS = {
SOURCE_TYPE: `source-[${animeId}]`,
LANGUAGE: `subOrDub-[${animeId}]`,
};
const navigate = useNavigate();
const [selectedBackgroundImage, setSelectedBackgroundImage] =
useState('');
const [episodes, setEpisodes] = useState([]);
const [currentEpisode, setCurrentEpisode] = useState({
id: '0',
number: 1,
title: '',
image: '',
description: '',
imageHash: '',
airDate: '',
});
const [animeInfo, setAnimeInfo] = useState(null);
const [loading, setLoading] = useState(true);
const [isEpisodeChanging, setIsEpisodeChanging] = useState(false);
const [showNoEpisodesMessage, setShowNoEpisodesMessage] = useState(false);
const [lastKeypressTime, setLastKeypressTime] = useState(0);
const [sourceType, setSourceType] = useState(
() => localStorage.getItem(STORAGE_KEYS.SOURCE_TYPE) || 'default',
);
const [embeddedVideoUrl, setEmbeddedVideoUrl] = useState('');
const [language, setLanguage] = useState(
() => localStorage.getItem(STORAGE_KEYS.LANGUAGE) || 'sub',
);
const [downloadLink, setDownloadLink] = useState('');
const nextEpisodeAiringTime =
animeInfo && animeInfo.nextAiringEpisode
? animeInfo.nextAiringEpisode.airingTime * 1000
: null;
const nextEpisodenumber = animeInfo?.nextAiringEpisode?.episode;
const countdown = useCountdown(nextEpisodeAiringTime);
const currentEpisodeIndex = episodes.findIndex(
(ep) => ep.id === currentEpisode.id,
);
const [languageChanged, setLanguageChanged] = useState(false);
//----------------------------------------------MORE VARIABLES----------------------------------------------
const GoToHomePageButton = () => {
const navigate = useNavigate();
const handleClick = () => {
navigate('/home');
};
return (
Go back Home
);
};
// TODO FETCH VIDSTREAMING VIDEO
const fetchVidstreamingUrl = async (episodeId: string) => {
try {
const embeddedServers = await fetchAnimeEmbeddedEpisodes(episodeId);
if (embeddedServers && embeddedServers.length > 0) {
const vidstreamingServer = embeddedServers.find(
(server: any) => server.name === 'Vidstreaming',
);
const selectedServer = vidstreamingServer || embeddedServers[0];
setEmbeddedVideoUrl(selectedServer.url);
}
} catch (error) {
console.error(
'Error fetching Vidstreaming servers for episode ID:',
episodeId,
error,
);
}
};
// TODO FETCH GOGO VIDEO
const fetchEmbeddedUrl = async (episodeId: string) => {
try {
const embeddedServers = await fetchAnimeEmbeddedEpisodes(episodeId);
if (embeddedServers && embeddedServers.length > 0) {
const gogoServer = embeddedServers.find(
(server: any) => server.name === 'Gogo server',
);
const selectedServer = gogoServer || embeddedServers[0];
setEmbeddedVideoUrl(selectedServer.url);
}
} catch (error) {
console.error(
'Error fetching gogo servers for episode ID:',
episodeId,
error,
);
}
};
// TODO SAVE TO LOCAL STORAGE NAVIGATED/CLICKED EPISODES
const updateWatchedEpisodes = (episode: Episode) => {
const watchedEpisodesJson = localStorage.getItem(
LOCAL_STORAGE_KEYS.WATCHED_EPISODES + animeId,
);
const watchedEpisodes: Episode[] = watchedEpisodesJson
? JSON.parse(watchedEpisodesJson)
: [];
if (!watchedEpisodes.some((ep) => ep.id === episode.id)) {
watchedEpisodes.push(episode);
localStorage.setItem(
LOCAL_STORAGE_KEYS.WATCHED_EPISODES + animeId,
JSON.stringify(watchedEpisodes),
);
}
};
// TODO UPDATES CURRENT EPISODE INFORMATION, UPDATES WATCHED EPISODES AND NAVIGATES TO NEW URL
const handleEpisodeSelect = useCallback(
async (selectedEpisode: Episode) => {
setIsEpisodeChanging(true);
const animeTitle = selectedEpisode.id.split('-episode')[0];
setCurrentEpisode({
id: selectedEpisode.id,
number: selectedEpisode.number,
image: selectedEpisode.image,
title: selectedEpisode.title,
description: selectedEpisode.description,
imageHash: selectedEpisode.imageHash,
airDate: selectedEpisode.airDate,
});
localStorage.setItem(
LOCAL_STORAGE_KEYS.LAST_WATCHED_EPISODE + animeId,
JSON.stringify({
id: selectedEpisode.id,
title: selectedEpisode.title,
number: selectedEpisode.number,
}),
);
updateWatchedEpisodes(selectedEpisode);
navigate(
`/watch/${animeId}/${encodeURI(animeTitle)}/${selectedEpisode.number}`,
{
replace: true,
},
);
await new Promise((resolve) => setTimeout(resolve, 100));
setIsEpisodeChanging(false);
},
[animeId, navigate],
);
// TODO UPDATE DOWNLOAD LINK WHEN EPISODE ID CHANGES
const updateDownloadLink = useCallback((link: string) => {
setDownloadLink(link);
}, []);
// TODO AUTOPLAY BUTTON TOGGLE PROPS
const handleEpisodeEnd = async () => {
const nextEpisodeIndex = currentEpisodeIndex + 1;
if (nextEpisodeIndex >= episodes.length) {
console.log('No more episodes.');
return;
}
handleEpisodeSelect(episodes[nextEpisodeIndex]);
};
// TODO NAVIGATE TO NEXT AND PREVIOUS EPISODES WITH SHIFT+N/P KEYBOARD COMBINATIONS (500MS DELAY)
const onPrevEpisode = () => {
const prevIndex = currentEpisodeIndex - 1;
if (prevIndex >= 0) {
handleEpisodeSelect(episodes[prevIndex]);
}
};
const onNextEpisode = () => {
const nextIndex = currentEpisodeIndex + 1;
if (nextIndex < episodes.length) {
handleEpisodeSelect(episodes[nextIndex]);
}
};
//----------------------------------------------USEFFECTS----------------------------------------------
// TODO SETS DEFAULT SOURCE TYPE AND LANGUGAE TO DEFAULT AND SUB
useEffect(() => {
const defaultSourceType = 'default';
const defaultLanguage = 'sub';
setSourceType(
localStorage.getItem(getSourceTypeKey(animeId || '')) ||
defaultSourceType,
);
setLanguage(
localStorage.getItem(getLanguageKey(animeId || '')) || defaultLanguage,
);
}, [animeId]);
// TODO SAVES LANGUAGE PREFERENCE TO LOCAL STORAGE
useEffect(() => {
localStorage.setItem(getLanguageKey(animeId), language);
}, [language, animeId]);
//FETCHES ANIME DATA AND ANIME INFO AS BACKUP
useEffect(() => {
let isMounted = true;
const fetchInfo = async () => {
if (!animeId) {
console.error('Anime ID is null.');
setLoading(false);
return;
}
setLoading(true);
try {
const info = await fetchAnimeData(animeId);
if (isMounted) {
setAnimeInfo(info);
}
} catch (error) {
console.error(
'Failed to fetch anime data, trying fetchAnimeInfo as a fallback:',
error,
);
try {
const fallbackInfo = await fetchAnimeInfo(animeId);
if (isMounted) {
setAnimeInfo(fallbackInfo);
}
} catch (fallbackError) {
console.error(
'Also failed to fetch anime info as a fallback:',
fallbackError,
);
} finally {
if (isMounted) setLoading(false);
}
}
};
fetchInfo();
return () => {
isMounted = false;
};
}, [animeId]);
// TODO FETCHES ANIME EPISODES BASED ON LANGUAGE, ANIME ID AND UPDATES COMPONENTS
useEffect(() => {
let isMounted = true;
const fetchData = async () => {
setLoading(true);
if (!animeId) return;
try {
const isDub = language === 'dub';
const animeData = await fetchAnimeEpisodes(animeId, undefined, isDub);
if (isMounted && animeData) {
const transformedEpisodes = animeData
.filter((ep: any) => ep.id.includes('-episode-')) // TODO Continue excluding entries without '-episode-'
.map((ep: any) => {
const episodePart = ep.id.split('-episode-')[1];
// TODO New regex to capture the episode number including cases like "7-5"
const episodeNumberMatch = episodePart.match(/^(\d+(?:-\d+)?)/);
return {
...ep,
number: episodeNumberMatch ? episodeNumberMatch[0] : ep.number,
id: ep.id,
title: ep.title,
image: ep.image,
};
});
setEpisodes(transformedEpisodes);
const navigateToEpisode = (() => {
if (languageChanged) {
const currentEpisodeNumber =
episodeNumber || currentEpisode.number;
return (
transformedEpisodes.find(
(ep: any) => ep.number === currentEpisodeNumber,
) || transformedEpisodes[transformedEpisodes.length - 1]
);
} else if (animeTitle && episodeNumber) {
const episodeId = `${animeTitle}-episode-${episodeNumber}`;
return (
transformedEpisodes.find((ep: any) => ep.id === episodeId) ||
navigate(`/watch/${animeId}`, { replace: true })
);
} else {
const savedEpisodeData = localStorage.getItem(
LOCAL_STORAGE_KEYS.LAST_WATCHED_EPISODE + animeId,
);
const savedEpisode = savedEpisodeData
? JSON.parse(savedEpisodeData)
: null;
return savedEpisode
? transformedEpisodes.find(
(ep: any) => ep.number === savedEpisode.number,
) || transformedEpisodes[0]
: transformedEpisodes[0];
}
})();
if (navigateToEpisode) {
setCurrentEpisode({
id: navigateToEpisode.id,
number: navigateToEpisode.number,
image: navigateToEpisode.image,
title: navigateToEpisode.title,
description: navigateToEpisode.description,
imageHash: navigateToEpisode.imageHash,
airDate: navigateToEpisode.airDate,
});
const newAnimeTitle = navigateToEpisode.id.split('-episode-')[0];
navigate(
`/watch/${animeId}/${newAnimeTitle}/${navigateToEpisode.number}`,
{ replace: true },
);
setLanguageChanged(false); // TODO Reset the languageChanged flag after handling the navigation
}
}
} catch (error) {
console.error('Failed to fetch episodes:', error);
} finally {
if (isMounted) setLoading(false);
}
};
// TODO Last visited cache to order continue watching
const updateLastVisited = () => {
if (!animeInfo || !animeId) return; // TODO Ensure both animeInfo and animeId are available
const lastVisited = localStorage.getItem(
LOCAL_STORAGE_KEYS.LAST_ANIME_VISITED,
);
const lastVisitedData = lastVisited ? JSON.parse(lastVisited) : {};
lastVisitedData[animeId] = {
timestamp: Date.now(),
titleEnglish: animeInfo.title.english, // TODO Assuming animeInfo contains the title in English
titleRomaji: animeInfo.title.romaji, // TODO Assuming animeInfo contains the title in Romaji
};
localStorage.setItem(
LOCAL_STORAGE_KEYS.LAST_ANIME_VISITED,
JSON.stringify(lastVisitedData),
);
};
if (animeId) {
updateLastVisited();
}
fetchData();
return () => {
isMounted = false;
};
}, [
animeId,
animeTitle,
episodeNumber,
navigate,
language,
languageChanged,
currentEpisode.number,
]);
// TODO FETCH EMBEDDED EPISODES IF VIDSTREAMING OR GOGO HAVE BEEN SELECTED
useEffect(() => {
if (sourceType === 'vidstreaming' && currentEpisode.id) {
fetchVidstreamingUrl(currentEpisode.id).catch(console.error);
} else if (sourceType === 'gogo' && currentEpisode.id) {
fetchEmbeddedUrl(currentEpisode.id).catch(console.error);
}
}, [sourceType, currentEpisode.id]);
// TODO UPDATE BACKGROUND IMAGE TO ANIME BANNER IF WIDTH IS UNDER 500PX / OR USE ANIME COVER IF NO BANNER FOUND
useEffect(() => {
const updateBackgroundImage = () => {
const episodeImage = currentEpisode.image;
const bannerImage = animeInfo?.cover || animeInfo?.artwork[3].img;
if (episodeImage && episodeImage !== animeInfo.image) {
const img = new Image();
img.onload = () => {
if (img.width > 500) {
setSelectedBackgroundImage(episodeImage);
} else {
setSelectedBackgroundImage(bannerImage);
}
};
img.onerror = () => {
setSelectedBackgroundImage(bannerImage);
};
img.src = episodeImage;
} else {
setSelectedBackgroundImage(bannerImage);
}
};
if (animeInfo && currentEpisode.id !== '0') {
updateBackgroundImage();
}
}, [animeInfo, currentEpisode]);
// TODO UPDATES VIDEOPLAYER WIDTH WHEN WINDOW GETS RESIZED
useEffect(() => {
updateVideoPlayerWidth();
const handleResize = () => {
updateVideoPlayerWidth();
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [updateVideoPlayerWidth]);
// TODO UPDATES EPISODE LIST MAX HEIGHT BASED ON VIDEO PLAYER CURRENT HEIGHT
useEffect(() => {
const updateMaxHeight = () => {
if (videoPlayerContainerRef.current) {
const height = videoPlayerContainerRef.current.offsetHeight;
setMaxEpisodeListHeight(`${height}px`);
}
};
updateMaxHeight();
window.addEventListener('resize', updateMaxHeight);
return () => window.removeEventListener('resize', updateMaxHeight);
}, []);
// TODO SAVES SOURCE TYPE PREFERENCE TO LOCAL STORAGE
useEffect(() => {
localStorage.setItem(getSourceTypeKey(animeId), sourceType);
}, [sourceType, animeId]);
// TODO NAVIGATE TO NEXT AND PREVIOUS EPISODES WITH SHIFT+N/P KEYBOARD COMBINATIONS (500MS DELAY)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const targetTagName = (event.target as HTMLElement).tagName.toLowerCase();
if (targetTagName === 'input' || targetTagName === 'textarea') {
return;
}
if (!event.shiftKey || !['N', 'P'].includes(event.key.toUpperCase()))
return;
const now = Date.now();
if (now - lastKeypressTime < 200) return;
setLastKeypressTime(now);
const currentIndex = episodes.findIndex(
(ep) => ep.id === currentEpisode.id,
);
if (
event.key.toUpperCase() === 'N' &&
currentIndex < episodes.length - 1
) {
const nextEpisode = episodes[currentIndex + 1];
handleEpisodeSelect(nextEpisode);
} else if (event.key.toUpperCase() === 'P' && currentIndex > 0) {
const prevEpisode = episodes[currentIndex - 1];
handleEpisodeSelect(prevEpisode);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [episodes, currentEpisode, handleEpisodeSelect, lastKeypressTime]);
// TODO SET PAGE TITLE TO MIRURO + ANIME TITLE
useEffect(() => {
if (animeInfo && animeInfo.title) {
document.title =
'Watch ' +
(animeInfo.title.english ||
animeInfo.title.romaji ||
animeInfo.title.romaji ||
'') +
' | Miruro';
}
}, [animeInfo]);
// TODO No idea
useEffect(() => {
let isMounted = true;
const fetchInfo = async () => {
if (!animeId) {
console.error('Anime ID is undefined.');
return;
}
try {
const info = await fetchAnimeData(animeId);
if (isMounted) {
setAnimeInfo(info);
}
} catch (error) {
console.error('Failed to fetch anime info:', error);
}
};
fetchInfo();
return () => {
isMounted = false;
};
}, [animeId]);
// TODO SHOW NO EPISODES DIV IF NO RESPONSE AFTER 10 SECONDS
useEffect(() => {
const timeoutId = setTimeout(() => {
if (!episodes || episodes.length === 0) {
setShowNoEpisodesMessage(true);
}
}, 10000);
return () => clearTimeout(timeoutId);
}, [loading, episodes]);
// TODO SHOW NO EPISODES DIV IF NOT LOADING AND NO EPISODES FOUND
useEffect(() => {
if (!loading && episodes.length === 0) {
setShowNoEpisodesMessage(true);
} else {
setShowNoEpisodesMessage(false);
}
}, [loading, episodes]);
return (
{animeInfo &&
animeInfo.status === 'Not yet aired' &&
animeInfo.trailer ? (
Time Remaining:
{animeInfo &&
animeInfo.nextAiringEpisode &&
countdown !== 'Airing now or aired' ? (
{countdown}
) : (
Unknown
)}
{animeInfo.trailer && (
)}
) : showNoEpisodesMessage ? (
No episodes found {':('}
) : (
{!showNoEpisodesMessage && (
<>
{loading ? (
) : sourceType === 'default' ? (
) : (
)}
{loading ? (
) : (
{
const episode = episodes.find((e) => e.id === episodeId);
if (episode) {
handleEpisodeSelect(episode);
}
}}
maxListHeight={maxEpisodeListHeight}
/>
)}
>
)}
)}
{animeInfo && animeInfo.status !== 'Not yet aired' && (
)}
{animeInfo && }
{animeInfo && }
);
};
export default Watch;
================================================
FILE: src/styles/animations.css
================================================
@keyframes slideUp {
0% {
opacity: 0;
transform: translateY(10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideDown {
0% {
opacity: 0;
transform: translateY(-20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideRight {
0% {
opacity: 0;
transform: translateX(-10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes dropDown {
0% {
max-height: 0;
}
100% {
max-height: 500px;
}
}
@keyframes slideDropDown {
0% {
opacity: 0;
transform: translateY(-20px);
max-height: 0;
}
100% {
opacity: 1;
transform: translateY(0);
max-height: 500px;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes popIn {
0% {
opacity: 0;
transform: scale(0.98);
}
100% {
opacity: 1;
transform: scale(1);
}
}
================================================
FILE: src/styles/globals.css
================================================
@import url('animations.css');
@import url('themes.css');
/* Basic Styles for the App */
body {
font-family:
ui-sans-serif,
system-ui,
sans-serif,
Apple Color Emoji,
Segoe UI Emoji,
Segoe UI Symbol,
Noto Color Emoji;
margin: 0 auto; /* Center the body content */
padding: 4.5rem 1rem 1.5rem; /* Top, Horizontal, Bottom padding */
max-width: 105rem; /* Max width to constrain content size */
background-color: var(
--global-primary-bg
); /* Background color from variable */
color: var(--global-text); /* Text color from variable */
transition: 0.2s ease; /* Smooth transition for changes */
}
/* Responsive Styles */
@media (max-width: 31.25rem) {
/* 500px */
body {
padding: 4rem 0.5rem 0.5rem; /* Adjust padding for small screens */
}
}
/* Text Selection Color */
::selection {
background-color: var(
--primary-accent-bg
); /* Background color when text is selected */
color: var(--primary-accent); /* Text color when selected */
}
/* Custom Scrollbar Styles */
/* Main scrollbar styling */
::-webkit-scrollbar {
width: 0.3125rem; /* 5px */
}
/* Track of the scrollbar, set to be transparent */
::-webkit-scrollbar-track {
background: transparent;
}
/* Handle of the scrollbar */
::-webkit-scrollbar-thumb {
background: #888; /* Gray color for the handle */
border-radius: 0.2rem; /* Rounded handle */
}
/* Handle color on hover */
::-webkit-scrollbar-thumb:hover {
background: #555; /* Darker gray when hovering */
}
================================================
FILE: src/styles/themes.css
================================================
/* Light Mode */
:root {
--global-primary-bg: #f5f5f5;
--global-primary-bg-tr: rgba(245, 245, 245, 0.8);
--global-secondary-bg: #e0e0e0;
--global-tertiary-bg: #eaeaea;
--global-div: #e0e0e0;
--global-div-tr: rgba(224, 224, 224, 0.5);
--global-border: rgba(8, 8, 8, 0.1);
--global-text: #333;
--global-text-muted: #888;
--global-card-bg: #fff;
--global-card-title-bg: #e8e8e8;
--global-card-shadow: rgba(0, 0, 0, 0.2);
--global-primary-skeleton: rgba(165, 165, 165, 0.1);
--global-secondary-skeleton: rgba(165, 165, 165, 0.3);
--global-button-bg: #e0e0e0;
--global-button-hover-bg: #c8c8c8;
--global-button-text: #333;
--global-button-shadow: rgba(255, 255, 255, 0.5);
--global-genre-button-bg: #d4d4d4;
--global-shadow: rgba(0, 0, 0, 0.1);
--global-border-radius: 0.3rem;
--primary-accent: #8080cf;
--primary-accent-bg: #595991;
--logo-text-transparent: url('/src/assets/miruro-text-transparent-black.webp');
--logo-transparent: url('/src/assets/miruro-transparent-black.webp');
--ongoing-dot-color: #aaff00;
--completed-indicator-color: #00aaff;
--cancelled-indicator-color: #ff0000;
--not-yet-aired-indicator-color: #ffa500;
--default-indicator-color: #808080;
}
/* Dark Mode */
:root.dark-mode {
--global-primary-bg: #080808;
--global-primary-bg-tr: rgba(8, 8, 8, 0.9);
--global-secondary-bg: #141414;
--global-tertiary-bg: #222222;
--global-div: #141414;
--global-div-tr: rgba(20, 20, 20, 0.5);
--global-border: rgba(245, 245, 245, 0.1);
--global-text: #e8e8e8;
--global-text-muted: #696969;
--global-card-bg: #181818;
--global-card-title-bg: #151515;
--global-card-shadow: rgba(0, 0, 0, 0.6);
--global-primary-skeleton: rgba(85, 85, 85, 0.1);
--global-secondary-skeleton: rgba(85, 85, 85, 0.3);
--global-button-bg: #202020;
--global-button-hover-bg: #292929;
--global-button-shadow: rgba(0, 0, 0, 0.6);
--global-button-text: #ebebeb;
--global-genre-button-bg: #222222;
--global-shadow: rgba(255, 255, 255, 0.08);
--logo-text-transparent: url('/src/assets/miruro-text-transparent-white.webp');
--logo-transparent: url('/src/assets/miruro-transparent-white.webp');
}
================================================
FILE: src/vite-env.d.ts
================================================
///
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"src",
"api"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}
================================================
FILE: tsconfig.node.json
================================================
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
================================================
FILE: vercel.json
================================================
{
"rewrites": [{ "source": "/(.*)", "destination": "/" }]
}
================================================
FILE: vite.config.ts
================================================
import { defineConfig, loadEnv, UserConfigExport, ConfigEnv } from 'vite';
import react from '@vitejs/plugin-react';
// This is a TypeScript Vite Config file.
// Comments are retained from both original configurations for clarity.
export default ({ mode }: ConfigEnv): UserConfigExport => {
// Load environment variables and merge them with process.env
// You can access Vite specific env variables here like VITE_NAME using process.env.VITE_NAME
process.env = { ...process.env, ...loadEnv(mode, process.cwd()) };
return defineConfig({
plugins: [react()], // Using React plugin from Vite
build: {
chunkSizeWarningLimit: 2000, // Control the size before showing a warning for chunk size
outDir: 'dist', // Specify your desired output directory
rollupOptions: {
output: {
manualChunks: {
lodash: ['lodash'], // Manually define chunk for lodash
vendor: ['react', 'react-dom'], // Manually define chunk for React and ReactDOM
},
},
},
},
server: {
port: parseInt(process.env.VITE_PORT || '5173'), // Use VITE_PORT from .env.local, default to 5173
open: true, // Automatically open the default browser when starting the server
},
});
};