Merge pull request #262 from linkwarden/dev

Dev
This commit is contained in:
Daniel 2023-10-24 17:23:14 -04:00 committed by GitHub
commit 4454e615b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 557 additions and 290 deletions

View File

@ -8,6 +8,7 @@ PAGINATION_TAKE_COUNT=
STORAGE_FOLDER= STORAGE_FOLDER=
AUTOSCROLL_TIMEOUT= AUTOSCROLL_TIMEOUT=
NEXT_PUBLIC_DISABLE_REGISTRATION= NEXT_PUBLIC_DISABLE_REGISTRATION=
IMPORT_SIZE_LIMIT=
# AWS S3 Settings # AWS S3 Settings
SPACES_KEY= SPACES_KEY=

View File

@ -1,5 +1,5 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEllipsis, faLink } from "@fortawesome/free-solid-svg-icons"; import { faEllipsis, faGlobe, faLink } from "@fortawesome/free-solid-svg-icons";
import Link from "next/link"; import Link from "next/link";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import Dropdown from "./Dropdown"; import Dropdown from "./Dropdown";
@ -82,6 +82,13 @@ export default function CollectionCard({ collection, className }: Props) {
</div> </div>
<div className="text-right w-40"> <div className="text-right w-40">
<div className="text-black dark:text-white font-bold text-sm flex justify-end gap-1 items-center"> <div className="text-black dark:text-white font-bold text-sm flex justify-end gap-1 items-center">
{collection.isPublic ? (
<FontAwesomeIcon
icon={faGlobe}
title="This collection is being shared publicly."
className="w-4 h-4 drop-shadow text-gray-500 dark:text-gray-300"
/>
) : undefined}
<FontAwesomeIcon <FontAwesomeIcon
icon={faLink} icon={faLink}
className="w-5 h-5 text-gray-500 dark:text-gray-300" className="w-5 h-5 text-gray-500 dark:text-gray-300"

View File

@ -41,7 +41,7 @@ export default function Search() {
}} }}
onKeyDown={(e) => onKeyDown={(e) =>
e.key === "Enter" && e.key === "Enter" &&
router.push("/search/" + encodeURIComponent(searchQuery)) router.push("/search?q=" + encodeURIComponent(searchQuery))
} }
autoFocus={searchBox} autoFocus={searchBox}
className="border border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 rounded-md pl-10 py-2 pr-2 w-44 sm:w-60 dark:hover:border-neutral-600 md:focus:w-80 hover:border-sky-300 duration-100 outline-none dark:bg-neutral-800" className="border border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 rounded-md pl-10 py-2 pr-2 w-44 sm:w-60 dark:hover:border-neutral-600 md:focus:w-80 hover:border-sky-300 duration-100 outline-none dark:bg-neutral-800"

View File

@ -6,6 +6,7 @@ import {
faChartSimple, faChartSimple,
faChevronDown, faChevronDown,
faLink, faLink,
faGlobe,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import useTagStore from "@/store/tags"; import useTagStore from "@/store/tags";
import Link from "next/link"; import Link from "next/link";
@ -151,10 +152,17 @@ export default function Sidebar({ className }: { className?: string }) {
className="w-6 h-6 drop-shadow" className="w-6 h-6 drop-shadow"
style={{ color: e.color }} style={{ color: e.color }}
/> />
<p className="text-black dark:text-white truncate w-full">
<p className="text-black dark:text-white truncate w-full pr-7">
{e.name} {e.name}
</p> </p>
{e.isPublic ? (
<FontAwesomeIcon
icon={faGlobe}
title="This collection is being shared publicly."
className="w-4 h-4 drop-shadow text-gray-500 dark:text-gray-300"
/>
) : undefined}
</div> </div>
</Link> </Link>
); );

View File

