finished adding profile deletion functionality + bug fix
This commit is contained in:
parent
97d8c35d2a
commit
cb8c2d5f10
|
@ -8,6 +8,7 @@ PAGINATION_TAKE_COUNT=
|
|||
STORAGE_FOLDER=
|
||||
AUTOSCROLL_TIMEOUT=
|
||||
NEXT_PUBLIC_DISABLE_REGISTRATION=
|
||||
IMPORT_SIZE_LIMIT=
|
||||
|
||||
# AWS S3 Settings
|
||||
SPACES_KEY=
|
||||
|
|
|
@ -41,7 +41,7 @@ export default function Search() {
|
|||
}}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" &&
|
||||
router.push("/search/" + encodeURIComponent(searchQuery))
|
||||
router.push("/search?q=" + encodeURIComponent(searchQuery))
|
||||
}
|
||||
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"
|
||||
|
|
|
@ -12,7 +12,7 @@ export default function CenteredForm({ text, children }: Props) {
|
|||
const { theme } = useTheme();
|
||||
return (
|
||||
<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" ? (
|
||||
<Image
|
||||
src="/linkwarden_dark.png"
|
||||
|
@ -31,7 +31,7 @@ export default function CenteredForm({ text, children }: Props) {
|
|||
/>
|
||||
)}
|
||||
{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}
|
||||
</p>
|
||||
) : undefined}
|
||||
|
|
|
@ -2,14 +2,8 @@ import { prisma } from "@/lib/api/db";
|
|||
import bcrypt from "bcrypt";
|
||||
import removeFolder from "@/lib/api/storage/removeFolder";
|
||||
import Stripe from "stripe";
|
||||
|
||||
type DeleteUserBody = {
|
||||
password: string;
|
||||
cancellation_details?: {
|
||||
comment?: string;
|
||||
feedback?: Stripe.SubscriptionCancelParams.CancellationDetails.Feedback;
|
||||
};
|
||||
};
|
||||
import { DeleteUserBody } from "@/types/global";
|
||||
import removeFile from "@/lib/api/storage/removeFile";
|
||||
|
||||
export default async function deleteUserById(
|
||||
userId: number,
|
||||
|
@ -22,7 +16,7 @@ export default async function deleteUserById(
|
|||
|
||||
if (!user) {
|
||||
return {
|
||||
response: "User not found.",
|
||||
response: "Invalid credentials.",
|
||||
status: 404,
|
||||
};
|
||||
}
|
||||
|
@ -32,7 +26,7 @@ export default async function deleteUserById(
|
|||
|
||||
if (!isPasswordValid) {
|
||||
return {
|
||||
response: "Invalid password.",
|
||||
response: "Invalid credentials.",
|
||||
status: 401, // Unauthorized
|
||||
};
|
||||
}
|
||||
|
@ -54,7 +48,7 @@ export default async function deleteUserById(
|
|||
where: { ownerId: userId },
|
||||
});
|
||||
|
||||
// Delete collections
|
||||
// Find collections that the user owns
|
||||
const collections = await prisma.collection.findMany({
|
||||
where: { ownerId: userId },
|
||||
});
|
||||
|
@ -65,7 +59,7 @@ export default async function deleteUserById(
|
|||
where: { collectionId: collection.id },
|
||||
});
|
||||
|
||||
// Optionally delete archive folders associated with collections
|
||||
// Delete archive folders associated with collections
|
||||
removeFolder({ filePath: `archives/${collection.id}` });
|
||||
}
|
||||
|
||||
|
@ -74,8 +68,8 @@ export default async function deleteUserById(
|
|||
where: { ownerId: userId },
|
||||
});
|
||||
|
||||
// Optionally delete user's avatar
|
||||
removeFolder({ filePath: `uploads/avatar/${userId}.jpg` });
|
||||
// Delete user's avatar
|
||||
removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
|
||||
|
||||
// Finally, delete the user
|
||||
await prisma.user.delete({
|
||||
|
@ -88,26 +82,30 @@ export default async function deleteUserById(
|
|||
apiVersion: "2022-11-15",
|
||||
});
|
||||
|
||||
const listByEmail = await stripe.customers.list({
|
||||
email: user.email?.toLowerCase(),
|
||||
expand: ["data.subscriptions"],
|
||||
});
|
||||
try {
|
||||
const listByEmail = await stripe.customers.list({
|
||||
email: user.email?.toLowerCase(),
|
||||
expand: ["data.subscriptions"],
|
||||
});
|
||||
|
||||
if (listByEmail.data[0].subscriptions?.data[0].id) {
|
||||
const deleted = await stripe.subscriptions.cancel(
|
||||
listByEmail.data[0].subscriptions?.data[0].id,
|
||||
{
|
||||
cancellation_details: {
|
||||
comment: body.cancellation_details?.comment,
|
||||
feedback: body.cancellation_details?.feedback,
|
||||
},
|
||||
}
|
||||
);
|
||||
if (listByEmail.data[0].subscriptions?.data[0].id) {
|
||||
const deleted = await stripe.subscriptions.cancel(
|
||||
listByEmail.data[0].subscriptions?.data[0].id,
|
||||
{
|
||||
cancellation_details: {
|
||||
comment: body.cancellation_details?.comment,
|
||||
feedback: body.cancellation_details?.feedback,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
response: deleted,
|
||||
status: 200,
|
||||
};
|
||||
return {
|
||||
response: deleted,
|
||||
status: 200,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,6 +6,14 @@ import importFromHTMLFile from "@/lib/api/controllers/migration/importFromHTMLFi
|
|||
import importFromLinkwarden from "@/lib/api/controllers/migration/importFromLinkwarden";
|
||||
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) {
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
|
||||
|
|
|
@ -41,11 +41,13 @@ export default function ChooseUsername() {
|
|||
return (
|
||||
<CenteredForm>
|
||||
<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">
|
||||
<p className="text-2xl text-center text-black dark:text-white font-bold">
|
||||
Choose a Username (Last step)
|
||||
<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-3xl text-center text-black dark:text-white font-extralight">
|
||||
Choose a Username
|
||||
</p>
|
||||
|
||||
<hr className="border-1 border-sky-100 dark:border-neutral-700" />
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1">
|
||||
Username
|
||||
|
|
|
@ -72,7 +72,7 @@ export default function Index() {
|
|||
style={{ color: activeCollection?.color }}
|
||||
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}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -42,7 +42,7 @@ export default function Collections() {
|
|||
icon={faFolder}
|
||||
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">
|
||||
<p className="sm:text-4xl text-3xl capitalize text-black dark:text-white font-thin">
|
||||
All Collections
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -5,22 +5,21 @@ import React from "react";
|
|||
export default function EmailConfirmaion() {
|
||||
return (
|
||||
<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">
|
||||
<p className="text-center text-xl font-bold mb-2">
|
||||
<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-2xl sm:text-3xl font-extralight mb-2 ">
|
||||
Please check your Email
|
||||
</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>You can safely close this page.</p>
|
||||
|
||||
<hr className="my-5 dark:border-neutral-700" />
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Didn't find the email in your inbox? Check your spam folder or
|
||||
visit the{" "}
|
||||
<p className="mt-3">
|
||||
Didn't see the email? Check your spam folder or visit the{" "}
|
||||
<Link href="/forgot" className="font-bold underline">
|
||||
Password Recovery
|
||||
</Link>{" "}
|
||||
page to resend the sign-in link by entering your email.
|
||||
page to resend the link.
|
||||
</p>
|
||||
</div>
|
||||
</CenteredForm>
|
||||
|
|
|
@ -124,7 +124,7 @@ export default function Dashboard() {
|
|||
icon={faChartSimple}
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -43,13 +43,16 @@ export default function Forgot() {
|
|||
return (
|
||||
<CenteredForm>
|
||||
<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">
|
||||
<p className="text-2xl text-center text-black dark:text-white font-bold">
|
||||
<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-3xl text-center text-black dark:text-white font-extralight">
|
||||
Password Recovery
|
||||
</p>
|
||||
|
||||
<hr className="border-1 border-sky-100 dark:border-neutral-700" />
|
||||
|
||||
<div>
|
||||
<p className="text-md text-black dark:text-white">
|
||||
Enter your Email so we can send you a link to recover your
|
||||
<p className="text-black dark:text-white">
|
||||
Enter your email so we can send you a link to recover your
|
||||
account. Make sure to change your password in the profile settings
|
||||
afterwards.
|
||||
</p>
|
||||
|
|
|
@ -26,7 +26,7 @@ export default function Links() {
|
|||
icon={faLink}
|
||||
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">
|
||||
<p className="sm:text-4xl text-3xl capitalize text-black dark:text-white font-thin">
|
||||
All Links
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -50,11 +50,13 @@ export default function Login() {
|
|||
return (
|
||||
<CenteredForm text="Sign in to your account">
|
||||
<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">
|
||||
<p className="text-2xl text-black dark:text-white text-center font-bold">
|
||||
<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-3xl text-black dark:text-white text-center font-extralight">
|
||||
Enter your credentials
|
||||
</p>
|
||||
|
||||
<hr className="border-1 border-sky-100 dark:border-neutral-700" />
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1">
|
||||
Username
|
||||
|
@ -70,7 +72,7 @@ export default function Login() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="w-full">
|
||||
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1">
|
||||
Password
|
||||
</p>
|
||||
|
|
|
@ -32,62 +32,64 @@ export default function Register() {
|
|||
async function registerUser(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
const checkFields = () => {
|
||||
if (emailEnabled) {
|
||||
return (
|
||||
form.name !== "" &&
|
||||
form.email !== "" &&
|
||||
form.password !== "" &&
|
||||
form.passwordConfirmation !== ""
|
||||
);
|
||||
if (!submitLoader) {
|
||||
const checkFields = () => {
|
||||
if (emailEnabled) {
|
||||
return (
|
||||
form.name !== "" &&
|
||||
form.email !== "" &&
|
||||
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 {
|
||||
return (
|
||||
form.name !== "" &&
|
||||
form.username !== "" &&
|
||||
form.password !== "" &&
|
||||
form.passwordConfirmation !== ""
|
||||
);
|
||||
toast.error("Please fill out all the fields.");
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
text={
|
||||
process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE
|
||||
? `Start using our Premium Services with a ${
|
||||
? `Unlock ${
|
||||
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14
|
||||
}-day free trial!`
|
||||
} days of Premium Service at no cost!`
|
||||
: "Create a new account"
|
||||
}
|
||||
>
|
||||
{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>
|
||||
Registration is disabled for this instance, please contact the admin
|
||||
in case of any issues.
|
||||
|
@ -110,10 +112,13 @@ export default function Register() {
|
|||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<p className="text-2xl text-black dark:text-white text-center font-bold">
|
||||
<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-3xl text-black dark:text-white text-center font-extralight">
|
||||
Enter your details
|
||||
</p>
|
||||
|
||||
<hr className="border-1 border-sky-100 dark:border-neutral-700" />
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1">
|
||||
Display Name
|
||||
|
@ -223,12 +228,12 @@ export default function Register() {
|
|||
</div>
|
||||
) : undefined}
|
||||
|
||||
<SubmitButton
|
||||
<button
|
||||
type="submit"
|
||||
label="Sign Up"
|
||||
className="mt-2 w-full text-center"
|
||||
loading={submitLoader}
|
||||
/>
|
||||
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 `}
|
||||
>
|
||||
<p className="text-center w-full font-bold">Sign Up</p>
|
||||
</button>
|
||||
<div className="flex items-baseline gap-1 justify-center">
|
||||
<p className="w-fit text-gray-500 dark:text-gray-400">
|
||||
Already have an account?
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 { useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function Search() {
|
||||
const { links } = useLinkStore();
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.push("/links");
|
||||
}, []);
|
||||
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: 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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -269,7 +269,7 @@ export default function Account() {
|
|||
Import your data from other platforms.
|
||||
</p>
|
||||
<div
|
||||
onClick={() => setImportDropdown(!importDropdown)}
|
||||
onClick={() => setImportDropdown(true)}
|
||||
className="w-fit relative"
|
||||
id="import-dropdown"
|
||||
>
|
||||
|
@ -387,6 +387,33 @@ export default function Account() {
|
|||
label="Save"
|
||||
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>
|
||||
</SettingsLayout>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -16,7 +16,7 @@ export default function Password() {
|
|||
|
||||
const submit = async () => {
|
||||
if (newPassword == "" || newPassword2 == "") {
|
||||
toast.error("Please fill all the fields.");
|
||||
return toast.error("Please fill all the fields.");
|
||||
}
|
||||
|
||||
if (newPassword !== newPassword2)
|
||||
|
|
|
@ -15,7 +15,7 @@ export default function Subscribe() {
|
|||
const { data, status } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
async function loginUser() {
|
||||
async function submit() {
|
||||
setSubmitLoader(true);
|
||||
|
||||
const redirectionToast = toast.loading("Redirecting to Stripe...");
|
||||
|
@ -32,11 +32,13 @@ export default function Subscribe() {
|
|||
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14
|
||||
}-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">
|
||||
<p className="text-2xl text-center font-bold">
|
||||
<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="sm:text-3xl text-2xl text-center font-extralight">
|
||||
Subscribe to Linkwarden!
|
||||
</p>
|
||||
|
||||
<hr className="border-1 border-sky-100 dark:border-neutral-700" />
|
||||
|
||||
<div>
|
||||
<p>
|
||||
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">
|
||||
Billed {plan === Plan.monthly ? "Monthly" : "Yearly"}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<p className="w-fit">Total:</p>
|
||||
<div className="w-full p-1 rounded-md border border-solid border-sky-100 dark:border-neutral-700">
|
||||
<p className="text-sm">
|
||||
{process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS}-day free trial, then
|
||||
${plan === Plan.monthly ? "4 per month" : "36 annually"}
|
||||
</p>
|
||||
<p className="text-sm">+ VAT if applicable</p>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset className="w-full px-4 pb-4 pt-1 rounded-md border border-sky-100 dark:border-neutral-700">
|
||||
<legend className="w-fit font-extralight px-2 border border-sky-100 dark:border-neutral-700 rounded-md text-xl">
|
||||
Total
|
||||
</legend>
|
||||
|
||||
<p className="text-sm">
|
||||
{process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS}-day free trial, then $
|
||||
{plan === Plan.monthly ? "4 per month" : "36 annually"}
|
||||
</p>
|
||||
<p className="text-sm">+ VAT if applicable</p>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<SubmitButton
|
||||
onClick={loginUser}
|
||||
label="Complete your Subscription"
|
||||
className="mt-2 w-full text-center"
|
||||
loading={submitLoader}
|
||||
/>
|
||||
<button
|
||||
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 `}
|
||||
onClick={() => {
|
||||
if (!submitLoader) submit();
|
||||
}}
|
||||
>
|
||||
<p className="text-center w-full font-bold">Complete Subscription!</p>
|
||||
</button>
|
||||
|
||||
<div
|
||||
onClick={() => signOut()}
|
||||
|
|
|
@ -86,7 +86,7 @@ export default function Index() {
|
|||
<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">
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex gap-2 items-end font-thin">
|
||||
<FontAwesomeIcon
|
||||
icon={faHashtag}
|
||||
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")
|
||||
setExpandDropdown(false);
|
||||
}}
|
||||
className="absolute top-8 left-0 w-36"
|
||||
className="absolute top-8 left-0 w-36 font-normal"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
@ -231,3 +231,11 @@
|
|||
.sky-shadow {
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ declare global {
|
|||
PAGINATION_TAKE_COUNT?: string;
|
||||
STORAGE_FOLDER?: string;
|
||||
AUTOSCROLL_TIMEOUT?: string;
|
||||
IMPORT_SIZE_LIMIT?: string;
|
||||
|
||||
SPACES_KEY?: string;
|
||||
SPACES_SECRET?: string;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Collection, Link, Tag, User } from "@prisma/client";
|
||||
import Stripe from "stripe";
|
||||
|
||||
type OptionalExcluding<T, TRequired extends keyof T> = Partial<T> &
|
||||
Pick<T, TRequired>;
|
||||
|
@ -96,3 +97,11 @@ export enum Plan {
|
|||
monthly,
|
||||
yearly,
|
||||
}
|
||||
|
||||
export type DeleteUserBody = {
|
||||
password: string;
|
||||
cancellation_details?: {
|
||||
comment?: string;
|
||||
feedback?: Stripe.SubscriptionCancelParams.CancellationDetails.Feedback;
|
||||
};
|
||||
};
|
||||
|
|
Ŝarĝante…
Reference in New Issue