feat: remove appwrite and add nextjs backend

This commit is contained in:
Elias Schneider
2022-10-09 22:30:32 +02:00
parent 7728351158
commit 4bab33ad8a
153 changed files with 13400 additions and 2811 deletions

View File

@@ -0,0 +1,15 @@
import { Anchor, Center, Footer as MFooter, Text } from "@mantine/core";
const Footer = () => {
return (
<MFooter height="auto" p={10}>
<Center>
<Text size="xs" color="dimmed">
Made with 🖤 by <Anchor size="xs" href="https://eliasschneider.com" target="_blank">Elias Schneider</Anchor>
</Text>
</Center>
</MFooter>
);
};
export default Footer;

View File

@@ -0,0 +1,27 @@
import Head from "next/head";
const Meta = ({
title,
description,
}: {
title: string;
description?: string;
}) => {
return (
<Head>
{/* TODO: Doesn't work because script get only executed on client side */}
<title>{title} - Pingvin Share</title>
<meta name="og:title" content={`${title} - Pingvin Share`} />
<meta
name="og:description"
content={
description ?? "An open-source and self-hosted sharing platform."
}
/>
<meta name="twitter:title" content={`${title} - Pingvin Share`} />
<meta name="twitter:description" content={description} />
</Head>
);
};
export default Meta;

View File

@@ -0,0 +1,95 @@
import {
Anchor,
Button,
Container,
Paper,
PasswordInput,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import getConfig from "next/config";
import * as yup from "yup";
import authService from "../../services/auth.service";
import toast from "../../utils/toast.util";
const { publicRuntimeConfig } = getConfig();
const AuthForm = ({ mode }: { mode: "signUp" | "signIn" }) => {
const validationSchema = yup.object().shape({
email: yup.string().email().required(),
password: yup.string().min(8).required(),
});
const form = useForm({
initialValues: {
email: "",
password: "",
},
schema: yupResolver(validationSchema),
});
const signIn = (email: string, password: string) => {
authService
.signIn(email, password)
.then(() => window.location.replace("/upload"))
.catch((e) => toast.error(e.response.data.message));
};
const signUp = (email: string, password: string) => {
authService
.signUp(email, password)
.then(() => signIn(email, password))
.catch((e) => toast.error(e.response.data.message));
};
return (
<Container size={420} my={40}>
<Title
align="center"
sx={(theme) => ({
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
fontWeight: 900,
})}
>
{mode == "signUp" ? "Sign up" : "Welcome back"}
</Title>
{publicRuntimeConfig.ALLOW_REGISTRATION == "true" && (
<Text color="dimmed" size="sm" align="center" mt={5}>
{mode == "signUp"
? "You have an account already?"
: "You don't have an account yet?"}{" "}
<Anchor href={mode == "signUp" ? "signIn" : "signUp"} size="sm">
{mode == "signUp" ? "Sign in" : "Sign up"}
</Anchor>
</Text>
)}
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form
onSubmit={form.onSubmit((values) =>
mode == "signIn"
? signIn(values.email, values.password)
: signUp(values.email, values.password)
)}
>
<TextInput
label="Email"
placeholder="you@email.com"
{...form.getInputProps("email")}
/>
<PasswordInput
label="Password"
placeholder="Your password"
mt="md"
{...form.getInputProps("password")}
/>
<Button fullWidth mt="xl" type="submit">
{mode == "signUp" ? "Let's get started" : "Sign in"}
</Button>
</form>
</Paper>
</Container>
);
};
export default AuthForm;

View File

@@ -0,0 +1,41 @@
import {
ColorScheme,
ColorSchemeProvider,
MantineProvider,
} from "@mantine/core";
import { ModalsProvider } from "@mantine/modals";
import { setCookies } from "cookies-next";
import { Dispatch, ReactNode, SetStateAction } from "react";
import mantineTheme from "../../styles/mantine.style";
const ThemeProvider = ({
children,
colorScheme,
setColorScheme,
}: {
children: ReactNode;
colorScheme: ColorScheme;
setColorScheme: Dispatch<SetStateAction<ColorScheme>>;
}) => {
const toggleColorScheme = (value?: ColorScheme) => {
const nextColorScheme =
value || (colorScheme === "dark" ? "light" : "dark");
setColorScheme(nextColorScheme);
setCookies("mantine-color-scheme", nextColorScheme, {
maxAge: 60 * 60 * 24 * 30,
});
};
return (
<MantineProvider theme={{ colorScheme, ...mantineTheme }} withGlobalStyles>
<ColorSchemeProvider
colorScheme={colorScheme}
toggleColorScheme={toggleColorScheme}
>
<ModalsProvider>{children}</ModalsProvider>
</ColorSchemeProvider>
</MantineProvider>
);
};
export default ThemeProvider;

View File

@@ -0,0 +1,47 @@
import { ActionIcon, Avatar, Menu } from "@mantine/core";
import { NextLink } from "@mantine/next";
import { DoorExit, Link, Moon } from "tabler-icons-react";
import authService from "../../services/auth.service";
import ToggleThemeButton from "./ToggleThemeButton";
const ActionAvatar = () => {
return (
<Menu
control={
<ActionIcon>
<Avatar size={28} radius="xl" />
</ActionIcon>
}
>
<Menu.Label>My account</Menu.Label>
<Menu.Item
component={NextLink}
href="/account/shares"
icon={<Link size={14} />}
>
Shares
</Menu.Item>
{/* <Menu.Item
component={NextLink}
href="/account/shares"
icon={<Settings size={14} />}
>
Settings
</Menu.Item> */}
<Menu.Item
onClick={async () => {
await authService.signOut();
}}
icon={<DoorExit size={14} />}
>
Sign out
</Menu.Item>
<Menu.Label>Settings</Menu.Label>
<Menu.Item icon={<Moon size={14} />}>
<ToggleThemeButton />
</Menu.Item>
</Menu>
);
};
export default ActionAvatar;

View File

@@ -0,0 +1,131 @@
import {
Burger,
Container,
Group,
Header as MantineHeader,
Paper,
Text,
Transition,
} from "@mantine/core";
import { useBooleanToggle } from "@mantine/hooks";
import { NextLink } from "@mantine/next";
import getConfig from "next/config";
import Image from "next/image";
import { ReactNode, useEffect, useState } from "react";
import useUser from "../../hooks/user.hook";
import headerStyle from "../../styles/header.style";
import ActionAvatar from "./ActionAvatar";
const { publicRuntimeConfig } = getConfig();
type Link = {
link?: string;
label?: string;
component?: ReactNode;
action?: () => Promise<void>;
};
const Header = () => {
const [opened, toggleOpened] = useBooleanToggle(false);
const [active, setActive] = useState<string>();
const user = useUser();
const { classes, cx } = headerStyle();
const authenticatedLinks: Link[] = [
{
link: "/upload",
label: "Upload",
},
{
component: <ActionAvatar />,
},
];
const unauthenticatedLinks: Link[] | undefined = [
{
link: "/auth/signIn",
label: "Sign in",
},
];
if (publicRuntimeConfig.SHOW_HOME_PAGE == "true")
unauthenticatedLinks.unshift({
link: "/",
label: "Home",
});
if (publicRuntimeConfig.ALLOW_REGISTRATION == "true")
unauthenticatedLinks.push({
link: "/auth/signUp",
label: "Sign up",
});
const links = user ? authenticatedLinks : unauthenticatedLinks;
const items = links.map((link, i) => {
if (link.component) {
return (
<Container key={i} pl={5} py={15}>
{link.component}
</Container>
);
}
if (link) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (window.location.pathname == link.link) {
setActive(link.link);
}
}, []);
return (
<NextLink
key={link.label}
href={link.link ?? ""}
onClick={link.action}
className={cx(classes.link, {
[classes.linkActive]: link.link && active === link.link,
})}
>
{link.label}
</NextLink>
);
}
});
return (
<MantineHeader height={60} mb={20} className={classes.root}>
<Container className={classes.header}>
<NextLink href="/">
<Group>
<Image
src="/img/logo.svg"
alt="Pinvgin Share Logo"
height={40}
width={40}
/>
<Text weight={600}>Pingvin Share</Text>
</Group>
</NextLink>
<Group spacing={5} className={classes.links}>
{items}
</Group>
<Burger
opened={opened}
onClick={() => toggleOpened()}
className={classes.burger}
size="sm"
/>
<Transition transition="pop-top-right" duration={200} mounted={opened}>
{(styles) => (
<Paper className={classes.dropdown} withBorder style={styles}>
{items}
</Paper>
)}
</Transition>
</Container>
</MantineHeader>
);
};
export default Header;