@ -12,7 +12,7 @@ export default function CenteredForm({ text, children }: Props) {
const { theme } = useTheme(); const { theme } = useTheme();
return ( return (
<div className="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center p-5"> <div className="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center p-5">
<div className="m-auto flex flex-col gap-2"> <div className="m-auto flex flex-col gap-2 w-full">
{theme === "dark" ? ( {theme === "dark" ? (
<Image <Image
src="/linkwarden_dark.png" src="/linkwarden_dark.png"
@ -31,7 +31,7 @@ export default function CenteredForm({ text, children }: Props) {
/> />
)} )}
{text ? ( {text ? (
<p className="text-lg sm:w-[30rem] w-80 mx-auto font-semibold text-black dark:text-white px-2 text-center"> <p className="text-lg max-w-[30rem] min-w-80 w-full mx-auto font-semibold text-black dark:text-white px-2 text-center">
{text} {text}
</p> </p>
) : undefined} ) : undefined}

View File

@ -2,14 +2,8 @@ import { prisma } from "@/lib/api/db";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import removeFolder from "@/lib/api/storage/removeFolder"; import removeFolder from "@/lib/api/storage/removeFolder";
import Stripe from "stripe"; import Stripe from "stripe";
import { DeleteUserBody } from "@/types/global";
type DeleteUserBody = { import removeFile from "@/lib/api/storage/removeFile";
password: string;
cancellation_details?: {
comment?: string;
feedback?: Stripe.SubscriptionCancelParams.CancellationDetails.Feedback;
};
};
export default async function deleteUserById( export default async function deleteUserById(
userId: number, userId: number,
@ -22,7 +16,7 @@ export default async function deleteUserById(
if (!user) { if (!user) {
return { return {
response: "User not found.", response: "Invalid credentials.",
status: 404, status: 404,
}; };
} }
@ -32,7 +26,7 @@ export default async function deleteUserById(
if (!isPasswordValid) { if (!isPasswordValid) {
return { return {
response: "Invalid password.", response: "Invalid credentials.",
status: 401, // Unauthorized status: 401, // Unauthorized
}; };
} }
@ -54,7 +48,7 @@ export default async function deleteUserById(
where: { ownerId: userId }, where: { ownerId: userId },
}); });
// Delete collections // Find collections that the user owns
const collections = await prisma.collection.findMany({ const collections = await prisma.collection.findMany({
where: { ownerId: userId }, where: { ownerId: userId },
}); });
@ -65,7 +59,7 @@ export default async function deleteUserById(
where: { collectionId: collection.id }, where: { collectionId: collection.id },
}); });
// Optionally delete archive folders associated with collections // Delete archive folders associated with collections
removeFolder({ filePath: `archives/${collection.id}` }); removeFolder({ filePath: `archives/${collection.id}` });
} }
@ -74,8 +68,8 @@ export default async function deleteUserById(
where: { ownerId: userId }, where: { ownerId: userId },
}); });
// Optionally delete user's avatar // Delete user's avatar
removeFolder({ filePath: `uploads/avatar/${userId}.jpg` }); removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
// Finally, delete the user // Finally, delete the user
await prisma.user.delete({ await prisma.user.delete({
@ -88,26 +82,30 @@ export default async function deleteUserById(
apiVersion: "2022-11-15", apiVersion: "2022-11-15",
}); });
const listByEmail = await stripe.customers.list({ try {
email: user.email?.toLowerCase(), const listByEmail = await stripe.customers.list({
expand: ["data.subscriptions"], email: user.email?.toLowerCase(),
}); expand: ["data.subscriptions"],
});
if (listByEmail.data[0].subscriptions?.data[0].id) { if (listByEmail.data[0].subscriptions?.data[0].id) {
const deleted = await stripe.subscriptions.cancel( const deleted = await stripe.subscriptions.cancel(
listByEmail.data[0].subscriptions?.data[0].id, listByEmail.data[0].subscriptions?.data[0].id,
{ {
cancellation_details: { cancellation_details: {
comment: body.cancellation_details?.comment, comment: body.cancellation_details?.comment,
feedback: body.cancellation_details?.feedback, feedback: body.cancellation_details?.feedback,
}, },
} }
); );
return { return {
response: deleted, response: deleted,
status: 200, status: 200,
}; };
}
} catch (err) {
console.log(err);
} }
} }

View File

@ -6,6 +6,14 @@ import importFromHTMLFile from "@/lib/api/controllers/migration/importFromHTMLFi
import importFromLinkwarden from "@/lib/api/controllers/migration/importFromLinkwarden"; import importFromLinkwarden from "@/lib/api/controllers/migration/importFromLinkwarden";
import { MigrationFormat, MigrationRequest } from "@/types/global"; import { MigrationFormat, MigrationRequest } from "@/types/global";
export const config = {
api: {
bodyParser: {
sizeLimit: process.env.IMPORT_SIZE_LIMIT || "2mb",
},
},
};
export default async function users(req: NextApiRequest, res: NextApiResponse) { export default async function users(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions); const session = await getServerSession(req, res, authOptions);

View File

@ -41,11 +41,13 @@ export default function ChooseUsername() {
return ( return (
<CenteredForm> <CenteredForm>
<form onSubmit={submitUsername}> <form onSubmit={submitUsername}>
<div className="p-4 mx-auto flex flex-col gap-3 justify-between sm:w-[30rem] w-80 bg-slate-50 dark:border-neutral-700 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100"> <div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-slate-50 dark:border-neutral-700 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100">
<p className="text-2xl text-center text-black dark:text-white font-bold"> <p className="text-3xl text-center text-black dark:text-white font-extralight">
Choose a Username (Last step) Choose a Username
</p> </p>
<hr className="border-1 border-sky-100 dark:border-neutral-700" />
<div> <div>
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1"> <p className="text-sm text-black dark:text-white w-fit font-semibold mb-1">
Username Username

View File

@ -72,7 +72,7 @@ export default function Index() {
style={{ color: activeCollection?.color }} style={{ color: activeCollection?.color }}
className="sm:w-8 sm:h-8 w-6 h-6 mt-3 drop-shadow" className="sm:w-8 sm:h-8 w-6 h-6 mt-3 drop-shadow"
/> />
<p className="sm:text-4xl text-3xl capitalize text-black dark:text-white w-full py-1 break-words hyphens-auto"> <p className="sm:text-4xl text-3xl capitalize text-black dark:text-white w-full py-1 break-words hyphens-auto font-thin">
{activeCollection?.name} {activeCollection?.name}
</p> </p>
</div> </div>
@ -220,7 +220,7 @@ export default function Index() {
if (target.id !== "expand-dropdown") if (target.id !== "expand-dropdown")
setExpandDropdown(false); setExpandDropdown(false);
}} }}
className="absolute top-8 right-0 z-10 w-fit" className="absolute top-8 right-0 z-10 w-40"
/> />
) : null} ) : null}
</div> </div>

