Merge pull request #258 from linkwarden/dev

Dev
This commit is contained in:
Daniel 2023-10-23 15:25:19 -04:00 committed by GitHub
commit 8fdc503f55
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 590 additions and 56 deletions

View File

@ -240,7 +240,7 @@ export default function LinkCard({ link, count, className }: Props) {
if (target.id !== "expand-dropdown" + link.id)
setExpandDropdown(false);
}}
className="absolute top-12 right-5 w-fit"
className="absolute top-12 right-5 w-40"
/>
) : null}
</div>

View File

@ -11,6 +11,7 @@ import useAccountStore from "@/store/account";
import ProfilePhoto from "@/components/ProfilePhoto";
import useModalStore from "@/store/modals";
import { useTheme } from "next-themes";
import useWindowDimensions from "@/hooks/useWindowDimensions";
export default function Navbar() {
const { setModal } = useModalStore();
@ -33,7 +34,11 @@ export default function Navbar() {
const [sidebar, setSidebar] = useState(false);
window.addEventListener("resize", () => setSidebar(false));
const { width } = useWindowDimensions();
useEffect(() => {
setSidebar(false);
}, [width]);
useEffect(() => {
setSidebar(false);

View File

@ -11,7 +11,7 @@ export default function NoLinksFound({ text }: Props) {
const { setModal } = useModalStore();
return (
<div className="border border-solid border-sky-100 dark:border-neutral-700 w-full p-10 rounded-2xl bg-gray-50 dark:bg-neutral-800">
<div className="border border-solid border-sky-100 dark:border-neutral-700 w-full h-full flex flex-col justify-center p-10 rounded-2xl bg-gray-50 dark:bg-neutral-800">
<p className="text-center text-2xl text-black dark:text-white">
{text || "You haven't created any Links Here"}
</p>

View File

@ -2,7 +2,6 @@ import useCollectionStore from "@/store/collections";
import { useEffect } from "react";
import { useSession } from "next-auth/react";
import useTagStore from "@/store/tags";
import useLinkStore from "@/store/links";
import useAccountStore from "@/store/account";
export default function useInitialData() {

View File

@ -7,11 +7,14 @@ import useLinkStore from "@/store/links";
export default function useLinks(
{
sort,
searchFilter,
searchQuery,
pinnedOnly,
collectionId,
tagId,
pinnedOnly,
searchQueryString,
searchByName,
searchByUrl,
searchByDescription,
searchByTags,
}: LinkRequestQuery = { sort: 0 }
) {
const { links, setLinks, resetLinks } = useLinkStore();
@ -20,20 +23,37 @@ export default function useLinks(
const { reachedBottom, setReachedBottom } = useDetectPageBottom();
const getLinks = async (isInitialCall: boolean, cursor?: number) => {
const requestBody: LinkRequestQuery = {
cursor,
const params = {
sort,
searchFilter,
searchQuery,
pinnedOnly,
cursor,
collectionId,
tagId,
pinnedOnly,
searchQueryString,
searchByName,
searchByUrl,
searchByDescription,
searchByTags,
};
const encodedData = encodeURIComponent(JSON.stringify(requestBody));
const buildQueryString = (params: LinkRequestQuery) => {
return Object.keys(params)
.filter((key) => params[key as keyof LinkRequestQuery] !== undefined)
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(
params[key as keyof LinkRequestQuery] as string
)}`
)
.join("&");
};
const queryString = buildQueryString(params);
const response = await fetch(
`/api/v1/links?body=${encodeURIComponent(encodedData)}`
`/api/v1/${
router.asPath === "/dashboard" ? "dashboard" : "links"
}?${queryString}`
);
const data = await response.json();
@ -45,7 +65,15 @@ export default function useLinks(
resetLinks();
getLinks(true);
}, [router, sort, searchFilter]);
}, [
router,
sort,
searchQueryString,
searchByName,
searchByUrl,
searchByDescription,
searchByTags,
]);
useEffect(() => {
if (reachedBottom) getLinks(false, links?.at(-1)?.id);

View File

@ -0,0 +1,25 @@
import React, { useState, useEffect } from "react";
export default function useWindowDimensions() {
const [dimensions, setDimensions] = useState({
height: window.innerHeight,
width: window.innerWidth,
});
useEffect(() => {
function handleResize() {
setDimensions({
height: window.innerHeight,
width: window.innerWidth,
});
}
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
return dimensions;
}

View File

@ -26,7 +26,7 @@ export default function MainLayout({ children }: Props) {
<Sidebar className="fixed top-0" />
</div>
<div className="w-full flex flex-col h-screen lg:ml-64 xl:ml-80">
<div className="w-full flex flex-col min-h-screen lg:ml-64 xl:ml-80">
<Navbar />
{children}
</div>

View File

@ -7,6 +7,7 @@ import ClickAwayHandler from "@/components/ClickAwayHandler";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBars, faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import Link from "next/link";
import useWindowDimensions from "@/hooks/useWindowDimensions";
interface Props {
children: ReactNode;
@ -25,7 +26,11 @@ export default function SettingsLayout({ children }: Props) {
const [sidebar, setSidebar] = useState(false);
window.addEventListener("resize", () => setSidebar(false));
const { width } = useWindowDimensions();
useEffect(() => {
setSidebar(false);
}, [width]);
useEffect(() => {
setSidebar(false);

View File

@ -0,0 +1,80 @@
import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Sort } from "@/types/global";
export default async function getDashboardData(
userId: number,
query: LinkRequestQuery
) {
let order: any;
if (query.sort === Sort.DateNewestFirst) order = { createdAt: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { createdAt: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
else if (query.sort === Sort.NameZA) order = { name: "desc" };
else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" };
else if (query.sort === Sort.DescriptionZA) order = { description: "desc" };
const pinnedLinks = await prisma.link.findMany({
take: Number(process.env.PAGINATION_TAKE_COUNT) || 20,
skip: query.cursor ? 1 : undefined,
cursor: query.cursor ? { id: query.cursor } : undefined,
where: {
AND: [
{
collection: {
OR: [
{ ownerId: userId },
{
members: {
some: { userId },
},
},
],
},
},
{
pinnedBy: { some: { id: userId } },
},
],
},
include: {
tags: true,
collection: true,
pinnedBy: {
where: { id: userId },
select: { id: true },
},
},
orderBy: order || { createdAt: "desc" },
});
const recentlyAddedLinks = await prisma.link.findMany({
take: 6,
where: {
collection: {
OR: [
{ ownerId: userId },
{
members: {
some: { userId },
},
},
],
},
},
include: {
tags: true,
collection: true,
pinnedBy: {
where: { id: userId },
select: { id: true },
},
},
orderBy: order || { createdAt: "desc" },
});
const links = [...recentlyAddedLinks, ...pinnedLinks].sort(
(a, b) => (new Date(b.createdAt) as any) - (new Date(a.createdAt) as any)
);
return { response: links, status: 200 };
}

View File

@ -1,9 +1,7 @@
import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Sort } from "@/types/global";
export default async function getLink(userId: number, body: string) {
const query: LinkRequestQuery = JSON.parse(decodeURIComponent(body));
export default async function getLink(userId: number, query: LinkRequestQuery) {
const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql");
let order: any;
@ -16,40 +14,40 @@ export default async function getLink(userId: number, body: string) {
const searchConditions = [];
if (query.searchQuery) {
if (query.searchFilter?.name) {
if (query.searchQueryString) {
if (query.searchByName) {
searchConditions.push({
name: {
contains: query.searchQuery,
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchFilter?.url) {
if (query.searchByUrl) {
searchConditions.push({
url: {
contains: query.searchQuery,
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchFilter?.description) {
if (query.searchByDescription) {
searchConditions.push({
description: {
contains: query.searchQuery,
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchFilter?.tags) {
if (query.searchByTags) {
searchConditions.push({
tags: {
some: {
name: {
contains: query.searchQuery,
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
OR: [
@ -117,7 +115,7 @@ export default async function getLink(userId: number, body: string) {
OR: [
...tagCondition,
{
[query.searchQuery ? "OR" : "AND"]: [
[query.searchQueryString ? "OR" : "AND"]: [
{
pinnedBy: query.pinnedOnly
? { some: { id: userId } }

View File

@ -0,0 +1,118 @@
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;
};
};
export default async function deleteUserById(
userId: number,
body: DeleteUserBody
) {
// First, we retrieve the user from the database
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
return {
response: "User not found.",
status: 404,
};
}
// Then, we check if the provided password matches the one stored in the database
const isPasswordValid = bcrypt.compareSync(body.password, user.password);
if (!isPasswordValid) {
return {
response: "Invalid password.",
status: 401, // Unauthorized
};
}
// Delete the user and all related data within a transaction
await prisma.$transaction(async (prisma) => {
// Delete whitelisted users
await prisma.whitelistedUser.deleteMany({
where: { userId },
});
// Delete links
await prisma.link.deleteMany({
where: { collection: { ownerId: userId } },
});
// Delete tags
await prisma.tag.deleteMany({
where: { ownerId: userId },
});
// Delete collections
const collections = await prisma.collection.findMany({
where: { ownerId: userId },
});
for (const collection of collections) {
// Delete related users and collections relations
await prisma.usersAndCollections.deleteMany({
where: { collectionId: collection.id },
});
// Optionally delete archive folders associated with collections
removeFolder({ filePath: `archives/${collection.id}` });
}
// Delete collections after cleaning up related data
await prisma.collection.deleteMany({
where: { ownerId: userId },
});
// Optionally delete user's avatar
removeFolder({ filePath: `uploads/avatar/${userId}.jpg` });
// Finally, delete the user
await prisma.user.delete({
where: { id: userId },
});
});
if (process.env.STRIPE_SECRET_KEY) {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: "2022-11-15",
});
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,
},
}
);
return {
response: deleted,
status: 200,
};
}
}
return {
response: "User account and all related data deleted successfully.",
status: 200,
};
}

View File

@ -1,6 +1,6 @@
import { prisma } from "@/lib/api/db";
export default async function getUser(
export default async function getPublicUserById(
targetId: number | string,
isId: boolean,
requestingUsername?: string

View File

@ -1,6 +1,6 @@
import { prisma } from "@/lib/api/db";
export default async function getUser(userId: number) {
export default async function getUserById(userId: number) {
const user = await prisma.user.findUnique({
where: {
id: userId,

View File

@ -9,7 +9,7 @@ import createFolder from "@/lib/api/storage/createFolder";
const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
export default async function updateUser(
export default async function updateUserById(
sessionUser: {
id: number;
username: string;

View File

@ -0,0 +1,27 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/pages/api/v1/auth/[...nextauth]";
import { LinkRequestQuery } from "@/types/global";
import getDashboardData from "@/lib/api/controllers/dashboard/getDashboardData";
export default async function links(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user?.id) {
return res.status(401).json({ response: "You must be logged in." });
} else if (session?.user?.isSubscriber === false)
res.status(401).json({
response:
"You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.",
});
if (req.method === "GET") {
const convertedData: LinkRequestQuery = {
sort: Number(req.query.sort as string),
cursor: req.query.cursor ? Number(req.query.cursor as string) : undefined,
};
const links = await getDashboardData(session.user.id, convertedData);
return res.status(links.status).json({ response: links.response });
}
}

View File

@ -3,6 +3,7 @@ import { getServerSession } from "next-auth/next";
import { authOptions } from "@/pages/api/v1/auth/[...nextauth]";
import getLinks from "@/lib/api/controllers/links/getLinks";
import postLink from "@/lib/api/controllers/links/postLink";
import { LinkRequestQuery } from "@/types/global";
export default async function links(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions);
@ -16,7 +17,28 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
});
if (req.method === "GET") {
const links = await getLinks(session.user.id, req?.query?.body as string);
// Convert the type of the request query to "LinkRequestQuery"
const convertedData: LinkRequestQuery = {
sort: Number(req.query.sort as string),
cursor: req.query.cursor ? Number(req.query.cursor as string) : undefined,
collectionId: req.query.collectionId
? Number(req.query.collectionId as string)
: undefined,
tagId: req.query.tagId ? Number(req.query.tagId as string) : undefined,
pinnedOnly: req.query.pinnedOnly
? req.query.pinnedOnly === "true"
: undefined,
searchQueryString: req.query.searchQueryString
? (req.query.searchQueryString as string)
: undefined,
searchByName: req.query.searchByName === "true" ? true : undefined,
searchByUrl: req.query.searchByUrl === "true" ? true : undefined,
searchByDescription:
req.query.searchByDescription === "true" ? true : undefined,
searchByTags: req.query.searchByTags === "true" ? true : undefined,
};
const links = await getLinks(session.user.id, convertedData);
return res.status(links.status).json({ response: links.response });
} else if (req.method === "POST") {
const newlink = await postLink(req.body, session.user.id);

View File

@ -4,6 +4,7 @@ import { authOptions } from "@/pages/api/v1/auth/[...nextauth]";
import getUserById from "@/lib/api/controllers/users/userId/getUserById";
import getPublicUserById from "@/lib/api/controllers/users/userId/getPublicUserById";
import updateUserById from "@/lib/api/controllers/users/userId/updateUserById";
import deleteUserById from "@/lib/api/controllers/users/userId/deleteUserById";
export default async function users(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions);
@ -36,5 +37,12 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
} else if (req.method === "PUT") {
const updated = await updateUserById(session.user, req.body);
return res.status(updated.status).json({ response: updated.response });
} else if (
req.method === "DELETE" &&
session.user.id === Number(req.query.id)
) {
console.log(req.body);
const updated = await deleteUserById(session.user.id, req.body);
return res.status(updated.status).json({ response: updated.response });
}
}

View File

@ -52,7 +52,7 @@ export default function Index() {
return (
<MainLayout>
<div className="p-5 flex flex-col gap-5 w-full">
<div className="p-5 flex flex-col gap-5 w-full h-full">
<div
style={{
backgroundImage: `linear-gradient(-45deg, ${

View File

@ -2,6 +2,9 @@ import useCollectionStore from "@/store/collections";
import {
faChartSimple,
faChevronDown,
faChevronRight,
faClockRotateLeft,
faFileImport,
faThumbTack,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@ -11,14 +14,26 @@ import useTagStore from "@/store/tags";
import LinkCard from "@/components/LinkCard";
import { useEffect, useState } from "react";
import useLinks from "@/hooks/useLinks";
import Link from "next/link";
import useWindowDimensions from "@/hooks/useWindowDimensions";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import React from "react";
import useModalStore from "@/store/modals";
import { toast } from "react-hot-toast";
import { MigrationFormat, MigrationRequest } from "@/types/global";
import ClickAwayHandler from "@/components/ClickAwayHandler";
export default function Dashboard() {
const { collections } = useCollectionStore();
const { links } = useLinkStore();
const { tags } = useTagStore();
const { setModal } = useModalStore();
const [numberOfLinks, setNumberOfLinks] = useState(0);
const [showRecents, setShowRecents] = useState(3);
const [linkPinDisclosure, setLinkPinDisclosure] = useState<boolean>(() => {
const storedValue =
typeof window !== "undefined" &&
@ -45,6 +60,61 @@ export default function Dashboard() {
);
}, [linkPinDisclosure]);
const handleNumberOfRecents = () => {
if (window.innerWidth > 1550) {
setShowRecents(6);
} else if (window.innerWidth > 1295) {
setShowRecents(4);
} else setShowRecents(3);
};
const { width } = useWindowDimensions();
useEffect(() => {
handleNumberOfRecents();
}, [width]);
const [importDropdown, setImportDropdown] = useState(false);
const importBookmarks = async (e: any, format: MigrationFormat) => {
const file: File = e.target.files[0];
if (file) {
var reader = new FileReader();
reader.readAsText(file, "UTF-8");
reader.onload = async function (e) {
const load = toast.loading("Importing...");
const request: string = e.target?.result as string;
const body: MigrationRequest = {
format,
data: request,
};
const response = await fetch("/api/v1/migration", {
method: "POST",
body: JSON.stringify(body),
});
const data = await response.json();
toast.dismiss(load);
toast.success("Imported the Bookmarks! Reloading the page...");
setImportDropdown(false);
setTimeout(() => {
location.reload();
}, 2000);
};
reader.onerror = function (e) {
console.log("Error:", e);
};
}
};
return (
<MainLayout>
<div style={{ flex: "1 1 auto" }} className="p-5 flex flex-col gap-5">
@ -89,6 +159,146 @@ export default function Dashboard() {
</div>
</div>
<div className="flex justify-between items-center">
<div className="flex gap-2 items-center">
<FontAwesomeIcon
icon={faClockRotateLeft}
className="w-5 h-5 text-sky-500 dark:text-sky-500 drop-shadow"
/>
<p className="text-2xl text-black dark:text-white">
Recently Added Links
</p>
</div>
<Link
href="/links"
className="text-black dark:text-white flex items-center gap-2 cursor-pointer"
>
View All
<FontAwesomeIcon
icon={faChevronRight}
className={`w-4 h-4 text-black dark:text-white`}
/>
</Link>
</div>
<div
style={{ flex: "0 1 auto" }}
className="flex flex-col 2xl:flex-row items-start 2xl:gap-2"
>
{links[0] ? (
<div className="w-full">
<div
className={`grid overflow-hidden 2xl:grid-cols-3 xl:grid-cols-2 grid-cols-1 gap-5 w-full`}
>
{links.slice(0, showRecents).map((e, i) => (
<LinkCard key={i} link={e} count={i} />
))}
</div>
</div>
) : (
<div
style={{ flex: "1 1 auto" }}
className="sky-shadow flex flex-col justify-center h-full border border-solid border-sky-100 dark:border-neutral-700 w-full mx-auto p-10 rounded-2xl bg-gray-50 dark:bg-neutral-800"
>
<p className="text-center text-2xl text-black dark:text-white">
View Your Recently Added Links Here!
</p>
<p className="text-center mx-auto max-w-96 w-fit text-gray-500 dark:text-gray-300 text-sm mt-2">
This section will view your latest added Links across every
Collections you have access to.
</p>
<div className="text-center text-black dark:text-white w-full mt-4 flex flex-wrap gap-4 justify-center">
<div
onClick={() => {
setModal({
modal: "LINK",
state: true,
method: "CREATE",
});
}}
className="inline-flex gap-1 relative w-[11.4rem] items-center font-semibold select-none cursor-pointer p-2 px-3 rounded-md dark:hover:bg-sky-600 text-white bg-sky-700 hover:bg-sky-600 duration-100 group"
>
<FontAwesomeIcon
icon={faPlus}
className="w-5 h-5 group-hover:ml-[4.325rem] absolute duration-100"
/>
<span className="group-hover:opacity-0 text-right w-full duration-100">
Create New Link
</span>
</div>
<div className="relative">
<div
onClick={() => setImportDropdown(!importDropdown)}
id="import-dropdown"
className="flex gap-2 select-none text-sm cursor-pointer p-2 px-3 rounded-md border dark:hover:border-sky-600 text-black border-black dark:text-white dark:border-white hover:border-sky-500 hover:dark:border-sky-500 hover:text-sky-500 hover:dark:text-sky-500 duration-100 group"
>
<FontAwesomeIcon
icon={faFileImport}
className="w-5 h-5 duration-100"
id="import-dropdown"
/>
<span
className="text-right w-full duration-100"
id="import-dropdown"
>
Import Your Bookmarks
</span>
</div>
{importDropdown ? (
<ClickAwayHandler
onClickOutside={(e: Event) => {
const target = e.target as HTMLInputElement;
if (target.id !== "import-dropdown")
setImportDropdown(false);
}}
className={`absolute text-black dark:text-white top-10 left-0 w-52 py-1 shadow-md border border-sky-100 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-800 rounded-md flex flex-col z-20`}
>
<div className="cursor-pointer rounded-md">
<label
htmlFor="import-linkwarden-file"
title="JSON File"
className="flex items-center gap-2 py-1 px-2 hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 cursor-pointer"
>
Linkwarden...
<input
type="file"
name="photo"
id="import-linkwarden-file"
accept=".json"
className="hidden"
onChange={(e) =>
importBookmarks(e, MigrationFormat.linkwarden)
}
/>
</label>
<label
htmlFor="import-html-file"
title="HTML File"
className="flex items-center gap-2 py-1 px-2 hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 cursor-pointer"
>
Bookmarks HTML file...
<input
type="file"
name="photo"
id="import-html-file"
accept=".html"
className="hidden"
onChange={(e) =>
importBookmarks(e, MigrationFormat.htmlFile)
}
/>
</label>
</div>
</ClickAwayHandler>
) : null}
</div>
</div>
</div>
)}
</div>
<div className="flex justify-between items-center">
<div className="flex gap-2 items-center">
<FontAwesomeIcon
@ -121,7 +331,7 @@ export default function Dashboard() {
<div className="w-full">
<div
className={`grid overflow-hidden 2xl:grid-cols-3 xl:grid-cols-2 grid-cols-1 gap-5 w-full ${
linkPinDisclosure ? "h-full" : "h-44"
linkPinDisclosure ? "h-full" : "h-[22rem]"
}`}
>
{links

View File

@ -19,7 +19,7 @@ export default function Links() {
return (
<MainLayout>
<div className="p-5 flex flex-col gap-5 w-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-2">
<FontAwesomeIcon
@ -60,7 +60,7 @@ export default function Links() {
})}
</div>
) : (
<NoLinksFound />
<NoLinksFound text="You Haven't Created Any Links Yet" />
)}
</div>
</MainLayout>

View File

@ -4,7 +4,7 @@ import SortDropdown from "@/components/SortDropdown";
import useLinks from "@/hooks/useLinks";
import MainLayout from "@/layouts/MainLayout";
import useLinkStore from "@/store/links";
import { LinkSearchFilter, Sort } from "@/types/global";
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";
@ -15,7 +15,7 @@ export default function Links() {
const router = useRouter();
const [searchFilter, setSearchFilter] = useState<LinkSearchFilter>({
const [searchFilter, setSearchFilter] = useState({
name: true,
url: true,
description: true,
@ -27,9 +27,12 @@ export default function Links() {
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
useLinks({
searchFilter: searchFilter,
searchQuery: router.query.query as string,
sort: sortBy,
searchQueryString: router.query.query as string,
searchByName: searchFilter.name,
searchByUrl: searchFilter.url,
searchByDescription: searchFilter.description,
searchByTags: searchFilter.tags,
});
return (

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClose, faPenToSquare } from "@fortawesome/free-solid-svg-icons";
import { faClose } from "@fortawesome/free-solid-svg-icons";
import useAccountStore from "@/store/account";
import { AccountSettings } from "@/types/global";
import { toast } from "react-hot-toast";
@ -269,7 +269,7 @@ export default function Account() {
Import your data from other platforms.
</p>
<div
onClick={() => setImportDropdown(true)}
onClick={() => setImportDropdown(!importDropdown)}
className="w-fit relative"
id="import-dropdown"
>
@ -286,7 +286,7 @@ export default function Account() {
if (target.id !== "import-dropdown")
setImportDropdown(false);
}}
className={`absolute top-7 left-0 w-48 py-1 shadow-md border border-sky-100 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-800 rounded-md flex flex-col z-20`}
className={`absolute top-7 left-0 w-52 py-1 shadow-md border border-sky-100 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-800 rounded-md flex flex-col z-20`}
>
<div className="cursor-pointer rounded-md">
<label

View File

@ -32,7 +32,16 @@ const useLinkStore = create<LinkStore>()((set) => ({
links: [],
}));
set((state) => ({
links: [...state.links, ...data],
// Filter duplicate links by id
links: [...state.links, ...data].reduce(
(links: LinkIncludingShortenedCollectionAndTags[], item) => {
if (!links.some((link) => link.id === item.id)) {
links.push(item);
}
return links;
},
[]
),
}));
},
addLink: async (body) => {
@ -94,6 +103,7 @@ const useLinkStore = create<LinkStore>()((set) => ({
links: state.links.filter((e) => e.id !== linkId),
}));
useTagStore.getState().setTags();
useCollectionStore.getState().setCollections();
}
return { ok: response.ok, data: data.response };

View File

@ -56,21 +56,17 @@ export enum Sort {
DescriptionZA,
}
export type LinkSearchFilter = {
name: boolean;
url: boolean;
description: boolean;
tags: boolean;
};
export type LinkRequestQuery = {
sort: Sort;
cursor?: number;
collectionId?: number;
tagId?: number;
sort: Sort;
searchFilter?: LinkSearchFilter;
searchQuery?: string;
pinnedOnly?: boolean;
searchQueryString?: string;
searchByName?: boolean;
searchByUrl?: boolean;
searchByDescription?: boolean;
searchByTags?: boolean;
};
export type PublicLinkRequestQuery = {