feat(stremio): add window to modify debrid data for cost comparison table (#79)

* feat: initial attempt

* feat: move new service data addition to existing service data container

* feat: move currency selector back to top of table

* feat: im a css god

* feat: no im not

* feat: final changes
This commit is contained in:
Viren070
2024-11-25 19:30:20 +00:00
committed by GitHub
parent 9edf69dbc4
commit 122c9bd3e5
9 changed files with 546 additions and 91 deletions
+2 -1
View File
@@ -223,6 +223,8 @@ I recommend coming to your own conclusion by reading the information below, in a
**Cost**
<details>
<summary>Expand me for a full table comparing the costs of all debrid services</summary>
@@ -234,7 +236,6 @@ The currency rates used here are updated semi-automatically every week.
These prices do not consider any discounts/deals/sales that may be available.
It also does not take into account any resellers that may offer the service at a cheaper price.
(I am working on allowing you to add/modify entries to this table.)
</Admonition>
<DebridCostComparisonTable />
@@ -0,0 +1,15 @@
import conversionRates from '@site/static/currency_rates.json';
export const availableCurrencies = Object.keys(conversionRates['USD']).filter((currency) =>
Object.keys(conversionRates['EUR']).includes(currency)
);
export const convertPrice = (price: number, fromCurrency: string, toCurrency: string): number => {
if (fromCurrency === toCurrency || !toCurrency) return price;
const rate = conversionRates[fromCurrency]?.[toCurrency];
return rate ? price * rate : price;
};
export const formatPrice = (price: number, currency: string) => {
return Intl.NumberFormat(undefined, { style: 'currency', currency }).format(price);
};
@@ -0,0 +1,65 @@
import { availableCurrencies } from "./CurrencyRates";
import Select from 'react-select';
import { useColorMode } from '@docusaurus/theme-common';
const currencyOptions = availableCurrencies.map((currency) => ({ value: currency, label: currency })).sort((a, b) => a.label.localeCompare(b.label));
const getStyles = (colorMode: string) => ({
control: (baseStyles: any, state: { isFocused: any; }) => ({
...baseStyles,
borderWidth: '2px',
backgroundColor: colorMode === 'dark' ? '#1b1b1d' : 'white',
borderRadius: 'var(--ifm-global-radius)',
borderColor: state.isFocused ? 'var(--ifm-color-primary-darkest)' : null,
color: 'var(--ifm-color-primary-dark)',
boxShadow: 'var(--button-box-shadow)',
"&:hover": {
borderColor: state.isFocused ? 'var(--ifm-color-primary-darkest)' : null,
boxShadow: 'var(--button-hover-box-shadow)',
},
}),
input: (baseStyles: any, state: any) => ({
...baseStyles,
color: 'var(--ifm-color-primary-lightest)',
}),
singleValue: (baseStyles: any, state: any) => ({
...baseStyles,
color: 'var(--ifm-color-primary-lightest)',
}),
menu: (baseStyles: any, state: any) => ({
...baseStyles,
color: colorMode === 'dark' ? 'white' : 'black',
backgroundColor: colorMode === 'dark' ? '#1b1b1d' : 'white',
}),
option: (baseStyles: any, state: { isFocused: any; }) => ({
...baseStyles,
color: 'var(--ifm-color-primary-lightest)',
backgroundColor: state.isFocused ? 'var(--ifm-color-primary-darkest)' : null,
"&:hover": {
backgroundColor: 'var(--ifm-color-primary-darker)',
},
"&:active": {
backgroundColor: 'var(--ifm-color-primary-darkest)',
},
}),
});
interface CurrencySelectProps {
currency: string;
setCurrency: (currency: string) => void;
}
export const CurrencySelect: React.FC<CurrencySelectProps> = ({ currency, setCurrency }) => {
const { colorMode } = useColorMode();
return (
<Select
id="currency-select"
value={{ value: currency, label: currency }}
onChange={(selectedOption) => setCurrency(selectedOption ? selectedOption.value : null)}
options={currencyOptions}
isClearable={true}
styles={getStyles(colorMode)}
/>
);
}
@@ -1,69 +1,19 @@
import React from 'react';
import Select from 'react-select';
import { useColorMode } from '@docusaurus/theme-common';
import styles from './styles.module.css';
import { CurrencySelect } from './CurrencySelect';
const getStyles = (colorMode: string) => ({
control: (baseStyles: any, state: { isFocused: any; }) => ({
...baseStyles,
borderWidth: '2px',
backgroundColor: colorMode === 'dark' ? '#1b1b1d' : 'white',
borderRadius: 'var(--ifm-global-radius)',
borderColor: state.isFocused ? 'var(--ifm-color-primary-darkest)' : null,
color: 'var(--ifm-color-primary-dark)',
boxShadow: 'var(--button-box-shadow)',
"&:hover": {
borderColor: state.isFocused ? 'var(--ifm-color-primary-darkest)' : null,
boxShadow: 'var(--button-hover-box-shadow)',
},
}),
input: (baseStyles: any, state: any) => ({
...baseStyles,
color: 'var(--ifm-color-primary-lightest)',
}),
singleValue: (baseStyles: any, state: any) => ({
...baseStyles,
color: 'var(--ifm-color-primary-lightest)',
}),
menu: (baseStyles: any, state: any) => ({
...baseStyles,
color: colorMode === 'dark' ? 'white' : 'black',
backgroundColor: colorMode === 'dark' ? '#1b1b1d' : 'white',
}),
option: (baseStyles: any, state: { isFocused: any; }) => ({
...baseStyles,
color: 'var(--ifm-color-primary-lightest)',
backgroundColor: state.isFocused ? 'var(--ifm-color-primary-darkest)' : null,
"&:hover": {
backgroundColor: 'var(--ifm-color-primary-darker)',
},
"&:active": {
backgroundColor: 'var(--ifm-color-primary-darkest)',
},
}),
});
interface CurrencySelectorProps {
primaryCurrency: string;
setPrimaryCurrency: (currency: string) => void;
currencyOptions: { value: string, label: string }[];
}
const CurrencySelector: React.FC<CurrencySelectorProps> = ({ primaryCurrency, setPrimaryCurrency, currencyOptions }) => {
const { colorMode } = useColorMode();
const CurrencySelector: React.FC<CurrencySelectorProps> = ({ primaryCurrency, setPrimaryCurrency }) => {
return (
<div className={styles["currency-select-container"]}>
<div className={styles["currency-select-box"]}>
<label htmlFor="currency-select"><strong>Select Currency (or clear for Original):</strong></label>
<Select
id="currency-select"
value={{ value: primaryCurrency, label: primaryCurrency }}
onChange={(selectedOption) => setPrimaryCurrency(selectedOption ? selectedOption.value : null)}
options={currencyOptions}
isClearable={true}
styles={getStyles(colorMode)}
/>
<label htmlFor="currency-select"><strong>Select Primary Currency</strong><br />The currency that all other prices are converted to. Leave blank to see original prices.</label>
<CurrencySelect setCurrency={setPrimaryCurrency} currency={primaryCurrency} />
</div>
</div>
);
@@ -0,0 +1,12 @@
export const initialServiceData = [
{ name: 'Torbox (Essential)', price: 33, duration: 365, currency: 'USD' },
{ name: 'Torbox (Standard)', price: 55, duration: 365, currency: 'USD' },
{ name: 'Torbox (Pro)', price: 110, duration: 365, currency: 'USD' },
{ name: 'Real-Debrid', price: 16, duration: 180, currency: 'EUR', pointData: { pointsPerPlan: 800, pointsRequiredForReward: 1000, durationPerReward: 30 } },
{ name: 'Debrid-Link', price: 25, duration: 300, currency: 'EUR' },
{ name: 'AllDebrid', price: 24.99, duration: 300, currency: 'EUR', pointData: { pointsPerPlan: 140, pointsRequiredForReward: 150, durationPerReward: 30 } },
{ name: 'Offcloud', price: 54.99, duration: 365, currency: 'USD' },
{ name: 'Premiumize', price: 69.99, duration: 365, currency: 'EUR' },
{ name: 'put.io (100GB)', price: 99, duration: 365, currency: 'USD' },
{ name: 'put.io (1TB)', price: 199, duration: 365, currency: 'USD' },
];
@@ -0,0 +1,243 @@
:root[data-theme="dark"] {
--service-data-background-color: #2d2f33;
}
:root[data-theme="light"] {
--service-data-background-color: #dadde1;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes appearFromCenter {
from {
transform: scale(0);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
@keyframes disappearToCenter {
from {
transform: scale(1);
opacity: 1;
}
to {
transform: scale(0);
opacity: 0;
}
}
.settings-popup {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
animation: fadeIn 0.5s ease-in-out;
}
.settings-popup.fade-out {
animation: fadeOut 0.5s ease-in-out;
}
.settings-content {
background: var(--service-data-background-color);
border-radius: var(--ifm-global-radius);
padding: 20px;
width: 80%;
max-width: 600px;
max-height: 80%;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
position: relative;
}
.close-button {
background: red;
color: white;
border: none;
padding: 12px;
cursor: pointer;
position: absolute;
top: 10px;
right: 10px;
border-radius: var(--ifm-global-radius);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.close-button:hover {
transform: scale(1.05);
box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15);
}
.service-list {
display: flex;
gap: 10px;
overflow-x: auto;
}
.service-item {
display: flex;
flex-direction: column;
background: var(--ifm-color-primary);
gap: 5px;
border: 1px solid #ccc;
padding: 15px;
border-radius: var(--ifm-global-radius);
min-width: 220px;
max-height: 1%;
overflow-y: auto;
transition: transform 0.5s ease-in-out;
}
.service-item.new-service {
animation: appearFromCenter 0.5s ease-in-out;
}
.service-item.deleted-service {
animation: disappearToCenter 0.5s ease-in-out;
}
.service-item.slide-left {
transform: translateX(-100%);
}
.service-item.slide-right {
transform: translateX(100%);
}
.add-new-service {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: transparent;
flex-grow: 1;
padding: 15px;
border-radius: var(--ifm-global-radius);
min-width: 220px;
transition: transform 0.2s, box-shadow 0.2s;
}
.add-new-service-button {
background: green;
color: white;
border: none;
padding: 15px;
cursor: pointer;
border-radius: var(--ifm-global-radius);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.add-new-service-button:hover {
transform: scale(1.05);
box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15);
}
.add-new-service-button:active {
transform: scale(0);
}
.settings-button {
background: none;
border: none;
cursor: pointer;
position: fixed;
top: 10px;
right: 10px;
}
.apply-button, .reset-button {
background: green;
color: white;
border: none;
padding: 15px;
cursor: pointer;
border-radius: var(--ifm-global-radius);
margin: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.apply-button:hover, .reset-button:hover {
transform: scale(1.05);
box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15);
}
.reset-button {
background: orange;
}
.settings-actions {
display: flex;
justify-content: space-between;
}
select, input {
padding: 10px;
border-radius: var(--ifm-global-radius);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s;
}
select:focus, input:focus {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15);
}
.delete-button {
background: red;
color: white;
border: none;
padding: 10px;
cursor: pointer;
border-radius: var(--ifm-global-radius);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.delete-button:hover {
transform: scale(1.05);
box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15);
}
@media (max-width: 600px) {
.settings-content {
width: 90%;
max-width: 90%;
padding: 10px;
}
.service-item {
flex-direction: column;
}
}
@@ -0,0 +1,163 @@
import React, { useState } from 'react';
import './Settings.css';
import { availableCurrencies } from './CurrencyRates';
import { initialServiceData } from './ServiceData';
import { showToast } from '@site/src/components/Toasts';
interface Service {
name: string;
price: number;
duration: number;
currency: string;
pointData?: {
pointsPerPlan: number;
pointsRequiredForReward: number;
durationPerReward: number;
},
slideClass?: string;
}
interface SettingsProps {
serviceData: Service[];
setServiceData: (data: Service[]) => void;
closeSettings: () => void;
primaryCurrency: string;
setPrimaryCurrency: (currency: string) => void;
}
const Settings: React.FC<SettingsProps> = ({ serviceData, setServiceData, closeSettings, primaryCurrency, setPrimaryCurrency }) => {
const [tempServiceData, setTempServiceData] = useState<Service[]>([...serviceData]);
const [isClosing, setIsClosing] = useState(false);
const [newServiceIndex, setNewServiceIndex] = useState<number | null>(null);
const [deletedServiceIndex, setDeletedServiceIndex] = useState<number | null>(null);
const handleAddService = () => {
const newService: Service = {
name: '',
price: 0,
duration: 0,
currency: 'USD',
};
setTempServiceData([...tempServiceData, newService]);
setNewServiceIndex(tempServiceData.length);
};
const handleUpdateService = (index: number, updatedService: Service) => {
const updatedServices = [...tempServiceData];
updatedServices[index] = updatedService;
setTempServiceData(updatedServices);
};
const handleDeleteService = (index: number) => {
setDeletedServiceIndex(index);
// Temporarily add the 'slide-left' class to items to the right of the deleted one.
const updatedServices = tempServiceData.map((service, i) => ({
...service,
slideClass: i > index ? 'slide-left' : '',
}));
setTempServiceData(updatedServices);
// Delay removing the service to allow the animation to complete.
setTimeout(() => {
const filteredServices = tempServiceData.filter((_, i) => i !== index);
setTempServiceData(filteredServices.map((service) => ({ ...service, slideClass: '' })));
setDeletedServiceIndex(null);
}, 500); // Match animation duration
};
const handleApplyChanges = () => {
if (tempServiceData.some((service) => !service.name || !service.price || !service.duration)) {
showToast('All fields must be filled in', 'error');
return;
}
if (tempServiceData.some((service) => service.price <= 0 || service.duration <= 0)) {
showToast('Price and duration must be greater than 0', 'error');
return;
}
setServiceData(tempServiceData);
handleClose();
};
const handleReset = () => {
setTempServiceData(initialServiceData);
};
const handleClose = () => {
setIsClosing(true);
setTimeout(() => {
closeSettings();
}, 500);
}
return (
<div className={`settings-popup ${isClosing ? 'fade-out' : ''}`}>
<div className="settings-content">
<button onClick={handleClose} className="close-button"></button>
<h2>Service Data</h2>
<div className="service-list">
{tempServiceData.map((service, index) => (
<div
key={index}
className={`service-item ${service.slideClass} ${
index === newServiceIndex ? 'new-service' : ''
} ${index === deletedServiceIndex ? 'deleted-service' : ''}`}
onAnimationEnd={() => {
if (index === newServiceIndex) {
setNewServiceIndex(null);
}
}}
>
<label style={{ color: 'white' }}>Service Name</label>
<input
type="text"
value={service.name}
onChange={(e) => handleUpdateService(index, { ...service, name: e.target.value })}
placeholder="Service Name"
/>
<label style={{ color: 'white' }}>Price</label>
<input
type="number"
value={service.price}
onChange={(e) => handleUpdateService(index, { ...service, price: parseFloat(e.target.value) })}
placeholder="Price"
/>
<label style={{ color: 'white' }}>Duration</label>
<input
type="number"
value={service.duration}
onChange={(e) => handleUpdateService(index, { ...service, duration: parseInt(e.target.value) })}
placeholder="Duration"
/>
<label style={{ color: 'white' }}>Currency</label>
<select
value={service.currency}
onChange={(e) => handleUpdateService(index, { ...service, currency: e.target.value })}
>
{availableCurrencies.map((currency) => (
<option key={currency} value={currency}>
{currency}
</option>
))}
</select>
<button onClick={() => handleDeleteService(index)} className="delete-button">Delete</button>
</div>
))}
<div className="service-item add-new-service">
<button className="add-new-service-button" onClick={handleAddService}>+</button>
</div>
</div>
<div className="settings-actions">
<button onClick={handleReset} className="reset-button">Reset</button>
<button onClick={handleApplyChanges} className="apply-button">Apply</button>
</div>
</div>
</div>
);
};
export default Settings;
@@ -1,24 +1,10 @@
import React, { useState, useEffect } from 'react';
import CurrencySelector from './CurrencySelector';
import conversionRates from '@site/static/currency_rates.json';
const availableCurrencies = Object.keys(conversionRates['USD']).filter((currency) =>
Object.keys(conversionRates['EUR']).includes(currency)
);
const currencyOptions = availableCurrencies.map((currency) => ({ value: currency, label: currency })).sort((a, b) => a.label.localeCompare(b.label));
const serviceData = [
{ name: 'Torbox (Essential)', price: 33, duration: 365, currency: 'USD' },
{ name: 'Torbox (Standard)', price: 55, duration: 365, currency: 'USD' },
{ name: 'Torbox (Pro)', price: 110, duration: 365, currency: 'USD' },
{ name: 'Real-Debrid', price: 16, duration: 180, currency: 'EUR', pointData: {pointsPerPlan: 800, pointsRequiredForReward: 1000, durationPerReward: 30} },
{ name: 'Debrid-Link', price: 25, duration: 300, currency: 'EUR' },
{ name: 'AllDebrid', price: 24.99, duration: 300, currency: 'EUR', pointData: {pointsPerPlan: 140, pointsRequiredForReward: 150, durationPerReward: 30} },
{ name: 'Offcloud', price: 54.99, duration: 365, currency: 'USD' },
{ name: 'Premiumize', price: 69.99, duration: 365, currency: 'EUR' },
{ name: 'put.io (100GB)', price: 99, duration: 365, currency: 'USD' },
{ name: 'put.io (1TB)', price: 199, duration: 365, currency: 'USD' },
];
import SettingsIcon from '@site/static/img/settings-icon.svg'
import Settings from './Settings';
import { convertPrice, formatPrice } from './CurrencyRates';
import { initialServiceData } from './ServiceData';
import styles from './styles.module.css';
interface Service {
name: string;
@@ -52,12 +38,6 @@ interface FormattedService extends CalculatedService {
formattedPlanDuration: JSX.Element;
}
const convertPrice = (price: number, fromCurrency: string, toCurrency: string): number => {
if (fromCurrency === toCurrency || !toCurrency) return price;
const rate = conversionRates[fromCurrency]?.[toCurrency];
return rate ? price * rate : price;
};
const calculatePrices = (service: Service, primaryCurrency: string): CalculatedService[] => {
const entries = [];
const baseCurrency = primaryCurrency || service.currency;
@@ -105,10 +85,6 @@ const calculatePrices = (service: Service, primaryCurrency: string): CalculatedS
return entries;
};
const formatPrice = (price: number, currency: string) => {
return Intl.NumberFormat(undefined, { style: 'currency', currency }).format(price);
};
const formatServiceData = (data: CalculatedService[], primaryCurrency: string): FormattedService[] => {
return data.map(service => ({
...service,
@@ -145,8 +121,10 @@ const formatServiceData = (data: CalculatedService[], primaryCurrency: string):
};
export default function DebridCostComparisonTable({ excludeServices }: { excludeServices?: string[]; }): JSX.Element {
const [primaryCurrency, setPrimaryCurrency] = useState<string>('GBP');
const [primaryCurrency, setPrimaryCurrency] = useState<string>('');
const [formattedData, setFormattedData] = useState<FormattedService[]>([]);
const [serviceData, setServiceData] = useState<Service[]>(initialServiceData);
const [showSettings, setShowSettings] = useState<boolean>(false);
useEffect(() => {
let filteredServices = serviceData;
@@ -166,15 +144,18 @@ export default function DebridCostComparisonTable({ excludeServices }: { exclude
const formatted = formatServiceData(data, primaryCurrency);
setFormattedData(formatted);
}, [primaryCurrency, excludeServices]);
}, [primaryCurrency, excludeServices, serviceData]);
return (
<>
<CurrencySelector
primaryCurrency={primaryCurrency}
setPrimaryCurrency={setPrimaryCurrency}
currencyOptions={currencyOptions}
/>
{showSettings && <Settings serviceData={serviceData} setServiceData={setServiceData} closeSettings={() => setShowSettings(false)} primaryCurrency={primaryCurrency} setPrimaryCurrency={setPrimaryCurrency} />}
<button onClick={() => setShowSettings(!showSettings)} className={styles.settingsButton} >
<b>Configure Table Data</b>
</button>
<CurrencySelector
primaryCurrency={primaryCurrency}
setPrimaryCurrency={setPrimaryCurrency}
/>
<table>
<thead>
<tr>
@@ -199,6 +180,8 @@ export default function DebridCostComparisonTable({ excludeServices }: { exclude
))}
</tbody>
</table>
</>
);
}
@@ -19,3 +19,26 @@
.currency-select {
color: var(--ifm-color-primary-darkest)
}
.settingsButton {
background: var(--ifm-color-primary);
width: 100%;
padding: 10px;
font-size: 1.2rem;
margin-bottom: 20px;
color: white;
border: none;
padding: 15px;
cursor: pointer;
align-self: flex-end;
border-radius: var(--ifm-global-radius);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s;
}
.settingsButton:hover {
transform: scale(1.01) translateY(-2px);
opacity: 0.9;
box-shadow: var(--button-hover-box-shadow);
}