View File

@ -26,7 +26,7 @@ export default function Collections() {
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
const [sortedCollections, setSortedCollections] = useState(collections); const [sortedCollections, setSortedCollections] = useState(collections);
const session = useSession(); const { data } = useSession();
const { setModal } = useModalStore(); const { setModal } = useModalStore();
@ -35,18 +35,24 @@ export default function Collections() {
return ( return (
<MainLayout> <MainLayout>
<div className="p-5"> <div className="p-5">
<div className="flex gap-3 items-center justify-between mb-5"> <div className="flex gap-3 justify-between mb-5">
<div className="flex gap-3 items-end"> <div className="flex gap-3">
<div className="flex gap-2"> <div className="flex items-center gap-3">
<FontAwesomeIcon <FontAwesomeIcon
icon={faFolder} icon={faFolder}
className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-sky-500 dark:text-sky-500 drop-shadow" className="sm:w-10 sm:h-10 w-6 h-6 text-sky-500 dark:text-sky-500 drop-shadow"
/> />
<p className="sm:text-4xl text-3xl capitalize text-black dark:text-white"> <div>
All Collections <p className="text-3xl capitalize text-black dark:text-white font-thin">
</p> Your Collections
</p>
<p className="capitalize text-black dark:text-white">
Collections you own
</p>
</div>
</div> </div>
<div className="relative"> <div className="relative mt-2">
<div <div
onClick={() => setExpandDropdown(!expandDropdown)} onClick={() => setExpandDropdown(!expandDropdown)}
id="expand-dropdown" id="expand-dropdown"
@ -79,13 +85,13 @@ export default function Collections() {
if (target.id !== "expand-dropdown") if (target.id !== "expand-dropdown")
setExpandDropdown(false); setExpandDropdown(false);
}} }}
className="absolute top-8 left-0 w-36" className="absolute top-8 sm:left-0 right-0 sm:right-auto w-36"
/> />
) : null} ) : null}
</div> </div>
</div> </div>
<div className="relative"> <div className="relative mt-2">
<div <div
onClick={() => setSortDropdown(!sortDropdown)} onClick={() => setSortDropdown(!sortDropdown)}
id="sort-dropdown" id="sort-dropdown"
@ -109,9 +115,11 @@ export default function Collections() {
</div> </div>
<div className="grid xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5"> <div className="grid xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
{sortedCollections.map((e, i) => { {sortedCollections
return <CollectionCard key={i} collection={e} />; .filter((e) => e.ownerId === data?.user.id)
})} .map((e, i) => {
return <CollectionCard key={i} collection={e} />;
})}
<div <div
className="p-5 bg-gray-50 dark:bg-neutral-800 self-stretch border border-solid border-sky-100 dark:border-neutral-700 min-h-[12rem] rounded-2xl cursor-pointer shadow duration-100 hover:shadow-none flex flex-col gap-4 justify-center items-center group" className="p-5 bg-gray-50 dark:bg-neutral-800 self-stretch border border-solid border-sky-100 dark:border-neutral-700 min-h-[12rem] rounded-2xl cursor-pointer shadow duration-100 hover:shadow-none flex flex-col gap-4 justify-center items-center group"
@ -132,6 +140,34 @@ export default function Collections() {
/> />
</div> </div>
</div> </div>
{sortedCollections.filter((e) => e.ownerId !== data?.user.id)[0] ? (
<>
<div className="flex items-center gap-3 my-5">
<FontAwesomeIcon
icon={faFolder}
className="sm:w-10 sm:h-10 w-6 h-6 text-sky-500 dark:text-sky-500 drop-shadow"
/>
<div>
<p className="text-3xl capitalize text-black dark:text-white font-thin">
Other Collections
</p>
<p className="capitalize text-black dark:text-white">
Shared collections you're a member of
</p>
</div>
</div>
<div className="grid xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
{sortedCollections
.filter((e) => e.ownerId !== data?.user.id)
.map((e, i) => {
return <CollectionCard key={i} collection={e} />;
})}
</div>
</>
) : undefined}
</div> </div>
</MainLayout> </MainLayout>
); );

View File

