delete nextjs, update packages

This commit is contained in:
Pouria Ezzati 2024-09-09 18:43:12 +03:30
parent dbc14c8fb6
commit 698cf6e305
No known key found for this signature in database
134 changed files with 1598 additions and 14890 deletions

1
.gitignore vendored
View File

@ -12,3 +12,4 @@ production-server
dump.rdb
docs/api/*.js
docs/api/static
**/.DS_Store

View File

@ -1,7 +0,0 @@
language: node_js
node_js:
- "12"
script:
- yarn run lint:nofix

View File

@ -1,4 +1,4 @@
FROM node:12-alpine
FROM node:18-alpine
RUN apk add --update bash

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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]
};

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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 &amp; open source" icon="heart">
Completely open source and free. You can host it on your own server.
</FeaturesItem>
</Flex>
</ColCenterH>
);
export default Features;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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;

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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;

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -1 +0,0 @@
export { default } from "./Icon";

View File

@ -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]
};

View File

@ -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}
/>
);

View File

@ -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>&quot;{removeProtocol(link.link)}&quot;</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>&quot;{removeProtocol(linkToDelete.link)}&quot;</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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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>
&quot;{domainToDelete && domainToDelete.address}&quot;
</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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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} />;

View File

@ -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;

View File

@ -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%)"
}

View File

@ -1 +0,0 @@
export * from "./consts";

View File

@ -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);
}
`;

View File

@ -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;
}

View File

@ -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
View File

@ -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>;
}
}

View File

@ -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.

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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&apos;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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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);
})
};

View File

@ -1 +0,0 @@
export * from "./store";

View File

@ -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;
})
};

View File

@ -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;
})
};

View File

@ -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);
})
};

View File

@ -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 });
};

View File

@ -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"
]
}

View File

@ -1,8 +0,0 @@
export interface TokenPayload {
iss: "ApiAuth";
sub: string;
domain: string;
admin: boolean;
iat: number;
exp: number;
}

View File

@ -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 || "";
};

View File

@ -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"
}
}
}
};

View File

@ -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
View File

@ -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;
};
}
}

View File

@ -1,5 +0,0 @@
import "@testing-library/jest-dom";
import nextConfig from "./next.config";
jest.mock('next/config', () => () => nextConfig);

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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