delete nextjs, update packages
This commit is contained in:
parent
dbc14c8fb6
commit
698cf6e305
1
.gitignore
vendored
1
.gitignore
vendored
@ -12,3 +12,4 @@ production-server
|
||||
dump.rdb
|
||||
docs/api/*.js
|
||||
docs/api/static
|
||||
**/.DS_Store
|
||||
|
@ -1,7 +0,0 @@
|
||||
language: node_js
|
||||
|
||||
node_js:
|
||||
- "12"
|
||||
|
||||
script:
|
||||
- yarn run lint:nofix
|
@ -1,4 +1,4 @@
|
||||
FROM node:12-alpine
|
||||
FROM node:18-alpine
|
||||
|
||||
RUN apk add --update bash
|
||||
|
||||
|
@ -1,51 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import { Box, BoxProps } from "rebass/styled-components";
|
||||
import styled, { css } from "styled-components";
|
||||
import { ifProp } from "styled-tools";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Props extends BoxProps {
|
||||
href?: string;
|
||||
title?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
forButton?: boolean;
|
||||
isNextLink?: boolean;
|
||||
}
|
||||
const StyledBox = styled(Box)<Props>`
|
||||
cursor: pointer;
|
||||
color: #2196f3;
|
||||
border-bottom: 1px dotted transparent;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease-out;
|
||||
|
||||
${ifProp(
|
||||
{ forButton: false },
|
||||
css`
|
||||
:hover {
|
||||
border-bottom-color: #2196f3;
|
||||
}
|
||||
`
|
||||
)}
|
||||
`;
|
||||
|
||||
export const ALink: FC<Props> = (props) => {
|
||||
if (props.isNextLink) {
|
||||
const { href, target, title, rel, ...rest } = props;
|
||||
return (
|
||||
<Link href={href} target={target} title={title} rel={rel} passHref>
|
||||
<StyledBox as="a" {...rest} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <StyledBox as="a" {...props} />;
|
||||
};
|
||||
|
||||
ALink.displayName = "ALink";
|
||||
|
||||
ALink.defaultProps = {
|
||||
pb: "1px",
|
||||
forButton: false
|
||||
};
|
||||
|
||||
export default ALink;
|
@ -1,17 +0,0 @@
|
||||
import { fadeInVertical } from "../helpers/animations";
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import styled from "styled-components";
|
||||
import { prop } from "styled-tools";
|
||||
import { FC } from "react";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Flex> {
|
||||
offset: string;
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
const Animation: FC<Props> = styled(Flex)<Props>`
|
||||
animation: ${(props) => fadeInVertical(props.offset)}
|
||||
${prop("duration", "0.3s")} ease-out;
|
||||
`;
|
||||
|
||||
export default Animation;
|
@ -1,54 +0,0 @@
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import React, { useEffect } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { useStoreState, useStoreActions } from "../store";
|
||||
import PageLoading from "./PageLoading";
|
||||
import Header from "./Header";
|
||||
|
||||
const Wrapper = styled(Flex)`
|
||||
input {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*::-moz-focus-inner {
|
||||
border: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const AppWrapper = ({ children }: { children: any }) => {
|
||||
const isAuthenticated = useStoreState((s) => s.auth.isAuthenticated);
|
||||
const logout = useStoreActions((s) => s.auth.logout);
|
||||
const fetched = useStoreState((s) => s.settings.fetched);
|
||||
const loading = useStoreState((s) => s.loading.loading);
|
||||
const getSettings = useStoreActions((s) => s.settings.getSettings);
|
||||
|
||||
const isVerifyEmailPage =
|
||||
typeof window !== "undefined" &&
|
||||
window.location.pathname.includes("verify-email");
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !fetched && !isVerifyEmailPage) {
|
||||
getSettings().catch(() => logout());
|
||||
}
|
||||
}, [isAuthenticated, fetched, isVerifyEmailPage, getSettings, logout]);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
minHeight="100vh"
|
||||
width={1}
|
||||
flex="0 0 auto"
|
||||
alignItems="center"
|
||||
flexDirection="column"
|
||||
>
|
||||
<Header />
|
||||
{loading ? <PageLoading /> : children}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppWrapper;
|
@ -1,118 +0,0 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import { switchProp, prop, ifProp } from "styled-tools";
|
||||
import { Flex, BoxProps } from "rebass/styled-components";
|
||||
|
||||
interface Props extends BoxProps {
|
||||
color?: "purple" | "gray" | "blue" | "red";
|
||||
disabled?: boolean;
|
||||
icon?: string; // TODO: better typing
|
||||
isRound?: boolean;
|
||||
onClick?: any; // TODO: better typing
|
||||
type?: "button" | "submit" | "reset";
|
||||
}
|
||||
|
||||
export const Button = styled(Flex)<Props>`
|
||||
position: relative;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: normal;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
word-break: keep-all;
|
||||
color: ${switchProp(prop("color", "blue"), {
|
||||
blue: "white",
|
||||
red: "white",
|
||||
purple: "white",
|
||||
gray: "#444"
|
||||
})};
|
||||
background: ${switchProp(prop("color", "blue"), {
|
||||
blue: "linear-gradient(to right, #42a5f5, #2979ff)",
|
||||
red: "linear-gradient(to right, #ee3b3b, #e11c1c)",
|
||||
purple: "linear-gradient(to right, #7e57c2, #6200ea)",
|
||||
gray: "linear-gradient(to right, #e0e0e0, #bdbdbd)"
|
||||
})};
|
||||
box-shadow: ${switchProp(prop("color", "blue"), {
|
||||
blue: "0 5px 6px rgba(66, 165, 245, 0.5)",
|
||||
red: "0 5px 6px rgba(168, 45, 45, 0.5)",
|
||||
purple: "0 5px 6px rgba(81, 45, 168, 0.5)",
|
||||
gray: "0 5px 6px rgba(160, 160, 160, 0.5)"
|
||||
})};
|
||||
border: none;
|
||||
border-radius: 100px;
|
||||
transition: all 0.4s ease-out;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
:hover,
|
||||
:focus {
|
||||
outline: none;
|
||||
box-shadow: ${switchProp(prop("color", "blue"), {
|
||||
blue: "0 6px 15px rgba(66, 165, 245, 0.5)",
|
||||
red: "0 6px 15px rgba(168, 45, 45, 0.5)",
|
||||
purple: "0 6px 15px rgba(81, 45, 168, 0.5)",
|
||||
gray: "0 6px 15px rgba(160, 160, 160, 0.5)"
|
||||
})};
|
||||
transform: translateY(-2px) scale(1.02, 1.02);
|
||||
}
|
||||
`;
|
||||
|
||||
Button.defaultProps = {
|
||||
as: "button",
|
||||
width: "auto",
|
||||
flex: "0 0 auto",
|
||||
height: [36, 40],
|
||||
py: 0,
|
||||
px: [24, 32],
|
||||
fontSize: [12, 13],
|
||||
color: "blue",
|
||||
icon: "",
|
||||
isRound: false
|
||||
};
|
||||
|
||||
interface NavButtonProps extends BoxProps {
|
||||
disabled?: boolean;
|
||||
onClick?: any; // TODO: better typing
|
||||
type?: "button" | "submit" | "reset";
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export const NavButton = styled(Flex)<NavButtonProps>`
|
||||
display: flex;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0px 10px rgba(100, 100, 100, 0.1);
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-out;
|
||||
box-sizing: border-box;
|
||||
|
||||
:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
${ifProp(
|
||||
"disabled",
|
||||
css`
|
||||
background-color: #f6f6f6;
|
||||
box-shadow: 0 0px 5px rgba(150, 150, 150, 0.1);
|
||||
cursor: default;
|
||||
|
||||
:hover {
|
||||
transform: none;
|
||||
}
|
||||
`
|
||||
)}
|
||||
`;
|
||||
|
||||
NavButton.defaultProps = {
|
||||
as: "button",
|
||||
type: "button",
|
||||
flex: "0 0 auto",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "auto",
|
||||
height: [26, 28],
|
||||
py: 0,
|
||||
px: ["6px", "8px"],
|
||||
fontSize: [12]
|
||||
};
|
@ -1,80 +0,0 @@
|
||||
import subMonths from "date-fns/subMonths";
|
||||
import subHours from "date-fns/subHours";
|
||||
import formatDate from "date-fns/format";
|
||||
import subDays from "date-fns/subDays";
|
||||
import React, { FC } from "react";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip
|
||||
} from "recharts";
|
||||
|
||||
interface Props {
|
||||
data: number[];
|
||||
period: string;
|
||||
}
|
||||
|
||||
const ChartArea: FC<Props> = ({ data: rawData, period }) => {
|
||||
const now = new Date();
|
||||
const getDate = index => {
|
||||
switch (period) {
|
||||
case "allTime":
|
||||
return formatDate(
|
||||
subMonths(now, rawData.length - index - 1),
|
||||
"MMM yyy"
|
||||
);
|
||||
case "lastDay":
|
||||
return formatDate(subHours(now, rawData.length - index - 1), "HH:00");
|
||||
case "lastMonth":
|
||||
case "lastWeek":
|
||||
default:
|
||||
return formatDate(subDays(now, rawData.length - index - 1), "MMM dd");
|
||||
}
|
||||
};
|
||||
const data = rawData.map((view, index) => ({
|
||||
name: getDate(index),
|
||||
views: view
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height={window.innerWidth < 468 ? 240 : 320}
|
||||
>
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 16,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 16
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#B39DDB" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#B39DDB" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<CartesianGrid strokeDasharray="1 1" />
|
||||
<Tooltip />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="views"
|
||||
isAnimationActive={false}
|
||||
stroke="#B39DDB"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorUv)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartArea;
|
@ -1,40 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer
|
||||
} from "recharts";
|
||||
|
||||
interface Props {
|
||||
data: any[]; // TODO: types
|
||||
}
|
||||
|
||||
const ChartBar: FC<Props> = ({ data }) => (
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height={window.innerWidth < 468 ? 240 : 320}
|
||||
>
|
||||
<BarChart
|
||||
data={data}
|
||||
layout="vertical"
|
||||
margin={{
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 24,
|
||||
bottom: 0
|
||||
}}
|
||||
>
|
||||
<XAxis type="number" dataKey="value" />
|
||||
<YAxis type="category" dataKey="name" />
|
||||
<CartesianGrid strokeDasharray="1 1" />
|
||||
<Tooltip />
|
||||
<Bar dataKey="value" fill="#B39DDB" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
export default ChartBar;
|
@ -1,83 +0,0 @@
|
||||
import styled from "styled-components";
|
||||
import React, { FC } from "react";
|
||||
// import { VectorMap } from "@south-paw/react-vector-maps";
|
||||
|
||||
import { Colors } from "../../consts";
|
||||
import Tooltip from "../Tooltip";
|
||||
import world from "./world.json";
|
||||
|
||||
const Svg = styled.svg`
|
||||
path {
|
||||
fill: ${Colors.Map0};
|
||||
stroke: #fff;
|
||||
}
|
||||
|
||||
path.country-6 {
|
||||
fill: ${Colors.Map06};
|
||||
stroke: #fff;
|
||||
}
|
||||
path.country-5 {
|
||||
fill: ${Colors.Map05};
|
||||
stroke: #fff;
|
||||
}
|
||||
path.country-4 {
|
||||
fill: ${Colors.Map04};
|
||||
stroke: #fff;
|
||||
}
|
||||
path.country-3 {
|
||||
fill: ${Colors.Map03};
|
||||
stroke: #fff;
|
||||
}
|
||||
path.country-2 {
|
||||
fill: ${Colors.Map02};
|
||||
stroke: #fff;
|
||||
}
|
||||
path.country-1 {
|
||||
fill: ${Colors.Map01};
|
||||
stroke: #fff;
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
data: Array<{ name: string; value: number }>;
|
||||
}
|
||||
|
||||
const Map: FC<Props> = ({ data }) => {
|
||||
const [mostVisits] = data.sort((a, b) => (b.value - a.value > 0 ? 1 : -1));
|
||||
return (
|
||||
<>
|
||||
{world.layers.map(layer => (
|
||||
<Tooltip
|
||||
key={layer.id}
|
||||
type="light"
|
||||
effect="float"
|
||||
id={`${layer.id}-tooltip-country`}
|
||||
>
|
||||
{layer.name}:{" "}
|
||||
{data.find(d => d.name.toLowerCase() === layer.id)?.value || 0}
|
||||
</Tooltip>
|
||||
))}
|
||||
<Svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="world map"
|
||||
viewBox={world.viewBox}
|
||||
>
|
||||
{world.layers.map(layer => (
|
||||
<path
|
||||
key={layer.id}
|
||||
data-tip
|
||||
data-for={`${layer.id}-tooltip-country`}
|
||||
className={`country-${Math.ceil(
|
||||
((data.find(d => d.name.toLowerCase() === layer.id)?.value || 0) /
|
||||
mostVisits?.value || 0) * 6
|
||||
)}`}
|
||||
aria-label={layer.name}
|
||||
d={layer.d}
|
||||
/>
|
||||
))}
|
||||
</Svg>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Map;
|
@ -1,33 +0,0 @@
|
||||
import { PieChart, Pie, Tooltip, ResponsiveContainer } from "recharts";
|
||||
import React, { FC } from "react";
|
||||
|
||||
interface Props {
|
||||
data: any[]; // TODO: types
|
||||
}
|
||||
|
||||
const ChartPie: FC<Props> = ({ data }) => (
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height={window.innerWidth < 468 ? 240 : 320}
|
||||
>
|
||||
<PieChart
|
||||
margin={{
|
||||
top: window.innerWidth < 468 ? 56 : 0,
|
||||
right: window.innerWidth < 468 ? 56 : 0,
|
||||
bottom: window.innerWidth < 468 ? 56 : 0,
|
||||
left: window.innerWidth < 468 ? 56 : 0
|
||||
}}
|
||||
>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
innerRadius={window.innerWidth < 468 ? 20 : 80}
|
||||
fill="#B39DDB"
|
||||
label={({ name }) => name}
|
||||
/>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
export default ChartPie;
|
@ -1,4 +0,0 @@
|
||||
export { default as Area } from "./Area";
|
||||
export { default as Bar } from "./Bar";
|
||||
export { default as Pie } from "./Pie";
|
||||
export { default as Map } from "./Map";
|
File diff suppressed because one or more lines are too long
@ -1,14 +0,0 @@
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { Colors } from "../consts";
|
||||
|
||||
const Divider = styled(Flex).attrs({ as: "hr" })`
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
outline: none;
|
||||
border: none;
|
||||
background-color: ${Colors.Divider};
|
||||
`;
|
||||
|
||||
export default Divider;
|
@ -1,116 +0,0 @@
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import SVG from "react-inlinesvg"; // TODO: another solution
|
||||
import { Colors } from "../consts";
|
||||
import { ColCenterH } from "./Layout";
|
||||
import { H3 } from "./Text";
|
||||
|
||||
const Button = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 16px;
|
||||
padding: 12px 28px;
|
||||
font-family: "Nunito", sans-serif;
|
||||
background-color: #eee;
|
||||
border: 1px solid #aaa;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.3s ease-out;
|
||||
cursor: pointer;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
margin-bottom: 16px;
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
> * {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
`;
|
||||
|
||||
const FirefoxButton = styled(Button)`
|
||||
color: #e0890f;
|
||||
`;
|
||||
|
||||
const ChromeButton = styled(Button)`
|
||||
color: #4285f4;
|
||||
`;
|
||||
|
||||
const Link = styled.a`
|
||||
text-decoration: none;
|
||||
|
||||
:visited,
|
||||
:hover,
|
||||
:active,
|
||||
:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Icon = styled(SVG)`
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 16px;
|
||||
fill: ${(props) => props.color || "#333"};
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Extensions = () => (
|
||||
<ColCenterH
|
||||
width={1}
|
||||
flex="0 0 auto"
|
||||
flexWrap={["wrap", "wrap", "nowrap"]}
|
||||
py={[64, 96]}
|
||||
backgroundColor={Colors.ExtensionsBg}
|
||||
>
|
||||
<H3 fontSize={[26, 28]} mb={5} color="white" light>
|
||||
Browser extensions.
|
||||
</H3>
|
||||
<Flex
|
||||
width={1200}
|
||||
maxWidth="100%"
|
||||
flex="1 1 auto"
|
||||
justifyContent="center"
|
||||
flexWrap={["wrap", "wrap", "nowrap"]}
|
||||
>
|
||||
<Link
|
||||
href="https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ChromeButton>
|
||||
<Icon src="/images/googlechrome.svg" color="#4285f4" />
|
||||
<span>Download for Chrome</span>
|
||||
</ChromeButton>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://addons.mozilla.org/en-US/firefox/addon/kutt/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FirefoxButton>
|
||||
<Icon src="/images/mozillafirefox.svg" color="#e0890f" />
|
||||
<span>Download for Firefox</span>
|
||||
</FirefoxButton>
|
||||
</Link>
|
||||
</Flex>
|
||||
</ColCenterH>
|
||||
);
|
||||
|
||||
export default Extensions;
|
@ -1,43 +0,0 @@
|
||||
import React from "react";
|
||||
import { Flex } from "rebass/styled-components";
|
||||
|
||||
import FeaturesItem from "./FeaturesItem";
|
||||
import { ColCenterH } from "./Layout";
|
||||
import { Colors } from "../consts";
|
||||
import { H3 } from "./Text";
|
||||
|
||||
const Features = () => (
|
||||
<ColCenterH
|
||||
width={1}
|
||||
flex="0 0 auto"
|
||||
py={[64, 100]}
|
||||
backgroundColor={Colors.FeaturesBg}
|
||||
>
|
||||
<H3 fontSize={[26, 28]} mb={72} light>
|
||||
Kutting edge features.
|
||||
</H3>
|
||||
<Flex
|
||||
width={1200}
|
||||
maxWidth="100%"
|
||||
flex="1 1 auto"
|
||||
justifyContent="center"
|
||||
flexWrap={["wrap", "wrap", "wrap", "nowrap"]}
|
||||
>
|
||||
<FeaturesItem title="Managing links" icon="edit">
|
||||
Create, protect and delete your links and monitor them with detailed
|
||||
statistics.
|
||||
</FeaturesItem>
|
||||
<FeaturesItem title="Custom domain" icon="shuffle">
|
||||
Use custom domains for your links. Add or remove them for free.
|
||||
</FeaturesItem>
|
||||
<FeaturesItem title="API" icon="zap">
|
||||
Use the provided API to create, delete, and get URLs from anywhere.
|
||||
</FeaturesItem>
|
||||
<FeaturesItem title="Free & open source" icon="heart">
|
||||
Completely open source and free. You can host it on your own server.
|
||||
</FeaturesItem>
|
||||
</Flex>
|
||||
</ColCenterH>
|
||||
);
|
||||
|
||||
export default Features;
|
@ -1,71 +0,0 @@
|
||||
import React, { FC, ReactNode } from "react";
|
||||
import styled from "styled-components";
|
||||
import { Flex } from "rebass/styled-components";
|
||||
|
||||
import { fadeIn } from "../helpers/animations";
|
||||
import Icon from "./Icon";
|
||||
import { Icons } from "./Icon/Icon";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
icon: Icons;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const Block = styled(Flex).attrs({
|
||||
maxWidth: ["100%", "100%", "50%", "25%"],
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
p: "0 24px",
|
||||
mb: [48, 48, 48, 0]
|
||||
})`
|
||||
animation: ${fadeIn} 0.8s ease-out;
|
||||
|
||||
:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const IconBox = styled(Flex).attrs({
|
||||
width: [40, 40, 48],
|
||||
height: [40, 40, 48],
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
})`
|
||||
border-radius: 100%;
|
||||
box-sizing: border-box;
|
||||
background-color: #2196f3;
|
||||
`;
|
||||
|
||||
const Title = styled.h3`
|
||||
margin: 16px;
|
||||
font-size: 15px;
|
||||
|
||||
@media only screen and (max-width: 448px) {
|
||||
margin: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Description = styled.p`
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
text-align: center;
|
||||
|
||||
@media only screen and (max-width: 448px) {
|
||||
font-size: 13px;
|
||||
}
|
||||
`;
|
||||
|
||||
const FeaturesItem: FC<Props> = ({ children, icon, title }) => (
|
||||
<Block>
|
||||
<IconBox>
|
||||
<Icon name={icon} stroke="white" strokeWidth="2" />
|
||||
</IconBox>
|
||||
<Title>{title}</Title>
|
||||
<Description>{children}</Description>
|
||||
</Block>
|
||||
);
|
||||
|
||||
export default FeaturesItem;
|
@ -1,66 +0,0 @@
|
||||
import React, { FC, useEffect } from "react";
|
||||
import getConfig from "next/config";
|
||||
|
||||
import showRecaptcha from "../helpers/recaptcha";
|
||||
import { useStoreState } from "../store";
|
||||
import { ColCenter } from "./Layout";
|
||||
import ReCaptcha from "./ReCaptcha";
|
||||
import ALink from "./ALink";
|
||||
import Text from "./Text";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const Footer: FC = () => {
|
||||
const { isAuthenticated } = useStoreState((s) => s.auth);
|
||||
|
||||
useEffect(() => {
|
||||
showRecaptcha();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ColCenter
|
||||
as="footer"
|
||||
width={1}
|
||||
backgroundColor="white"
|
||||
p={isAuthenticated ? 2 : 24}
|
||||
>
|
||||
{!isAuthenticated && <ReCaptcha />}
|
||||
<Text fontSize={[12, 13]} py={2}>
|
||||
Made with love by{" "}
|
||||
<ALink href="//thedevs.network/" title="The Devs" target="_blank">
|
||||
The Devs
|
||||
</ALink>
|
||||
.{" | "}
|
||||
<ALink
|
||||
href="https://github.com/thedevs-network/kutt"
|
||||
title="GitHub"
|
||||
target="_blank"
|
||||
>
|
||||
GitHub
|
||||
</ALink>
|
||||
{" | "}
|
||||
<ALink href="/terms" title="Terms of Service" isNextLink>
|
||||
Terms of Service
|
||||
</ALink>
|
||||
{" | "}
|
||||
<ALink href="/report" title="Report abuse" isNextLink>
|
||||
Report Abuse
|
||||
</ALink>
|
||||
{publicRuntimeConfig.CONTACT_EMAIL && (
|
||||
<>
|
||||
{" | "}
|
||||
<ALink
|
||||
href={`mailto:${publicRuntimeConfig.CONTACT_EMAIL}`}
|
||||
title="Contact us"
|
||||
>
|
||||
Contact us
|
||||
</ALink>
|
||||
</>
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
</ColCenter>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
@ -1,184 +0,0 @@
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import getConfig from "next/config";
|
||||
import React, { FC } from "react";
|
||||
import Router from "next/router";
|
||||
import useMedia from "use-media";
|
||||
import Image from "next/image";
|
||||
|
||||
import { DISALLOW_REGISTRATION } from "../consts";
|
||||
import { useStoreState } from "../store";
|
||||
import styled from "styled-components";
|
||||
import { RowCenterV } from "./Layout";
|
||||
import { Button } from "./Button";
|
||||
import ALink from "./ALink";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const Li = styled(Flex).attrs({ ml: [12, 24, 32] })`
|
||||
a {
|
||||
color: inherit;
|
||||
|
||||
:hover {
|
||||
color: #2196f3;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const LogoImage = styled.div`
|
||||
& > a {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 8px 0 0;
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.2s ease-out;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 488px) {
|
||||
a {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
margin-right: 10px !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const Header: FC = () => {
|
||||
const { isAuthenticated } = useStoreState((s) => s.auth);
|
||||
const isMobile = useMedia({ maxWidth: 640 });
|
||||
|
||||
const login = !isAuthenticated && (
|
||||
<Li>
|
||||
<ALink
|
||||
href="/login"
|
||||
title={!DISALLOW_REGISTRATION ? "login / signup" : "login"}
|
||||
forButton
|
||||
isNextLink
|
||||
>
|
||||
<Button height={[32, 40]}>
|
||||
{!DISALLOW_REGISTRATION ? "Log in / Sign up" : "Log in"}
|
||||
</Button>
|
||||
</ALink>
|
||||
</Li>
|
||||
);
|
||||
const logout = isAuthenticated && (
|
||||
<Li>
|
||||
<ALink href="/logout" title="logout" fontSize={[14, 16]} isNextLink>
|
||||
Log out
|
||||
</ALink>
|
||||
</Li>
|
||||
);
|
||||
const settings = isAuthenticated && (
|
||||
<Li>
|
||||
<ALink href="/settings" title="Settings" forButton isNextLink>
|
||||
<Button height={[32, 40]}>Settings</Button>
|
||||
</ALink>
|
||||
</Li>
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
width={1232}
|
||||
maxWidth="100%"
|
||||
p={[16, "0 32px"]}
|
||||
mb={[32, 0]}
|
||||
height={["auto", "auto", 102]}
|
||||
justifyContent="space-between"
|
||||
alignItems={["flex-start", "center"]}
|
||||
>
|
||||
<Flex
|
||||
flexDirection={["column", "row"]}
|
||||
alignItems={["flex-start", "stretch"]}
|
||||
>
|
||||
<LogoImage>
|
||||
<ALink
|
||||
href="/"
|
||||
title="Homepage"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (window.location.pathname !== "/") Router.push("/");
|
||||
}}
|
||||
forButton
|
||||
isNextLink
|
||||
>
|
||||
<Image
|
||||
src="/images/logo.svg"
|
||||
alt="kutt logo"
|
||||
width={18}
|
||||
height={24}
|
||||
/>
|
||||
{publicRuntimeConfig.SITE_NAME}
|
||||
</ALink>
|
||||
</LogoImage>
|
||||
|
||||
{!isMobile && (
|
||||
<Flex
|
||||
style={{ listStyle: "none" }}
|
||||
display={["none", "flex"]}
|
||||
alignItems="flex-end"
|
||||
as="ul"
|
||||
m={0}
|
||||
px={0}
|
||||
pt={0}
|
||||
pb="2px"
|
||||
>
|
||||
<Li>
|
||||
<ALink
|
||||
href="//github.com/thedevs-network/kutt"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="GitHub"
|
||||
fontSize={[14, 16]}
|
||||
>
|
||||
GitHub
|
||||
</ALink>
|
||||
</Li>
|
||||
<Li>
|
||||
<ALink
|
||||
href="/report"
|
||||
title="Report abuse"
|
||||
fontSize={[14, 16]}
|
||||
isNextLink
|
||||
>
|
||||
Report
|
||||
</ALink>
|
||||
</Li>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
<RowCenterV
|
||||
m={0}
|
||||
p={0}
|
||||
justifyContent="flex-end"
|
||||
as="ul"
|
||||
style={{ listStyle: "none" }}
|
||||
>
|
||||
{isMobile && (
|
||||
<Li>
|
||||
<Flex>
|
||||
<ALink
|
||||
href="/report"
|
||||
title="Report"
|
||||
fontSize={[14, 16]}
|
||||
isNextLink
|
||||
>
|
||||
Report
|
||||
</ALink>
|
||||
</Flex>
|
||||
</Li>
|
||||
)}
|
||||
{logout}
|
||||
{settings}
|
||||
{login}
|
||||
</RowCenterV>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
@ -1,21 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function ArrowLeft() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
fill="none"
|
||||
stroke="#000"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M19 12H6m6-7l-7 7 7 7"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ArrowLeft);
|
@ -1,21 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function Check() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
fill="none"
|
||||
stroke="#000"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M20 6L9 17 4 12"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Check);
|
@ -1,22 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function ChevronLeft() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
className="feather feather-chevron-left"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M15 18L9 12 15 6"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ChevronLeft);
|
@ -1,22 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function ChevronRight() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
className="feather feather-chevron-right"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 18L15 12 9 6"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ChevronRight);
|
@ -1,23 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function Clipboard() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="auto"
|
||||
height="auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
className="feather feather-clipboard"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2"></path>
|
||||
<rect width="8" height="4" x="8" y="2" rx="1" ry="1"></rect>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Clipboard);
|
@ -1,23 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function Copy() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
className="feather feather-copy"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<rect width="13" height="13" x="9" y="9" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Copy);
|
@ -1,22 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function Edit() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
fill="none"
|
||||
stroke="#000"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M20 14.66V20a2 2 0 01-2 2H4a2 2 0 01-2-2V6a2 2 0 012-2h5.34"></path>
|
||||
<path d="M18 2L22 6 12 16 8 16 8 12 18 2z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Edit);
|
@ -1,21 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function EditAlt() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
fill="none"
|
||||
stroke="#5c666b"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M16 3L21 8 8 21 3 21 3 16 16 3z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(EditAlt);
|
@ -1,21 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function Heart() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
fill="none"
|
||||
stroke="#000"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Heart);
|
@ -1,155 +0,0 @@
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import styled, { css } from "styled-components";
|
||||
import { prop, ifProp } from "styled-tools";
|
||||
import React, { FC } from "react";
|
||||
|
||||
import ChevronRight from "./ChevronRight";
|
||||
import ChevronLeft from "./ChevronLeft";
|
||||
import { Colors } from "../../consts";
|
||||
import Clipboard from "./Clipboard";
|
||||
import ArrowLeft from "./ArrowLeft";
|
||||
import PieChart from "./PieChart";
|
||||
import Refresh from "./Refresh";
|
||||
import Spinner from "./Spinner";
|
||||
import Shuffle from "./Shuffle";
|
||||
import EditAlt from "./EditAlt";
|
||||
import QRCode from "./QRCode";
|
||||
import Signup from "./Signup";
|
||||
import Trash from "./Trash";
|
||||
import Check from "./Check";
|
||||
import Login from "./Login";
|
||||
import Heart from "./Heart";
|
||||
import Stop from "./Stop";
|
||||
import Plus from "./Plus";
|
||||
import Lock from "./Lock";
|
||||
import Edit from "./Edit";
|
||||
import Copy from "./Copy";
|
||||
import Send from "./Send";
|
||||
import Key from "./Key";
|
||||
import Zap from "./Zap";
|
||||
import X from "./X";
|
||||
|
||||
const icons = {
|
||||
arrowLeft: ArrowLeft,
|
||||
check: Check,
|
||||
chevronLeft: ChevronLeft,
|
||||
chevronRight: ChevronRight,
|
||||
clipboard: Clipboard,
|
||||
copy: Copy,
|
||||
edit: Edit,
|
||||
editAlt: EditAlt,
|
||||
heart: Heart,
|
||||
key: Key,
|
||||
lock: Lock,
|
||||
login: Login,
|
||||
pieChart: PieChart,
|
||||
plus: Plus,
|
||||
qrcode: QRCode,
|
||||
refresh: Refresh,
|
||||
send: Send,
|
||||
shuffle: Shuffle,
|
||||
signup: Signup,
|
||||
spinner: Spinner,
|
||||
stop: Stop,
|
||||
trash: Trash,
|
||||
x: X,
|
||||
zap: Zap
|
||||
};
|
||||
|
||||
export type Icons = keyof typeof icons;
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Flex> {
|
||||
name: Icons;
|
||||
stroke?: string;
|
||||
fill?: string;
|
||||
hoverFill?: string;
|
||||
hoverStroke?: string;
|
||||
strokeWidth?: string;
|
||||
}
|
||||
|
||||
const CustomIcon: FC<React.ComponentProps<typeof Flex>> = styled(Flex)`
|
||||
position: relative;
|
||||
|
||||
svg {
|
||||
transition: all 0.2s ease-out;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
${ifProp(
|
||||
"fill",
|
||||
css`
|
||||
fill: ${prop("fill")};
|
||||
`
|
||||
)}
|
||||
|
||||
${ifProp(
|
||||
"stroke",
|
||||
css`
|
||||
stroke: ${prop("stroke")};
|
||||
`
|
||||
)}
|
||||
|
||||
${ifProp(
|
||||
"strokeWidth",
|
||||
css`
|
||||
stroke-width: ${prop("strokeWidth")};
|
||||
`
|
||||
)}
|
||||
}
|
||||
|
||||
${ifProp(
|
||||
"hoverFill",
|
||||
css`
|
||||
:hover {
|
||||
svg {
|
||||
fill: ${prop("hoverFill")};
|
||||
}
|
||||
}
|
||||
`
|
||||
)}
|
||||
|
||||
${ifProp(
|
||||
"hoverStroke",
|
||||
css`
|
||||
:hover {
|
||||
svg {
|
||||
stroke: ${prop("stroke")};
|
||||
}
|
||||
}
|
||||
`
|
||||
)}
|
||||
|
||||
${ifProp(
|
||||
{ as: "button" },
|
||||
css`
|
||||
border: none;
|
||||
outline: none;
|
||||
transition: transform 0.4s ease-out;
|
||||
border-radius: 100%;
|
||||
background-color: none !important;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 2px 1px ${Colors.IconShadow};
|
||||
|
||||
:hover,
|
||||
:focus {
|
||||
transform: translateY(-2px) scale(1.02, 1.02);
|
||||
}
|
||||
:focus {
|
||||
outline: 3px solid rgba(65, 164, 245, 0.5);
|
||||
}
|
||||
`
|
||||
)}
|
||||
`;
|
||||
|
||||
const Icon: FC<Props> = ({ name, ...rest }) => (
|
||||
<CustomIcon {...rest}>{React.createElement(icons[name])}</CustomIcon>
|
||||
);
|
||||
|
||||
Icon.defaultProps = {
|
||||
size: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
};
|
||||
|
||||
export default Icon;
|
@ -1,22 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function Key() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
className="feather feather-key"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 017.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Key);
|
@ -1,22 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function Lock() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
fill="none"
|
||||
stroke="#000"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0110 0v4"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Lock);
|
@ -1,21 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function Login() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
fill="none"
|
||||
stroke="#000"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4m-5-4l5-5-5-5m3.8 5H3"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Login);
|
@ -1,21 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function Icon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
fill="none"
|
||||
stroke="#000"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M21.21 15.89A10 10 0 118 2.83M22 12A10 10 0 0012 2v10z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Icon);
|
@ -1,23 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function Plus() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
className="feather feather-plus"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 5L12 19"></path>
|
||||
<path d="M5 12L19 12"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Plus);
|
@ -1,19 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function QRCOde() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
fill="currentColor"
|
||||
className="jam jam-qr-code"
|
||||
preserveAspectRatio="xMinYMin"
|
||||
viewBox="-2 -2 24 24"
|
||||
>
|
||||
<path d="M13 18h3a2 2 0 002-2v-3a1 1 0 012 0v3a4 4 0 01-4 4H4a4 4 0 01-4-4v-3a1 1 0 012 0v3a2 2 0 002 2h3a1 1 0 010 2h6a1 1 0 010-2zM2 7a1 1 0 11-2 0V4a4 4 0 014-4h3a1 1 0 110 2H4a2 2 0 00-2 2v3zm16 0V4a2 2 0 00-2-2h-3a1 1 0 010-2h3a4 4 0 014 4v3a1 1 0 01-2 0z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(QRCOde);
|
@ -1,24 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function Refresh() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
className="feather feather-refresh-ccw"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M1 4L1 10 7 10"></path>
|
||||
<path d="M23 20L23 14 17 14"></path>
|
||||
<path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Refresh);
|
@ -1,18 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function Send() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
fill="currentColor"
|
||||
version="1.1"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M2 21l21-9L2 3v7l15 2-15 2v7z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Send;
|
@ -1,21 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function Shuffle() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
fill="none"
|
||||
stroke="#000"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M16 3h5v5M4 20L20.2 3.8M21 16v5h-5m-1-6l5.1 5.1M4 4l5 5"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Shuffle);
|
@ -1,24 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function Signup() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
fill="none"
|
||||
stroke="#000"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M16 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"></path>
|
||||
<circle cx="8.5" cy="7" r="4"></circle>
|
||||
<path d="M20 8L20 14"></path>
|
||||
<path d="M23 11L17 11"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Signup);
|
@ -1,43 +0,0 @@
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import React from "react";
|
||||
|
||||
const spin = keyframes`
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
`;
|
||||
|
||||
const Svg = styled.svg`
|
||||
animation: ${spin} 1s linear infinite;
|
||||
`;
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<Svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
className="feather feather-loader"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 2L12 6"></path>
|
||||
<path d="M12 18L12 22"></path>
|
||||
<path d="M4.93 4.93L7.76 7.76"></path>
|
||||
<path d="M16.24 16.24L19.07 19.07"></path>
|
||||
<path d="M2 12L6 12"></path>
|
||||
<path d="M18 12L22 12"></path>
|
||||
<path d="M4.93 19.07L7.76 16.24"></path>
|
||||
<path d="M16.24 7.76L19.07 4.93"></path>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Spinner);
|
@ -1,22 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function Stop() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
fill="none"
|
||||
stroke="#5c666b"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M4.93 4.93L19.07 19.07"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Stop);
|
@ -1,25 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function Trash() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
className="feather feather-trash-2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M3 6L5 6 21 6"></path>
|
||||
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path>
|
||||
<path d="M10 11L10 17"></path>
|
||||
<path d="M14 11L14 17"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Trash);
|
@ -1,22 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function X() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
fill="none"
|
||||
stroke="#000"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M18 6L6 18"></path>
|
||||
<path d="M6 6L18 18"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(X);
|
@ -1,22 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function Zap() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
className="feather feather-zap"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M13 2L3 14 12 14 11 22 21 10 12 10 13 2z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Zap);
|
@ -1 +0,0 @@
|
||||
export { default } from "./Icon";
|
@ -1,257 +0,0 @@
|
||||
import React from "react";
|
||||
import { Flex, BoxProps } from "rebass/styled-components";
|
||||
import styled, { css, keyframes } from "styled-components";
|
||||
import { withProp, prop, ifProp } from "styled-tools";
|
||||
import { FC } from "react";
|
||||
|
||||
import { Span } from "./Text";
|
||||
|
||||
interface StyledTextProps extends BoxProps {
|
||||
autoFocus?: boolean;
|
||||
name?: string;
|
||||
id?: string;
|
||||
type?: string;
|
||||
value?: string;
|
||||
required?: boolean;
|
||||
onChange?: any;
|
||||
placeholderSize?: number[];
|
||||
br?: string;
|
||||
bbw?: string;
|
||||
autocomplete?: "on" | "off";
|
||||
}
|
||||
|
||||
export const TextInput = styled(Flex).attrs({
|
||||
as: "input"
|
||||
})<StyledTextProps>`
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
letter-spacing: 0.05em;
|
||||
color: #444;
|
||||
background-color: white;
|
||||
box-shadow: 0 10px 35px hsla(200, 15%, 70%, 0.2);
|
||||
border: none;
|
||||
border-radius: ${prop("br", "100px")};
|
||||
border-bottom: 5px solid #f5f5f5;
|
||||
border-bottom-width: ${prop("bbw", "5px")};
|
||||
transition: all 0.5s ease-out;
|
||||
|
||||
:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 20px 35px hsla(200, 15%, 70%, 0.4);
|
||||
}
|
||||
|
||||
::placeholder {
|
||||
font-size: ${withProp("placeholderSize", (s) => s[0] || 14)}px;
|
||||
letter-spacing: 0.05em;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 64em) {
|
||||
::placeholder {
|
||||
font-size: ${withProp(
|
||||
"placeholderSize",
|
||||
(s) => s[3] || s[2] || s[1] || s[0] || 16
|
||||
)}px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 52em) {
|
||||
letter-spacing: 0.1em;
|
||||
border-bottom-width: ${prop("bbw", "6px")};
|
||||
::placeholder {
|
||||
font-size: ${withProp(
|
||||
"placeholderSize",
|
||||
(s) => s[2] || s[1] || s[0] || 15
|
||||
)}px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 40em) {
|
||||
::placeholder {
|
||||
font-size: ${withProp("placeholderSize", (s) => s[1] || s[0] || 15)}px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
TextInput.defaultProps = {
|
||||
value: "",
|
||||
height: [40, 44],
|
||||
py: 0,
|
||||
px: [3, 24],
|
||||
fontSize: [14, 15],
|
||||
placeholderSize: [13, 14]
|
||||
};
|
||||
|
||||
interface StyledSelectProps extends BoxProps {
|
||||
name?: string;
|
||||
id?: string;
|
||||
type?: string;
|
||||
value?: string;
|
||||
required?: boolean;
|
||||
onChange?: any;
|
||||
br?: string;
|
||||
bbw?: string;
|
||||
}
|
||||
|
||||
interface SelectOptions extends StyledSelectProps {
|
||||
options: Array<{ key: string; value: string | number }>;
|
||||
}
|
||||
|
||||
const StyledSelect: FC<StyledSelectProps> = styled(Flex).attrs({
|
||||
as: "select"
|
||||
})<StyledSelectProps>`
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
letter-spacing: 0.05em;
|
||||
color: #444;
|
||||
background-color: white;
|
||||
box-shadow: 0 10px 35px hsla(200, 15%, 70%, 0.2);
|
||||
border: none;
|
||||
border-radius: ${prop("br", "100px")};
|
||||
border-bottom: 5px solid #f5f5f5;
|
||||
border-bottom-width: ${prop("bbw", "5px")};
|
||||
transition: all 0.5s ease-out;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='48' height='48' viewBox='0 0 24 24' fill='none' stroke='%235c666b' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat, repeat;
|
||||
background-position: right 1.2em top 50%, 0 0;
|
||||
background-size: 1em auto, 100%;
|
||||
|
||||
:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 20px 35px hsla(200, 15%, 70%, 0.4);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 52em) {
|
||||
letter-spacing: 0.1em;
|
||||
border-bottom-width: ${prop("bbw", "6px")};
|
||||
}
|
||||
`;
|
||||
|
||||
export const Select: FC<SelectOptions> = ({ options, ...props }) => (
|
||||
<StyledSelect {...props}>
|
||||
{options.map(({ key, value }) => (
|
||||
<option key={value} value={value}>
|
||||
{key}
|
||||
</option>
|
||||
))}
|
||||
</StyledSelect>
|
||||
);
|
||||
|
||||
Select.defaultProps = {
|
||||
value: "",
|
||||
height: [40, 44],
|
||||
py: 0,
|
||||
px: [3, 24],
|
||||
fontSize: [14, 15]
|
||||
};
|
||||
|
||||
interface ChecknoxInputProps {
|
||||
checked: boolean;
|
||||
id?: string;
|
||||
name: string;
|
||||
onChange: any;
|
||||
}
|
||||
|
||||
const CheckboxInput = styled(Flex).attrs({
|
||||
as: "input",
|
||||
type: "checkbox",
|
||||
m: 0,
|
||||
p: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
opacity: 0
|
||||
})<ChecknoxInputProps>`
|
||||
position: relative;
|
||||
opacity: 0;
|
||||
`;
|
||||
|
||||
const CheckboxBox = styled(Flex).attrs({
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
})<{ checked: boolean }>`
|
||||
position: relative;
|
||||
transition: color 0.3s ease-out;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
box-shadow: 0 2px 4px rgba(50, 50, 50, 0.2);
|
||||
cursor: pointer;
|
||||
|
||||
input:focus + & {
|
||||
outline: 3px solid rgba(65, 164, 245, 0.5);
|
||||
}
|
||||
|
||||
${ifProp(
|
||||
"checked",
|
||||
css`
|
||||
box-shadow: 0 3px 5px rgba(50, 50, 50, 0.4);
|
||||
|
||||
:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
display: block;
|
||||
border-radius: 2px;
|
||||
background-color: #9575cd;
|
||||
box-shadow: 0 2px 4px rgba(50, 50, 50, 0.2);
|
||||
cursor: pointer;
|
||||
animation: ${keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0, 0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1, 1);
|
||||
}
|
||||
`} 0.1s ease-in;
|
||||
}
|
||||
`
|
||||
)}
|
||||
`;
|
||||
|
||||
interface CheckboxProps
|
||||
extends ChecknoxInputProps,
|
||||
Omit<BoxProps, "name" | "checked" | "onChange" | "value"> {
|
||||
label: string;
|
||||
value?: boolean | string;
|
||||
}
|
||||
|
||||
export const Checkbox: FC<CheckboxProps> = ({
|
||||
checked,
|
||||
height,
|
||||
id,
|
||||
label,
|
||||
name,
|
||||
width,
|
||||
onChange,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<Flex
|
||||
flex="0 0 auto"
|
||||
as="label"
|
||||
alignItems="center"
|
||||
style={{ cursor: "pointer" }}
|
||||
{...(rest as any)}
|
||||
>
|
||||
<CheckboxInput
|
||||
onChange={onChange}
|
||||
name={name}
|
||||
id={id}
|
||||
checked={checked}
|
||||
/>
|
||||
<CheckboxBox checked={checked} width={width} height={height} />
|
||||
<Span ml={[10, 12]} mt="1px" color="#555">
|
||||
{label}
|
||||
</Span>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
Checkbox.defaultProps = {
|
||||
width: [16, 18],
|
||||
height: [16, 18],
|
||||
fontSize: [15, 16]
|
||||
};
|
@ -1,38 +0,0 @@
|
||||
import React from "react";
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import { FC } from "react";
|
||||
|
||||
type Props = React.ComponentProps<typeof Flex>;
|
||||
|
||||
export const Col: FC<Props> = (props) => (
|
||||
<Flex flexDirection="column" {...props} />
|
||||
);
|
||||
|
||||
export const RowCenterV: FC<Props> = (props) => (
|
||||
<Flex alignItems="center" {...props} />
|
||||
);
|
||||
|
||||
export const RowCenterH: FC<Props> = (props) => (
|
||||
<Flex justifyContent="center" {...props} />
|
||||
);
|
||||
|
||||
export const RowCenter: FC<Props> = (props) => (
|
||||
<Flex alignItems="center" justifyContent="center" {...props} />
|
||||
);
|
||||
|
||||
export const ColCenterV: FC<Props> = (props) => (
|
||||
<Flex flexDirection="column" justifyContent="center" {...props} />
|
||||
);
|
||||
|
||||
export const ColCenterH: FC<Props> = (props) => (
|
||||
<Flex flexDirection="column" alignItems="center" {...props} />
|
||||
);
|
||||
|
||||
export const ColCenter: FC<Props> = (props) => (
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
{...props}
|
||||
/>
|
||||
);
|
@ -1,761 +0,0 @@
|
||||
import formatDistanceToNow from "date-fns/formatDistanceToNow";
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import React, { FC, useState, useEffect } from "react";
|
||||
import { useFormState } from "react-use-form-state";
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import styled, { css } from "styled-components";
|
||||
import { ifProp } from "styled-tools";
|
||||
import getConfig from "next/config";
|
||||
import QRCode from "qrcode.react";
|
||||
import differenceInMilliseconds from "date-fns/differenceInMilliseconds";
|
||||
import ms from "ms";
|
||||
|
||||
import { removeProtocol, withComma, errorMessage } from "../utils";
|
||||
import { useStoreActions, useStoreState } from "../store";
|
||||
import { Link as LinkType } from "../store/links";
|
||||
import { Checkbox, TextInput } from "./Input";
|
||||
import { NavButton, Button } from "./Button";
|
||||
import { Col, RowCenter } from "./Layout";
|
||||
import Text, { H2, Span } from "./Text";
|
||||
import { useMessage } from "../hooks";
|
||||
import Animation from "./Animation";
|
||||
import { Colors } from "../consts";
|
||||
import Tooltip from "./Tooltip";
|
||||
import Table from "./Table";
|
||||
import ALink from "./ALink";
|
||||
import Modal from "./Modal";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const Tr = styled(Flex).attrs({ as: "tr", px: [12, 12, 2] })``;
|
||||
const Th = styled(Flex)``;
|
||||
Th.defaultProps = { as: "th", flexBasis: 0, py: [12, 12, 3], px: [12, 12, 3] };
|
||||
|
||||
const Td = styled(Flex)<{ withFade?: boolean }>`
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
|
||||
${ifProp(
|
||||
"withFade",
|
||||
css`
|
||||
:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 16px;
|
||||
background: linear-gradient(to left, white, rgba(255, 255, 255, 0.001));
|
||||
}
|
||||
|
||||
tr:hover &:after {
|
||||
background: linear-gradient(
|
||||
to left,
|
||||
${Colors.TableRowHover},
|
||||
rgba(255, 255, 255, 0.001)
|
||||
);
|
||||
}
|
||||
`
|
||||
)}
|
||||
`;
|
||||
Td.defaultProps = {
|
||||
as: "td",
|
||||
fontSize: [15, 16],
|
||||
alignItems: "center",
|
||||
flexBasis: 0,
|
||||
py: [12, 12, 3],
|
||||
px: [12, 12, 3]
|
||||
};
|
||||
|
||||
const EditContent = styled(Col)`
|
||||
border-bottom: 1px solid ${Colors.TableRowHover};
|
||||
background-color: #fafafa;
|
||||
`;
|
||||
|
||||
const Action = (props: React.ComponentProps<typeof Icon>) => (
|
||||
<Icon
|
||||
as="button"
|
||||
py={0}
|
||||
px={0}
|
||||
mr={2}
|
||||
size={[23, 24]}
|
||||
flexShrink={0}
|
||||
p={["4px", "5px"]}
|
||||
stroke="#666"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const ogLinkFlex = { flexGrow: [1, 3, 7], flexShrink: [1, 3, 7] };
|
||||
const createdFlex = { flexGrow: [1, 1, 2.5], flexShrink: [1, 1, 2.5] };
|
||||
const shortLinkFlex = { flexGrow: [1, 1, 3], flexShrink: [1, 1, 3] };
|
||||
const viewsFlex = {
|
||||
flexGrow: [0.5, 0.5, 1],
|
||||
flexShrink: [0.5, 0.5, 1],
|
||||
justifyContent: "flex-end"
|
||||
};
|
||||
const actionsFlex = { flexGrow: [1, 1, 3], flexShrink: [1, 1, 3] };
|
||||
|
||||
interface RowProps {
|
||||
index: number;
|
||||
link: LinkType;
|
||||
setDeleteModal: (number) => void;
|
||||
}
|
||||
|
||||
interface BanForm {
|
||||
host: boolean;
|
||||
user: boolean;
|
||||
userLinks: boolean;
|
||||
domain: boolean;
|
||||
}
|
||||
|
||||
interface EditForm {
|
||||
target: string;
|
||||
address: string;
|
||||
description?: string;
|
||||
expire_in?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
const Row: FC<RowProps> = ({ index, link, setDeleteModal }) => {
|
||||
const isAdmin = useStoreState((s) => s.auth.isAdmin);
|
||||
const ban = useStoreActions((s) => s.links.ban);
|
||||
const edit = useStoreActions((s) => s.links.edit);
|
||||
const [banFormState, { checkbox }] = useFormState<BanForm>();
|
||||
const [editFormState, { text, label, password }] = useFormState<EditForm>(
|
||||
{
|
||||
target: link.target,
|
||||
address: link.address,
|
||||
description: link.description,
|
||||
expire_in: link.expire_in
|
||||
? ms(differenceInMilliseconds(new Date(link.expire_in), new Date()), {
|
||||
long: true
|
||||
})
|
||||
: "",
|
||||
password: ""
|
||||
},
|
||||
{ withIds: true }
|
||||
);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [qrModal, setQRModal] = useState(false);
|
||||
const [banModal, setBanModal] = useState(false);
|
||||
const [banLoading, setBanLoading] = useState(false);
|
||||
const [banMessage, setBanMessage] = useMessage();
|
||||
const [editLoading, setEditLoading] = useState(false);
|
||||
const [editMessage, setEditMessage] = useMessage();
|
||||
|
||||
const onCopy = () => {
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const onBan = async () => {
|
||||
setBanLoading(true);
|
||||
try {
|
||||
const res = await ban({ id: link.id, ...banFormState.values });
|
||||
setBanMessage(res.message, "green");
|
||||
setTimeout(() => {
|
||||
setBanModal(false);
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
setBanMessage(errorMessage(err));
|
||||
}
|
||||
setBanLoading(false);
|
||||
};
|
||||
|
||||
const onEdit = async () => {
|
||||
if (editLoading) return;
|
||||
setEditLoading(true);
|
||||
try {
|
||||
await edit({ id: link.id, ...editFormState.values });
|
||||
setShowEdit(false);
|
||||
} catch (err) {
|
||||
setEditMessage(errorMessage(err));
|
||||
}
|
||||
editFormState.setField("password", "");
|
||||
setEditLoading(false);
|
||||
};
|
||||
|
||||
const toggleEdit = () => {
|
||||
setShowEdit((s) => !s);
|
||||
if (showEdit) editFormState.reset();
|
||||
setEditMessage("");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tr key={link.id}>
|
||||
<Td {...ogLinkFlex} withFade>
|
||||
<Col alignItems="flex-start">
|
||||
<ALink href={link.target}>{link.target}</ALink>
|
||||
{link.description && (
|
||||
<Text fontSize={[13, 14]} color="#888">
|
||||
{link.description}
|
||||
</Text>
|
||||
)}
|
||||
</Col>
|
||||
</Td>
|
||||
<Td {...createdFlex} flexDirection="column" alignItems="flex-start">
|
||||
<Text>{formatDistanceToNow(new Date(link.created_at))} ago</Text>
|
||||
{link.expire_in && (
|
||||
<Text fontSize={[13, 14]} color="#888">
|
||||
Expires in{" "}
|
||||
{ms(
|
||||
differenceInMilliseconds(new Date(link.expire_in), new Date()),
|
||||
{
|
||||
long: true
|
||||
}
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Td>
|
||||
<Td {...shortLinkFlex} withFade>
|
||||
{copied ? (
|
||||
<Animation
|
||||
minWidth={32}
|
||||
offset="10px"
|
||||
duration="0.2s"
|
||||
alignItems="center"
|
||||
>
|
||||
<Icon
|
||||
size={[23, 24]}
|
||||
py={0}
|
||||
px={0}
|
||||
mr={2}
|
||||
p="3px"
|
||||
name="check"
|
||||
strokeWidth="3"
|
||||
stroke={Colors.CheckIcon}
|
||||
/>
|
||||
</Animation>
|
||||
) : (
|
||||
<Animation minWidth={32} offset="-10px" duration="0.2s">
|
||||
<CopyToClipboard text={link.link} onCopy={onCopy}>
|
||||
<Action
|
||||
name="copy"
|
||||
strokeWidth="2.5"
|
||||
stroke={Colors.CopyIcon}
|
||||
backgroundColor={Colors.CopyIconBg}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
</Animation>
|
||||
)}
|
||||
<ALink href={link.link}>{removeProtocol(link.link)}</ALink>
|
||||
</Td>
|
||||
<Td {...viewsFlex}>{withComma(link.visit_count)}</Td>
|
||||
<Td {...actionsFlex} justifyContent="flex-end">
|
||||
{link.password && (
|
||||
<>
|
||||
<Tooltip id={`${index}-tooltip-password`}>
|
||||
Password protected
|
||||
</Tooltip>
|
||||
<Action
|
||||
as="span"
|
||||
data-tip
|
||||
data-for={`${index}-tooltip-password`}
|
||||
name="key"
|
||||
stroke={"#bbb"}
|
||||
strokeWidth="2.5"
|
||||
backgroundColor="none"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{link.banned && (
|
||||
<>
|
||||
<Tooltip id={`${index}-tooltip-banned`}>Banned</Tooltip>
|
||||
<Action
|
||||
as="span"
|
||||
data-tip
|
||||
data-for={`${index}-tooltip-banned`}
|
||||
name="stop"
|
||||
stroke="#bbb"
|
||||
strokeWidth="2.5"
|
||||
backgroundColor="none"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{link.visit_count > 0 && (
|
||||
<ALink
|
||||
href={`/stats?id=${link.id}`}
|
||||
title="View stats"
|
||||
forButton
|
||||
isNextLink
|
||||
>
|
||||
<Action
|
||||
name="pieChart"
|
||||
stroke={Colors.PieIcon}
|
||||
strokeWidth="2.5"
|
||||
backgroundColor={Colors.PieIconBg}
|
||||
/>
|
||||
</ALink>
|
||||
)}
|
||||
<Action
|
||||
name="qrcode"
|
||||
stroke="none"
|
||||
fill={Colors.QrCodeIcon}
|
||||
backgroundColor={Colors.QrCodeIconBg}
|
||||
onClick={() => setQRModal(true)}
|
||||
/>
|
||||
<Action
|
||||
name="editAlt"
|
||||
strokeWidth="2.5"
|
||||
stroke={Colors.EditIcon}
|
||||
backgroundColor={Colors.EditIconBg}
|
||||
onClick={toggleEdit}
|
||||
/>
|
||||
{isAdmin && !link.banned && (
|
||||
<Action
|
||||
name="stop"
|
||||
strokeWidth="2"
|
||||
stroke={Colors.StopIcon}
|
||||
backgroundColor={Colors.StopIconBg}
|
||||
onClick={() => setBanModal(true)}
|
||||
/>
|
||||
)}
|
||||
<Action
|
||||
mr={0}
|
||||
name="trash"
|
||||
strokeWidth="2"
|
||||
stroke={Colors.TrashIcon}
|
||||
backgroundColor={Colors.TrashIconBg}
|
||||
onClick={() => setDeleteModal(index)}
|
||||
/>
|
||||
</Td>
|
||||
</Tr>
|
||||
{showEdit && (
|
||||
<EditContent as="tr">
|
||||
<Col
|
||||
as="td"
|
||||
alignItems="flex-start"
|
||||
px={[3, 3, 24]}
|
||||
py={[3, 3, 24]}
|
||||
width={1}
|
||||
>
|
||||
<Flex alignItems="flex-start" width={1}>
|
||||
<Col alignItems="flex-start" mr={3}>
|
||||
<Text
|
||||
{...label("target")}
|
||||
as="label"
|
||||
mb={2}
|
||||
fontSize={[14, 15]}
|
||||
bold
|
||||
>
|
||||
Target:
|
||||
</Text>
|
||||
<Flex as="form">
|
||||
<TextInput
|
||||
{...text("target")}
|
||||
placeholder="Target..."
|
||||
placeholderSize={[13, 14]}
|
||||
fontSize={[14, 15]}
|
||||
height={[40, 44]}
|
||||
width={[1, 300, 420]}
|
||||
pl={[3, 24]}
|
||||
pr={[3, 24]}
|
||||
required
|
||||
/>
|
||||
</Flex>
|
||||
</Col>
|
||||
<Col alignItems="flex-start" mr={3}>
|
||||
<Text
|
||||
{...label("address")}
|
||||
as="label"
|
||||
mb={2}
|
||||
fontSize={[14, 15]}
|
||||
bold
|
||||
>
|
||||
{link.domain || publicRuntimeConfig.DEFAULT_DOMAIN}/
|
||||
</Text>
|
||||
<Flex as="form">
|
||||
<TextInput
|
||||
{...text("address")}
|
||||
placeholder="Custom address..."
|
||||
placeholderSize={[13, 14]}
|
||||
fontSize={[14, 15]}
|
||||
height={[40, 44]}
|
||||
width={[1, 210, 240]}
|
||||
pl={[3, 24]}
|
||||
pr={[3, 24]}
|
||||
required
|
||||
/>
|
||||
</Flex>
|
||||
</Col>
|
||||
<Col alignItems="flex-start">
|
||||
<Text
|
||||
{...label("password")}
|
||||
as="label"
|
||||
mb={2}
|
||||
fontSize={[14, 15]}
|
||||
bold
|
||||
>
|
||||
Password
|
||||
</Text>
|
||||
<Flex as="form">
|
||||
<TextInput
|
||||
{...password({
|
||||
name: "password"
|
||||
})}
|
||||
placeholder={link.password ? "••••••••" : "Password..."}
|
||||
autocomplete="off"
|
||||
data-lpignore
|
||||
pl={[3, 24]}
|
||||
pr={[3, 24]}
|
||||
placeholderSize={[13, 14]}
|
||||
fontSize={[14, 15]}
|
||||
height={[40, 44]}
|
||||
width={[1, 210, 240]}
|
||||
/>
|
||||
</Flex>
|
||||
</Col>
|
||||
</Flex>
|
||||
<Flex alignItems="flex-start" width={1} mt={3}>
|
||||
<Col alignItems="flex-start" mr={3}>
|
||||
<Text
|
||||
{...label("description")}
|
||||
as="label"
|
||||
mb={2}
|
||||
fontSize={[14, 15]}
|
||||
bold
|
||||
>
|
||||
Description:
|
||||
</Text>
|
||||
<Flex as="form">
|
||||
<TextInput
|
||||
{...text("description")}
|
||||
placeholder="description..."
|
||||
placeholderSize={[13, 14]}
|
||||
fontSize={[14, 15]}
|
||||
height={[40, 44]}
|
||||
width={[1, 300, 420]}
|
||||
pl={[3, 24]}
|
||||
pr={[3, 24]}
|
||||
required
|
||||
/>
|
||||
</Flex>
|
||||
</Col>
|
||||
<Col alignItems="flex-start">
|
||||
<Text
|
||||
{...label("expire_in")}
|
||||
as="label"
|
||||
mb={2}
|
||||
fontSize={[14, 15]}
|
||||
bold
|
||||
>
|
||||
Expire in:
|
||||
</Text>
|
||||
<Flex as="form">
|
||||
<TextInput
|
||||
{...text("expire_in")}
|
||||
placeholder="2 minutes/hours/days"
|
||||
placeholderSize={[13, 14]}
|
||||
fontSize={[14, 15]}
|
||||
height={[40, 44]}
|
||||
width={[1, 210, 240]}
|
||||
pl={[3, 24]}
|
||||
pr={[3, 24]}
|
||||
required
|
||||
/>
|
||||
</Flex>
|
||||
</Col>
|
||||
</Flex>
|
||||
<Button
|
||||
color="blue"
|
||||
mt={3}
|
||||
height={[30, 38]}
|
||||
disabled={editLoading}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Icon
|
||||
name={editLoading ? "spinner" : "refresh"}
|
||||
stroke="white"
|
||||
mr={2}
|
||||
/>
|
||||
{editLoading ? "Updating..." : "Update"}
|
||||
</Button>
|
||||
{editMessage.text && (
|
||||
<Text mt={3} fontSize={15} color={editMessage.color}>
|
||||
{editMessage.text}
|
||||
</Text>
|
||||
)}
|
||||
</Col>
|
||||
</EditContent>
|
||||
)}
|
||||
<Modal
|
||||
id="table-qrcode-modal"
|
||||
minWidth="max-content"
|
||||
show={qrModal}
|
||||
closeHandler={() => setQRModal(false)}
|
||||
>
|
||||
<RowCenter width={192}>
|
||||
<QRCode size={192} value={link.link} />
|
||||
</RowCenter>
|
||||
</Modal>
|
||||
<Modal
|
||||
id="table-ban-modal"
|
||||
show={banModal}
|
||||
closeHandler={() => setBanModal(false)}
|
||||
>
|
||||
<>
|
||||
<H2 mb={24} textAlign="center" bold>
|
||||
Ban link?
|
||||
</H2>
|
||||
<Text mb={24} textAlign="center">
|
||||
Are you sure do you want to ban the link{" "}
|
||||
<Span bold>"{removeProtocol(link.link)}"</Span>?
|
||||
</Text>
|
||||
<RowCenter>
|
||||
<Checkbox {...checkbox("user")} label="User" mb={12} />
|
||||
<Checkbox {...checkbox("userLinks")} label="User links" mb={12} />
|
||||
<Checkbox {...checkbox("host")} label="Host" mb={12} />
|
||||
<Checkbox {...checkbox("domain")} label="Domain" mb={12} />
|
||||
</RowCenter>
|
||||
<Flex justifyContent="center" mt={4}>
|
||||
{banLoading ? (
|
||||
<>
|
||||
<Icon name="spinner" size={20} stroke={Colors.Spinner} />
|
||||
</>
|
||||
) : banMessage.text ? (
|
||||
<Text fontSize={15} color={banMessage.color}>
|
||||
{banMessage.text}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Button color="gray" mr={3} onClick={() => setBanModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" ml={3} onClick={onBan}>
|
||||
<Icon name="stop" stroke="white" mr={2} />
|
||||
Ban
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface Form {
|
||||
all: boolean;
|
||||
limit: string;
|
||||
skip: string;
|
||||
search: string;
|
||||
}
|
||||
|
||||
const LinksTable: FC = () => {
|
||||
const isAdmin = useStoreState((s) => s.auth.isAdmin);
|
||||
const links = useStoreState((s) => s.links);
|
||||
const { get, remove } = useStoreActions((s) => s.links);
|
||||
const [tableMessage, setTableMessage] = useState("No links to show.");
|
||||
const [deleteModal, setDeleteModal] = useState(-1);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [deleteMessage, setDeleteMessage] = useMessage();
|
||||
const [formState, { label, checkbox, text }] = useFormState<Form>(
|
||||
{ skip: "0", limit: "10", all: false },
|
||||
{ withIds: true }
|
||||
);
|
||||
|
||||
const options = formState.values;
|
||||
const linkToDelete = links.items[deleteModal];
|
||||
|
||||
useEffect(() => {
|
||||
get(options).catch((err) =>
|
||||
setTableMessage(err?.response?.data?.error || "An error occurred.")
|
||||
);
|
||||
}, [options, get]);
|
||||
|
||||
const onSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
get(options);
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await remove(linkToDelete.id);
|
||||
await get(options);
|
||||
setDeleteModal(-1);
|
||||
} catch (err) {
|
||||
setDeleteMessage(errorMessage(err));
|
||||
}
|
||||
setDeleteLoading(false);
|
||||
};
|
||||
|
||||
const onNavChange = (nextPage: number) => () => {
|
||||
formState.setField("skip", (parseInt(options.skip) + nextPage).toString());
|
||||
};
|
||||
|
||||
const Nav = (
|
||||
<Th
|
||||
alignItems="center"
|
||||
justifyContent="flex-end"
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
>
|
||||
<Flex as="ul" m={0} p={0} style={{ listStyle: "none" }}>
|
||||
{["10", "25", "50"].map((c) => (
|
||||
<Flex key={c} ml={[10, 12]}>
|
||||
<NavButton
|
||||
disabled={options.limit === c}
|
||||
onClick={() => {
|
||||
formState.setField("limit", c);
|
||||
formState.setField("skip", "0");
|
||||
}}
|
||||
>
|
||||
{c}
|
||||
</NavButton>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
<Flex
|
||||
width="1px"
|
||||
height={20}
|
||||
mx={[3, 24]}
|
||||
style={{ backgroundColor: "#ccc" }}
|
||||
/>
|
||||
<Flex>
|
||||
<NavButton
|
||||
onClick={onNavChange(-parseInt(options.limit))}
|
||||
disabled={options.skip === "0"}
|
||||
px={2}
|
||||
>
|
||||
<Icon name="chevronLeft" size={15} />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
onClick={onNavChange(parseInt(options.limit))}
|
||||
disabled={
|
||||
parseInt(options.skip) + parseInt(options.limit) > links.total
|
||||
}
|
||||
ml={12}
|
||||
px={2}
|
||||
>
|
||||
<Icon name="chevronRight" size={15} />
|
||||
</NavButton>
|
||||
</Flex>
|
||||
</Th>
|
||||
);
|
||||
|
||||
return (
|
||||
<Col width={1200} maxWidth="95%" margin="40px 0 120px" my={6}>
|
||||
<H2 mb={3} light>
|
||||
Recent shortened links.
|
||||
</H2>
|
||||
<Table scrollWidth="1000px">
|
||||
<thead>
|
||||
<Tr justifyContent="space-between">
|
||||
<Th flexGrow={1} flexShrink={1}>
|
||||
<Flex as="form" onSubmit={onSubmit}>
|
||||
<TextInput
|
||||
{...text("search")}
|
||||
placeholder="Search..."
|
||||
height={[30, 32]}
|
||||
placeholderSize={[13, 13, 13, 13]}
|
||||
fontSize={[14]}
|
||||
pl={12}
|
||||
pr={12}
|
||||
width={[1]}
|
||||
br="3px"
|
||||
bbw="2px"
|
||||
/>
|
||||
|
||||
{isAdmin && (
|
||||
<Checkbox
|
||||
{...label("all")}
|
||||
{...checkbox("all")}
|
||||
label="All links"
|
||||
ml={3}
|
||||
fontSize={[14, 15]}
|
||||
width={[15, 16]}
|
||||
height={[15, 16]}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Th>
|
||||
{Nav}
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Th {...ogLinkFlex}>Original URL</Th>
|
||||
<Th {...createdFlex}>Created</Th>
|
||||
<Th {...shortLinkFlex}>Short URL</Th>
|
||||
<Th {...viewsFlex}>Views</Th>
|
||||
<Th {...actionsFlex}></Th>
|
||||
</Tr>
|
||||
</thead>
|
||||
<tbody style={{ opacity: links.loading ? 0.4 : 1 }}>
|
||||
{!links.items.length ? (
|
||||
<Tr width={1} justifyContent="center">
|
||||
<Td flex="1 1 auto" justifyContent="center">
|
||||
<Text fontSize={18} light>
|
||||
{links.loading ? "Loading links..." : tableMessage}
|
||||
</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
) : (
|
||||
<>
|
||||
{links.items.map((link, index) => (
|
||||
<Row
|
||||
setDeleteModal={setDeleteModal}
|
||||
index={index}
|
||||
link={link}
|
||||
key={link.id}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<Tr justifyContent="flex-end">{Nav}</Tr>
|
||||
</tfoot>
|
||||
</Table>
|
||||
<Modal
|
||||
id="delete-custom-domain"
|
||||
show={deleteModal > -1}
|
||||
closeHandler={() => setDeleteModal(-1)}
|
||||
>
|
||||
{linkToDelete && (
|
||||
<>
|
||||
<H2 mb={24} textAlign="center" bold>
|
||||
Delete link?
|
||||
</H2>
|
||||
<Text textAlign="center">
|
||||
Are you sure do you want to delete the link{" "}
|
||||
<Span bold>"{removeProtocol(linkToDelete.link)}"</Span>?
|
||||
</Text>
|
||||
<Flex justifyContent="center" mt={44}>
|
||||
{deleteLoading ? (
|
||||
<>
|
||||
<Icon name="spinner" size={20} stroke={Colors.Spinner} />
|
||||
</>
|
||||
) : deleteMessage.text ? (
|
||||
<Text fontSize={15} color={deleteMessage.color}>
|
||||
{deleteMessage.text}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
color="gray"
|
||||
mr={3}
|
||||
onClick={() => setDeleteModal(-1)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" ml={3} onClick={onDelete}>
|
||||
<Icon name="trash" stroke="white" mr={2} />
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</Col>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinksTable;
|
@ -1,58 +0,0 @@
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import styled from "styled-components";
|
||||
import React, { FC } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
import Animation from "./Animation";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Flex> {
|
||||
show: boolean;
|
||||
id?: string;
|
||||
closeHandler?: () => unknown;
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: rgba(50, 50, 50, 0.8);
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
const Modal: FC<Props> = ({ children, id, show, closeHandler, ...rest }) => {
|
||||
if (!show) return null;
|
||||
|
||||
const onClickOutside = (e) => {
|
||||
if (e.target.id === id) closeHandler();
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<Wrapper id={id} onClick={onClickOutside}>
|
||||
<Animation
|
||||
offset="-20px"
|
||||
duration="0.2s"
|
||||
minWidth={[400, 450]}
|
||||
maxWidth="90%"
|
||||
py={[32, 32, 48]}
|
||||
px={[24, 24, 32]}
|
||||
style={{ borderRadius: 8, backgroundColor: "white" }}
|
||||
flexDirection="column"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Animation>
|
||||
</Wrapper>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
Modal.defaultProps = {
|
||||
show: false
|
||||
};
|
||||
|
||||
export default Modal;
|
@ -1,76 +0,0 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import styled from "styled-components";
|
||||
import { Flex } from "rebass/styled-components";
|
||||
|
||||
import { Button } from "./Button";
|
||||
import { fadeIn } from "../helpers/animations";
|
||||
import { Col } from "./Layout";
|
||||
|
||||
const Wrapper = styled(Flex).attrs({
|
||||
width: 1200,
|
||||
maxWidth: "98%",
|
||||
alignItems: "center",
|
||||
margin: "150px 0 0",
|
||||
flexDirection: ["column", "column", "row"]
|
||||
})`
|
||||
animation: ${fadeIn} 0.8s ease-out;
|
||||
box-sizing: border-box;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Title = styled.h2`
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
padding-right: 32px;
|
||||
margin-bottom: 48px;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
font-size: 22px;
|
||||
text-align: center;
|
||||
padding-right: 0;
|
||||
margin-bottom: 32px;
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 448px) {
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Image = styled.img`
|
||||
flex: 0 0 60%;
|
||||
width: 60%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
flex-basis: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const NeedToLogin = () => (
|
||||
<Wrapper>
|
||||
<Col
|
||||
alignItems={["center", "center", "flex-start"]}
|
||||
mt={-32}
|
||||
mb={[32, 32, 0]}
|
||||
>
|
||||
<Title>
|
||||
Manage links, set custom <b>domains</b> and view <b>stats</b>.
|
||||
</Title>
|
||||
<Link href="/login" title="login / signup">
|
||||
<Button>Login / Signup</Button>
|
||||
</Link>
|
||||
</Col>
|
||||
<Image src="/images/callout.png" alt="callout image" />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
export default NeedToLogin;
|
@ -1,19 +0,0 @@
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import React from "react";
|
||||
|
||||
import { Colors } from "../consts";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const PageLoading = () => (
|
||||
<Flex
|
||||
flex="1 1 250px"
|
||||
alignItems="center"
|
||||
alignSelf="center"
|
||||
justifyContent="center"
|
||||
margin="0 0 48px"
|
||||
>
|
||||
<Icon name="spinner" size={24} stroke={Colors.Spinner} />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
export default PageLoading;
|
@ -1,24 +0,0 @@
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import getConfig from "next/config";
|
||||
import React from "react";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const ReCaptcha = () => {
|
||||
if (process.env.NODE_ENV !== "production") return null;
|
||||
if (!publicRuntimeConfig.RECAPTCHA_SITE_KEY) return null;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
margin="54px 0 16px"
|
||||
id="g-recaptcha"
|
||||
className="g-recaptcha"
|
||||
data-sitekey={publicRuntimeConfig.RECAPTCHA_SITE_KEY}
|
||||
data-callback="recaptchaCallback"
|
||||
data-size="invisible"
|
||||
data-badge="inline"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReCaptcha;
|
@ -1,108 +0,0 @@
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import React, { FC, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { useStoreState, useStoreActions } from "../../store";
|
||||
import { useCopy, useMessage } from "../../hooks";
|
||||
import { errorMessage } from "../../utils";
|
||||
import { Colors } from "../../consts";
|
||||
import Animation from "../Animation";
|
||||
import { Button } from "../Button";
|
||||
import Text, { H2 } from "../Text";
|
||||
import { Col } from "../Layout";
|
||||
import ALink from "../ALink";
|
||||
import Icon from "../Icon";
|
||||
|
||||
const ApiKey = styled(Text).attrs({
|
||||
mt: [0, "2px"],
|
||||
fontSize: [15, 16],
|
||||
bold: true
|
||||
})`
|
||||
border-bottom: 1px dotted ${Colors.StatsTotalUnderline};
|
||||
cursor: pointer;
|
||||
word-break: break-word;
|
||||
|
||||
:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
`;
|
||||
|
||||
const SettingsApi: FC = () => {
|
||||
const [copied, setCopied] = useCopy();
|
||||
const [message, setMessage] = useMessage(1500);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const apikey = useStoreState((s) => s.settings.apikey);
|
||||
const generateApiKey = useStoreActions((s) => s.settings.generateApiKey);
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
await generateApiKey().catch((err) => setMessage(errorMessage(err)));
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Col alignItems="flex-start" maxWidth="100%">
|
||||
<H2 mb={4} bold>
|
||||
API
|
||||
</H2>
|
||||
<Text mb={4}>
|
||||
In additional to this website, you can use the API to create, delete and
|
||||
get shortened URLs. If
|
||||
{" you're"} not familiar with API, {"don't"} generate the key. DO NOT
|
||||
share this key on the client side of your website.{" "}
|
||||
<ALink href="https://docs.kutt.it" title="API Docs" target="_blank">
|
||||
Read API docs.
|
||||
</ALink>
|
||||
</Text>
|
||||
{apikey && (
|
||||
<Flex alignItems={["flex-start", "center"]} my={3}>
|
||||
{copied ? (
|
||||
<Animation offset="10px" duration="0.2s">
|
||||
<Icon
|
||||
size={[23, 24]}
|
||||
py={0}
|
||||
px={0}
|
||||
mr={2}
|
||||
p="3px"
|
||||
name="check"
|
||||
strokeWidth="3"
|
||||
stroke={Colors.CheckIcon}
|
||||
/>
|
||||
</Animation>
|
||||
) : (
|
||||
<Animation offset="-10px" duration="0.2s">
|
||||
<CopyToClipboard text={apikey} onCopy={setCopied}>
|
||||
<Icon
|
||||
as="button"
|
||||
py={0}
|
||||
px={0}
|
||||
mr={2}
|
||||
size={[23, 24]}
|
||||
p={["4px", "5px"]}
|
||||
name="copy"
|
||||
strokeWidth="2.5"
|
||||
stroke={Colors.CopyIcon}
|
||||
backgroundColor={Colors.CopyIconBg}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
</Animation>
|
||||
)}
|
||||
<CopyToClipboard text={apikey} onCopy={setCopied}>
|
||||
<ApiKey>{apikey}</ApiKey>
|
||||
</CopyToClipboard>
|
||||
</Flex>
|
||||
)}
|
||||
<Button mt={3} color="purple" onClick={onSubmit} disabled={loading}>
|
||||
<Icon name={loading ? "spinner" : "zap"} mr={2} stroke="white" />
|
||||
{loading ? "Generating..." : apikey ? "Regenerate" : "Generate"} key
|
||||
</Button>
|
||||
<Text fontSize={15} mt={3} color={message.color}>
|
||||
{message.text}
|
||||
</Text>
|
||||
</Col>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsApi;
|
@ -1,99 +0,0 @@
|
||||
import { useFormState } from "react-use-form-state";
|
||||
import React, { FC, useState } from "react";
|
||||
import { Flex } from "rebass";
|
||||
import axios from "axios";
|
||||
|
||||
import { getAxiosConfig } from "../../utils";
|
||||
import { useMessage } from "../../hooks";
|
||||
import { APIv2 } from "../../consts";
|
||||
import { TextInput } from "../Input";
|
||||
import Text, { H2 } from "../Text";
|
||||
import { Button } from "../Button";
|
||||
import { Col } from "../Layout";
|
||||
import Icon from "../Icon";
|
||||
|
||||
const SettingsChangeEmail: FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useMessage(5000);
|
||||
const [formState, { password, email, label }] = useFormState<{
|
||||
changeemailpass: string;
|
||||
changeemailaddress: string;
|
||||
}>(null, {
|
||||
withIds: true
|
||||
});
|
||||
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.post(
|
||||
APIv2.AuthChangeEmail,
|
||||
{
|
||||
password: formState.values.changeemailpass,
|
||||
email: formState.values.changeemailaddress
|
||||
},
|
||||
getAxiosConfig()
|
||||
);
|
||||
setMessage(res.data.message, "green");
|
||||
} catch (error) {
|
||||
setMessage(error?.response?.data?.error || "Couldn't send email.");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Col alignItems="flex-start" maxWidth="100%">
|
||||
<H2 mb={4} bold>
|
||||
Change email address
|
||||
</H2>
|
||||
<Col alignItems="flex-start" onSubmit={onSubmit} width={1} as="form">
|
||||
<Flex width={1} flexDirection={["column", "row"]}>
|
||||
<Col mr={[0, 2]} mb={[3, 0]} flex="0 0 auto">
|
||||
<Text
|
||||
{...label("changeemailpass")}
|
||||
as="label"
|
||||
mb={[2, 3]}
|
||||
fontSize={[15, 16]}
|
||||
bold
|
||||
>
|
||||
Password:
|
||||
</Text>
|
||||
<TextInput
|
||||
{...password("changeemailpass")}
|
||||
placeholder="Password..."
|
||||
maxWidth="240px"
|
||||
required
|
||||
/>
|
||||
</Col>
|
||||
<Col ml={[0, 2]} flex="0 0 auto">
|
||||
<Text
|
||||
{...label("changeemailaddress")}
|
||||
as="label"
|
||||
mb={[2, 3]}
|
||||
fontSize={[15, 16]}
|
||||
bold
|
||||
>
|
||||
New email address:
|
||||
</Text>
|
||||
<TextInput
|
||||
{...email("changeemailaddress")}
|
||||
placeholder="john@example.com"
|
||||
flex="1 1 auto"
|
||||
maxWidth="240px"
|
||||
/>
|
||||
</Col>
|
||||
</Flex>
|
||||
<Button type="submit" color="blue" mt={[24, 3]} disabled={loading}>
|
||||
<Icon name={loading ? "spinner" : "refresh"} mr={2} stroke="white" />
|
||||
{loading ? "Sending..." : "Update"}
|
||||
</Button>
|
||||
</Col>
|
||||
<Text fontSize={15} color={message.color} mt={3}>
|
||||
{message.text}
|
||||
</Text>
|
||||
</Col>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsChangeEmail;
|
@ -1,122 +0,0 @@
|
||||
import { useFormState } from "react-use-form-state";
|
||||
import React, { FC, useState } from "react";
|
||||
import getConfig from "next/config";
|
||||
import Router from "next/router";
|
||||
import axios from "axios";
|
||||
|
||||
import { getAxiosConfig } from "../../utils";
|
||||
import { Col, RowCenterV, RowCenterH } from "../Layout";
|
||||
import Text, { H2, Span } from "../Text";
|
||||
import { useMessage } from "../../hooks";
|
||||
import { TextInput } from "../Input";
|
||||
import { APIv2, Colors } from "../../consts";
|
||||
import { Button } from "../Button";
|
||||
import Icon from "../Icon";
|
||||
import Modal from "../Modal";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const SettingsDeleteAccount: FC = () => {
|
||||
const [message, setMessage] = useMessage(1500);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modal, setModal] = useState(false);
|
||||
const [formState, { password, label }] = useFormState<{ accpass: string }>(
|
||||
null,
|
||||
{
|
||||
withIds: true
|
||||
}
|
||||
);
|
||||
|
||||
const onSubmit = async e => {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
setModal(true);
|
||||
};
|
||||
|
||||
const onDelete = async e => {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await axios.post(
|
||||
`${APIv2.Users}/delete`,
|
||||
{ password: formState.values.accpass },
|
||||
getAxiosConfig()
|
||||
);
|
||||
Router.push("/logout");
|
||||
} catch (error) {
|
||||
setMessage(error.response.data.error);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Col alignItems="flex-start" maxWidth="100%">
|
||||
<H2 mb={4} bold>
|
||||
Delete account
|
||||
</H2>
|
||||
<Text mb={4}>
|
||||
Delete your account from {publicRuntimeConfig.SITE_NAME}.
|
||||
</Text>
|
||||
<Text
|
||||
{...label("password")}
|
||||
as="label"
|
||||
mb={[2, 3]}
|
||||
fontSize={[15, 16]}
|
||||
bold
|
||||
>
|
||||
Password:
|
||||
</Text>
|
||||
<RowCenterV as="form" onSubmit={onSubmit}>
|
||||
<TextInput
|
||||
{...password("accpass")}
|
||||
placeholder="Password..."
|
||||
autocomplete="off"
|
||||
mr={3}
|
||||
/>
|
||||
<Button color="red" type="submit" disabled={loading}>
|
||||
<Icon name={loading ? "spinner" : "trash"} mr={2} stroke="white" />
|
||||
Delete
|
||||
</Button>
|
||||
</RowCenterV>
|
||||
<Modal
|
||||
id="delete-account"
|
||||
show={modal}
|
||||
closeHandler={() => setModal(false)}
|
||||
>
|
||||
<>
|
||||
<H2 mb={24} textAlign="center" bold>
|
||||
Delete account?
|
||||
</H2>
|
||||
<Text textAlign="center">
|
||||
All of your data including your <Span bold>LINKS</Span> and{" "}
|
||||
<Span bold>STATS</Span> will be deleted.
|
||||
</Text>
|
||||
<RowCenterH mt={44}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Icon name="spinner" size={20} stroke={Colors.Spinner} />
|
||||
</>
|
||||
) : message.text ? (
|
||||
<Text fontSize={15} color={message.color}>
|
||||
{message.text}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Button color="gray" mr={3} onClick={() => setModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" ml={3} onClick={onDelete}>
|
||||
<Icon name="trash" stroke="white" mr={2} />
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</RowCenterH>
|
||||
</>
|
||||
</Modal>
|
||||
</Col>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsDeleteAccount;
|
@ -1,204 +0,0 @@
|
||||
import { useFormState } from "react-use-form-state";
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import React, { FC, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import getConfig from "next/config";
|
||||
|
||||
import { useStoreState, useStoreActions } from "../../store";
|
||||
import { Domain } from "../../store/settings";
|
||||
import { errorMessage } from "../../utils";
|
||||
import { useMessage } from "../../hooks";
|
||||
import Text, { H2, Span } from "../Text";
|
||||
import { Colors } from "../../consts";
|
||||
import { TextInput } from "../Input";
|
||||
import { Button } from "../Button";
|
||||
import { Col } from "../Layout";
|
||||
import Table from "../Table";
|
||||
import Modal from "../Modal";
|
||||
import Icon from "../Icon";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const Th = styled(Flex).attrs({ as: "th", py: 3, px: 3 })`
|
||||
font-size: 15px;
|
||||
`;
|
||||
const Td = styled(Flex).attrs({ as: "td", py: 12, px: 3 })`
|
||||
font-size: 15px;
|
||||
`;
|
||||
|
||||
const SettingsDomain: FC = () => {
|
||||
const { saveDomain, deleteDomain } = useStoreActions((s) => s.settings);
|
||||
const [domainToDelete, setDomainToDelete] = useState<Domain>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const domains = useStoreState((s) => s.settings.domains);
|
||||
const [message, setMessage] = useMessage(2000);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modal, setModal] = useState(false);
|
||||
const [formState, { label, text }] = useFormState<{
|
||||
address: string;
|
||||
homepage: string;
|
||||
}>(null, { withIds: true });
|
||||
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await saveDomain(formState.values);
|
||||
} catch (err) {
|
||||
setMessage(err?.response?.data?.error || "Couldn't add domain.");
|
||||
}
|
||||
formState.clear();
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setDomainToDelete(null);
|
||||
setModal(false);
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
setDeleteLoading(true);
|
||||
await deleteDomain(domainToDelete.id).catch((err) =>
|
||||
setMessage(errorMessage(err, "Couldn't delete the domain."))
|
||||
);
|
||||
setMessage("Domain has been deleted successfully.", "green");
|
||||
closeModal();
|
||||
setDeleteLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Col alignItems="flex-start" maxWidth="100%">
|
||||
<H2 mb={4} bold>
|
||||
Custom domain
|
||||
</H2>
|
||||
<Text mb={3}>
|
||||
You can set a custom domain for your short URLs, so instead of{" "}
|
||||
<b>{publicRuntimeConfig.DEFAULT_DOMAIN}/shorturl</b> you can have{" "}
|
||||
<b>example.com/shorturl.</b>
|
||||
</Text>
|
||||
<Text mb={4}>
|
||||
Point your domain A record to <b>192.64.116.170</b> then add the domain
|
||||
via form below:
|
||||
</Text>
|
||||
{domains.length > 0 && (
|
||||
<Table my={3} scrollWidth="550px">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th width={2 / 5}>Domain</Th>
|
||||
<Th width={2 / 5}>Homepage</Th>
|
||||
<Th width={1 / 5}></Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{domains.map((d) => (
|
||||
<tr key={d.address}>
|
||||
<Td width={2 / 5}>{d.address}</Td>
|
||||
<Td width={2 / 5}>
|
||||
{d.homepage || publicRuntimeConfig.DEFAULT_DOMAIN}
|
||||
</Td>
|
||||
<Td width={1 / 5} justifyContent="center">
|
||||
<Icon
|
||||
as="button"
|
||||
name="trash"
|
||||
stroke={Colors.TrashIcon}
|
||||
strokeWidth="2.5"
|
||||
backgroundColor={Colors.TrashIconBg}
|
||||
py={0}
|
||||
px={0}
|
||||
size={[23, 24]}
|
||||
p={["4px", "5px"]}
|
||||
onClick={() => {
|
||||
setDomainToDelete(d);
|
||||
setModal(true);
|
||||
}}
|
||||
/>
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
)}
|
||||
<Col
|
||||
alignItems="flex-start"
|
||||
onSubmit={onSubmit}
|
||||
width={1}
|
||||
as="form"
|
||||
my={[3, 4]}
|
||||
>
|
||||
<Flex width={1} flexDirection={["column", "row"]}>
|
||||
<Col mr={[0, 2]} mb={[3, 0]} flex="0 0 auto">
|
||||
<Text
|
||||
{...label("address")}
|
||||
as="label"
|
||||
mb={[2, 3]}
|
||||
fontSize={[15, 16]}
|
||||
bold
|
||||
>
|
||||
Domain:
|
||||
</Text>
|
||||
<TextInput
|
||||
{...text("address")}
|
||||
placeholder="example.com"
|
||||
maxWidth="240px"
|
||||
required
|
||||
/>
|
||||
</Col>
|
||||
<Col ml={[0, 2]} flex="0 0 auto">
|
||||
<Text
|
||||
{...label("homepage")}
|
||||
as="label"
|
||||
mb={[2, 3]}
|
||||
fontSize={[15, 16]}
|
||||
bold
|
||||
>
|
||||
Homepage (optional):
|
||||
</Text>
|
||||
<TextInput
|
||||
{...text("homepage")}
|
||||
placeholder="Homepage URL"
|
||||
flex="1 1 auto"
|
||||
maxWidth="240px"
|
||||
/>
|
||||
</Col>
|
||||
</Flex>
|
||||
<Button type="submit" color="purple" mt={[24, 3]} disabled={loading}>
|
||||
<Icon name={loading ? "spinner" : "plus"} mr={2} stroke="white" />
|
||||
{loading ? "Setting..." : "Set domain"}
|
||||
</Button>
|
||||
</Col>
|
||||
<Text color={message.color}>{message.text}</Text>
|
||||
<Modal id="delete-custom-domain" show={modal} closeHandler={closeModal}>
|
||||
<H2 mb={24} textAlign="center" bold>
|
||||
Delete domain?
|
||||
</H2>
|
||||
<Text textAlign="center">
|
||||
Are you sure do you want to delete the domain{" "}
|
||||
<Span bold>
|
||||
"{domainToDelete && domainToDelete.address}"
|
||||
</Span>
|
||||
?
|
||||
</Text>
|
||||
<Flex justifyContent="center" mt={44}>
|
||||
{deleteLoading ? (
|
||||
<>
|
||||
<Icon name="spinner" size={20} stroke={Colors.Spinner} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button color="gray" mr={3} onClick={closeModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" ml={3} onClick={onDelete}>
|
||||
<Icon name="trash" stroke="white" mr={2} />
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Modal>
|
||||
</Col>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsDomain;
|
@ -1,89 +0,0 @@
|
||||
import { useFormState } from "react-use-form-state";
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import React, { FC, useState } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
import { getAxiosConfig } from "../../utils";
|
||||
import { useMessage } from "../../hooks";
|
||||
import { TextInput } from "../Input";
|
||||
import { APIv2 } from "../../consts";
|
||||
import { Button } from "../Button";
|
||||
import Text, { H2 } from "../Text";
|
||||
import { Col } from "../Layout";
|
||||
import Icon from "../Icon";
|
||||
|
||||
const SettingsPassword: FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useMessage(2000);
|
||||
const [formState, { password, label }] = useFormState<{ password: string }>(
|
||||
null,
|
||||
{ withIds: true }
|
||||
);
|
||||
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
if (!formState.validity.password) {
|
||||
return setMessage(formState.errors.password);
|
||||
}
|
||||
setLoading(true);
|
||||
setMessage();
|
||||
try {
|
||||
const res = await axios.post(
|
||||
APIv2.AuthChangePassword,
|
||||
formState.values,
|
||||
getAxiosConfig()
|
||||
);
|
||||
formState.clear();
|
||||
setMessage(res.data.message, "green");
|
||||
} catch (err) {
|
||||
setMessage(err?.response?.data?.error || "Couldn't update the password.");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Col alignItems="flex-start" maxWidth="100%">
|
||||
<H2 mb={4} bold>
|
||||
Change password
|
||||
</H2>
|
||||
<Text mb={4}>Enter a new password to change your current password.</Text>
|
||||
<Text
|
||||
{...label("password")}
|
||||
as="label"
|
||||
mb={[2, 3]}
|
||||
fontSize={[15, 16]}
|
||||
bold
|
||||
>
|
||||
New password:
|
||||
</Text>
|
||||
<Flex as="form" onSubmit={onSubmit}>
|
||||
<TextInput
|
||||
{...password({
|
||||
name: "password",
|
||||
validate: (value) => {
|
||||
const val = value.trim();
|
||||
if (!val || val.length < 8) {
|
||||
return "Password must be at least 8 chars.";
|
||||
}
|
||||
}
|
||||
})}
|
||||
autocomplete="off"
|
||||
placeholder="New password..."
|
||||
width={[1, 2 / 3]}
|
||||
mr={3}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" disabled={loading}>
|
||||
<Icon name={loading ? "spinner" : "refresh"} mr={2} stroke="white" />
|
||||
{loading ? "Updating..." : "Update"}
|
||||
</Button>
|
||||
</Flex>
|
||||
<Text color={message.color} mt={3} fontSize={15}>
|
||||
{message.text}
|
||||
</Text>
|
||||
</Col>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPassword;
|
@ -1,380 +0,0 @@
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import { useFormState } from "react-use-form-state";
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import React, { useState } from "react";
|
||||
import styled from "styled-components";
|
||||
import getConfig from "next/config";
|
||||
|
||||
import { useStoreActions, useStoreState } from "../store";
|
||||
import { Checkbox, Select, TextInput } from "./Input";
|
||||
import { Col, RowCenterH, RowCenter } from "./Layout";
|
||||
import { useMessage, useCopy } from "../hooks";
|
||||
import { removeProtocol } from "../utils";
|
||||
import Text, { H1, Span } from "./Text";
|
||||
import { Link } from "../store/links";
|
||||
import Animation from "./Animation";
|
||||
import { Colors } from "../consts";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const SubmitIconWrapper = styled.div`
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 12px;
|
||||
width: 64px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
:hover svg {
|
||||
fill: #673ab7;
|
||||
}
|
||||
@media only screen and (max-width: 448px) {
|
||||
right: 8px;
|
||||
width: 40px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ShortenedLink = styled(H1)`
|
||||
cursor: "pointer";
|
||||
border-bottom: 1px dotted ${Colors.StatsTotalUnderline};
|
||||
cursor: pointer;
|
||||
|
||||
:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
`;
|
||||
|
||||
interface Form {
|
||||
target: string;
|
||||
domain?: string;
|
||||
customurl?: string;
|
||||
password?: string;
|
||||
description?: string;
|
||||
expire_in?: string;
|
||||
showAdvanced?: boolean;
|
||||
}
|
||||
|
||||
const defaultDomain = publicRuntimeConfig.DEFAULT_DOMAIN;
|
||||
|
||||
const Shortener = () => {
|
||||
const { isAuthenticated } = useStoreState((s) => s.auth);
|
||||
const domains = useStoreState((s) => s.settings.domains);
|
||||
const submit = useStoreActions((s) => s.links.submit);
|
||||
const [link, setLink] = useState<Link | null>(null);
|
||||
const [message, setMessage] = useMessage(3000);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copied, setCopied] = useCopy();
|
||||
const [formState, { raw, password, text, select, label }] =
|
||||
useFormState<Form>(
|
||||
{ showAdvanced: false },
|
||||
{
|
||||
withIds: true,
|
||||
onChange(e, stateValues, nextStateValues) {
|
||||
if (stateValues.showAdvanced && !nextStateValues.showAdvanced) {
|
||||
formState.clear();
|
||||
formState.setField("target", stateValues.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const submitLink = async (reCaptchaToken?: string) => {
|
||||
try {
|
||||
const link = await submit({ ...formState.values, reCaptchaToken });
|
||||
setLink(link);
|
||||
formState.clear();
|
||||
} catch (err) {
|
||||
setMessage(
|
||||
err?.response?.data?.error || "Couldn't create the short link."
|
||||
);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
setCopied(false);
|
||||
setLoading(true);
|
||||
|
||||
if (
|
||||
process.env.NODE_ENV === "production" &&
|
||||
!!publicRuntimeConfig.RECAPTCHA_SITE_KEY &&
|
||||
!isAuthenticated
|
||||
) {
|
||||
window.grecaptcha.execute(window.captchaId);
|
||||
const getCaptchaToken = () => {
|
||||
setTimeout(() => {
|
||||
if (window.isCaptchaReady) {
|
||||
const reCaptchaToken = window.grecaptcha.getResponse(
|
||||
window.captchaId
|
||||
);
|
||||
window.isCaptchaReady = false;
|
||||
window.grecaptcha.reset(window.captchaId);
|
||||
return submitLink(reCaptchaToken);
|
||||
}
|
||||
return getCaptchaToken();
|
||||
}, 200);
|
||||
};
|
||||
return getCaptchaToken();
|
||||
}
|
||||
|
||||
return submitLink();
|
||||
};
|
||||
|
||||
const title = !link && (
|
||||
<H1 fontSize={[25, 27, 32]} light>
|
||||
Kutt your links{" "}
|
||||
<Span style={{ borderBottom: "2px dotted #999" }} light>
|
||||
shorter
|
||||
</Span>
|
||||
.
|
||||
</H1>
|
||||
);
|
||||
|
||||
const result = link && (
|
||||
<Animation
|
||||
as={RowCenter}
|
||||
offset="-20px"
|
||||
duration="0.4s"
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
{copied ? (
|
||||
<Animation offset="10px" duration="0.2s" alignItems="center">
|
||||
<Icon
|
||||
size={[30, 35]}
|
||||
py={0}
|
||||
px={0}
|
||||
mr={3}
|
||||
p={["4px", "5px"]}
|
||||
name="check"
|
||||
strokeWidth="3"
|
||||
stroke={Colors.CheckIcon}
|
||||
/>
|
||||
</Animation>
|
||||
) : (
|
||||
<Animation offset="-10px" duration="0.2s">
|
||||
<CopyToClipboard text={link.link} onCopy={setCopied}>
|
||||
<Icon
|
||||
as="button"
|
||||
py={0}
|
||||
px={0}
|
||||
mr={3}
|
||||
size={[30, 35]}
|
||||
p={["6px", "7px"]}
|
||||
name="copy"
|
||||
strokeWidth="2.5"
|
||||
stroke={Colors.CopyIcon}
|
||||
backgroundColor={Colors.CopyIconBg}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
</Animation>
|
||||
)}
|
||||
<CopyToClipboard text={link.link} onCopy={setCopied}>
|
||||
<ShortenedLink fontSize={[24, 26, 30]} pb="2px" light>
|
||||
{removeProtocol(link.link)}
|
||||
</ShortenedLink>
|
||||
</CopyToClipboard>
|
||||
</Animation>
|
||||
);
|
||||
|
||||
return (
|
||||
<Col width={800} maxWidth="100%" px={[3]} flex="0 0 auto" mt={4}>
|
||||
<RowCenterH mb={[4, 48]}>
|
||||
{title}
|
||||
{result}
|
||||
</RowCenterH>
|
||||
<Flex
|
||||
as="form"
|
||||
id="shortenerform"
|
||||
width={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
style={{ position: "relative" }}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<TextInput
|
||||
{...text("target")}
|
||||
placeholder="Paste your long URL"
|
||||
placeholderSize={[16, 17, 18]}
|
||||
fontSize={[18, 20, 22]}
|
||||
aria-label="target"
|
||||
width={1}
|
||||
height={[58, 64, 72]}
|
||||
px={0}
|
||||
pr={[48, 84]}
|
||||
pl={[32, 40]}
|
||||
autoFocus
|
||||
data-lpignore
|
||||
/>
|
||||
<SubmitIconWrapper onClick={onSubmit} role="button" aria-label="submit">
|
||||
<Icon
|
||||
name={loading ? "spinner" : "send"}
|
||||
size={[22, 26, 28]}
|
||||
fill={loading ? "none" : "#aaa"}
|
||||
stroke={loading ? Colors.Spinner : "none"}
|
||||
mb={1}
|
||||
mr={1}
|
||||
/>
|
||||
</SubmitIconWrapper>
|
||||
</Flex>
|
||||
{message.text && (
|
||||
<Text color={message.color} mt={24} mb={1} textAlign="center">
|
||||
{message.text}
|
||||
</Text>
|
||||
)}
|
||||
<Checkbox
|
||||
{...raw({
|
||||
name: "showAdvanced",
|
||||
onChange: () => {
|
||||
if (!isAuthenticated) {
|
||||
setMessage(
|
||||
"You need to log in or sign up to use advanced options."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return !formState.values.showAdvanced;
|
||||
}
|
||||
})}
|
||||
checked={formState.values.showAdvanced}
|
||||
label="Show advanced options"
|
||||
mt={[3, 24]}
|
||||
alignSelf="flex-start"
|
||||
/>
|
||||
{formState.values.showAdvanced && (
|
||||
<div>
|
||||
<Flex mt={4} flexDirection={["column", "row"]}>
|
||||
<Col mb={[3, 0]}>
|
||||
<Text
|
||||
as="label"
|
||||
{...label("domain")}
|
||||
fontSize={[14, 15]}
|
||||
mb={2}
|
||||
bold
|
||||
>
|
||||
Domain:
|
||||
</Text>
|
||||
<Select
|
||||
{...select("domain")}
|
||||
data-lpignore
|
||||
pl={[3, 24]}
|
||||
pr={[3, 24]}
|
||||
fontSize={[14, 15]}
|
||||
height={[40, 44]}
|
||||
width={[1, 210, 240]}
|
||||
options={[
|
||||
{ key: defaultDomain, value: "" },
|
||||
...domains.map((d) => ({
|
||||
key: d.address,
|
||||
value: d.address
|
||||
}))
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col mb={[3, 0]} ml={[0, 24]}>
|
||||
<Text
|
||||
as="label"
|
||||
{...label("customurl")}
|
||||
fontSize={[14, 15]}
|
||||
mb={2}
|
||||
bold
|
||||
>
|
||||
{formState.values.domain || defaultDomain}/
|
||||
</Text>
|
||||
<TextInput
|
||||
{...text("customurl")}
|
||||
placeholder="Custom address..."
|
||||
autocomplete="off"
|
||||
data-lpignore
|
||||
pl={[3, 24]}
|
||||
pr={[3, 24]}
|
||||
placeholderSize={[13, 14]}
|
||||
fontSize={[14, 15]}
|
||||
height={[40, 44]}
|
||||
width={[1, 210, 240]}
|
||||
/>
|
||||
</Col>
|
||||
<Col ml={[0, 24]}>
|
||||
<Text
|
||||
as="label"
|
||||
{...label("password")}
|
||||
fontSize={[14, 15]}
|
||||
mb={2}
|
||||
bold
|
||||
>
|
||||
Password:
|
||||
</Text>
|
||||
<TextInput
|
||||
{...password("password")}
|
||||
placeholder="Password..."
|
||||
autocomplete="off"
|
||||
data-lpignore
|
||||
pl={[3, 24]}
|
||||
pr={[3, 24]}
|
||||
placeholderSize={[13, 14]}
|
||||
fontSize={[14, 15]}
|
||||
height={[40, 44]}
|
||||
width={[1, 210, 240]}
|
||||
/>
|
||||
</Col>
|
||||
</Flex>
|
||||
<Flex mt={[3]} flexDirection={["column", "row"]}>
|
||||
<Col mb={[3, 0]}>
|
||||
<Text
|
||||
as="label"
|
||||
{...label("expire_in")}
|
||||
fontSize={[14, 15]}
|
||||
mb={2}
|
||||
bold
|
||||
>
|
||||
Expire in:
|
||||
</Text>
|
||||
<TextInput
|
||||
{...text("expire_in")}
|
||||
placeholder="2 minutes/hours/days"
|
||||
data-lpignore
|
||||
pl={[3, 24]}
|
||||
pr={[3, 24]}
|
||||
placeholderSize={[13, 14]}
|
||||
fontSize={[14, 15]}
|
||||
height={[40, 44]}
|
||||
width={[1, 210, 240]}
|
||||
maxWidth="100%"
|
||||
/>
|
||||
</Col>
|
||||
<Col width={[1, 2 / 3]} ml={[0, 26]}>
|
||||
<Text
|
||||
as="label"
|
||||
{...label("description")}
|
||||
fontSize={[14, 15]}
|
||||
mb={2}
|
||||
bold
|
||||
>
|
||||
Description:
|
||||
</Text>
|
||||
<TextInput
|
||||
{...text("description")}
|
||||
placeholder="Description"
|
||||
data-lpignore
|
||||
pl={[3, 24]}
|
||||
pr={[3, 24]}
|
||||
placeholderSize={[13, 14]}
|
||||
fontSize={[14, 15]}
|
||||
height={[40, 44]}
|
||||
width={1}
|
||||
maxWidth="100%"
|
||||
/>
|
||||
</Col>
|
||||
</Flex>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
);
|
||||
};
|
||||
|
||||
export default Shortener;
|
@ -1,84 +0,0 @@
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import styled, { css } from "styled-components";
|
||||
import { ifProp, prop } from "styled-tools";
|
||||
|
||||
import { Colors } from "../consts";
|
||||
|
||||
const Table = styled(Flex)<{ scrollWidth?: string }>`
|
||||
background-color: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 6px 15px ${Colors.TableShadow};
|
||||
text-align: center;
|
||||
overflow: auto;
|
||||
|
||||
tr,
|
||||
th,
|
||||
td,
|
||||
tbody,
|
||||
thead,
|
||||
tfoot {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
tbody,
|
||||
thead,
|
||||
tfoot {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid ${Colors.TableHeadBorder};
|
||||
}
|
||||
|
||||
tbody {
|
||||
border-bottom-right-radius: 12px;
|
||||
border-bottom-left-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
tbody + tfoot {
|
||||
border: none;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: ${Colors.TableRowHover};
|
||||
}
|
||||
|
||||
thead {
|
||||
background-color: ${Colors.TableHeadBg};
|
||||
border-top-right-radius: 12px;
|
||||
border-top-left-radius: 12px;
|
||||
font-weight: bold;
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid ${Colors.TableBorder};
|
||||
}
|
||||
}
|
||||
|
||||
tfoot {
|
||||
background-color: ${Colors.TableHeadBg};
|
||||
border-bottom-right-radius: 12px;
|
||||
border-bottom-left-radius: 12px;
|
||||
}
|
||||
|
||||
${ifProp(
|
||||
"scrollWidth",
|
||||
css`
|
||||
thead,
|
||||
tbody,
|
||||
tfoot {
|
||||
min-width: ${prop("scrollWidth")};
|
||||
}
|
||||
`
|
||||
)}
|
||||
`;
|
||||
|
||||
Table.defaultProps = {
|
||||
as: "table",
|
||||
flex: "1 1 auto",
|
||||
flexDirection: "column",
|
||||
width: 1
|
||||
};
|
||||
|
||||
export default Table;
|
@ -1,67 +0,0 @@
|
||||
import React from "react";
|
||||
import { switchProp, ifNotProp, ifProp } from "styled-tools";
|
||||
import { Box, BoxProps } from "rebass/styled-components";
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
import { FC, CSSProperties } from "react";
|
||||
import { Colors } from "../consts";
|
||||
|
||||
interface Props extends Omit<BoxProps, "as"> {
|
||||
as?: string;
|
||||
htmlFor?: string;
|
||||
light?: boolean;
|
||||
normal?: boolean;
|
||||
bold?: boolean;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
const Text: FC<Props> = styled(Box)<Props>`
|
||||
font-weight: 400;
|
||||
${ifNotProp(
|
||||
"fontSize",
|
||||
css`
|
||||
font-size: ${switchProp("a", {
|
||||
p: "1rem",
|
||||
h1: "1.802em",
|
||||
h2: "1.602em",
|
||||
h3: "1.424em",
|
||||
h4: "1.266em",
|
||||
h5: "1.125em"
|
||||
})};
|
||||
`
|
||||
)}
|
||||
|
||||
${ifProp(
|
||||
"light",
|
||||
css`
|
||||
font-weight: 300;
|
||||
`
|
||||
)}
|
||||
|
||||
${ifProp(
|
||||
"normal",
|
||||
css`
|
||||
font-weight: 400;
|
||||
`
|
||||
)}
|
||||
|
||||
${ifProp(
|
||||
"bold",
|
||||
css`
|
||||
font-weight: 700;
|
||||
`
|
||||
)}
|
||||
`;
|
||||
|
||||
Text.defaultProps = {
|
||||
color: Colors.Text
|
||||
};
|
||||
|
||||
export default Text;
|
||||
|
||||
export const H1: FC<Props> = (props) => <Text as="h1" {...props} />;
|
||||
export const H2: FC<Props> = (props) => <Text as="h2" {...props} />;
|
||||
export const H3: FC<Props> = (props) => <Text as="h3" {...props} />;
|
||||
export const H4: FC<Props> = (props) => <Text as="h4" {...props} />;
|
||||
export const H5: FC<Props> = (props) => <Text as="h5" {...props} />;
|
||||
export const H6: FC<Props> = (props) => <Text as="h6" {...props} />;
|
||||
export const Span: FC<Props> = (props) => <Text as="span" {...props} />;
|
@ -1,14 +0,0 @@
|
||||
import ReactTooltip from "react-tooltip";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Tooltip = styled(ReactTooltip)`
|
||||
padding: 3px 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
`;
|
||||
|
||||
Tooltip.defaultProps = {
|
||||
effect: "solid"
|
||||
};
|
||||
|
||||
export default Tooltip;
|
@ -1,62 +0,0 @@
|
||||
import getConfig from "next/config";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
export const DISALLOW_ANONYMOUS_LINKS =
|
||||
publicRuntimeConfig.DISALLOW_ANONYMOUS_LINKS === "true";
|
||||
|
||||
export const DISALLOW_REGISTRATION =
|
||||
publicRuntimeConfig.DISALLOW_REGISTRATION === "true";
|
||||
|
||||
export enum APIv2 {
|
||||
AuthLogin = "/api/v2/auth/login",
|
||||
AuthSignup = "/api/v2/auth/signup",
|
||||
AuthRenew = "/api/v2/auth/renew",
|
||||
AuthResetPassword = "/api/v2/auth/reset-password",
|
||||
AuthChangePassword = "/api/v2/auth/change-password",
|
||||
AuthChangeEmail = "/api/v2/auth/change-email",
|
||||
AuthGenerateApikey = "/api/v2/auth/apikey",
|
||||
Users = "/api/v2/users",
|
||||
Domains = "/api/v2/domains",
|
||||
Links = "/api/v2/links"
|
||||
}
|
||||
|
||||
export enum Colors {
|
||||
Bg = "hsl(206, 12%, 95%)",
|
||||
CheckIcon = "hsl(144, 50%, 60%)",
|
||||
CopyIcon = "hsl(144, 40%, 57%)",
|
||||
CopyIconBg = "hsl(144, 100%, 96%)",
|
||||
Divider = "hsl(200, 20%, 92%)",
|
||||
EditIcon = "hsl(46, 90%, 50%)",
|
||||
EditIconBg = "hsl(46, 100%, 94%)",
|
||||
ExtensionsBg = "hsl(230, 15%, 20%)",
|
||||
FeaturesBg = "hsl(230, 15%, 92%)",
|
||||
Icon = "hsl(200, 35%, 45%)",
|
||||
IconShadow = "hsla(200, 15%, 60%, 0.12)",
|
||||
Map0 = "hsl(200, 15%, 92%)",
|
||||
Map06 = "hsl(261, 46%, 68%)",
|
||||
Map05 = "hsl(261, 46%, 72%)",
|
||||
Map04 = "hsl(261, 46%, 76%)",
|
||||
Map03 = "hsl(261, 46%, 82%)",
|
||||
Map02 = "hsl(261, 46%, 86%)",
|
||||
Map01 = "hsl(261, 46%, 90%)",
|
||||
PieIcon = "hsl(260, 100%, 69%)",
|
||||
PieIconBg = "hsl(260, 100%, 96%)",
|
||||
QrCodeIcon = "hsl(0, 0%, 35%)",
|
||||
QrCodeIconBg = "hsl(0, 0%, 94%)",
|
||||
Spinner = "hsl(200, 15%, 70%)",
|
||||
StatsLastUpdateText = "hsl(200, 14%, 60%)",
|
||||
StatsTotalUnderline = "hsl(200, 35%, 65%)",
|
||||
StopIcon = "hsl(10, 100%, 40%)",
|
||||
StopIconBg = "hsl(10, 100%, 96%)",
|
||||
TableBorder = "hsl(200, 14%, 90%)",
|
||||
TableHeadBg = "hsl(200, 12%, 95%)",
|
||||
TableHeadBorder = "hsl(200, 14%, 94%)",
|
||||
TableRowHover = "hsl(200, 14%, 98%)",
|
||||
TableRowBanned = "hsl(0, 100%, 98%)",
|
||||
TableRowBannedHower = "hsl(0, 100%, 96%)",
|
||||
TableShadow = "hsla(200, 20%, 70%, 0.3)",
|
||||
Text = "hsl(200, 35%, 25%)",
|
||||
TrashIcon = "hsl(0, 100%, 69%)",
|
||||
TrashIconBg = "hsl(0, 100%, 96%)"
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from "./consts";
|
@ -1,30 +0,0 @@
|
||||
import { keyframes } from "styled-components";
|
||||
|
||||
export const fadeInVertical = vertical => keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(${vertical});
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
`;
|
||||
|
||||
export const fadeIn = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export const spin = keyframes`
|
||||
from {
|
||||
transform: rotate(0);
|
||||
}
|
||||
to {
|
||||
transform: rotate(-360deg);
|
||||
}
|
||||
`;
|
@ -1,11 +0,0 @@
|
||||
export default function showRecaptcha() {
|
||||
const captcha = document.getElementById('g-recaptcha');
|
||||
if (!captcha) return null;
|
||||
if (!window.grecaptcha || !window.grecaptcha.render) {
|
||||
return setTimeout(() => showRecaptcha(), 200);
|
||||
}
|
||||
if (!captcha.childNodes.length) {
|
||||
window.captchaId = window.grecaptcha.render(captcha);
|
||||
}
|
||||
return null;
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
const initialMessage = { color: "red", text: "" };
|
||||
|
||||
export const useMessage = (timeout?: number) => {
|
||||
const [message, set] = useState(initialMessage);
|
||||
|
||||
const setMessage = (text = "", color = "red") => {
|
||||
set({ text, color });
|
||||
|
||||
if (timeout) {
|
||||
setTimeout(() => set(initialMessage), timeout);
|
||||
}
|
||||
};
|
||||
|
||||
return [message, setMessage] as const;
|
||||
};
|
||||
|
||||
export const useCopy = (timeout = 1500) => {
|
||||
const [copied, set] = useState(false);
|
||||
|
||||
const setCopied = (isCopied = true) => {
|
||||
set(isCopied);
|
||||
|
||||
if (isCopied && timeout) {
|
||||
setTimeout(() => set(false), timeout);
|
||||
}
|
||||
};
|
||||
|
||||
return [copied, setCopied] as const;
|
||||
};
|
19
client/module.d.ts
vendored
19
client/module.d.ts
vendored
@ -1,19 +0,0 @@
|
||||
import "next";
|
||||
import { initializeStore } from "./store";
|
||||
|
||||
declare module "*.svg";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
GA_INITIALIZED: boolean;
|
||||
grecaptcha: any;
|
||||
isCaptchaReady: boolean;
|
||||
captchaId: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next" {
|
||||
export interface NextPageContext {
|
||||
store: ReturnType<typeof initializeStore>;
|
||||
}
|
||||
}
|
5
client/next-env.d.ts
vendored
5
client/next-env.d.ts
vendored
@ -1,5 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
@ -1,81 +0,0 @@
|
||||
import App, { AppContext } from "next/app";
|
||||
import { StoreProvider } from "easy-peasy";
|
||||
import getConfig from "next/config";
|
||||
import Router from "next/router";
|
||||
import decode from "jwt-decode";
|
||||
import cookie from "js-cookie";
|
||||
import Head from "next/head";
|
||||
import React from "react";
|
||||
|
||||
import { initializeStore } from "../store";
|
||||
import { TokenPayload } from "../types";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
// TODO: types
|
||||
class MyApp extends App<any> {
|
||||
static async getInitialProps({ Component, ctx }: AppContext) {
|
||||
const store = initializeStore();
|
||||
ctx.store = store;
|
||||
|
||||
let pageProps = {};
|
||||
if (Component.getInitialProps) {
|
||||
pageProps = await Component.getInitialProps(ctx);
|
||||
}
|
||||
|
||||
const token =
|
||||
ctx.req && (ctx.req as any).cookies && (ctx.req as any).cookies.token;
|
||||
const tokenPayload: TokenPayload = token ? decode(token) : null;
|
||||
|
||||
if (tokenPayload) {
|
||||
store.dispatch.auth.add(tokenPayload);
|
||||
}
|
||||
|
||||
return { pageProps, tokenPayload, initialState: store.getState() };
|
||||
}
|
||||
|
||||
store: ReturnType<typeof initializeStore>;
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.store = initializeStore(props.initialState);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { loading, auth } = this.store.dispatch;
|
||||
const token = cookie.get("token");
|
||||
const isVerifyEmailPage =
|
||||
typeof window !== "undefined" &&
|
||||
window.location.pathname.includes("verify-email");
|
||||
|
||||
if (token && !isVerifyEmailPage) {
|
||||
auth.renew().catch(() => {
|
||||
auth.logout();
|
||||
});
|
||||
}
|
||||
|
||||
Router.events.on("routeChangeStart", () => loading.show());
|
||||
Router.events.on("routeChangeComplete", () => {
|
||||
loading.hide();
|
||||
});
|
||||
Router.events.on("routeChangeError", () => loading.hide());
|
||||
}
|
||||
|
||||
render() {
|
||||
const { Component, pageProps } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>
|
||||
{publicRuntimeConfig.SITE_NAME} | Modern Open Source URL shortener.
|
||||
</title>
|
||||
</Head>
|
||||
<StoreProvider store={this.store}>
|
||||
<Component {...pageProps} />
|
||||
</StoreProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MyApp;
|
@ -1,110 +0,0 @@
|
||||
import Document, { Head, Main, NextScript } from "next/document";
|
||||
import { ServerStyleSheet } from "styled-components";
|
||||
import getConfig from "next/config";
|
||||
import React from "react";
|
||||
|
||||
import { Colors } from "../consts";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
interface Props {
|
||||
styleTags: any;
|
||||
}
|
||||
|
||||
class AppDocument extends Document<Props> {
|
||||
static async getInitialProps(ctx) {
|
||||
const initialProps = await Document.getInitialProps(ctx);
|
||||
const sheet = new ServerStyleSheet();
|
||||
const page = ctx.renderPage(
|
||||
(App) => (props) => sheet.collectStyles(<App {...props} />)
|
||||
);
|
||||
const styleTags = sheet.getStyleElement();
|
||||
return { ...initialProps, ...page, styleTags };
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<html lang="en">
|
||||
<Head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
content={`${publicRuntimeConfig.SITE_NAME} is a free and open source URL shortener with custom domains and stats.`}
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Nunito:300,400,700&display=optional"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="icon" sizes="196x196" href="/images/favicon-196x196.png" />
|
||||
<link rel="icon" sizes="32x32" href="/images/favicon-32x32.png" />
|
||||
<link rel="icon" sizes="16x16" href="/images/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" href="/images/favicon-196x196.png" />
|
||||
<link rel="mask-icon" href="/images/icon.svg" color="blue" />
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#f3f3f3" />
|
||||
|
||||
<meta property="fb:app_id" content="123456789" />
|
||||
<meta
|
||||
property="og:url"
|
||||
content={`https://${publicRuntimeConfig.DEFAULT_DOMAIN}`}
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={publicRuntimeConfig.SITE_NAME} />
|
||||
<meta
|
||||
property="og:image"
|
||||
content={`https://${publicRuntimeConfig.DEFAULT_DOMAIN}/images/card.png`}
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Free & Open Source Modern URL Shortener"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:url"
|
||||
content={`https://${publicRuntimeConfig.DEFAULT_DOMAIN}`}
|
||||
/>
|
||||
<meta name="twitter:title" content={publicRuntimeConfig.SITE_NAME} />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Free & Open Source Modern URL Shortener"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content={`https://${publicRuntimeConfig.DEFAULT_DOMAIN}/images/card.png`}
|
||||
/>
|
||||
|
||||
{this.props.styleTags}
|
||||
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.recaptchaCallback = function() { window.isCaptchaReady = true; }`
|
||||
}}
|
||||
/>
|
||||
|
||||
<script
|
||||
src="https://www.google.com/recaptcha/api.js?render=explicit"
|
||||
async
|
||||
defer
|
||||
/>
|
||||
</Head>
|
||||
<body
|
||||
style={{
|
||||
margin: 0,
|
||||
backgroundColor: Colors.Bg,
|
||||
font: '16px/1.45 "Nunito", sans-serif',
|
||||
overflowX: "hidden",
|
||||
color: Colors.Text
|
||||
}}
|
||||
>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AppDocument;
|
@ -1,37 +0,0 @@
|
||||
import getConfig from "next/config";
|
||||
import React from "react";
|
||||
|
||||
import AppWrapper from "../components/AppWrapper";
|
||||
import { H2, H4, Span } from "../components/Text";
|
||||
import Footer from "../components/Footer";
|
||||
import ALink from "../components/ALink";
|
||||
import { Col } from "../components/Layout";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const BannedPage = () => {
|
||||
return (
|
||||
<AppWrapper>
|
||||
<Col flex="1 1 100%" alignItems="center">
|
||||
<H2 textAlign="center" my={3} normal>
|
||||
Link has been banned and removed because of{" "}
|
||||
<Span style={{ borderBottom: "1px dotted rgba(0, 0, 0, 0.4)" }} bold>
|
||||
malware or scam
|
||||
</Span>
|
||||
.
|
||||
</H2>
|
||||
<H4 textAlign="center" normal>
|
||||
If you noticed a malware/scam link shortened by{" "}
|
||||
{publicRuntimeConfig.SITE_NAME},{" "}
|
||||
<ALink href="/report" title="Send report" isNextLink>
|
||||
send us a report
|
||||
</ALink>
|
||||
.
|
||||
</H4>
|
||||
</Col>
|
||||
<Footer />
|
||||
</AppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default BannedPage;
|
@ -1,38 +0,0 @@
|
||||
import React from "react";
|
||||
import Router from "next/router";
|
||||
|
||||
import { DISALLOW_ANONYMOUS_LINKS } from "../consts";
|
||||
import NeedToLogin from "../components/NeedToLogin";
|
||||
import Extensions from "../components/Extensions";
|
||||
import LinksTable from "../components/LinksTable";
|
||||
import AppWrapper from "../components/AppWrapper";
|
||||
import Shortener from "../components/Shortener";
|
||||
import Features from "../components/Features";
|
||||
import Footer from "../components/Footer";
|
||||
import { useStoreState } from "../store";
|
||||
|
||||
const Homepage = () => {
|
||||
const isAuthenticated = useStoreState(s => s.auth.isAuthenticated);
|
||||
|
||||
if (
|
||||
!isAuthenticated &&
|
||||
DISALLOW_ANONYMOUS_LINKS &&
|
||||
typeof window !== "undefined"
|
||||
) {
|
||||
Router.push("/login");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppWrapper>
|
||||
<Shortener />
|
||||
{!isAuthenticated && <NeedToLogin />}
|
||||
{isAuthenticated && <LinksTable />}
|
||||
<Features />
|
||||
<Extensions />
|
||||
<Footer />
|
||||
</AppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Homepage;
|
@ -1,185 +0,0 @@
|
||||
import { useFormState } from "react-use-form-state";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import emailValidator from "email-validator";
|
||||
import styled from "styled-components";
|
||||
import Router from "next/router";
|
||||
import axios from "axios";
|
||||
|
||||
import { useStoreState, useStoreActions } from "../store";
|
||||
import { APIv2, DISALLOW_REGISTRATION } from "../consts";
|
||||
import { ColCenterV } from "../components/Layout";
|
||||
import AppWrapper from "../components/AppWrapper";
|
||||
import { TextInput } from "../components/Input";
|
||||
import { fadeIn } from "../helpers/animations";
|
||||
import { Button } from "../components/Button";
|
||||
import Text, { H2 } from "../components/Text";
|
||||
import ALink from "../components/ALink";
|
||||
import Icon from "../components/Icon";
|
||||
|
||||
const LoginForm = styled(Flex).attrs({
|
||||
as: "form",
|
||||
flexDirection: "column"
|
||||
})`
|
||||
animation: ${fadeIn} 0.8s ease-out;
|
||||
`;
|
||||
|
||||
const Email = styled.span`
|
||||
font-weight: normal;
|
||||
color: #512da8;
|
||||
border-bottom: 1px dotted #999;
|
||||
`;
|
||||
|
||||
const LoginPage = () => {
|
||||
const { isAuthenticated } = useStoreState((s) => s.auth);
|
||||
const login = useStoreActions((s) => s.auth.login);
|
||||
const [error, setError] = useState("");
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [loading, setLoading] = useState({ login: false, signup: false });
|
||||
const [formState, { email, password, label }] = useFormState<{
|
||||
email: string;
|
||||
password: string;
|
||||
}>(null, { withIds: true });
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) Router.push("/");
|
||||
}, [isAuthenticated]);
|
||||
|
||||
function onSubmit(type: "login" | "signup") {
|
||||
return async (e) => {
|
||||
e.preventDefault();
|
||||
const { email, password } = formState.values;
|
||||
|
||||
if (loading.login || loading.signup) return null;
|
||||
|
||||
if (!email) {
|
||||
return setError("Email address must not be empty.");
|
||||
}
|
||||
|
||||
if (!emailValidator.validate(email)) {
|
||||
return setError("Email address is not valid.");
|
||||
}
|
||||
|
||||
if (password.trim().length < 8) {
|
||||
return setError("Password must be at least 8 chars long.");
|
||||
}
|
||||
|
||||
setError("");
|
||||
|
||||
if (type === "login") {
|
||||
setLoading((s) => ({ ...s, login: true }));
|
||||
try {
|
||||
await login(formState.values);
|
||||
Router.push("/");
|
||||
} catch (error) {
|
||||
setError(error.response.data.error);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "signup" && !DISALLOW_REGISTRATION) {
|
||||
setLoading((s) => ({ ...s, signup: true }));
|
||||
try {
|
||||
await axios.post(APIv2.AuthSignup, { email, password });
|
||||
setVerifying(true);
|
||||
} catch (error) {
|
||||
setError(error.response.data.error);
|
||||
}
|
||||
}
|
||||
|
||||
setLoading({ login: false, signup: false });
|
||||
};
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppWrapper>
|
||||
<ColCenterV maxWidth="100%" px={3} flex="0 0 auto" mt={4}>
|
||||
{verifying ? (
|
||||
<H2 textAlign="center" light>
|
||||
A verification email has been sent to{" "}
|
||||
<Email>{formState.values.email}</Email>.
|
||||
</H2>
|
||||
) : (
|
||||
<LoginForm id="login-form" onSubmit={onSubmit("login")}>
|
||||
<Text {...label("email")} as="label" mb={2} bold>
|
||||
Email address:
|
||||
</Text>
|
||||
<TextInput
|
||||
{...email("email")}
|
||||
placeholder="Email address..."
|
||||
height={[56, 64, 72]}
|
||||
fontSize={[15, 16]}
|
||||
px={[4, 40]}
|
||||
mb={[24, 4]}
|
||||
width={[300, 400]}
|
||||
maxWidth="100%"
|
||||
autoFocus
|
||||
/>
|
||||
<Text {...label("password")} as="label" mb={2} bold>
|
||||
Password{!DISALLOW_REGISTRATION ? " (min chars: 8)" : ""}:
|
||||
</Text>
|
||||
<TextInput
|
||||
{...password("password")}
|
||||
placeholder="Password..."
|
||||
px={[4, 40]}
|
||||
height={[56, 64, 72]}
|
||||
fontSize={[15, 16]}
|
||||
width={[300, 400]}
|
||||
maxWidth="100%"
|
||||
mb={[24, 4]}
|
||||
/>
|
||||
<Flex justifyContent="center">
|
||||
<Button
|
||||
flex="1 1 auto"
|
||||
mr={!DISALLOW_REGISTRATION ? ["8px", 16] : 0}
|
||||
height={[44, 56]}
|
||||
onClick={onSubmit("login")}
|
||||
>
|
||||
<Icon
|
||||
name={loading.login ? "spinner" : "login"}
|
||||
stroke="white"
|
||||
mr={2}
|
||||
/>
|
||||
Log in
|
||||
</Button>
|
||||
{!DISALLOW_REGISTRATION && (
|
||||
<Button
|
||||
flex="1 1 auto"
|
||||
ml={["8px", 16]}
|
||||
height={[44, 56]}
|
||||
color="purple"
|
||||
onClick={onSubmit("signup")}
|
||||
>
|
||||
<Icon
|
||||
name={loading.signup ? "spinner" : "signup"}
|
||||
stroke="white"
|
||||
mr={2}
|
||||
/>
|
||||
Sign up
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
<ALink
|
||||
href="/reset-password"
|
||||
title="Forget password"
|
||||
fontSize={14}
|
||||
alignSelf="flex-start"
|
||||
my={16}
|
||||
isNextLink
|
||||
>
|
||||
Forgot your password?
|
||||
</ALink>
|
||||
<Text color="red" mt={1} normal>
|
||||
{error}
|
||||
</Text>
|
||||
</LoginForm>
|
||||
)}
|
||||
</ColCenterV>
|
||||
</AppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
@ -1,19 +0,0 @@
|
||||
import React, { FC, useEffect } from "react";
|
||||
import Router from "next/router";
|
||||
|
||||
import { useStoreActions } from "../store";
|
||||
|
||||
const LogoutPage: FC = () => {
|
||||
const logout = useStoreActions((s) => s.auth.logout);
|
||||
const reset = useStoreActions((s) => s.reset);
|
||||
|
||||
useEffect(() => {
|
||||
logout();
|
||||
reset();
|
||||
Router.push("/");
|
||||
}, [logout, reset]);
|
||||
|
||||
return <div />;
|
||||
};
|
||||
|
||||
export default LogoutPage;
|
@ -1,98 +0,0 @@
|
||||
import { useFormState } from "react-use-form-state";
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import React, { useState } from "react";
|
||||
import { NextPage } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import axios from "axios";
|
||||
|
||||
import AppWrapper from "../../components/AppWrapper";
|
||||
import { TextInput } from "../../components/Input";
|
||||
import { Button } from "../../components/Button";
|
||||
import Text, { H2 } from "../../components/Text";
|
||||
import { Col } from "../../components/Layout";
|
||||
import Icon from "../../components/Icon";
|
||||
import { APIv2 } from "../../consts";
|
||||
|
||||
interface Props {
|
||||
protectedLink?: string;
|
||||
}
|
||||
|
||||
const ProtectedPage: NextPage<Props> = () => {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formState, { password }] = useFormState<{ password: string }>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const { password } = formState.values;
|
||||
|
||||
if (!password) {
|
||||
return setError("Password must not be empty.");
|
||||
}
|
||||
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await axios.post(
|
||||
`${APIv2.Links}/${router.query.id}/protected`,
|
||||
{
|
||||
password
|
||||
}
|
||||
);
|
||||
window.location.replace(data.target);
|
||||
} catch ({ response }) {
|
||||
setError(response.data.error);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppWrapper>
|
||||
{!router.query.id ? (
|
||||
<H2 my={4} light>
|
||||
404 | Link could not be found.
|
||||
</H2>
|
||||
) : (
|
||||
<Col width={500} maxWidth="97%">
|
||||
<H2 my={3} bold>
|
||||
Protected link
|
||||
</H2>
|
||||
<Text mb={4}>Enter the password to be redirected to the link.</Text>
|
||||
<Flex
|
||||
as="form"
|
||||
alignItems="center"
|
||||
onSubmit={onSubmit}
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
<TextInput
|
||||
{...password("password")}
|
||||
placeholder="Password"
|
||||
autocomplete="off"
|
||||
height={[44, 54]}
|
||||
width={[1, 1 / 2]}
|
||||
mr={3}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<Button type="submit" height={[40, 44]}>
|
||||
{loading && <Icon name={"spinner"} stroke="white" mr={2} />}
|
||||
Go
|
||||
</Button>
|
||||
</Flex>
|
||||
<Text fontSize={14} color="red" mt={3} normal>
|
||||
{error}
|
||||
</Text>
|
||||
</Col>
|
||||
)}
|
||||
</AppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
ProtectedPage.getInitialProps = async ({ req }) => {
|
||||
return {
|
||||
protectedLink: req && (req as any).protectedLink
|
||||
};
|
||||
};
|
||||
|
||||
export default ProtectedPage;
|
@ -1,84 +0,0 @@
|
||||
import { useFormState } from "react-use-form-state";
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import React, { useState } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
import Text, { H2, Span } from "../components/Text";
|
||||
import AppWrapper from "../components/AppWrapper";
|
||||
import { TextInput } from "../components/Input";
|
||||
import { Button } from "../components/Button";
|
||||
import { Col } from "../components/Layout";
|
||||
import Icon from "../components/Icon";
|
||||
import { useMessage } from "../hooks";
|
||||
import { APIv2 } from "../consts";
|
||||
|
||||
import getConfig from "next/config";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const ReportPage = () => {
|
||||
const [formState, { text }] = useFormState<{ url: string }>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useMessage(5000);
|
||||
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setMessage();
|
||||
try {
|
||||
await axios.post(`${APIv2.Links}/report`, { link: formState.values.url });
|
||||
setMessage("Thanks for the report, we'll take actions shortly.", "green");
|
||||
formState.clear();
|
||||
} catch (error) {
|
||||
setMessage(error?.response?.data?.error || "Couldn't send report.");
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppWrapper>
|
||||
<Col width={600} maxWidth="97%" alignItems="flex-start">
|
||||
<H2 my={3} bold>
|
||||
Report abuse
|
||||
</H2>
|
||||
<Text mb={3}>
|
||||
Report abuses, malware and phishing links to the below email address
|
||||
or use the form. We will take actions shortly.
|
||||
</Text>
|
||||
<Text mb={4}>
|
||||
{(publicRuntimeConfig.REPORT_EMAIL || "").replace("@", "[at]")}
|
||||
</Text>
|
||||
<Text mb={3}>
|
||||
<Span bold>URL containing malware/scam:</Span>
|
||||
</Text>
|
||||
<Flex
|
||||
as="form"
|
||||
flexDirection={["column", "row"]}
|
||||
alignItems={["flex-start", "center"]}
|
||||
justifyContent="flex-start"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<TextInput
|
||||
{...text("url")}
|
||||
placeholder={`${publicRuntimeConfig.DEFAULT_DOMAIN}/example`}
|
||||
height={[44, 54]}
|
||||
width={[1, 1 / 2]}
|
||||
flex="0 0 auto"
|
||||
mr={3}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" flex="0 0 auto" height={[40, 44]} mt={[3, 0]}>
|
||||
{loading && <Icon name={"spinner"} stroke="white" mr={2} />}
|
||||
Send report
|
||||
</Button>
|
||||
</Flex>
|
||||
<Text fontSize={14} mt={3} color={message.color}>
|
||||
{message.text}
|
||||
</Text>
|
||||
</Col>
|
||||
</AppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportPage;
|
@ -1,110 +0,0 @@
|
||||
import { useFormState } from "react-use-form-state";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import Router from "next/router";
|
||||
import decode from "jwt-decode";
|
||||
import { NextPage } from "next";
|
||||
import cookie from "js-cookie";
|
||||
import axios from "axios";
|
||||
|
||||
import { useStoreState, useStoreActions } from "../store";
|
||||
import AppWrapper from "../components/AppWrapper";
|
||||
import { TextInput } from "../components/Input";
|
||||
import { Button } from "../components/Button";
|
||||
import Text, { H2 } from "../components/Text";
|
||||
import { Col } from "../components/Layout";
|
||||
import { TokenPayload } from "../types";
|
||||
import { useMessage } from "../hooks";
|
||||
import Icon from "../components/Icon";
|
||||
import { APIv2 } from "../consts";
|
||||
|
||||
interface Props {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
const ResetPassword: NextPage<Props> = ({ token }) => {
|
||||
const auth = useStoreState((s) => s.auth);
|
||||
const addAuth = useStoreActions((s) => s.auth.add);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useMessage();
|
||||
const [formState, { email, label }] = useFormState<{ email: string }>(null, {
|
||||
withIds: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
Router.push("/settings");
|
||||
}
|
||||
|
||||
if (token) {
|
||||
cookie.set("token", token, { expires: 7 });
|
||||
const decoded: TokenPayload = decode(token);
|
||||
addAuth(decoded);
|
||||
Router.push("/settings");
|
||||
}
|
||||
}, [auth, token, addAuth]);
|
||||
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!formState.validity.email) return;
|
||||
|
||||
setLoading(true);
|
||||
setMessage();
|
||||
try {
|
||||
await axios.post(APIv2.AuthResetPassword, {
|
||||
email: formState.values.email
|
||||
});
|
||||
setMessage("Reset password email has been sent.", "green");
|
||||
} catch (error) {
|
||||
setMessage(error?.response?.data?.error || "Couldn't reset password.");
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// FIXME: make a container for width
|
||||
return (
|
||||
<AppWrapper>
|
||||
<Col width={600} maxWidth="100%" px={3}>
|
||||
<H2 my={3} bold>
|
||||
Reset password
|
||||
</H2>
|
||||
<Text mb={4}>
|
||||
If you forgot you password you can use the form below to get reset
|
||||
password link.
|
||||
</Text>
|
||||
<Text {...label("homepage")} as="label" mt={2} fontSize={[15, 16]} bold>
|
||||
Email address
|
||||
</Text>
|
||||
<Flex
|
||||
as="form"
|
||||
alignItems="center"
|
||||
justifyContent="flex-start"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<TextInput
|
||||
{...email("email")}
|
||||
placeholder="Email address..."
|
||||
height={[44, 54]}
|
||||
width={[1, 1 / 2]}
|
||||
mr={3}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<Button type="submit" height={[40, 44]} my={3}>
|
||||
{loading && <Icon name={"spinner"} stroke="white" mr={2} />}
|
||||
Reset password
|
||||
</Button>
|
||||
</Flex>
|
||||
<Text fontSize={14} color={message.color} mt={2} normal>
|
||||
{message.text}
|
||||
</Text>
|
||||
</Col>
|
||||
</AppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
ResetPassword.getInitialProps = async (ctx) => {
|
||||
return { token: ctx.req && (ctx.req as any).token };
|
||||
};
|
||||
|
||||
export default ResetPassword;
|
@ -1,45 +0,0 @@
|
||||
import { NextPage } from "next";
|
||||
import React from "react";
|
||||
|
||||
import SettingsDeleteAccount from "../components/Settings/SettingsDeleteAccount";
|
||||
import SettingsChangeEmail from "../components/Settings/SettingsChangeEmail";
|
||||
import SettingsPassword from "../components/Settings/SettingsPassword";
|
||||
import SettingsDomain from "../components/Settings/SettingsDomain";
|
||||
import SettingsApi from "../components/Settings/SettingsApi";
|
||||
import AppWrapper from "../components/AppWrapper";
|
||||
import { H1, Span } from "../components/Text";
|
||||
import Divider from "../components/Divider";
|
||||
import { Col } from "../components/Layout";
|
||||
import Footer from "../components/Footer";
|
||||
import { useStoreState } from "../store";
|
||||
|
||||
const SettingsPage: NextPage = () => {
|
||||
const email = useStoreState(s => s.auth.email);
|
||||
|
||||
return (
|
||||
<AppWrapper>
|
||||
<Col width={600} maxWidth="90%" alignItems="flex-start" pb={80} mt={4}>
|
||||
<H1 alignItems="center" fontSize={[24, 28]} light>
|
||||
Welcome,{" "}
|
||||
<Span pb="2px" style={{ borderBottom: "2px dotted #999" }}>
|
||||
{email}
|
||||
</Span>
|
||||
.
|
||||
</H1>
|
||||
<Divider mt={4} mb={48} />
|
||||
<SettingsDomain />
|
||||
<Divider mt={4} mb={48} />
|
||||
<SettingsApi />
|
||||
<Divider mt={4} mb={48} />
|
||||
<SettingsPassword />
|
||||
<Divider mt={4} mb={48} />
|
||||
<SettingsChangeEmail />
|
||||
<Divider mt={4} mb={48} />
|
||||
<SettingsDeleteAccount />
|
||||
</Col>
|
||||
<Footer />
|
||||
</AppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
@ -1,207 +0,0 @@
|
||||
import { Box, Flex } from "rebass/styled-components";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import formatDate from "date-fns/format";
|
||||
import { NextPage } from "next";
|
||||
import axios from "axios";
|
||||
|
||||
import Text, { H1, H2, H4, Span } from "../components/Text";
|
||||
import { getAxiosConfig, removeProtocol } from "../utils";
|
||||
import { Button, NavButton } from "../components/Button";
|
||||
import { Col, RowCenterV } from "../components/Layout";
|
||||
import { Area, Bar, Pie, Map } from "../components/Charts";
|
||||
import PageLoading from "../components/PageLoading";
|
||||
import AppWrapper from "../components/AppWrapper";
|
||||
import Divider from "../components/Divider";
|
||||
import { APIv2, Colors } from "../consts";
|
||||
import { useStoreState } from "../store";
|
||||
import ALink from "../components/ALink";
|
||||
import Icon from "../components/Icon";
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const StatsPage: NextPage<Props> = ({ id }) => {
|
||||
const { isAuthenticated } = useStoreState((s) => s.auth);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [data, setData] = useState<Record<string, any> | undefined>();
|
||||
const [period, setPeriod] = useState("lastDay");
|
||||
|
||||
const stats = data && data[period];
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !isAuthenticated) return;
|
||||
axios
|
||||
.get(`${APIv2.Links}/${id}/stats`, getAxiosConfig())
|
||||
.then(({ data }) => {
|
||||
setLoading(false);
|
||||
setError(!data);
|
||||
setData(data);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
setError(true);
|
||||
});
|
||||
}, [id, isAuthenticated]);
|
||||
|
||||
let errorMessage;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
errorMessage = (
|
||||
<Flex mt={3}>
|
||||
<Icon name="x" size={32} mr={3} stroke={Colors.TrashIcon} />
|
||||
<H2>You need to login to view stats.</H2>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (!id || error) {
|
||||
errorMessage = (
|
||||
<Flex mt={3}>
|
||||
<Icon name="x" size={32} mr={3} stroke={Colors.TrashIcon} />
|
||||
<H2>Couldn't get stats.</H2>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const loader = loading && <PageLoading />;
|
||||
|
||||
const total = stats && stats.views.reduce((sum, view) => sum + view, 0);
|
||||
const periodText = period.includes("last")
|
||||
? `the last ${period.replace("last", "").toLocaleLowerCase()}`
|
||||
: "all time";
|
||||
|
||||
return (
|
||||
<AppWrapper>
|
||||
{errorMessage ||
|
||||
loader ||
|
||||
(data && (
|
||||
<Col width={1200} maxWidth="95%" alignItems="stretch" m="40px 0">
|
||||
<Flex justifyContent="space-between" alignItems="center" mb={3}>
|
||||
<H1 fontSize={[18, 20, 24]} light>
|
||||
Stats for:{" "}
|
||||
<ALink href={data.link} title="Short link">
|
||||
{removeProtocol(data.link)}
|
||||
</ALink>
|
||||
</H1>
|
||||
<Text fontSize={[13, 14]} textAlign="right">
|
||||
{data.target.length > 80
|
||||
? `${data.target.split("").slice(0, 80).join("")}...`
|
||||
: data.target}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Col
|
||||
backgroundColor="white"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
boxShadow: "0 6px 15px hsla(200, 20%, 70%, 0.3)",
|
||||
overflow: "hidden"
|
||||
}}
|
||||
>
|
||||
<RowCenterV
|
||||
flex="1 1 auto"
|
||||
backgroundColor={Colors.TableHeadBg}
|
||||
justifyContent="space-between"
|
||||
py={[3, 3, 24]}
|
||||
px={[3, 4]}
|
||||
>
|
||||
<H4>
|
||||
Total clicks: <Span bold>{data.total}</Span>
|
||||
</H4>
|
||||
<Flex>
|
||||
{[
|
||||
["allTime", "All Time"],
|
||||
["lastMonth", "Month"],
|
||||
["lastWeek", "Week"],
|
||||
["lastDay", "Day"]
|
||||
].map(([p, n]) => (
|
||||
<NavButton
|
||||
ml={10}
|
||||
disabled={p === period}
|
||||
onClick={() => setPeriod(p as any)}
|
||||
key={p}
|
||||
>
|
||||
{n}
|
||||
</NavButton>
|
||||
))}
|
||||
</Flex>
|
||||
</RowCenterV>
|
||||
<Col p={[3, 4]}>
|
||||
<H2 mb={2} light>
|
||||
<Span
|
||||
style={{
|
||||
borderBottom: `1px dotted ${Colors.StatsTotalUnderline}`
|
||||
}}
|
||||
bold
|
||||
>
|
||||
{total}
|
||||
</Span>{" "}
|
||||
tracked clicks in {periodText}.
|
||||
</H2>
|
||||
<Text fontSize={[13, 14]} color={Colors.StatsLastUpdateText}>
|
||||
Last update in{" "}
|
||||
{formatDate(new Date(data.updatedAt), "hh:mm aa")}
|
||||
</Text>
|
||||
<Flex width={1} mt={4}>
|
||||
<Area data={stats.views} period={period} />
|
||||
</Flex>
|
||||
{total > 0 && (
|
||||
<>
|
||||
<Divider my={4} />
|
||||
<Flex width={1}>
|
||||
<Col flex="1 1 0">
|
||||
<H2 mb={3} light>
|
||||
Referrals.
|
||||
</H2>
|
||||
<Pie data={stats.stats.referrer} />
|
||||
</Col>
|
||||
<Col flex="1 1 0">
|
||||
<H2 mb={3} light>
|
||||
Browsers.
|
||||
</H2>
|
||||
<Bar data={stats.stats.browser} />
|
||||
</Col>
|
||||
</Flex>
|
||||
<Divider my={4} />
|
||||
<Flex width={1}>
|
||||
<Col flex="1 1 0">
|
||||
<H2 mb={3} light>
|
||||
Country.
|
||||
</H2>
|
||||
<Map data={stats.stats.country} />
|
||||
</Col>
|
||||
<Col flex="1 1 0">
|
||||
<H2 mb={3} light>
|
||||
OS.
|
||||
</H2>
|
||||
<Bar data={stats.stats.os} />
|
||||
</Col>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
</Col>
|
||||
<Box alignSelf="center" my={64}>
|
||||
<ALink href="/" title="Back to homepage" forButton isNextLink>
|
||||
<Button>
|
||||
<Icon name="arrowLeft" stroke="white" mr={2} />
|
||||
Back to homepage
|
||||
</Button>
|
||||
</ALink>
|
||||
</Box>
|
||||
</Col>
|
||||
))}
|
||||
</AppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
StatsPage.getInitialProps = ({ query }) => {
|
||||
return Promise.resolve(query);
|
||||
};
|
||||
|
||||
StatsPage.defaultProps = {
|
||||
id: ""
|
||||
};
|
||||
|
||||
export default StatsPage;
|
@ -1,66 +0,0 @@
|
||||
import getConfig from "next/config";
|
||||
import React from "react";
|
||||
|
||||
import AppWrapper from "../components/AppWrapper";
|
||||
import { Col } from "../components/Layout";
|
||||
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const TermsPage = () => (
|
||||
<AppWrapper>
|
||||
{/* TODO: better container */}
|
||||
<Col width={600} maxWidth="97%" alignItems="flex-start">
|
||||
<h3>{publicRuntimeConfig.SITE_NAME} Terms of Service</h3>
|
||||
<p>
|
||||
By accessing the website at{" "}
|
||||
<a href={`https://${publicRuntimeConfig.DEFAULT_DOMAIN}`}>
|
||||
https://{publicRuntimeConfig.DEFAULT_DOMAIN}
|
||||
</a>
|
||||
, you are agreeing to be bound by these terms of service, all applicable
|
||||
laws and regulations, and agree that you are responsible for compliance
|
||||
with any applicable local laws. If you do not agree with any of these
|
||||
terms, you are prohibited from using or accessing this site. The
|
||||
materials contained in this website are protected by applicable
|
||||
copyright and trademark law.
|
||||
</p>
|
||||
<p>
|
||||
In no event shall {publicRuntimeConfig.SITE_NAME} or its suppliers be
|
||||
liable for any damages (including, without limitation, damages for loss
|
||||
of data or profit, or due to business interruption) arising out of the
|
||||
use or inability to use the materials on{" "}
|
||||
{publicRuntimeConfig.DEFAULT_DOMAIN} website, even if{" "}
|
||||
{publicRuntimeConfig.SITE_NAME} or a {publicRuntimeConfig.SITE_NAME}{" "}
|
||||
authorized representative has been notified orally or in writing of the
|
||||
possibility of such damage. Because some jurisdictions do not allow
|
||||
limitations on implied warranties, or limitations of liability for
|
||||
consequential or incidental damages, these limitations may not apply to
|
||||
you.
|
||||
</p>
|
||||
<p>
|
||||
The materials appearing on {publicRuntimeConfig.SITE_NAME} website could
|
||||
include technical, typographical, or photographic errors.{" "}
|
||||
{publicRuntimeConfig.SITE_NAME} does not warrant that any of the
|
||||
materials on its website are accurate, complete or current.{" "}
|
||||
{publicRuntimeConfig.SITE_NAME} may make changes to the materials
|
||||
contained on its website at any time without notice. However{" "}
|
||||
{publicRuntimeConfig.SITE_NAME} does not make any commitment to update
|
||||
the materials.
|
||||
</p>
|
||||
<p>
|
||||
{publicRuntimeConfig.SITE_NAME} has not reviewed all of the sites linked
|
||||
to its website and is not responsible for the contents of any such
|
||||
linked site. The inclusion of any link does not imply endorsement by{" "}
|
||||
{publicRuntimeConfig.SITE_NAME} of the site. Use of any such linked
|
||||
website is at the {"user's"} own risk.
|
||||
</p>
|
||||
<p>
|
||||
{publicRuntimeConfig.SITE_NAME} may revise these terms of service for
|
||||
its website at any time without notice. By using this website you are
|
||||
agreeing to be bound by the then current version of these terms of
|
||||
service.
|
||||
</p>
|
||||
</Col>
|
||||
</AppWrapper>
|
||||
);
|
||||
|
||||
export default TermsPage;
|
@ -1,32 +0,0 @@
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
|
||||
import AppWrapper from "../components/AppWrapper";
|
||||
import Footer from "../components/Footer";
|
||||
import { H2, H4 } from "../components/Text";
|
||||
import { Col } from "../components/Layout";
|
||||
|
||||
const UrlInfoPage = () => {
|
||||
const { query } = useRouter();
|
||||
return (
|
||||
<AppWrapper>
|
||||
{!query.target ? (
|
||||
<H2 my={4} light>
|
||||
404 | Link could not be found.
|
||||
</H2>
|
||||
) : (
|
||||
<>
|
||||
<Col flex="1 1 100%" alignItems="center">
|
||||
<H2 my={3} light>
|
||||
Target:
|
||||
</H2>
|
||||
<H4 bold>{query.target}</H4>
|
||||
</Col>
|
||||
<Footer />
|
||||
</>
|
||||
)}
|
||||
</AppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default UrlInfoPage;
|
@ -1,55 +0,0 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import decode from "jwt-decode";
|
||||
import { NextPage } from "next";
|
||||
import cookie from "js-cookie";
|
||||
|
||||
import { useStoreActions } from "../store";
|
||||
import AppWrapper from "../components/AppWrapper";
|
||||
import { H2 } from "../components/Text";
|
||||
import { TokenPayload } from "../types";
|
||||
import Icon from "../components/Icon";
|
||||
import { Colors } from "../consts";
|
||||
import Footer from "../components/Footer";
|
||||
|
||||
interface Props {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
const VerifyEmail: NextPage<Props> = ({ token }) => {
|
||||
const addAuth = useStoreActions((s) => s.auth.add);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
cookie.set("token", token, { expires: 7 });
|
||||
const decoded: TokenPayload = decode(token);
|
||||
addAuth(decoded);
|
||||
}
|
||||
}, [addAuth, token]);
|
||||
|
||||
return (
|
||||
<AppWrapper>
|
||||
<Flex flex="1 1 100%" justifyContent="center" mt={4}>
|
||||
<Icon
|
||||
name={token ? "check" : "x"}
|
||||
size={26}
|
||||
stroke={token ? Colors.CheckIcon : Colors.TrashIcon}
|
||||
mr={3}
|
||||
mt={1}
|
||||
/>
|
||||
<H2 textAlign="center" normal>
|
||||
{token
|
||||
? "Email address verified successfully."
|
||||
: "Couldn't verify the email address."}
|
||||
</H2>
|
||||
</Flex>
|
||||
<Footer />
|
||||
</AppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
VerifyEmail.getInitialProps = async (ctx) => {
|
||||
return { token: (ctx?.req as any)?.token };
|
||||
};
|
||||
|
||||
export default VerifyEmail;
|
@ -1,84 +0,0 @@
|
||||
import { Flex } from "rebass/styled-components";
|
||||
import React, { useEffect } from "react";
|
||||
import styled from "styled-components";
|
||||
import decode from "jwt-decode";
|
||||
import cookie from "js-cookie";
|
||||
|
||||
import AppWrapper from "../components/AppWrapper";
|
||||
import { Button } from "../components/Button";
|
||||
import { useStoreActions } from "../store";
|
||||
import { Col } from "../components/Layout";
|
||||
import { TokenPayload } from "../types";
|
||||
import Icon from "../components/Icon";
|
||||
import { NextPage } from "next";
|
||||
import { Colors } from "../consts";
|
||||
import ALink from "../components/ALink";
|
||||
|
||||
interface Props {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
const MessageWrapper = styled(Flex).attrs({
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
my: 32
|
||||
})``;
|
||||
|
||||
const Message = styled.p`
|
||||
font-size: 24px;
|
||||
font-weight: 300;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
font-size: 18px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Verify: NextPage<Props> = ({ token }) => {
|
||||
const addAuth = useStoreActions((s) => s.auth.add);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
cookie.set("token", token, { expires: 7 });
|
||||
const payload: TokenPayload = decode(token);
|
||||
addAuth(payload);
|
||||
}
|
||||
}, [token, addAuth]);
|
||||
|
||||
return (
|
||||
<AppWrapper>
|
||||
{token ? (
|
||||
<Col alignItems="center">
|
||||
<MessageWrapper>
|
||||
<Icon name="check" size={32} mr={3} stroke={Colors.CheckIcon} />
|
||||
<Message>Your account has been verified successfully!</Message>
|
||||
</MessageWrapper>
|
||||
<ALink href="/" forButton isNextLink>
|
||||
<Button>
|
||||
<Icon name="arrowLeft" stroke="white" mr={2} />
|
||||
Back to homepage
|
||||
</Button>
|
||||
</ALink>
|
||||
</Col>
|
||||
) : (
|
||||
<Col alignItems="center">
|
||||
<MessageWrapper>
|
||||
<Icon name="x" size={32} mr={3} stroke={Colors.TrashIcon} />
|
||||
<Message>Invalid verification.</Message>
|
||||
</MessageWrapper>
|
||||
<ALink href="/login" forButton isNextLink>
|
||||
<Button color="purple">
|
||||
<Icon name="arrowLeft" stroke="white" mr={2} />
|
||||
Back to signup
|
||||
</Button>
|
||||
</ALink>
|
||||
</Col>
|
||||
)}
|
||||
</AppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
Verify.getInitialProps = async ({ req }) => {
|
||||
return { token: req && (req as any).token }; // TODO: types bro
|
||||
};
|
||||
|
||||
export default Verify;
|
@ -1,51 +0,0 @@
|
||||
import { action, Action, thunk, Thunk, computed, Computed } from "easy-peasy";
|
||||
import decode from "jwt-decode";
|
||||
import cookie from "js-cookie";
|
||||
import axios from "axios";
|
||||
|
||||
import { TokenPayload } from "../types";
|
||||
import { APIv2 } from "../consts";
|
||||
import { getAxiosConfig } from "../utils";
|
||||
|
||||
export interface Auth {
|
||||
domain?: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
isAuthenticated: Computed<Auth, boolean>;
|
||||
add: Action<Auth, TokenPayload>;
|
||||
logout: Action<Auth>;
|
||||
login: Thunk<Auth, { email: string; password: string }>;
|
||||
renew: Thunk<Auth>;
|
||||
}
|
||||
|
||||
export const auth: Auth = {
|
||||
domain: null,
|
||||
email: null,
|
||||
isAdmin: false,
|
||||
isAuthenticated: computed(s => !!s.email),
|
||||
add: action((state, payload) => {
|
||||
state.domain = payload.domain;
|
||||
state.email = payload.sub;
|
||||
state.isAdmin = payload.admin;
|
||||
}),
|
||||
logout: action(state => {
|
||||
cookie.remove("token");
|
||||
state.domain = null;
|
||||
state.email = null;
|
||||
state.isAdmin = false;
|
||||
}),
|
||||
login: thunk(async (actions, payload) => {
|
||||
const res = await axios.post(APIv2.AuthLogin, payload);
|
||||
const { token } = res.data;
|
||||
cookie.set("token", token, { expires: 7 });
|
||||
const tokenPayload: TokenPayload = decode(token);
|
||||
actions.add(tokenPayload);
|
||||
}),
|
||||
renew: thunk(async actions => {
|
||||
const res = await axios.post(APIv2.AuthRenew, null, getAxiosConfig());
|
||||
const { token } = res.data;
|
||||
cookie.set("token", token, { expires: 7 });
|
||||
const tokenPayload: TokenPayload = decode(token);
|
||||
actions.add(tokenPayload);
|
||||
})
|
||||
};
|
@ -1 +0,0 @@
|
||||
export * from "./store";
|
@ -1,142 +0,0 @@
|
||||
import { action, Action, thunk, Thunk } from "easy-peasy";
|
||||
import axios from "axios";
|
||||
import query from "query-string";
|
||||
|
||||
import { getAxiosConfig } from "../utils";
|
||||
import { APIv2 } from "../consts";
|
||||
|
||||
export interface Link {
|
||||
id: string;
|
||||
address: string;
|
||||
banned: boolean;
|
||||
banned_by_id?: number;
|
||||
created_at: string;
|
||||
link: string;
|
||||
domain?: string;
|
||||
domain_id?: number;
|
||||
password?: string;
|
||||
description?: string;
|
||||
expire_in?: string;
|
||||
target: string;
|
||||
updated_at: string;
|
||||
user_id?: number;
|
||||
visit_count: number;
|
||||
}
|
||||
|
||||
export interface NewLink {
|
||||
target: string;
|
||||
customurl?: string;
|
||||
password?: string;
|
||||
domain?: string;
|
||||
reuse?: boolean;
|
||||
reCaptchaToken?: string;
|
||||
}
|
||||
|
||||
export interface BanLink {
|
||||
id: string;
|
||||
host?: boolean;
|
||||
domain?: boolean;
|
||||
user?: boolean;
|
||||
userLinks?: boolean;
|
||||
}
|
||||
|
||||
export interface EditLink {
|
||||
id: string;
|
||||
target: string;
|
||||
address: string;
|
||||
description?: string;
|
||||
expire_in?: string;
|
||||
}
|
||||
|
||||
export interface LinksQuery {
|
||||
limit: string;
|
||||
skip: string;
|
||||
search: string;
|
||||
all: boolean;
|
||||
}
|
||||
|
||||
export interface LinksListRes {
|
||||
data: Link[];
|
||||
total: number;
|
||||
limit: number;
|
||||
skip: number;
|
||||
}
|
||||
|
||||
export interface Links {
|
||||
link?: Link;
|
||||
items: Link[];
|
||||
total: number;
|
||||
loading: boolean;
|
||||
submit: Thunk<Links, NewLink>;
|
||||
get: Thunk<Links, LinksQuery>;
|
||||
add: Action<Links, Link>;
|
||||
set: Action<Links, LinksListRes>;
|
||||
update: Action<Links, Partial<Link>>;
|
||||
remove: Thunk<Links, string>;
|
||||
edit: Thunk<Links, EditLink>;
|
||||
ban: Thunk<Links, BanLink>;
|
||||
setLoading: Action<Links, boolean>;
|
||||
}
|
||||
|
||||
export const links: Links = {
|
||||
link: null,
|
||||
items: [],
|
||||
total: 0,
|
||||
loading: true,
|
||||
submit: thunk(async (actions, payload) => {
|
||||
const data = Object.fromEntries(
|
||||
Object.entries(payload).filter(([, value]) => value !== "")
|
||||
);
|
||||
const res = await axios.post(APIv2.Links, data, getAxiosConfig());
|
||||
actions.add(res.data);
|
||||
return res.data;
|
||||
}),
|
||||
get: thunk(async (actions, payload) => {
|
||||
actions.setLoading(true);
|
||||
const res = await axios.get(
|
||||
`${APIv2.Links}?${query.stringify(payload)}`,
|
||||
getAxiosConfig()
|
||||
);
|
||||
actions.set(res.data);
|
||||
actions.setLoading(false);
|
||||
return res.data;
|
||||
}),
|
||||
remove: thunk(async (actions, id) => {
|
||||
await axios.delete(`${APIv2.Links}/${id}`, getAxiosConfig());
|
||||
}),
|
||||
ban: thunk(async (actions, { id, ...payload }) => {
|
||||
const res = await axios.post(
|
||||
`${APIv2.Links}/admin/ban/${id}`,
|
||||
payload,
|
||||
getAxiosConfig()
|
||||
);
|
||||
actions.update({ id, banned: true });
|
||||
return res.data;
|
||||
}),
|
||||
edit: thunk(async (actions, { id, ...payload }) => {
|
||||
const res = await axios.patch(
|
||||
`${APIv2.Links}/${id}`,
|
||||
payload,
|
||||
getAxiosConfig()
|
||||
);
|
||||
actions.update(res.data);
|
||||
}),
|
||||
add: action((state, payload) => {
|
||||
if (state.items.length >= 10) {
|
||||
state.items.pop();
|
||||
}
|
||||
state.items.unshift(payload);
|
||||
}),
|
||||
set: action((state, payload) => {
|
||||
state.items = payload.data;
|
||||
state.total = payload.total;
|
||||
}),
|
||||
update: action((state, payload) => {
|
||||
state.items = state.items.map(item =>
|
||||
item.id === payload.id ? { ...item, ...payload } : item
|
||||
);
|
||||
}),
|
||||
setLoading: action((state, payload) => {
|
||||
state.loading = payload;
|
||||
})
|
||||
};
|
@ -1,17 +0,0 @@
|
||||
import { action, Action } from "easy-peasy";
|
||||
|
||||
export interface Loading {
|
||||
loading: boolean;
|
||||
show: Action<Loading>;
|
||||
hide: Action<Loading>;
|
||||
}
|
||||
|
||||
export const loading: Loading = {
|
||||
loading: false,
|
||||
show: action(state => {
|
||||
state.loading = true;
|
||||
}),
|
||||
hide: action(state => {
|
||||
state.loading = false;
|
||||
})
|
||||
};
|
@ -1,85 +0,0 @@
|
||||
import { action, Action, thunk, Thunk } from "easy-peasy";
|
||||
import axios from "axios";
|
||||
|
||||
import { getAxiosConfig } from "../utils";
|
||||
import { StoreModel } from "./store";
|
||||
import { APIv2 } from "../consts";
|
||||
|
||||
export interface Domain {
|
||||
id: string;
|
||||
address: string;
|
||||
banned: boolean;
|
||||
created_at: string;
|
||||
homepage?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface NewDomain {
|
||||
address: string;
|
||||
homepage?: string;
|
||||
}
|
||||
|
||||
export interface SettingsResp {
|
||||
apikey: string;
|
||||
email: string;
|
||||
domains: Domain[];
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
domains: Array<Domain>;
|
||||
apikey: string;
|
||||
email: string;
|
||||
fetched: boolean;
|
||||
setSettings: Action<Settings, SettingsResp>;
|
||||
getSettings: Thunk<Settings, null, null, StoreModel>;
|
||||
setApiKey: Action<Settings, string>;
|
||||
generateApiKey: Thunk<Settings>;
|
||||
addDomain: Action<Settings, Domain>;
|
||||
removeDomain: Action<Settings, string>;
|
||||
saveDomain: Thunk<Settings, NewDomain>;
|
||||
deleteDomain: Thunk<Settings, string>;
|
||||
}
|
||||
|
||||
export const settings: Settings = {
|
||||
domains: [],
|
||||
email: null,
|
||||
apikey: null,
|
||||
fetched: false,
|
||||
getSettings: thunk(async (actions, payload, { getStoreActions }) => {
|
||||
getStoreActions().loading.show();
|
||||
const res = await axios.get(APIv2.Users, getAxiosConfig());
|
||||
actions.setSettings(res.data);
|
||||
getStoreActions().loading.hide();
|
||||
}),
|
||||
generateApiKey: thunk(async actions => {
|
||||
const res = await axios.post(
|
||||
APIv2.AuthGenerateApikey,
|
||||
null,
|
||||
getAxiosConfig()
|
||||
);
|
||||
actions.setApiKey(res.data.apikey);
|
||||
}),
|
||||
deleteDomain: thunk(async (actions, id) => {
|
||||
await axios.delete(`${APIv2.Domains}/${id}`, getAxiosConfig());
|
||||
actions.removeDomain(id);
|
||||
}),
|
||||
setSettings: action((state, payload) => {
|
||||
state.apikey = payload.apikey;
|
||||
state.domains = payload.domains;
|
||||
state.email = payload.email;
|
||||
state.fetched = true;
|
||||
}),
|
||||
setApiKey: action((state, payload) => {
|
||||
state.apikey = payload;
|
||||
}),
|
||||
addDomain: action((state, payload) => {
|
||||
state.domains.push(payload);
|
||||
}),
|
||||
removeDomain: action((state, id) => {
|
||||
state.domains = state.domains.filter(d => d.id !== id);
|
||||
}),
|
||||
saveDomain: thunk(async (actions, payload) => {
|
||||
const res = await axios.post(APIv2.Domains, payload, getAxiosConfig());
|
||||
actions.addDomain(res.data);
|
||||
})
|
||||
};
|
@ -1,35 +0,0 @@
|
||||
import { Action, createStore, createTypedHooks, action } from "easy-peasy";
|
||||
|
||||
import { settings, Settings } from "./settings";
|
||||
import { loading, Loading } from "./loading";
|
||||
import { links, Links } from "./links";
|
||||
import { auth, Auth } from "./auth";
|
||||
|
||||
export interface StoreModel {
|
||||
auth: Auth;
|
||||
links: Links;
|
||||
loading: Loading;
|
||||
settings: Settings;
|
||||
reset: Action<StoreModel>;
|
||||
}
|
||||
|
||||
let initState: any = {};
|
||||
|
||||
export const store: StoreModel = {
|
||||
auth,
|
||||
links,
|
||||
loading,
|
||||
settings,
|
||||
reset: action(() => initState)
|
||||
};
|
||||
|
||||
const typedHooks = createTypedHooks<StoreModel>();
|
||||
|
||||
export const useStoreActions = typedHooks.useStoreActions;
|
||||
export const useStoreDispatch = typedHooks.useStoreDispatch;
|
||||
export const useStoreState = typedHooks.useStoreState;
|
||||
|
||||
export const initializeStore = (initialState?: StoreModel) => {
|
||||
initState = initialState;
|
||||
return createStore(store, { initialState });
|
||||
};
|
@ -1,31 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"./module.d.ts"
|
||||
]
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
export interface TokenPayload {
|
||||
iss: "ApiAuth";
|
||||
sub: string;
|
||||
domain: string;
|
||||
admin: boolean;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
import cookie from "js-cookie";
|
||||
import { AxiosRequestConfig, AxiosError } from "axios";
|
||||
|
||||
export const removeProtocol = (link: string) =>
|
||||
link.replace(/^https?:\/\//, "");
|
||||
|
||||
export const withComma = (num: number) =>
|
||||
num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
|
||||
export const getAxiosConfig = (
|
||||
options: AxiosRequestConfig = {}
|
||||
): AxiosRequestConfig => ({
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Authorization: cookie.get("token")
|
||||
}
|
||||
});
|
||||
|
||||
export const errorMessage = (err: AxiosError, defaultMessage?: string) => {
|
||||
const data = err?.response?.data as Record<string, any>;
|
||||
return data?.message || data?.error || defaultMessage || "";
|
||||
};
|
631
docs/api/api.ts
631
docs/api/api.ts
@ -1,631 +0,0 @@
|
||||
import * as p from "../../package.json";
|
||||
|
||||
export default {
|
||||
openapi: "3.0.0",
|
||||
info: {
|
||||
title: "Kutt.it",
|
||||
description: "API reference for [http://kutt.it](http://kutt.it).\n",
|
||||
version: p.version
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: "https://kutt.it/api/v2"
|
||||
}
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: "health"
|
||||
},
|
||||
{
|
||||
name: "links"
|
||||
},
|
||||
{
|
||||
name: "domains"
|
||||
},
|
||||
{
|
||||
name: "users"
|
||||
}
|
||||
],
|
||||
paths: {
|
||||
"/health": {
|
||||
get: {
|
||||
tags: ["health"],
|
||||
summary: "API health",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Health",
|
||||
content: {
|
||||
"text/html": {
|
||||
example: "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/links": {
|
||||
get: {
|
||||
tags: ["links"],
|
||||
description: "Get list of links",
|
||||
parameters: [
|
||||
{
|
||||
name: "limit",
|
||||
in: "query",
|
||||
description: "Limit",
|
||||
required: false,
|
||||
style: "form",
|
||||
explode: true,
|
||||
schema: {
|
||||
type: "number",
|
||||
example: 10
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "skip",
|
||||
in: "query",
|
||||
description: "Skip",
|
||||
required: false,
|
||||
style: "form",
|
||||
explode: true,
|
||||
schema: {
|
||||
type: "number",
|
||||
example: 0
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "all",
|
||||
in: "query",
|
||||
description: "All links (ADMIN only)",
|
||||
required: false,
|
||||
style: "form",
|
||||
explode: true,
|
||||
schema: {
|
||||
type: "boolean",
|
||||
example: false
|
||||
}
|
||||
}
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "List of links",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
$ref: "#/components/schemas/inline_response_200"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
security: [
|
||||
{
|
||||
APIKeyAuth: []
|
||||
}
|
||||
]
|
||||
},
|
||||
post: {
|
||||
tags: ["links"],
|
||||
description: "Create a short link",
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
$ref: "#/components/schemas/body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Created link",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
$ref: "#/components/schemas/Link"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
security: [
|
||||
{
|
||||
APIKeyAuth: []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/links/{id}": {
|
||||
delete: {
|
||||
tags: ["links"],
|
||||
description: "Delete a link",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
style: "simple",
|
||||
explode: false,
|
||||
schema: {
|
||||
type: "string",
|
||||
format: "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Deleted link successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
$ref: "#/components/schemas/inline_response_200_1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
security: [
|
||||
{
|
||||
APIKeyAuth: []
|
||||
}
|
||||
]
|
||||
},
|
||||
patch: {
|
||||
tags: ["links"],
|
||||
description: "Update a link",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
style: "simple",
|
||||
explode: false,
|
||||
schema: {
|
||||
type: "string",
|
||||
format: "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
$ref: "#/components/schemas/body_1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Updated link successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
$ref: "#/components/schemas/Link"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
security: [
|
||||
{
|
||||
APIKeyAuth: []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/links/{id}/stats": {
|
||||
get: {
|
||||
tags: ["links"],
|
||||
description: "Get link stats",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
style: "simple",
|
||||
explode: false,
|
||||
schema: {
|
||||
type: "string",
|
||||
format: "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Link stats",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
$ref: "#/components/schemas/Stats"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
security: [
|
||||
{
|
||||
APIKeyAuth: []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/domains": {
|
||||
post: {
|
||||
tags: ["domains"],
|
||||
description: "Create a domain",
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
$ref: "#/components/schemas/body_2"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Created domain",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
$ref: "#/components/schemas/Domain"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
security: [
|
||||
{
|
||||
APIKeyAuth: []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/domains/{id}": {
|
||||
delete: {
|
||||
tags: ["domains"],
|
||||
description: "Delete a domain",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
style: "simple",
|
||||
explode: false,
|
||||
schema: {
|
||||
type: "string",
|
||||
format: "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Deleted domain successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
$ref: "#/components/schemas/inline_response_200_1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
security: [
|
||||
{
|
||||
APIKeyAuth: []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/users": {
|
||||
get: {
|
||||
tags: ["users"],
|
||||
description: "Get user info",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "User info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
$ref: "#/components/schemas/User"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
security: [
|
||||
{
|
||||
APIKeyAuth: []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
Link: {
|
||||
type: "object",
|
||||
properties: {
|
||||
address: {
|
||||
type: "string"
|
||||
},
|
||||
banned: {
|
||||
type: "boolean",
|
||||
default: false
|
||||
},
|
||||
created_at: {
|
||||
type: "string",
|
||||
format: "date-time"
|
||||
},
|
||||
id: {
|
||||
type: "string",
|
||||
format: "uuid"
|
||||
},
|
||||
link: {
|
||||
type: "string"
|
||||
},
|
||||
password: {
|
||||
type: "boolean",
|
||||
default: false
|
||||
},
|
||||
target: {
|
||||
type: "string"
|
||||
},
|
||||
description: {
|
||||
type: "string"
|
||||
},
|
||||
updated_at: {
|
||||
type: "string",
|
||||
format: "date-time"
|
||||
},
|
||||
visit_count: {
|
||||
type: "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
Domain: {
|
||||
type: "object",
|
||||
properties: {
|
||||
address: {
|
||||
type: "string"
|
||||
},
|
||||
banned: {
|
||||
type: "boolean",
|
||||
default: false
|
||||
},
|
||||
created_at: {
|
||||
type: "string",
|
||||
format: "date-time"
|
||||
},
|
||||
id: {
|
||||
type: "string",
|
||||
format: "uuid"
|
||||
},
|
||||
homepage: {
|
||||
type: "string"
|
||||
},
|
||||
updated_at: {
|
||||
type: "string",
|
||||
format: "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
User: {
|
||||
type: "object",
|
||||
properties: {
|
||||
apikey: {
|
||||
type: "string"
|
||||
},
|
||||
email: {
|
||||
type: "string"
|
||||
},
|
||||
domains: {
|
||||
type: "array",
|
||||
items: {
|
||||
$ref: "#/components/schemas/Domain"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
StatsItem: {
|
||||
type: "object",
|
||||
properties: {
|
||||
stats: {
|
||||
$ref: "#/components/schemas/StatsItem_stats"
|
||||
},
|
||||
views: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Stats: {
|
||||
type: "object",
|
||||
properties: {
|
||||
lastDay: {
|
||||
$ref: "#/components/schemas/StatsItem"
|
||||
},
|
||||
lastMonth: {
|
||||
$ref: "#/components/schemas/StatsItem"
|
||||
},
|
||||
lastWeek: {
|
||||
$ref: "#/components/schemas/StatsItem"
|
||||
},
|
||||
lastYear: {
|
||||
$ref: "#/components/schemas/StatsItem"
|
||||
},
|
||||
updatedAt: {
|
||||
type: "string"
|
||||
},
|
||||
address: {
|
||||
type: "string"
|
||||
},
|
||||
banned: {
|
||||
type: "boolean",
|
||||
default: false
|
||||
},
|
||||
created_at: {
|
||||
type: "string",
|
||||
format: "date-time"
|
||||
},
|
||||
id: {
|
||||
type: "string",
|
||||
format: "uuid"
|
||||
},
|
||||
link: {
|
||||
type: "string"
|
||||
},
|
||||
password: {
|
||||
type: "boolean",
|
||||
default: false
|
||||
},
|
||||
target: {
|
||||
type: "string"
|
||||
},
|
||||
updated_at: {
|
||||
type: "string",
|
||||
format: "date-time"
|
||||
},
|
||||
visit_count: {
|
||||
type: "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
inline_response_200: {
|
||||
properties: {
|
||||
limit: {
|
||||
type: "number",
|
||||
default: 10
|
||||
},
|
||||
skip: {
|
||||
type: "number",
|
||||
default: 0
|
||||
},
|
||||
total: {
|
||||
type: "number",
|
||||
default: 0
|
||||
},
|
||||
data: {
|
||||
type: "array",
|
||||
items: {
|
||||
$ref: "#/components/schemas/Link"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
body: {
|
||||
required: ["target"],
|
||||
properties: {
|
||||
target: {
|
||||
type: "string"
|
||||
},
|
||||
description: {
|
||||
type: "string"
|
||||
},
|
||||
expire_in: {
|
||||
type: "string",
|
||||
example: "2 minutes/hours/days"
|
||||
},
|
||||
password: {
|
||||
type: "string"
|
||||
},
|
||||
customurl: {
|
||||
type: "string"
|
||||
},
|
||||
reuse: {
|
||||
type: "boolean",
|
||||
default: false
|
||||
},
|
||||
domain: {
|
||||
type: "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
inline_response_200_1: {
|
||||
properties: {
|
||||
message: {
|
||||
type: "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
body_1: {
|
||||
required: ["target", "address"],
|
||||
properties: {
|
||||
target: {
|
||||
type: "string"
|
||||
},
|
||||
address: {
|
||||
type: "string"
|
||||
},
|
||||
description: {
|
||||
type: "string"
|
||||
},
|
||||
expire_in: {
|
||||
type: "string",
|
||||
example: "2 minutes/hours/days"
|
||||
}
|
||||
}
|
||||
},
|
||||
body_2: {
|
||||
required: ["address"],
|
||||
properties: {
|
||||
address: {
|
||||
type: "string"
|
||||
},
|
||||
homepage: {
|
||||
type: "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
StatsItem_stats_browser: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string"
|
||||
},
|
||||
value: {
|
||||
type: "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
StatsItem_stats: {
|
||||
type: "object",
|
||||
properties: {
|
||||
browser: {
|
||||
type: "array",
|
||||
items: {
|
||||
$ref: "#/components/schemas/StatsItem_stats_browser"
|
||||
}
|
||||
},
|
||||
os: {
|
||||
type: "array",
|
||||
items: {
|
||||
$ref: "#/components/schemas/StatsItem_stats_browser"
|
||||
}
|
||||
},
|
||||
country: {
|
||||
type: "array",
|
||||
items: {
|
||||
$ref: "#/components/schemas/StatsItem_stats_browser"
|
||||
}
|
||||
},
|
||||
referrer: {
|
||||
type: "array",
|
||||
items: {
|
||||
$ref: "#/components/schemas/StatsItem_stats_browser"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
securitySchemes: {
|
||||
APIKeyAuth: {
|
||||
type: "apiKey",
|
||||
name: "X-API-KEY",
|
||||
in: "header"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
@ -1,48 +0,0 @@
|
||||
import { join, dirname } from 'path';
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
|
||||
import api from './api';
|
||||
|
||||
const Template = (output, { api, title, redoc }) =>
|
||||
fs.writeFile(output,
|
||||
`<DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
<title>${title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<redoc spec-url="${api}" />
|
||||
<script src="${redoc}"></script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const Api = output =>
|
||||
fs.writeFile(output, JSON.stringify(api));
|
||||
|
||||
const Redoc = output =>
|
||||
fs.copyFile(join(
|
||||
dirname(require.resolve('redoc')),
|
||||
'redoc.standalone.js'),
|
||||
output);
|
||||
|
||||
export default (async () => {
|
||||
const out = join(__dirname, 'static');
|
||||
const apiFile = 'api.json';
|
||||
const redocFile = 'redoc.js';
|
||||
await fs.mkdir(out, { recursive: true });
|
||||
return Promise.all([
|
||||
Api(join(out, apiFile)),
|
||||
Redoc(join(out, redocFile)),
|
||||
Template(join(out, 'index.html'), {
|
||||
api: apiFile,
|
||||
title: api.info.title,
|
||||
redoc: redocFile
|
||||
}),
|
||||
|
||||
]);
|
||||
})();
|
160
global.d.ts
vendored
160
global.d.ts
vendored
@ -1,160 +0,0 @@
|
||||
type Raw = import("knex").Knex.Raw;
|
||||
|
||||
type Match<T> = {
|
||||
[K in keyof T]?: T[K] | [">" | ">=" | "<=" | "<", T[K]];
|
||||
};
|
||||
|
||||
interface User {
|
||||
apikey?: string;
|
||||
banned_by_id?: number;
|
||||
banned: boolean;
|
||||
change_email_address?: string;
|
||||
change_email_expires?: string;
|
||||
change_email_token?: string;
|
||||
cooldowns?: string[];
|
||||
created_at: string;
|
||||
email: string;
|
||||
id: number;
|
||||
password: string;
|
||||
reset_password_expires?: string;
|
||||
reset_password_token?: string;
|
||||
updated_at: string;
|
||||
verification_expires?: string;
|
||||
verification_token?: string;
|
||||
verified?: boolean;
|
||||
}
|
||||
|
||||
interface UserJoined extends User {
|
||||
admin?: boolean;
|
||||
homepage?: string;
|
||||
domain?: string;
|
||||
domain_id?: number;
|
||||
}
|
||||
|
||||
interface Domain {
|
||||
id: number;
|
||||
uuid: string;
|
||||
address: string;
|
||||
banned: boolean;
|
||||
banned_by_id?: number;
|
||||
created_at: string;
|
||||
homepage?: string;
|
||||
updated_at: string;
|
||||
user_id?: number;
|
||||
}
|
||||
|
||||
interface DomainSanitized {
|
||||
id: string;
|
||||
uuid: undefined;
|
||||
address: string;
|
||||
banned: boolean;
|
||||
banned_by_id?: undefined;
|
||||
created_at: string;
|
||||
homepage?: string;
|
||||
updated_at: string;
|
||||
user_id?: undefined;
|
||||
}
|
||||
|
||||
interface Host {
|
||||
id: number;
|
||||
address: string;
|
||||
banned: boolean;
|
||||
banned_by_id?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface IP {
|
||||
id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
interface Link {
|
||||
address: string;
|
||||
banned_by_id?: number;
|
||||
banned: boolean;
|
||||
created_at: string;
|
||||
description?: string;
|
||||
domain_id?: number;
|
||||
expire_in: string;
|
||||
id: number;
|
||||
password?: string;
|
||||
target: string;
|
||||
updated_at: string;
|
||||
user_id?: number;
|
||||
uuid: string;
|
||||
visit_count: number;
|
||||
}
|
||||
|
||||
interface LinkSanitized {
|
||||
address: string;
|
||||
banned_by_id?: undefined;
|
||||
banned: boolean;
|
||||
created_at: string;
|
||||
domain_id?: undefined;
|
||||
id: string;
|
||||
link: string;
|
||||
password: boolean;
|
||||
target: string;
|
||||
updated_at: string;
|
||||
user_id?: undefined;
|
||||
uuid?: undefined;
|
||||
visit_count: number;
|
||||
}
|
||||
|
||||
interface LinkJoinedDomain extends Link {
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
interface Visit {
|
||||
id: number;
|
||||
countries: Record<string, number>;
|
||||
created_at: string;
|
||||
link_id: number;
|
||||
referrers: Record<string, number>;
|
||||
total: number;
|
||||
br_chrome: number;
|
||||
br_edge: number;
|
||||
br_firefox: number;
|
||||
br_ie: number;
|
||||
br_opera: number;
|
||||
br_other: number;
|
||||
br_safari: number;
|
||||
os_android: number;
|
||||
os_ios: number;
|
||||
os_linux: number;
|
||||
os_macos: number;
|
||||
os_other: number;
|
||||
os_windows: number;
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
browser: Record<
|
||||
"chrome" | "edge" | "firefox" | "ie" | "opera" | "other" | "safari",
|
||||
number
|
||||
>;
|
||||
os: Record<
|
||||
"android" | "ios" | "linux" | "macos" | "other" | "windows",
|
||||
number
|
||||
>;
|
||||
country: Record<string, number>;
|
||||
referrer: Record<string, number>;
|
||||
}
|
||||
|
||||
declare namespace Express {
|
||||
export interface Request {
|
||||
realIP?: string;
|
||||
pageType?: string;
|
||||
linkTarget?: string;
|
||||
protectedLink?: string;
|
||||
token?: string;
|
||||
user: UserJoined;
|
||||
context?: {
|
||||
limit: number;
|
||||
skip: number;
|
||||
all: boolean;
|
||||
};
|
||||
}
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import nextConfig from "./next.config";
|
||||
|
||||
jest.mock('next/config', () => () => nextConfig);
|
||||
|
@ -1,13 +0,0 @@
|
||||
const { parsed: localEnv } = require("dotenv").config();
|
||||
|
||||
module.exports = {
|
||||
publicRuntimeConfig: {
|
||||
CONTACT_EMAIL: localEnv && localEnv.CONTACT_EMAIL,
|
||||
SITE_NAME: localEnv && localEnv.SITE_NAME,
|
||||
DEFAULT_DOMAIN: localEnv && localEnv.DEFAULT_DOMAIN,
|
||||
RECAPTCHA_SITE_KEY: localEnv && localEnv.RECAPTCHA_SITE_KEY,
|
||||
REPORT_EMAIL: localEnv && localEnv.REPORT_EMAIL,
|
||||
DISALLOW_ANONYMOUS_LINKS: localEnv && localEnv.DISALLOW_ANONYMOUS_LINKS,
|
||||
DISALLOW_REGISTRATION: localEnv && localEnv.DISALLOW_REGISTRATION
|
||||
}
|
||||
};
|
8173
package-lock.json
generated
8173
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
166
package.json
166
package.json
@ -2,22 +2,19 @@
|
||||
"name": "kutt",
|
||||
"version": "2.7.4",
|
||||
"description": "Modern URL shortener.",
|
||||
"main": "./production-server/server.js",
|
||||
"main": "./server/server.js",
|
||||
"scripts": {
|
||||
"test": "jest --passWithNoTests",
|
||||
"docker:build": "docker build -t kutt .",
|
||||
"docker:run": "docker run -p 3000:3000 --env-file .env -d kutt:latest",
|
||||
"dev": "node --watch-path=./server server/server.js",
|
||||
"dev:backup": "npm run migrate && cross-env NODE_ENV=development nodemon server/server.ts",
|
||||
"build": "rimraf production-server && tsc --project tsconfig.json && copyfiles -f \"server/mail/*.html\" production-server/mail && next build client/ ",
|
||||
"start": "npm run migrate && cross-env NODE_ENV=production node production-server/server.js",
|
||||
"start": "npm run migrate && cross-env NODE_ENV=production node server/server.js",
|
||||
"migrate": "knex migrate:latest --env production",
|
||||
"migrate:make": "knex migrate:make --env production",
|
||||
"docs:build": "cd docs/api && tsc generate.ts --resolveJsonModule && node generate && cd ../.."
|
||||
"docs:build": "cd docs/api && node generate && cd ../.."
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/TheDevs-Network/kutt.git"
|
||||
"url": "git+https://github.com/thedevs-network/kutt.git"
|
||||
},
|
||||
"keywords": [
|
||||
"url-shortener"
|
||||
@ -25,112 +22,61 @@
|
||||
"author": "Pouria Ezzati <ezzati.upt@gmail.com>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/TheDevs-Network/kutt/issues"
|
||||
"url": "https://github.com/thedevs-network/kutt/issues"
|
||||
},
|
||||
"homepage": "https://github.com/TheDevs-Network/kutt#readme",
|
||||
"homepage": "https://github.com/thedevs-network/kutt#readme",
|
||||
"dependencies": {
|
||||
"app-root-path": "^3.1.0",
|
||||
"axios": "^1.1.3",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bull": "^4.16.2",
|
||||
"compression": "^1.7.4",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"cross-env": "^7.0.3",
|
||||
"d3-color": "^3.1.0",
|
||||
"date-fns": "^2.29.3",
|
||||
"dotenv": "^16.0.3",
|
||||
"easy-peasy": "^5.1.0",
|
||||
"email-validator": "^2.0.4",
|
||||
"envalid": "^7.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-async-handler": "1.1.4",
|
||||
"express-validator": "^6.14.2",
|
||||
"geoip-lite": "^1.4.6",
|
||||
"hbs": "^4.2.0",
|
||||
"helmet": "^6.0.0",
|
||||
"ioredis": "^5.2.4",
|
||||
"isbot": "^3.6.3",
|
||||
"js-cookie": "^3.0.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"knex": "^2.3.0",
|
||||
"morgan": "^1.10.0",
|
||||
"ms": "^2.1.3",
|
||||
"nanoid": "^2.1.11",
|
||||
"node-cron": "^3.0.2",
|
||||
"nodemailer": "^6.8.0",
|
||||
"p-queue": "^7.3.0",
|
||||
"passport": "^0.6.0",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"passport-localapikey-update": "^0.6.0",
|
||||
"pg": "^8.8.0",
|
||||
"pg-query-stream": "^4.2.4",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"query-string": "^7.1.1",
|
||||
"rebass": "^4.0.7",
|
||||
"recharts": "^2.1.16",
|
||||
"redis": "^4.5.0",
|
||||
"signale": "^1.4.0",
|
||||
"styled-components": "^5.3.6",
|
||||
"styled-tools": "^1.7.2",
|
||||
"url-regex-safe": "^3.0.0",
|
||||
"use-media": "^1.4.0",
|
||||
"useragent": "^2.2.1",
|
||||
"uuid": "^3.4.0",
|
||||
"winston": "^3.3.3",
|
||||
"winston-daily-rotate-file": "^4.7.1"
|
||||
"app-root-path": "3.1.0",
|
||||
"axios": "1.7.7",
|
||||
"bcryptjs": "2.4.3",
|
||||
"bull": "4.16.2",
|
||||
"cookie-parser": "1.4.6",
|
||||
"cors": "2.8.5",
|
||||
"cross-env": "7.0.3",
|
||||
"date-fns": "2.30.0",
|
||||
"dotenv": "16.0.3",
|
||||
"envalid": "8.0.0",
|
||||
"express": "4.19.2",
|
||||
"express-validator": "6.14.2",
|
||||
"geoip-lite": "1.4.10",
|
||||
"hbs": "4.2.0",
|
||||
"helmet": "7.1.0",
|
||||
"ioredis": "5.2.4",
|
||||
"isbot": "5.1.17",
|
||||
"jsonwebtoken": "9.0.2",
|
||||
"knex": "3.1.0",
|
||||
"morgan": "1.10.0",
|
||||
"ms": "2.1.3",
|
||||
"nanoid": "2.1.11",
|
||||
"node-cron": "3.0.2",
|
||||
"nodemailer": "^6.9.15",
|
||||
"passport": "0.7.0",
|
||||
"passport-jwt": "4.0.1",
|
||||
"passport-local": "1.0.0",
|
||||
"passport-localapikey-update": "0.6.0",
|
||||
"pg": "8.12.0",
|
||||
"pg-query-stream": "4.6.0",
|
||||
"signale": "1.4.0",
|
||||
"useragent": "2.3.0",
|
||||
"uuid": "10.0.0",
|
||||
"winston": "3.3.3",
|
||||
"winston-daily-rotate-file": "4.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/express": "^4.17.14",
|
||||
"@types/hbs": "^4.0.4",
|
||||
"@types/jest": "^26.0.20",
|
||||
"@types/jsonwebtoken": "^7.2.8",
|
||||
"@types/morgan": "^1.7.37",
|
||||
"@types/ms": "^0.7.31",
|
||||
"@types/nanoid": "^3.0.0",
|
||||
"@types/node": "^18.11.9",
|
||||
"@types/node-cron": "^2.0.2",
|
||||
"@types/nodemailer": "^6.4.6",
|
||||
"@types/pg": "^8.6.5",
|
||||
"@types/rebass": "^4.0.10",
|
||||
"@types/signale": "^1.4.4",
|
||||
"@types/styled-components": "^5.1.7",
|
||||
"copyfiles": "^2.4.1",
|
||||
"husky": "^8.0.2",
|
||||
"jest": "^29.3.1",
|
||||
"nodemon": "^2.0.20",
|
||||
"prettier": "^2.7.1",
|
||||
"redoc": "^2.0.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^4.8.4"
|
||||
},
|
||||
"overrides": {
|
||||
"react-use-form-state": {
|
||||
"react": "*",
|
||||
"react-dom": "*"
|
||||
},
|
||||
"redoc": {
|
||||
"react": "*",
|
||||
"react-dom": "*"
|
||||
},
|
||||
"use-media": {
|
||||
"react": "*",
|
||||
"react-dom": "*"
|
||||
},
|
||||
"react-transition-group": {
|
||||
"react": "*",
|
||||
"react-dom": "*"
|
||||
},
|
||||
"recharts": {
|
||||
"react": "*",
|
||||
"react-dom": "*",
|
||||
"d3-color": "*"
|
||||
}
|
||||
"@types/bcryptjs": "2.4.2",
|
||||
"@types/cookie-parser": "1.4.3",
|
||||
"@types/cors": "2.8.12",
|
||||
"@types/express": "4.17.14",
|
||||
"@types/hbs": "4.0.4",
|
||||
"@types/jsonwebtoken": "7.2.8",
|
||||
"@types/morgan": "1.7.37",
|
||||
"@types/ms": "0.7.31",
|
||||
"@types/node": "18.11.9",
|
||||
"@types/node-cron": "2.0.2",
|
||||
"@types/nodemailer": "6.4.6",
|
||||
"@types/pg": "8.6.5",
|
||||
"@types/rebass": "4.0.10",
|
||||
"@types/signale": "1.4.4",
|
||||
"redoc": "2.0.0"
|
||||
}
|
||||
}
|
||||
|
14
server/cron.js
Normal file
14
server/cron.js
Normal file
@ -0,0 +1,14 @@
|
||||
const cron = require("node-cron");
|
||||
|
||||
const query = require("./queries");
|
||||
const env = require("./env");
|
||||
|
||||
if (env.NON_USER_COOLDOWN) {
|
||||
cron.schedule("* */24 * * *", function() {
|
||||
query.ip.clear().catch();
|
||||
});
|
||||
}
|
||||
|
||||
cron.schedule("*/15 * * * * *", function() {
|
||||
query.link.batchRemove({ expire_in: ["<", new Date().toISOString()] }).catch();
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user