@ -5,22 +5,21 @@ import React from "react";
export default function EmailConfirmaion() { export default function EmailConfirmaion() {
return ( return (
<CenteredForm> <CenteredForm>
<div className="p-4 sm:w-[30rem] w-80 rounded-2xl shadow-md m-auto border border-sky-100 dark:border-neutral-700 bg-slate-50 text-black dark:text-white dark:bg-neutral-800"> <div className="p-4 max-w-[30rem] min-w-80 w-full rounded-2xl shadow-md mx-auto border border-sky-100 dark:border-neutral-700 bg-slate-50 text-black dark:text-white dark:bg-neutral-800">
<p className="text-center text-xl font-bold mb-2"> <p className="text-center text-2xl sm:text-3xl font-extralight mb-2 ">
Please check your Email Please check your Email
</p> </p>
<hr className="border-1 border-sky-100 dark:border-neutral-700 my-3" />
<p>A sign in link has been sent to your email address.</p> <p>A sign in link has been sent to your email address.</p>
<p>You can safely close this page.</p>
<hr className="my-5 dark:border-neutral-700" /> <p className="mt-3">
Didn&apos;t see the email? Check your spam folder or visit the{" "}
<p className="text-sm text-gray-500 dark:text-gray-400">
Didn&apos;t find the email in your inbox? Check your spam folder or
visit the{" "}
<Link href="/forgot" className="font-bold underline"> <Link href="/forgot" className="font-bold underline">
Password Recovery Password Recovery
</Link>{" "} </Link>{" "}
page to resend the sign-in link by entering your email. page to resend the link.
</p> </p>
</div> </div>
</CenteredForm> </CenteredForm>

View File

@ -124,7 +124,7 @@ export default function Dashboard() {
icon={faChartSimple} icon={faChartSimple}
className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-sky-500 dark:text-sky-500 drop-shadow" className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-sky-500 dark:text-sky-500 drop-shadow"
/> />
<p className="sm:text-4xl text-3xl text-black dark:text-white"> <p className="sm:text-4xl text-3xl text-black dark:text-white font-thin">
Dashboard Dashboard
</p> </p>
</div> </div>

View File

@ -43,13 +43,16 @@ export default function Forgot() {
return ( return (
<CenteredForm> <CenteredForm>
<form onSubmit={sendConfirmation}> <form onSubmit={sendConfirmation}>
<div className="p-4 flex flex-col gap-3 justify-between sm:w-[30rem] w-80 bg-slate-50 dark:border-neutral-700 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100"> <div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-slate-50 dark:border-neutral-700 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100">
<p className="text-2xl text-center text-black dark:text-white font-bold"> <p className="text-3xl text-center text-black dark:text-white font-extralight">
Password Recovery Password Recovery
</p> </p>
<hr className="border-1 border-sky-100 dark:border-neutral-700" />
<div> <div>
<p className="text-md text-black dark:text-white"> <p className="text-black dark:text-white">
Enter your Email so we can send you a link to recover your Enter your email so we can send you a link to recover your
account. Make sure to change your password in the profile settings account. Make sure to change your password in the profile settings
afterwards. afterwards.
</p> </p>

View File

@ -20,18 +20,24 @@ export default function Links() {
return ( return (
<MainLayout> <MainLayout>
<div className="p-5 flex flex-col gap-5 w-full h-full"> <div className="p-5 flex flex-col gap-5 w-full h-full">
<div className="flex gap-3 justify-between items-center"> <div className="flex gap-3 justify-between">
<div className="flex gap-2"> <div className="flex items-center gap-3">
<FontAwesomeIcon <FontAwesomeIcon
icon={faLink} icon={faLink}
className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-sky-500 dark:text-sky-500 drop-shadow" className="sm:w-10 sm:h-10 w-6 h-6 text-sky-500 dark:text-sky-500 drop-shadow"
/> />
<p className="sm:text-4xl text-3xl capitalize text-black dark:text-white"> <div>
All Links <p className="text-3xl capitalize text-black dark:text-white font-thin">
</p> All Links
</p>
<p className="capitalize text-black dark:text-white">
All Links from every Collections
</p>
</div>
</div> </div>
<div className="relative"> <div className="relative mt-2">
<div <div
onClick={() => setSortDropdown(!sortDropdown)} onClick={() => setSortDropdown(!sortDropdown)}
id="sort-dropdown" id="sort-dropdown"

View File

@ -50,11 +50,13 @@ export default function Login() {
return ( return (
<CenteredForm text="Sign in to your account"> <CenteredForm text="Sign in to your account">
<form onSubmit={loginUser}> <form onSubmit={loginUser}>
<div className="p-4 flex flex-col gap-3 justify-between sm:w-[30rem] w-80 bg-slate-50 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100 dark:border-neutral-700"> <div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-slate-50 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100 dark:border-neutral-700">
<p className="text-2xl text-black dark:text-white text-center font-bold"> <p className="text-3xl text-black dark:text-white text-center font-extralight">
Enter your credentials Enter your credentials
</p> </p>
<hr className="border-1 border-sky-100 dark:border-neutral-700" />
<div> <div>
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1"> <p className="text-sm text-black dark:text-white w-fit font-semibold mb-1">
Username Username
@ -70,7 +72,7 @@ export default function Login() {
/> />
</div> </div>
<div> <div className="w-full">
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1"> <p className="text-sm text-black dark:text-white w-fit font-semibold mb-1">
Password Password
</p> </p>

View File

@ -32,62 +32,64 @@ export default function Register() {
async function registerUser(event: FormEvent<HTMLFormElement>) { async function registerUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
const checkFields = () => { if (!submitLoader) {
if (emailEnabled) { const checkFields = () => {
return ( if (emailEnabled) {
form.name !== "" && return (
form.email !== "" && form.name !== "" &&
form.password !== "" && form.email !== "" &&
form.passwordConfirmation !== "" form.password !== "" &&
); form.passwordConfirmation !== ""
);
} else {
return (
form.name !== "" &&
form.username !== "" &&
form.password !== "" &&
form.passwordConfirmation !== ""
);
}
};
if (checkFields()) {
if (form.password !== form.passwordConfirmation)
return toast.error("Passwords do not match.");
else if (form.password.length < 8)
return toast.error("Passwords must be at least 8 characters.");
const { passwordConfirmation, ...request } = form;
setSubmitLoader(true);
const load = toast.loading("Creating Account...");
const response = await fetch("/api/v1/users", {
body: JSON.stringify(request),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const data = await response.json();
toast.dismiss(load);
setSubmitLoader(false);
if (response.ok) {
if (form.email && emailEnabled)
await signIn("email", {
email: form.email,
callbackUrl: "/",
});
else if (!emailEnabled) router.push("/login");
toast.success("User Created!");
} else {
toast.error(data.response);
}
} else { } else {
return ( toast.error("Please fill out all the fields.");
form.name !== "" &&
form.username !== "" &&
form.password !== "" &&
form.passwordConfirmation !== ""
);
} }
};
if (checkFields()) {
if (form.password !== form.passwordConfirmation)
return toast.error("Passwords do not match.");
else if (form.password.length < 8)
return toast.error("Passwords must be at least 8 characters.");
const { passwordConfirmation, ...request } = form;
setSubmitLoader(true);
const load = toast.loading("Creating Account...");
const response = await fetch("/api/v1/users", {
body: JSON.stringify(request),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const data = await response.json();
toast.dismiss(load);
setSubmitLoader(false);
if (response.ok) {
if (form.email && emailEnabled)
await signIn("email", {
email: form.email,
callbackUrl: "/",
});
else if (!emailEnabled) router.push("/login");
toast.success("User Created!");
} else {
toast.error(data.response);
}
} else {
toast.error("Please fill out all the fields.");
} }
} }
@ -95,14 +97,14 @@ export default function Register() {
<CenteredForm <CenteredForm
text={ text={
process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE
? `Start using our Premium Services with a ${ ? `Unlock ${
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14 process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14
}-day free trial!` } days of Premium Service at no cost!`
: "Create a new account" : "Create a new account"
} }
> >
{process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" ? ( {process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" ? (
<div className="p-4 flex flex-col gap-3 justify-between sm:w-[30rem] w-80 bg-slate-50 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100 dark:border-neutral-700"> <div className="p-4 flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-slate-50 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100 dark:border-neutral-700">
<p> <p>
Registration is disabled for this instance, please contact the admin Registration is disabled for this instance, please contact the admin
in case of any issues. in case of any issues.
@ -110,10 +112,13 @@ export default function Register() {
</div> </div>
) : ( ) : (
<form onSubmit={registerUser}> <form onSubmit={registerUser}>
<div className="p-4 flex flex-col gap-3 justify-between sm:w-[30rem] w-80 bg-slate-50 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100 dark:border-neutral-700"> <div className="p-4 flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full mx-auto bg-slate-50 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100 dark:border-neutral-700">
<p className="text-2xl text-black dark:text-white text-center font-bold"> <p className="text-3xl text-black dark:text-white text-center font-extralight">
Enter your details Enter your details
</p> </p>
<hr className="border-1 border-sky-100 dark:border-neutral-700" />
<div> <div>
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1"> <p className="text-sm text-black dark:text-white w-fit font-semibold mb-1">
Display Name Display Name
@ -223,12 +228,12 @@ export default function Register() {
</div> </div>
) : undefined} ) : undefined}
<SubmitButton <button
type="submit" type="submit"
label="Sign Up" className={`border primary-btn-gradient select-none duration-100 bg-black border-[#0071B7] hover:border-[#059bf8] rounded-lg text-center px-4 py-2 text-slate-200 hover:text-white `}
className="mt-2 w-full text-center" >
loading={submitLoader} <p className="text-center w-full font-bold">Sign Up</p>
/> </button>
<div className="flex items-baseline gap-1 justify-center"> <div className="flex items-baseline gap-1 justify-center">
<p className="w-fit text-gray-500 dark:text-gray-400"> <p className="w-fit text-gray-500 dark:text-gray-400">
Already have an account? Already have an account?

View File

@ -1,117 +0,0 @@
import FilterSearchDropdown from "@/components/FilterSearchDropdown";
import LinkCard from "@/components/LinkCard";
import SortDropdown from "@/components/SortDropdown";
import useLinks from "@/hooks/useLinks";
import MainLayout from "@/layouts/MainLayout";
import useLinkStore from "@/store/links";
import { Sort } from "@/types/global";
import { faFilter, faSearch, faSort } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useRouter } from "next/router";
import { useState } from "react";
export default function Links() {
const { links } = useLinkStore();
const router = useRouter();
const [searchFilter, setSearchFilter] = useState({
name: true,
url: true,
description: true,
tags: true,
});
const [filterDropdown, setFilterDropdown] = useState(false);
const [sortDropdown, setSortDropdown] = useState(false);
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
useLinks({
sort: sortBy,
searchQueryString: router.query.query as string,
searchByName: searchFilter.name,
searchByUrl: searchFilter.url,
searchByDescription: searchFilter.description,
searchByTags: searchFilter.tags,
});
return (
<MainLayout>
<div className="p-5 flex flex-col gap-5 w-full">
<div className="flex gap-3 items-center justify-between">
<div className="flex gap-3 items-center mb-5">
<div className="flex gap-2">
<FontAwesomeIcon
icon={faSearch}
className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-sky-500 dark:text-sky-500 drop-shadow"
/>
<p className="sm:text-4xl text-3xl capitalize text-black dark:text-white">
Search Results
</p>
</div>
</div>
<div className="flex gap-3 items-center">
<div className="relative">
<div
onClick={() => setFilterDropdown(!filterDropdown)}
id="filter-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1"
>
<FontAwesomeIcon
icon={faFilter}
id="filter-dropdown"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
{filterDropdown ? (
<FilterSearchDropdown
setFilterDropdown={setFilterDropdown}
searchFilter={searchFilter}
setSearchFilter={setSearchFilter}
/>
) : null}
</div>
<div className="relative">
<div
onClick={() => setSortDropdown(!sortDropdown)}
id="sort-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1"
>
<FontAwesomeIcon
icon={faSort}
id="sort-dropdown"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
{sortDropdown ? (
<SortDropdown
sortBy={sortBy}
setSort={setSortBy}
toggleSortDropdown={() => setSortDropdown(!sortDropdown)}
/>
) : null}
</div>
</div>
</div>
{links[0] ? (
<div className="grid 2xl:grid-cols-3 xl:grid-cols-2 grid-cols-1 gap-5">
{links.map((e, i) => {
return <LinkCard key={i} link={e} count={i} />;
})}
</div>
) : (
<p className="text-black dark:text-white">
Nothing found.{" "}
<span className="font-bold text-xl" title="Shruggie">
¯\_()_/¯
</span>
</p>
)}
</div>
</MainLayout>
);
}

View File

@ -1,10 +1,117 @@
import FilterSearchDropdown from "@/components/FilterSearchDropdown";
import LinkCard from "@/components/LinkCard";
import SortDropdown from "@/components/SortDropdown";
import useLinks from "@/hooks/useLinks";
import MainLayout from "@/layouts/MainLayout";
import useLinkStore from "@/store/links";
import { Sort } from "@/types/global";
import { faFilter, faSearch, faSort } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect } from "react"; import { useState } from "react";
export default function Search() {
const { links } = useLinkStore();
export default function Home() {
const router = useRouter(); const router = useRouter();
useEffect(() => { const [searchFilter, setSearchFilter] = useState({
router.push("/links"); name: true,
}, []); url: true,
description: true,
tags: true,
});
const [filterDropdown, setFilterDropdown] = useState(false);
const [sortDropdown, setSortDropdown] = useState(false);
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
useLinks({
sort: sortBy,
searchQueryString: decodeURIComponent(router.query.q as string),
searchByName: searchFilter.name,
searchByUrl: searchFilter.url,
searchByDescription: searchFilter.description,
searchByTags: searchFilter.tags,
});
return (
<MainLayout>
<div className="p-5 flex flex-col gap-5 w-full">
<div className="flex gap-3 items-center justify-between">
<div className="flex gap-3 items-center mb-5">
<div className="flex gap-2">
<FontAwesomeIcon
icon={faSearch}
className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-sky-500 dark:text-sky-500 drop-shadow"
/>
<p className="sm:text-4xl text-3xl capitalize text-black dark:text-white font-thin">
Search Results
</p>
</div>
</div>
<div className="flex gap-3 items-center">
<div className="relative">
<div
onClick={() => setFilterDropdown(!filterDropdown)}
id="filter-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1"
>
<FontAwesomeIcon
icon={faFilter}
id="filter-dropdown"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
{filterDropdown ? (
<FilterSearchDropdown
setFilterDropdown={setFilterDropdown}
searchFilter={searchFilter}
setSearchFilter={setSearchFilter}
/>
) : null}
</div>
<div className="relative">
<div
onClick={() => setSortDropdown(!sortDropdown)}
id="sort-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1"
>
<FontAwesomeIcon
icon={faSort}
id="sort-dropdown"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
{sortDropdown ? (
<SortDropdown
sortBy={sortBy}
setSort={setSortBy}
toggleSortDropdown={() => setSortDropdown(!sortDropdown)}
/>
) : null}
</div>
</div>
</div>
{links[0] ? (
<div className="grid 2xl:grid-cols-3 xl:grid-cols-2 grid-cols-1 gap-5">
{links.map((e, i) => {
return <LinkCard key={i} link={e} count={i} />;
})}
</div>
) : (
<p className="text-black dark:text-white">
Nothing found.{" "}
<span className="font-bold text-xl" title="Shruggie">
¯\_()_/¯
</span>
</p>
)}
</div>
</MainLayout>
);
} }

View File

@ -269,7 +269,7 @@ export default function Account() {
Import your data from other platforms. Import your data from other platforms.
</p> </p>
<div <div
onClick={() => setImportDropdown(!importDropdown)} onClick={() => setImportDropdown(true)}
className="w-fit relative" className="w-fit relative"
id="import-dropdown" id="import-dropdown"
> >
@ -387,6 +387,33 @@ export default function Account() {
label="Save" label="Save"
className="mt-2 mx-auto lg:mx-0" className="mt-2 mx-auto lg:mx-0"
/> />
<div>
<div className="flex items-center gap-2 w-full rounded-md h-8">
<p className="text-red-500 dark:text-red-500 truncate w-full pr-7 text-3xl font-thin">
Delete Account
</p>
</div>
<hr className="my-3 border-1 border-sky-100 dark:border-neutral-700" />
<p>
This will permanently delete ALL the Links, Collections, Tags, and
archived data you own.{" "}
{process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE
? "It will also cancel your subscription. "
: undefined}{" "}
You will be prompted to enter your password before the deletion
process.
</p>
<Link
href="/settings/delete"
className="mx-auto lg:mx-0 text-white mt-3 flex items-center gap-2 py-1 px-3 rounded-md text-lg tracking-wide select-none font-semibold duration-100 w-fit bg-red-500 hover:bg-red-400 cursor-pointer"
>
<p className="text-center w-full">Delete Your Account</p>
</Link>
</div>
</div> </div>
</SettingsLayout> </SettingsLayout>
); );

152
pages/settings/delete.tsx Normal file
View File

@ -0,0 +1,152 @@
import { useState } from "react";
import { toast } from "react-hot-toast";
import TextInput from "@/components/TextInput";
import CenteredForm from "@/layouts/CenteredForm";
import { signOut, useSession } from "next-auth/react";
import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
export default function Password() {
const [password, setPassword] = useState("");
const [comment, setComment] = useState<string>();
const [feedback, setFeedback] = useState<string>();
const [submitLoader, setSubmitLoader] = useState(false);
const { data } = useSession();
const submit = async () => {
const body = {
password,
cancellation_details: {
comment,
feedback,
},
};
if (password == "") {
return toast.error("Please fill the required fields.");
}
setSubmitLoader(true);
const load = toast.loading("Deleting everything, please wait...");
const response = await fetch(`/api/v1/users/${data?.user.id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const message = (await response.json()).response;
toast.dismiss(load);
if (response.ok) {
signOut();
} else toast.error(message);
setSubmitLoader(false);
};
return (
<CenteredForm>
<div className="p-4 mx-auto relative flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 dark:border-neutral-700 bg-slate-50 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100">
<Link
href="/settings/account"
className="absolute top-4 left-4 gap-1 items-center select-none cursor-pointer p-2 text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700"
>
<FontAwesomeIcon icon={faChevronLeft} className="w-5 h-5" />
</Link>
<div className="flex items-center gap-2 w-full rounded-md h-8">
<p className="text-red-500 dark:text-red-500 truncate w-full text-3xl text-center">
Delete Account
</p>
</div>
<hr className="border-1 border-sky-100 dark:border-neutral-700" />
<p>
This will permanently delete all the Links, Collections, Tags, and
archived data you own. It will also log you out
{process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE
? " and cancel your subscription"
: undefined}
. This action is irreversible!
</p>
<div>
<p className="text-sm mb-2 text-black dark:text-white">
Confirm Your Password
</p>
<TextInput
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••••••••"
type="password"
/>
</div>
{process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE ? (
<fieldset className="border rounded-md p-2 border-sky-500">
<legend className="px-3 py-1 text-sm sm:text-base border rounded-md border-sky-500">
<b>Optional</b>{" "}
<i className="min-[390px]:text-sm text-xs">
(but it really helps us improve!)
</i>
</legend>
<label className="w-full flex min-[430px]:items-center items-start gap-2 mb-3 min-[430px]:flex-row flex-col">
<p className="text-sm">Reason for cancellation:</p>
<select
className="rounded-md p-1 border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100 dark:bg-neutral-950"
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
>
<option value={undefined}>Please specify</option>
<option value="customer_service">Customer Service</option>
<option value="low_quality">Low Quality</option>
<option value="missing_features">Missing Features</option>
<option value="switched_service">Switched Service</option>
<option value="too_complex">Too Complex</option>
<option value="too_expensive">Too Expensive</option>
<option value="unused">Unused</option>
<option value="other">Other</option>
</select>
</label>
<div>
<p className="text-sm mb-2 text-black dark:text-white">
More information (the more details, the more helpful it'd be)
</p>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="e.g. I needed a feature that..."
className="resize-none w-full rounded-md p-2 border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100 dark:bg-neutral-950"
/>
</div>
</fieldset>
) : undefined}
<button
className={`mx-auto lg:mx-0 text-white flex items-center gap-2 py-1 px-3 rounded-md text-lg tracking-wide select-none font-semibold duration-100 w-fit ${
submitLoader
? "bg-red-400 cursor-auto"
: "bg-red-500 hover:bg-red-400 cursor-pointer"
}`}
onClick={() => {
if (!submitLoader) {
submit();
}
}}
>
<p className="text-center w-full">Delete Your Account</p>
</button>
</div>
</CenteredForm>
);
}

View File

@ -16,7 +16,7 @@ export default function Password() {
const submit = async () => { const submit = async () => {
if (newPassword == "" || newPassword2 == "") { if (newPassword == "" || newPassword2 == "") {
toast.error("Please fill all the fields."); return toast.error("Please fill all the fields.");
} }
if (newPassword !== newPassword2) if (newPassword !== newPassword2)

View File

@ -15,7 +15,7 @@ export default function Subscribe() {
const { data, status } = useSession(); const { data, status } = useSession();
const router = useRouter(); const router = useRouter();
async function loginUser() { async function submit() {
setSubmitLoader(true); setSubmitLoader(true);
const redirectionToast = toast.loading("Redirecting to Stripe..."); const redirectionToast = toast.loading("Redirecting to Stripe...");
@ -32,11 +32,13 @@ export default function Subscribe() {
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14 process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14
}-day free trial, cancel anytime!`} }-day free trial, cancel anytime!`}
> >
<div className="p-2 mx-auto flex flex-col gap-3 justify-between sm:w-[30rem] dark:border-neutral-700 w-80 bg-slate-50 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100"> <div className="p-4 mx-auto flex flex-col gap-3 justify-between dark:border-neutral-700 max-w-[30rem] min-w-80 w-full bg-slate-50 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100">
<p className="text-2xl text-center font-bold"> <p className="sm:text-3xl text-2xl text-center font-extralight">
Subscribe to Linkwarden! Subscribe to Linkwarden!
</p> </p>
<hr className="border-1 border-sky-100 dark:border-neutral-700" />
<div> <div>
<p> <p>
You will be redirected to Stripe, feel free to reach out to us at{" "} You will be redirected to Stripe, feel free to reach out to us at{" "}
@ -84,24 +86,27 @@ export default function Subscribe() {
<p className="font-semibold"> <p className="font-semibold">
Billed {plan === Plan.monthly ? "Monthly" : "Yearly"} Billed {plan === Plan.monthly ? "Monthly" : "Yearly"}
</p> </p>
<div className="flex gap-3"> <fieldset className="w-full px-4 pb-4 pt-1 rounded-md border border-sky-100 dark:border-neutral-700">
<p className="w-fit">Total:</p> <legend className="w-fit font-extralight px-2 border border-sky-100 dark:border-neutral-700 rounded-md text-xl">
<div className="w-full p-1 rounded-md border border-solid border-sky-100 dark:border-neutral-700"> Total
<p className="text-sm"> </legend>
{process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS}-day free trial, then
${plan === Plan.monthly ? "4 per month" : "36 annually"} <p className="text-sm">
</p> {process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS}-day free trial, then $
<p className="text-sm">+ VAT if applicable</p> {plan === Plan.monthly ? "4 per month" : "36 annually"}
</div> </p>
</div> <p className="text-sm">+ VAT if applicable</p>
</fieldset>
</div> </div>
<SubmitButton <button
onClick={loginUser} className={`border primary-btn-gradient select-none duration-100 bg-black border-[#0071B7] hover:border-[#059bf8] rounded-lg text-center px-4 py-2 text-slate-200 hover:text-white `}
label="Complete your Subscription" onClick={() => {
className="mt-2 w-full text-center" if (!submitLoader) submit();
loading={submitLoader} }}
/> >
<p className="text-center w-full font-bold">Complete Subscription!</p>
</button>
<div <div
onClick={() => signOut()} onClick={() => signOut()}

View File

@ -86,7 +86,7 @@ export default function Index() {
<div className="p-5 flex flex-col gap-5 w-full"> <div className="p-5 flex flex-col gap-5 w-full">
<div className="flex gap-3 items-center justify-between"> <div className="flex gap-3 items-center justify-between">
<div className="flex gap-3 items-center"> <div className="flex gap-3 items-center">
<div className="flex gap-2 items-end"> <div className="flex gap-2 items-end font-thin">
<FontAwesomeIcon <FontAwesomeIcon
icon={faHashtag} icon={faHashtag}
className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-sky-500 dark:text-sky-500" className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-sky-500 dark:text-sky-500"
@ -159,7 +159,7 @@ export default function Index() {
if (target.id !== "expand-dropdown") if (target.id !== "expand-dropdown")
setExpandDropdown(false); setExpandDropdown(false);
}} }}
className="absolute top-8 left-0 w-36" className="absolute top-8 left-0 w-36 font-normal"
/> />
) : null} ) : null}
</div> </div>

View File

@ -231,3 +231,11 @@
.sky-shadow { .sky-shadow {
box-shadow: 0px 0px 3px #0ea5e9; box-shadow: 0px 0px 3px #0ea5e9;
} }
.primary-btn-gradient {
box-shadow: inset 0px -10px 10px #0071b7;
}
.primary-btn-gradient:hover {
box-shadow: inset 0px -15px 10px #059bf8;
}

View File

@ -8,6 +8,7 @@ declare global {
PAGINATION_TAKE_COUNT?: string; PAGINATION_TAKE_COUNT?: string;
STORAGE_FOLDER?: string; STORAGE_FOLDER?: string;
AUTOSCROLL_TIMEOUT?: string; AUTOSCROLL_TIMEOUT?: string;
IMPORT_SIZE_LIMIT?: string;
SPACES_KEY?: string; SPACES_KEY?: string;
SPACES_SECRET?: string; SPACES_SECRET?: string;

View File

@ -1,4 +1,5 @@
import { Collection, Link, Tag, User } from "@prisma/client"; import { Collection, Link, Tag, User } from "@prisma/client";
import Stripe from "stripe";
type OptionalExcluding<T, TRequired extends keyof T> = Partial<T> & type OptionalExcluding<T, TRequired extends keyof T> = Partial<T> &
Pick<T, TRequired>; Pick<T, TRequired>;
@ -96,3 +97,11 @@ export enum Plan {
monthly, monthly,
yearly, yearly,
} }
export type DeleteUserBody = {
password: string;
cancellation_details?: {
comment?: string;
feedback?: Stripe.SubscriptionCancelParams.CancellationDetails.Feedback;
};
};