├── .dockerignore ├── .env ├── .eslintrc.json ├── .github └── workflows │ └── cd.yaml ├── .gitignore ├── Dockerfile ├── README.md ├── bun.lock ├── components.json ├── docker-compose.yml ├── docs └── pb.json ├── logo.png ├── next.config.mjs ├── package.json ├── postcss.config.mjs ├── public ├── icon-192x192.png ├── icon-256x256.png ├── icon-384x384.png ├── icon-512x512.png ├── icon.png ├── loader.gif ├── manifest.json ├── sw.js └── workbox-1bb06f5e.js ├── src ├── app │ ├── anime │ │ ├── [slug] │ │ │ └── page.tsx │ │ └── watch │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── video-player-section.tsx │ ├── api │ │ ├── anime │ │ │ └── [id] │ │ │ │ ├── episodes │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ ├── episode │ │ │ ├── servers │ │ │ │ └── route.ts │ │ │ └── sources │ │ │ │ └── route.ts │ │ ├── health │ │ │ └── route.ts │ │ ├── home │ │ │ └── route.ts │ │ ├── import │ │ │ └── [provider] │ │ │ │ └── route.ts │ │ ├── schedule │ │ │ └── route.ts │ │ └── search │ │ │ ├── route.ts │ │ │ └── suggestion │ │ │ └── route.ts │ ├── error.tsx │ ├── fonts │ │ ├── GeistMonoVF.woff │ │ └── GeistVF.woff │ ├── global-error.tsx │ ├── globals.css │ ├── layout.tsx │ ├── loading.tsx │ ├── page.tsx │ ├── profile │ │ └── [username] │ │ │ ├── components │ │ │ ├── anilist-import.tsx │ │ │ ├── anime-heatmap.tsx │ │ │ └── anime-lists.tsx │ │ │ ├── heatmap.module.css │ │ │ └── page.tsx │ └── search │ │ ├── page.tsx │ │ └── search-results.tsx ├── assets │ ├── cover.png │ ├── dmca.json │ ├── error.gif │ ├── fonts │ │ ├── NightinTokyo-Bold.ttf │ │ ├── NightinTokyo.ttf │ │ └── NightinTokyoShadow.ttf │ ├── genkai.gif │ └── loader.gif ├── components │ ├── anime-card.tsx │ ├── anime-carousel.tsx │ ├── anime-episodes.tsx │ ├── anime-schedule.tsx │ ├── anime-sections.tsx │ ├── common │ │ ├── avatar.tsx │ │ ├── button-link.tsx │ │ ├── character-card.tsx │ │ ├── custom-button.tsx │ │ ├── episode-card.tsx │ │ ├── pagination.tsx │ │ ├── select.tsx │ │ └── tooltip.tsx │ ├── container.tsx │ ├── continue-watching.tsx │ ├── episode-playlist.tsx │ ├── featured-collection-card.tsx │ ├── featured-collection.tsx │ ├── footer.tsx │ ├── hero-section.tsx │ ├── kitsune-player.tsx │ ├── latest-episodes-section.tsx │ ├── login-popover-button.tsx │ ├── navbar-avatar.tsx │ ├── navbar.tsx │ ├── popular-section.tsx │ ├── search-bar.tsx │ ├── theme-provider.tsx │ ├── ui │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── blur-fade.tsx │ │ ├── button.tsx │ │ ├── carousel.tsx │ │ ├── dialog.tsx │ │ ├── input.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── tabs.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ └── tooltip.tsx │ ├── watch-button.tsx │ └── watch-trailer.tsx ├── constants │ ├── query-keys.ts │ ├── requests.ts │ ├── routes.ts │ └── search-filters.ts ├── hooks │ ├── use-anime-search-params.ts │ ├── use-debounce.ts │ ├── use-get-bookmark.tsx │ ├── use-get-last-episode-watched.ts │ ├── use-is-anime-watched.ts │ └── use-scroll-position.ts ├── icons │ ├── anilist.tsx │ ├── discord.tsx │ ├── mal.tsx │ └── spinner.tsx ├── lib │ ├── api.ts │ ├── art-player-skip.ts │ ├── hianime.ts │ ├── pocketbase.ts │ └── utils.ts ├── mutation │ └── get-anilist-animes.ts ├── providers │ └── query-provider.tsx ├── query │ ├── get-all-episodes.ts │ ├── get-anime-details.ts │ ├── get-anime-schedule.ts │ ├── get-banner-anime.ts │ ├── get-episode-data.ts │ ├── get-episode-servers.ts │ ├── get-home-page-data.ts │ ├── get-search-results.ts │ └── search-anime.ts ├── store │ ├── anime-store.ts │ └── auth-store.ts ├── types │ ├── anilist-animes.ts │ ├── anime-api-response.ts │ ├── anime-details.ts │ ├── anime-schedule.ts │ ├── anime.ts │ ├── episodes.ts │ ├── requests.ts │ └── watched-anime.ts └── utils │ ├── constants.ts │ ├── fallback-server.ts │ └── fonts.ts ├── tailwind.config.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.pnp 3 | .pnp.js 4 | .yarn/install-state.gz 5 | 6 | /coverage 7 | 8 | /out/ 9 | 10 | /build 11 | 12 | .DS_Store 13 | *.pem 14 | 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | 19 | .env*.local 20 | 21 | .vercel 22 | 23 | *.tsbuildinfo 24 | next-env.d.ts 25 | 26 | .git 27 | .github 28 | .gitignore 29 | .husky 30 | .vscode 31 | Dockerfile 32 | README.md 33 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_PROXY_URL=http://localhost:4040 2 | NEXT_PUBLIC_POCKETBASE_URL=http://localhost:8090 3 | NODE_ENV=development 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next/core-web-vitals", 4 | "next/typescript" 5 | ], 6 | "rules": { 7 | "@typescript-eslint/no-non-null-asserted-optional-chain": "off", 8 | "@typescript-eslint/no-explicit-any": "off", 9 | "react-hooks/exhaustive-deps": "off" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/cd.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy Kitsune 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up Docker Buildx 17 | uses: docker/setup-buildx-action@v2 18 | 19 | - name: Log in to Docker Hub 20 | uses: docker/login-action@v2 21 | with: 22 | username: ${{ secrets.DOCKER_USERNAME }} 23 | password: ${{ secrets.DOCKER_TOKEN }} 24 | 25 | - name: Create env file 26 | uses: SpicyPizza/create-envfile@v2.0 27 | with: 28 | envkey_NEXT_PUBLIC_PROXY_URL: ${{secrets.NEXT_PUBLIC_PROXY_URL}} 29 | envkey_NEXT_PUBLIC_POCKETBASE_URL: ${{secrets.NEXT_PUBLIC_POCKETBASE_URL}} 30 | 31 | - name: Build and push Docker image 32 | uses: docker/build-push-action@v4 33 | with: 34 | context: . 35 | push: true 36 | tags: dovakiin0/kitsune:latest, dovakiin0/kitsune:${{ github.sha }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # Sentry Config File 39 | .env.sentry-build-plugin 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS base 2 | 3 | # 1. Install dependencies only when needed 4 | FROM base AS deps 5 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 6 | RUN apk add --no-cache libc6-compat 7 | 8 | WORKDIR /app 9 | 10 | # Install dependencies based on the preferred package manager 11 | COPY package.json ./ 12 | RUN npm install 13 | 14 | # 2. Rebuild the source code only when needed 15 | FROM base AS builder 16 | WORKDIR /app 17 | COPY --from=deps /app/node_modules ./node_modules 18 | COPY . . 19 | 20 | ENV NODE_ENV=production 21 | 22 | RUN npm run build 23 | 24 | # 3. Production image, copy all the files and run next 25 | FROM base AS runner 26 | WORKDIR /app 27 | 28 | LABEL org.opencontainers.image.title="Kitsune" \ 29 | org.opencontainers.image.description="Anime streaming app" \ 30 | org.opencontainers.image.maintainer="dovakiin0@kitsunee.online" \ 31 | org.opencontainers.image.source="https://github.com/dovakiin0/Kitsune.git" \ 32 | org.opencontainers.image.vendor="kitsunee.online" 33 | 34 | RUN addgroup -g 1001 -S nodejs 35 | RUN adduser -S nextjs -u 1001 36 | 37 | COPY --from=builder /app/public ./public 38 | 39 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone/ ./ 40 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static/ ./.next/static/ 41 | 42 | USER nextjs 43 | 44 | EXPOSE 3000 45 | 46 | CMD ["node", "server.js"] 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo.png](logo.png) 2 | 3 | Watch your favourite anime anywhere, anytime. No Ads. 4 | 5 | Kitsune is a free, open-source anime streaming website. It is built using the [Next Js](https://nextjs.org/) framework, [Shadcn/ui](https://ui.shadcn.com), and [Tailwind CSS](https://tailwindcss.com/). 6 | 7 | _Kitsune is still under development and may encounter many bugs. Feel free to open any issue regarding bugs or features_ 8 | 9 | ## Features 10 | 11 | - **No Ads** - No ads, no popups, no redirects, no bullshit. 12 | - **PWA Support** - Kitsune is a PWA, which means you can install it on your phone. 13 | 14 | ## Contributing 15 | 16 | ``` 17 | fork the repo 18 | 19 | git clone 20 | git checkout -b 21 | git add 22 | git commit -m "New feature" 23 | git push origin 24 | 25 | then submit a pull request 26 | ``` 27 | 28 | ## Local Development 29 | 30 | ### Prerequisite 31 | 32 | - Node js 33 | - Golang (if you wish to use my proxy server) 34 | - [Pocketbase](https://pocketbase.io) 35 | 36 | ### Proxy Server 37 | 38 | Head over to [Proxy-M3U8](https://github.com/Dovakiin0/proxy-m3u8) and follow the instruction to setup. or if you wish to use your own proxy server, feel free. 39 | 40 | ### Pocketbase 41 | 42 | Follow the instruction from the official website. Setup initial superadmin credentials. Once inside dashboard, go to settings > Import Collections and paste the content from [collection-JSON](https://github.com/Dovakiin0/Kitsune/blob/master/docs/pb.json) and click import. 43 | 44 | You will need discord client secret if you wish to use login from discord feature. 45 | 46 | ### Frontend 47 | 48 | Clone the repo and `cd Kitsune/`. 49 | Open `.env` file and change the port. if you are using the above proxy and pocketbase then you are good to go. Then, 50 | 51 | ``` 52 | npm install 53 | npm run dev 54 | ``` 55 | 56 | ### Using Docker 57 | 58 | There is a `docker-compose.yaml` file which you can use to run the both frontend and server. 59 | Simply run `docker compose up -d`. 60 | 61 | ## Support 62 | 63 | Join the Discord server: 64 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | kitsune: 3 | image: dovakiin0/kitsune:latest 4 | ports: 5 | - "3000:3000" 6 | environment: 7 | - NEXT_PUBLIC_POCKETBASE_URL=http://localhost:8090 8 | - NEXT_PUBLIC_PROXY_URL=http://localhost:4040 9 | 10 | proxy: 11 | image: dovakiin0/proxy-m3u8:latest 12 | ports: 13 | - "4040:4040" 14 | environment: 15 | - PORT=4040 16 | - CORS_DOMAIN=localhost:3000 17 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dovakiin0/Kitsune/74d105d75dc225a98dca7c9b6566c067a5544f1a/logo.png -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import withPWA from "next-pwa"; 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | output: "standalone", 6 | reactStrictMode: true, 7 | images: { 8 | remotePatterns: [ 9 | { 10 | protocol: "https", 11 | hostname: "**", 12 | }, 13 | ], 14 | }, 15 | }; 16 | 17 | export default withPWA({ 18 | dest: "public", 19 | register: true, 20 | skipWaiting: true, 21 | disable: process.env.NODE_ENV === "development", 22 | })(nextConfig); 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kitsune", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-avatar": "^1.1.4", 13 | "@radix-ui/react-dialog": "^1.1.14", 14 | "@radix-ui/react-icons": "^1.3.2", 15 | "@radix-ui/react-menubar": "^1.1.15", 16 | "@radix-ui/react-popover": "^1.1.7", 17 | "@radix-ui/react-progress": "^1.1.4", 18 | "@radix-ui/react-select": "^2.1.7", 19 | "@radix-ui/react-separator": "^1.1.2", 20 | "@radix-ui/react-slot": "^1.2.3", 21 | "@radix-ui/react-switch": "^1.2.0", 22 | "@radix-ui/react-tabs": "^1.1.3", 23 | "@radix-ui/react-toggle": "^1.1.9", 24 | "@radix-ui/react-toggle-group": "^1.1.10", 25 | "@radix-ui/react-tooltip": "^1.2.0", 26 | "aniwatch": "^2.23.0", 27 | "artplayer": "^5.2.2", 28 | "artplayer-plugin-ambilight": "^1.0.0", 29 | "artplayer-plugin-hls-control": "^1.0.1", 30 | "axios": "^1.7.7", 31 | "class-variance-authority": "^0.7.0", 32 | "clsx": "^2.1.1", 33 | "embla-carousel-react": "^8.3.0", 34 | "framer-motion": "^11.11.17", 35 | "hls.js": "^1.5.20", 36 | "html-react-parser": "^5.1.18", 37 | "lucide": "^0.447.0", 38 | "lucide-react": "^0.447.0", 39 | "next": "15.3.0", 40 | "next-pwa": "^5.6.0", 41 | "next-runtime-env": "^3.2.2", 42 | "next-themes": "^0.3.0", 43 | "pocketbase": "^0.25.2", 44 | "react": "^18.3.0", 45 | "react-calendar-heatmap": "^1.10.0", 46 | "react-dom": "^18.3.0", 47 | "react-query": "^3.39.3", 48 | "react-tooltip": "^5.28.1", 49 | "sonner": "^2.0.3", 50 | "tailwind-merge": "^2.5.2", 51 | "tailwindcss-animate": "^1.0.7", 52 | "zustand": "^5.0.3" 53 | }, 54 | "devDependencies": { 55 | "@types/node": "^20", 56 | "@types/react": "^18", 57 | "@types/react-calendar-heatmap": "^1.9.0", 58 | "@types/react-dom": "^18", 59 | "eslint": "^8", 60 | "eslint-config-next": "14.2.14", 61 | "pino": "^9.7.0", 62 | "pino-pretty": "^13.0.0", 63 | "postcss": "^8", 64 | "tailwindcss": "^3.4.1", 65 | "typescript": "^5" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dovakiin0/Kitsune/74d105d75dc225a98dca7c9b6566c067a5544f1a/public/icon-192x192.png -------------------------------------------------------------------------------- /public/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dovakiin0/Kitsune/74d105d75dc225a98dca7c9b6566c067a5544f1a/public/icon-256x256.png -------------------------------------------------------------------------------- /public/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dovakiin0/Kitsune/74d105d75dc225a98dca7c9b6566c067a5544f1a/public/icon-384x384.png -------------------------------------------------------------------------------- /public/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dovakiin0/Kitsune/74d105d75dc225a98dca7c9b6566c067a5544f1a/public/icon-512x512.png -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dovakiin0/Kitsune/74d105d75dc225a98dca7c9b6566c067a5544f1a/public/icon.png -------------------------------------------------------------------------------- /public/loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dovakiin0/Kitsune/74d105d75dc225a98dca7c9b6566c067a5544f1a/public/loader.gif -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme_color": "#282A36", 3 | "background_color": "#282A36", 4 | "display": "standalone", 5 | "scope": "/", 6 | "start_url": "/", 7 | "name": "Kitsune", 8 | "short_name": "Kitsune", 9 | "description": "Watch anime with ease and no ads", 10 | "orientation": "portrait-primary", 11 | "icons": [ 12 | { 13 | "src": "/icon-192x192.png", 14 | "sizes": "192x192", 15 | "type": "image/png" 16 | }, 17 | { 18 | "src": "/icon-256x256.png", 19 | "sizes": "256x256", 20 | "type": "image/png" 21 | }, 22 | { 23 | "src": "/icon-384x384.png", 24 | "sizes": "384x384", 25 | "type": "image/png" 26 | }, 27 | { 28 | "src": "/icon-512x512.png", 29 | "sizes": "512x512", 30 | "type": "image/png" 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /src/app/anime/watch/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Loading from "@/app/loading"; 4 | import parse from "html-react-parser"; 5 | import { ROUTES } from "@/constants/routes"; 6 | 7 | import Container from "@/components/container"; 8 | import AnimeCard from "@/components/anime-card"; 9 | import { useAnimeStore } from "@/store/anime-store"; 10 | 11 | import EpisodePlaylist from "@/components/episode-playlist"; 12 | import Select, { ISelectOptions } from "@/components/common/select"; 13 | import { 14 | Ban, 15 | BookmarkCheck, 16 | CheckCheck, 17 | Hand, 18 | TvMinimalPlay, 19 | } from "lucide-react"; 20 | import { useRouter, useSearchParams } from "next/navigation"; 21 | import { useGetAnimeDetails } from "@/query/get-anime-details"; 22 | import React, { ReactNode, useEffect, useMemo, useState } from "react"; 23 | import AnimeCarousel from "@/components/anime-carousel"; 24 | import { IAnime } from "@/types/anime"; 25 | import useBookMarks from "@/hooks/use-get-bookmark"; 26 | import { toast } from "sonner"; 27 | import { useGetAllEpisodes } from "@/query/get-all-episodes"; 28 | 29 | type Props = { 30 | children: ReactNode; 31 | }; 32 | 33 | const SelectOptions: ISelectOptions[] = [ 34 | { 35 | value: "plan to watch", 36 | label: "Plan to Watch", 37 | icon: BookmarkCheck, 38 | }, 39 | { 40 | value: "watching", 41 | label: "Watching", 42 | icon: TvMinimalPlay, 43 | }, 44 | { 45 | value: "completed", 46 | label: "Completed", 47 | icon: CheckCheck, 48 | }, 49 | { 50 | value: "on hold", 51 | label: "On Hold", 52 | icon: Hand, 53 | }, 54 | { 55 | value: "dropped", 56 | label: "Dropped", 57 | icon: Ban, 58 | }, 59 | ]; 60 | 61 | const Layout = (props: Props) => { 62 | const searchParams = useSearchParams(); 63 | const { setAnime, setSelectedEpisode } = useAnimeStore(); 64 | const router = useRouter(); 65 | 66 | const currentAnimeId = useMemo( 67 | () => searchParams.get("anime"), 68 | [searchParams], 69 | ); 70 | const episodeId = searchParams.get("episode"); 71 | 72 | const [animeId, setAnimeId] = useState(currentAnimeId); 73 | 74 | useEffect(() => { 75 | if (currentAnimeId !== animeId) { 76 | setAnimeId(currentAnimeId); 77 | } 78 | 79 | if (episodeId) { 80 | setSelectedEpisode(episodeId); 81 | } 82 | }, [currentAnimeId, episodeId, animeId, setSelectedEpisode]); 83 | 84 | const { data: anime, isLoading } = useGetAnimeDetails(animeId as string); 85 | 86 | useEffect(() => { 87 | if (anime) { 88 | setAnime(anime); 89 | } 90 | }, [anime, setAnime]); 91 | 92 | useEffect(() => { 93 | if (!animeId) { 94 | router.push(ROUTES.HOME); 95 | } 96 | //eslint-disable-next-line 97 | }, [animeId]); 98 | 99 | const { bookmarks, createOrUpdateBookMark } = useBookMarks({ 100 | animeID: currentAnimeId as string, 101 | page: 1, 102 | per_page: 1, 103 | }); 104 | const [selected, setSelected] = useState(""); 105 | 106 | const handleSelect = async (value: string) => { 107 | const previousSelected = selected; 108 | setSelected(value); 109 | 110 | try { 111 | await createOrUpdateBookMark( 112 | currentAnimeId as string, 113 | anime?.anime.info.name!, 114 | anime?.anime.info.poster!, 115 | value, 116 | ); 117 | } catch (error) { 118 | console.log(error); 119 | setSelected(previousSelected); 120 | toast.error("Error adding to list", { style: { background: "red" } }); 121 | } 122 | }; 123 | 124 | const { data: episodes, isLoading: episodeLoading } = useGetAllEpisodes( 125 | animeId as string, 126 | ); 127 | 128 | if (isLoading) return ; 129 | 130 | return ( 131 | anime?.anime.info && ( 132 | 133 |
134 |
135 | {props.children} 136 |
137 | {episodes && ( 138 | 150 | )} 151 |
152 |
153 | 161 |
162 | setUsername(e.target.value)} 143 | /> 144 |
145 | 146 | 156 | 157 | 158 | )} 159 | {step === 2 && ( 160 | <> 161 |
162 |

163 | Following animes will be imported: 164 |

165 |
166 |
167 |
    168 | {animes.map((anime) => ( 169 |
  • 170 | {anime.name}:{" "} 171 | {anime.entries.length} entries 172 |
  • 173 | ))} 174 |
175 |
176 | 177 | {isLoading && ( 178 |

179 | Please be patient as it may take some while to import 180 |

181 | )} 182 | 192 |
193 | 194 | )} 195 | 196 | 197 | 198 | ); 199 | } 200 | 201 | export default AnilistImport; 202 | -------------------------------------------------------------------------------- /src/app/profile/[username]/components/anime-heatmap.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import CalendarHeatmap from "react-calendar-heatmap"; 3 | import { WatchHistory } from "@/hooks/use-get-bookmark"; 4 | import styles from "../heatmap.module.css"; 5 | import { useAuthStore } from "@/store/auth-store"; 6 | import { pb } from "@/lib/pocketbase"; 7 | import { toast } from "sonner"; 8 | import { Tooltip } from "react-tooltip"; 9 | 10 | type HeatmapValue = { 11 | date: string; 12 | count: number; 13 | }; 14 | 15 | export type BookmarkData = { 16 | watchHistory: string[]; 17 | }; 18 | 19 | function AnimeHeatmap() { 20 | const { auth } = useAuthStore(); 21 | const [heatmapData, setHeatmapData] = useState([]); 22 | const [totalContributionCount, setTotalContributionCount] = 23 | useState(0); 24 | 25 | const startDate = new Date(new Date().setMonth(0, 1)); 26 | const endDate = new Date(new Date().setMonth(11, 31)); 27 | 28 | // --- Data Fetching and Aggregation --- 29 | const fetchAndAggregateWatchHistory = async () => { 30 | if (!auth?.id) return; // Need authenticated user ID 31 | 32 | try { 33 | // 1. Get all bookmark records for the user 34 | const bookmarkRecords = await pb 35 | .collection("bookmarks") 36 | .getFullList({ 37 | filter: `user = "${auth.id}"`, 38 | fields: "watchHistory", // Only fetch the relation IDs needed 39 | }); 40 | 41 | if (!bookmarkRecords || bookmarkRecords.length === 0) { 42 | console.log("No bookmarks found for user."); 43 | setHeatmapData([]); 44 | setTotalContributionCount(0); 45 | return; 46 | } 47 | 48 | // 2. Collect all unique watched record IDs from all bookmarks 49 | const watchedRecordIds = bookmarkRecords.reduce( 50 | (acc: string[], bookmark) => { 51 | // Ensure watchHistory is an array and add its IDs to accumulator 52 | if (Array.isArray(bookmark.watchHistory)) { 53 | bookmark.watchHistory.forEach((id) => { 54 | if (!acc.includes(id)) { 55 | // Add only unique IDs 56 | acc.push(id); 57 | } 58 | }); 59 | } 60 | return acc; 61 | }, 62 | [], 63 | ); 64 | 65 | if (watchedRecordIds.length === 0) { 66 | setHeatmapData([]); 67 | setTotalContributionCount(0); 68 | return; 69 | } 70 | 71 | const watchedFilter = watchedRecordIds 72 | .map((id) => `id = "${id}"`) 73 | .join(" || "); 74 | 75 | try { 76 | // 4. Fetch all corresponding 'watched' records 77 | const watchedRecords = await pb 78 | .collection("watched") 79 | .getFullList({ 80 | filter: watchedFilter, 81 | fields: "created", // Only need the creation date 82 | }); 83 | const dailyCounts: { [key: string]: number } = {}; 84 | let totalCount = 0; 85 | 86 | watchedRecords.forEach((record) => { 87 | const dateStr = record.created.substring(0, 10); // Extracts "YYYY-MM-DD" 88 | 89 | if (dailyCounts[dateStr]) { 90 | dailyCounts[dateStr] += 1; 91 | } else { 92 | dailyCounts[dateStr] = 1; 93 | } 94 | totalCount += 1; 95 | }); 96 | 97 | const formattedData = Object.entries(dailyCounts).map( 98 | ([date, count]) => ({ 99 | date, 100 | count, 101 | }), 102 | ); 103 | 104 | setHeatmapData(formattedData); 105 | setTotalContributionCount(totalCount); 106 | } catch (error) { 107 | console.error("Error fetching watched records:", error); 108 | } 109 | } catch (error) { 110 | console.error("Error fetching or aggregating watch history:", error); 111 | toast.error("Failed to load watch activity."); 112 | setHeatmapData([]); // Clear data on error 113 | setTotalContributionCount(0); 114 | } 115 | }; 116 | 117 | useEffect(() => { 118 | if (!auth?.id) return; // Need authenticated user ID 119 | fetchAndAggregateWatchHistory(); 120 | }, []); 121 | 122 | const getClassForValue = (value: HeatmapValue | null): string => { 123 | if (!value || value.count === 0) { 124 | return styles.colorEmpty; 125 | } 126 | if (value.count >= 10) { 127 | return styles.colorScale4; 128 | } 129 | if (value.count >= 5) { 130 | return styles.colorScale3; 131 | } 132 | if (value.count >= 2) { 133 | return styles.colorScale2; 134 | } 135 | if (value.count >= 1) { 136 | return styles.colorScale1; 137 | } 138 | return styles.colorEmpty; 139 | }; 140 | 141 | const getTooltipContent = ( 142 | value: HeatmapValue | null, 143 | ): Record => { 144 | const val = value as HeatmapValue; 145 | if (!val.date) { 146 | return { 147 | "data-tooltip-id": "heatmap-tooltip", 148 | "data-tooltip-content": "No episodes watched", 149 | }; 150 | } 151 | const formatedDate = new Date(val.date).toLocaleDateString("en-US", { 152 | month: "short", 153 | day: "numeric", 154 | }); 155 | return { 156 | "data-tooltip-id": "heatmap-tooltip", 157 | "data-tooltip-content": `Watched ${val.count} episodes on ${formatedDate}`, 158 | } as Record; 159 | }; 160 | 161 | return ( 162 | <> 163 |

164 | Watched {totalContributionCount} episodes in the last year 165 |

166 | 173 | getClassForValue(value as unknown as HeatmapValue) 174 | } 175 | values={heatmapData} 176 | gutterSize={2} 177 | tooltipDataAttrs={(value) => getTooltipContent(value as HeatmapValue)} 178 | /> 179 | 180 | 181 | ); 182 | } 183 | 184 | export default AnimeHeatmap; 185 | -------------------------------------------------------------------------------- /src/app/profile/[username]/components/anime-lists.tsx: -------------------------------------------------------------------------------- 1 | import AnimeCard from "@/components/anime-card"; 2 | import Pagination from "@/components/common/pagination"; 3 | import { ROUTES } from "@/constants/routes"; 4 | import useBookMarks from "@/hooks/use-get-bookmark"; 5 | import React from "react"; 6 | 7 | type Props = { 8 | status: string; 9 | }; 10 | 11 | function AnimeLists(props: Props) { 12 | const [currentPage, setPage] = React.useState(1); 13 | 14 | const { bookmarks, totalPages, isLoading } = useBookMarks({ 15 | status: props.status, 16 | page: currentPage, 17 | per_page: 8, 18 | }); 19 | 20 | const handleNextPage = () => { 21 | if (currentPage < totalPages) { 22 | setPage((prevPage) => prevPage + 1); 23 | } 24 | }; 25 | 26 | const handlePreviousPage = () => { 27 | if (currentPage > 1) { 28 | setPage((prevPage) => prevPage - 1); 29 | } 30 | }; 31 | 32 | const handlePageChange = (pageNumber: number) => { 33 | if (pageNumber < 1 || pageNumber > totalPages) return; 34 | setPage(pageNumber); 35 | }; 36 | 37 | if (isLoading) { 38 | return ( 39 |
40 |

Loading...

41 |
42 | ); 43 | } 44 | 45 | return bookmarks && bookmarks.length > 0 ? ( 46 | <> 47 |
48 | {bookmarks.map((bookmark) => { 49 | const latestEpisode = bookmark.expand.watchHistory 50 | ? bookmark.expand.watchHistory.sort( 51 | (a, b) => b.episodeNumber - a.episodeNumber, 52 | )[0] 53 | : null; 54 | 55 | const url = latestEpisode 56 | ? `${ROUTES.WATCH}?anime=${bookmark.animeId}&episode=${latestEpisode.episodeId}` 57 | : `${ROUTES.ANIME_DETAILS}/${bookmark.animeId}`; 58 | 59 | return ( 60 | 68 | ); 69 | })} 70 |
71 | {totalPages > 1 && ( 72 | 79 | )} 80 | 81 | ) : ( 82 |
83 |

No anime found

84 |
85 | ); 86 | } 87 | 88 | export default AnimeLists; 89 | -------------------------------------------------------------------------------- /src/app/profile/[username]/heatmap.module.css: -------------------------------------------------------------------------------- 1 | /* Empty/No Contribution Days */ 2 | .colorEmpty { 3 | fill: #1c1c1c; 4 | } 5 | 6 | /* Lightest Pink/Red */ 7 | .colorScale1 { 8 | fill: #f7acbc; 9 | } 10 | 11 | /* Medium Light Pink/Red */ 12 | .colorScale2 { 13 | fill: #f17896; 14 | } 15 | 16 | /* Primary Color */ 17 | .colorScale3 { 18 | fill: #e9376b; 19 | } 20 | 21 | /* Darker Shade */ 22 | .colorScale4 { 23 | fill: #b82a53; 24 | } 25 | -------------------------------------------------------------------------------- /src/app/profile/[username]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useEffect } from "react"; 3 | import Container from "@/components/container"; 4 | import Avatar from "@/components/common/avatar"; 5 | import { useAuthHydrated, useAuthStore } from "@/store/auth-store"; 6 | import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; 7 | import { useRouter } from "next/navigation"; 8 | import { pb } from "@/lib/pocketbase"; 9 | import { toast } from "sonner"; 10 | import Image from "next/image"; 11 | import CoverImage from "@/assets/cover.png"; 12 | import AnimeLists from "./components/anime-lists"; 13 | import AnimeHeatmap from "./components/anime-heatmap"; 14 | import Loading from "@/app/loading"; 15 | import AnilistImport from "./components/anilist-import"; 16 | 17 | function ProfilePage() { 18 | const { auth, setAuth } = useAuthStore(); 19 | const router = useRouter(); 20 | const fileInputRef = React.useRef(null); 21 | const hasHydrated = useAuthHydrated(); 22 | 23 | useEffect(() => { 24 | if (hasHydrated && !auth) { 25 | router.replace("/"); 26 | } 27 | }, [auth, hasHydrated, router]); 28 | 29 | if (!hasHydrated) { 30 | return ; 31 | } 32 | 33 | if (!auth) { 34 | return null; 35 | } 36 | 37 | const handleFileChange = async ( 38 | event: React.ChangeEvent, 39 | ) => { 40 | const file = event.target.files?.[0]; 41 | if (file) { 42 | const res = await pb.collection("users").update(auth.id, { 43 | avatar: file, 44 | }); 45 | 46 | if (res) { 47 | setAuth({ ...auth, avatar: res.avatar }); 48 | toast.success("Avatar updated successfully", { 49 | style: { background: "green" }, 50 | }); 51 | } 52 | } 53 | }; 54 | 55 | return ( 56 | <> 57 |
58 | {"cover"} 67 |
68 | 69 |
70 | { 77 | if (fileInputRef.current) { 78 | fileInputRef.current.click(); 79 | } 80 | }} 81 | /> 82 | 89 |

