Merge branch 'master' into portal-latest
|
@ -10,7 +10,7 @@ services:
|
||||||
abuse-scanner:
|
abuse-scanner:
|
||||||
# uncomment "build" and comment out "image" to build from sources
|
# uncomment "build" and comment out "image" to build from sources
|
||||||
# build: https://github.com/SkynetLabs/abuse-scanner.git#main
|
# build: https://github.com/SkynetLabs/abuse-scanner.git#main
|
||||||
image: skynetlabs/abuse-scanner:0.1.0
|
image: skynetlabs/abuse-scanner:0.1.1
|
||||||
container_name: abuse-scanner
|
container_name: abuse-scanner
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
|
|
|
@ -89,6 +89,7 @@ services:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- GATSBY_PORTAL_DOMAIN=${PORTAL_DOMAIN}
|
- GATSBY_PORTAL_DOMAIN=${PORTAL_DOMAIN}
|
||||||
|
- GATSBY_STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY}
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/data/dashboard-v2/.cache:/usr/app/.cache
|
- ./docker/data/dashboard-v2/.cache:/usr/app/.cache
|
||||||
- ./docker/data/dashboard-v2/public:/usr/app/public
|
- ./docker/data/dashboard-v2/public:/usr/app/public
|
||||||
|
|
|
@ -15,7 +15,7 @@ services:
|
||||||
blocker:
|
blocker:
|
||||||
# uncomment "build" and comment out "image" to build from sources
|
# uncomment "build" and comment out "image" to build from sources
|
||||||
# build: https://github.com/SkynetLabs/blocker.git#main
|
# build: https://github.com/SkynetLabs/blocker.git#main
|
||||||
image: skynetlabs/blocker:0.1.0
|
image: skynetlabs/blocker:0.1.1
|
||||||
container_name: blocker
|
container_name: blocker
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { SWRConfig } from "swr";
|
import { SWRConfig } from "swr";
|
||||||
|
import { Elements } from "@stripe/react-stripe-js";
|
||||||
|
import { loadStripe } from "@stripe/stripe-js";
|
||||||
import "@fontsource/sora/300.css"; // light
|
import "@fontsource/sora/300.css"; // light
|
||||||
import "@fontsource/sora/400.css"; // normal
|
import "@fontsource/sora/400.css"; // normal
|
||||||
import "@fontsource/sora/500.css"; // medium
|
import "@fontsource/sora/500.css"; // medium
|
||||||
|
@ -11,15 +13,19 @@ import swrConfig from "./src/lib/swrConfig";
|
||||||
import { MODAL_ROOT_ID } from "./src/components/Modal";
|
import { MODAL_ROOT_ID } from "./src/components/Modal";
|
||||||
import { PortalSettingsProvider } from "./src/contexts/portal-settings";
|
import { PortalSettingsProvider } from "./src/contexts/portal-settings";
|
||||||
|
|
||||||
|
const stripePromise = loadStripe(process.env.GATSBY_STRIPE_PUBLISHABLE_KEY);
|
||||||
|
|
||||||
export function wrapPageElement({ element, props }) {
|
export function wrapPageElement({ element, props }) {
|
||||||
const Layout = element.type.Layout ?? React.Fragment;
|
const Layout = element.type.Layout ?? React.Fragment;
|
||||||
return (
|
return (
|
||||||
<PortalSettingsProvider>
|
<PortalSettingsProvider>
|
||||||
<SWRConfig value={swrConfig}>
|
<SWRConfig value={swrConfig}>
|
||||||
|
<Elements stripe={stripePromise}>
|
||||||
<Layout {...props}>
|
<Layout {...props}>
|
||||||
{element}
|
{element}
|
||||||
<div id={MODAL_ROOT_ID} />
|
<div id={MODAL_ROOT_ID} />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
</Elements>
|
||||||
</SWRConfig>
|
</SWRConfig>
|
||||||
</PortalSettingsProvider>
|
</PortalSettingsProvider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { SWRConfig } from "swr";
|
import { SWRConfig } from "swr";
|
||||||
|
import { Elements } from "@stripe/react-stripe-js";
|
||||||
|
import { loadStripe } from "@stripe/stripe-js";
|
||||||
import "@fontsource/sora/300.css"; // light
|
import "@fontsource/sora/300.css"; // light
|
||||||
import "@fontsource/sora/400.css"; // normal
|
import "@fontsource/sora/400.css"; // normal
|
||||||
import "@fontsource/sora/500.css"; // medium
|
import "@fontsource/sora/500.css"; // medium
|
||||||
|
@ -11,15 +13,19 @@ import swrConfig from "./src/lib/swrConfig";
|
||||||
import { MODAL_ROOT_ID } from "./src/components/Modal";
|
import { MODAL_ROOT_ID } from "./src/components/Modal";
|
||||||
import { PortalSettingsProvider } from "./src/contexts/portal-settings";
|
import { PortalSettingsProvider } from "./src/contexts/portal-settings";
|
||||||
|
|
||||||
|
const stripePromise = loadStripe(process.env.GATSBY_STRIPE_PUBLISHABLE_KEY);
|
||||||
|
|
||||||
export function wrapPageElement({ element, props }) {
|
export function wrapPageElement({ element, props }) {
|
||||||
const Layout = element.type.Layout ?? React.Fragment;
|
const Layout = element.type.Layout ?? React.Fragment;
|
||||||
return (
|
return (
|
||||||
<PortalSettingsProvider>
|
<PortalSettingsProvider>
|
||||||
<SWRConfig value={swrConfig}>
|
<SWRConfig value={swrConfig}>
|
||||||
|
<Elements stripe={stripePromise}>
|
||||||
<Layout {...props}>
|
<Layout {...props}>
|
||||||
{element}
|
{element}
|
||||||
<div id={MODAL_ROOT_ID} />
|
<div id={MODAL_ROOT_ID} />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
</Elements>
|
||||||
</SWRConfig>
|
</SWRConfig>
|
||||||
</PortalSettingsProvider>
|
</PortalSettingsProvider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -22,6 +22,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/sora": "^4.5.3",
|
"@fontsource/sora": "^4.5.3",
|
||||||
"@fontsource/source-sans-pro": "^4.5.3",
|
"@fontsource/source-sans-pro": "^4.5.3",
|
||||||
|
"@stripe/react-stripe-js": "^1.7.1",
|
||||||
|
"@stripe/stripe-js": "^1.27.0",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
"copy-text-to-clipboard": "^3.0.1",
|
"copy-text-to-clipboard": "^3.0.1",
|
||||||
"dayjs": "^1.10.8",
|
"dayjs": "^1.10.8",
|
||||||
|
@ -33,7 +35,6 @@
|
||||||
"nanoid": "^3.3.1",
|
"nanoid": "^3.3.1",
|
||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
"postcss": "^8.4.6",
|
"postcss": "^8.4.6",
|
||||||
"pretty-bytes": "^6.0.0",
|
|
||||||
"react": "^17.0.1",
|
"react": "^17.0.1",
|
||||||
"react-dom": "^17.0.1",
|
"react-dom": "^17.0.1",
|
||||||
"react-dropzone": "^12.0.4",
|
"react-dropzone": "^12.0.4",
|
||||||
|
|
|
@ -5,15 +5,25 @@ import styled from "styled-components";
|
||||||
/**
|
/**
|
||||||
* Primary UI component for user interaction
|
* Primary UI component for user interaction
|
||||||
*/
|
*/
|
||||||
export const Button = styled.button.attrs(({ disabled, $primary, type }) => ({
|
export const Button = styled.button.attrs(({ as: polymorphicAs, disabled, $primary, type }) => {
|
||||||
type,
|
// We want to default to type=button in most cases, but sometimes we use this component
|
||||||
className: cn("px-6 py-2.5 rounded-full font-sans uppercase text-xs tracking-wide transition-[opacity_filter]", {
|
// as a polymorphic one (i.e. for links), and then we should avoid setting `type` property,
|
||||||
|
// as it breaks styling in Safari.
|
||||||
|
const typeAttr = polymorphicAs && polymorphicAs !== "button" ? undefined : type;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: typeAttr,
|
||||||
|
className: cn(
|
||||||
|
"px-6 py-2.5 inline-block rounded-full font-sans uppercase text-xs tracking-wide transition-[opacity_filter]",
|
||||||
|
{
|
||||||
"bg-primary text-palette-600": $primary,
|
"bg-primary text-palette-600": $primary,
|
||||||
"bg-white border-2 border-black text-palette-600": !$primary,
|
"bg-white border-2 border-black text-palette-600": !$primary,
|
||||||
"cursor-not-allowed opacity-60": disabled,
|
"cursor-not-allowed opacity-60": disabled,
|
||||||
"hover:brightness-90": !disabled,
|
"hover:brightness-90": !disabled,
|
||||||
}),
|
}
|
||||||
}))``;
|
),
|
||||||
|
};
|
||||||
|
})``;
|
||||||
|
|
||||||
Button.propTypes = {
|
Button.propTypes = {
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
import prettyBytes from "pretty-bytes";
|
|
||||||
|
|
||||||
import { useUser } from "../../contexts/user";
|
import { useUser } from "../../contexts/user";
|
||||||
import useActivePlan from "../../hooks/useActivePlan";
|
import useActivePlan from "../../hooks/useActivePlan";
|
||||||
|
import humanBytes from "../../lib/humanBytes";
|
||||||
import { ContainerLoadingIndicator } from "../LoadingIndicator";
|
import { ContainerLoadingIndicator } from "../LoadingIndicator";
|
||||||
|
|
||||||
import LatestPayment from "./LatestPayment";
|
import LatestPayment from "./LatestPayment";
|
||||||
|
@ -33,7 +33,7 @@ const CurrentPlan = () => {
|
||||||
<h4>{activePlan.name}</h4>
|
<h4>{activePlan.name}</h4>
|
||||||
<div className="text-palette-400 justify-between flex flex-col grow">
|
<div className="text-palette-400 justify-between flex flex-col grow">
|
||||||
{activePlan.price === 0 && activePlan.limits && (
|
{activePlan.price === 0 && activePlan.limits && (
|
||||||
<p>{prettyBytes(activePlan.limits.storageLimit, { binary: true })} without paying a dime! 🎉</p>
|
<p>{humanBytes(activePlan.limits.storageLimit)} without paying a dime! 🎉</p>
|
||||||
)}
|
)}
|
||||||
{activePlan.price !== 0 &&
|
{activePlan.price !== 0 &&
|
||||||
(user.subscriptionCancelAtPeriodEnd ? (
|
(user.subscriptionCancelAtPeriodEnd ? (
|
||||||
|
|
|
@ -14,7 +14,7 @@ const SuggestedPlan = ({ plans, activePlan }) => {
|
||||||
<div className="mt-7">
|
<div className="mt-7">
|
||||||
<p className="font-sans font-semibold text-xs uppercase text-primary">Discover {nextPlan.name}</p>
|
<p className="font-sans font-semibold text-xs uppercase text-primary">Discover {nextPlan.name}</p>
|
||||||
<p className="pt-1 text-xs sm:text-base">{nextPlan.description}</p>
|
<p className="pt-1 text-xs sm:text-base">{nextPlan.description}</p>
|
||||||
<Button $primary as={Link} to={`/upgrade?selectedPlan=${nextPlan.id}`} className="mt-6">
|
<Button $primary as={Link} to="/payments" className="mt-6">
|
||||||
Upgrade
|
Upgrade
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import fileSize from "pretty-bytes";
|
|
||||||
import { Link } from "gatsby";
|
import { Link } from "gatsby";
|
||||||
import cn from "classnames";
|
import cn from "classnames";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
@ -10,6 +9,7 @@ import { ContainerLoadingIndicator } from "../LoadingIndicator";
|
||||||
|
|
||||||
import { GraphBar } from "./GraphBar";
|
import { GraphBar } from "./GraphBar";
|
||||||
import { UsageGraph } from "./UsageGraph";
|
import { UsageGraph } from "./UsageGraph";
|
||||||
|
import humanBytes from "../../lib/humanBytes";
|
||||||
|
|
||||||
const useUsageData = () => {
|
const useUsageData = () => {
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
@ -45,7 +45,7 @@ const useUsageData = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const size = (bytes) => {
|
const size = (bytes) => {
|
||||||
const text = fileSize(bytes ?? 0, { maximumFractionDigits: 0, binary: true });
|
const text = humanBytes(bytes ?? 0, { precision: 0 });
|
||||||
const [value, unit] = text.split(" ");
|
const [value, unit] = text.split(" ");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -99,7 +99,7 @@ export default function CurrentUsage() {
|
||||||
<span>Files</span>
|
<span>Files</span>
|
||||||
<span className="inline-flex place-content-between w-[37%]">
|
<span className="inline-flex place-content-between w-[37%]">
|
||||||
<Link
|
<Link
|
||||||
to="/upgrade"
|
to="/payments"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-primary underline-offset-3 decoration-dotted hover:text-primary-light hover:underline",
|
"text-primary underline-offset-3 decoration-dotted hover:text-primary-light hover:underline",
|
||||||
{ invisible: !nextPlan }
|
{ invisible: !nextPlan }
|
||||||
|
|
|
@ -1,29 +1,40 @@
|
||||||
import * as React from "react";
|
import { useState } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useMedia } from "react-use";
|
import { useMedia } from "react-use";
|
||||||
|
|
||||||
import theme from "../../lib/theme";
|
import theme from "../../lib/theme";
|
||||||
|
|
||||||
import { ContainerLoadingIndicator } from "../LoadingIndicator";
|
import { ContainerLoadingIndicator } from "../LoadingIndicator";
|
||||||
import { Select, SelectOption } from "../Select";
|
|
||||||
import { Switch } from "../Switch";
|
|
||||||
import { TextInputIcon } from "../TextInputIcon/TextInputIcon";
|
|
||||||
import { SearchIcon } from "../Icons";
|
|
||||||
|
|
||||||
import FileTable from "./FileTable";
|
import FileTable from "./FileTable";
|
||||||
import useFormattedFilesData from "./useFormattedFilesData";
|
import useFormattedFilesData from "./useFormattedFilesData";
|
||||||
|
import { MobileFileList } from "./MobileFileList";
|
||||||
|
import { Pagination } from "./Pagination";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
const FileList = ({ type }) => {
|
const FileList = ({ type }) => {
|
||||||
const isMediumScreenOrLarger = useMedia(`(min-width: ${theme.screens.md})`);
|
const isMediumScreenOrLarger = useMedia(`(min-width: ${theme.screens.md})`);
|
||||||
const { data, error } = useSWR(`user/${type}?pageSize=10`);
|
const [offset, setOffset] = useState(0);
|
||||||
|
const baseUrl = `user/${type}?pageSize=${PAGE_SIZE}`;
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
mutate: refreshList,
|
||||||
|
} = useSWR(`${baseUrl}&offset=${offset}`, {
|
||||||
|
revalidateOnMount: true,
|
||||||
|
});
|
||||||
const items = useFormattedFilesData(data?.items || []);
|
const items = useFormattedFilesData(data?.items || []);
|
||||||
|
const count = data?.count || 0;
|
||||||
|
|
||||||
const setFilter = (name, value) => console.log("filter", name, "set to", value);
|
// Next page preloading
|
||||||
|
const hasMoreRecords = data ? data.offset + data.pageSize < data.count : false;
|
||||||
|
const nextPageOffset = hasMoreRecords ? data.offset + data.pageSize : offset;
|
||||||
|
useSWR(`${baseUrl}&offset=${nextPageOffset}`);
|
||||||
|
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full h-full justify-center items-center text-palette-400">
|
<div className="flex w-full h-full justify-center items-center text-palette-400">
|
||||||
{/* TODO: proper error message */}
|
|
||||||
{!data && !error && <ContainerLoadingIndicator />}
|
{!data && !error && <ContainerLoadingIndicator />}
|
||||||
{!data && error && <p>An error occurred while loading this data.</p>}
|
{!data && error && <p>An error occurred while loading this data.</p>}
|
||||||
{data && <p>No {type} found.</p>}
|
{data && <p>No {type} found.</p>}
|
||||||
|
@ -32,42 +43,14 @@ const FileList = ({ type }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 pt-4">
|
<>
|
||||||
<div className="flex flex-col gap-4 lg:flex-row justify-between items-center">
|
{isMediumScreenOrLarger ? (
|
||||||
<TextInputIcon
|
<FileTable onUpdated={refreshList} items={items} />
|
||||||
className="w-full lg:w-[280px] xl:w-[420px]"
|
) : (
|
||||||
placeholder="Search"
|
<MobileFileList items={items} onUpdated={refreshList} />
|
||||||
icon={<SearchIcon size={20} />}
|
)}
|
||||||
onChange={console.log.bind(console)}
|
<Pagination count={count} offset={offset} setOffset={setOffset} pageSize={PAGE_SIZE} />
|
||||||
/>
|
</>
|
||||||
<div className="flex flex-row items-center uppercase font-light text-sm gap-4">
|
|
||||||
<Switch onChange={(value) => setFilter("showSmallFiles", value)} className="mr-8">
|
|
||||||
<span className="underline decoration-dashed underline-offset-2 decoration-2 decoration-gray-300">
|
|
||||||
Show small files
|
|
||||||
</span>
|
|
||||||
</Switch>
|
|
||||||
<div>
|
|
||||||
<span className="pr-2">File type:</span>
|
|
||||||
<Select onChange={(value) => setFilter("type", value)}>
|
|
||||||
<SelectOption value="all" label="All" />
|
|
||||||
<SelectOption value="mp4" label=".mp4" />
|
|
||||||
<SelectOption value="pdf" label=".pdf" />
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="pr-2">Sort:</span>
|
|
||||||
<Select onChange={(value) => setFilter("type", value)}>
|
|
||||||
<SelectOption value="size-desc" label="Biggest size" />
|
|
||||||
<SelectOption value="size-asc" label="Smallest size" />
|
|
||||||
<SelectOption value="date-desc" label="Latest" />
|
|
||||||
<SelectOption value="date-asc" label="Oldest" />
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* TODO: mobile view (it's not tabular) */}
|
|
||||||
{isMediumScreenOrLarger ? <FileTable items={items} /> : "Mobile view"}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -2,72 +2,47 @@ import { CogIcon, ShareIcon } from "../Icons";
|
||||||
import { PopoverMenu } from "../PopoverMenu/PopoverMenu";
|
import { PopoverMenu } from "../PopoverMenu/PopoverMenu";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeadCell, TableRow } from "../Table";
|
import { Table, TableBody, TableCell, TableHead, TableHeadCell, TableRow } from "../Table";
|
||||||
import { CopyButton } from "../CopyButton";
|
import { CopyButton } from "../CopyButton";
|
||||||
|
import { useSkylinkOptions } from "./useSkylinkOptions";
|
||||||
|
import { useSkylinkSharing } from "./useSkylinkSharing";
|
||||||
|
|
||||||
const buildShareMenu = (item) => {
|
const SkylinkOptionsMenu = ({ skylink, onUpdated }) => {
|
||||||
return [
|
const { inProgress, options } = useSkylinkOptions({ skylink, onUpdated });
|
||||||
{
|
|
||||||
label: "Facebook",
|
|
||||||
callback: () => {
|
|
||||||
console.info("share to Facebook", item);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Twitter",
|
|
||||||
callback: () => {
|
|
||||||
console.info("share to Twitter", item);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Discord",
|
|
||||||
callback: () => {
|
|
||||||
console.info("share to Discord", item);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildOptionsMenu = (item) => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: "Preview",
|
|
||||||
callback: () => {
|
|
||||||
console.info("preview", item);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Download",
|
|
||||||
callback: () => {
|
|
||||||
console.info("download", item);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Unpin",
|
|
||||||
callback: () => {
|
|
||||||
console.info("unpin", item);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Report",
|
|
||||||
callback: () => {
|
|
||||||
console.info("report", item);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function FileTable({ items }) {
|
|
||||||
return (
|
return (
|
||||||
|
<PopoverMenu inProgress={inProgress} options={options} openClassName="text-primary">
|
||||||
|
<button aria-label="Manage this skylink">
|
||||||
|
<CogIcon />
|
||||||
|
</button>
|
||||||
|
</PopoverMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SkylinkSharingMenu = ({ skylink }) => {
|
||||||
|
const { options } = useSkylinkSharing(skylink);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopoverMenu options={options} openClassName="text-primary">
|
||||||
|
<button aria-label="Share this skylink">
|
||||||
|
<ShareIcon size={22} />
|
||||||
|
</button>
|
||||||
|
</PopoverMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FileTable({ items, onUpdated }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
<Table style={{ tableLayout: "fixed" }}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow noHoverEffect>
|
<TableRow noHoverEffect>
|
||||||
<TableHeadCell className="w-[240px] xl:w-[360px]">Name</TableHeadCell>
|
<TableHeadCell className="w-[180px] xl:w-[360px]">Name</TableHeadCell>
|
||||||
<TableHeadCell className="w-[80px]">Type</TableHeadCell>
|
<TableHeadCell className="w-[80px]">Type</TableHeadCell>
|
||||||
<TableHeadCell className="w-[80px]" align="right">
|
<TableHeadCell className="w-[100px]" align="right">
|
||||||
Size
|
Size
|
||||||
</TableHeadCell>
|
</TableHeadCell>
|
||||||
<TableHeadCell className="w-[180px]">Uploaded</TableHeadCell>
|
<TableHeadCell className="w-[160px]">Uploaded</TableHeadCell>
|
||||||
<TableHeadCell className="hidden lg:table-cell">Skylink</TableHeadCell>
|
<TableHeadCell className="hidden lg:table-cell">Skylink</TableHeadCell>
|
||||||
<TableHeadCell className="w-[100px]">Activity</TableHeadCell>
|
<TableHeadCell className="w-[90px]">Activity</TableHeadCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
@ -76,30 +51,22 @@ export default function FileTable({ items }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={id}>
|
<TableRow key={id}>
|
||||||
<TableCell className="w-[240px] xl:w-[360px]">{name}</TableCell>
|
<TableCell className="w-[180px] xl:w-[360px]">{name}</TableCell>
|
||||||
<TableCell className="w-[80px]">{type}</TableCell>
|
<TableCell className="w-[80px]">{type}</TableCell>
|
||||||
<TableCell className="w-[80px]" align="right">
|
<TableCell className="w-[100px]" align="right">
|
||||||
{size}
|
{size}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="w-[180px]">{date}</TableCell>
|
<TableCell className="w-[160px]">{date}</TableCell>
|
||||||
<TableCell className="hidden lg:table-cell pr-6 !overflow-visible">
|
<TableCell className="hidden lg:table-cell pr-6 !overflow-visible">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<CopyButton value={skylink} className="mr-2" aria-label="Copy skylink" />
|
<CopyButton value={skylink} className="mr-2" aria-label="Copy skylink" />
|
||||||
<span className="w-full inline-block truncate">{skylink}</span>
|
<span className="w-full inline-block truncate">{skylink}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="w-[100px] !overflow-visible">
|
<TableCell className="w-[90px] !overflow-visible">
|
||||||
<div className="flex text-palette-600 gap-4">
|
<div className="flex text-palette-600 gap-4">
|
||||||
<PopoverMenu options={buildShareMenu(item)} openClassName="text-primary">
|
<SkylinkOptionsMenu skylink={skylink} onUpdated={onUpdated} />
|
||||||
<button aria-label="Share this skylink">
|
<SkylinkSharingMenu skylink={skylink} />
|
||||||
<ShareIcon size={22} />
|
|
||||||
</button>
|
|
||||||
</PopoverMenu>
|
|
||||||
<PopoverMenu options={buildOptionsMenu(item)} openClassName="text-primary">
|
|
||||||
<button aria-label="Manage this skylink">
|
|
||||||
<CogIcon />
|
|
||||||
</button>
|
|
||||||
</PopoverMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
@ -107,5 +74,6 @@ export default function FileTable({ items }) {
|
||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import cn from "classnames";
|
||||||
|
|
||||||
|
import { ChevronDownIcon } from "../Icons";
|
||||||
|
import { useSkylinkSharing } from "./useSkylinkSharing";
|
||||||
|
import { ContainerLoadingIndicator } from "../LoadingIndicator";
|
||||||
|
import { useSkylinkOptions } from "./useSkylinkOptions";
|
||||||
|
|
||||||
|
const SharingMenu = ({ skylink }) => {
|
||||||
|
const { options } = useSkylinkSharing(skylink);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 bg-white px-4 py-6 w-1/2">
|
||||||
|
{options.map(({ label, callback }, index) => (
|
||||||
|
<button key={index} className="uppercase text-left" onClick={callback}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OptionsMenu = ({ skylink, onUpdated }) => {
|
||||||
|
const { inProgress, options } = useSkylinkOptions({ skylink, onUpdated });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("relative px-4 py-6 w-1/2", { "bg-primary/10": !inProgress })}>
|
||||||
|
<div className={cn("flex flex-col gap-4", { "opacity-0": inProgress })}>
|
||||||
|
{options.map(({ label, callback }, index) => (
|
||||||
|
<button key={index} className="uppercase text-left" onClick={callback}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{inProgress && (
|
||||||
|
<ContainerLoadingIndicator className="absolute inset-0 !p-0 z-50 bg-primary/10 !text-palette-200" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ListItem = ({ item, onUpdated }) => {
|
||||||
|
const { name, type, size, date, skylink } = item;
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const toggle = () => setOpen((open) => !open);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("p-4 flex flex-col bg-palette-100", { "bg-opacity-50": !open })}>
|
||||||
|
<div className="flex items-center gap-4 justify-between">
|
||||||
|
<div className="info flex flex-col gap-2 truncate">
|
||||||
|
<div className="truncate">{name}</div>
|
||||||
|
<div className="flex divide-x divide-palette-200 text-xs">
|
||||||
|
<span className="px-1">{type}</span>
|
||||||
|
<span className="px-1">{size}</span>
|
||||||
|
<span className="px-1">{date}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={toggle}>
|
||||||
|
<ChevronDownIcon className={cn("transition-[transform]", { "-scale-100": open })} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex transition-[max-height_padding] overflow-hidden text-xs text-left font-sans tracking-normal",
|
||||||
|
{ "pt-4 max-h-[150px]": open, "pt-0 max-h-0": !open }
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SharingMenu skylink={skylink} />
|
||||||
|
<OptionsMenu skylink={skylink} onUpdated={onUpdated} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MobileFileList = ({ items, onUpdated }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<ListItem key={item.id} item={item} onUpdated={onUpdated} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { Button } from "../Button";
|
||||||
|
|
||||||
|
export const Pagination = ({ count, offset, setOffset, pageSize }) => {
|
||||||
|
const start = count ? offset + 1 : 0;
|
||||||
|
const end = offset + pageSize > count ? count : offset + pageSize;
|
||||||
|
|
||||||
|
const showPaginationButtons = offset > 0 || count > end;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="px-4 py-3 flex items-center justify-between" aria-label="Pagination">
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
Showing {start} to {end} of {count} results
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{showPaginationButtons && (
|
||||||
|
<div className="flex-1 flex justify-between sm:justify-end space-x-3">
|
||||||
|
<Button disabled={offset - pageSize < 0} onClick={() => setOffset(offset - pageSize)} className="!border-0">
|
||||||
|
Previous page
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={offset + pageSize >= count}
|
||||||
|
onClick={() => setOffset(offset + pageSize)}
|
||||||
|
className="!border-0"
|
||||||
|
>
|
||||||
|
Next page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,7 +1,7 @@
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import prettyBytes from "pretty-bytes";
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { DATE_FORMAT } from "../../lib/config";
|
import { DATE_FORMAT } from "../../lib/config";
|
||||||
|
import humanBytes from "../../lib/humanBytes";
|
||||||
|
|
||||||
const parseFileName = (fileName) => {
|
const parseFileName = (fileName) => {
|
||||||
const lastDotIndex = Math.max(0, fileName.lastIndexOf(".")) || Infinity;
|
const lastDotIndex = Math.max(0, fileName.lastIndexOf(".")) || Infinity;
|
||||||
|
@ -16,7 +16,7 @@ const formatItem = ({ size, name: rawFileName, uploadedOn, downloadedOn, ...rest
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
date,
|
date,
|
||||||
size: prettyBytes(size),
|
size: humanBytes(size, { precision: 2 }),
|
||||||
type,
|
type,
|
||||||
name,
|
name,
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import accountsService from "../../services/accountsService";
|
||||||
|
import skynetClient from "../../services/skynetClient";
|
||||||
|
|
||||||
|
export const useSkylinkOptions = ({ skylink, onUpdated }) => {
|
||||||
|
const [inProgress, setInProgress] = useState(false);
|
||||||
|
|
||||||
|
const options = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: "Preview",
|
||||||
|
callback: async () => window.open(await skynetClient.getSkylinkUrl(skylink)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Download",
|
||||||
|
callback: () => skynetClient.downloadFile(skylink),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Unpin",
|
||||||
|
callback: async () => {
|
||||||
|
setInProgress(true);
|
||||||
|
await accountsService.delete(`user/uploads/${skylink}`);
|
||||||
|
await onUpdated(); // No need to setInProgress(false), since at this point this hook should already be unmounted
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[skylink, onUpdated]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inProgress,
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
};
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import copy from "copy-text-to-clipboard";
|
||||||
|
|
||||||
|
import skynetClient from "../../services/skynetClient";
|
||||||
|
|
||||||
|
const COPY_LINK_LABEL = "Copy link";
|
||||||
|
|
||||||
|
export const useSkylinkSharing = (skylink) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [copyLabel, setCopyLabel] = useState(COPY_LINK_LABEL);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (copied) {
|
||||||
|
setCopyLabel("Copied!");
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => setCopied(false), 1500);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
} else {
|
||||||
|
setCopyLabel(COPY_LINK_LABEL);
|
||||||
|
}
|
||||||
|
}, [copied]);
|
||||||
|
|
||||||
|
const options = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: copyLabel,
|
||||||
|
callback: async () => {
|
||||||
|
setCopied(true);
|
||||||
|
copy(await skynetClient.getSkylinkUrl(skylink));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[skylink, copyLabel]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
};
|
|
@ -1,6 +1,13 @@
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import { graphql, useStaticQuery } from "gatsby";
|
import { graphql, useStaticQuery } from "gatsby";
|
||||||
|
|
||||||
|
import favicon from "../../../static/favicon.ico";
|
||||||
|
import favicon16 from "../../../static/favicon-16x16.png";
|
||||||
|
import favicon32 from "../../../static/favicon-32x32.png";
|
||||||
|
import appleIcon144 from "../../../static/apple-touch-icon-144x144.png";
|
||||||
|
import appleIcon152 from "../../../static/apple-touch-icon-152x152.png";
|
||||||
|
import msTileIcon from "../../../static/mstile-144x144.png";
|
||||||
|
|
||||||
export const Metadata = ({ children }) => {
|
export const Metadata = ({ children }) => {
|
||||||
const { site } = useStaticQuery(
|
const { site } = useStaticQuery(
|
||||||
graphql`
|
graphql`
|
||||||
|
@ -18,7 +25,15 @@ export const Metadata = ({ children }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Helmet htmlAttributes={{ lang: "en" }} titleTemplate={`%s | ${title}`} defaultTitle={title}>
|
<Helmet htmlAttributes={{ lang: "en" }} titleTemplate={`%s | ${title}`} defaultTitle={title}>
|
||||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
<meta name="application-name" content="Skynet Account Dashboard" />
|
||||||
|
<link rel="icon" type="image/x-icon" href={favicon} />
|
||||||
|
<link rel="icon" type="image/png" href={favicon16} sizes="16x16" />
|
||||||
|
<link rel="icon" type="image/png" href={favicon32} sizes="32x32" />
|
||||||
|
<link rel="apple-touch-icon-precomposed" sizes="144x144" href={appleIcon144} />
|
||||||
|
<link rel="apple-touch-icon-precomposed" sizes="152x152" href={appleIcon152} />
|
||||||
|
<meta name="msapplication-TileColor" content="#FFFFFF" />
|
||||||
|
<meta name="msapplication-TileImage" content={msTileIcon} />
|
||||||
|
|
||||||
<meta name="description" content="Manage your Skynet uploads, account subscription, settings and API keys" />
|
<meta name="description" content="Manage your Skynet uploads, account subscription, settings and API keys" />
|
||||||
<link rel="preconnect" href={`https://${process.env.GATSBY_PORTAL_DOMAIN}/`} />
|
<link rel="preconnect" href={`https://${process.env.GATSBY_PORTAL_DOMAIN}/`} />
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -14,8 +14,8 @@ const PanelTitle = styled.h6.attrs({
|
||||||
*
|
*
|
||||||
* These additional props will be rendered onto the panel's body element.
|
* These additional props will be rendered onto the panel's body element.
|
||||||
*/
|
*/
|
||||||
export const Panel = ({ title, ...props }) => (
|
export const Panel = ({ title, wrapperClassName, ...props }) => (
|
||||||
<div>
|
<div className={wrapperClassName}>
|
||||||
{title && <PanelTitle>{title}</PanelTitle>}
|
{title && <PanelTitle>{title}</PanelTitle>}
|
||||||
<PanelBody {...props} />
|
<PanelBody {...props} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -26,8 +26,13 @@ Panel.propTypes = {
|
||||||
* Label of the panel
|
* Label of the panel
|
||||||
*/
|
*/
|
||||||
title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
|
title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
|
||||||
|
/**
|
||||||
|
* CSS class to be applied to the external container
|
||||||
|
*/
|
||||||
|
wrapperClassName: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
Panel.defaultProps = {
|
Panel.defaultProps = {
|
||||||
title: "",
|
title: "",
|
||||||
|
wrapperClassName: "",
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,6 +2,9 @@ import { Children, cloneElement, useRef, useState } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { useClickAway } from "react-use";
|
import { useClickAway } from "react-use";
|
||||||
import styled, { css, keyframes } from "styled-components";
|
import styled, { css, keyframes } from "styled-components";
|
||||||
|
import cn from "classnames";
|
||||||
|
|
||||||
|
import { ContainerLoadingIndicator } from "../LoadingIndicator";
|
||||||
|
|
||||||
const dropDown = keyframes`
|
const dropDown = keyframes`
|
||||||
0% {
|
0% {
|
||||||
|
@ -41,15 +44,15 @@ const Option = styled.li.attrs({
|
||||||
hover:before:content-['']`,
|
hover:before:content-['']`,
|
||||||
})``;
|
})``;
|
||||||
|
|
||||||
export const PopoverMenu = ({ options, children, openClassName, ...props }) => {
|
export const PopoverMenu = ({ options, children, openClassName, inProgress, ...props }) => {
|
||||||
const containerRef = useRef();
|
const containerRef = useRef();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
useClickAway(containerRef, () => setOpen(false));
|
useClickAway(containerRef, () => setOpen(false));
|
||||||
|
|
||||||
const handleChoice = (callback) => () => {
|
const handleChoice = (callback) => async () => {
|
||||||
|
await callback();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
callback();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -62,11 +65,16 @@ export const PopoverMenu = ({ options, children, openClassName, ...props }) => {
|
||||||
)}
|
)}
|
||||||
{open && (
|
{open && (
|
||||||
<Flyout>
|
<Flyout>
|
||||||
|
<div className={cn("relative", { "opacity-50": inProgress })}>
|
||||||
{options.map(({ label, callback }) => (
|
{options.map(({ label, callback }) => (
|
||||||
<Option key={label} onClick={handleChoice(callback)}>
|
<Option key={label} onClick={handleChoice(callback)}>
|
||||||
{label}
|
{label}
|
||||||
</Option>
|
</Option>
|
||||||
))}
|
))}
|
||||||
|
{inProgress && (
|
||||||
|
<ContainerLoadingIndicator className="absolute inset-0 !p-0 z-50 bg-white !text-palette-300" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Flyout>
|
</Flyout>
|
||||||
)}
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
|
@ -87,4 +95,9 @@ PopoverMenu.propTypes = {
|
||||||
callback: PropTypes.func.isRequired,
|
callback: PropTypes.func.isRequired,
|
||||||
})
|
})
|
||||||
).isRequired,
|
).isRequired,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If true, a loading icon will be displayed to signal an async action is taking place.
|
||||||
|
*/
|
||||||
|
inProgress: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,7 +2,7 @@ import styled from "styled-components";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
const Slide = styled.div.attrs(({ isVisible }) => ({
|
const Slide = styled.div.attrs(({ isVisible }) => ({
|
||||||
className: `slider-slide transition-opacity ${isVisible ? "" : "opacity-50 cursor-pointer select-none"}`,
|
className: `slider-slide transition-opacity h-full ${isVisible ? "" : "opacity-50 cursor-pointer select-none"}`,
|
||||||
}))``;
|
}))``;
|
||||||
|
|
||||||
Slide.propTypes = {
|
Slide.propTypes = {
|
||||||
|
|
|
@ -62,7 +62,7 @@ const Slider = ({ slides, breakpoints, scrollerClassName, className }) => {
|
||||||
const isVisible = index >= activeIndex && index < activeIndex + visibleSlides;
|
const isVisible = index >= activeIndex && index < activeIndex + visibleSlides;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`slide-${index}`}>
|
<div key={`slide-${index}`} className="h-full">
|
||||||
<Slide
|
<Slide
|
||||||
isVisible={isVisible || !scrollable}
|
isVisible={isVisible || !scrollable}
|
||||||
onClickCapture={
|
onClickCapture={
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import cn from "classnames";
|
import cn from "classnames";
|
||||||
import bytes from "pretty-bytes";
|
|
||||||
import { StatusCodes } from "http-status-codes";
|
import { StatusCodes } from "http-status-codes";
|
||||||
import copy from "copy-text-to-clipboard";
|
import copy from "copy-text-to-clipboard";
|
||||||
import path from "path-browserify";
|
import path from "path-browserify";
|
||||||
|
@ -9,6 +8,7 @@ import { ProgressBar } from "./ProgressBar";
|
||||||
import UploaderItemIcon from "./UploaderItemIcon";
|
import UploaderItemIcon from "./UploaderItemIcon";
|
||||||
import buildUploadErrorMessage from "./buildUploadErrorMessage";
|
import buildUploadErrorMessage from "./buildUploadErrorMessage";
|
||||||
import skynetClient from "../../services/skynetClient";
|
import skynetClient from "../../services/skynetClient";
|
||||||
|
import humanBytes from "../../lib/humanBytes";
|
||||||
|
|
||||||
const getFilePath = (file) => file.webkitRelativePath || file.path || file.name;
|
const getFilePath = (file) => file.webkitRelativePath || file.path || file.name;
|
||||||
|
|
||||||
|
@ -88,7 +88,7 @@ export default function UploaderItem({ onUploadStateChange, upload }) {
|
||||||
<div className="font-content truncate">
|
<div className="font-content truncate">
|
||||||
{upload.status === "uploading" && (
|
{upload.status === "uploading" && (
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
Uploading {bytes(upload.file.size * upload.progress)} of {bytes(upload.file.size)}
|
Uploading {humanBytes(upload.file.size * upload.progress)} of {humanBytes(upload.file.size)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{upload.status === "enqueued" && <span className="text-palette-300">Upload in queue, please wait</span>}
|
{upload.status === "enqueued" && <span className="text-palette-300">Upload in queue, please wait</span>}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { getReasonPhrase } from "http-status-codes";
|
import { getReasonPhrase } from "http-status-codes";
|
||||||
import bytes from "pretty-bytes";
|
import humanBytes from "../../lib/humanBytes";
|
||||||
|
|
||||||
export default function buildUploadErrorMessage(error) {
|
export default function buildUploadErrorMessage(error) {
|
||||||
// The request was made and the server responded with a status code that falls out of the range of 2xx
|
// The request was made and the server responded with a status code that falls out of the range of 2xx
|
||||||
|
@ -29,7 +29,7 @@ export default function buildUploadErrorMessage(error) {
|
||||||
const matchTusMaxFileSizeError = error.message.match(/upload exceeds maximum size: \d+ > (?<limit>\d+)/);
|
const matchTusMaxFileSizeError = error.message.match(/upload exceeds maximum size: \d+ > (?<limit>\d+)/);
|
||||||
|
|
||||||
if (matchTusMaxFileSizeError) {
|
if (matchTusMaxFileSizeError) {
|
||||||
return `File exceeds size limit of ${bytes(parseInt(matchTusMaxFileSizeError.groups.limit, 10))}`;
|
return `File exceeds size limit of ${humanBytes(matchTusMaxFileSizeError.groups.limit, { precision: 0 })}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: We should add a note "our team has been notified" and have some kind of notification with this error.
|
// TODO: We should add a note "our team has been notified" and have some kind of notification with this error.
|
||||||
|
|
|
@ -17,7 +17,7 @@ export default function useUpgradeRedirect() {
|
||||||
|
|
||||||
if (isDataLoaded) {
|
if (isDataLoaded) {
|
||||||
if (settings.isSubscriptionRequired && !hasPaidSubscription) {
|
if (settings.isSubscriptionRequired && !hasPaidSubscription) {
|
||||||
navigate("/upgrade");
|
navigate("/payments");
|
||||||
} else {
|
} else {
|
||||||
setVerifyingSubscription(false);
|
setVerifyingSubscription(false);
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,12 +10,14 @@ const Sidebar = () => (
|
||||||
<SidebarLink activeClassName="!border-l-primary" to="/settings">
|
<SidebarLink activeClassName="!border-l-primary" to="/settings">
|
||||||
Account
|
Account
|
||||||
</SidebarLink>
|
</SidebarLink>
|
||||||
|
{/*
|
||||||
<SidebarLink activeClassName="!border-l-primary" to="/settings/notifications">
|
<SidebarLink activeClassName="!border-l-primary" to="/settings/notifications">
|
||||||
Notifications
|
Notifications
|
||||||
</SidebarLink>
|
</SidebarLink>
|
||||||
<SidebarLink activeClassName="!border-l-primary" to="/settings/export">
|
<SidebarLink activeClassName="!border-l-primary" to="/settings/export">
|
||||||
Export
|
Export
|
||||||
</SidebarLink>
|
</SidebarLink>
|
||||||
|
*/}
|
||||||
<SidebarLink activeClassName="!border-l-primary" to="/settings/developer-settings">
|
<SidebarLink activeClassName="!border-l-primary" to="/settings/developer-settings">
|
||||||
Developer settings
|
Developer settings
|
||||||
</SidebarLink>
|
</SidebarLink>
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
const UNITS = ["B", "kB", "MB", "GB", "TB", "PB", "EB"];
|
||||||
|
const BASE = 1024;
|
||||||
|
const DEFAULT_OPTIONS = { precision: 1 };
|
||||||
|
|
||||||
|
export default function humanBytes(bytes, { precision } = DEFAULT_OPTIONS) {
|
||||||
|
if (!Number.isFinite(bytes) || bytes < 0) {
|
||||||
|
throw new TypeError(`Expected a finite, positive number. Received: ${typeof bytes}: ${bytes}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
|
||||||
|
while (value >= BASE) {
|
||||||
|
value /= BASE;
|
||||||
|
unitIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localizedValue = value.toLocaleString(undefined, { maximumFractionDigits: precision });
|
||||||
|
|
||||||
|
return `${localizedValue} ${UNITS[unitIndex]}`;
|
||||||
|
}
|
|
@ -1,54 +1,27 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Link } from "gatsby";
|
|
||||||
|
|
||||||
// styles
|
import DashboardLayout from "../layouts/DashboardLayout";
|
||||||
const pageStyles = {
|
|
||||||
color: "#232129",
|
|
||||||
padding: "96px",
|
|
||||||
fontFamily: "-apple-system, Roboto, sans-serif, serif",
|
|
||||||
};
|
|
||||||
const headingStyles = {
|
|
||||||
marginTop: 0,
|
|
||||||
marginBottom: 64,
|
|
||||||
maxWidth: 320,
|
|
||||||
};
|
|
||||||
|
|
||||||
const paragraphStyles = {
|
import { Metadata } from "../components/Metadata";
|
||||||
marginBottom: 48,
|
import HighlightedLink from "../components/HighlightedLink";
|
||||||
};
|
|
||||||
const codeStyles = {
|
|
||||||
color: "#8A6534",
|
|
||||||
padding: 4,
|
|
||||||
backgroundColor: "#FFF4DB",
|
|
||||||
fontSize: "1.25rem",
|
|
||||||
borderRadius: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
// markup
|
|
||||||
const NotFoundPage = () => {
|
const NotFoundPage = () => {
|
||||||
return (
|
return (
|
||||||
<main style={pageStyles}>
|
<div>
|
||||||
|
<Metadata>
|
||||||
<title>Not found</title>
|
<title>Not found</title>
|
||||||
<h1 style={headingStyles}>Page not found</h1>
|
</Metadata>
|
||||||
<p style={paragraphStyles}>
|
<section className="mt-12">
|
||||||
Sorry{" "}
|
<h1>Oops! 😔</h1>
|
||||||
<span role="img" aria-label="Pensive emoji">
|
<p>Whatever you're looking for is not here.</p>
|
||||||
😔
|
<p>
|
||||||
</span>{" "}
|
Would you like to <HighlightedLink to="/">go back to homepage</HighlightedLink>?
|
||||||
we couldn’t find what you were looking for.
|
|
||||||
<br />
|
|
||||||
{process.env.NODE_ENV === "development" ? (
|
|
||||||
<>
|
|
||||||
<br />
|
|
||||||
Try creating a page in <code style={codeStyles}>src/pages/</code>.
|
|
||||||
<br />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<br />
|
|
||||||
<Link to="/">Go home</Link>.
|
|
||||||
</p>
|
</p>
|
||||||
</main>
|
</section>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
NotFoundPage.Layout = DashboardLayout;
|
||||||
|
|
||||||
export default NotFoundPage;
|
export default NotFoundPage;
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import bytes from "pretty-bytes";
|
|
||||||
|
|
||||||
import AuthLayout from "../../layouts/AuthLayout";
|
import AuthLayout from "../../layouts/AuthLayout";
|
||||||
|
|
||||||
|
@ -10,12 +9,13 @@ import { usePortalSettings } from "../../contexts/portal-settings";
|
||||||
import { PlansProvider, usePlans } from "../../contexts/plans";
|
import { PlansProvider, usePlans } from "../../contexts/plans";
|
||||||
import { Metadata } from "../../components/Metadata";
|
import { Metadata } from "../../components/Metadata";
|
||||||
import { useUser } from "../../contexts/user";
|
import { useUser } from "../../contexts/user";
|
||||||
|
import humanBytes from "../../lib/humanBytes";
|
||||||
|
|
||||||
const FreePortalHeader = () => {
|
const FreePortalHeader = () => {
|
||||||
const { plans } = usePlans();
|
const { plans } = usePlans();
|
||||||
|
|
||||||
const freePlan = plans.find(({ price }) => price === 0);
|
const freePlan = plans.find(({ price }) => price === 0);
|
||||||
const freeStorage = freePlan?.limits ? bytes(freePlan.limits?.storageLimit, { binary: true }) : null;
|
const freeStorage = freePlan?.limits ? humanBytes(freePlan.limits?.storageLimit, { binary: true }) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 mb-8 font-sans">
|
<div className="mt-4 mb-8 font-sans">
|
||||||
|
|
|
@ -12,7 +12,7 @@ const FilesPage = () => {
|
||||||
<Metadata>
|
<Metadata>
|
||||||
<title>Files</title>
|
<title>Files</title>
|
||||||
</Metadata>
|
</Metadata>
|
||||||
<Panel title="Files">
|
<Panel title="Uploads">
|
||||||
<FileList type="uploads" />
|
<FileList type="uploads" />
|
||||||
</Panel>
|
</Panel>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -1,11 +1,232 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { useStripe } from "@stripe/react-stripe-js";
|
||||||
|
import cn from "classnames";
|
||||||
|
|
||||||
|
import { useUser } from "../contexts/user";
|
||||||
|
import { PlansProvider } from "../contexts/plans/PlansProvider";
|
||||||
|
import useActivePlan from "../hooks/useActivePlan";
|
||||||
import DashboardLayout from "../layouts/DashboardLayout";
|
import DashboardLayout from "../layouts/DashboardLayout";
|
||||||
|
import { Panel } from "../components/Panel";
|
||||||
|
import Slider from "../components/Slider/Slider";
|
||||||
|
import { CheckmarkIcon } from "../components/Icons";
|
||||||
|
import { Button } from "../components/Button";
|
||||||
|
import { usePortalSettings } from "../contexts/portal-settings";
|
||||||
|
import { Alert } from "../components/Alert";
|
||||||
|
import HighlightedLink from "../components/HighlightedLink";
|
||||||
|
import { Metadata } from "../components/Metadata";
|
||||||
|
import accountsService from "../services/accountsService";
|
||||||
|
import humanBytes from "../lib/humanBytes";
|
||||||
|
import { Modal } from "../components/Modal";
|
||||||
|
|
||||||
const PaymentsPage = () => {
|
const PAID_PORTAL_BREAKPOINTS = [
|
||||||
return <>PAYMENTS HISTORY</>;
|
{
|
||||||
|
name: "lg",
|
||||||
|
scrollable: true,
|
||||||
|
visibleSlides: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sm",
|
||||||
|
scrollable: true,
|
||||||
|
visibleSlides: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scrollable: true,
|
||||||
|
visibleSlides: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const FREE_PORTAL_BREAKPOINTS = [
|
||||||
|
{
|
||||||
|
name: "xl",
|
||||||
|
scrollable: true,
|
||||||
|
visibleSlides: 4,
|
||||||
|
},
|
||||||
|
...PAID_PORTAL_BREAKPOINTS,
|
||||||
|
];
|
||||||
|
|
||||||
|
const PlanSummaryItem = ({ children }) => (
|
||||||
|
<li className="flex items-start gap-1 my-2">
|
||||||
|
<CheckmarkIcon size={32} className="text-primary shrink-0" />
|
||||||
|
<div className="mt-1">{children}</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Description = styled.div`
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 6rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Price = ({ price }) => (
|
||||||
|
<div className="my-8 text-center">
|
||||||
|
<h2>
|
||||||
|
<sup className="text-lg -top-4">$</sup>
|
||||||
|
{price}
|
||||||
|
</h2>
|
||||||
|
<p className="uppercase text-sm font-light -mt-2">per month</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const bandwidth = (value) => `${humanBytes(value, { bits: true })}/s`;
|
||||||
|
|
||||||
|
const storage = (value) => humanBytes(value, { binary: true });
|
||||||
|
|
||||||
|
const localizedNumber = (value) => value.toLocaleString();
|
||||||
|
|
||||||
|
const PlansSlider = () => {
|
||||||
|
const { user, error: userError } = useUser();
|
||||||
|
const { plans, loading, activePlan, error: plansError } = useActivePlan(user);
|
||||||
|
const { settings } = usePortalSettings();
|
||||||
|
const [showPaymentError, setShowPaymentError] = React.useState(false);
|
||||||
|
const stripe = useStripe();
|
||||||
|
// This will be the base plan that we compare upload/download speeds against.
|
||||||
|
// On will either be the user's active plan or lowest of available tiers.
|
||||||
|
const basePlan = activePlan || plans[0];
|
||||||
|
|
||||||
|
if (userError || plansError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<h3>Oooops!</h3>
|
||||||
|
<p>Something went wrong, please try again later.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubscribe = async (selectedPlan) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = await accountsService
|
||||||
|
.post("stripe/checkout", {
|
||||||
|
json: {
|
||||||
|
price: selectedPlan.stripe,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.json();
|
||||||
|
await stripe.redirectToCheckout({ sessionId });
|
||||||
|
} catch (error) {
|
||||||
|
setShowPaymentError(true);
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full mb-24">
|
||||||
|
<Metadata>
|
||||||
|
<title>Payments</title>
|
||||||
|
</Metadata>
|
||||||
|
{settings.isSubscriptionRequired && !activePlan && (
|
||||||
|
<Alert $variant="info" className="mb-6">
|
||||||
|
<p className="font-semibold mt-0">This Skynet portal requires a paid subscription.</p>
|
||||||
|
<p>
|
||||||
|
If you're not ready for that yet, you can use your account on{" "}
|
||||||
|
<HighlightedLink as="a" href="https://skynetfree.net">
|
||||||
|
SkynetFree.net
|
||||||
|
</HighlightedLink>{" "}
|
||||||
|
to store up to 100GB for free.
|
||||||
|
</p>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{!loading && (
|
||||||
|
<Slider
|
||||||
|
slides={plans.map((plan) => {
|
||||||
|
const isHigherThanCurrent = plan.tier > activePlan?.tier;
|
||||||
|
const isCurrentPlanPaid = activePlan?.tier > 1;
|
||||||
|
const isCurrent = plan.tier === activePlan?.tier;
|
||||||
|
const isLower = plan.tier < activePlan?.tier;
|
||||||
|
const speed = plan.limits.uploadBandwidth;
|
||||||
|
const currentSpeed = basePlan?.limits?.uploadBandwidth;
|
||||||
|
const speedChange = speed > currentSpeed ? speed / currentSpeed : currentSpeed / speed;
|
||||||
|
const hasActivePlan = Boolean(activePlan);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
className={cn("min-h-[620px] px-6 py-10 flex flex-col relative h-full shadow-md", {
|
||||||
|
"border border-primary": isCurrent,
|
||||||
|
})}
|
||||||
|
wrapperClassName="h-full"
|
||||||
|
>
|
||||||
|
{isCurrent && (
|
||||||
|
<div className="absolute top-0 left-0 w-full h-6 bg-white px-6 rounded-t">
|
||||||
|
<span className="font-sans uppercase font-semibold text-xs bg-palette-100 px-2 py-1.5 rounded-b-md">
|
||||||
|
Current plan
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h3>{plan.name}</h3>
|
||||||
|
<Description>{plan.description}</Description>
|
||||||
|
<Price price={plan.price} />
|
||||||
|
|
||||||
|
<div className="text-center my-6">
|
||||||
|
{(!hasActivePlan || isHigherThanCurrent) &&
|
||||||
|
(isCurrentPlanPaid ? (
|
||||||
|
<Button $primary as="a" href="/api/stripe/billing">
|
||||||
|
Upgrade
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button $primary onClick={() => handleSubscribe(plan)}>
|
||||||
|
Upgrade
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{isCurrent && <Button disabled>Current</Button>}
|
||||||
|
{isLower && (
|
||||||
|
<Button as="a" href="/api/stripe/billing">
|
||||||
|
Choose
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{plan.limits && (
|
||||||
|
<ul className="-ml-2">
|
||||||
|
<PlanSummaryItem>
|
||||||
|
Pin up to {storage(plan.limits.storageLimit)} on decentralized storage
|
||||||
|
</PlanSummaryItem>
|
||||||
|
<PlanSummaryItem>
|
||||||
|
Support for up to {localizedNumber(plan.limits.maxNumberUploads)} files
|
||||||
|
</PlanSummaryItem>
|
||||||
|
<PlanSummaryItem>
|
||||||
|
{speed === currentSpeed
|
||||||
|
? `${bandwidth(plan.limits.uploadBandwidth)} upload and ${bandwidth(
|
||||||
|
plan.limits.downloadBandwidth
|
||||||
|
)} download`
|
||||||
|
: `${speedChange}X ${
|
||||||
|
speed > currentSpeed ? "faster" : "slower"
|
||||||
|
} upload and download speeds (${bandwidth(plan.limits.uploadBandwidth)} and ${bandwidth(
|
||||||
|
plan.limits.downloadBandwidth
|
||||||
|
)})`}
|
||||||
|
</PlanSummaryItem>
|
||||||
|
<PlanSummaryItem>
|
||||||
|
{plan.limits.maxUploadSize === plan.limits.storageLimit
|
||||||
|
? "No limit to file upload size"
|
||||||
|
: `Upload files up to ${storage(plan.limits.maxUploadSize)}`}
|
||||||
|
</PlanSummaryItem>
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
breakpoints={settings.isSubscriptionRequired ? PAID_PORTAL_BREAKPOINTS : FREE_PORTAL_BREAKPOINTS}
|
||||||
|
className="px-8 sm:px-4 md:px-0 lg:px-0 mt-10"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showPaymentError && (
|
||||||
|
<Modal onClose={() => setShowPaymentError(false)}>
|
||||||
|
<h3>Oops! 😔</h3>
|
||||||
|
<p className="font-semibold">There was an error contacting our payments provider</p>
|
||||||
|
<p>Please try again later</p>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PaymentsPage = () => (
|
||||||
|
<PlansProvider>
|
||||||
|
<PlansSlider />
|
||||||
|
</PlansProvider>
|
||||||
|
);
|
||||||
|
|
||||||
PaymentsPage.Layout = DashboardLayout;
|
PaymentsPage.Layout = DashboardLayout;
|
||||||
|
|
||||||
export default PaymentsPage;
|
export default PaymentsPage;
|
||||||
|
|
|
@ -1,157 +0,0 @@
|
||||||
import * as React from "react";
|
|
||||||
import bytes from "pretty-bytes";
|
|
||||||
import styled from "styled-components";
|
|
||||||
|
|
||||||
import { useUser } from "../contexts/user";
|
|
||||||
import { PlansProvider } from "../contexts/plans/PlansProvider";
|
|
||||||
import useActivePlan from "../hooks/useActivePlan";
|
|
||||||
import DashboardLayout from "../layouts/DashboardLayout";
|
|
||||||
import { Panel } from "../components/Panel";
|
|
||||||
import Slider from "../components/Slider/Slider";
|
|
||||||
import { CheckmarkIcon } from "../components/Icons";
|
|
||||||
import { Button } from "../components/Button";
|
|
||||||
import { usePortalSettings } from "../contexts/portal-settings";
|
|
||||||
import { Alert } from "../components/Alert";
|
|
||||||
import HighlightedLink from "../components/HighlightedLink";
|
|
||||||
import { Metadata } from "../components/Metadata";
|
|
||||||
|
|
||||||
const PAID_PORTAL_BREAKPOINTS = [
|
|
||||||
{
|
|
||||||
name: "lg",
|
|
||||||
scrollable: true,
|
|
||||||
visibleSlides: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "sm",
|
|
||||||
scrollable: true,
|
|
||||||
visibleSlides: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scrollable: true,
|
|
||||||
visibleSlides: 1,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const FREE_PORTAL_BREAKPOINTS = [
|
|
||||||
{
|
|
||||||
name: "xl",
|
|
||||||
scrollable: true,
|
|
||||||
visibleSlides: 4,
|
|
||||||
},
|
|
||||||
...PAID_PORTAL_BREAKPOINTS,
|
|
||||||
];
|
|
||||||
|
|
||||||
const PlanSummaryItem = ({ children }) => (
|
|
||||||
<li className="flex items-start gap-1 my-2">
|
|
||||||
<CheckmarkIcon size={32} className="text-primary shrink-0" />
|
|
||||||
<div className="mt-1">{children}</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
|
|
||||||
const Description = styled.div`
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 4;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 6rem;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Price = ({ price }) => (
|
|
||||||
<div className="my-8 text-center">
|
|
||||||
<h2>
|
|
||||||
<sup className="text-lg -top-4">$</sup>
|
|
||||||
{price}
|
|
||||||
</h2>
|
|
||||||
<p className="uppercase text-sm font-light -mt-2">per month</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const bandwidth = (value) => `${bytes(value, { bits: true })}/s`;
|
|
||||||
|
|
||||||
const storage = (value) => bytes(value, { binary: true });
|
|
||||||
|
|
||||||
const localizedNumber = (value) => value.toLocaleString();
|
|
||||||
|
|
||||||
const PlansSlider = () => {
|
|
||||||
const { user, error: userError } = useUser();
|
|
||||||
const { plans, loading, activePlan, error: plansError } = useActivePlan(user);
|
|
||||||
const { settings } = usePortalSettings();
|
|
||||||
|
|
||||||
if (userError || plansError) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center">
|
|
||||||
<h3>Oooops!</h3>
|
|
||||||
<p>Something went wrong, please try again later.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full mb-24">
|
|
||||||
<Metadata>
|
|
||||||
<title>Upgrade</title>
|
|
||||||
</Metadata>
|
|
||||||
{settings.isSubscriptionRequired && !activePlan && (
|
|
||||||
<Alert $variant="info" className="mb-6">
|
|
||||||
<p className="font-semibold mt-0">This Skynet portal requires a paid subscription.</p>
|
|
||||||
<p>
|
|
||||||
If you're not ready for that yet, you can use your account on{" "}
|
|
||||||
<HighlightedLink as="a" href="https://skynetfree.net">
|
|
||||||
SkynetFree.net
|
|
||||||
</HighlightedLink>{" "}
|
|
||||||
to store up to 100GB for free.
|
|
||||||
</p>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{!loading && (
|
|
||||||
<Slider
|
|
||||||
slides={plans.map((plan) => {
|
|
||||||
const isHigherThanCurrent = plan.tier > activePlan?.tier;
|
|
||||||
const isCurrent = plan.tier === activePlan?.tier;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Panel className="min-h-[620px] px-6 py-10 flex flex-col">
|
|
||||||
<h3>{plan.name}</h3>
|
|
||||||
<Description>{plan.description}</Description>
|
|
||||||
<Price price={plan.price} />
|
|
||||||
|
|
||||||
<div className="text-center my-6">
|
|
||||||
<Button $primary={isHigherThanCurrent} disabled={isCurrent}>
|
|
||||||
{isHigherThanCurrent && "Upgrade"}
|
|
||||||
{isCurrent && "Current"}
|
|
||||||
{!isHigherThanCurrent && !isCurrent && "Choose"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{plan.limits && (
|
|
||||||
<ul className="-ml-2">
|
|
||||||
<PlanSummaryItem>
|
|
||||||
Pin up to {storage(plan.limits.storageLimit)} of censorship-resistant storage
|
|
||||||
</PlanSummaryItem>
|
|
||||||
<PlanSummaryItem>
|
|
||||||
Support for up to {localizedNumber(plan.limits.maxNumberUploads)} files
|
|
||||||
</PlanSummaryItem>
|
|
||||||
<PlanSummaryItem>{bandwidth(plan.limits.uploadBandwidth)} upload bandwidth</PlanSummaryItem>
|
|
||||||
<PlanSummaryItem>{bandwidth(plan.limits.downloadBandwidth)} download bandwidth</PlanSummaryItem>
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
breakpoints={settings.isSubscriptionRequired ? PAID_PORTAL_BREAKPOINTS : FREE_PORTAL_BREAKPOINTS}
|
|
||||||
className="px-8 sm:px-4 md:px-0 lg:px-0"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const UpgradePage = () => (
|
|
||||||
<PlansProvider>
|
|
||||||
<PlansSlider />
|
|
||||||
</PlansProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
UpgradePage.Layout = DashboardLayout;
|
|
||||||
|
|
||||||
export default UpgradePage;
|
|
After Width: | Height: | Size: 9.6 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 453 B |
After Width: | Height: | Size: 1.0 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 5.3 KiB |
After Width: | Height: | Size: 9.6 KiB |
|
@ -29,6 +29,7 @@ module.exports = {
|
||||||
textColor: (theme) => ({ ...theme("colors"), ...colors }),
|
textColor: (theme) => ({ ...theme("colors"), ...colors }),
|
||||||
placeholderColor: (theme) => ({ ...theme("colors"), ...colors }),
|
placeholderColor: (theme) => ({ ...theme("colors"), ...colors }),
|
||||||
outlineColor: (theme) => ({ ...theme("colors"), ...colors }),
|
outlineColor: (theme) => ({ ...theme("colors"), ...colors }),
|
||||||
|
divideColor: (theme) => ({ ...theme("colors"), ...colors }),
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ["Sora", ...defaultTheme.fontFamily.sans],
|
sans: ["Sora", ...defaultTheme.fontFamily.sans],
|
||||||
|
|
|
@ -3107,6 +3107,18 @@
|
||||||
resolve-from "^5.0.0"
|
resolve-from "^5.0.0"
|
||||||
store2 "^2.12.0"
|
store2 "^2.12.0"
|
||||||
|
|
||||||
|
"@stripe/react-stripe-js@^1.7.1":
|
||||||
|
version "1.7.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@stripe/react-stripe-js/-/react-stripe-js-1.7.1.tgz#6e1db8f4a0eaf2193b153173d4aa7c38b681310d"
|
||||||
|
integrity sha512-GiUPoMo0xVvmpRD6JR9JAhAZ0W3ZpnYZNi0KE+91+tzrSFVpChKZbeSsJ5InlZhHFk9NckJCt1wOYBTqNsvt3A==
|
||||||
|
dependencies:
|
||||||
|
prop-types "^15.7.2"
|
||||||
|
|
||||||
|
"@stripe/stripe-js@^1.27.0":
|
||||||
|
version "1.27.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.27.0.tgz#ab0c82fa89fd40260de4414f69868b769e810550"
|
||||||
|
integrity sha512-SEiybUBu+tlsFKuzdFFydxxjkbrdzHo0tz/naYC5Dt9or/Ux2gcKJBPYQ4RmqQCNHFxgyNj6UYsclywwhe2inQ==
|
||||||
|
|
||||||
"@szmarczak/http-timer@^1.1.2":
|
"@szmarczak/http-timer@^1.1.2":
|
||||||
version "1.1.2"
|
version "1.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421"
|
resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421"
|
||||||
|
@ -12696,11 +12708,6 @@ pretty-bytes@^5.4.1:
|
||||||
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"
|
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"
|
||||||
integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==
|
integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==
|
||||||
|
|
||||||
pretty-bytes@^6.0.0:
|
|
||||||
version "6.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-6.0.0.tgz#928be2ad1f51a2e336add8ba764739f9776a8140"
|
|
||||||
integrity sha512-6UqkYefdogmzqAZWzJ7laYeJnaXDy2/J+ZqiiMtS7t7OfpXWTlaeGMwX8U6EFvPV/YWWEKRkS8hKS4k60WHTOg==
|
|
||||||
|
|
||||||
pretty-error@^2.1.1, pretty-error@^2.1.2:
|
pretty-error@^2.1.1, pretty-error@^2.1.2:
|
||||||
version "2.1.2"
|
version "2.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.2.tgz#be89f82d81b1c86ec8fdfbc385045882727f93b6"
|
resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.2.tgz#be89f82d81b1c86ec8fdfbc385045882727f93b6"
|
||||||
|
|