initial commit
This commit is contained in:
91
src/components/auth/AuthForm.tsx
Normal file
91
src/components/auth/AuthForm.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Container,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import * as yup from "yup";
|
||||
import aw from "../../utils/appwrite.util";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
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) => {
|
||||
aw.account
|
||||
.createSession(email, password)
|
||||
.then(() => window.location.replace("/upload"))
|
||||
.catch((e) => toast.error(e.message));
|
||||
};
|
||||
const signUp = (email: string, password: string) => {
|
||||
aw.account
|
||||
.create("unique()", email, password)
|
||||
.then(() => signIn(email, password))
|
||||
.catch((e) => toast.error(e.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>
|
||||
<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;
|
||||
41
src/components/mantine/ThemeProvider.tsx
Normal file
41
src/components/mantine/ThemeProvider.tsx
Normal 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/global.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;
|
||||
122
src/components/navBar/NavBar.tsx
Normal file
122
src/components/navBar/NavBar.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
Burger,
|
||||
Container,
|
||||
Group,
|
||||
Header as MantineHeader,
|
||||
Paper,
|
||||
Space,
|
||||
Text,
|
||||
Transition,
|
||||
} from "@mantine/core";
|
||||
import { useBooleanToggle } from "@mantine/hooks";
|
||||
import { NextLink } from "@mantine/next";
|
||||
import Image from "next/image";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import headerStyle from "../../styles/header.style";
|
||||
import aw from "../../utils/appwrite.util";
|
||||
import { IsSignedInContext } from "../../utils/auth.util";
|
||||
import ToggleThemeButton from "./ToggleThemeButton";
|
||||
|
||||
type Link = {
|
||||
link?: string;
|
||||
label: string;
|
||||
action?: () => Promise<void>;
|
||||
};
|
||||
|
||||
const authenticatedLinks: Link[] = [
|
||||
{
|
||||
link: "/upload",
|
||||
label: "Upload",
|
||||
},
|
||||
{
|
||||
label: "Sign out",
|
||||
action: async () => {
|
||||
await aw.account.deleteSession("current");
|
||||
window.location.reload();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const unauthenticatedLinks: Link[] = [
|
||||
{
|
||||
link: "/",
|
||||
label: "Home",
|
||||
},
|
||||
{
|
||||
link: "/auth/signUp",
|
||||
label: "Sign up",
|
||||
},
|
||||
{
|
||||
link: "/auth/signIn",
|
||||
label: "Sign in",
|
||||
},
|
||||
];
|
||||
|
||||
const Header = () => {
|
||||
const [opened, toggleOpened] = useBooleanToggle(false);
|
||||
const [active, setActive] = useState<string>();
|
||||
const isSignedIn = useContext(IsSignedInContext);
|
||||
const { classes, cx } = headerStyle();
|
||||
|
||||
const links = isSignedIn ? authenticatedLinks : unauthenticatedLinks;
|
||||
|
||||
const items = links.map((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="/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}
|
||||
<Space w={5} />
|
||||
<ToggleThemeButton />
|
||||
</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;
|
||||
26
src/components/navBar/ToggleThemeButton.tsx
Normal file
26
src/components/navBar/ToggleThemeButton.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ActionIcon, useMantineColorScheme } from "@mantine/core";
|
||||
import { Sun, MoonStars } from "tabler-icons-react";
|
||||
|
||||
const ToggleThemeButton = () => {
|
||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
onClick={() => toggleColorScheme()}
|
||||
sx={(theme) => ({
|
||||
backgroundColor:
|
||||
theme.colorScheme === "dark"
|
||||
? theme.colors.dark[6]
|
||||
: theme.colors.gray[0],
|
||||
color:
|
||||
theme.colorScheme === "dark"
|
||||
? theme.colors.yellow[4]
|
||||
: theme.colors.violet,
|
||||
})}
|
||||
>
|
||||
{colorScheme === "dark" ? <Sun size={18} /> : <MoonStars size={18} />}
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToggleThemeButton;
|
||||
80
src/components/share/FileList.tsx
Normal file
80
src/components/share/FileList.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { ActionIcon, Skeleton, Table } from "@mantine/core";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
import { Download } from "tabler-icons-react";
|
||||
import { AppwriteFileWithPreview } from "../../types/File.type";
|
||||
import aw from "../../utils/appwrite.util";
|
||||
import { bytesToSize } from "../../utils/math/byteToSize.util";
|
||||
|
||||
const FileList = ({
|
||||
files,
|
||||
shareId,
|
||||
isLoading,
|
||||
}: {
|
||||
files: AppwriteFileWithPreview[];
|
||||
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"
|
||||
src={`data:image/png;base64,${new Buffer(file.preview).toString(
|
||||
"base64"
|
||||
)}`}
|
||||
></Image>
|
||||
</td>
|
||||
<td>{file.name}</td>
|
||||
<td>{bytesToSize(file.sizeOriginal)}</td>
|
||||
<td>
|
||||
<ActionIcon
|
||||
size={25}
|
||||
onClick={() =>
|
||||
router.push(aw.storage.getFileDownload(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;
|
||||
58
src/components/share/showEnterPasswordModal.tsx
Normal file
58
src/components/share/showEnterPasswordModal.tsx
Normal 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;
|
||||
39
src/components/share/showShareNotFoundModal.tsx
Normal file
39
src/components/share/showShareNotFoundModal.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
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 showShareNotFoundModal = (modals: ModalsContextProps) => {
|
||||
return modals.openModal({
|
||||
closeOnClickOutside: false,
|
||||
withCloseButton: false,
|
||||
closeOnEscape: false,
|
||||
title: <Title order={4}>Not found</Title>,
|
||||
|
||||
children: <Body />,
|
||||
});
|
||||
};
|
||||
|
||||
const Body = () => {
|
||||
const modals = useModals();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<Group grow direction="column">
|
||||
<Text size="sm">
|
||||
This share can't be found. Please check your link.
|
||||
</Text>
|
||||
<Button
|
||||
onClick={() => {
|
||||
modals.closeAll();
|
||||
router.back();
|
||||
}}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default showShareNotFoundModal;
|
||||
39
src/components/share/showVisitorLimitExceededModal.tsx
Normal file
39
src/components/share/showVisitorLimitExceededModal.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
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 showVisitorLimitExceededModal = (modals: ModalsContextProps) => {
|
||||
return modals.openModal({
|
||||
closeOnClickOutside: false,
|
||||
withCloseButton: false,
|
||||
closeOnEscape: false,
|
||||
title: <Title order={4}>Visitor limit exceeded</Title>,
|
||||
|
||||
children: <Body />,
|
||||
});
|
||||
};
|
||||
|
||||
const Body = () => {
|
||||
const modals = useModals();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<Group grow direction="column">
|
||||
<Text size="sm">
|
||||
The visitor count limit from this share has been exceeded.
|
||||
</Text>
|
||||
<Button
|
||||
onClick={() => {
|
||||
modals.closeAll();
|
||||
router.back();
|
||||
}}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default showVisitorLimitExceededModal;
|
||||
104
src/components/upload/Dropzone.tsx
Normal file
104
src/components/upload/Dropzone.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
Button,
|
||||
Center,
|
||||
createStyles,
|
||||
Group,
|
||||
MantineTheme,
|
||||
Text,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
import { Dropzone as MantineDropzone, DropzoneStatus } from "@mantine/dropzone";
|
||||
import React, { Dispatch, ForwardedRef, SetStateAction, useRef } from "react";
|
||||
import { CloudUpload, Upload } from "tabler-icons-react";
|
||||
|
||||
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
|
||||
disabled={isUploading}
|
||||
openRef={openRef as ForwardedRef<() => void>}
|
||||
onDrop={(files) => {
|
||||
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;
|
||||
60
src/components/upload/FileList.tsx
Normal file
60
src/components/upload/FileList.tsx
Normal 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 { bytesToSize } from "../../utils/math/byteToSize.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={file.name}>
|
||||
<td>{file.name}</td>
|
||||
<td>{file.type}</td>
|
||||
<td>{bytesToSize(file.size)}</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;
|
||||
65
src/components/upload/showCompletedUploadModal.tsx
Normal file
65
src/components/upload/showCompletedUploadModal.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
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 { useRouter } from "next/router";
|
||||
import { Copy } from "tabler-icons-react";
|
||||
|
||||
const showCompletedUploadModal = (
|
||||
modals: ModalsContextProps,
|
||||
link: string,
|
||||
expiresAt: string
|
||||
) => {
|
||||
return modals.openModal({
|
||||
closeOnClickOutside: false,
|
||||
withCloseButton: false,
|
||||
closeOnEscape: false,
|
||||
title: <Title order={4}>Share ready</Title>,
|
||||
children: <Body link={link} expiresAt={expiresAt} />,
|
||||
});
|
||||
};
|
||||
|
||||
const Body = ({ link, expiresAt }: { link: string; expiresAt: string }) => {
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const modals = useModals();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Group grow direction="column">
|
||||
<TextInput
|
||||
variant="filled"
|
||||
value={link}
|
||||
rightSection={
|
||||
<ActionIcon onClick={() => clipboard.copy(link)}>
|
||||
<Copy />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
size="xs"
|
||||
sx={(theme) => ({
|
||||
color: theme.colors.gray[6],
|
||||
})}
|
||||
>
|
||||
Your share expires at {expiresAt}{" "}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
modals.closeAll();
|
||||
router.push("/upload");
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default showCompletedUploadModal;
|
||||
140
src/components/upload/showCreateUploadModal.tsx
Normal file
140
src/components/upload/showCreateUploadModal.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
Col,
|
||||
Grid,
|
||||
Group,
|
||||
NumberInput,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { ModalsContextProps } from "@mantine/modals/lib/context";
|
||||
import * as yup from "yup";
|
||||
|
||||
const showCreateUploadModal = (
|
||||
modals: ModalsContextProps,
|
||||
uploadCallback: (
|
||||
id: string,
|
||||
expiration: number,
|
||||
security: { password?: string; maxVisitors?: number }
|
||||
) => void
|
||||
) => {
|
||||
return modals.openModal({
|
||||
title: <Title order={4}>Share</Title>,
|
||||
children: <Body uploadCallback={uploadCallback} />,
|
||||
});
|
||||
};
|
||||
|
||||
const Body = ({
|
||||
uploadCallback,
|
||||
}: {
|
||||
uploadCallback: (
|
||||
id: string,
|
||||
expiration: number,
|
||||
security: { password?: string; maxVisitors?: number }
|
||||
) => void;
|
||||
}) => {
|
||||
const modals = useModals();
|
||||
const validationSchema = yup.object().shape({
|
||||
link: yup.string().required().min(2).max(50),
|
||||
password: yup.string().min(3).max(100),
|
||||
maxVisitors: yup.number().min(1),
|
||||
});
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
link: "",
|
||||
password: undefined,
|
||||
maxVisitors: undefined,
|
||||
expiration: "1440",
|
||||
},
|
||||
schema: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
modals.closeAll();
|
||||
uploadCallback(values.link, parseInt(values.expiration), {
|
||||
password: values.password,
|
||||
maxVisitors: values.maxVisitors,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Group direction="column" grow>
|
||||
<Grid align="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", label: "10 Minutes" },
|
||||
{ value: "60", label: "1 Hour" },
|
||||
{ value: "1440", label: "1 Day" },
|
||||
{ value: "1080", label: "1 Week" },
|
||||
{ value: "43000", label: "1 Month" },
|
||||
]}
|
||||
/>
|
||||
<Accordion>
|
||||
<Accordion.Item label="Security" sx={{ borderBottom: "none" }}>
|
||||
<Group direction="column" grow>
|
||||
<PasswordInput
|
||||
variant="filled"
|
||||
placeholder="No password"
|
||||
label="Password protection"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<NumberInput
|
||||
type="number"
|
||||
variant="filled"
|
||||
placeholder="No limit"
|
||||
label="Maximal views"
|
||||
{...form.getInputProps("maxVisitors")}
|
||||
/>
|
||||
</Group>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
<Button type="submit">Share</Button>
|
||||
</Group>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default showCreateUploadModal;
|
||||
67
src/pages/_app.tsx
Normal file
67
src/pages/_app.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
ColorScheme,
|
||||
Container,
|
||||
LoadingOverlay,
|
||||
MantineProvider,
|
||||
} 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 { useEffect, useState } from "react";
|
||||
import "../../styles/globals.css";
|
||||
import ThemeProvider from "../components/mantine/ThemeProvider";
|
||||
import Header from "../components/navBar/NavBar";
|
||||
import globalStyle from "../styles/global.style";
|
||||
import authUtil, { IsSignedInContext } from "../utils/auth.util";
|
||||
import { GlobalLoadingContext } from "../utils/loading.util";
|
||||
|
||||
function App(props: AppProps & { colorScheme: ColorScheme }) {
|
||||
const { Component, pageProps } = props;
|
||||
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>(
|
||||
props.colorScheme
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSignedIn, setIsSignedIn] = useState(false);
|
||||
|
||||
const checkIfSignedIn = async () => {
|
||||
setIsLoading(true);
|
||||
setIsSignedIn(await authUtil.isSignedIn());
|
||||
setIsLoading(false);
|
||||
};
|
||||
useEffect(() => {
|
||||
checkIfSignedIn();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MantineProvider withGlobalStyles withNormalizeCSS theme={globalStyle}>
|
||||
<ThemeProvider colorScheme={colorScheme} setColorScheme={setColorScheme}>
|
||||
<NotificationsProvider>
|
||||
<ModalsProvider>
|
||||
<GlobalLoadingContext.Provider value={{ isLoading, setIsLoading }}>
|
||||
{isLoading ? (
|
||||
<LoadingOverlay visible overlayOpacity={1} />
|
||||
) : (
|
||||
<IsSignedInContext.Provider value={isSignedIn}>
|
||||
<LoadingOverlay visible={isLoading} overlayOpacity={1} />
|
||||
<Header />
|
||||
<Container>
|
||||
<Component {...pageProps} />
|
||||
</Container>
|
||||
</IsSignedInContext.Provider>
|
||||
)}
|
||||
</GlobalLoadingContext.Provider>
|
||||
</ModalsProvider>
|
||||
</NotificationsProvider>
|
||||
</ThemeProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
App.getInitialProps = ({ ctx }: { ctx: GetServerSidePropsContext }) => ({
|
||||
colorScheme: getCookie("mantine-color-scheme", ctx) || "light",
|
||||
});
|
||||
8
src/pages/_document.tsx
Normal file
8
src/pages/_document.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import Document from "next/document";
|
||||
import { createGetInitialProps } from "@mantine/next";
|
||||
|
||||
const getInitialProps = createGetInitialProps();
|
||||
|
||||
export default class _Document extends Document {
|
||||
static getInitialProps = getInitialProps;
|
||||
}
|
||||
44
src/pages/api/share/[shareId]/enterPassword.ts
Normal file
44
src/pages/api/share/[shareId]/enterPassword.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import {
|
||||
SecurityDocument,
|
||||
ShareDocument,
|
||||
} from "../../../../types/Appwrite.type";
|
||||
import awServer from "../../../../utils/appwriteServer.util";
|
||||
import { hashPassword } from "../../../../utils/shares/security.util";
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const shareId = req.query.shareId as string;
|
||||
let hashedPassword;
|
||||
try {
|
||||
hashedPassword = await checkPassword(shareId, req.body.password);
|
||||
} catch (e) {
|
||||
return res.status(403).json({ message: e });
|
||||
}
|
||||
if (hashedPassword)
|
||||
res.setHeader(
|
||||
"Set-Cookie",
|
||||
`${shareId}-password=${hashedPassword}; Path=/api/share/${shareId}; max-age=3600; HttpOnly`
|
||||
);
|
||||
res.send(200);
|
||||
};
|
||||
|
||||
export const checkPassword = async (shareId: string, password?: string) => {
|
||||
let hashedPassword;
|
||||
const shareDocument = await awServer.database.getDocument<ShareDocument>(
|
||||
"shares",
|
||||
shareId
|
||||
);
|
||||
await awServer.database
|
||||
.getDocument<SecurityDocument>("shareSecurity", shareDocument.securityID)
|
||||
.then((securityDocument) => {
|
||||
if (securityDocument.password) {
|
||||
hashedPassword = hashPassword(password as string, shareId);
|
||||
if (hashedPassword !== securityDocument.password) {
|
||||
throw "wrong_password";
|
||||
}
|
||||
}
|
||||
});
|
||||
return hashedPassword;
|
||||
};
|
||||
|
||||
export default handler;
|
||||
64
src/pages/api/share/[shareId]/index.ts
Normal file
64
src/pages/api/share/[shareId]/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { ShareDocument } from "../../../../types/Appwrite.type";
|
||||
import { AppwriteFileWithPreview } from "../../../../types/File.type";
|
||||
import awServer from "../../../../utils/appwriteServer.util";
|
||||
import { checkSecurity } from "../../../../utils/shares/security.util";
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const shareId = req.query.shareId as string;
|
||||
const fileList: AppwriteFileWithPreview[] = [];
|
||||
const hashedPassword = req.cookies[`${shareId}-password`];
|
||||
|
||||
if (!(await shareExists(shareId)))
|
||||
return res.status(404).json({ message: "not_found" });
|
||||
|
||||
try {
|
||||
await checkSecurity(shareId, hashedPassword);
|
||||
} catch (e) {
|
||||
return res.status(403).json({ message: e });
|
||||
}
|
||||
|
||||
addVisitorCount(shareId);
|
||||
|
||||
const fileListWithoutPreview = (await awServer.storage.listFiles(shareId))
|
||||
.files;
|
||||
|
||||
for (const file of fileListWithoutPreview) {
|
||||
const filePreview = await awServer.storage.getFilePreview(
|
||||
shareId,
|
||||
file.$id
|
||||
);
|
||||
fileList.push({ ...file, preview: filePreview });
|
||||
}
|
||||
|
||||
if (hashedPassword)
|
||||
res.setHeader(
|
||||
"Set-Cookie",
|
||||
`${shareId}-password=${hashedPassword}; Path=/share/${shareId}; max-age=3600; HttpOnly`
|
||||
);
|
||||
res.status(200).json(fileList);
|
||||
};
|
||||
|
||||
const shareExists = async (shareId: string) => {
|
||||
try {
|
||||
const shareDocument = await awServer.database.getDocument<ShareDocument>(
|
||||
"shares",
|
||||
shareId
|
||||
);
|
||||
return shareDocument.enabled && shareDocument.expiresAt > Date.now();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const addVisitorCount = async (shareId: string) => {
|
||||
const currentDocument = await awServer.database.getDocument<ShareDocument>(
|
||||
"shares",
|
||||
shareId
|
||||
);
|
||||
currentDocument.visitorCount++;
|
||||
|
||||
awServer.database.updateDocument("shares", shareId, currentDocument);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
15
src/pages/auth/signIn.tsx
Normal file
15
src/pages/auth/signIn.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useContext } from "react";
|
||||
import AuthForm from "../../components/auth/AuthForm";
|
||||
import { IsSignedInContext } from "../../utils/auth.util";
|
||||
|
||||
const SignIn = () => {
|
||||
const isSignedIn = useContext(IsSignedInContext);
|
||||
const router = useRouter();
|
||||
if (isSignedIn) {
|
||||
router.replace("/");
|
||||
} else {
|
||||
return <AuthForm mode="signIn" />;
|
||||
}
|
||||
};
|
||||
export default SignIn;
|
||||
15
src/pages/auth/signUp.tsx
Normal file
15
src/pages/auth/signUp.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useContext } from "react";
|
||||
import AuthForm from "../../components/auth/AuthForm";
|
||||
import { IsSignedInContext } from "../../utils/auth.util";
|
||||
|
||||
const SignUp = () => {
|
||||
const isSignedIn = useContext(IsSignedInContext);
|
||||
const router = useRouter();
|
||||
if (isSignedIn) {
|
||||
router.replace("/");
|
||||
} else {
|
||||
return <AuthForm mode="signUp" />;
|
||||
}
|
||||
};
|
||||
export default SignUp;
|
||||
151
src/pages/index.tsx
Normal file
151
src/pages/index.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
createStyles,
|
||||
Group,
|
||||
List,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { NextLink } from "@mantine/next";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useContext } from "react";
|
||||
import { Check } from "tabler-icons-react";
|
||||
import { IsSignedInContext } from "../utils/auth.util";
|
||||
import Image from "next/image";
|
||||
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,
|
||||
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
|
||||
fontSize: 44,
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 900,
|
||||
|
||||
[theme.fn.smallerThan("xs")]: {
|
||||
fontSize: 28,
|
||||
},
|
||||
},
|
||||
|
||||
control: {
|
||||
[theme.fn.smallerThan("xs")]: {
|
||||
flex: 1,
|
||||
},
|
||||
},
|
||||
|
||||
image: {
|
||||
flex: 1,
|
||||
|
||||
[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 isSignedIn = useContext(IsSignedInContext);
|
||||
const { classes } = useStyles();
|
||||
const router = useRouter();
|
||||
if (isSignedIn) {
|
||||
router.replace("/upload");
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
alt="Pingvin Share Logo"
|
||||
width={200}
|
||||
height={200}
|
||||
className={classes.image}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
54
src/pages/share/[shareId].tsx
Normal file
54
src/pages/share/[shareId].tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import FileList from "../../components/share/FileList";
|
||||
import showEnterPasswordModal from "../../components/share/showEnterPasswordModal";
|
||||
import showShareNotFoundModal from "../../components/share/showShareNotFoundModal";
|
||||
import showVisitorLimitExceededModal from "../../components/share/showVisitorLimitExceededModal";
|
||||
import shareService from "../../services/share.service";
|
||||
import { AppwriteFileWithPreview } from "../../types/File.type";
|
||||
|
||||
const Share = () => {
|
||||
const router = useRouter();
|
||||
const modals = useModals();
|
||||
const shareId = router.query.shareId as string;
|
||||
const [shareList, setShareList] = useState<AppwriteFileWithPreview[]>([]);
|
||||
|
||||
const submitPassword = async (password: string) => {
|
||||
await shareService.authenticateWithPassword(shareId, password).then(() => {
|
||||
modals.closeAll();
|
||||
getFiles();
|
||||
});
|
||||
};
|
||||
|
||||
const getFiles = (password?: string) =>
|
||||
shareService
|
||||
.get(shareId, password)
|
||||
.then((files) => setShareList(files))
|
||||
.catch((e) => {
|
||||
const error = e.response.data.message;
|
||||
if (e.response.status == 404) {
|
||||
showShareNotFoundModal(modals);
|
||||
} else if (error == "password_required") {
|
||||
showEnterPasswordModal(modals, submitPassword);
|
||||
} else if (error == "visitor_limit_exceeded") {
|
||||
showVisitorLimitExceededModal(modals);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
getFiles();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FileList
|
||||
files={shareList}
|
||||
shareId={shareId}
|
||||
isLoading={shareList.length == 0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Share;
|
||||
103
src/pages/upload.tsx
Normal file
103
src/pages/upload.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Button, Group, Menu } from "@mantine/core";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { useRouter } from "next/router";
|
||||
import { useContext, useState } from "react";
|
||||
import { Link, Mail } from "tabler-icons-react";
|
||||
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 { FileUpload } from "../types/File.type";
|
||||
import aw from "../utils/appwrite.util";
|
||||
import { IsSignedInContext } from "../utils/auth.util";
|
||||
import toast from "../utils/toast.util";
|
||||
|
||||
const Upload = () => {
|
||||
const router = useRouter();
|
||||
const modals = useModals();
|
||||
const isSignedIn = useContext(IsSignedInContext);
|
||||
const [files, setFiles] = useState<FileUpload[]>([]);
|
||||
const [isUploading, setisUploading] = useState(false);
|
||||
|
||||
const uploadFiles = async (
|
||||
id: string,
|
||||
expiration: number,
|
||||
security: { password?: string; maxVisitors?: number }
|
||||
) => {
|
||||
setisUploading(true);
|
||||
|
||||
const bucketId = JSON.parse(
|
||||
(
|
||||
await aw.functions.createExecution(
|
||||
"createShare",
|
||||
JSON.stringify({ id, security, expiration }),
|
||||
false
|
||||
)
|
||||
).stdout
|
||||
).id;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
files[i].uploadingState = "inProgress";
|
||||
setFiles([...files]);
|
||||
aw.storage.createFile(bucketId, "unique()", files[i]).then(
|
||||
async () => {
|
||||
files[i].uploadingState = "finished";
|
||||
setFiles([...files]);
|
||||
if (!files.some((f) => f.uploadingState == "inProgress")) {
|
||||
await aw.functions.createExecution(
|
||||
"finishShare",
|
||||
JSON.stringify({ id }),
|
||||
false
|
||||
),
|
||||
setisUploading(false);
|
||||
showCompletedUploadModal(
|
||||
modals,
|
||||
`${window.location.origin}/share/${bucketId}`,
|
||||
new Date(Date.now()).toLocaleString()
|
||||
);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
files[i].uploadingState = undefined;
|
||||
toast.error(error.message);
|
||||
setisUploading(false);
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isSignedIn) {
|
||||
router.replace("/");
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<Group position="right" mb={20}>
|
||||
<div>
|
||||
<Menu
|
||||
control={
|
||||
<Button loading={isUploading} disabled={files.length <= 0}>
|
||||
Share
|
||||
</Button>
|
||||
}
|
||||
transition="pop-top-right"
|
||||
placement="end"
|
||||
size="lg"
|
||||
>
|
||||
<Menu.Item
|
||||
icon={<Link size={16} />}
|
||||
onClick={() => showCreateUploadModal(modals, uploadFiles)}
|
||||
>
|
||||
Share with link
|
||||
</Menu.Item>
|
||||
<Menu.Item disabled icon={<Mail size={16} />}>
|
||||
Share with email
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</div>
|
||||
</Group>
|
||||
<Dropzone setFiles={setFiles} isUploading={isUploading} />
|
||||
{files.length > 0 && <FileList files={files} setFiles={setFiles} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
export default Upload;
|
||||
5
src/pages/user/account.tsx
Normal file
5
src/pages/user/account.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const Account = () => {
|
||||
return <div></div>;
|
||||
};
|
||||
|
||||
export default Account;
|
||||
19
src/services/share.service.ts
Normal file
19
src/services/share.service.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import axios from "axios";
|
||||
import { AppwriteFileWithPreview } from "../types/File.type";
|
||||
|
||||
const get = async (shareId: string, password?: string) => {
|
||||
return (
|
||||
await axios.post(`http://localhost:3000/api/share/${shareId}`, { password })
|
||||
).data as AppwriteFileWithPreview[];
|
||||
};
|
||||
|
||||
const authenticateWithPassword = async (shareId: string, password?: string) => {
|
||||
return (
|
||||
await axios.post(
|
||||
`http://localhost:3000/api/share/${shareId}/enterPassword`,
|
||||
{ password }
|
||||
)
|
||||
).data as AppwriteFileWithPreview[];
|
||||
};
|
||||
|
||||
export default { get, authenticateWithPassword };
|
||||
19
src/styles/global.style.ts
Normal file
19
src/styles/global.style.ts
Normal 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",
|
||||
};
|
||||
80
src/styles/header.style.ts
Normal file
80
src/styles/header.style.ts
Normal 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],
|
||||
},
|
||||
},
|
||||
}));
|
||||
15
src/types/Appwrite.type.ts
Normal file
15
src/types/Appwrite.type.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Models } from "node-appwrite";
|
||||
|
||||
export type ShareDocument = {
|
||||
securityID: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
visitorCount: number;
|
||||
enabled: boolean;
|
||||
} & Models.Document;
|
||||
|
||||
|
||||
export type SecurityDocument = {
|
||||
password: string;
|
||||
maxVisitors: number;
|
||||
} & Models.Document;
|
||||
7
src/types/File.type.ts
Normal file
7
src/types/File.type.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Models } from "appwrite";
|
||||
|
||||
export type FileUpload = File & { uploadingState?: UploadState };
|
||||
export type UploadState = "finished" | "inProgress" | undefined;
|
||||
|
||||
export type AppwriteFileWithPreview = Models.File & { preview: Buffer };
|
||||
|
||||
9
src/utils/appwrite.util.ts
Normal file
9
src/utils/appwrite.util.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Appwrite } from "appwrite";
|
||||
|
||||
// SDK for client side (browser)
|
||||
const aw = new Appwrite();
|
||||
|
||||
aw.setEndpoint("http://localhost:86/v1")
|
||||
.setProject("pingvin-share");
|
||||
|
||||
export default aw;
|
||||
17
src/utils/appwriteServer.util.ts
Normal file
17
src/utils/appwriteServer.util.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import sdk from "node-appwrite";
|
||||
|
||||
// SDK for server side (api)
|
||||
const client = new sdk.Client();
|
||||
|
||||
client
|
||||
.setEndpoint(process.env["APPWRITE_HOST"] as string)
|
||||
.setProject("pingvin-share")
|
||||
.setKey(process.env["APPWRITE_FUNCTION_API_KEY"] as string);
|
||||
|
||||
const awServer = {
|
||||
user: new sdk.Users(client),
|
||||
storage: new sdk.Storage(client),
|
||||
database: new sdk.Database(client),
|
||||
};
|
||||
|
||||
export default awServer;
|
||||
17
src/utils/auth.util.ts
Normal file
17
src/utils/auth.util.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createContext } from "react";
|
||||
import aw from "./appwrite.util";
|
||||
|
||||
const isSignedIn = async() => {
|
||||
try {
|
||||
await aw.account.get();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const IsSignedInContext = createContext(false);
|
||||
|
||||
export default {
|
||||
isSignedIn,
|
||||
};
|
||||
6
src/utils/loading.util.ts
Normal file
6
src/utils/loading.util.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createContext, Dispatch, SetStateAction } from "react";
|
||||
|
||||
export const GlobalLoadingContext = createContext<{
|
||||
isLoading: boolean;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
}>({ isLoading: false, setIsLoading: () => {} });
|
||||
6
src/utils/math/byteToSize.util.ts
Normal file
6
src/utils/math/byteToSize.util.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function bytesToSize(bytes: number) {
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
if (bytes == 0) return "0 Byte";
|
||||
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
|
||||
return (bytes / Math.pow(1024, i)).toFixed(1).toString() + " " + sizes[i];
|
||||
}
|
||||
35
src/utils/shares/security.util.ts
Normal file
35
src/utils/shares/security.util.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { scryptSync } from "crypto";
|
||||
import { SecurityDocument, ShareDocument } from "../../types/Appwrite.type";
|
||||
import awServer from "../appwriteServer.util";
|
||||
|
||||
export const hashPassword = (password: string, salt: string) => {
|
||||
return scryptSync(password, salt, 64).toString("hex");
|
||||
};
|
||||
|
||||
export const checkSecurity = async (
|
||||
shareId: string,
|
||||
hashedPassword?: string
|
||||
) => {
|
||||
const shareDocument = await awServer.database.getDocument<ShareDocument>(
|
||||
"shares",
|
||||
shareId
|
||||
);
|
||||
if (!shareDocument.securityID) return;
|
||||
await awServer.database
|
||||
.getDocument<SecurityDocument>("shareSecurity", shareDocument.securityID)
|
||||
.then((securityDocument) => {
|
||||
if (securityDocument.maxVisitors) {
|
||||
if (shareDocument.visitorCount > securityDocument.maxVisitors) {
|
||||
throw "visitor_limit_exceeded";
|
||||
}
|
||||
}
|
||||
if (securityDocument.password) {
|
||||
if (!hashedPassword) throw "password_required";
|
||||
|
||||
if (hashedPassword !== securityDocument.password) {
|
||||
throw "wrong_password";
|
||||
}
|
||||
}
|
||||
});
|
||||
return { hashedPassword };
|
||||
};
|
||||
26
src/utils/toast.util.tsx
Normal file
26
src/utils/toast.util.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user