@{auth.username}

90 |
91 |
92 |
93 |
94 |

Import:

95 | 96 |
97 | 98 | 99 | Watching 100 | Plan To Watch 101 | On Hold 102 | Completed 103 | Dropped 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 |
123 |
124 | 125 |
126 |
127 |
128 | 129 | ); 130 | } 131 | 132 | export default ProfilePage; 133 | -------------------------------------------------------------------------------- /src/app/search/page.tsx: -------------------------------------------------------------------------------- 1 | import Container from "@/components/container"; 2 | import React from "react"; 3 | import SearchResults from "./search-results"; 4 | 5 | const page = () => { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default page; 14 | 15 | -------------------------------------------------------------------------------- /src/assets/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dovakiin0/Kitsune/74d105d75dc225a98dca7c9b6566c067a5544f1a/src/assets/cover.png -------------------------------------------------------------------------------- /src/assets/dmca.json: -------------------------------------------------------------------------------- 1 | { 2 | "animes": [""] 3 | } 4 | -------------------------------------------------------------------------------- /src/assets/error.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dovakiin0/Kitsune/74d105d75dc225a98dca7c9b6566c067a5544f1a/src/assets/error.gif -------------------------------------------------------------------------------- /src/assets/fonts/NightinTokyo-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dovakiin0/Kitsune/74d105d75dc225a98dca7c9b6566c067a5544f1a/src/assets/fonts/NightinTokyo-Bold.ttf -------------------------------------------------------------------------------- /src/assets/fonts/NightinTokyo.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dovakiin0/Kitsune/74d105d75dc225a98dca7c9b6566c067a5544f1a/src/assets/fonts/NightinTokyo.ttf -------------------------------------------------------------------------------- /src/assets/fonts/NightinTokyoShadow.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dovakiin0/Kitsune/74d105d75dc225a98dca7c9b6566c067a5544f1a/src/assets/fonts/NightinTokyoShadow.ttf -------------------------------------------------------------------------------- /src/assets/genkai.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dovakiin0/Kitsune/74d105d75dc225a98dca7c9b6566c067a5544f1a/src/assets/genkai.gif -------------------------------------------------------------------------------- /src/assets/loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Dovakiin0/Kitsune/74d105d75dc225a98dca7c9b6566c067a5544f1a/src/assets/loader.gif -------------------------------------------------------------------------------- /src/components/anime-card.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Link from "next/link"; 3 | import Image from "next/image"; 4 | 5 | import { cn, formatSecondsToMMSS } from "@/lib/utils"; 6 | import { Badge } from "./ui/badge"; 7 | import { Captions, Mic } from "lucide-react"; 8 | import { WatchHistory } from "@/hooks/use-get-bookmark"; 9 | import { Progress } from "./ui/progress"; 10 | 11 | type Props = { 12 | className?: string; 13 | poster: string; 14 | title: string; 15 | episodeCard?: boolean; 16 | sub?: number | null; 17 | dub?: number | null; 18 | subTitle?: string; 19 | displayDetails?: boolean; 20 | variant?: "sm" | "lg"; 21 | href?: string; 22 | showGenre?: boolean; 23 | watchDetail?: WatchHistory | null; 24 | }; 25 | 26 | const AnimeCard = ({ 27 | displayDetails = true, 28 | // showGenre = true, 29 | variant = "sm", 30 | ...props 31 | }: Props) => { 32 | const safeCurrent = 33 | typeof props.watchDetail?.current === "number" 34 | ? props.watchDetail.current 35 | : 0; 36 | const safeTotal = 37 | typeof props.watchDetail?.timestamp === "number" && 38 | props.watchDetail.timestamp > 0 39 | ? props.watchDetail.timestamp 40 | : 0; 41 | 42 | const clampedCurrent = Math.min(safeCurrent, safeTotal); 43 | 44 | const percentage = safeTotal > 0 ? (clampedCurrent / safeTotal) * 100 : 0; 45 | 46 | return ( 47 | 48 |
59 | image 67 | {displayDetails && ( 68 | <> 69 |
70 |
71 |
{props.title}
72 | {props.watchDetail && ( 73 | <> 74 |

75 | Episode {props.watchDetail.episodeNumber} - 76 | {formatSecondsToMMSS(props.watchDetail.current)} / 77 | {formatSecondsToMMSS(props.watchDetail.timestamp)} 78 |

79 | 80 | 81 | )} 82 | {props.episodeCard ? ( 83 |
84 | {props.sub && ( 85 | 86 | 87 | {props.sub} 88 | 89 | )} 90 | {props.dub && ( 91 | 92 | 93 | {props.dub} 94 | 95 | )} 96 |

{props.subTitle}

97 |
98 | ) : ( 99 | {props.subTitle} 100 | )} 101 |
102 | 103 | )} 104 |
105 | 106 | ); 107 | }; 108 | 109 | export default AnimeCard; 110 | -------------------------------------------------------------------------------- /src/components/anime-carousel.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import AnimeCard from "./anime-card"; 5 | import { IAnime } from "@/types/anime"; 6 | import { 7 | Carousel, 8 | CarouselApi, 9 | CarouselContent, 10 | CarouselItem, 11 | } from "./ui/carousel"; 12 | import Button from "./common/custom-button"; 13 | import { ArrowLeft, ArrowRight } from "lucide-react"; 14 | import { cn } from "@/lib/utils"; 15 | import BlurFade from "./ui/blur-fade"; 16 | import { ROUTES } from "@/constants/routes"; 17 | 18 | type Props = { 19 | anime: IAnime[]; 20 | title: string; 21 | className?: string; 22 | }; 23 | 24 | const AnimeCarousel = (props: Props) => { 25 | const [api, setApi] = React.useState(); 26 | 27 | return ( 28 |
29 |
30 |
{props.title}
31 |
32 | 40 | 46 |
47 |
48 | 49 | 50 | {props.anime?.map((ani, idx) => ( 51 | 52 | 56 | 67 | 68 | 69 | ))} 70 | 71 | 72 |
73 | ); 74 | }; 75 | 76 | export default AnimeCarousel; 77 | -------------------------------------------------------------------------------- /src/components/anime-episodes.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useEffect, useState } from "react"; 4 | import { cn } from "@/lib/utils"; 5 | import { Input } from "./ui/input"; 6 | import { Search } from "lucide-react"; 7 | import EpisodeCard from "./common/episode-card"; 8 | import { useGetAllEpisodes } from "@/query/get-all-episodes"; 9 | import { Episode } from "@/types/episodes"; 10 | import { 11 | Select, 12 | SelectContent, 13 | SelectGroup, 14 | SelectItem, 15 | SelectTrigger, 16 | SelectValue, 17 | } from "./ui/select"; 18 | 19 | type Props = { 20 | animeId: string; 21 | }; 22 | 23 | const AnimeEpisodes = ({ animeId }: Props) => { 24 | const [episodes, setEpisodes] = useState([]); 25 | const [allEpisodes, setAllEpisodes] = useState([]); 26 | const [ranges, setRanges] = useState([]); 27 | const [selectedRange, setSelectedRange] = useState(""); 28 | 29 | const { data, isLoading } = useGetAllEpisodes(animeId); 30 | 31 | useEffect(() => { 32 | if (data) { 33 | const episodes = data.episodes; 34 | setAllEpisodes(episodes); 35 | 36 | if (episodes.length > 50) { 37 | // Calculate ranges 38 | const rangesArray = []; 39 | for (let i = 0; i < episodes.length; i += 50) { 40 | const start = i + 1; 41 | const end = Math.min(i + 50, episodes.length); 42 | rangesArray.push(`${start}-${end}`); 43 | } 44 | setRanges(rangesArray); 45 | setSelectedRange(rangesArray[0]); 46 | 47 | // Filter the first range directly from episodes 48 | const filteredEpisodes = episodes.filter( 49 | (_, index) => index + 1 >= 1 && index + 1 <= 50, 50 | ); 51 | setEpisodes(filteredEpisodes); 52 | } else { 53 | setEpisodes(episodes); 54 | } 55 | } 56 | }, [data]); 57 | 58 | const handleRangeChange = (range: string) => { 59 | setSelectedRange(range); 60 | 61 | const [start, end] = range.split("-").map(Number); 62 | const filteredEpisodes = allEpisodes.filter( 63 | (_, index) => index + 1 >= start && index + 1 <= end, 64 | ); 65 | setEpisodes(filteredEpisodes); 66 | }; 67 | 68 | const handleSearch = (e: React.ChangeEvent) => { 69 | const query = e.target.value.toLowerCase(); 70 | if (!query) { 71 | // Reset episodes to the selected range 72 | const [start, end] = selectedRange.split("-").map(Number); 73 | const filteredEpisodes = allEpisodes.filter( 74 | (_, index) => index + 1 >= start && index + 1 <= end, 75 | ); 76 | setEpisodes(filteredEpisodes); 77 | } else { 78 | const filteredEpisodes = episodes.filter((episode, index) => { 79 | return ( 80 | (index + 1).toString().includes(query) || 81 | episode.title.toLowerCase().includes(query) || 82 | "episode".includes(query.trim()) 83 | ); 84 | }); 85 | setEpisodes(filteredEpisodes); 86 | } 87 | }; 88 | 89 | return ( 90 | <> 91 |
92 |

Episodes

93 |
94 | {ranges.length > 0 && ( 95 | 112 | )} 113 |
114 | 115 | 120 |
121 |
122 |
123 |
124 | {episodes.map((episode, idx) => ( 125 | 131 | ))} 132 | {!episodes.length && !isLoading && ( 133 |
134 | No Episodes 135 |
136 | )} 137 | {isLoading && 138 | Array.from({ length: 14 }).map((_, idx) => ( 139 |
146 | ))} 147 |
148 | 149 | ); 150 | }; 151 | 152 | export default AnimeEpisodes; 153 | -------------------------------------------------------------------------------- /src/components/anime-schedule.tsx: -------------------------------------------------------------------------------- 1 | import Container from "./container"; 2 | import React, { useMemo } from "react"; 3 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; 4 | import { useGetAnimeSchedule } from "@/query/get-anime-schedule"; 5 | import Button from "./common/custom-button"; 6 | import Link from "next/link"; 7 | import { ROUTES } from "@/constants/routes"; 8 | 9 | function AnimeSchedule() { 10 | const currentDate = new Date(); 11 | const currentDay = currentDate 12 | .toLocaleString("en-US", { weekday: "long" }) 13 | .toLowerCase(); 14 | const currentDayIndex = currentDate.getDay(); 15 | const daysOfWeek = [ 16 | "sunday", 17 | "monday", 18 | "tuesday", 19 | "wednesday", 20 | "thursday", 21 | "friday", 22 | "saturday", 23 | ]; 24 | const [currentSelectedTab, setCurrentSelectedTab] = 25 | React.useState(currentDay); 26 | 27 | const defaultTab = daysOfWeek.includes(currentDay) ? currentDay : "monday"; 28 | 29 | const selectedDate = useMemo(() => { 30 | const date = getDateForWeekday(currentSelectedTab); 31 | date.setDate(date.getDate() + 1); // idk why i had to add 1 day, but the schedule API returns the next day 32 | return date.toLocaleDateString("en-US"); 33 | }, [currentSelectedTab, getDateForWeekday]); 34 | 35 | const { isLoading, data } = useGetAnimeSchedule(selectedDate); 36 | 37 | function getDateForWeekday(targetDay: string) { 38 | const targetIndex = daysOfWeek.indexOf(targetDay); 39 | const date = new Date(currentDate); 40 | const diff = targetIndex - currentDayIndex; 41 | date.setDate(currentDate.getDate() + diff); 42 | return date; 43 | } 44 | 45 | return ( 46 | 47 |
Schedule
48 | setCurrentSelectedTab(val)} 52 | value={currentSelectedTab} 53 | className="w-full" 54 | > 55 | 56 | {daysOfWeek.map((day) => ( 57 | 58 | {day.substring(0, 3).toUpperCase()} -{" "} 59 | {getDateForWeekday(day).toLocaleDateString("en-US", { 60 | month: "short", 61 | day: "numeric", 62 | })} 63 | 64 | ))} 65 | 66 | 67 | {isLoading ? ( 68 | 69 | ) : ( 70 | daysOfWeek.map((day) => ( 71 | 72 | {day === currentSelectedTab && ( 73 |
74 | {data?.scheduledAnimes.map((anime) => ( 75 |
79 |
80 |

81 | {new Date(anime.airingTimestamp).toLocaleTimeString( 82 | "en-US", 83 | { 84 | hour: "numeric", 85 | minute: "2-digit", 86 | hour12: true, 87 | }, 88 | )} 89 |

90 |

{anime.name}

91 |
92 | 93 | 99 | 100 |
101 | ))} 102 |
103 | )} 104 |
105 | )) 106 | )} 107 |
108 |
109 | ); 110 | } 111 | 112 | const LoadingSkeleton = () => { 113 | return ( 114 | 115 |
116 |
117 |
118 |
119 |
120 | ); 121 | }; 122 | 123 | export default AnimeSchedule; 124 | -------------------------------------------------------------------------------- /src/components/anime-sections.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import Container from "./container"; 5 | import AnimeCard from "./anime-card"; 6 | 7 | import BlurFade from "./ui/blur-fade"; 8 | import { IAnime } from "@/types/anime"; 9 | import { ROUTES } from "@/constants/routes"; 10 | 11 | type Props = { 12 | trendingAnime: IAnime[]; 13 | loading: boolean; 14 | title: string; 15 | }; 16 | 17 | const AnimeSections = (props: Props) => { 18 | if (props.loading) return ; 19 | return ( 20 | 21 |
{props.title}
22 |
23 | {props.trendingAnime.map((anime, idx) => ( 24 | 25 | 35 | 36 | ))} 37 |
38 |
39 | ); 40 | }; 41 | 42 | const LoadingSkeleton = () => { 43 | return ( 44 | 45 |
46 |
47 | {[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1].map((_, idx) => { 48 | return ( 49 |
53 | ); 54 | })} 55 |
56 |
57 | ); 58 | }; 59 | 60 | export default AnimeSections; 61 | -------------------------------------------------------------------------------- /src/components/common/avatar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Avatar as AvatarCN, 4 | AvatarFallback, 5 | AvatarImage, 6 | } from "@/components/ui/avatar"; 7 | import { env } from "next-runtime-env"; 8 | 9 | type Props = { 10 | url?: string; 11 | username?: string; 12 | collectionID?: string; 13 | id?: string; 14 | className?: string; 15 | onClick?: () => void; 16 | }; 17 | 18 | function Avatar({ 19 | url, 20 | username, 21 | id, 22 | className, 23 | collectionID, 24 | onClick, 25 | }: Props) { 26 | const src = `${env("NEXT_PUBLIC_POCKETBASE_URL")}/api/files/${collectionID}/${id}/${url}`; 27 | 28 | return ( 29 | 30 | 31 | 32 | {username?.charAt(0).toUpperCase()} 33 | {username?.charAt(1).toLowerCase()} 34 | 35 | 36 | ); 37 | } 38 | 39 | export default Avatar; 40 | -------------------------------------------------------------------------------- /src/components/common/button-link.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { cn } from "@/lib/utils"; 3 | import { LucideIcon } from "lucide-react"; 4 | 5 | import Link, { LinkProps } from "next/link"; 6 | import { buttonVariants } from "../ui/button"; 7 | import { VariantProps } from "class-variance-authority"; 8 | 9 | interface ButtonLinkProps 10 | extends LinkProps, 11 | VariantProps, 12 | Omit, "href" | "ref"> { 13 | children?: React.ReactNode; 14 | LeftIcon?: React.FC>; 15 | RightIcon?: React.FC> | LucideIcon; 16 | className?: string; 17 | classNames?: { 18 | leftIcon?: string; 19 | rightcon?: string; 20 | }; 21 | } 22 | 23 | const ButtonLink = React.forwardRef( 24 | ( 25 | { 26 | children, 27 | LeftIcon, 28 | RightIcon, 29 | classNames, 30 | variant, 31 | size, 32 | className, 33 | ...props 34 | }, 35 | ref, 36 | ) => { 37 | return ( 38 | 43 | {LeftIcon && ( 44 | 48 | )} 49 | 50 | {children} 51 | 52 | {RightIcon && ( 53 | 54 | )} 55 | 56 | ); 57 | }, 58 | ); 59 | 60 | ButtonLink.displayName = "ButtonLink"; 61 | 62 | export type { ButtonLinkProps }; 63 | export { ButtonLink }; 64 | -------------------------------------------------------------------------------- /src/components/common/character-card.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Image from "next/image"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | import { CharactersVoiceActor } from "@/types/anime-details"; 7 | 8 | type Props = { 9 | className?: string; 10 | character: CharactersVoiceActor; 11 | }; 12 | 13 | const CharacterCard = ({ ...props }: Props) => { 14 | return ( 15 |
23 | image 31 | 32 |
33 |
34 |
{props.character.character.name}
35 |

{props.character.character.cast}

36 |
37 |
38 | ); 39 | }; 40 | 41 | export default CharacterCard; 42 | -------------------------------------------------------------------------------- /src/components/common/custom-button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Button as ShadButton, 4 | ButtonProps as IButtonProps, 5 | } from "@/components/ui/button"; 6 | import { LucideIcon } from "lucide-react"; 7 | import { cn } from "@/lib/utils"; 8 | import { SpinnerIcon } from "@/icons/spinner"; 9 | 10 | interface ButtonProps extends IButtonProps { 11 | disabled?: boolean; 12 | loading?: boolean; 13 | iconClass?: string; 14 | iconColor?: string; 15 | LeftIcon?: React.FC> | LucideIcon; 16 | RightIcon?: React.FC> | LucideIcon; 17 | } 18 | 19 | const Button = React.forwardRef( 20 | ( 21 | { 22 | RightIcon, 23 | LeftIcon, 24 | loading, 25 | iconClass, 26 | iconColor, 27 | children, 28 | className, 29 | ...props 30 | }, 31 | ref, 32 | ) => { 33 | return ( 34 | 45 | {loading && } 46 | 47 | {LeftIcon && ( 48 | 53 | )} 54 | 55 | {children} 56 | 57 | {RightIcon && ( 58 | 59 | )} 60 | 61 | ); 62 | }, 63 | ); 64 | 65 | Button.displayName = "CustomButton"; 66 | 67 | export type { ButtonProps }; 68 | export default Button; 69 | -------------------------------------------------------------------------------- /src/components/common/episode-card.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | import { ROUTES } from "@/constants/routes"; 8 | import { Episode } from "@/types/episodes"; 9 | import { useAnimeStore } from "@/store/anime-store"; 10 | import { useHasAnimeWatched } from "@/hooks/use-is-anime-watched"; 11 | import { Captions, Mic } from "lucide-react"; 12 | import Link from "next/link"; 13 | import { WatchHistory } from "@/hooks/use-get-bookmark"; 14 | 15 | type Props = { 16 | className?: string; 17 | episode: Episode; 18 | showCard?: boolean; 19 | animeId: string; 20 | variant?: "card" | "list"; 21 | subOrDub?: { sub: number; dub: number }; 22 | watchedEpisodes?: WatchHistory[] | null; 23 | }; 24 | 25 | const EpisodeCard = ({ 26 | showCard = false, 27 | variant = "card", 28 | ...props 29 | }: Props) => { 30 | const { selectedEpisode } = useAnimeStore(); 31 | const { hasWatchedEpisode } = useHasAnimeWatched( 32 | props.animeId, 33 | props.episode.episodeId, 34 | props.watchedEpisodes!, 35 | ); 36 | 37 | if (showCard && variant === "card") { 38 | return ( 39 |
47 | {/* image */} 55 | 56 |
57 |
58 |
{`${props.episode.number}. ${props.episode.title}`}
59 | {/*

{props.episode.airDate}

*/} 60 |
61 |
62 | ); 63 | } else if (!showCard && variant === "card") { 64 | return ( 65 | 68 |
75 | {`Episode ${props.episode.number}`} 76 |
77 | 78 | ); 79 | } else { 80 | return ( 81 | 84 |
96 | {/*
*/} 97 | {/* {`Episode */} 105 | {/*
*/} 106 |

{`Episode ${props.episode.number}`}

107 | {props.subOrDub && props.episode.number <= props.subOrDub.sub && ( 108 | 109 | )} 110 | {props.subOrDub && props.episode.number <= props.subOrDub.dub && ( 111 | 112 | )} 113 |
114 | 115 | ); 116 | } 117 | }; 118 | 119 | export default EpisodeCard; 120 | -------------------------------------------------------------------------------- /src/components/common/pagination.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Pagination as PaginationShad, 4 | PaginationContent, 5 | PaginationEllipsis, 6 | PaginationItem, 7 | PaginationLink, 8 | PaginationNext, 9 | PaginationPrevious, 10 | } from "@/components/ui/pagination"; 11 | 12 | type Props = { 13 | currentPage: number; 14 | totalPages: number; 15 | handlePreviousPage: () => void; 16 | handleNextPage: () => void; 17 | handlePageChange: (pageNumber: number) => void; 18 | }; 19 | 20 | function Pagination({ 21 | currentPage, 22 | totalPages, 23 | handleNextPage, 24 | handlePreviousPage, 25 | handlePageChange, 26 | }: Props) { 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | {Array.from({ length: totalPages }, (_, index) => index + 1) 35 | .filter((page) => { 36 | return ( 37 | page === 1 || // Always show first page 38 | page === totalPages || // Always show last page 39 | Math.abs(page - currentPage) <= 1 // Show current and its neighbors 40 | ); 41 | }) 42 | .reduce((acc: (number | "...")[], page, i, arr) => { 43 | if (i > 0 && page - (arr[i - 1] as number) > 1) { 44 | acc.push("..."); 45 | } 46 | acc.push(page); 47 | return acc; 48 | }, []) 49 | .map((page, index) => ( 50 | typeof page === "number" && handlePageChange(page)} 53 | > 54 | {page === "..." ? ( 55 | 56 | ) : ( 57 | 58 | {page} 59 | 60 | )} 61 | 62 | ))} 63 | 64 | 65 | 66 | 67 | 68 | 69 | ); 70 | } 71 | 72 | export default Pagination; 73 | -------------------------------------------------------------------------------- /src/components/common/select.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Select as SelectCN, 4 | SelectContent, 5 | SelectGroup, 6 | SelectItem, 7 | SelectTrigger, 8 | SelectValue, 9 | } from "@/components/ui/select"; 10 | import { cn } from "@/lib/utils"; 11 | 12 | export type ISelectOptions = { 13 | value: string; 14 | label: string; 15 | icon?: React.FC; 16 | }; 17 | 18 | type Props = { 19 | placeholder: string; 20 | placeholderIcon?: React.FC; 21 | options: ISelectOptions[]; 22 | value?: string; 23 | onChange?: (value: string) => void; 24 | className?: string; 25 | }; 26 | 27 | function Select(props: Props) { 28 | const value = props.options.find((option) => option.value === props.value); 29 | 30 | return ( 31 | 32 | 33 | {value?.icon ? ( 34 | 35 | ) : props.placeholderIcon ? ( 36 | 37 | ) : ( 38 | <> 39 | )} 40 | 41 | 42 | 43 | 44 | {props.options.map((option, i) => ( 45 |
46 | {option.icon && } 47 | {option.label} 48 |
49 | ))} 50 |
51 |
52 |
53 | ); 54 | } 55 | 56 | export default Select; 57 | -------------------------------------------------------------------------------- /src/components/common/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Tooltip as TooltipCN, 3 | TooltipContent, 4 | TooltipProvider, 5 | TooltipTrigger, 6 | } from "@/components/ui/tooltip"; 7 | 8 | type TooltipProps = { 9 | children: React.ReactNode; 10 | content: React.ReactNode; 11 | side?: "top" | "bottom" | "left" | "right"; 12 | className?: string; 13 | }; 14 | 15 | export default function Tooltip(props: TooltipProps) { 16 | return ( 17 | 18 | 19 | 20 | {props.children} 21 | 22 | 23 | {props.content} 24 | 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/components/container.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import React, { ReactNode } from "react"; 3 | 4 | type Props = { 5 | children?: ReactNode; 6 | className?: string; 7 | }; 8 | 9 | const Container = (props: Props) => { 10 | return ( 11 |
20 | {props.children} 21 |
22 | ); 23 | }; 24 | 25 | export default Container; 26 | 27 | -------------------------------------------------------------------------------- /src/components/continue-watching.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useEffect, useState } from "react"; 4 | import Container from "./container"; 5 | import AnimeCard from "./anime-card"; 6 | import { ROUTES } from "@/constants/routes"; 7 | import BlurFade from "./ui/blur-fade"; 8 | import { IAnime } from "@/types/anime"; 9 | import { History } from "lucide-react"; 10 | import useBookMarks, { WatchHistory } from "@/hooks/use-get-bookmark"; 11 | import { useAuthStore } from "@/store/auth-store"; 12 | 13 | type Props = { 14 | loading: boolean; 15 | }; 16 | 17 | interface WatchedAnime extends IAnime { 18 | episode: WatchHistory | string; 19 | } 20 | 21 | const ContinueWatching = (props: Props) => { 22 | const [anime, setAnime] = useState(null); 23 | 24 | const { auth } = useAuthStore(); 25 | const { bookmarks } = useBookMarks({ 26 | page: 1, 27 | per_page: 8, 28 | status: "watching", 29 | }); 30 | 31 | useEffect(() => { 32 | if (!auth) { 33 | if (typeof window !== "undefined") { 34 | const storedData = localStorage.getItem("watched"); 35 | const watchedAnimes: { 36 | anime: { id: string; title: string; poster: string }; 37 | episodes: string[]; 38 | }[] = storedData ? JSON.parse(storedData) : []; 39 | 40 | if (!Array.isArray(watchedAnimes)) { 41 | localStorage.removeItem("watched"); 42 | return; 43 | } 44 | 45 | const animes = watchedAnimes.reverse().map((anime) => ({ 46 | id: anime.anime.id, 47 | name: anime.anime.title, 48 | poster: anime.anime.poster, 49 | episode: anime.episodes[anime.episodes.length - 1], 50 | })); 51 | setAnime(animes as WatchedAnime[]); 52 | } 53 | } else { 54 | if (bookmarks && bookmarks.length > 0) { 55 | const animes = bookmarks.map((anime) => ({ 56 | id: anime.animeId, 57 | name: anime.animeTitle, 58 | poster: anime.thumbnail, 59 | episode: anime.expand.watchHistory 60 | ? anime.expand.watchHistory.sort( 61 | (a, b) => b.episodeNumber - a.episodeNumber, 62 | )[0] 63 | : null, 64 | })); 65 | setAnime(animes as WatchedAnime[]); 66 | } 67 | } 68 | }, [auth, bookmarks]); 69 | 70 | if (props.loading) return ; 71 | 72 | if ((!anime || !anime.length) && !props.loading) return <>; 73 | 74 | return ( 75 | 76 |
77 | 78 |
Continue Watching
79 |
80 |
81 | {anime?.map( 82 | (ani, idx) => 83 | ani.episode && ( 84 | 85 | 94 | 95 | ), 96 | )} 97 |
98 |
99 | ); 100 | }; 101 | 102 | const LoadingSkeleton = () => { 103 | return ( 104 | 105 |
106 |
107 | {[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1].map((_, idx) => { 108 | return ( 109 |
113 | ); 114 | })} 115 |
116 |
117 | ); 118 | }; 119 | 120 | export default ContinueWatching; 121 | -------------------------------------------------------------------------------- /src/components/featured-collection-card.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import AnimeCard from "./anime-card"; 3 | import { IAnime } from "@/types/anime"; 4 | import { ROUTES } from "@/constants/routes"; 5 | 6 | type Props = { 7 | title: string; 8 | anime: IAnime[]; 9 | }; 10 | 11 | const FeaturedCollectionCard = (props: Props) => { 12 | return ( 13 |
14 |
{props.title}
15 |
16 | 23 | {" "} 30 | 37 |
38 |
39 | ); 40 | }; 41 | 42 | export default FeaturedCollectionCard; 43 | -------------------------------------------------------------------------------- /src/components/featured-collection.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Container from "./container"; 3 | import FeaturedCollectionCard from "./featured-collection-card"; 4 | import { IAnime, LatestCompletedAnime } from "@/types/anime"; 5 | 6 | type Props = { 7 | featuredAnime: [ 8 | mostFavorite: { title: string; anime: IAnime[] }, 9 | mostPopular: { title: string; anime: IAnime[] }, 10 | latestCompleted: { title: string; anime: LatestCompletedAnime[] } 11 | ]; 12 | loading: boolean; 13 | }; 14 | 15 | const FeaturedCollection = ({ featuredAnime, loading }: Props) => { 16 | if (loading) return ; 17 | return ( 18 | 19 |
Featured Collection
20 |
21 | {featuredAnime.map((category, idx) => ( 22 | 27 | ))} 28 |
29 |
30 | ); 31 | }; 32 | 33 | const LoadingSkeleton = () => { 34 | return ( 35 | 36 |
37 |
38 | {[1, 1, 1].map((_, idx) => { 39 | return ( 40 |
44 | ); 45 | })} 46 |
47 |
48 | ); 49 | }; 50 | 51 | export default FeaturedCollection; 52 | 53 | -------------------------------------------------------------------------------- /src/components/footer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { DiscordLogoIcon, GitHubLogoIcon } from "@radix-ui/react-icons"; 3 | import Image from "next/image"; 4 | 5 | const Footer = () => { 6 | return ( 7 |
8 | logo 9 | 17 |

18 | Kitsune does not store any files on the server, we only link to the 19 | media which is hosted on 3rd party services. 20 |

21 |

© Kitsune

22 |
23 | ); 24 | }; 25 | 26 | export default Footer; 27 | -------------------------------------------------------------------------------- /src/components/hero-section.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Carousel, 5 | CarouselApi, 6 | CarouselContent, 7 | CarouselItem, 8 | } from "./ui/carousel"; 9 | 10 | import Container from "./container"; 11 | import { Button } from "./ui/button"; 12 | import parse from "html-react-parser"; 13 | 14 | import React from "react"; 15 | import { ArrowLeft, ArrowRight, Captions, Mic } from "lucide-react"; 16 | 17 | import { ROUTES } from "@/constants/routes"; 18 | import { ButtonLink } from "./common/button-link"; 19 | import { SpotlightAnime } from "@/types/anime"; 20 | import { Badge } from "./ui/badge"; 21 | 22 | type IHeroSectionProps = { 23 | spotlightAnime: SpotlightAnime[]; 24 | isDataLoading: boolean; 25 | }; 26 | 27 | const HeroSection = (props: IHeroSectionProps) => { 28 | const [api, setApi] = React.useState(); 29 | 30 | if (props.isDataLoading) return ; 31 | 32 | return ( 33 |
34 | 35 | 36 | {props?.spotlightAnime.map((anime, index) => ( 37 | 38 | 39 | 40 | ))} 41 | 42 | 43 |
44 | 52 | 58 |
59 |
60 | ); 61 | }; 62 | 63 | const HeroCarouselItem = ({ anime }: { anime: SpotlightAnime }) => { 64 | // const [isHovered, setIsHovered] = useState(false); 65 | 66 | // const hoverTimeoutRef = React.useRef(null); // Use ref to store the timeout ID 67 | 68 | // const handleMouseEnter = () => { 69 | // hoverTimeoutRef.current = setTimeout(() => { 70 | // setIsHovered(true); 71 | // }, 1500); 72 | // }; 73 | 74 | // const handleMouseLeave = () => { 75 | // if (hoverTimeoutRef.current) { 76 | // clearTimeout(hoverTimeoutRef.current); // Clear the timeout when mouse leaves 77 | // } 78 | // setIsHovered(false); 79 | // }; 80 | 81 | return ( 82 |
88 | {/* {isHovered && ( 89 |
90 | 97 |
98 | )} */} 99 | 100 | {/* Gradient Overlay */} 101 |
102 |
103 | 104 | {/* Content Section (remains outside the hover area) */} 105 |
106 | 107 |
108 | {/* Title and description moved inside the hover area */} 109 |

{anime?.name}

110 | 111 |
112 | {anime.episodes.sub && ( 113 | 114 | 115 | {anime.episodes.sub} 116 | 117 | )} 118 | {anime.episodes.dub && ( 119 | 120 | 121 | {anime.episodes.dub} 122 | 123 | )} 124 |
125 | 126 |

127 | {parse(anime?.description as string)} 128 |

129 |
130 | 134 | Learn More 135 | 136 | {/* 137 | Watch 138 | */} 139 |
140 |
141 |
142 |
143 |
144 | ); 145 | }; 146 | 147 | const LoadingSkeleton = () => { 148 | return ( 149 |
150 |
151 | 152 |
153 |
154 |
155 |
156 | 157 | 158 |
159 |
160 |
161 |
162 |
163 | 164 | 165 |
166 |
167 | ); 168 | }; 169 | export default HeroSection; 170 | -------------------------------------------------------------------------------- /src/components/latest-episodes-section.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import Container from "./container"; 5 | import AnimeCard from "./anime-card"; 6 | import { ROUTES } from "@/constants/routes"; 7 | import BlurFade from "./ui/blur-fade"; 8 | import { LatestCompletedAnime } from "@/types/anime"; 9 | 10 | type Props = { 11 | latestEpisodes: LatestCompletedAnime[]; 12 | loading: boolean; 13 | }; 14 | 15 | const LatestEpisodesAnime = (props: Props) => { 16 | if (props.loading) return ; 17 | return ( 18 | 19 |
Recent Releases
20 |
21 | {props.latestEpisodes?.map((anime, idx) => ( 22 | 23 | 33 | 34 | ))} 35 |
36 |
37 | ); 38 | }; 39 | 40 | const LoadingSkeleton = () => { 41 | return ( 42 | 43 |
44 |
45 | {[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1].map((_, idx) => { 46 | return ( 47 |
51 | ); 52 | })} 53 |
54 |
55 | ); 56 | }; 57 | 58 | export default LatestEpisodesAnime; 59 | -------------------------------------------------------------------------------- /src/components/navbar-avatar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Avatar from "./common/avatar"; 3 | import { IAuthStore } from "@/store/auth-store"; 4 | import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; 5 | import Link from "next/link"; 6 | import { User, LogOut } from "lucide-react"; 7 | import { pb } from "@/lib/pocketbase"; 8 | 9 | type Props = { 10 | auth: IAuthStore; 11 | }; 12 | 13 | function NavbarAvatar({ auth }: Props) { 14 | const [open, setOpen] = React.useState(false); 15 | 16 | return ( 17 | auth.auth && ( 18 | 19 | 20 | 26 | 27 | 28 |
29 |

30 | Hello, @{auth.auth.username} 31 |

32 |
33 |
34 | setOpen(false)} 38 | > 39 | 40 |

Profile

41 | 42 |
43 | 44 |
{ 47 | pb.authStore.clear(); 48 | auth.clearAuth(); 49 | }} 50 | > 51 | 52 |

Logout

53 |
54 |
55 |
56 | ) 57 | ); 58 | } 59 | 60 | export default NavbarAvatar; 61 | -------------------------------------------------------------------------------- /src/components/navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import Image from "next/image"; 5 | import { cn } from "@/lib/utils"; 6 | 7 | import Container from "./container"; 8 | import { Separator } from "./ui/separator"; 9 | 10 | import { nightTokyo } from "@/utils/fonts"; 11 | import { ROUTES } from "@/constants/routes"; 12 | import React, { ReactNode, useEffect, useState } from "react"; 13 | 14 | import SearchBar from "./search-bar"; 15 | import { MenuIcon, X } from "lucide-react"; 16 | import useScrollPosition from "@/hooks/use-scroll-position"; 17 | import { Sheet, SheetClose, SheetContent, SheetTrigger } from "./ui/sheet"; 18 | import LoginPopoverButton from "./login-popover-button"; 19 | import { useAuthStore } from "@/store/auth-store"; 20 | import { pb } from "@/lib/pocketbase"; 21 | import NavbarAvatar from "./navbar-avatar"; 22 | 23 | const menuItems: Array<{ title: string; href?: string }> = [ 24 | // { 25 | // title: "Home", 26 | // href: ROUTES.HOME, 27 | // }, 28 | // { 29 | // title: "Catalog", 30 | // }, 31 | // { 32 | // title: "News", 33 | // }, 34 | // { 35 | // title: "Collection", 36 | // }, 37 | ]; 38 | 39 | const NavBar = () => { 40 | const auth = useAuthStore(); 41 | const { y } = useScrollPosition(); 42 | const isHeaderFixed = true; 43 | const isHeaderSticky = y > 0; 44 | 45 | useEffect(() => { 46 | const refreshAuth = async () => { 47 | const auth_token = JSON.parse( 48 | localStorage.getItem("pocketbase_auth") as string, 49 | ); 50 | if (auth_token) { 51 | const user = await pb.collection("users").authRefresh(); 52 | if (user) { 53 | auth.setAuth({ 54 | id: user.record.id, 55 | email: user.record.email, 56 | username: user.record.username, 57 | avatar: user.record.avatar, 58 | collectionId: user.record.collectionId, 59 | collectionName: user.record.collectionName, 60 | autoSkip: user.record.autoSkip, 61 | }); 62 | } 63 | } 64 | }; 65 | refreshAuth(); 66 | }, []); 67 | 68 | return ( 69 |
79 | 80 | 84 | logo 85 |

91 | Kitsunee 92 |

93 | 94 |
95 | {menuItems.map((menu, idx) => ( 96 | 97 | {menu.title} 98 | 99 | ))} 100 |
101 |
102 | 103 | {auth.auth ? : } 104 |
105 |
106 | } /> 107 | {auth.auth ? : } 108 |
109 |
110 |
111 | ); 112 | }; 113 | 114 | const MobileMenuSheet = ({ trigger }: { trigger: ReactNode }) => { 115 | const [open, setOpen] = useState(false); 116 | return ( 117 | 118 | {trigger} 119 | e.preventDefault()} 123 | onCloseAutoFocus={(e) => e.preventDefault()} 124 | > 125 |
126 | 127 | 128 | 129 |
130 | {menuItems.map((menu, idx) => ( 131 | setOpen(false)} 135 | > 136 | {menu.title} 137 | 138 | ))} 139 | 140 | setOpen(false)} /> 141 |
142 |
143 |
144 |
145 | ); 146 | }; 147 | 148 | export default NavBar; 149 | -------------------------------------------------------------------------------- /src/components/popular-section.tsx: -------------------------------------------------------------------------------- 1 | // "use client"; 2 | // 3 | // import React from "react"; 4 | // import Container from "./container"; 5 | // import AnimeCard from "./anime-card"; 6 | // import { useGetPopularAnime } from "@/query/get-popular-anime"; 7 | // import { LIMIT } from "@/constants/requests"; 8 | // import Button from "./common/custom-button"; 9 | // import { cn } from "@/lib/utils"; 10 | // import BlurFade from "./ui/blur-fade"; 11 | // 12 | // const PopularSection = () => { 13 | // const { data, isLoading} = 14 | // useGetPopularAnime({ 15 | // limit: LIMIT, 16 | // page: 1, 17 | // }); 18 | // if (isLoading) return ; 19 | // return ( 20 | // 21 | //
Most Popular
22 | //
23 | // {/* {data?.pages.map((anime, idx) => */} 24 | // {/* anime.results.map((ani, index) => ( */} 25 | // {/* */} 26 | // {/* */} 32 | // {/* */} 33 | // {/* )) */} 34 | // {/* )} */} 35 | //
36 | // 44 | //
45 | // ); 46 | // }; 47 | // 48 | // const LoadingSkeleton = () => { 49 | // return ( 50 | // 51 | //
52 | //
53 | // {[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1].map((_, idx) => { 54 | // return ( 55 | //
59 | // ); 60 | // })} 61 | //
62 | //
63 | //
64 | // ); 65 | // }; 66 | // 67 | // export default PopularSection; 68 | -------------------------------------------------------------------------------- /src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { type ThemeProviderProps } from "next-themes/dist/types"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | 11 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /src/components/ui/blur-fade.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRef } from "react"; 4 | import { 5 | AnimatePresence, 6 | motion, 7 | useInView, 8 | UseInViewOptions, 9 | Variants, 10 | } from "framer-motion"; 11 | 12 | type MarginType = UseInViewOptions["margin"]; 13 | 14 | interface BlurFadeProps { 15 | children: React.ReactNode; 16 | className?: string; 17 | variant?: { 18 | hidden: { y: number }; 19 | visible: { y: number }; 20 | }; 21 | duration?: number; 22 | delay?: number; 23 | yOffset?: number; 24 | inView?: boolean; 25 | inViewMargin?: MarginType; 26 | blur?: string; 27 | } 28 | 29 | export default function BlurFade({ 30 | children, 31 | className, 32 | variant, 33 | duration = 0.4, 34 | delay = 0, 35 | yOffset = 6, 36 | inView = false, 37 | inViewMargin = "-50px", 38 | blur = "6px", 39 | }: BlurFadeProps) { 40 | const ref = useRef(null); 41 | const inViewResult = useInView(ref, { once: true, margin: inViewMargin }); 42 | const isInView = !inView || inViewResult; 43 | const defaultVariants: Variants = { 44 | hidden: { y: yOffset, opacity: 0, filter: `blur(${blur})` }, 45 | visible: { y: -yOffset, opacity: 1, filter: `blur(0px)` }, 46 | }; 47 | const combinedVariants = variant || defaultVariants; 48 | return ( 49 | 50 | 63 | {children} 64 | 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-10 px-4 py-2 text-lg", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ); 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean; 41 | loading?: boolean; 42 | } 43 | 44 | const Button = React.forwardRef( 45 | ({ className, variant, size, asChild = false, ...props }, ref) => { 46 | const Comp = asChild ? Slot : "button"; 47 | return ( 48 | 53 | ); 54 | } 55 | ); 56 | Button.displayName = "Button"; 57 | 58 | export { Button, buttonVariants }; 59 | 60 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { cn } from "@/lib/utils" 6 | import { Cross2Icon } from "@radix-ui/react-icons" 7 | 8 | const Dialog = DialogPrimitive.Root 9 | 10 | const DialogTrigger = DialogPrimitive.Trigger 11 | 12 | const DialogPortal = DialogPrimitive.Portal 13 | 14 | const DialogClose = DialogPrimitive.Close 15 | 16 | const DialogOverlay = React.forwardRef< 17 | React.ElementRef, 18 | React.ComponentPropsWithoutRef 19 | >(({ className, ...props }, ref) => ( 20 | 28 | )) 29 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 30 | 31 | const DialogContent = React.forwardRef< 32 | React.ElementRef, 33 | React.ComponentPropsWithoutRef 34 | >(({ className, children, ...props }, ref) => ( 35 | 36 | 37 | 45 | {children} 46 | 47 | 48 | Close 49 | 50 | 51 | 52 | )) 53 | DialogContent.displayName = DialogPrimitive.Content.displayName 54 | 55 | const DialogHeader = ({ 56 | className, 57 | ...props 58 | }: React.HTMLAttributes) => ( 59 |
66 | ) 67 | DialogHeader.displayName = "DialogHeader" 68 | 69 | const DialogFooter = ({ 70 | className, 71 | ...props 72 | }: React.HTMLAttributes) => ( 73 |
80 | ) 81 | DialogFooter.displayName = "DialogFooter" 82 | 83 | const DialogTitle = React.forwardRef< 84 | React.ElementRef, 85 | React.ComponentPropsWithoutRef 86 | >(({ className, ...props }, ref) => ( 87 | 95 | )) 96 | DialogTitle.displayName = DialogPrimitive.Title.displayName 97 | 98 | const DialogDescription = React.forwardRef< 99 | React.ElementRef, 100 | React.ComponentPropsWithoutRef 101 | >(({ className, ...props }, ref) => ( 102 | 107 | )) 108 | DialogDescription.displayName = DialogPrimitive.Description.displayName 109 | 110 | export { 111 | Dialog, 112 | DialogPortal, 113 | DialogOverlay, 114 | DialogTrigger, 115 | DialogClose, 116 | DialogContent, 117 | DialogHeader, 118 | DialogFooter, 119 | DialogTitle, 120 | DialogDescription, 121 | } 122 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | //eslint-disable-next-line 6 | export interface InputProps 7 | extends React.InputHTMLAttributes {} 8 | 9 | const Input = React.forwardRef( 10 | ({ className, type, ...props }, ref) => { 11 | return ( 12 | 21 | ); 22 | } 23 | ); 24 | Input.displayName = "Input"; 25 | 26 | export { Input }; 27 | 28 | -------------------------------------------------------------------------------- /src/components/ui/pagination.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cn } from "@/lib/utils"; 3 | import { ButtonProps, buttonVariants } from "@/components/ui/button"; 4 | import { 5 | ChevronLeftIcon, 6 | ChevronRightIcon, 7 | DotsHorizontalIcon, 8 | } from "@radix-ui/react-icons"; 9 | import Link from "next/link"; 10 | 11 | const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( 12 |