Add configure page to main repo
@@ -0,0 +1,42 @@
|
|||||||
|
# Etapa de construção do frontend
|
||||||
|
FROM node:18-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copia os arquivos de configuração primeiro
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Instala as dependências
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copia o restante do código fonte
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build da aplicação React
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Etapa de produção
|
||||||
|
FROM node:18-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copia apenas os arquivos necessários
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Instala apenas dependências de produção
|
||||||
|
RUN npm install --production
|
||||||
|
|
||||||
|
# Copia os arquivos do servidor
|
||||||
|
COPY --from=builder /app/addon ./addon
|
||||||
|
|
||||||
|
# Copia os arquivos buildados do React
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Copia a pasta public com as imagens
|
||||||
|
COPY --from=builder /app/configure/public ./public
|
||||||
|
|
||||||
|
# Exposição da porta
|
||||||
|
EXPOSE 7000
|
||||||
|
|
||||||
|
# Comando para iniciar o servidor
|
||||||
|
CMD ["node", "addon/server.js"]
|
||||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
@@ -42,7 +42,7 @@ const respond = function (res, data, opts) {
|
|||||||
res.send(data);
|
res.send(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
addon.get("/", async function (_, res) {
|
addon.get("/", function (_, res) {
|
||||||
res.redirect("/configure");
|
res.redirect("/configure");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,10 +57,13 @@ addon.get("/session_id", async function (req, res) {
|
|||||||
respond(res, sessionId);
|
respond(res, sessionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
addon.use('/configure', express.static(path.join(__dirname, 'configure/dist')));
|
addon.use(express.static(path.join(__dirname, '../dist')));
|
||||||
|
addon.use('/streaming', express.static(path.join(__dirname, '../public/streaming')));
|
||||||
|
|
||||||
addon.get("/:catalogChoices?/configure", async function (req, res) {
|
addon.use('/configure', express.static(path.join(__dirname, '../dist')));
|
||||||
res.sendFile(path.join(__dirname + "/configure.html"));
|
|
||||||
|
addon.get('/configure/*', function (req, res) {
|
||||||
|
res.sendFile(path.join(__dirname, '../dist/index.html'));
|
||||||
});
|
});
|
||||||
|
|
||||||
addon.get("/:catalogChoices?/manifest.json", async function (req, res) {
|
addon.get("/:catalogChoices?/manifest.json", async function (req, res) {
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
const FanartTvApi = require("fanart.tv-api");
|
const FanartTvApi = require("fanart.tv-api");
|
||||||
const api_key = process.env.FANART_API;
|
const apiKey = process.env.FANART_API;
|
||||||
const baseUrl = "http://webservice.fanart.tv/v3/";
|
const baseUrl = "http://webservice.fanart.tv/v3/";
|
||||||
const fanart = new FanartTvApi({ api_key, baseUrl });
|
const fanart = new FanartTvApi({ apiKey, baseUrl });
|
||||||
|
|
||||||
const { MovieDb } = require("moviedb-promise");
|
const { MovieDb } = require("moviedb-promise");
|
||||||
const moviedb = new MovieDb(process.env.TMDB_API);
|
const moviedb = new MovieDb(process.env.TMDB_API);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
require("dotenv").config();
|
require("dotenv").config();
|
||||||
const { getGenreList } = require("./getGenreList");
|
const { getGenreList } = require("./getGenreList");
|
||||||
const { getLanguages } = require("./getLanguages");
|
const { getLanguages } = require("./getLanguages");
|
||||||
const package = require("../package.json");
|
const packageJson = require("../../package.json");
|
||||||
const catalogsTranslations = require("../static/translations.json");
|
const catalogsTranslations = require("../static/translations.json");
|
||||||
const CATALOG_TYPES = require("../static/catalog-types.json");
|
const CATALOG_TYPES = require("../static/catalog-types.json");
|
||||||
const DEFAULT_LANGUAGE = "en-US";
|
const DEFAULT_LANGUAGE = "en-US";
|
||||||
@@ -139,13 +139,13 @@ async function getManifest(config) {
|
|||||||
const descriptionSuffix = language && language !== DEFAULT_LANGUAGE ? ` with ${language} language.` : ".";
|
const descriptionSuffix = language && language !== DEFAULT_LANGUAGE ? ` with ${language} language.` : ".";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: package.name,
|
id: packageJson.name,
|
||||||
version: package.version,
|
version: packageJson.version,
|
||||||
favicon: "https://github.com/mrcanelas/tmdb-addon/raw/main/images/favicon.png",
|
favicon: "https://github.com/mrcanelas/tmdb-addon/raw/main/images/favicon.png",
|
||||||
logo: "https://github.com/mrcanelas/tmdb-addon/raw/main/images/logo.png",
|
logo: "https://github.com/mrcanelas/tmdb-addon/raw/main/images/logo.png",
|
||||||
background: "https://github.com/mrcanelas/tmdb-addon/raw/main/images/background.png",
|
background: "https://github.com/mrcanelas/tmdb-addon/raw/main/images/background.png",
|
||||||
name: "The Movie Database Addon",
|
name: "The Movie Database Addon",
|
||||||
description: package.description + descriptionSuffix,
|
description: packageJson.description + descriptionSuffix,
|
||||||
resources: ["catalog", "meta"],
|
resources: ["catalog", "meta"],
|
||||||
types: ["movie", "series"],
|
types: ["movie", "series"],
|
||||||
idPrefixes: provideImdbId ? ["tmdb:", "tt"] : ["tmdb:"],
|
idPrefixes: provideImdbId ? ["tmdb:", "tt"] : ["tmdb:"],
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"projectName": "tmdb-addon",
|
"projectName": "tmdb-addon",
|
||||||
"lastCommit": "d164ba4"
|
"lastCommit": "9a54bd8"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>The Movie Database Addon - Stremio Addon</title>
|
|
||||||
<link rel="shortcut icon" href="https://github.com/mrcanelas/tmdb-addon/raw/main/images/favicon.png"
|
|
||||||
type="image/x-icon" />
|
|
||||||
</head>
|
|
||||||
<iframe src="https://tmdb-addon-config-page.vercel.app" sandbox="allow-scripts allow-same-origin"
|
|
||||||
allow="clipboard-read; clipboard-write"
|
|
||||||
style="position:fixed; top:0; left:0; bottom:0; right:0; width:100%; height:100%; border:none; margin:0; padding:0; overflow:hidden; z-index:999999;"></iframe>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="24" height="24" rx="12" fill="#E2E8F0"/>
|
||||||
|
<path d="M12 6V18M6 12H18" stroke="#64748B" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 310 B |
|
After Width: | Height: | Size: 720 B |
|
After Width: | Height: | Size: 864 B |
|
After Width: | Height: | Size: 914 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 848 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 828 B |
|
After Width: | Height: | Size: 998 B |
@@ -0,0 +1,25 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Streaming Catalogs</title>
|
||||||
|
<!-- Google tag (gtag.js) -->
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id=G-S47YFG3SDZ"></script>
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag() {
|
||||||
|
dataLayer.push(arguments);
|
||||||
|
}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', 'G-S47YFG3SDZ');
|
||||||
|
</script>
|
||||||
|
<script type="module" crossorigin src="/assets/index.0f7d9069.js"></script>
|
||||||
|
<link rel="stylesheet" href="/assets/index.79bc1b06.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 594 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import Home from "./pages/Home";
|
||||||
|
import Catalogs from "./pages/Catalogs";
|
||||||
|
import Integrations from "./pages/Integrations";
|
||||||
|
import Others from "./pages/Others";
|
||||||
|
import NotFound from "./pages/NotFound";
|
||||||
|
import { Sidebar } from "./components/Sidebar";
|
||||||
|
import { Header } from "./components/Header";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { ConfigProvider } from "@/contexts/ConfigContext";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
type Page = "home" | "catalogs" | "integrations" | "others";
|
||||||
|
|
||||||
|
const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState<Page>("home");
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPage = () => {
|
||||||
|
switch (currentPage) {
|
||||||
|
case "home":
|
||||||
|
return <Home />;
|
||||||
|
case "catalogs":
|
||||||
|
return <Catalogs />;
|
||||||
|
case "integrations":
|
||||||
|
return <Integrations />;
|
||||||
|
case "others":
|
||||||
|
return <Others />;
|
||||||
|
default:
|
||||||
|
return <NotFound />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isHome = currentPage === "home";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<Sidebar
|
||||||
|
isOpen={isOpen}
|
||||||
|
setIsOpen={setIsOpen}
|
||||||
|
currentPage={currentPage}
|
||||||
|
setCurrentPage={setCurrentPage}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 flex flex-col md:pl-64 h-screen">
|
||||||
|
{(!isHome || window.innerWidth < 768) && (
|
||||||
|
<Header isOpen={isOpen} toggleSidebar={toggleSidebar} isHome={isHome} />
|
||||||
|
)}
|
||||||
|
<ScrollArea className="flex-1 px-4 sm:px-6 md:px-8 lg:px-12">
|
||||||
|
{renderPage()}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const App = () => (
|
||||||
|
<ConfigProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Toaster />
|
||||||
|
<Sonner />
|
||||||
|
<Layout>
|
||||||
|
<Home />
|
||||||
|
</Layout>
|
||||||
|
</TooltipProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export default function DefaultIntegration() {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-3 sm:py-4">
|
||||||
|
<p className="text-sm sm:text-base text-muted-foreground">
|
||||||
|
Configuration options for this integration will be available soon.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Menu, X } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
isHome: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ isOpen, toggleSidebar, isHome }: HeaderProps) {
|
||||||
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
setIsMobile(window.innerWidth < 768);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isTransparent = isHome && isMobile;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={`flex justify-between items-center p-6 z-10 ${isTransparent ? 'bg-transparent' : 'bg-white'}`}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
className={`inline-flex items-center justify-center p-2 rounded-md ${isHome ? ' text-white hover:text-gray-100' : 'text-sidebar hover:bg-gray-100'} shadow-sm`}
|
||||||
|
>
|
||||||
|
<Menu size={24} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center gap-4 ${isHome ? 'hidden' : 'block'}`}>
|
||||||
|
<img
|
||||||
|
src="https://ui-avatars.com/api/?name=silas-alves"
|
||||||
|
alt="User"
|
||||||
|
className="inline-block w-10 h-10 rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { IntegrationDialog } from "./IntegrationDialog";
|
||||||
|
|
||||||
|
interface IntegrationCardProps {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IntegrationCard({ id, name, icon, description }: IntegrationCardProps) {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Card className="p-4 sm:p-6 hover:shadow-lg transition-shadow cursor-pointer h-full">
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex flex-col items-center text-center flex-1">
|
||||||
|
<img src={icon} alt={name} className="w-10 h-10 sm:w-12 sm:h-12 mb-3 sm:mb-4" />
|
||||||
|
<h3 className="font-semibold mb-1.5 sm:mb-2 text-base sm:text-lg">{name}</h3>
|
||||||
|
<p className="text-xs sm:text-sm text-gray-500">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center mt-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-primary hover:text-primary/80 font-medium text-sm sm:text-base px-3 py-1.5 sm:px-4 sm:py-2"
|
||||||
|
>
|
||||||
|
Setup
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="w-[95%] sm:w-auto">
|
||||||
|
<IntegrationDialog id={id} name={name} icon={icon} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
interface IntegrationDialogProps {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IntegrationDialog({ id, name, icon }: IntegrationDialogProps) {
|
||||||
|
const IntegrationComponent = lazy(() =>
|
||||||
|
/* @vite-ignore */
|
||||||
|
import(`../integrations/${id}.tsx`).catch(() => {
|
||||||
|
console.error(`Failed to load integration component for ${id}`);
|
||||||
|
return import("./DefaultIntegration");
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-lg sm:text-xl">
|
||||||
|
<img src={icon} alt={name} className="w-5 h-5 sm:w-6 sm:h-6" />
|
||||||
|
{name} Configuration
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-sm sm:text-base">
|
||||||
|
Configure your {name} integration settings below.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:gap-4">
|
||||||
|
<Suspense fallback={<Skeleton className="h-[200px] w-full" />}>
|
||||||
|
<IntegrationComponent />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { generateAddonUrl } from "@/lib/config";
|
||||||
|
import { useConfig } from "@/contexts/ConfigContext";
|
||||||
|
|
||||||
|
const MultiActionButton = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const config = useConfig();
|
||||||
|
const [currentAction, setCurrentAction] = useState<number>(0);
|
||||||
|
|
||||||
|
const handleInstall = () => {
|
||||||
|
const url = generateAddonUrl(config);
|
||||||
|
window.location.href = url.replace(/^https?:\/\//, "stremio://");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstallWeb = () => {
|
||||||
|
const addonUrl = generateAddonUrl(config);
|
||||||
|
const webUrl = `https://web.stremio.com/#/addons?addon=${encodeURIComponent(addonUrl)}`;
|
||||||
|
window.open(webUrl, "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyUrl = async () => {
|
||||||
|
await navigator.clipboard.writeText(generateAddonUrl(config));
|
||||||
|
toast({
|
||||||
|
title: "URL Copied",
|
||||||
|
description: "The URL has been copied to your clipboard",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
{ label: 'Install', action: handleInstall },
|
||||||
|
{ label: 'Install Web', action: handleInstallWeb },
|
||||||
|
{ label: 'Copy URL', action: handleCopyUrl }
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleMainClick = () => {
|
||||||
|
actions[currentAction].action();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex rounded-md w-full">
|
||||||
|
<Button
|
||||||
|
onClick={handleMainClick}
|
||||||
|
className="rounded-r-none border-r-0 pr-3 w-full"
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
{actions[currentAction].label}
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="rounded-l-none px-2 hover:bg-primary/90"
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="bg-background border shadow-md">
|
||||||
|
{actions.map((action, index) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={action.label}
|
||||||
|
onClick={() => setCurrentAction(index)}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MultiActionButton;
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import MultiActionButton from "@/components/MultiActionButton";
|
||||||
|
import { Home, GalleryVerticalEnd, Puzzle, Settings } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { KoFiDialog } from "react-kofi";
|
||||||
|
import "react-kofi/dist/styles.css";
|
||||||
|
import "@/styles/kofi-dialog.css";
|
||||||
|
|
||||||
|
type Page = "home" | "catalogs" | "integrations" | "others";
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ icon: Home, label: "Home", id: "home" as Page },
|
||||||
|
{ icon: GalleryVerticalEnd, label: "Catalogs", id: "catalogs" as Page },
|
||||||
|
{ icon: Puzzle, label: "Integrations", id: "integrations" as Page },
|
||||||
|
{ icon: Settings, label: "Others", id: "others" as Page },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
|
currentPage: Page;
|
||||||
|
setCurrentPage: (page: Page) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ isOpen, setIsOpen, currentPage, setCurrentPage }: SidebarProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-y-0 left-0 z-40 bg-sidebar transform transition-transform duration-200 ease-in-out",
|
||||||
|
isOpen ? "translate-x-0" : "-translate-x-full",
|
||||||
|
"md:translate-x-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col min-h-screen py-6 space-y-10">
|
||||||
|
<div className="flex items-center gap-2 mx-6 mt-10">
|
||||||
|
<img
|
||||||
|
src="https://www.themoviedb.org/assets/2/v4/logos/v2/blue_short-8e7b30f73a4020692ccca9c88bafe5dcb6f8a62a4c6bc55cd9ba82bb2cd95f6c.svg"
|
||||||
|
alt="TMDB Logo"
|
||||||
|
className="w-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1">
|
||||||
|
<ul>
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<li key={item.label}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentPage(item.id);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center w-full px-6 py-2 mt-4 text-gray-500 hover:bg-gray-700 hover:bg-opacity-25 hover:text-gray-100",
|
||||||
|
currentPage === item.id &&
|
||||||
|
"bg-gray-700 text-gray-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5 mr-2" />
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-6 grid place-items-center space-y-3">
|
||||||
|
<MultiActionButton />
|
||||||
|
<KoFiDialog
|
||||||
|
color="#01b4e4"
|
||||||
|
textColor="#fff"
|
||||||
|
id="mrcanelas"
|
||||||
|
label="Support me"
|
||||||
|
padding={6}
|
||||||
|
iframe={false}
|
||||||
|
buttonRadius="6px"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-30 md:hidden"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { GripVertical } from "lucide-react";
|
||||||
|
import { Card, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { type Catalog } from "@/data/catalogs";
|
||||||
|
import { streamingServices } from "@/data/streamings";
|
||||||
|
import { integrations } from "@/data/integrations";
|
||||||
|
|
||||||
|
interface SortableCatalogCardProps {
|
||||||
|
catalog: Catalog;
|
||||||
|
config?: { enabled: boolean; showInHome: boolean };
|
||||||
|
onChange: (enabled: boolean, showInHome: boolean) => void;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIntegrationInfo = (catalogId: string) => {
|
||||||
|
const [integrationId] = catalogId.split(".");
|
||||||
|
const integration = integrations.find(i => i.id === integrationId);
|
||||||
|
|
||||||
|
return integration || {
|
||||||
|
id: integrationId,
|
||||||
|
name: integrationId.toUpperCase(),
|
||||||
|
icon: "/default.svg",
|
||||||
|
description: "Unknown integration"
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SortableCatalogCard({ catalog, config, onChange, id }: SortableCatalogCardProps) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEnabled = config?.enabled ?? true;
|
||||||
|
const showInHome = config?.showInHome ?? true;
|
||||||
|
let integration = getIntegrationInfo(catalog.id);
|
||||||
|
|
||||||
|
if (integration.id === "streaming") {
|
||||||
|
const streamindId = catalog.id.split(".")[1];
|
||||||
|
const foundService = streamingServices.find(s => s.id === streamindId);
|
||||||
|
integration = { ...integration, icon: foundService?.icon || integration.icon };
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style}>
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div className="space-y-1 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="cursor-grab active:cursor-grabbing p-1 hover:bg-gray-100 rounded"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-5 w-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
<div className="w-8 h-8 flex items-center justify-center bg-gray-50 rounded-md">
|
||||||
|
<img
|
||||||
|
src={integration.icon}
|
||||||
|
alt={`${integration.name} logo`}
|
||||||
|
className="w-full h-full object-contain rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 className="font-semibold flex items-center gap-2">
|
||||||
|
{catalog.name}
|
||||||
|
<Badge variant="outline">
|
||||||
|
{catalog.type === "movie" ? "Movie" : "Series"}
|
||||||
|
</Badge>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm text-muted-foreground">Enable</span>
|
||||||
|
<Switch
|
||||||
|
checked={isEnabled}
|
||||||
|
onCheckedChange={(checked) => onChange(checked, checked ? showInHome : false)}
|
||||||
|
/>
|
||||||
|
<span className={`text-sm ${!isEnabled ? "text-muted-foreground/50" : "text-muted-foreground"}`}>
|
||||||
|
Home
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={showInHome}
|
||||||
|
onCheckedChange={(checked) => onChange(isEnabled, checked)}
|
||||||
|
disabled={!isEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Alert.displayName = "Alert"
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertTitle.displayName = "AlertTitle"
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDescription.displayName = "AlertDescription"
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full 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",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative overflow-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
))
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none transition-colors",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
))
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
))
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
))
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner } from "sonner"
|
||||||
|
|
||||||
|
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
|
actionButton:
|
||||||
|
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||||
|
cancelButton:
|
||||||
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||||
|
VariantProps<typeof toastVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ToastPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
))
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm opacity-90", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||||
|
|
||||||
|
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useToast } from "@/hooks/use-toast"
|
||||||
|
import {
|
||||||
|
Toast,
|
||||||
|
ToastClose,
|
||||||
|
ToastDescription,
|
||||||
|
ToastProvider,
|
||||||
|
ToastTitle,
|
||||||
|
ToastViewport,
|
||||||
|
} from "@/components/ui/toast"
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||||
|
return (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && (
|
||||||
|
<ToastDescription>{description}</ToastDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { useToast, toast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
export { useToast, toast };
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { createContext, useContext, useState } from "react";
|
||||||
|
import { ConfigContext, type ConfigContextType, type CatalogConfig } from "./config";
|
||||||
|
|
||||||
|
export function ConfigProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [rpdbkey, setRpdbkey] = useState("");
|
||||||
|
const [mdblistkey, setMdblistkey] = useState("");
|
||||||
|
const [includeAdult, setIncludeAdult] = useState(false);
|
||||||
|
const [provideImdbId, setProvideImdbId] = useState(false);
|
||||||
|
const [tmdbPrefix, setTmdbPrefix] = useState(false);
|
||||||
|
const [language, setLanguage] = useState("en-US");
|
||||||
|
const [sessionId, setSessionId] = useState("");
|
||||||
|
const [streaming, setStreaming] = useState<string[]>([]);
|
||||||
|
const [catalogs, setCatalogs] = useState<CatalogConfig[]>([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigContext.Provider
|
||||||
|
value={{
|
||||||
|
rpdbkey,
|
||||||
|
mdblistkey,
|
||||||
|
includeAdult,
|
||||||
|
provideImdbId,
|
||||||
|
tmdbPrefix,
|
||||||
|
language,
|
||||||
|
sessionId,
|
||||||
|
streaming,
|
||||||
|
catalogs,
|
||||||
|
setRpdbkey,
|
||||||
|
setMdblistkey,
|
||||||
|
setIncludeAdult,
|
||||||
|
setProvideImdbId,
|
||||||
|
setTmdbPrefix,
|
||||||
|
setLanguage,
|
||||||
|
setSessionId,
|
||||||
|
setStreaming,
|
||||||
|
setCatalogs,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ConfigContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConfig() {
|
||||||
|
const context = useContext(ConfigContext);
|
||||||
|
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useConfig must be used within a ConfigProvider");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
|
||||||
|
export interface CatalogConfig {
|
||||||
|
id: string;
|
||||||
|
type: "movie" | "series";
|
||||||
|
enabled: boolean;
|
||||||
|
showInHome: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigContextType {
|
||||||
|
rpdbkey: string;
|
||||||
|
mdblistkey: string;
|
||||||
|
includeAdult: boolean;
|
||||||
|
provideImdbId: boolean;
|
||||||
|
tmdbPrefix: boolean;
|
||||||
|
language: string;
|
||||||
|
sessionId: string;
|
||||||
|
streaming: string[];
|
||||||
|
catalogs: CatalogConfig[];
|
||||||
|
setRpdbkey: (value: string) => void;
|
||||||
|
setMdblistkey: (value: string) => void;
|
||||||
|
setIncludeAdult: (value: boolean) => void;
|
||||||
|
setProvideImdbId: (value: boolean) => void;
|
||||||
|
setTmdbPrefix: (value: boolean) => void;
|
||||||
|
setLanguage: (value: string) => void;
|
||||||
|
setSessionId: (value: string) => void;
|
||||||
|
setStreaming: (value: string[]) => void;
|
||||||
|
setCatalogs: (value: CatalogConfig[] | ((prev: CatalogConfig[]) => CatalogConfig[])) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
export interface ExtraOption {
|
||||||
|
name: string;
|
||||||
|
options?: string[];
|
||||||
|
isRequired?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Catalog {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "movie" | "series";
|
||||||
|
extraRequired?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catálogos base que sempre estarão disponíveis
|
||||||
|
export const baseCatalogs: Catalog[] = [
|
||||||
|
{ id: "tmdb.top", name: "Popular", type: "movie" },
|
||||||
|
{ id: "tmdb.top", name: "Popular", type: "series" },
|
||||||
|
{ id: "tmdb.year", name: "Year", type: "movie" },
|
||||||
|
{ id: "tmdb.year", name: "Year", type: "series" },
|
||||||
|
{ id: "tmdb.language", name: "Language", type: "movie" },
|
||||||
|
{ id: "tmdb.language", name: "Language", type: "series" },
|
||||||
|
{ id: "tmdb.trending", name: "Trending", type: "movie" },
|
||||||
|
{ id: "tmdb.trending", name: "Trending", type: "series" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Catálogos que requerem autenticação
|
||||||
|
export const authCatalogs: Catalog[] = [
|
||||||
|
{ id: "tmdb.favorites", name: "Favorites", type: "movie" },
|
||||||
|
{ id: "tmdb.favorites", name: "Favorites", type: "series" },
|
||||||
|
{ id: "tmdb.watchlist", name: "Watchlist", type: "movie" },
|
||||||
|
{ id: "tmdb.watchlist", name: "Watchlist", type: "series" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Catálogos do MDBList
|
||||||
|
export const mdblistCatalogs: Catalog[] = [
|
||||||
|
{ id: "mdblist.lists", name: "MDBList Lists", type: "movie" },
|
||||||
|
{ id: "mdblist.lists", name: "MDBList Lists", type: "series" },
|
||||||
|
{ id: "mdblist.recommended", name: "MDBList Recommended", type: "movie" },
|
||||||
|
{ id: "mdblist.recommended", name: "MDBList Recommended", type: "series" },
|
||||||
|
{ id: "mdblist.watchlist", name: "MDBList Watchlist", type: "movie" },
|
||||||
|
{ id: "mdblist.watchlist", name: "MDBList Watchlist", type: "series" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Catálogos de streaming
|
||||||
|
export const streamingCatalogs: Record<string, Catalog[]> = {
|
||||||
|
nfx: [
|
||||||
|
{ id: "streaming.nfx", name: "Netflix", type: "movie" },
|
||||||
|
{ id: "streaming.nfx", name: "Netflix", type: "series" }
|
||||||
|
],
|
||||||
|
nfk: [
|
||||||
|
{ id: "streaming.nfk", name: "Netflix Kids", type: "movie" },
|
||||||
|
{ id: "streaming.nfk", name: "Netflix Kids", type: "series" }
|
||||||
|
],
|
||||||
|
hbm: [
|
||||||
|
{ id: "streaming.hbm", name: "HBO Max", type: "movie" },
|
||||||
|
{ id: "streaming.hbm", name: "HBO Max", type: "series" }
|
||||||
|
],
|
||||||
|
dnp: [
|
||||||
|
{ id: "streaming.dnp", name: "Disney+", type: "movie" },
|
||||||
|
{ id: "streaming.dnp", name: "Disney+", type: "series" }
|
||||||
|
],
|
||||||
|
amp: [
|
||||||
|
{ id: "streaming.amp", name: "Prime Video", type: "movie" },
|
||||||
|
{ id: "streaming.amp", name: "Prime Video", type: "series" }
|
||||||
|
],
|
||||||
|
atp: [
|
||||||
|
{ id: "streaming.atp", name: "Apple TV+", type: "movie" },
|
||||||
|
{ id: "streaming.atp", name: "Apple TV+", type: "series" }
|
||||||
|
],
|
||||||
|
pmp: [
|
||||||
|
{ id: "streaming.pmp", name: "Paramount+", type: "movie" },
|
||||||
|
{ id: "streaming.pmp", name: "Paramount+", type: "series" }
|
||||||
|
],
|
||||||
|
pcp: [
|
||||||
|
{ id: "streaming.pcp", name: "Peacock Premium", type: "movie" },
|
||||||
|
{ id: "streaming.pcp", name: "Peacock Premium", type: "series" }
|
||||||
|
],
|
||||||
|
hlu: [
|
||||||
|
{ id: "streaming.hlu", name: "Hulu", type: "movie" },
|
||||||
|
{ id: "streaming.hlu", name: "Hulu", type: "series" }
|
||||||
|
],
|
||||||
|
cts: [
|
||||||
|
{ id: "streaming.cts", name: "Curiosity Stream", type: "movie" },
|
||||||
|
{ id: "streaming.cts", name: "Curiosity Stream", type: "series" }
|
||||||
|
],
|
||||||
|
mgl: [
|
||||||
|
{ id: "streaming.mgl", name: "MagellanTV", type: "movie" },
|
||||||
|
{ id: "streaming.mgl", name: "MagellanTV", type: "series" }
|
||||||
|
],
|
||||||
|
cru: [
|
||||||
|
{ id: "streaming.cru", name: "Crunchyroll", type: "movie" },
|
||||||
|
{ id: "streaming.cru", name: "Crunchyroll", type: "series" }
|
||||||
|
],
|
||||||
|
hay: [
|
||||||
|
{ id: "streaming.hay", name: "Hayu", type: "series" }
|
||||||
|
],
|
||||||
|
clv: [
|
||||||
|
{ id: "streaming.clv", name: "Clarovideo", type: "movie" },
|
||||||
|
{ id: "streaming.clv", name: "Clarovideo", type: "series" }
|
||||||
|
],
|
||||||
|
gop: [
|
||||||
|
{ id: "streaming.gop", name: "Globoplay", type: "movie" },
|
||||||
|
{ id: "streaming.gop", name: "Globoplay", type: "series" }
|
||||||
|
],
|
||||||
|
hst: [
|
||||||
|
{ id: "streaming.hst", name: "Hotstar", type: "movie" },
|
||||||
|
{ id: "streaming.hst", name: "Hotstar", type: "series" }
|
||||||
|
],
|
||||||
|
zee: [
|
||||||
|
{ id: "streaming.zee", name: "Zee5", type: "movie" },
|
||||||
|
{ id: "streaming.zee", name: "Zee5", type: "series" }
|
||||||
|
],
|
||||||
|
nlz: [
|
||||||
|
{ id: "streaming.nlz", name: "NLZIET", type: "movie" },
|
||||||
|
{ id: "streaming.nlz", name: "NLZIET", type: "series" }
|
||||||
|
],
|
||||||
|
vil: [
|
||||||
|
{ id: "streaming.vil", name: "Videoland", type: "movie" },
|
||||||
|
{ id: "streaming.vil", name: "Videoland", type: "series" }
|
||||||
|
],
|
||||||
|
sst: [
|
||||||
|
{ id: "streaming.sst", name: "SkyShowtime", type: "movie" },
|
||||||
|
{ id: "streaming.sst", name: "SkyShowtime", type: "series" }
|
||||||
|
],
|
||||||
|
blv: [
|
||||||
|
{ id: "streaming.blv", name: "BluTV", type: "movie" },
|
||||||
|
{ id: "streaming.blv", name: "BluTV", type: "series" }
|
||||||
|
],
|
||||||
|
cpd: [
|
||||||
|
{ id: "streaming.cpd", name: "Canal+", type: "movie" },
|
||||||
|
{ id: "streaming.cpd", name: "Canal+", type: "series" }
|
||||||
|
],
|
||||||
|
dpe: [
|
||||||
|
{ id: "streaming.dpe", name: "Discovery+", type: "movie" },
|
||||||
|
{ id: "streaming.dpe", name: "Discovery+", type: "series" }
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
interface Integration {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const integrations: Integration[] = [
|
||||||
|
{
|
||||||
|
id: "tmdb",
|
||||||
|
name: "TMDB Lists",
|
||||||
|
icon: "https://www.themoviedb.org/assets/2/v4/logos/v2/blue_square_2-d537fb228cf3ded904ef09b136fe3fec72548ebc1fea3fbbd1ad9e36364db38b.svg",
|
||||||
|
description: "Sync your TMDB lists to discover new movies and TV shows.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rpdb",
|
||||||
|
name: "Rating Poster Database",
|
||||||
|
icon: "https://github.com/RatingPosterDB.png",
|
||||||
|
description: "Add ratings and scores to your movies and TV shows posters.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "streaming",
|
||||||
|
name: "Streaming Catalogs",
|
||||||
|
icon: "https://www.svgrepo.com/show/303341/netflix-1-logo.svg",
|
||||||
|
description: "Set up your streaming services to see content availability. Based on rleroi/Stremio-Streaming-Catalogs-Addon.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mdblist",
|
||||||
|
name: "MDBList",
|
||||||
|
icon: "https://mdblist.com/static/mdblist.png",
|
||||||
|
description: "Integrate your MDBList lists to expand your content library.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trakt",
|
||||||
|
name: "Trakt",
|
||||||
|
icon: "https://trakt.tv/assets/logos/logomark.square.gradient-b644b16c38ff775861b4b1f58c1230f6a097a2466ab33ae00445a505c33fcb91.svg",
|
||||||
|
description: "Track what you watch and sync your progress with Trakt.tv.",
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
export const streamingServices = [
|
||||||
|
{ id: "nfx", name: "Netflix", icon: "/streaming/netflix.webp" },
|
||||||
|
{ id: "nfk", name: "Netflix Kids", icon: "/streaming/netflixkids.webp" },
|
||||||
|
{ id: "hbm", name: "HBO Max", icon: "/streaming/hbo.webp" },
|
||||||
|
{ id: "dnp", name: "Disney+", icon: "/streaming/disney.webp" },
|
||||||
|
{ id: "amp", name: "Prime Video", icon: "/streaming/prime.webp" },
|
||||||
|
{ id: "atp", name: "Apple TV+", icon: "/streaming/apple.webp" },
|
||||||
|
{ id: "pmp", name: "Paramount+", icon: "/streaming/paramount.webp" },
|
||||||
|
{ id: "pcp", name: "Peacock Premium", icon: "/streaming/peacock.webp" },
|
||||||
|
{ id: "hlu", name: "Hulu", icon: "/streaming/hulu.webp" },
|
||||||
|
{ id: "cts", name: "Curiosity Stream", icon: "/streaming/curiositystream.webp" },
|
||||||
|
{ id: "mgl", name: "MagellanTV", icon: "/streaming/magellan.webp" },
|
||||||
|
{ id: "cru", name: "Crunchyroll", icon: "/streaming/crunchyroll.webp" },
|
||||||
|
{ id: "hay", name: "Hayu", icon: "/streaming/hayu.webp" },
|
||||||
|
{ id: "clv", name: "Clarovideo", icon: "/streaming/claro.webp" },
|
||||||
|
{ id: "gop", name: "Globoplay", icon: "/streaming/globo.webp" },
|
||||||
|
{ id: "hst", name: "Hotstar", icon: "/streaming/hotstar.webp" },
|
||||||
|
{ id: "zee", name: "Zee5", icon: "/streaming/zee5.webp" },
|
||||||
|
{ id: "nlz", name: "NLZIET", icon: "/streaming/nlziet.webp" },
|
||||||
|
{ id: "vil", name: "Videoland", icon: "/streaming/videoland.webp" },
|
||||||
|
{ id: "sst", name: "SkyShowtime", icon: "/streaming/skyshowtime.webp" },
|
||||||
|
{ id: "blv", name: "BluTV", icon: "/streaming/blu.webp" },
|
||||||
|
{ id: "cpd", name: "Canal+", icon: "/streaming/canal-plus.webp" },
|
||||||
|
{ id: "dpe", name: "Discovery+", icon: "/streaming/discovery-plus.webp" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const regions = {
|
||||||
|
'United States': [
|
||||||
|
'nfx', 'nfk', 'dnp', 'amp', 'atp', 'hbm', 'cru', 'pmp', 'mgl', 'cts', 'hlu', 'pcp', 'dpe'
|
||||||
|
],
|
||||||
|
'Brazil': [
|
||||||
|
'nfx', 'nfk', 'dnp', 'atp', 'amp', 'pmp', 'hbm', 'cru', 'clv', 'gop', 'mgl', 'cts'
|
||||||
|
],
|
||||||
|
'India': [
|
||||||
|
'hay', 'nfx', 'nfk', 'atp', 'amp', 'cru', 'zee', 'hst', 'mgl', 'cts', 'dpe'
|
||||||
|
],
|
||||||
|
'Turkey': [
|
||||||
|
'nfx', 'nfk', 'dnp', 'atp', 'amp', 'cru', 'blv', 'mgl', 'cts'
|
||||||
|
],
|
||||||
|
'Netherlands': [
|
||||||
|
'nfx', 'nfk', 'dnp', 'amp', 'atp', 'hbm', 'cru', 'hay', 'vil', 'sst', 'mgl', 'cts', 'nlz', 'dpe'
|
||||||
|
],
|
||||||
|
'France': [
|
||||||
|
'nfx', 'nfk', 'dnp', 'amp', 'atp', 'hbm', 'hay', 'cpd'
|
||||||
|
],
|
||||||
|
'Any': [
|
||||||
|
'nfx', 'nfk', 'dnp', 'amp', 'atp', 'hbm', 'pmp', 'hlu', 'pcp', 'clv', 'gop', 'blv',
|
||||||
|
'zee', 'hst', 'hay', 'vil', 'sst', 'mgl', 'cts', 'cru', 'nlz', 'cpd', 'dpe'
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||||
|
const onChange = () => {
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
}
|
||||||
|
mql.addEventListener("change", onChange)
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
return () => mql.removeEventListener("change", onChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return !!isMobile
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ToastActionElement,
|
||||||
|
ToastProps,
|
||||||
|
} from "@/components/ui/toast"
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string
|
||||||
|
title?: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
action?: ToastActionElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: "ADD_TOAST",
|
||||||
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||||
|
return count.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = typeof actionTypes
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType["ADD_TOAST"]
|
||||||
|
toast: ToasterToast
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["UPDATE_TOAST"]
|
||||||
|
toast: Partial<ToasterToast>
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["DISMISS_TOAST"]
|
||||||
|
toastId?: ToasterToast["id"]
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["REMOVE_TOAST"]
|
||||||
|
toastId?: ToasterToast["id"]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId)
|
||||||
|
dispatch({
|
||||||
|
type: "REMOVE_TOAST",
|
||||||
|
toastId: toastId,
|
||||||
|
})
|
||||||
|
}, TOAST_REMOVE_DELAY)
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ADD_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "UPDATE_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "DISMISS_TOAST": {
|
||||||
|
const { toastId } = action
|
||||||
|
|
||||||
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
|
// but I'll keep it here for simplicity
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId)
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "REMOVE_TOAST":
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = []
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] }
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action)
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, "id">
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId()
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_TOAST",
|
||||||
|
toast: { ...props, id },
|
||||||
|
})
|
||||||
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_TOAST",
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
if (!open) dismiss()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState)
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState)
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast }
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 222.2 84% 4.9%;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground font-['Inter'];
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useConfig } from "@/contexts/ConfigContext";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { DialogClose } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { AlertCircle, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export default function RPDB() {
|
||||||
|
const { rpdbkey, setRpdbkey } = useConfig();
|
||||||
|
const [tempKey, setTempKey] = useState(rpdbkey);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [isValid, setIsValid] = useState(false);
|
||||||
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTempKey(rpdbkey);
|
||||||
|
// Se tiver uma chave existente, vamos validá-la
|
||||||
|
if (rpdbkey) {
|
||||||
|
validateRPDBKey(rpdbkey);
|
||||||
|
}
|
||||||
|
}, [rpdbkey]);
|
||||||
|
|
||||||
|
const validateRPDBKey = async (key: string) => {
|
||||||
|
if (!key) {
|
||||||
|
setIsValid(false);
|
||||||
|
setError("");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsChecking(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://api.ratingposterdb.com/${key}/isValid`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!(data || {}).valid) {
|
||||||
|
setError("RPDB Key is invalid, please try again");
|
||||||
|
setIsValid(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
setIsValid(true);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError("Error validating RPDB key");
|
||||||
|
setIsValid(false);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (isValid) {
|
||||||
|
setRpdbkey(tempKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setTempKey(rpdbkey);
|
||||||
|
setError("");
|
||||||
|
setIsValid(rpdbkey ? true : false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="rpdbkey">
|
||||||
|
RPDB API Key (get it from{" "}
|
||||||
|
<a
|
||||||
|
href="https://ratingposterdb.com/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
RatingPosterDB
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="rpdbkey"
|
||||||
|
value={tempKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTempKey(e.target.value);
|
||||||
|
setIsValid(false);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
placeholder="Enter your RPDB API key"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
{isValid ? (
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button onClick={handleSave}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => validateRPDBKey(tempKey)}
|
||||||
|
disabled={!tempKey || isChecking}
|
||||||
|
>
|
||||||
|
{isChecking ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Checking
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Check Key'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useConfig } from "@/contexts/ConfigContext";
|
||||||
|
import { DialogClose } from "@/components/ui/dialog";
|
||||||
|
import { regions, streamingServices } from "@/data/streamings";
|
||||||
|
|
||||||
|
export default function Streaming() {
|
||||||
|
const [selectedCountry, setSelectedCountry] = useState("Brazil");
|
||||||
|
const { streaming, setStreaming } = useConfig();
|
||||||
|
const [tempSelectedServices, setTempSelectedServices] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTempSelectedServices(streaming);
|
||||||
|
}, [streaming]);
|
||||||
|
|
||||||
|
const toggleService = (serviceId: string) => {
|
||||||
|
setTempSelectedServices(prev =>
|
||||||
|
prev.includes(serviceId)
|
||||||
|
? prev.filter(id => id !== serviceId)
|
||||||
|
: [...prev, serviceId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showProvider = (serviceId: string) => {
|
||||||
|
return regions[selectedCountry as keyof typeof regions]?.includes(serviceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
setStreaming(tempSelectedServices);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setTempSelectedServices(streaming);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Based on <a href="https://github.com/rleroi/Stremio-Streaming-Catalogs-Addon" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">rleroi/Stremio-Streaming-Catalogs-Addon</a>
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">Filter providers by country:</p>
|
||||||
|
<Select value={selectedCountry} onValueChange={setSelectedCountry}>
|
||||||
|
<SelectTrigger className="bg-background">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-background border shadow-md">
|
||||||
|
{Object.keys(regions).map((country) => (
|
||||||
|
<SelectItem
|
||||||
|
key={country}
|
||||||
|
value={country}
|
||||||
|
className="cursor-pointer hover:bg-accent hover:text-accent-foreground data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
|
||||||
|
>
|
||||||
|
{country}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-5 gap-4">
|
||||||
|
{streamingServices.map((service) => (
|
||||||
|
showProvider(service.id) && (
|
||||||
|
<button
|
||||||
|
key={service.id}
|
||||||
|
onClick={() => toggleService(service.id)}
|
||||||
|
className={`w-12 h-12 sm:w-14 sm:h-14 rounded-xl border transition-opacity ${
|
||||||
|
tempSelectedServices.includes(service.id)
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border opacity-50 hover:opacity-100"
|
||||||
|
}`}
|
||||||
|
title={service.name}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={service.icon}
|
||||||
|
alt={service.name}
|
||||||
|
className="w-full h-full rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline" type="button" onClick={handleCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" onClick={handleSave}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useConfig } from "@/contexts/ConfigContext";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { DialogClose } from "@/components/ui/dialog";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { AlertCircle, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export default function TMDB() {
|
||||||
|
const { sessionId, setSessionId } = useConfig();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleRequestToken = useCallback(async (requestToken: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/session_id?request_token=${requestToken}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to create session');
|
||||||
|
|
||||||
|
const sessionId = await response.text();
|
||||||
|
setSessionId(sessionId);
|
||||||
|
|
||||||
|
// Limpa os parâmetros da URL sem mudar a página
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError("Failed to create TMDB session");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [setSessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const requestToken = urlParams.get('request_token');
|
||||||
|
|
||||||
|
if (requestToken) {
|
||||||
|
handleRequestToken(requestToken);
|
||||||
|
}
|
||||||
|
}, [handleRequestToken]);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uuid = crypto.randomUUID();
|
||||||
|
const response = await fetch(`/request_token?cache_buster=${uuid}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to get request token');
|
||||||
|
|
||||||
|
const requestToken = await response.text();
|
||||||
|
const tmdbAuthUrl = `https://www.themoviedb.org/authenticate/${requestToken}?redirect_to=${window.location.href}`;
|
||||||
|
window.location.href = tmdbAuthUrl;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError("Failed to start TMDB authentication");
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
setSessionId("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sessionId ? (
|
||||||
|
<div className="flex flex-col items-center space-y-4">
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
You are logged in to TMDB
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="destructive" onClick={handleLogout}>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleLogin}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Connecting to TMDB...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Login with TMDB'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
interface AddonConfig {
|
||||||
|
rpdbkey?: string;
|
||||||
|
mdblistkey?: string;
|
||||||
|
includeAdult?: boolean;
|
||||||
|
provideImdbId?: boolean;
|
||||||
|
tmdbPrefix?: boolean;
|
||||||
|
language?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
catalogs?: Array<{
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
enabled: boolean;
|
||||||
|
showInHome: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateAddonUrl(config: AddonConfig): string {
|
||||||
|
// Criar um novo objeto apenas com os valores necessários
|
||||||
|
const configToEncode = {
|
||||||
|
...config,
|
||||||
|
// Remove os itens se forem nulos/vazios
|
||||||
|
rpdbkey: config.rpdbkey || undefined,
|
||||||
|
mdblistkey: config.mdblistkey || undefined,
|
||||||
|
sessionId: config.sessionId || undefined,
|
||||||
|
// Filtra apenas catálogos habilitados
|
||||||
|
catalogs: config.catalogs?.filter(c => c.enabled).map(({ id, type, showInHome }) => ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
showInHome
|
||||||
|
})) || undefined,
|
||||||
|
// Converte booleanos para strings
|
||||||
|
includeAdult: config.includeAdult === true ? "true" : undefined,
|
||||||
|
provideImdbId: config.provideImdbId === true ? "true" : undefined,
|
||||||
|
tmdbPrefix: config.tmdbPrefix === true ? "true" : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remover propriedades undefined/null
|
||||||
|
const cleanConfig = Object.fromEntries(
|
||||||
|
Object.entries(configToEncode).filter(([_, value]) => value !== undefined && value !== null)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Converter o objeto em string e codificar para URL
|
||||||
|
const encodedConfig = encodeURIComponent(JSON.stringify(cleanConfig));
|
||||||
|
|
||||||
|
return `/${encodedConfig}/manifest.json`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App.tsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(<App />);
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useConfig } from "@/contexts/ConfigContext";
|
||||||
|
import { baseCatalogs, authCatalogs, mdblistCatalogs, streamingCatalogs } from "@/data/catalogs";
|
||||||
|
import { DndContext, DragEndEvent, closestCenter } from "@dnd-kit/core";
|
||||||
|
import { SortableContext, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
||||||
|
import { SortableCatalogCard } from "@/components/SortableCatalogCard";
|
||||||
|
|
||||||
|
const CatalogColumn = ({
|
||||||
|
title,
|
||||||
|
catalogs,
|
||||||
|
catalogConfigs,
|
||||||
|
onCatalogChange,
|
||||||
|
onDragEnd,
|
||||||
|
}) => (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
|
<DndContext collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||||
|
<SortableContext
|
||||||
|
items={catalogs.map((c) => `${c.id}-${c.type}`)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{catalogs.map((catalog) => (
|
||||||
|
<SortableCatalogCard
|
||||||
|
key={`${catalog.id}-${catalog.type}`}
|
||||||
|
id={`${catalog.id}-${catalog.type}`}
|
||||||
|
catalog={catalog}
|
||||||
|
name={catalog.name}
|
||||||
|
config={catalogConfigs[`${catalog.id}-${catalog.type}`]}
|
||||||
|
onChange={(enabled, showInHome) =>
|
||||||
|
onCatalogChange(catalog.id, catalog.type, enabled, showInHome)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Catalogs = () => {
|
||||||
|
const { sessionId, mdblistkey, streaming, catalogs, setCatalogs } = useConfig();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const allCatalogs = [
|
||||||
|
...baseCatalogs,
|
||||||
|
...(sessionId ? authCatalogs : []),
|
||||||
|
...(mdblistkey ? mdblistCatalogs : []),
|
||||||
|
...(streaming?.length
|
||||||
|
? streaming.flatMap((serviceId) => streamingCatalogs[serviceId] || [])
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
setCatalogs((prev) => {
|
||||||
|
const existingIds = new Set(prev.map((c) => `${c.id}-${c.type}`));
|
||||||
|
const newCatalogs = allCatalogs.filter(
|
||||||
|
(c) => !existingIds.has(`${c.id}-${c.type}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
...newCatalogs.map((c) => ({ id: c.id, type: c.type, name: c.name, enabled: true, showInHome: true })),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}, [sessionId, mdblistkey, streaming]);
|
||||||
|
|
||||||
|
const catalogConfigs = catalogs.reduce((acc, config) => {
|
||||||
|
acc[`${config.id}-${config.type}`] = {
|
||||||
|
enabled: config.enabled,
|
||||||
|
showInHome: config.showInHome,
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const handleCatalogChange = (catalogId, type, enabled, showInHome) => {
|
||||||
|
setCatalogs((prev) =>
|
||||||
|
prev.map((c) =>
|
||||||
|
c.id === catalogId && c.type === type
|
||||||
|
? { ...c, enabled, showInHome }
|
||||||
|
: c
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
setCatalogs((prev) => {
|
||||||
|
const oldIndex = prev.findIndex((c) => `${c.id}-${c.type}` === active.id);
|
||||||
|
const newIndex = prev.findIndex((c) => `${c.id}-${c.type}` === over.id);
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return prev;
|
||||||
|
|
||||||
|
return arrayMove(prev, oldIndex, newIndex);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="md:p-12 px-2 py-12">
|
||||||
|
<div className="flex flex-col mb-6">
|
||||||
|
<h1 className="text-xl font-semibold mb-1">Catalogs</h1>
|
||||||
|
<p className="text-gray-500 text-sm">Manage the catalogs available in the addon.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<CatalogColumn
|
||||||
|
title="Movies"
|
||||||
|
catalogs={catalogs.filter((c) => c.type === "movie")}
|
||||||
|
catalogConfigs={catalogConfigs}
|
||||||
|
onCatalogChange={handleCatalogChange}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
/>
|
||||||
|
<CatalogColumn
|
||||||
|
title="TV Shows"
|
||||||
|
catalogs={catalogs.filter((c) => c.type === "series")}
|
||||||
|
catalogConfigs={catalogConfigs}
|
||||||
|
onCatalogChange={handleCatalogChange}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Catalogs;
|
||||||