View File

@@ -0,0 +1,19 @@
import { Switch, useMantineColorScheme } from "@mantine/core";
const ToggleThemeButton = () => {
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
return (
<Switch
size="sm"
checked={colorScheme == "dark"}
onClick={(v) =>
toggleColorScheme(v.currentTarget.checked ? "dark" : "light")
}
onLabel="ON"
offLabel="OFF"
/>
);
};
export default ToggleThemeButton;

View File

@@ -0,0 +1,146 @@
import {
Accordion,
Button,
Col,
Grid,
Group,
NumberInput,
PasswordInput,
Select,
Text,
TextInput,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import { useModals } from "@mantine/modals";
import * as yup from "yup";
import shareService from "../../services/share.service";
import { ShareSecurity } from "../../types/share.type";
const CreateUploadModalBody = ({
uploadCallback,
}: {
uploadCallback: (
id: string,
expiration: string,
security: ShareSecurity
) => void;
}) => {
const modals = useModals();
const validationSchema = yup.object().shape({
link: yup
.string()
.required()
.min(3)
.max(100)
.matches(new RegExp("^[a-zA-Z0-9_-]*$"), {
message: "Can only contain letters, numbers, underscores and hyphens",
}),
password: yup.string().min(3).max(30),
maxViews: yup.number().min(1),
});
const form = useForm({
initialValues: {
link: "",
password: undefined,
maxViews: undefined,
expiration: "1-day",
},
schema: yupResolver(validationSchema),
});
return (
<form
onSubmit={form.onSubmit(async (values) => {
if (!(await shareService.isShareIdAvailable(values.link))) {
form.setFieldError("link", "This link is already in use");
} else {
uploadCallback(values.link, values.expiration, {
password: values.password,
maxViews: values.maxViews,
});
modals.closeAll();
}
})}
>
<Group direction="column" grow>
<Grid align={form.errors.link ? "center" : "flex-end"}>
<Col xs={9}>
<TextInput
variant="filled"
label="Link"
placeholder="myAwesomeShare"
{...form.getInputProps("link")}
/>
</Col>
<Col xs={3}>
<Button
variant="outline"
onClick={() =>
form.setFieldValue(
"link",
Buffer.from(Math.random().toString(), "utf8")
.toString("base64")
.substr(10, 7)
)
}
>
Generate
</Button>
</Col>
</Grid>
<Text
size="xs"
sx={(theme) => ({
color: theme.colors.gray[6],
})}
>
{window.location.origin}/share/
{form.values.link == "" ? "myAwesomeShare" : form.values.link}
</Text>
<Select
label="Expiration"
{...form.getInputProps("expiration")}
data={[
{
value: "10-minutes",
label: "10 Minutes",
},
{ value: "1-hour", label: "1 Hour" },
{ value: "1-day", label: "1 Day" },
{ value: "1-week".toString(), label: "1 Week" },
{ value: "1-month", label: "1 Month" },
]}
/>
<Accordion>
<Accordion.Item
label="Security options"
sx={{ borderBottom: "none" }}
>
<Group direction="column" grow>
<PasswordInput
variant="filled"
placeholder="No password"
label="Password protection"
{...form.getInputProps("password")}
/>
<NumberInput
min={1}
type="number"
variant="filled"
placeholder="No limit"
label="Maximal views"
{...form.getInputProps("maxViews")}
/>
</Group>
</Accordion.Item>
</Accordion>
<Button type="submit">Share</Button>
</Group>
</form>
);
};
export default CreateUploadModalBody;

View File

@@ -0,0 +1,53 @@
import { Button, Tooltip } from "@mantine/core";
import { useEffect, useState } from "react";
import shareService from "../../services/share.service";
const DownloadAllButton = ({ shareId }: { shareId: string }) => {
const [isZipReady, setIsZipReady] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const downloadAll = async () => {
setIsLoading(true);
await shareService
.downloadFile(shareId, "zip")
.then(() => setIsLoading(false));
};
useEffect(() => {
shareService
.getMetaData(shareId)
.then((share) => setIsZipReady(share.isZipReady))
.catch(() => {});
const timer = setInterval(() => {
shareService.getMetaData(shareId).then((share) => {
setIsZipReady(share.isZipReady);
if (share.isZipReady) clearInterval(timer);
}).catch(() => {});
}, 5000);
return () => {
clearInterval(timer);
};
}, []);
if (!isZipReady)
return (
<Tooltip
wrapLines
position="bottom"
width={220}
withArrow
label="The share is preparing. This can take a few minutes."
>
<Button variant="outline" onClick={downloadAll} disabled>
Download all
</Button>
</Tooltip>
);
return (
<Button variant="outline" loading={isLoading} onClick={downloadAll}>
Download all
</Button>
);
};
export default DownloadAllButton;

View File

@@ -0,0 +1,88 @@
import { ActionIcon, Loader, Skeleton, Table } from "@mantine/core";
import { useRouter } from "next/router";
import { CircleCheck, Download } from "tabler-icons-react";
import shareService from "../../services/share.service";
import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util";
const FileList = ({
files,
shareId,
isLoading,
}: {
files: any[];
shareId: string;
isLoading: boolean;
}) => {
const router = useRouter();
const skeletonRows = [...Array(5)].map((c, i) => (
<tr key={i}>
<td>
<Skeleton height={30} width={30} />
</td>
<td>
<Skeleton height={14} />
</td>
<td>
<Skeleton height={14} />
</td>
<td>
<Skeleton height={25} width={25} />
</td>
</tr>
));
const rows = files.map((file) => (
<tr key={file.name}>
<td>
{/* <Image
width={30}
height={30}
alt={file.name}
objectFit="cover"
style={{ borderRadius: 3 }}
src={`data:image/png;base64,${new Buffer(file.preview).toString(
"base64"
)}`}
></Image> */}
</td>
<td>{file.name}</td>
<td>{byteStringToHumanSizeString(file.size)}</td>
<td>
{file.uploadingState ? (
file.uploadingState != "finished" ? (
<Loader size={22} />
) : (
<CircleCheck color="green" size={22} />
)
) : (
<ActionIcon
size={25}
onClick={async () => {
await shareService.downloadFile(shareId, file.id);
}}
>
<Download />
</ActionIcon>
)}
</td>
</tr>
));
return (
<Table>
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Size</th>
<th></th>
</tr>
</thead>
<tbody>{isLoading ? skeletonRows : rows}</tbody>
</Table>
);
};
export default FileList;

View File

@@ -0,0 +1,58 @@
import { Button, Group, PasswordInput, Text, Title } from "@mantine/core";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import { useState } from "react";
const showEnterPasswordModal = (
modals: ModalsContextProps,
submitCallback: any
) => {
return modals.openModal({
closeOnClickOutside: false,
withCloseButton: false,
closeOnEscape: false,
title: (
<>
<Title order={4}>Password required</Title>
<Text size="sm">
This access this share please enter the password for the share.
</Text>
</>
),
children: <Body submitCallback={submitCallback} />,
});
};
const Body = ({ submitCallback }: { submitCallback: any }) => {
const [password, setPassword] = useState("");
const [passwordWrong, setPasswordWrong] = useState(false);
return (
<>
<Group grow direction="column">
<PasswordInput
variant="filled"
placeholder="Password"
error={passwordWrong && "Wrong password"}
onFocus={() => setPasswordWrong(false)}
onChange={(e) => setPassword(e.target.value)}
value={password}
/>
<Button
onClick={() =>
submitCallback(password)
.then((res: any) => res)
.catch((e: any) => {
const error = e.response.data.message;
if (error == "Wrong password") {
setPasswordWrong(true);
}
})
}
>
Submit
</Button>
</Group>
</>
);
};
export default showEnterPasswordModal;

View File

@@ -0,0 +1,41 @@
import { Button, Group, Text, Title } from "@mantine/core";
import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import { useRouter } from "next/router";
const showErrorModal = (
modals: ModalsContextProps,
title: string,
text: string
) => {
return modals.openModal({
closeOnClickOutside: false,
withCloseButton: false,
closeOnEscape: false,
title: <Title order={4}>{title}</Title>,
children: <Body text={text} />,
});
};
const Body = ({ text }: { text: string }) => {
const modals = useModals();
const router = useRouter();
return (
<>
<Group grow direction="column">
<Text size="sm">{text}</Text>
<Button
onClick={() => {
modals.closeAll();
router.back();
}}
>
Go back
</Button>
</Group>
</>
);
};
export default showErrorModal;

View File

@@ -0,0 +1,115 @@
import {
Button,
Center,
createStyles,
Group,
MantineTheme,
Text,
useMantineTheme,
} from "@mantine/core";
import { Dropzone as MantineDropzone, DropzoneStatus } from "@mantine/dropzone";
import getConfig from "next/config";
import React, { Dispatch, ForwardedRef, SetStateAction, useRef } from "react";
import { CloudUpload, Upload } from "tabler-icons-react";
import toast from "../../utils/toast.util";
const { publicRuntimeConfig } = getConfig()
const useStyles = createStyles((theme) => ({
wrapper: {
position: "relative",
marginBottom: 30,
},
dropzone: {
borderWidth: 1,
paddingBottom: 50,
},
icon: {
color:
theme.colorScheme === "dark"
? theme.colors.dark[3]
: theme.colors.gray[4],
},
control: {
position: "absolute",
bottom: -20,
},
}));
function getActiveColor(status: DropzoneStatus, theme: MantineTheme) {
return status.accepted
? theme.colors[theme.primaryColor][6]
: theme.colorScheme === "dark"
? theme.colors.dark[2]
: theme.black;
}
const Dropzone = ({
isUploading,
setFiles,
}: {
isUploading: boolean;
setFiles: Dispatch<SetStateAction<File[]>>;
}) => {
const theme = useMantineTheme();
const { classes } = useStyles();
const openRef = useRef<() => void>();
return (
<div className={classes.wrapper}>
<MantineDropzone
maxSize={parseInt(publicRuntimeConfig.MAX_FILE_SIZE!)}
onReject={(e) => {
toast.error(e[0].errors[0].message);
}}
disabled={isUploading}
openRef={openRef as ForwardedRef<() => void>}
onDrop={(files) => {
if (files.length > 100) {
toast.error("You can't upload more than 100 files per share.");
} else {
setFiles(files);
}
}}
className={classes.dropzone}
radius="md"
>
{(status) => (
<div style={{ pointerEvents: "none" }}>
<Group position="center">
<CloudUpload size={50} color={getActiveColor(status, theme)} />
</Group>
<Text
align="center"
weight={700}
size="lg"
mt="xl"
sx={{ color: getActiveColor(status, theme) }}
>
{status.accepted ? "Drop files here" : "Upload files"}
</Text>
<Text align="center" size="sm" mt="xs" color="dimmed">
Drag and drop your files or use the upload button to start your
share.
</Text>
</div>
)}
</MantineDropzone>
<Center>
<Button
className={classes.control}
variant="light"
size="sm"
radius="xl"
disabled={isUploading}
onClick={() => openRef.current && openRef.current()}
>
{<Upload />}
</Button>
</Center>
</div>
);
};
export default Dropzone;

View File

@@ -0,0 +1,60 @@
import { ActionIcon, Loader, Table } from "@mantine/core";
import { Dispatch, SetStateAction } from "react";
import { CircleCheck, Trash } from "tabler-icons-react";
import { FileUpload } from "../../types/File.type";
import { byteStringToHumanSizeString } from "../../utils/math/byteStringToHumanSizeString.util";
const FileList = ({
files,
setFiles,
}: {
files: FileUpload[];
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
}) => {
const remove = (index: number) => {
files.splice(index, 1);
setFiles([...files]);
};
const rows = files.map((file, i) => (
<tr key={i}>
<td>{file.name}</td>
<td>{file.type}</td>
<td>{byteStringToHumanSizeString(file.size.toString())}</td>
<td>
{file.uploadingState ? (
file.uploadingState != "finished" ? (
<Loader size={22} />
) : (
<CircleCheck color="green" size={22} />
)
) : (
<ActionIcon
color="red"
variant="light"
size={25}
onClick={() => remove(i)}
>
<Trash />
</ActionIcon>
)}
</td>
</tr>
));
return (
<Table>
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Size</th>
<th></th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
);
};
export default FileList;

View File

@@ -0,0 +1,77 @@
import {
ActionIcon,
Button,
Group,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import moment from "moment";
import { useRouter } from "next/router";
import { Copy } from "tabler-icons-react";
import { Share } from "../../types/share.type";
import toast from "../../utils/toast.util";
const showCompletedUploadModal = (
modals: ModalsContextProps,
share: Share,
) => {
return modals.openModal({
closeOnClickOutside: false,
withCloseButton: false,
closeOnEscape: false,
title: (
<Group grow direction="column" spacing={0}>
<Title order={4}>Share ready</Title>
</Group>
),
children: <Body share={share} />,
});
};
const Body = ({ share }: { share: Share }) => {
const clipboard = useClipboard({ timeout: 500 });
const modals = useModals();
const router = useRouter();
const link = `${window.location.origin}/share/${share.id}`;
return (
<Group grow direction="column">
<TextInput
variant="filled"
value={link}
rightSection={
<ActionIcon
onClick={() => {
clipboard.copy(link);
toast.success("Your link was copied to the keyboard.");
}}
>
<Copy />
</ActionIcon>
}
/>
<Text
size="xs"
sx={(theme) => ({
color: theme.colors.gray[6],
})}
>
Your share expires at {moment(share.expiration).format("LLL")}
</Text>
<Button
onClick={() => {
modals.closeAll();
router.push("/upload");
}}
>
Done
</Button>
</Group>
);
};
export default showCompletedUploadModal;

View File

@@ -0,0 +1,22 @@
import { Title } from "@mantine/core";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import { ShareSecurity } from "../../types/share.type";
import CreateUploadModalBody from "../share/CreateUploadModalBody";
const showCreateUploadModal = (
modals: ModalsContextProps,
uploadCallback: (
id: string,
expiration: string,
security: ShareSecurity,
) => void
) => {
return modals.openModal({
title: <Title order={4}>Share</Title>,
children: (
<CreateUploadModalBody uploadCallback={uploadCallback} />
),
});
};
export default showCreateUploadModal;

View File

@@ -0,0 +1,10 @@
import { createContext, useContext } from "react";
import { CurrentUser } from "../types/user.type";
export const UserContext = createContext<CurrentUser | null>(null);
const useUser = () => {
return useContext(UserContext);
};
export default useUser;

View File

@@ -0,0 +1,64 @@
import React from "react";
import {
createStyles,
Title,
Text,
Button,
Container,
Group,
} from "@mantine/core";
import Meta from "../components/Meta";
import { NextLink } from "@mantine/next";
const useStyles = createStyles((theme) => ({
root: {
paddingTop: 80,
paddingBottom: 80,
},
label: {
textAlign: "center",
fontWeight: 900,
fontSize: 220,
lineHeight: 1,
marginBottom: theme.spacing.xl * 1.5,
color: theme.colors.gray[2],
[theme.fn.smallerThan("sm")]: {
fontSize: 120,
},
},
description: {
maxWidth: 500,
margin: "auto",
marginBottom: theme.spacing.xl * 1.5,
},
}));
const ErrorNotFound = () => {
const { classes } = useStyles();
return (
<>
<Meta title="Not found" />
<Container className={classes.root}>
<div className={classes.label}>404</div>
<Title align="center" order={3}>
Oops this page doesn't exist.
</Title>
<Text
color="dimmed"
align="center"
className={classes.description}
></Text>
<Group position="center">
<Button component={NextLink} href="/" variant="light">
Bring me back
</Button>
</Group>
</Container>
</>
);
};
export default ErrorNotFound;

View File

@@ -0,0 +1,86 @@
import {
ColorScheme,
Container,
LoadingOverlay,
MantineProvider,
Stack,
} from "@mantine/core";
import { ModalsProvider } from "@mantine/modals";
import { NotificationsProvider } from "@mantine/notifications";
import { getCookie } from "cookies-next";
import { GetServerSidePropsContext } from "next";
import type { AppProps } from "next/app";
import getConfig from "next/config";
import { useEffect, useState } from "react";
import Footer from "../components/Footer";
import ThemeProvider from "../components/mantine/ThemeProvider";
import Header from "../components/navBar/NavBar";
import { UserContext } from "../hooks/user.hook";
import authService from "../services/auth.service";
import userService from "../services/user.service";
import GlobalStyle from "../styles/global.style";
import globalStyle from "../styles/mantine.style";
import { CurrentUser } from "../types/user.type";
import { GlobalLoadingContext } from "../utils/loading.util";
const { publicRuntimeConfig } = getConfig()
function App(
props: AppProps & { colorScheme: ColorScheme; environmentVariables: any }
) {
const { Component, pageProps } = props;
const [colorScheme, setColorScheme] = useState<ColorScheme>(
props.colorScheme
);
const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState<CurrentUser | null>(null);
const getInitalData = async () => {
console.log(publicRuntimeConfig)
setIsLoading(true);
setUser(await userService.getCurrentUser());
setIsLoading(false);
};
useEffect(() => {
setInterval(async () => await authService.refreshAccessToken(), 30 * 1000);
getInitalData();
}, []);
return (
<MantineProvider withGlobalStyles withNormalizeCSS theme={globalStyle}>
<ThemeProvider colorScheme={colorScheme} setColorScheme={setColorScheme}>
<GlobalStyle />
<NotificationsProvider>
<ModalsProvider>
<GlobalLoadingContext.Provider value={{ isLoading, setIsLoading }}>
{isLoading ? (
<LoadingOverlay visible overlayOpacity={1} />
) : (
<UserContext.Provider value={user}>
<LoadingOverlay visible={isLoading} overlayOpacity={1} />
<Stack justify="space-between" sx={{ minHeight: "100vh" }}>
<div>
<Header />
<Container>
<Component {...pageProps} />
</Container>
</div>
<Footer />
</Stack>
</UserContext.Provider>
)}
</GlobalLoadingContext.Provider>
</ModalsProvider>
</NotificationsProvider>
</ThemeProvider>
</MantineProvider>
);
}
export default App;
App.getInitialProps = ({ ctx }: { ctx: GetServerSidePropsContext }) => {
return {
colorScheme: getCookie("mantine-color-scheme", ctx) || "light",
};
};

View File

@@ -0,0 +1,29 @@
import { createGetInitialProps } from "@mantine/next";
import Document, { Head, Html, Main, NextScript } from "next/document";
const getInitialProps = createGetInitialProps();
export default class _Document extends Document {
static getInitialProps = getInitialProps;
render() {
return (
<Html>
<Head>
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-white-128x128.png" />
<meta property="og:image" content="/img/opengraph-default.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="/img/opengraph-default.png" />
<meta name="robots" content="noindex" />
<meta name="theme-color" content="#46509e" />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}

View File

@@ -0,0 +1,122 @@
import {
ActionIcon,
Button,
Center,
Group,
LoadingOverlay,
Space,
Table,
Text,
Title,
} from "@mantine/core";
import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals";
import { NextLink } from "@mantine/next";
import moment from "moment";
import { useEffect, useState } from "react";
import { Link, Trash } from "tabler-icons-react";
import Meta from "../../components/Meta";
import shareService from "../../services/share.service";
import { MyShare } from "../../types/share.type";
import toast from "../../utils/toast.util";
const MyShares = () => {
const modals = useModals();
const clipboard = useClipboard();
const [shares, setShares] = useState<MyShare[]>();
useEffect(() => {
shareService.getMyShares().then((shares) => setShares(shares));
}, []);
if (!shares) return <LoadingOverlay visible />;
return (
<>
<Meta title="My shares" />
<Title mb={30} order={3}>
My shares
</Title>
{shares.length == 0 ? (
<Center style={{ height: "70vh" }}>
<Group direction="column" align="center" spacing={10}>
<Title order={3}>It's empty here 👀</Title>
<Text>You don't have any shares.</Text>
<Space h={5} />
<Button component={NextLink} href="/upload" variant="light">
Create one
</Button>
</Group>
</Center>
) : (
<Table>
<thead>
<tr>
<th>Name</th>
<th>Visitors</th>
<th>Expires at</th>
<th></th>
</tr>
</thead>
<tbody>
{shares.map((share) => (
<tr key={share.id}>
<td>{share.id}</td>
<td>{share.views}</td>
<td>
{moment(share.expiration).format("MMMM DD YYYY, HH:mm")}
</td>
<td>
<Group position="right">
<ActionIcon
color="victoria"
variant="light"
size={25}
onClick={() => {
clipboard.copy(
`${window.location.origin}/share/${share.id}`
);
toast.success("Your link was copied to the keyboard.");
}}
>
<Link />
</ActionIcon>
<ActionIcon
color="red"
variant="light"
size={25}
onClick={() => {
modals.openConfirmModal({
title: `Delete share ${share.id}`,
children: (
<Text size="sm">
Do you really want to delete this share?
</Text>
),
confirmProps: {
color: "red",
},
labels: { confirm: "Confirm", cancel: "Cancel" },
onConfirm: () => {
shareService.remove(share.id);
setShares(
shares.filter((item) => item.id !== share.id)
);
},
});
}}
>
<Trash />
</ActionIcon>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
)}
</>
);
};
export default MyShares;

View File

@@ -0,0 +1,19 @@
import { NextApiRequest, NextApiResponse } from "next";
import httpProxyMiddleware from "next-http-proxy-middleware";
import getConfig from "next/config";
const { publicRuntimeConfig } = getConfig();
export const config = {
api: {
bodyParser: false,
externalResolver: true,
},
};
// This function can be marked `async` if using `await` inside
export default (req: NextApiRequest, res: NextApiResponse) =>
httpProxyMiddleware(req, res, {
// You can use the `http-proxy` option
target: publicRuntimeConfig.BACKEND_URL,
});

View File

@@ -0,0 +1,20 @@
import { useRouter } from "next/router";
import AuthForm from "../../components/auth/AuthForm";
import Meta from "../../components/Meta";
import useUser from "../../hooks/user.hook";
const SignIn = () => {
const user = useUser();
const router = useRouter();
if (user) {
router.replace("/");
} else {
return (
<>
<Meta title="Sign In" />
<AuthForm mode="signIn" />
</>
);
}
};
export default SignIn;

View File

@@ -0,0 +1,22 @@
import { useRouter } from "next/router";
import AuthForm from "../../components/auth/AuthForm";
import Meta from "../../components/Meta";
import useUser from "../../hooks/user.hook";
const SignUp = () => {
const user = useUser();
const router = useRouter();
if (user) {
router.replace("/");
} else if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION) {
router.replace("/auth/signIn");
} else {
return (
<>
<Meta title="Sign Up" />
<AuthForm mode="signUp" />
</>
);
}
};
export default SignUp;

View File

@@ -0,0 +1,157 @@
import {
Button,
Container,
createStyles,
Group,
List,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { NextLink } from "@mantine/next";
import getConfig from "next/config";
import Image from "next/image";
import { useRouter } from "next/router";
import { Check } from "tabler-icons-react";
import Meta from "../components/Meta";
import useUser from "../hooks/user.hook";
const { publicRuntimeConfig } = getConfig()
const useStyles = createStyles((theme) => ({
inner: {
display: "flex",
justifyContent: "space-between",
paddingTop: theme.spacing.xl * 4,
paddingBottom: theme.spacing.xl * 4,
},
content: {
maxWidth: 480,
marginRight: theme.spacing.xl * 3,
[theme.fn.smallerThan("md")]: {
maxWidth: "100%",
marginRight: 0,
},
},
title: {
color: theme.colorScheme === "dark" ? theme.white : theme.black,
fontSize: 44,
lineHeight: 1.2,
fontWeight: 900,
[theme.fn.smallerThan("xs")]: {
fontSize: 28,
},
},
control: {
[theme.fn.smallerThan("xs")]: {
flex: 1,
},
},
image: {
[theme.fn.smallerThan("md")]: {
display: "none",
},
},
highlight: {
position: "relative",
backgroundColor:
theme.colorScheme === "dark"
? theme.fn.rgba(theme.colors[theme.primaryColor][6], 0.55)
: theme.colors[theme.primaryColor][0],
borderRadius: theme.radius.sm,
padding: "4px 12px",
},
}));
export default function Home() {
const user = useUser();
const { classes } = useStyles();
const router = useRouter();
if (user) {
router.replace("/upload");
} else if (publicRuntimeConfig.SHOW_HOME_PAGE == "false") {
router.replace("/auth/signIn");
} else {
return (
<>
<Meta title="Home" />
<Container>
<div className={classes.inner}>
<div className={classes.content}>
<Title className={classes.title}>
A <span className={classes.highlight}>self-hosted</span> <br />{" "}
file sharing platform.
</Title>
<Text color="dimmed" mt="md">
Do you really want to give your personal files in the hand of
third parties like WeTransfer?
</Text>
<List
mt={30}
spacing="sm"
size="sm"
icon={
<ThemeIcon size={20} radius="xl">
<Check size={12} />
</ThemeIcon>
}
>
<List.Item>
<b>Self-Hosted</b> - Host Pingvin Share on your own machine.
</List.Item>
<List.Item>
<b>Privacy</b> - Your files are your files and should never
get into the hands of third parties.
</List.Item>
<List.Item>
<b>No annoying file size limit</b> - Upload as big files as
you want. Only your hard drive will be your limit.
</List.Item>
</List>
<Group mt={30}>
<Button
component={NextLink}
href="/auth/signUp"
radius="xl"
size="md"
className={classes.control}
>
Get started
</Button>
<Button
component={NextLink}
href="https://github.com/stonith404/pingvin-share"
target="_blank"
variant="default"
radius="xl"
size="md"
className={classes.control}
>
Source code
</Button>
</Group>
</div>
<Group className={classes.image} align="center">
<Image
src="/img/logo.svg"
alt="Pingvin Share Logo"
width={200}
height={200}
/>
</Group>
</div>
</Container>
</>
);
}
}

View File

@@ -0,0 +1,85 @@
import { Group } from "@mantine/core";
import { useModals } from "@mantine/modals";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import Meta from "../../components/Meta";
import DownloadAllButton from "../../components/share/DownloadAllButton";
import FileList from "../../components/share/FileList";
import showEnterPasswordModal from "../../components/share/showEnterPasswordModal";
import showErrorModal from "../../components/share/showErrorModal";
import shareService from "../../services/share.service";
const Share = () => {
const router = useRouter();
const modals = useModals();
const shareId = router.query.shareId as string;
const [fileList, setFileList] = useState<any[]>([]);
const submitPassword = async (password: string) => {
await shareService
.exchangeSharePasswordWithToken(shareId, password)
.then(() => {
modals.closeAll();
getFiles();
});
};
const getFiles = async () => {
shareService
.get(shareId)
.then((share) => {
setFileList(share.files);
})
.catch((e) => {
const { error } = e.response.data;
if (e.response.status == 404) {
showErrorModal(
modals,
"Not found",
"This share can't be found. Please check your link."
);
} else if (error == "share_token_required") {
showEnterPasswordModal(modals, submitPassword);
} else if (error == "share_max_views_exceeded") {
showErrorModal(
modals,
"Visitor limit exceeded",
"The visitor limit from this share has been exceeded."
);
} else if (error == "forbidden") {
showErrorModal(
modals,
"Forbidden",
"You're not allowed to see this share. Are you logged in with the correct account?"
);
} else {
showErrorModal(modals, "Error", "An unknown error occurred.");
}
});
};
useEffect(() => {
getFiles();
}, []);
return (
<>
<Meta
title={`Share ${shareId}`}
description="Look what I've shared with you."
/>
<Group position="right" mb="lg">
<DownloadAllButton
shareId={shareId}
/>
</Group>
<FileList
files={fileList}
shareId={shareId}
isLoading={fileList.length == 0}
/>
</>
);
};
export default Share;

View File

@@ -0,0 +1,81 @@
import { Button, Group } from "@mantine/core";
import { useModals } from "@mantine/modals";
import { useRouter } from "next/router";
import { useState } from "react";
import Meta from "../components/Meta";
import Dropzone from "../components/upload/Dropzone";
import FileList from "../components/upload/FileList";
import showCompletedUploadModal from "../components/upload/showCompletedUploadModal";
import showCreateUploadModal from "../components/upload/showCreateUploadModal";
import useUser from "../hooks/user.hook";
import shareService from "../services/share.service";
import { FileUpload } from "../types/File.type";
import { ShareSecurity } from "../types/share.type";
const Upload = () => {
const router = useRouter();
const modals = useModals();
const user = useUser();
const [files, setFiles] = useState<FileUpload[]>([]);
const [isUploading, setisUploading] = useState(false);
const uploadFiles = async (
id: string,
expiration: string,
security: ShareSecurity
) => {
setisUploading(true);
try {
files.forEach((file) => {
file.uploadingState = "inProgress";
});
setFiles([...files]);
const share = await shareService.create(id, expiration, security);
for (let i = 0; i < files.length; i++) {
await shareService.uploadFile(share.id, files[i]);
files[i].uploadingState = "finished";
setFiles([...files]);
if (!files.some((f) => f.uploadingState == "inProgress")) {
await shareService.completeShare(share.id);
setisUploading(false);
showCompletedUploadModal(
modals,
share
);
setFiles([]);
}
}
} catch {
files.forEach((file) => {
file.uploadingState = undefined;
});
setFiles([...files]);
setisUploading(false);
}
};
if (!user) {
router.replace("/");
} else {
return (
<>
<Meta title="Upload" />
<Group position="right" mb={20}>
<Button
loading={isUploading}
disabled={files.length <= 0}
onClick={() => showCreateUploadModal(modals, uploadFiles)}
>
Share
</Button>
</Group>
<Dropzone setFiles={setFiles} isUploading={isUploading} />
{files.length > 0 && <FileList files={files} setFiles={setFiles} />}
</>
);
}
};
export default Upload;

View File

@@ -0,0 +1,6 @@
// TODO: Add user account
const Account = () => {
return <div></div>;
};
export default Account;

View File

@@ -0,0 +1,36 @@
import axios, { AxiosError } from "axios";
import { getCookie } from "cookies-next";
import toast from "../utils/toast.util";
const api = axios.create({
baseURL: "/api",
});
api.interceptors.request.use(
(config) => {
const accessToken = getCookie("access_token");
if (accessToken) {
config!.headers!.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error: AxiosError) => {
return Promise.reject(error);
}
);
api.interceptors.response.use(
(response) => {
return response;
},
(error: AxiosError) => {
const status = error.response?.status;
if (status == 400) {
toast.error(error.response?.data?.message ?? "An unkown error occured");
}
return Promise.reject(error);
}
);
export default api;

View File

@@ -0,0 +1,41 @@
import { getCookie, setCookies } from "cookies-next";
import * as jose from "jose";
import api from "./api.service";
const signIn = async (email: string, password: string) => {
const response = await api.post("auth/signIn", { email, password });
setCookies("access_token", response.data.accessToken);
setCookies("refresh_token", response.data.refreshToken);
return response;
};
const signUp = async (email: string, password: string) => {
return await api.post("auth/signUp", { email, password });
};
const signOut = () => {
setCookies("access_token", null);
window.location.reload();
};
const refreshAccessToken = async () => {
const currentAccessToken = getCookie("access_token") as string;
if (
currentAccessToken &&
(jose.decodeJwt(currentAccessToken).exp ?? 0) * 1000 <
Date.now() + 2 * 60 * 1000
) {
const refreshToken = getCookie("refresh_token");
const response = await api.post("auth/token", { refreshToken });
setCookies("access_token", response.data.accessToken);
}
};
export default {
signIn,
signUp,
signOut,
refreshAccessToken,
};

View File

@@ -0,0 +1,88 @@
import {
MyShare,
Share,
ShareMetaData,
ShareSecurity,
} from "../types/share.type";
import api from "./api.service";
const create = async (
id: string,
expiration: string,
security?: ShareSecurity
) => {
return (await api.post("shares", { id, expiration, security })).data;
};
const completeShare = async (id: string) => {
return (await api.post(`shares/${id}/complete`)).data;
};
const get = async (id: string): Promise<Share> => {
const shareToken = localStorage.getItem(`share_${id}_token`);
return (
await api.get(`shares/${id}`, {
headers: { "X-Share-Token": shareToken ?? "" },
})
).data;
};
const getMetaData = async (id: string): Promise<ShareMetaData> => {
const shareToken = localStorage.getItem(`share_${id}_token`);
return (
await api.get(`shares/${id}/metaData`, {
headers: { "X-Share-Token": shareToken ?? "" },
})
).data;
};
const remove = async (id: string) => {
await api.delete(`shares/${id}`);
};
const getMyShares = async (): Promise<MyShare[]> => {
return (await api.get("shares")).data;
};
const exchangeSharePasswordWithToken = async (id: string, password: string) => {
const { token } = (await api.post(`/shares/${id}/password`, { password }))
.data;
localStorage.setItem(`share_${id}_token`, token);
};
const isShareIdAvailable = async (id: string): Promise<boolean> => {
return (await api.get(`shares/isShareIdAvailable/${id}`)).data.isAvailable;
};
const getFileDownloadUrl = async (shareId: string, fileId: string) => {
const shareToken = localStorage.getItem(`share_${shareId}_token`);
return (
await api.get(`shares/${shareId}/files/${fileId}/download`, {
headers: { "X-Share-Token": shareToken ?? "" },
})
).data.url;
};
const downloadFile = async (shareId: string, fileId: string) => {
window.location.href = await getFileDownloadUrl(shareId, fileId);
};
const uploadFile = async (shareId: string, file: File) => {
var formData = new FormData();
formData.append("file", file);
return (await api.post(`shares/${shareId}/files`, formData)).data;
};
export default {
create,
completeShare,
exchangeSharePasswordWithToken,
get,
remove,
getMetaData,
getMyShares,
isShareIdAvailable,
downloadFile,
uploadFile,
};

View File

@@ -0,0 +1,16 @@
import { CurrentUser } from "../types/user.type";
import api from "./api.service";
import authService from "./auth.service";
const getCurrentUser = async (): Promise<CurrentUser | null> => {
try {
await authService.refreshAccessToken();
return (await api.get("users/me")).data;
} catch {
return null;
}
};
export default {
getCurrentUser,
};

View File

@@ -0,0 +1,15 @@
import { Global } from "@mantine/core";
const GlobalStyle = () => {
return (
<Global
styles={(theme) => ({
a: {
color: "inherit",
textDecoration: "none",
},
})}
/>
);
};
export default GlobalStyle;

View File

@@ -0,0 +1,80 @@
import { createStyles } from "@mantine/core";
export default createStyles((theme) => ({
root: {
position: "relative",
zIndex: 1,
},
dropdown: {
position: "absolute",
top: 60,
left: 0,
right: 0,
zIndex: 0,
borderTopRightRadius: 0,
borderTopLeftRadius: 0,
borderTopWidth: 0,
overflow: "hidden",
[theme.fn.largerThan("sm")]: {
display: "none",
},
},
header: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
height: "100%",
},
links: {
[theme.fn.smallerThan("sm")]: {
display: "none",
},
},
burger: {
[theme.fn.largerThan("sm")]: {
display: "none",
},
},
link: {
display: "block",
lineHeight: 1,
padding: "8px 12px",
borderRadius: theme.radius.sm,
textDecoration: "none",
color:
theme.colorScheme === "dark"
? theme.colors.dark[0]
: theme.colors.gray[7],
fontSize: theme.fontSizes.sm,
fontWeight: 500,
"&:hover": {
backgroundColor:
theme.colorScheme === "dark"
? theme.colors.dark[6]
: theme.colors.gray[0],
},
[theme.fn.smallerThan("sm")]: {
borderRadius: 0,
padding: theme.spacing.md,
},
},
linkActive: {
"&, &:hover": {
backgroundColor:
theme.colorScheme === "dark"
? theme.fn.rgba(theme.colors[theme.primaryColor][9], 0.25)
: theme.colors[theme.primaryColor][0],
color:
theme.colors[theme.primaryColor][theme.colorScheme === "dark" ? 3 : 7],
},
},
}));

View File

@@ -0,0 +1,19 @@
import { MantineThemeOverride } from "@mantine/core";
export default <MantineThemeOverride>{
colors: {
victoria: [
"#E2E1F1",
"#C2C0E7",
"#A19DE4",
"#7D76E8",
"#544AF4",
"#4940DE",
"#4239C8",
"#463FA8",
"#47428E",
"#464379",
],
},
primaryColor: "victoria",
};

View File

@@ -0,0 +1,2 @@
export type FileUpload = File & { uploadingState?: UploadState };
export type UploadState = "finished" | "inProgress" | undefined;

View File

@@ -0,0 +1,23 @@
import User from "./user.type";
export type Share = {
id: string;
files: any;
creator: User;
expiration: Date;
};
export type ShareMetaData = {
id: string;
isZipReady: boolean;
};
export type MyShare = Share & {
views: number;
cratedAt: Date;
};
export type ShareSecurity = {
maxViews?: number;
password?: string;
};

View File

@@ -0,0 +1,8 @@
export default interface User {
id: string;
firstName?: string;
lastName?: string;
email: string;
}
export interface CurrentUser extends User {}

View File

@@ -0,0 +1,6 @@
import { createContext, Dispatch, SetStateAction } from "react";
export const GlobalLoadingContext = createContext<{
isLoading: boolean;
setIsLoading: Dispatch<SetStateAction<boolean>>;
}>({ isLoading: false, setIsLoading: () => {} });

View File

@@ -0,0 +1,11 @@
export function byteStringToHumanSizeString(bytes: string) {
const bytesNumber = parseInt(bytes);
const sizes = ["B", "KB", "MB", "GB", "TB"];
if (bytesNumber == 0) return "0 Byte";
const i = parseInt(
Math.floor(Math.log(bytesNumber) / Math.log(1024)).toString()
);
return (
(bytesNumber / Math.pow(1024, i)).toFixed(1).toString() + " " + sizes[i]
);
}

View File

@@ -0,0 +1,27 @@
import { showNotification } from "@mantine/notifications";
import { Check, X } from "tabler-icons-react";
const error = (message: string) =>
showNotification({
icon: <X />,
color: "red",
radius: "md",
title: "Error",
message: message,
});
const success = (message: string) =>
showNotification({
icon: <Check />,
color: "green",
radius: "md",
title: "Success",
message: message,
});
const toast = {
error,
success,
};
export default toast;