Merge pull request #703 from linkwarden/chore/react-query-implementation

Chore/react query implementation
This commit is contained in:
Daniel 2024-08-14 16:45:40 -04:00 committed by GitHub
commit 299498ffa6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
81 changed files with 2501 additions and 1804 deletions

View File

@ -5,12 +5,12 @@ import ProfilePhoto from "./ProfilePhoto";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import useLocalSettingsStore from "@/store/localSettings"; import useLocalSettingsStore from "@/store/localSettings";
import getPublicUserData from "@/lib/client/getPublicUserData"; import getPublicUserData from "@/lib/client/getPublicUserData";
import useAccountStore from "@/store/account";
import EditCollectionModal from "./ModalContent/EditCollectionModal"; import EditCollectionModal from "./ModalContent/EditCollectionModal";
import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal"; import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal";
import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal"; import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal";
import { dropdownTriggerer } from "@/lib/client/utils"; import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
type Props = { type Props = {
collection: CollectionIncludingMembersAndLinkCount; collection: CollectionIncludingMembersAndLinkCount;
@ -20,7 +20,7 @@ type Props = {
export default function CollectionCard({ collection, className }: Props) { export default function CollectionCard({ collection, className }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { settings } = useLocalSettingsStore(); const { settings } = useLocalSettingsStore();
const { account } = useAccountStore(); const { data: user = {} } = useUser();
const formattedDate = new Date(collection.createdAt as string).toLocaleString( const formattedDate = new Date(collection.createdAt as string).toLocaleString(
"en-US", "en-US",
@ -45,18 +45,18 @@ export default function CollectionCard({ collection, className }: Props) {
useEffect(() => { useEffect(() => {
const fetchOwner = async () => { const fetchOwner = async () => {
if (collection && collection.ownerId !== account.id) { if (collection && collection.ownerId !== user.id) {
const owner = await getPublicUserData(collection.ownerId as number); const owner = await getPublicUserData(collection.ownerId as number);
setCollectionOwner(owner); setCollectionOwner(owner);
} else if (collection && collection.ownerId === account.id) { } else if (collection && collection.ownerId === user.id) {
setCollectionOwner({ setCollectionOwner({
id: account.id as number, id: user.id as number,
name: account.name, name: user.name,
username: account.username as string, username: user.username as string,
image: account.image as string, image: user.image as string,
archiveAsScreenshot: account.archiveAsScreenshot as boolean, archiveAsScreenshot: user.archiveAsScreenshot as boolean,
archiveAsMonolith: account.archiveAsMonolith as boolean, archiveAsMonolith: user.archiveAsMonolith as boolean,
archiveAsPDF: account.archiveAsPDF as boolean, archiveAsPDF: user.archiveAsPDF as boolean,
}); });
} }
}; };

View File

@ -9,14 +9,14 @@ import Tree, {
TreeSourcePosition, TreeSourcePosition,
TreeDestinationPosition, TreeDestinationPosition,
} from "@atlaskit/tree"; } from "@atlaskit/tree";
import useCollectionStore from "@/store/collections";
import { Collection } from "@prisma/client"; import { Collection } from "@prisma/client";
import Link from "next/link"; import Link from "next/link";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useAccountStore from "@/store/account";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useCollections, useUpdateCollection } from "@/hooks/store/collections";
import { useUpdateUser, useUser } from "@/hooks/store/user";
interface ExtendedTreeItem extends TreeItem { interface ExtendedTreeItem extends TreeItem {
data: Collection; data: Collection;
@ -24,53 +24,57 @@ interface ExtendedTreeItem extends TreeItem {
const CollectionListing = () => { const CollectionListing = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { collections, updateCollection } = useCollectionStore(); const updateCollection = useUpdateCollection();
const { account, updateAccount } = useAccountStore(); const { data: collections = [], isLoading } = useCollections();
const { data: user = {} } = useUser();
const updateUser = useUpdateUser();
const router = useRouter(); const router = useRouter();
const currentPath = router.asPath; const currentPath = router.asPath;
const [tree, setTree] = useState<TreeData | undefined>();
const initialTree = useMemo(() => { const initialTree = useMemo(() => {
if (collections.length > 0) { if (
// !tree &&
collections.length > 0
) {
return buildTreeFromCollections( return buildTreeFromCollections(
collections, collections,
router, router,
account.collectionOrder user.collectionOrder
); );
} } else return undefined;
return undefined; }, [collections, user, router]);
}, [collections, router]);
const [tree, setTree] = useState(initialTree);
useEffect(() => { useEffect(() => {
// if (!tree)
setTree(initialTree); setTree(initialTree);
}, [initialTree]); }, [initialTree]);
useEffect(() => { useEffect(() => {
if (account.username) { if (user.username) {
if ( if (
(!account.collectionOrder || account.collectionOrder.length === 0) && (!user.collectionOrder || user.collectionOrder.length === 0) &&
collections.length > 0 collections.length > 0
) )
updateAccount({ updateUser.mutate({
...account, ...user,
collectionOrder: collections collectionOrder: collections
.filter( .filter(
(e) => (e) =>
e.parentId === null || e.parentId === null ||
!collections.find((i) => i.id === e.parentId) !collections.find((i) => i.id === e.parentId)
) // Filter out collections with non-null parentId ) // Filter out collections with non-null parentId
.map((e) => e.id as number), // Use "as number" to assert that e.id is a number .map((e) => e.id as number),
}); });
else { else {
const newCollectionOrder: number[] = [ const newCollectionOrder: number[] = [...(user.collectionOrder || [])];
...(account.collectionOrder || []),
];
// Start with collections that are in both account.collectionOrder and collections // Start with collections that are in both account.collectionOrder and collections
const existingCollectionIds = collections.map((c) => c.id as number); const existingCollectionIds = collections.map((c) => c.id as number);
const filteredCollectionOrder = account.collectionOrder.filter((id) => const filteredCollectionOrder = user.collectionOrder.filter((id: any) =>
existingCollectionIds.includes(id) existingCollectionIds.includes(id)
); );
@ -78,7 +82,7 @@ const CollectionListing = () => {
collections.forEach((collection) => { collections.forEach((collection) => {
if ( if (
!filteredCollectionOrder.includes(collection.id as number) && !filteredCollectionOrder.includes(collection.id as number) &&
(!collection.parentId || collection.ownerId === account.id) (!collection.parentId || collection.ownerId === user.id)
) { ) {
filteredCollectionOrder.push(collection.id as number); filteredCollectionOrder.push(collection.id as number);
} }
@ -87,10 +91,10 @@ const CollectionListing = () => {
// check if the newCollectionOrder is the same as the old one // check if the newCollectionOrder is the same as the old one
if ( if (
JSON.stringify(newCollectionOrder) !== JSON.stringify(newCollectionOrder) !==
JSON.stringify(account.collectionOrder) JSON.stringify(user.collectionOrder)
) { ) {
updateAccount({ updateUser.mutateAsync({
...account, ...user,
collectionOrder: newCollectionOrder, collectionOrder: newCollectionOrder,
}); });
} }
@ -138,9 +142,9 @@ const CollectionListing = () => {
); );
if ( if (
(movedCollection?.ownerId !== account.id && (movedCollection?.ownerId !== user.id &&
destination.parentId !== source.parentId) || destination.parentId !== source.parentId) ||
(destinationCollection?.ownerId !== account.id && (destinationCollection?.ownerId !== user.id &&
destination.parentId !== "root") destination.parentId !== "root")
) { ) {
return toast.error(t("cant_change_collection_you_dont_own")); return toast.error(t("cant_change_collection_you_dont_own"));
@ -148,18 +152,25 @@ const CollectionListing = () => {
setTree((currentTree) => moveItemOnTree(currentTree!, source, destination)); setTree((currentTree) => moveItemOnTree(currentTree!, source, destination));
const updatedCollectionOrder = [...account.collectionOrder]; const updatedCollectionOrder = [...user.collectionOrder];
if (source.parentId !== destination.parentId) { if (source.parentId !== destination.parentId) {
await updateCollection({ await updateCollection.mutateAsync(
...movedCollection, {
parentId: ...movedCollection,
destination.parentId && destination.parentId !== "root" parentId:
? Number(destination.parentId) destination.parentId && destination.parentId !== "root"
: destination.parentId === "root" ? Number(destination.parentId)
? "root" : destination.parentId === "root"
: null, ? "root"
} as any); : null,
},
{
onError: (error) => {
toast.error(error.message);
},
}
);
} }
if ( if (
@ -172,8 +183,8 @@ const CollectionListing = () => {
updatedCollectionOrder.splice(destination.index, 0, movedCollectionId); updatedCollectionOrder.splice(destination.index, 0, movedCollectionId);
await updateAccount({ await updateUser.mutateAsync({
...account, ...user,
collectionOrder: updatedCollectionOrder, collectionOrder: updatedCollectionOrder,
}); });
} else if ( } else if (
@ -182,8 +193,8 @@ const CollectionListing = () => {
) { ) {
updatedCollectionOrder.splice(destination.index, 0, movedCollectionId); updatedCollectionOrder.splice(destination.index, 0, movedCollectionId);
await updateAccount({ updateUser.mutate({
...account, ...user,
collectionOrder: updatedCollectionOrder, collectionOrder: updatedCollectionOrder,
}); });
} else if ( } else if (
@ -193,14 +204,22 @@ const CollectionListing = () => {
) { ) {
updatedCollectionOrder.splice(source.index, 1); updatedCollectionOrder.splice(source.index, 1);
await updateAccount({ await updateUser.mutateAsync({
...account, ...user,
collectionOrder: updatedCollectionOrder, collectionOrder: updatedCollectionOrder,
}); });
} }
}; };
if (!tree) { if (isLoading) {
return (
<div className="flex flex-col gap-4">
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
</div>
);
} else if (!tree) {
return ( return (
<p className="text-neutral text-xs font-semibold truncate w-full px-2 mt-5 mb-8"> <p className="text-neutral text-xs font-semibold truncate w-full px-2 mt-5 mb-8">
{t("you_have_no_collections")} {t("you_have_no_collections")}

View File

@ -1,10 +1,10 @@
import useCollectionStore from "@/store/collections";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { styles } from "./styles"; import { styles } from "./styles";
import { Options } from "./types"; import { Options } from "./types";
import CreatableSelect from "react-select/creatable"; import CreatableSelect from "react-select/creatable";
import Select from "react-select"; import Select from "react-select";
import { useCollections } from "@/hooks/store/collections";
type Props = { type Props = {
onChange: any; onChange: any;
@ -24,7 +24,8 @@ export default function CollectionSelection({
showDefaultValue = true, showDefaultValue = true,
creatable = true, creatable = true,
}: Props) { }: Props) {
const { collections } = useCollectionStore(); const { data: collections = [] } = useCollections();
const router = useRouter(); const router = useRouter();
const [options, setOptions] = useState<Options[]>([]); const [options, setOptions] = useState<Options[]>([]);

View File

@ -1,8 +1,8 @@
import useTagStore from "@/store/tags";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import CreatableSelect from "react-select/creatable"; import CreatableSelect from "react-select/creatable";
import { styles } from "./styles"; import { styles } from "./styles";
import { Options } from "./types"; import { Options } from "./types";
import { useTags } from "@/hooks/store/tags";
type Props = { type Props = {
onChange: any; onChange: any;
@ -13,12 +13,12 @@ type Props = {
}; };
export default function TagSelection({ onChange, defaultValue }: Props) { export default function TagSelection({ onChange, defaultValue }: Props) {
const { tags } = useTagStore(); const { data: tags = [] } = useTags();
const [options, setOptions] = useState<Options[]>([]); const [options, setOptions] = useState<Options[]>([]);
useEffect(() => { useEffect(() => {
const formatedCollections = tags.map((e) => { const formatedCollections = tags.map((e: any) => {
return { value: e.id, label: e.name }; return { value: e.id, label: e.name };
}); });

View File

@ -8,7 +8,7 @@ const InstallApp = (props: Props) => {
const [isOpen, setIsOpen] = useState(true); const [isOpen, setIsOpen] = useState(true);
return isOpen && !isPWA() ? ( return isOpen && !isPWA() ? (
<div className="absolute left-0 right-0 bottom-10 w-full p-5"> <div className="fixed left-0 right-0 bottom-10 w-full p-5">
<div className="mx-auto w-fit p-2 flex justify-between gap-2 items-center border border-neutral-content rounded-xl bg-base-300 backdrop-blur-md bg-opacity-80 max-w-md"> <div className="mx-auto w-fit p-2 flex justify-between gap-2 items-center border border-neutral-content rounded-xl bg-base-300 backdrop-blur-md bg-opacity-80 max-w-md">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@ -5,17 +5,18 @@ import ViewDropdown from "./ViewDropdown";
import { TFunction } from "i18next"; import { TFunction } from "i18next";
import BulkDeleteLinksModal from "./ModalContent/BulkDeleteLinksModal"; import BulkDeleteLinksModal from "./ModalContent/BulkDeleteLinksModal";
import BulkEditLinksModal from "./ModalContent/BulkEditLinksModal"; import BulkEditLinksModal from "./ModalContent/BulkEditLinksModal";
import toast from "react-hot-toast";
import useCollectivePermissions from "@/hooks/useCollectivePermissions"; import useCollectivePermissions from "@/hooks/useCollectivePermissions";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import { Sort } from "@/types/global"; import { Sort, ViewMode } from "@/types/global";
import { useBulkDeleteLinks, useLinks } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
t: TFunction<"translation", undefined>; t: TFunction<"translation", undefined>;
viewMode: string; viewMode: ViewMode;
setViewMode: Dispatch<SetStateAction<string>>; setViewMode: Dispatch<SetStateAction<ViewMode>>;
searchFilter?: { searchFilter?: {
name: boolean; name: boolean;
url: boolean; url: boolean;
@ -48,8 +49,11 @@ const LinkListOptions = ({
editMode, editMode,
setEditMode, setEditMode,
}: Props) => { }: Props) => {
const { links, selectedLinks, setSelectedLinks, deleteLinksById } = const { selectedLinks, setSelectedLinks } = useLinkStore();
useLinkStore();
const deleteLinksById = useBulkDeleteLinks();
const { links } = useLinks();
const router = useRouter(); const router = useRouter();
@ -73,23 +77,23 @@ const LinkListOptions = ({
}; };
const bulkDeleteLinks = async () => { const bulkDeleteLinks = async () => {
const load = toast.loading(t("deleting_selections")); const load = toast.loading(t("deleting"));
const response = await deleteLinksById( await deleteLinksById.mutateAsync(
selectedLinks.map((link) => link.id as number) selectedLinks.map((link) => link.id as number),
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedLinks([]);
toast.success(t("deleted"));
}
},
}
); );
toast.dismiss(load);
if (response.ok) {
toast.success(
selectedLinks.length === 1
? t("link_deleted")
: t("links_deleted", { count: selectedLinks.length })
);
} else {
toast.error(response.data as string);
}
}; };
return ( return (
@ -99,57 +103,64 @@ const LinkListOptions = ({
<div className="flex gap-3 items-center justify-end"> <div className="flex gap-3 items-center justify-end">
<div className="flex gap-2 items-center mt-2"> <div className="flex gap-2 items-center mt-2">
{links.length > 0 && editMode !== undefined && setEditMode && ( {links &&
<div links.length > 0 &&
role="button" editMode !== undefined &&
onClick={() => { setEditMode && (
setEditMode(!editMode); <div
setSelectedLinks([]); role="button"
}} onClick={() => {
className={`btn btn-square btn-sm btn-ghost ${ setEditMode(!editMode);
editMode setSelectedLinks([]);
? "bg-primary/20 hover:bg-primary/20" }}
: "hover:bg-neutral/20" className={`btn btn-square btn-sm btn-ghost ${
}`} editMode
> ? "bg-primary/20 hover:bg-primary/20"
<i className="bi-pencil-fill text-neutral text-xl"></i> : "hover:bg-neutral/20"
</div> }`}
)} >
<i className="bi-pencil-fill text-neutral text-xl"></i>
</div>
)}
{searchFilter && setSearchFilter && ( {searchFilter && setSearchFilter && (
<FilterSearchDropdown <FilterSearchDropdown
searchFilter={searchFilter} searchFilter={searchFilter}
setSearchFilter={setSearchFilter} setSearchFilter={setSearchFilter}
/> />
)} )}
<SortDropdown sortBy={sortBy} setSort={setSortBy} t={t} /> <SortDropdown
sortBy={sortBy}
setSort={(value) => {
setSortBy(value);
}}
t={t}
/>
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} /> <ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
</div> </div>
</div> </div>
</div> </div>
{editMode && links.length > 0 && ( {links && editMode && links.length > 0 && (
<div className="w-full flex justify-between items-center min-h-[32px]"> <div className="w-full flex justify-between items-center min-h-[32px]">
{links.length > 0 && ( <div className="flex gap-3 ml-3">
<div className="flex gap-3 ml-3"> <input
<input type="checkbox"
type="checkbox" className="checkbox checkbox-primary"
className="checkbox checkbox-primary" onChange={() => handleSelectAll()}
onChange={() => handleSelectAll()} checked={
checked={ selectedLinks.length === links.length && links.length > 0
selectedLinks.length === links.length && links.length > 0 }
} />
/> {selectedLinks.length > 0 ? (
{selectedLinks.length > 0 ? ( <span>
<span> {selectedLinks.length === 1
{selectedLinks.length === 1 ? t("link_selected")
? t("link_selected") : t("links_selected", { count: selectedLinks.length })}
: t("links_selected", { count: selectedLinks.length })} </span>
</span> ) : (
) : ( <span>{t("nothing_selected")}</span>
<span>{t("nothing_selected")}</span> )}
)} </div>
</div>
)}
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={() => setBulkEditLinksModal(true)} onClick={() => setBulkEditLinksModal(true)}

View File

@ -1,39 +0,0 @@
import LinkCard from "@/components/LinkViews/LinkCard";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { link } from "fs";
import { GridLoader } from "react-spinners";
export default function CardView({
links,
editMode,
isLoading,
}: {
links: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
}) {
return (
<div className="grid min-[1901px]:grid-cols-5 min-[1501px]:grid-cols-4 min-[881px]:grid-cols-3 min-[551px]:grid-cols-2 grid-cols-1 gap-5 pb-5">
{links.map((e, i) => {
return (
<LinkCard
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{isLoading && links.length > 0 && (
<GridLoader
color="oklch(var(--p))"
loading={true}
size={20}
className="fixed top-5 right-5 opacity-50 z-30"
/>
)}
</div>
);
}

View File

@ -1,38 +0,0 @@
import LinkList from "@/components/LinkViews/LinkList";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { GridLoader } from "react-spinners";
export default function ListView({
links,
editMode,
isLoading,
}: {
links: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
}) {
return (
<div className="flex gap-1 flex-col">
{links.map((e, i) => {
return (
<LinkList
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{isLoading && links.length > 0 && (
<GridLoader
color="oklch(var(--p))"
loading={true}
size={20}
className="fixed top-5 right-5 opacity-50 z-30"
/>
)}
</div>
);
}

View File

@ -1,58 +0,0 @@
import LinkMasonry from "@/components/LinkViews/LinkMasonry";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { GridLoader } from "react-spinners";
import Masonry from "react-masonry-css";
import resolveConfig from "tailwindcss/resolveConfig";
import tailwindConfig from "../../../tailwind.config.js";
import { useMemo } from "react";
export default function MasonryView({
links,
editMode,
isLoading,
}: {
links: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
}) {
const fullConfig = resolveConfig(tailwindConfig as any);
const breakpointColumnsObj = useMemo(() => {
return {
default: 5,
1900: 4,
1500: 3,
880: 2,
550: 1,
};
}, []);
return (
<Masonry
breakpointCols={breakpointColumnsObj}
columnClassName="flex flex-col gap-5 !w-full"
className="grid min-[1901px]:grid-cols-5 min-[1501px]:grid-cols-4 min-[881px]:grid-cols-3 min-[551px]:grid-cols-2 grid-cols-1 gap-5 pb-5"
>
{links.map((e, i) => {
return (
<LinkMasonry
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{isLoading && links.length > 0 && (
<GridLoader
color="oklch(var(--p))"
loading={true}
size={20}
className="fixed top-5 right-5 opacity-50 z-30"
/>
)}
</Masonry>
);
}

View File

@ -7,11 +7,11 @@ import usePermissions from "@/hooks/usePermissions";
import EditLinkModal from "@/components/ModalContent/EditLinkModal"; import EditLinkModal from "@/components/ModalContent/EditLinkModal";
import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal"; import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal";
import PreservedFormatsModal from "@/components/ModalContent/PreservedFormatsModal"; import PreservedFormatsModal from "@/components/ModalContent/PreservedFormatsModal";
import useLinkStore from "@/store/links";
import { toast } from "react-hot-toast";
import useAccountStore from "@/store/account";
import { dropdownTriggerer } from "@/lib/client/utils"; import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
import { useDeleteLink, useUpdateLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
@ -39,41 +39,35 @@ export default function LinkActions({
const [deleteLinkModal, setDeleteLinkModal] = useState(false); const [deleteLinkModal, setDeleteLinkModal] = useState(false);
const [preservedFormatsModal, setPreservedFormatsModal] = useState(false); const [preservedFormatsModal, setPreservedFormatsModal] = useState(false);
const { account } = useAccountStore(); const { data: user = {} } = useUser();
const { removeLink, updateLink } = useLinkStore(); const updateLink = useUpdateLink();
const deleteLink = useDeleteLink();
const pinLink = async () => { const pinLink = async () => {
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0]; const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0] ? true : false;
const load = toast.loading(t("applying")); const load = toast.loading(t("updating"));
const response = await updateLink({ await updateLink.mutateAsync(
...link, {
pinnedBy: isAlreadyPinned ? undefined : [{ id: account.id }], ...link,
}); pinnedBy: isAlreadyPinned ? undefined : [{ id: user.id }],
},
{
onSettled: (data, error) => {
toast.dismiss(load);
toast.dismiss(load); if (error) {
toast.error(error.message);
if (response.ok) { } else {
toast.success(isAlreadyPinned ? t("link_unpinned") : t("link_unpinned")); toast.success(
} else { isAlreadyPinned ? t("link_unpinned") : t("link_pinned")
toast.error(response.data as string); );
} }
}; },
}
const deleteLink = async () => { );
const load = toast.loading(t("deleting"));
const response = await removeLink(link.id as number);
toast.dismiss(load);
if (response.ok) {
toast.success(t("deleted"));
} else {
toast.error(response.data as string);
}
}; };
return ( return (
@ -157,9 +151,25 @@ export default function LinkActions({
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={(e) => { onClick={async (e) => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
e.shiftKey ? deleteLink() : setDeleteLinkModal(true); e.shiftKey
? async () => {
const load = toast.loading(t("deleting"));
await deleteLink.mutateAsync(link.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("deleted"));
}
},
});
}
: setDeleteLinkModal(true);
}} }}
> >
{t("delete")} {t("delete")}
@ -184,7 +194,7 @@ export default function LinkActions({
{preservedFormatsModal ? ( {preservedFormatsModal ? (
<PreservedFormatsModal <PreservedFormatsModal
onClose={() => setPreservedFormatsModal(false)} onClose={() => setPreservedFormatsModal(false)}
activeLink={link} link={link}
/> />
) : undefined} ) : undefined}
{/* {expandedLink ? ( {/* {expandedLink ? (

View File

@ -5,7 +5,6 @@ import {
} from "@/types/global"; } from "@/types/global";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import useCollectionStore from "@/store/collections";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions"; import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate"; import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
@ -13,14 +12,16 @@ import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection
import Image from "next/image"; import Image from "next/image";
import { previewAvailable } from "@/lib/shared/getArchiveValidity"; import { previewAvailable } from "@/lib/shared/getArchiveValidity";
import Link from "next/link"; import Link from "next/link";
import LinkIcon from "./LinkComponents/LinkIcon"; import LinkIcon from "./LinkIcon";
import useOnScreen from "@/hooks/useOnScreen"; import useOnScreen from "@/hooks/useOnScreen";
import { generateLinkHref } from "@/lib/client/generateLinkHref"; import { generateLinkHref } from "@/lib/client/generateLinkHref";
import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge"; import LinkTypeBadge from "./LinkTypeBadge";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user";
import { useGetLink, useLinks } from "@/hooks/store/links";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
@ -33,11 +34,16 @@ type Props = {
export default function LinkCard({ link, flipDropdown, editMode }: Props) { export default function LinkCard({ link, flipDropdown, editMode }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const viewMode = localStorage.getItem("viewMode") || "card"; const { data: collections = [] } = useCollections();
const { collections } = useCollectionStore();
const { account } = useAccountStore();
const { links, getLink, setSelectedLinks, selectedLinks } = useLinkStore(); const { data: user = {} } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
const {
data: { data: links = [] },
} = useLinks();
const getLink = useGetLink();
useEffect(() => { useEffect(() => {
if (!editMode) { if (!editMode) {
@ -93,7 +99,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
link.preview !== "unavailable" link.preview !== "unavailable"
) { ) {
interval = setInterval(async () => { interval = setInterval(async () => {
getLink(link.id as number); getLink.mutateAsync(link.id as number);
}, 5000); }, 5000);
} }
@ -131,7 +137,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
<div <div
className="rounded-2xl cursor-pointer h-full flex flex-col justify-between" className="rounded-2xl cursor-pointer h-full flex flex-col justify-between"
onClick={() => onClick={() =>
!editMode && window.open(generateLinkHref(link, account), "_blank") !editMode && window.open(generateLinkHref(link, user), "_blank")
} }
> >
<div> <div>

View File

@ -3,7 +3,6 @@ import {
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
} from "@/types/global"; } from "@/types/global";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
import React from "react"; import React from "react";
export default function LinkCollection({ export default function LinkCollection({
@ -13,22 +12,22 @@ export default function LinkCollection({
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
collection: CollectionIncludingMembersAndLinkCount; collection: CollectionIncludingMembersAndLinkCount;
}) { }) {
const router = useRouter();
return ( return (
<Link <>
href={`/collections/${link.collection.id}`} <Link
onClick={(e) => { href={`/collections/${link.collection.id}`}
e.stopPropagation(); onClick={(e) => {
}} e.stopPropagation();
className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100 select-none" }}
title={collection?.name} className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100 select-none"
> title={collection?.name}
<i >
className="bi-folder-fill text-lg drop-shadow" <i
style={{ color: collection?.color }} className="bi-folder-fill text-lg drop-shadow"
></i> style={{ color: collection?.color }}
<p className="truncate capitalize">{collection?.name}</p> ></i>
</Link> <p className="truncate capitalize">{collection?.name}</p>
</Link>
</>
); );
} }

View File

@ -4,7 +4,6 @@ import {
} from "@/types/global"; } from "@/types/global";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import useCollectionStore from "@/store/collections";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions"; import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate"; import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
@ -12,11 +11,13 @@ import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon"; import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
import { isPWA } from "@/lib/client/utils"; import { isPWA } from "@/lib/client/utils";
import { generateLinkHref } from "@/lib/client/generateLinkHref"; import { generateLinkHref } from "@/lib/client/generateLinkHref";
import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge"; import LinkTypeBadge from "./LinkTypeBadge";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user";
import { useLinks } from "@/hooks/store/links";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
@ -33,9 +34,12 @@ export default function LinkCardCompact({
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { collections } = useCollectionStore(); const { data: collections = [] } = useCollections();
const { account } = useAccountStore();
const { links, setSelectedLinks, selectedLinks } = useLinkStore(); const { data: user = {} } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
const { links } = useLinks();
useEffect(() => { useEffect(() => {
if (!editMode) { if (!editMode) {
@ -119,7 +123,7 @@ export default function LinkCardCompact({
<div <div
className="flex items-center cursor-pointer w-full" className="flex items-center cursor-pointer w-full"
onClick={() => onClick={() =>
!editMode && window.open(generateLinkHref(link, account), "_blank") !editMode && window.open(generateLinkHref(link, user), "_blank")
} }
> >
<div className="shrink-0"> <div className="shrink-0">

View File

@ -5,7 +5,6 @@ import {
} from "@/types/global"; } from "@/types/global";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import useCollectionStore from "@/store/collections";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions"; import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate"; import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
@ -13,14 +12,16 @@ import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection
import Image from "next/image"; import Image from "next/image";
import { previewAvailable } from "@/lib/shared/getArchiveValidity"; import { previewAvailable } from "@/lib/shared/getArchiveValidity";
import Link from "next/link"; import Link from "next/link";
import LinkIcon from "./LinkComponents/LinkIcon"; import LinkIcon from "./LinkIcon";
import useOnScreen from "@/hooks/useOnScreen"; import useOnScreen from "@/hooks/useOnScreen";
import { generateLinkHref } from "@/lib/client/generateLinkHref"; import { generateLinkHref } from "@/lib/client/generateLinkHref";
import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge"; import LinkTypeBadge from "./LinkTypeBadge";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user";
import { useGetLink, useLinks } from "@/hooks/store/links";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
@ -33,10 +34,13 @@ type Props = {
export default function LinkMasonry({ link, flipDropdown, editMode }: Props) { export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { collections } = useCollectionStore(); const { data: collections = [] } = useCollections();
const { account } = useAccountStore(); const { data: user = {} } = useUser();
const { links, getLink, setSelectedLinks, selectedLinks } = useLinkStore(); const { setSelectedLinks, selectedLinks } = useLinkStore();
const { links } = useLinks();
const getLink = useGetLink();
useEffect(() => { useEffect(() => {
if (!editMode) { if (!editMode) {
@ -92,7 +96,7 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
link.preview !== "unavailable" link.preview !== "unavailable"
) { ) {
interval = setInterval(async () => { interval = setInterval(async () => {
getLink(link.id as number); getLink.mutateAsync(link.id as number);
}, 5000); }, 5000);
} }
@ -130,7 +134,7 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
<div <div
className="rounded-2xl cursor-pointer" className="rounded-2xl cursor-pointer"
onClick={() => onClick={() =>
!editMode && window.open(generateLinkHref(link, account), "_blank") !editMode && window.open(generateLinkHref(link, user), "_blank")
} }
> >
<div className="relative rounded-t-2xl overflow-hidden"> <div className="relative rounded-t-2xl overflow-hidden">

View File

@ -0,0 +1,238 @@
import LinkCard from "@/components/LinkViews/LinkComponents/LinkCard";
import {
LinkIncludingShortenedCollectionAndTags,
ViewMode,
} from "@/types/global";
import { useEffect } from "react";
import { useInView } from "react-intersection-observer";
import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry";
import Masonry from "react-masonry-css";
import resolveConfig from "tailwindcss/resolveConfig";
import tailwindConfig from "../../tailwind.config.js";
import { useMemo } from "react";
import LinkList from "@/components/LinkViews/LinkComponents/LinkList";
export function CardView({
links,
editMode,
isLoading,
placeholders,
hasNextPage,
placeHolderRef,
}: {
links?: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
placeholders?: number[];
hasNextPage?: boolean;
placeHolderRef?: any;
}) {
return (
<div className="grid min-[1901px]:grid-cols-5 min-[1501px]:grid-cols-4 min-[881px]:grid-cols-3 min-[551px]:grid-cols-2 grid-cols-1 gap-5 pb-5">
{links?.map((e, i) => {
return (
<LinkCard
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{(hasNextPage || isLoading) &&
placeholders?.map((e, i) => {
return (
<div
className="flex flex-col gap-4"
ref={e === 1 ? placeHolderRef : undefined}
key={i}
>
<div className="skeleton h-40 w-full"></div>
<div className="skeleton h-3 w-2/3"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-1/3"></div>
</div>
);
})}
</div>
);
}
export function MasonryView({
links,
editMode,
isLoading,
placeholders,
hasNextPage,
placeHolderRef,
}: {
links?: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
placeholders?: number[];
hasNextPage?: boolean;
placeHolderRef?: any;
}) {
const fullConfig = resolveConfig(tailwindConfig as any);
const breakpointColumnsObj = useMemo(() => {
return {
default: 5,
1900: 4,
1500: 3,
880: 2,
550: 1,
};
}, []);
return (
<Masonry
breakpointCols={breakpointColumnsObj}
columnClassName="flex flex-col gap-5 !w-full"
className="grid min-[1901px]:grid-cols-5 min-[1501px]:grid-cols-4 min-[881px]:grid-cols-3 min-[551px]:grid-cols-2 grid-cols-1 gap-5 pb-5"
>
{links?.map((e, i) => {
return (
<LinkMasonry
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{(hasNextPage || isLoading) &&
placeholders?.map((e, i) => {
return (
<div
className="flex flex-col gap-4"
ref={e === 1 ? placeHolderRef : undefined}
key={i}
>
<div className="skeleton h-40 w-full"></div>
<div className="skeleton h-3 w-2/3"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-1/3"></div>
</div>
);
})}
</Masonry>
);
}
export function ListView({
links,
editMode,
isLoading,
placeholders,
hasNextPage,
placeHolderRef,
}: {
links?: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
placeholders?: number[];
hasNextPage?: boolean;
placeHolderRef?: any;
}) {
return (
<div className="flex gap-1 flex-col">
{links?.map((e, i) => {
return (
<LinkList
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{(hasNextPage || isLoading) &&
placeholders?.map((e, i) => {
return (
<div
ref={e === 1 ? placeHolderRef : undefined}
key={i}
className="flex gap-4 p-4"
>
<div className="skeleton h-16 w-16"></div>
<div className="flex flex-col gap-4 w-full">
<div className="skeleton h-3 w-2/3"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-1/3"></div>
</div>
</div>
);
})}
</div>
);
}
export default function Links({
layout,
links,
editMode,
placeholderCount,
useData,
}: {
layout: ViewMode;
links?: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
placeholderCount?: number;
useData?: any;
}) {
const { ref, inView } = useInView();
useEffect(() => {
if (inView && useData?.fetchNextPage && useData?.hasNextPage) {
useData.fetchNextPage();
}
}, [useData, inView]);
if (layout === ViewMode.List) {
return (
<ListView
links={links}
editMode={editMode}
isLoading={useData?.isLoading}
placeholders={placeholderCountToArray(placeholderCount)}
hasNextPage={useData?.hasNextPage}
placeHolderRef={ref}
/>
);
} else if (layout === ViewMode.Masonry) {
return (
<MasonryView
links={links}
editMode={editMode}
isLoading={useData?.isLoading}
placeholders={placeholderCountToArray(placeholderCount)}
hasNextPage={useData?.hasNextPage}
placeHolderRef={ref}
/>
);
} else {
// Default to card view
return (
<CardView
links={links}
editMode={editMode}
isLoading={useData?.isLoading}
placeholders={placeholderCountToArray(placeholderCount)}
hasNextPage={useData?.hasNextPage}
placeHolderRef={ref}
/>
);
}
}
const placeholderCountToArray = (num?: number) =>
num ? Array.from({ length: num }, (_, i) => i + 1) : [];

View File

@ -1,9 +1,10 @@
import React from "react"; import React from "react";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import toast from "react-hot-toast";
import Modal from "../Modal"; import Modal from "../Modal";
import Button from "../ui/Button"; import Button from "../ui/Button";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useBulkDeleteLinks } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = { type Props = {
onClose: Function; onClose: Function;
@ -11,22 +12,29 @@ type Props = {
export default function BulkDeleteLinksModal({ onClose }: Props) { export default function BulkDeleteLinksModal({ onClose }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { selectedLinks, setSelectedLinks, deleteLinksById } = useLinkStore(); const { selectedLinks, setSelectedLinks } = useLinkStore();
const deleteLinksById = useBulkDeleteLinks();
const deleteLink = async () => { const deleteLink = async () => {
const load = toast.loading(t("deleting")); const load = toast.loading(t("deleting"));
const response = await deleteLinksById( await deleteLinksById.mutateAsync(
selectedLinks.map((link) => link.id as number) selectedLinks.map((link) => link.id as number),
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedLinks([]);
onClose();
toast.success(t("deleted"));
}
},
}
); );
toast.dismiss(load);
if (response.ok) {
toast.success(t("deleted"));
setSelectedLinks([]);
onClose();
} else toast.error(response.data as string);
}; };
return ( return (

View File

@ -6,6 +6,7 @@ import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import Modal from "../Modal"; import Modal from "../Modal";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useBulkEditLinks } from "@/hooks/store/links";
type Props = { type Props = {
onClose: Function; onClose: Function;
@ -13,13 +14,14 @@ type Props = {
export default function BulkEditLinksModal({ onClose }: Props) { export default function BulkEditLinksModal({ onClose }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { updateLinks, selectedLinks, setSelectedLinks } = useLinkStore(); const { selectedLinks, setSelectedLinks } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const [removePreviousTags, setRemovePreviousTags] = useState(false); const [removePreviousTags, setRemovePreviousTags] = useState(false);
const [updatedValues, setUpdatedValues] = useState< const [updatedValues, setUpdatedValues] = useState<
Pick<LinkIncludingShortenedCollectionAndTags, "tags" | "collectionId"> Pick<LinkIncludingShortenedCollectionAndTags, "tags" | "collectionId">
>({ tags: [] }); >({ tags: [] });
const updateLinks = useBulkEditLinks();
const setCollection = (e: any) => { const setCollection = (e: any) => {
const collectionId = e?.value || null; const collectionId = e?.value || null;
setUpdatedValues((prevValues) => ({ ...prevValues, collectionId })); setUpdatedValues((prevValues) => ({ ...prevValues, collectionId }));
@ -36,22 +38,28 @@ export default function BulkEditLinksModal({ onClose }: Props) {
const load = toast.loading(t("updating")); const load = toast.loading(t("updating"));
const response = await updateLinks( await updateLinks.mutateAsync(
selectedLinks, {
removePreviousTags, links: selectedLinks,
updatedValues newData: updatedValues,
removePreviousTags,
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedLinks([]);
onClose();
toast.success(t("updated"));
}
},
}
); );
toast.dismiss(load);
if (response.ok) {
toast.success(t("updated"));
setSelectedLinks([]);
onClose();
} else toast.error(response.data as string);
setSubmitLoader(false); setSubmitLoader(false);
return response;
} }
}; };

View File

@ -1,13 +1,13 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput"; import TextInput from "@/components/TextInput";
import useCollectionStore from "@/store/collections";
import toast from "react-hot-toast";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import Modal from "../Modal"; import Modal from "../Modal";
import Button from "../ui/Button"; import Button from "../ui/Button";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useDeleteCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast";
type Props = { type Props = {
onClose: Function; onClose: Function;
@ -22,7 +22,6 @@ export default function DeleteCollectionModal({
const [collection, setCollection] = const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection); useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const { removeCollection } = useCollectionStore();
const router = useRouter(); const router = useRouter();
const [inputField, setInputField] = useState(""); const [inputField, setInputField] = useState("");
const permissions = usePermissions(collection.id as number); const permissions = usePermissions(collection.id as number);
@ -31,6 +30,8 @@ export default function DeleteCollectionModal({
setCollection(activeCollection); setCollection(activeCollection);
}, []); }, []);
const deleteCollection = useDeleteCollection();
const submit = async () => { const submit = async () => {
if (permissions === true && collection.name !== inputField) return; if (permissions === true && collection.name !== inputField) return;
if (!submitLoader) { if (!submitLoader) {
@ -41,17 +42,19 @@ export default function DeleteCollectionModal({
const load = toast.loading(t("deleting_collection")); const load = toast.loading(t("deleting_collection"));
let response = await removeCollection(collection.id as number); deleteCollection.mutateAsync(collection.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
toast.dismiss(load); if (error) {
toast.error(error.message);
if (response.ok) { } else {
toast.success(t("deleted")); onClose();
onClose(); toast.success(t("deleted"));
router.push("/collections"); router.push("/collections");
} else { }
toast.error(response.data as string); },
} });
setSubmitLoader(false); setSubmitLoader(false);
} }

View File

@ -1,11 +1,11 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast";
import Modal from "../Modal"; import Modal from "../Modal";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Button from "../ui/Button"; import Button from "../ui/Button";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useDeleteLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = { type Props = {
onClose: Function; onClose: Function;
@ -16,31 +16,32 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const [link, setLink] = const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink); useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const { removeLink } = useLinkStore();
const deleteLink = useDeleteLink();
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
setLink(activeLink); setLink(activeLink);
}, []); }, []);
const deleteLink = async () => { const submit = async () => {
const load = toast.loading(t("deleting")); const load = toast.loading(t("deleting"));
const response = await removeLink(link.id as number); await deleteLink.mutateAsync(link.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
toast.dismiss(load); if (error) {
toast.error(error.message);
if (response.ok) { } else {
toast.success(t("deleted")); if (router.pathname.startsWith("/links/[id]")) {
} else { router.push("/dashboard");
toast.error(response.data as string); }
} toast.success(t("deleted"));
onClose();
if (router.pathname.startsWith("/links/[id]")) { }
router.push("/dashboard"); },
} });
onClose();
}; };
return ( return (
@ -61,7 +62,7 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
<p>{t("shift_key_tip")}</p> <p>{t("shift_key_tip")}</p>
<Button className="ml-auto" intent="destructive" onClick={deleteLink}> <Button className="ml-auto" intent="destructive" onClick={submit}>
<i className="bi-trash text-xl" /> <i className="bi-trash text-xl" />
{t("delete")} {t("delete")}
</Button> </Button>

View File

@ -1,8 +1,8 @@
import toast from "react-hot-toast";
import Modal from "../Modal"; import Modal from "../Modal";
import useUserStore from "@/store/admin/users";
import Button from "../ui/Button"; import Button from "../ui/Button";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useDeleteUser } from "@/hooks/store/admin/users";
import { useState } from "react";
type Props = { type Props = {
onClose: Function; onClose: Function;
@ -11,22 +11,22 @@ type Props = {
export default function DeleteUserModal({ onClose, userId }: Props) { export default function DeleteUserModal({ onClose, userId }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { removeUser } = useUserStore();
const deleteUser = async () => { const [submitLoader, setSubmitLoader] = useState(false);
const load = toast.loading(t("deleting_user")); const deleteUser = useDeleteUser();
const response = await removeUser(userId); const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
toast.dismiss(load); await deleteUser.mutateAsync(userId, {
onSuccess: () => {
onClose();
},
});
if (response.ok) { setSubmitLoader(false);
toast.success(t("user_deleted"));
} else {
toast.error(response.data as string);
} }
onClose();
}; };
return ( return (
@ -45,7 +45,7 @@ export default function DeleteUserModal({ onClose, userId }: Props) {
</span> </span>
</div> </div>
<Button className="ml-auto" intent="destructive" onClick={deleteUser}> <Button className="ml-auto" intent="destructive" onClick={submit}>
<i className="bi-trash text-xl" /> <i className="bi-trash text-xl" />
{t("delete_confirmation")} {t("delete_confirmation")}
</Button> </Button>

View File

@ -1,11 +1,11 @@
import React, { useState } from "react"; import React, { useState } from "react";
import TextInput from "@/components/TextInput"; import TextInput from "@/components/TextInput";
import useCollectionStore from "@/store/collections";
import toast from "react-hot-toast";
import { HexColorPicker } from "react-colorful"; import { HexColorPicker } from "react-colorful";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import Modal from "../Modal"; import Modal from "../Modal";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useUpdateCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast";
type Props = { type Props = {
onClose: Function; onClose: Function;
@ -21,7 +21,7 @@ export default function EditCollectionModal({
useState<CollectionIncludingMembersAndLinkCount>(activeCollection); useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const { updateCollection } = useCollectionStore(); const updateCollection = useUpdateCollection();
const submit = async () => { const submit = async () => {
if (!submitLoader) { if (!submitLoader) {
@ -32,14 +32,18 @@ export default function EditCollectionModal({
const load = toast.loading(t("updating_collection")); const load = toast.loading(t("updating_collection"));
let response = await updateCollection(collection as any); await updateCollection.mutateAsync(collection, {
onSettled: (data, error) => {
toast.dismiss(load);
toast.dismiss(load); if (error) {
toast.error(error.message);
if (response.ok) { } else {
toast.success(t("updated")); onClose();
onClose(); toast.success(t("updated"));
} else toast.error(response.data as string); }
},
});
setSubmitLoader(false); setSubmitLoader(false);
} }

View File

@ -1,16 +1,16 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput"; import TextInput from "@/components/TextInput";
import useCollectionStore from "@/store/collections";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global"; import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
import getPublicUserData from "@/lib/client/getPublicUserData"; import getPublicUserData from "@/lib/client/getPublicUserData";
import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import ProfilePhoto from "../ProfilePhoto"; import ProfilePhoto from "../ProfilePhoto";
import addMemberToCollection from "@/lib/client/addMemberToCollection"; import addMemberToCollection from "@/lib/client/addMemberToCollection";
import Modal from "../Modal"; import Modal from "../Modal";
import { dropdownTriggerer } from "@/lib/client/utils"; import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useUpdateCollection } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user";
type Props = { type Props = {
onClose: Function; onClose: Function;
@ -27,7 +27,7 @@ export default function EditCollectionSharingModal({
useState<CollectionIncludingMembersAndLinkCount>(activeCollection); useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const { updateCollection } = useCollectionStore(); const updateCollection = useUpdateCollection();
const submit = async () => { const submit = async () => {
if (!submitLoader) { if (!submitLoader) {
@ -36,24 +36,26 @@ export default function EditCollectionSharingModal({
setSubmitLoader(true); setSubmitLoader(true);
const load = toast.loading(t("updating")); const load = toast.loading(t("updating_collection"));
let response; await updateCollection.mutateAsync(collection, {
onSettled: (data, error) => {
toast.dismiss(load);
response = await updateCollection(collection as any); if (error) {
toast.error(error.message);
toast.dismiss(load); } else {
onClose();
if (response.ok) { toast.success(t("updated"));
toast.success(t("updated")); }
onClose(); },
} else toast.error(response.data as string); });
setSubmitLoader(false); setSubmitLoader(false);
} }
}; };
const { account } = useAccountStore(); const { data: user = {} } = useUser();
const permissions = usePermissions(collection.id as number); const permissions = usePermissions(collection.id as number);
const currentURL = new URL(document.URL); const currentURL = new URL(document.URL);
@ -165,7 +167,7 @@ export default function EditCollectionSharingModal({
onKeyDown={(e) => onKeyDown={(e) =>
e.key === "Enter" && e.key === "Enter" &&
addMemberToCollection( addMemberToCollection(
account.username as string, user.username as string,
memberUsername || "", memberUsername || "",
collection, collection,
setMemberState, setMemberState,
@ -177,7 +179,7 @@ export default function EditCollectionSharingModal({
<div <div
onClick={() => onClick={() =>
addMemberToCollection( addMemberToCollection(
account.username as string, user.username as string,
memberUsername || "", memberUsername || "",
collection, collection,
setMemberState, setMemberState,

View File

@ -3,12 +3,12 @@ import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection"; import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput"; import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast";
import Link from "next/link"; import Link from "next/link";
import Modal from "../Modal"; import Modal from "../Modal";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useUpdateLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = { type Props = {
onClose: Function; onClose: Function;
@ -27,9 +27,10 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
console.log(error); console.log(error);
} }
const { updateLink } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const updateLink = useUpdateLink();
const setCollection = (e: any) => { const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null; if (e?.__isNew__) e.value = null;
setLink({ setLink({
@ -50,19 +51,23 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
const submit = async () => { const submit = async () => {
if (!submitLoader) { if (!submitLoader) {
setSubmitLoader(true); setSubmitLoader(true);
const load = toast.loading(t("updating"));
let response = await updateLink(link);
toast.dismiss(load);
if (response.ok) { const load = toast.loading(t("updating"));
toast.success(t("updated"));
onClose(); await updateLink.mutateAsync(link, {
} else { onSettled: (data, error) => {
toast.error(response.data as string); toast.dismiss(load);
}
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("updated"));
}
},
});
setSubmitLoader(false); setSubmitLoader(false);
return response;
} }
}; };

View File

@ -1,14 +1,12 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput"; import TextInput from "@/components/TextInput";
import useCollectionStore from "@/store/collections";
import toast from "react-hot-toast";
import { HexColorPicker } from "react-colorful"; import { HexColorPicker } from "react-colorful";
import { Collection } from "@prisma/client"; import { Collection } from "@prisma/client";
import Modal from "../Modal"; import Modal from "../Modal";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import useAccountStore from "@/store/account";
import { useSession } from "next-auth/react";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useCreateCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast";
type Props = { type Props = {
onClose: Function; onClose: Function;
@ -25,15 +23,14 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
} as Partial<Collection>; } as Partial<Collection>;
const [collection, setCollection] = useState<Partial<Collection>>(initial); const [collection, setCollection] = useState<Partial<Collection>>(initial);
const { setAccount } = useAccountStore();
const { data } = useSession();
useEffect(() => { useEffect(() => {
setCollection(initial); setCollection(initial);
}, []); }, []);
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const { addCollection } = useCollectionStore();
const createCollection = useCreateCollection();
const submit = async () => { const submit = async () => {
if (submitLoader) return; if (submitLoader) return;
@ -43,16 +40,18 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
const load = toast.loading(t("creating")); const load = toast.loading(t("creating"));
let response = await addCollection(collection as any); await createCollection.mutateAsync(collection, {
toast.dismiss(load); onSettled: (data, error) => {
toast.dismiss(load);
if (response.ok) { if (error) {
toast.success(t("created_success")); toast.error(error.message);
if (response.data) { } else {
setAccount(data?.user.id as number); onClose();
onClose(); toast.success(t("created"));
} }
} else toast.error(response.data as string); },
});
setSubmitLoader(false); setSubmitLoader(false);
}; };

View File

@ -1,17 +1,16 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Toaster } from "react-hot-toast";
import CollectionSelection from "@/components/InputSelect/CollectionSelection"; import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection"; import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput"; import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import useCollectionStore from "@/store/collections";
import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import toast from "react-hot-toast";
import Modal from "../Modal"; import Modal from "../Modal";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useAddLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = { type Props = {
onClose: Function; onClose: Function;
@ -40,11 +39,13 @@ export default function NewLinkModal({ onClose }: Props) {
const [link, setLink] = const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(initial); useState<LinkIncludingShortenedCollectionAndTags>(initial);
const { addLink } = useLinkStore();
const addLink = useAddLink();
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false); const [optionsExpanded, setOptionsExpanded] = useState(false);
const router = useRouter(); const router = useRouter();
const { collections } = useCollectionStore(); const { data: collections = [] } = useCollections();
const setCollection = (e: any) => { const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null; if (e?.__isNew__) e.value = null;
@ -87,15 +88,22 @@ export default function NewLinkModal({ onClose }: Props) {
const submit = async () => { const submit = async () => {
if (!submitLoader) { if (!submitLoader) {
setSubmitLoader(true); setSubmitLoader(true);
const load = toast.loading(t("creating_link")); const load = toast.loading(t("creating_link"));
const response = await addLink(link);
toast.dismiss(load); await addLink.mutateAsync(link, {
if (response.ok) { onSettled: (data, error) => {
toast.success(t("link_created")); toast.dismiss(load);
onClose();
} else { if (error) {
toast.error(response.data as string); toast.error(error.message);
} } else {
onClose();
toast.success(t("link_created"));
}
},
});
setSubmitLoader(false); setSubmitLoader(false);
} }
}; };

View File

@ -3,10 +3,10 @@ import TextInput from "@/components/TextInput";
import { TokenExpiry } from "@/types/global"; import { TokenExpiry } from "@/types/global";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import Modal from "../Modal"; import Modal from "../Modal";
import useTokenStore from "@/store/tokens";
import { dropdownTriggerer } from "@/lib/client/utils"; import { dropdownTriggerer } from "@/lib/client/utils";
import Button from "../ui/Button"; import Button from "../ui/Button";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useAddToken } from "@/hooks/store/tokens";
type Props = { type Props = {
onClose: Function; onClose: Function;
@ -15,7 +15,7 @@ type Props = {
export default function NewTokenModal({ onClose }: Props) { export default function NewTokenModal({ onClose }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const [newToken, setNewToken] = useState(""); const [newToken, setNewToken] = useState("");
const { addToken } = useTokenStore(); const addToken = useAddToken();
const initial = { const initial = {
name: "", name: "",
@ -28,16 +28,20 @@ export default function NewTokenModal({ onClose }: Props) {
const submit = async () => { const submit = async () => {
if (!submitLoader) { if (!submitLoader) {
setSubmitLoader(true); setSubmitLoader(true);
const load = toast.loading(t("creating_token")); const load = toast.loading(t("creating_token"));
const { ok, data } = await addToken(token); await addToken.mutateAsync(token, {
onSettled: (data, error) => {
toast.dismiss(load);
toast.dismiss(load); if (error) {
toast.error(error.message);
if (ok) { } else {
toast.success(t("token_created")); setNewToken(data.secretKey);
setNewToken((data as any).secretKey); }
} else toast.error(data as string); },
});
setSubmitLoader(false); setSubmitLoader(false);
} }

View File

@ -1,9 +1,9 @@
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import Modal from "../Modal"; import Modal from "../Modal";
import useUserStore from "@/store/admin/users";
import TextInput from "../TextInput"; import TextInput from "../TextInput";
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { useTranslation, Trans } from "next-i18next"; import { useTranslation, Trans } from "next-i18next";
import { useAddUser } from "@/hooks/store/admin/users";
type Props = { type Props = {
onClose: Function; onClose: Function;
@ -20,7 +20,9 @@ const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true";
export default function NewUserModal({ onClose }: Props) { export default function NewUserModal({ onClose }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { addUser } = useUserStore();
const addUser = useAddUser();
const [form, setForm] = useState<FormData>({ const [form, setForm] = useState<FormData>({
name: "", name: "",
username: "", username: "",
@ -44,24 +46,15 @@ export default function NewUserModal({ onClose }: Props) {
}; };
if (checkFields()) { if (checkFields()) {
if (form.password.length < 8)
return toast.error(t("password_length_error"));
setSubmitLoader(true); setSubmitLoader(true);
const load = toast.loading(t("creating_account")); await addUser.mutateAsync(form, {
onSuccess: () => {
onClose();
},
});
const response = await addUser(form);
toast.dismiss(load);
setSubmitLoader(false); setSubmitLoader(false);
if (response.ok) {
toast.success(t("user_created"));
onClose();
} else {
toast.error(response.data as string);
}
} else { } else {
toast.error(t("fill_all_fields_error")); toast.error(t("fill_all_fields_error"));
} }

View File

@ -1,5 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import { import {
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
ArchivedFormat, ArchivedFormat,
@ -16,23 +15,22 @@ import {
screenshotAvailable, screenshotAvailable,
} from "@/lib/shared/getArchiveValidity"; } from "@/lib/shared/getArchiveValidity";
import PreservedFormatRow from "@/components/PreserverdFormatRow"; import PreservedFormatRow from "@/components/PreserverdFormatRow";
import useAccountStore from "@/store/account";
import getPublicUserData from "@/lib/client/getPublicUserData"; import getPublicUserData from "@/lib/client/getPublicUserData";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { BeatLoader } from "react-spinners"; import { BeatLoader } from "react-spinners";
import { useUser } from "@/hooks/store/user";
import { useGetLink } from "@/hooks/store/links";
type Props = { type Props = {
onClose: Function; onClose: Function;
activeLink: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
}; };
export default function PreservedFormatsModal({ onClose, activeLink }: Props) { export default function PreservedFormatsModal({ onClose, link }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const session = useSession(); const session = useSession();
const { getLink } = useLinkStore(); const getLink = useGetLink();
const { account } = useAccountStore(); const { data: user = {} } = useUser();
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const router = useRouter(); const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined; let isPublic = router.pathname.startsWith("/public") ? true : undefined;
@ -49,20 +47,20 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
useEffect(() => { useEffect(() => {
const fetchOwner = async () => { const fetchOwner = async () => {
if (link.collection.ownerId !== account.id) { if (link.collection.ownerId !== user.id) {
const owner = await getPublicUserData( const owner = await getPublicUserData(
link.collection.ownerId as number link.collection.ownerId as number
); );
setCollectionOwner(owner); setCollectionOwner(owner);
} else if (link.collection.ownerId === account.id) { } else if (link.collection.ownerId === user.id) {
setCollectionOwner({ setCollectionOwner({
id: account.id as number, id: user.id as number,
name: account.name, name: user.name,
username: account.username as string, username: user.username as string,
image: account.image as string, image: user.image as string,
archiveAsScreenshot: account.archiveAsScreenshot as boolean, archiveAsScreenshot: user.archiveAsScreenshot as boolean,
archiveAsMonolith: account.archiveAsScreenshot as boolean, archiveAsMonolith: user.archiveAsScreenshot as boolean,
archiveAsPDF: account.archiveAsPDF as boolean, archiveAsPDF: user.archiveAsPDF as boolean,
}); });
} }
}; };
@ -98,20 +96,14 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const data = await getLink(link.id as number, isPublic); await getLink.mutateAsync(link.id as number);
setLink(
(data as any).response as LinkIncludingShortenedCollectionAndTags
);
})(); })();
let interval: any; let interval: any;
if (!isReady()) { if (!isReady()) {
interval = setInterval(async () => { interval = setInterval(async () => {
const data = await getLink(link.id as number, isPublic); await getLink.mutateAsync(link.id as number);
setLink(
(data as any).response as LinkIncludingShortenedCollectionAndTags
);
}, 5000); }, 5000);
} else { } else {
if (interval) { if (interval) {
@ -137,10 +129,8 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
toast.dismiss(load); toast.dismiss(load);
if (response.ok) { if (response.ok) {
const newLink = await getLink(link?.id as number); await getLink.mutateAsync(link?.id as number);
setLink(
(newLink as any).response as LinkIncludingShortenedCollectionAndTags
);
toast.success(t("link_being_archived")); toast.success(t("link_being_archived"));
} else toast.error(data.response); } else toast.error(data.response);
}; };
@ -164,7 +154,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
name={t("webpage")} name={t("webpage")}
icon={"bi-filetype-html"} icon={"bi-filetype-html"}
format={ArchivedFormat.monolith} format={ArchivedFormat.monolith}
activeLink={link} link={link}
downloadable={true} downloadable={true}
/> />
) : undefined} ) : undefined}
@ -178,7 +168,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
? ArchivedFormat.png ? ArchivedFormat.png
: ArchivedFormat.jpeg : ArchivedFormat.jpeg
} }
activeLink={link} link={link}
downloadable={true} downloadable={true}
/> />
) : undefined} ) : undefined}
@ -188,7 +178,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
name={t("pdf")} name={t("pdf")}
icon={"bi-file-earmark-pdf"} icon={"bi-file-earmark-pdf"}
format={ArchivedFormat.pdf} format={ArchivedFormat.pdf}
activeLink={link} link={link}
downloadable={true} downloadable={true}
/> />
) : undefined} ) : undefined}
@ -198,7 +188,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
name={t("readable")} name={t("readable")}
icon={"bi-file-earmark-text"} icon={"bi-file-earmark-text"}
format={ArchivedFormat.readability} format={ArchivedFormat.readability}
activeLink={link} link={link}
/> />
) : undefined} ) : undefined}

View File

@ -1,10 +1,10 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import useTokenStore from "@/store/tokens";
import toast from "react-hot-toast";
import Modal from "../Modal"; import Modal from "../Modal";
import Button from "../ui/Button"; import Button from "../ui/Button";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { AccessToken } from "@prisma/client"; import { AccessToken } from "@prisma/client";
import { useRevokeToken } from "@/hooks/store/tokens";
import toast from "react-hot-toast";
type Props = { type Props = {
onClose: Function; onClose: Function;
@ -15,7 +15,7 @@ export default function DeleteTokenModal({ onClose, activeToken }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const [token, setToken] = useState<AccessToken>(activeToken); const [token, setToken] = useState<AccessToken>(activeToken);
const { revokeToken } = useTokenStore(); const revokeToken = useRevokeToken();
useEffect(() => { useEffect(() => {
setToken(activeToken); setToken(activeToken);
@ -24,17 +24,18 @@ export default function DeleteTokenModal({ onClose, activeToken }: Props) {
const deleteLink = async () => { const deleteLink = async () => {
const load = toast.loading(t("deleting")); const load = toast.loading(t("deleting"));
const response = await revokeToken(token.id as number); await revokeToken.mutateAsync(token.id, {
onSettled: (data, error) => {
toast.dismiss(load);
toast.dismiss(load); if (error) {
toast.error(error.message);
if (response.ok) { } else {
toast.success(t("token_revoked")); onClose();
} else { toast.success(t("token_revoked"));
toast.error(response.data as string); }
} },
});
onClose();
}; };
return ( return (

View File

@ -3,8 +3,6 @@ import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection"; import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput"; import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import useCollectionStore from "@/store/collections";
import useLinkStore from "@/store/links";
import { import {
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
ArchivedFormat, ArchivedFormat,
@ -14,6 +12,8 @@ import { useRouter } from "next/router";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import Modal from "../Modal"; import Modal from "../Modal";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useUploadFile } from "@/hooks/store/links";
type Props = { type Props = {
onClose: Function; onClose: Function;
@ -45,11 +45,11 @@ export default function UploadFileModal({ onClose }: Props) {
useState<LinkIncludingShortenedCollectionAndTags>(initial); useState<LinkIncludingShortenedCollectionAndTags>(initial);
const [file, setFile] = useState<File>(); const [file, setFile] = useState<File>();
const { uploadFile } = useLinkStore(); const uploadFile = useUploadFile();
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false); const [optionsExpanded, setOptionsExpanded] = useState(false);
const router = useRouter(); const router = useRouter();
const { collections } = useCollectionStore(); const { data: collections = [] } = useCollections();
const setCollection = (e: any) => { const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null; if (e?.__isNew__) e.value = null;
@ -115,20 +115,26 @@ export default function UploadFileModal({ onClose }: Props) {
// } // }
setSubmitLoader(true); setSubmitLoader(true);
const load = toast.loading(t("creating")); const load = toast.loading(t("creating"));
const response = await uploadFile(link, file); await uploadFile.mutateAsync(
{ link, file },
{
onSettled: (data, error) => {
toast.dismiss(load);
toast.dismiss(load); if (error) {
if (response.ok) { toast.error(error.message);
toast.success(t("created_success")); } else {
onClose(); onClose();
} else { toast.success(t("created_success"));
toast.error(response.data as string); }
} },
}
);
setSubmitLoader(false); setSubmitLoader(false);
return response;
} }
}; };

View File

@ -1,19 +1,16 @@
import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import { import {
ArchivedFormat, ArchivedFormat,
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
} from "@/types/global"; } from "@/types/global";
import toast from "react-hot-toast";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useSession } from "next-auth/react"; import { useGetLink } from "@/hooks/store/links";
type Props = { type Props = {
name: string; name: string;
icon: string; icon: string;
format: ArchivedFormat; format: ArchivedFormat;
activeLink: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
downloadable?: boolean; downloadable?: boolean;
}; };
@ -21,48 +18,15 @@ export default function PreservedFormatRow({
name, name,
icon, icon,
format, format,
activeLink, link,
downloadable, downloadable,
}: Props) { }: Props) {
const session = useSession(); const getLink = useGetLink();
const { getLink } = useLinkStore();
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const router = useRouter(); const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined; let isPublic = router.pathname.startsWith("/public") ? true : undefined;
useEffect(() => {
(async () => {
const data = await getLink(link.id as number, isPublic);
setLink(
(data as any).response as LinkIncludingShortenedCollectionAndTags
);
})();
let interval: any;
if (link?.image === "pending" || link?.pdf === "pending") {
interval = setInterval(async () => {
const data = await getLink(link.id as number, isPublic);
setLink(
(data as any).response as LinkIncludingShortenedCollectionAndTags
);
}, 5000);
} else {
if (interval) {
clearInterval(interval);
}
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [link?.image, link?.pdf, link?.readable, link?.monolith]);
const handleDownload = () => { const handleDownload = () => {
const path = `/api/v1/archives/${link?.id}?format=${format}`; const path = `/api/v1/archives/${link?.id}?format=${format}`;
fetch(path) fetch(path)

View File

@ -1,17 +1,17 @@
import useLocalSettingsStore from "@/store/localSettings"; import useLocalSettingsStore from "@/store/localSettings";
import { dropdownTriggerer } from "@/lib/client/utils"; import { dropdownTriggerer } from "@/lib/client/utils";
import ProfilePhoto from "./ProfilePhoto"; import ProfilePhoto from "./ProfilePhoto";
import useAccountStore from "@/store/account";
import Link from "next/link"; import Link from "next/link";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
export default function ProfileDropdown() { export default function ProfileDropdown() {
const { t } = useTranslation(); const { t } = useTranslation();
const { settings, updateSettings } = useLocalSettingsStore(); const { settings, updateSettings } = useLocalSettingsStore();
const { account } = useAccountStore(); const { data: user = {} } = useUser();
const isAdmin = account.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1); const isAdmin = user.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1);
const handleToggle = () => { const handleToggle = () => {
const newTheme = settings.theme === "dark" ? "light" : "dark"; const newTheme = settings.theme === "dark" ? "light" : "dark";
@ -27,7 +27,7 @@ export default function ProfileDropdown() {
className="btn btn-circle btn-ghost" className="btn btn-circle btn-ghost"
> >
<ProfilePhoto <ProfilePhoto
src={account.image ? account.image : undefined} src={user.image ? user.image : undefined}
priority={true} priority={true}
/> />
</div> </div>

View File

@ -1,7 +1,6 @@
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import { readabilityAvailable } from "@/lib/shared/getArchiveValidity"; import { readabilityAvailable } from "@/lib/shared/getArchiveValidity";
import isValidUrl from "@/lib/shared/isValidUrl"; import isValidUrl from "@/lib/shared/isValidUrl";
import useLinkStore from "@/store/links";
import { import {
ArchivedFormat, ArchivedFormat,
CollectionIncludingMembersAndLinkCount, CollectionIncludingMembersAndLinkCount,
@ -14,8 +13,9 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import LinkActions from "./LinkViews/LinkComponents/LinkActions"; import LinkActions from "./LinkViews/LinkComponents/LinkActions";
import useCollectionStore from "@/store/collections";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useGetLink } from "@/hooks/store/links";
type LinkContent = { type LinkContent = {
title: string; title: string;
@ -45,8 +45,8 @@ export default function ReadableView({ link }: Props) {
const router = useRouter(); const router = useRouter();
const { getLink } = useLinkStore(); const getLink = useGetLink();
const { collections } = useCollectionStore(); const { data: collections = [] } = useCollections();
const collection = useMemo(() => { const collection = useMemo(() => {
return collections.find( return collections.find(
@ -73,7 +73,7 @@ export default function ReadableView({ link }: Props) {
}, [link]); }, [link]);
useEffect(() => { useEffect(() => {
if (link) getLink(link?.id as number); if (link) getLink.mutateAsync(link?.id as number);
let interval: any; let interval: any;
if ( if (
@ -87,7 +87,10 @@ export default function ReadableView({ link }: Props) {
!link?.readable || !link?.readable ||
!link?.monolith) !link?.monolith)
) { ) {
interval = setInterval(() => getLink(link.id as number), 5000); interval = setInterval(
() => getLink.mutateAsync(link.id as number),
5000
);
} else { } else {
if (interval) { if (interval) {
clearInterval(interval); clearInterval(interval);

View File

@ -1,5 +1,3 @@
import useCollectionStore from "@/store/collections";
import useTagStore from "@/store/tags";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -7,6 +5,8 @@ import { Disclosure, Transition } from "@headlessui/react";
import SidebarHighlightLink from "@/components/SidebarHighlightLink"; import SidebarHighlightLink from "@/components/SidebarHighlightLink";
import CollectionListing from "@/components/CollectionListing"; import CollectionListing from "@/components/CollectionListing";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useTags } from "@/hooks/store/tags";
export default function Sidebar({ className }: { className?: string }) { export default function Sidebar({ className }: { className?: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -22,8 +22,9 @@ export default function Sidebar({ className }: { className?: string }) {
} }
); );
const { collections } = useCollectionStore(); const { data: collections } = useCollections();
const { tags } = useTagStore();
const { data: tags = [], isLoading } = useTags();
const [active, setActive] = useState(""); const [active, setActive] = useState("");
const router = useRouter(); const router = useRouter();
@ -127,10 +128,16 @@ export default function Sidebar({ className }: { className?: string }) {
leaveTo="transform opacity-0 -translate-y-3" leaveTo="transform opacity-0 -translate-y-3"
> >
<Disclosure.Panel className="flex flex-col gap-1"> <Disclosure.Panel className="flex flex-col gap-1">
{tags[0] ? ( {isLoading ? (
<div className="flex flex-col gap-4">
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
</div>
) : tags[0] ? (
tags tags
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a: any, b: any) => a.name.localeCompare(b.name))
.map((e, i) => { .map((e: any, i: any) => {
return ( return (
<Link key={i} href={`/tags/${e.id}`}> <Link key={i} href={`/tags/${e.id}`}>
<div <div

View File

@ -1,7 +1,8 @@
import React, { Dispatch, SetStateAction } from "react"; import React, { Dispatch, SetStateAction, useEffect } from "react";
import { Sort } from "@/types/global"; import { Sort } from "@/types/global";
import { dropdownTriggerer } from "@/lib/client/utils"; import { dropdownTriggerer } from "@/lib/client/utils";
import { TFunction } from "i18next"; import { TFunction } from "i18next";
import useLocalSettingsStore from "@/store/localSettings";
type Props = { type Props = {
sortBy: Sort; sortBy: Sort;
@ -10,6 +11,12 @@ type Props = {
}; };
export default function SortDropdown({ sortBy, setSort, t }: Props) { export default function SortDropdown({ sortBy, setSort, t }: Props) {
const { updateSettings } = useLocalSettingsStore();
useEffect(() => {
updateSettings({ sortBy });
}, [sortBy]);
return ( return (
<div className="dropdown dropdown-bottom dropdown-end"> <div className="dropdown dropdown-bottom dropdown-end">
<div <div

View File

@ -4,8 +4,8 @@ import useLocalSettingsStore from "@/store/localSettings";
import { ViewMode } from "@/types/global"; import { ViewMode } from "@/types/global";
type Props = { type Props = {
viewMode: string; viewMode: ViewMode;
setViewMode: Dispatch<SetStateAction<string>>; setViewMode: Dispatch<SetStateAction<ViewMode>>;
}; };
export default function ViewDropdown({ viewMode, setViewMode }: Props) { export default function ViewDropdown({ viewMode, setViewMode }: Props) {
@ -19,7 +19,7 @@ export default function ViewDropdown({ viewMode, setViewMode }: Props) {
}; };
useEffect(() => { useEffect(() => {
updateSettings({ viewMode: viewMode as ViewMode }); updateSettings({ viewMode });
}, [viewMode]); }, [viewMode]);
return ( return (

272
components/ui/Loader.tsx Normal file
View File

@ -0,0 +1,272 @@
import React from "react";
type Props = {
className?: string;
color: string;
size: string;
};
const Loader = (props: Props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid"
width={props.size}
height={props.size}
className={props.className}
style={{
shapeRendering: "auto",
display: "block",
background: "rgba(255, 255, 255, 0)",
}}
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<g>
<g transform="rotate(0 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.9166666666666666s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(30 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.8333333333333334s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(60 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.75s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(90 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.6666666666666666s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(120 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.5833333333333334s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(150 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.5s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(180 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.4166666666666667s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(210 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.3333333333333333s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(240 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.25s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(270 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.16666666666666666s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(300 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.08333333333333333s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(330 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="0s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g></g>
</g>
</svg>
);
};
export default Loader;

View File

@ -0,0 +1,93 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { useTranslation } from "next-i18next";
import { useSession } from "next-auth/react";
const useUsers = () => {
const { status } = useSession();
return useQuery({
queryKey: ["users"],
queryFn: async () => {
const response = await fetch("/api/v1/users");
if (!response.ok) {
if (response.status === 401) {
window.location.href = "/dashboard";
}
throw new Error("Failed to fetch users.");
}
const data = await response.json();
return data.response;
},
enabled: status === "authenticated",
});
};
const useAddUser = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: async (body: any) => {
if (body.password.length < 8) throw new Error(t("password_length_error"));
const load = toast.loading(t("creating_account"));
const response = await fetch("/api/v1/users", {
method: "POST",
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
},
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
toast.dismiss(load);
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["users"], (oldData: any) => [...oldData, data]);
toast.success(t("user_created"));
},
onError: (error) => {
toast.error(error.message);
},
});
};
const useDeleteUser = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: async (userId: number) => {
const load = toast.loading(t("deleting_user"));
const response = await fetch(`/api/v1/users/${userId}`, {
method: "DELETE",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
toast.dismiss(load);
return data.response;
},
onSuccess: (data, variables) => {
queryClient.setQueryData(["users"], (oldData: any) =>
oldData.filter((user: any) => user.id !== variables)
);
toast.success(t("user_deleted"));
},
onError: (error) => {
toast.error(error.message);
},
});
};
export { useUsers, useAddUser, useDeleteUser };

116
hooks/store/collections.tsx Normal file
View File

@ -0,0 +1,116 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useSession } from "next-auth/react";
const useCollections = () => {
const { status } = useSession();
return useQuery({
queryKey: ["collections"],
queryFn: async (): Promise<CollectionIncludingMembersAndLinkCount[]> => {
const response = await fetch("/api/v1/collections");
const data = await response.json();
return data.response;
},
enabled: status === "authenticated",
});
};
const useCreateCollection = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body: any) => {
const response = await fetch("/api/v1/collections", {
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
return queryClient.setQueryData(["collections"], (oldData: any) => {
return [...oldData, data];
});
},
});
};
const useUpdateCollection = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body: any) => {
const response = await fetch(`/api/v1/collections/${body.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
{
return queryClient.setQueryData(["collections"], (oldData: any) => {
return oldData.map((collection: any) =>
collection.id === data.id ? data : collection
);
});
}
},
// onMutate: async (data) => {
// await queryClient.cancelQueries({ queryKey: ["collections"] });
// queryClient.setQueryData(["collections"], (oldData: any) => {
// return oldData.map((collection: any) =>
// collection.id === data.id ? data : collection
// )
// });
// },
});
};
const useDeleteCollection = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await fetch(`/api/v1/collections/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
return queryClient.setQueryData(["collections"], (oldData: any) => {
return oldData.filter((collection: any) => collection.id !== data.id);
});
},
});
};
export {
useCollections,
useCreateCollection,
useUpdateCollection,
useDeleteCollection,
};

View File

@ -0,0 +1,20 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useQuery } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
const useDashboardData = () => {
const { status } = useSession();
return useQuery({
queryKey: ["dashboardData"],
queryFn: async (): Promise<LinkIncludingShortenedCollectionAndTags[]> => {
const response = await fetch("/api/v1/dashboard");
const data = await response.json();
return data.response;
},
enabled: status === "authenticated",
});
};
export { useDashboardData };

437
hooks/store/links.tsx Normal file
View File

@ -0,0 +1,437 @@
import {
InfiniteData,
useInfiniteQuery,
UseInfiniteQueryResult,
useQueryClient,
useMutation,
} from "@tanstack/react-query";
import { useMemo } from "react";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
LinkRequestQuery,
} from "@/types/global";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
const useLinks = (params: LinkRequestQuery = {}) => {
const router = useRouter();
const queryParamsObject = {
sort: params.sort ?? Number(window.localStorage.getItem("sortBy")) ?? 0,
collectionId:
params.collectionId ?? router.pathname === "/collections/[id]"
? router.query.id
: undefined,
tagId:
params.tagId ?? router.pathname === "/tags/[id]"
? router.query.id
: undefined,
pinnedOnly:
params.pinnedOnly ?? router.pathname === "/links/pinned"
? true
: undefined,
searchQueryString: params.searchQueryString,
searchByName: params.searchByName,
searchByUrl: params.searchByUrl,
searchByDescription: params.searchByDescription,
searchByTextContent: params.searchByTextContent,
searchByTags: params.searchByTags,
} as LinkRequestQuery;
const queryString = buildQueryString(queryParamsObject);
const { data, ...rest } = useFetchLinks(queryString);
const links = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return [...acc, ...page];
}, []);
}, [data]);
return {
links,
data: { ...data, ...rest },
} as {
links: LinkIncludingShortenedCollectionAndTags[];
data: UseInfiniteQueryResult<InfiniteData<any, unknown>, Error>;
};
};
const useFetchLinks = (params: string) => {
const { status } = useSession();
return useInfiniteQuery({
queryKey: ["links", { params }],
queryFn: async (params) => {
const response = await fetch(
"/api/v1/links?cursor=" +
params.pageParam +
((params.queryKey[1] as any).params
? "&" + (params.queryKey[1] as any).params
: "")
);
const data = await response.json();
return data.response;
},
initialPageParam: 0,
refetchOnWindowFocus: false,
getNextPageParam: (lastPage) => {
if (lastPage.length === 0) {
return undefined;
}
return lastPage.at(-1).id;
},
enabled: status === "authenticated",
});
};
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 useAddLink = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (link: LinkIncludingShortenedCollectionAndTags) => {
const response = await fetch("/api/v1/links", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(link),
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return [data, ...oldData];
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: [[data, ...oldData?.pages[0]], ...oldData?.pages.slice(1)],
pageParams: oldData?.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useUpdateLink = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (link: LinkIncludingShortenedCollectionAndTags) => {
const response = await fetch(`/api/v1/links/${link.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(link),
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return oldData.map((e: any) => (e.id === data.id ? data : e));
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: oldData.pages.map((page: any) =>
page.map((item: any) => (item.id === data.id ? data : item))
),
pageParams: oldData.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useDeleteLink = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await fetch(`/api/v1/links/${id}`, {
method: "DELETE",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return oldData.filter((e: any) => e.id !== data.id);
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: oldData.pages.map((page: any) =>
page.filter((item: any) => item.id !== data.id)
),
pageParams: oldData.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useGetLink = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await fetch(`/api/v1/links/${id}`);
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return oldData.map((e: any) => (e.id === data.id ? data : e));
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: oldData.pages.map((page: any) =>
page.map((item: any) => (item.id === data.id ? data : item))
),
pageParams: oldData.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useBulkDeleteLinks = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (linkIds: number[]) => {
const response = await fetch("/api/v1/links", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ linkIds }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return linkIds;
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return oldData.filter((e: any) => !data.includes(e.id));
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: oldData.pages.map((page: any) =>
page.filter((item: any) => !data.includes(item.id))
),
pageParams: oldData.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useUploadFile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ link, file }: any) => {
let fileType: ArchivedFormat | null = null;
let linkType: "url" | "image" | "pdf" | null = null;
if (file?.type === "image/jpg" || file.type === "image/jpeg") {
fileType = ArchivedFormat.jpeg;
linkType = "image";
} else if (file.type === "image/png") {
fileType = ArchivedFormat.png;
linkType = "image";
} else if (file.type === "application/pdf") {
fileType = ArchivedFormat.pdf;
linkType = "pdf";
} else {
return { ok: false, data: "Invalid file type." };
}
const response = await fetch("/api/v1/links", {
body: JSON.stringify({
...link,
type: linkType,
name: link.name ? link.name : file.name,
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
if (response.ok) {
const formBody = new FormData();
file && formBody.append("file", file);
await fetch(
`/api/v1/archives/${(data as any).response.id}?format=${fileType}`,
{
body: formBody,
method: "POST",
}
);
}
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return [data, ...oldData];
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: [[data, ...oldData?.pages[0]], ...oldData?.pages.slice(1)],
pageParams: oldData?.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useBulkEditLinks = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
links,
newData,
removePreviousTags,
}: {
links: LinkIncludingShortenedCollectionAndTags[];
newData: Pick<
LinkIncludingShortenedCollectionAndTags,
"tags" | "collectionId"
>;
removePreviousTags: boolean;
}) => {
const response = await fetch("/api/v1/links", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ links, newData, removePreviousTags }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data, { links, newData, removePreviousTags }) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return oldData.map((e: any) =>
data.find((d: any) => d.id === e.id) ? data : e
);
});
// TODO: Fix this
// queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
// if (!oldData) return undefined;
// return {
// pages: oldData.pages.map((page: any) => for (item of links) {
// page.map((item: any) => (item.id === data.id ? data : item))
// }
// ),
// pageParams: oldData.pageParams,
// };
// });
queryClient.invalidateQueries({ queryKey: ["links"] }); // Temporary workaround
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
export {
useLinks,
useAddLink,
useUpdateLink,
useDeleteLink,
useBulkDeleteLinks,
useUploadFile,
useGetLink,
useBulkEditLinks,
};

View File

@ -0,0 +1,93 @@
import {
InfiniteData,
useInfiniteQuery,
UseInfiniteQueryResult,
} from "@tanstack/react-query";
import { useMemo } from "react";
import {
LinkIncludingShortenedCollectionAndTags,
LinkRequestQuery,
} from "@/types/global";
import { useRouter } from "next/router";
const usePublicLinks = (params: LinkRequestQuery = {}) => {
const router = useRouter();
const queryParamsObject = {
sort: params.sort ?? Number(window.localStorage.getItem("sortBy")) ?? 0,
collectionId: params.collectionId ?? router.query.id,
tagId:
params.tagId ?? router.pathname === "/tags/[id]"
? router.query.id
: undefined,
pinnedOnly:
params.pinnedOnly ?? router.pathname === "/links/pinned"
? true
: undefined,
searchQueryString: params.searchQueryString,
searchByName: params.searchByName,
searchByUrl: params.searchByUrl,
searchByDescription: params.searchByDescription,
searchByTextContent: params.searchByTextContent,
searchByTags: params.searchByTags,
} as LinkRequestQuery;
const queryString = buildQueryString(queryParamsObject);
const { data, ...rest } = useFetchLinks(queryString);
const links = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return [...acc, ...page];
}, []);
}, [data]);
return {
links,
data: { ...data, ...rest },
} as {
links: LinkIncludingShortenedCollectionAndTags[];
data: UseInfiniteQueryResult<InfiniteData<any, unknown>, Error>;
};
};
const useFetchLinks = (params: string) => {
return useInfiniteQuery({
queryKey: ["links", { params }],
queryFn: async (params) => {
const response = await fetch(
"/api/v1/public/collections/links?cursor=" +
params.pageParam +
((params.queryKey[1] as any).params
? "&" + (params.queryKey[1] as any).params
: "")
);
const data = await response.json();
return data.response;
},
initialPageParam: 0,
refetchOnWindowFocus: false,
getNextPageParam: (lastPage) => {
if (lastPage.length === 0) {
return undefined;
}
return lastPage.at(-1).id;
},
});
};
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("&");
};
export { usePublicLinks };

71
hooks/store/tags.tsx Normal file
View File

@ -0,0 +1,71 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { TagIncludingLinkCount } from "@/types/global";
import { useSession } from "next-auth/react";
const useTags = () => {
const { status } = useSession();
return useQuery({
queryKey: ["tags"],
queryFn: async () => {
const response = await fetch("/api/v1/tags");
if (!response.ok) throw new Error("Failed to fetch tags.");
const data = await response.json();
return data.response;
},
enabled: status === "authenticated",
});
};
const useUpdateTag = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (tag: TagIncludingLinkCount) => {
const response = await fetch(`/api/v1/tags/${tag.id}`, {
body: JSON.stringify(tag),
headers: {
"Content-Type": "application/json",
},
method: "PUT",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["tags"], (oldData: any) =>
oldData.map((tag: TagIncludingLinkCount) =>
tag.id === data.id ? data : tag
)
);
},
});
};
const useRemoveTag = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (tagId: number) => {
const response = await fetch(`/api/v1/tags/${tagId}`, {
method: "DELETE",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data, variables) => {
queryClient.setQueryData(["tags"], (oldData: any) =>
oldData.filter((tag: TagIncludingLinkCount) => tag.id !== variables)
);
},
});
};
export { useTags, useUpdateTag, useRemoveTag };

68
hooks/store/tokens.tsx Normal file
View File

@ -0,0 +1,68 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AccessToken } from "@prisma/client";
import { useSession } from "next-auth/react";
const useTokens = () => {
const { status } = useSession();
return useQuery({
queryKey: ["tokens"],
queryFn: async () => {
const response = await fetch("/api/v1/tokens");
if (!response.ok) throw new Error("Failed to fetch tokens.");
const data = await response.json();
return data.response as AccessToken[];
},
enabled: status === "authenticated",
});
};
const useAddToken = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body: Partial<AccessToken>) => {
const response = await fetch("/api/v1/tokens", {
body: JSON.stringify(body),
method: "POST",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["tokens"], (oldData: AccessToken[]) => [
...oldData,
data.token,
]);
},
});
};
const useRevokeToken = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (tokenId: number) => {
const response = await fetch(`/api/v1/tokens/${tokenId}`, {
method: "DELETE",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data, variables) => {
queryClient.setQueryData(["tokens"], (oldData: AccessToken[]) =>
oldData.filter((token: Partial<AccessToken>) => token.id !== variables)
);
},
});
};
export { useTokens, useAddToken, useRevokeToken };

53
hooks/store/user.tsx Normal file
View File

@ -0,0 +1,53 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
const useUser = () => {
const { data, status } = useSession();
const userId = data?.user.id;
return useQuery({
queryKey: ["user"],
queryFn: async () => {
const response = await fetch(`/api/v1/users/${userId}`);
if (!response.ok) throw new Error("Failed to fetch user data.");
const data = await response.json();
return data.response;
},
enabled: !!userId && status === "authenticated",
placeholderData: {},
});
};
const useUpdateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (user: any) => {
const response = await fetch(`/api/v1/users/${user.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(user),
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data;
},
onSuccess: (data) => {
queryClient.setQueryData(["user"], data.response);
},
onMutate: async (user) => {
await queryClient.cancelQueries({ queryKey: ["user"] });
queryClient.setQueryData(["user"], (oldData: any) => {
return { ...oldData, ...user };
});
},
});
};
export { useUser, useUpdateUser };

View File

@ -1,12 +1,12 @@
import useAccountStore from "@/store/account";
import useCollectionStore from "@/store/collections";
import { Member } from "@/types/global"; import { Member } from "@/types/global";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useCollections } from "./store/collections";
import { useUser } from "./store/user";
export default function useCollectivePermissions(collectionIds: number[]) { export default function useCollectivePermissions(collectionIds: number[]) {
const { collections } = useCollectionStore(); const { data: collections = [] } = useCollections();
const { account } = useAccountStore(); const { data: user = {} } = useUser();
const [permissions, setPermissions] = useState<Member | true>(); const [permissions, setPermissions] = useState<Member | true>();
useEffect(() => { useEffect(() => {
@ -15,7 +15,7 @@ export default function useCollectivePermissions(collectionIds: number[]) {
if (collection) { if (collection) {
let getPermission: Member | undefined = collection.members.find( let getPermission: Member | undefined = collection.members.find(
(e) => e.userId === account.id (e) => e.userId === user.id
); );
if ( if (
@ -25,10 +25,10 @@ export default function useCollectivePermissions(collectionIds: number[]) {
) )
getPermission = undefined; getPermission = undefined;
setPermissions(account.id === collection.ownerId || getPermission); setPermissions(user.id === collection.ownerId || getPermission);
} }
} }
}, [account, collections, collectionIds]); }, [user, collections, collectionIds]);
return permissions; return permissions;
} }

View File

@ -1,34 +1,14 @@
import useCollectionStore from "@/store/collections";
import { useEffect } from "react"; import { useEffect } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import useTagStore from "@/store/tags";
import useAccountStore from "@/store/account";
import useLocalSettingsStore from "@/store/localSettings"; import useLocalSettingsStore from "@/store/localSettings";
export default function useInitialData() { export default function useInitialData() {
const { status, data } = useSession(); const { status, data } = useSession();
const { setCollections } = useCollectionStore();
const { setTags } = useTagStore();
// const { setLinks } = useLinkStore();
const { account, setAccount } = useAccountStore();
const { setSettings } = useLocalSettingsStore(); const { setSettings } = useLocalSettingsStore();
useEffect(() => { useEffect(() => {
setSettings(); setSettings();
if (status === "authenticated") {
// Get account info
setAccount(data?.user.id as number);
}
}, [status, data]); }, [status, data]);
// Get the rest of the data
useEffect(() => {
if (account.id && (!process.env.NEXT_PUBLIC_STRIPE || account.username)) {
setCollections();
setTags();
// setLinks();
}
}, [account]);
return status; return status;
} }

View File

@ -1,103 +0,0 @@
import { LinkRequestQuery } from "@/types/global";
import { useEffect, useState } from "react";
import useDetectPageBottom from "./useDetectPageBottom";
import { useRouter } from "next/router";
import useLinkStore from "@/store/links";
export default function useLinks(
{
sort,
collectionId,
tagId,
pinnedOnly,
searchQueryString,
searchByName,
searchByUrl,
searchByDescription,
searchByTags,
searchByTextContent,
}: LinkRequestQuery = { sort: 0 }
) {
const { links, setLinks, resetLinks, selectedLinks, setSelectedLinks } =
useLinkStore();
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const { reachedBottom, setReachedBottom } = useDetectPageBottom();
const getLinks = async (isInitialCall: boolean, cursor?: number) => {
const params = {
sort,
cursor,
collectionId,
tagId,
pinnedOnly,
searchQueryString,
searchByName,
searchByUrl,
searchByDescription,
searchByTags,
searchByTextContent,
};
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("&");
};
let queryString = buildQueryString(params);
let basePath;
if (router.pathname === "/dashboard") basePath = "/api/v1/dashboard";
else if (router.pathname.startsWith("/public/collections/[id]")) {
queryString = queryString + "&collectionId=" + router.query.id;
basePath = "/api/v1/public/collections/links";
} else basePath = "/api/v1/links";
setIsLoading(true);
const response = await fetch(`${basePath}?${queryString}`);
const data = await response.json();
setIsLoading(false);
if (response.ok) setLinks(data.response, isInitialCall);
};
useEffect(() => {
// Save the selected links before resetting the links
// and then restore the selected links after resetting the links
const previouslySelected = selectedLinks;
resetLinks();
setSelectedLinks(previouslySelected);
getLinks(true);
}, [
router,
sort,
searchQueryString,
searchByName,
searchByUrl,
searchByDescription,
searchByTextContent,
searchByTags,
]);
useEffect(() => {
if (reachedBottom) getLinks(false, links?.at(-1)?.id);
setReachedBottom(false);
}, [reachedBottom]);
return { isLoading };
}

View File

@ -1,12 +1,12 @@
import useAccountStore from "@/store/account";
import useCollectionStore from "@/store/collections";
import { Member } from "@/types/global"; import { Member } from "@/types/global";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useCollections } from "./store/collections";
import { useUser } from "./store/user";
export default function usePermissions(collectionId: number) { export default function usePermissions(collectionId: number) {
const { collections } = useCollectionStore(); const { data: collections = [] } = useCollections();
const { account } = useAccountStore(); const { data: user = {} } = useUser();
const [permissions, setPermissions] = useState<Member | true>(); const [permissions, setPermissions] = useState<Member | true>();
useEffect(() => { useEffect(() => {
@ -14,7 +14,7 @@ export default function usePermissions(collectionId: number) {
if (collection) { if (collection) {
let getPermission: Member | undefined = collection.members.find( let getPermission: Member | undefined = collection.members.find(
(e) => e.userId === account.id (e) => e.userId === user.id
); );
if ( if (
@ -24,9 +24,9 @@ export default function usePermissions(collectionId: number) {
) )
getPermission = undefined; getPermission = undefined;
setPermissions(account.id === collection.ownerId || getPermission); setPermissions(user.id === collection.ownerId || getPermission);
} }
}, [account, collections, collectionId]); }, [user, collections, collectionId]);
return permissions; return permissions;
} }

View File

@ -2,7 +2,7 @@ import { ReactNode, useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import useInitialData from "@/hooks/useInitialData"; import useInitialData from "@/hooks/useInitialData";
import useAccountStore from "@/store/account"; import { useUser } from "@/hooks/store/user";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@ -14,7 +14,7 @@ export default function AuthRedirect({ children }: Props) {
const router = useRouter(); const router = useRouter();
const { status } = useSession(); const { status } = useSession();
const [shouldRenderChildren, setShouldRenderChildren] = useState(false); const [shouldRenderChildren, setShouldRenderChildren] = useState(false);
const { account } = useAccountStore(); const { data: user = {} } = useUser();
useInitialData(); useInitialData();
@ -23,7 +23,7 @@ export default function AuthRedirect({ children }: Props) {
const isUnauthenticated = status === "unauthenticated"; const isUnauthenticated = status === "unauthenticated";
const isPublicPage = router.pathname.startsWith("/public"); const isPublicPage = router.pathname.startsWith("/public");
const hasInactiveSubscription = const hasInactiveSubscription =
account.id && !account.subscription?.active && stripeEnabled; user.id && !user.subscription?.active && stripeEnabled;
// There are better ways of doing this... but this one works for now // There are better ways of doing this... but this one works for now
const routes = [ const routes = [
@ -63,7 +63,7 @@ export default function AuthRedirect({ children }: Props) {
setShouldRenderChildren(true); setShouldRenderChildren(true);
} }
} }
}, [status, account, router.pathname]); }, [status, user, router.pathname]);
function redirectTo(destination: string) { function redirectTo(destination: string) {
router.push(destination).then(() => setShouldRenderChildren(true)); router.push(destination).then(() => setShouldRenderChildren(true));

View File

@ -5,7 +5,7 @@ export default async function getDashboardData(
userId: number, userId: number,
query: LinkRequestQuery query: LinkRequestQuery
) { ) {
let order: any; let order: any = { id: "desc" };
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" }; if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" }; else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" }; else if (query.sort === Sort.NameAZ) order = { name: "asc" };
@ -42,7 +42,7 @@ export default async function getDashboardData(
select: { id: true }, select: { id: true },
}, },
}, },
orderBy: order || { id: "desc" }, orderBy: order,
}); });
const recentlyAddedLinks = await prisma.link.findMany({ const recentlyAddedLinks = await prisma.link.findMany({
@ -67,10 +67,18 @@ export default async function getDashboardData(
select: { id: true }, select: { id: true },
}, },
}, },
orderBy: order || { id: "desc" }, orderBy: order,
}); });
const links = [...recentlyAddedLinks, ...pinnedLinks].sort( const combinedLinks = [...recentlyAddedLinks, ...pinnedLinks];
const uniqueLinks = Array.from(
combinedLinks
.reduce((map, item) => map.set(item.id, item), new Map())
.values()
);
const links = uniqueLinks.sort(
(a, b) => (new Date(b.id) as any) - (new Date(a.id) as any) (a, b) => (new Date(b.id) as any) - (new Date(a.id) as any)
); );

View File

@ -5,7 +5,7 @@ export default async function getLink(userId: number, query: LinkRequestQuery) {
const POSTGRES_IS_ENABLED = const POSTGRES_IS_ENABLED =
process.env.DATABASE_URL?.startsWith("postgresql"); process.env.DATABASE_URL?.startsWith("postgresql");
let order: any; let order: any = { id: "desc" };
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" }; if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" }; else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" }; else if (query.sort === Sort.NameAZ) order = { name: "asc" };
@ -146,7 +146,7 @@ export default async function getLink(userId: number, query: LinkRequestQuery) {
select: { id: true }, select: { id: true },
}, },
}, },
orderBy: order || { id: "desc" }, orderBy: order,
}); });
return { response: links, status: 200 }; return { response: links, status: 200 };

View File

@ -27,6 +27,8 @@
"@mozilla/readability": "^0.4.4", "@mozilla/readability": "^0.4.4",
"@prisma/client": "^4.16.2", "@prisma/client": "^4.16.2",
"@stripe/stripe-js": "^1.54.1", "@stripe/stripe-js": "^1.54.1",
"@tanstack/react-query": "^5.51.15",
"@tanstack/react-query-devtools": "^5.51.15",
"@types/crypto-js": "^4.1.1", "@types/crypto-js": "^4.1.1",
"@types/formidable": "^3.4.5", "@types/formidable": "^3.4.5",
"@types/node": "^20.10.4", "@types/node": "^20.10.4",
@ -67,9 +69,10 @@
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"react-i18next": "^14.1.2", "react-i18next": "^14.1.2",
"react-image-file-resizer": "^0.4.8", "react-image-file-resizer": "^0.4.8",
"react-intersection-observer": "^9.13.0",
"react-masonry-css": "^1.0.16", "react-masonry-css": "^1.0.16",
"react-select": "^5.7.4", "react-select": "^5.7.4",
"react-spinners": "^0.13.8", "react-spinners": "^0.14.1",
"socks-proxy-agent": "^8.0.2", "socks-proxy-agent": "^8.0.2",
"stripe": "^12.13.0", "stripe": "^12.13.0",
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.3.0",

View File

@ -11,7 +11,16 @@ import { Session } from "next-auth";
import { isPWA } from "@/lib/client/utils"; import { isPWA } from "@/lib/client/utils";
// import useInitialData from "@/hooks/useInitialData"; // import useInitialData from "@/hooks/useInitialData";
import { appWithTranslation } from "next-i18next"; import { appWithTranslation } from "next-i18next";
import nextI18nextConfig from "../next-i18next.config"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 30,
},
},
});
function App({ function App({
Component, Component,
@ -29,82 +38,76 @@ function App({
}, []); }, []);
return ( return (
<SessionProvider <QueryClientProvider client={queryClient}>
session={pageProps.session} <SessionProvider
refetchOnWindowFocus={false} session={pageProps.session}
basePath="/api/v1/auth" refetchOnWindowFocus={false}
> basePath="/api/v1/auth"
<Head> >
<title>Linkwarden</title> <Head>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Linkwarden</title>
<meta name="theme-color" content="#000000" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link <meta name="theme-color" content="#000000" />
rel="apple-touch-icon" <link
sizes="180x180" rel="apple-touch-icon"
href="/apple-touch-icon.png" sizes="180x180"
/> href="/apple-touch-icon.png"
<link />
rel="icon" <link
type="image/png" rel="icon"
sizes="32x32" type="image/png"
href="/favicon-32x32.png" sizes="32x32"
/> href="/favicon-32x32.png"
<link />
rel="icon" <link
type="image/png" rel="icon"
sizes="16x16" type="image/png"
href="/favicon-16x16.png" sizes="16x16"
/> href="/favicon-16x16.png"
<link rel="manifest" href="/site.webmanifest" /> />
</Head> <link rel="manifest" href="/site.webmanifest" />
<AuthRedirect> </Head>
{/* <GetData> */} <AuthRedirect>
<Toaster {/* <GetData> */}
position="top-center" <Toaster
reverseOrder={false} position="top-center"
toastOptions={{ reverseOrder={false}
className: toastOptions={{
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white", className:
}} "border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white",
> }}
{(t) => ( >
<ToastBar toast={t}> {(t) => (
{({ icon, message }) => ( <ToastBar toast={t}>
<div {({ icon, message }) => (
className="flex flex-row" <div
data-testid="toast-message-container" className="flex flex-row"
data-type={t.type} data-testid="toast-message-container"
> data-type={t.type}
{icon} >
<span data-testid="toast-message">{message}</span> {icon}
{t.type !== "loading" && ( <span data-testid="toast-message">{message}</span>
<button {t.type !== "loading" && (
className="btn btn-xs outline-none btn-circle btn-ghost" <button
data-testid="close-toast-button" className="btn btn-xs outline-none btn-circle btn-ghost"
onClick={() => toast.dismiss(t.id)} data-testid="close-toast-button"
> onClick={() => toast.dismiss(t.id)}
<i className="bi bi-x"></i> >
</button> <i className="bi bi-x"></i>
)} </button>
</div> )}
)} </div>
</ToastBar> )}
)} </ToastBar>
</Toaster> )}
<Component {...pageProps} /> </Toaster>
{/* </GetData> */} <Component {...pageProps} />
</AuthRedirect> {/* </GetData> */}
</SessionProvider> </AuthRedirect>
</SessionProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
); );
} }
export default appWithTranslation(App); export default appWithTranslation(App);
// function GetData({ children }: { children: React.ReactNode }) {
// const status = useInitialData();
// return typeof window !== "undefined" && status !== "loading" ? (
// children
// ) : (
// <></>
// );
// }

View File

@ -1,11 +1,11 @@
import NewUserModal from "@/components/ModalContent/NewUserModal"; import NewUserModal from "@/components/ModalContent/NewUserModal";
import useUserStore from "@/store/admin/users";
import { User as U } from "@prisma/client"; import { User as U } from "@prisma/client";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useState } from "react";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import getServerSideProps from "@/lib/client/getServerSideProps"; import getServerSideProps from "@/lib/client/getServerSideProps";
import UserListing from "@/components/UserListing"; import UserListing from "@/components/UserListing";
import { useUsers } from "@/hooks/store/admin/users";
interface User extends U { interface User extends U {
subscriptions: { subscriptions: {
@ -21,7 +21,7 @@ type UserModal = {
export default function Admin() { export default function Admin() {
const { t } = useTranslation(); const { t } = useTranslation();
const { users, setUsers } = useUserStore(); const { data: users = [] } = useUsers();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [filteredUsers, setFilteredUsers] = useState<User[]>(); const [filteredUsers, setFilteredUsers] = useState<User[]>();
@ -33,10 +33,6 @@ export default function Admin() {
const [newUserModal, setNewUserModal] = useState(false); const [newUserModal, setNewUserModal] = useState(false);
useEffect(() => {
setUsers();
}, []);
return ( return (
<div className="max-w-6xl mx-auto p-5"> <div className="max-w-6xl mx-auto p-5">
<div className="flex sm:flex-row flex-col justify-between gap-2"> <div className="flex sm:flex-row flex-col justify-between gap-2">
@ -71,7 +67,7 @@ export default function Admin() {
if (users) { if (users) {
setFilteredUsers( setFilteredUsers(
users.filter((user) => users.filter((user: any) =>
JSON.stringify(user) JSON.stringify(user)
.toLowerCase() .toLowerCase()
.includes(e.target.value.toLowerCase()) .includes(e.target.value.toLowerCase())

View File

@ -1,5 +1,3 @@
import useCollectionStore from "@/store/collections";
import useLinkStore from "@/store/links";
import { import {
CollectionIncludingMembersAndLinkCount, CollectionIncludingMembersAndLinkCount,
Sort, Sort,
@ -9,23 +7,22 @@ import { useRouter } from "next/router";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import ProfilePhoto from "@/components/ProfilePhoto"; import ProfilePhoto from "@/components/ProfilePhoto";
import useLinks from "@/hooks/useLinks";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import NoLinksFound from "@/components/NoLinksFound"; import NoLinksFound from "@/components/NoLinksFound";
import useLocalSettingsStore from "@/store/localSettings"; import useLocalSettingsStore from "@/store/localSettings";
import useAccountStore from "@/store/account";
import getPublicUserData from "@/lib/client/getPublicUserData"; import getPublicUserData from "@/lib/client/getPublicUserData";
import EditCollectionModal from "@/components/ModalContent/EditCollectionModal"; import EditCollectionModal from "@/components/ModalContent/EditCollectionModal";
import EditCollectionSharingModal from "@/components/ModalContent/EditCollectionSharingModal"; import EditCollectionSharingModal from "@/components/ModalContent/EditCollectionSharingModal";
import DeleteCollectionModal from "@/components/ModalContent/DeleteCollectionModal"; import DeleteCollectionModal from "@/components/ModalContent/DeleteCollectionModal";
import CardView from "@/components/LinkViews/Layouts/CardView";
import ListView from "@/components/LinkViews/Layouts/ListView";
import { dropdownTriggerer } from "@/lib/client/utils"; import { dropdownTriggerer } from "@/lib/client/utils";
import NewCollectionModal from "@/components/ModalContent/NewCollectionModal"; import NewCollectionModal from "@/components/ModalContent/NewCollectionModal";
import MasonryView from "@/components/LinkViews/Layouts/MasonryView";
import getServerSideProps from "@/lib/client/getServerSideProps"; import getServerSideProps from "@/lib/client/getServerSideProps";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import LinkListOptions from "@/components/LinkListOptions"; import LinkListOptions from "@/components/LinkListOptions";
import { useCollections } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user";
import { useLinks } from "@/hooks/store/links";
import Links from "@/components/LinkViews/Links";
export default function Index() { export default function Index() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -33,25 +30,29 @@ export default function Index() {
const router = useRouter(); const router = useRouter();
const { links } = useLinkStore(); const { data: collections = [] } = useCollections();
const { collections } = useCollectionStore();
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(
Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst
);
const { links, data } = useLinks({
sort: sortBy,
collectionId: Number(router.query.id),
});
const [activeCollection, setActiveCollection] = const [activeCollection, setActiveCollection] =
useState<CollectionIncludingMembersAndLinkCount>(); useState<CollectionIncludingMembersAndLinkCount>();
const permissions = usePermissions(activeCollection?.id as number); const permissions = usePermissions(activeCollection?.id as number);
useLinks({ collectionId: Number(router.query.id), sort: sortBy });
useEffect(() => { useEffect(() => {
setActiveCollection( setActiveCollection(
collections.find((e) => e.id === Number(router.query.id)) collections.find((e) => e.id === Number(router.query.id))
); );
}, [router, collections]); }, [router, collections]);
const { account } = useAccountStore(); const { data: user = {} } = useUser();
const [collectionOwner, setCollectionOwner] = useState({ const [collectionOwner, setCollectionOwner] = useState({
id: null as unknown as number, id: null as unknown as number,
@ -65,20 +66,20 @@ export default function Index() {
useEffect(() => { useEffect(() => {
const fetchOwner = async () => { const fetchOwner = async () => {
if (activeCollection && activeCollection.ownerId !== account.id) { if (activeCollection && activeCollection.ownerId !== user.id) {
const owner = await getPublicUserData( const owner = await getPublicUserData(
activeCollection.ownerId as number activeCollection.ownerId as number
); );
setCollectionOwner(owner); setCollectionOwner(owner);
} else if (activeCollection && activeCollection.ownerId === account.id) { } else if (activeCollection && activeCollection.ownerId === user.id) {
setCollectionOwner({ setCollectionOwner({
id: account.id as number, id: user.id as number,
name: account.name, name: user.name,
username: account.username as string, username: user.username as string,
image: account.image as string, image: user.image as string,
archiveAsScreenshot: account.archiveAsScreenshot as boolean, archiveAsScreenshot: user.archiveAsScreenshot as boolean,
archiveAsMonolith: account.archiveAsScreenshot as boolean, archiveAsMonolith: user.archiveAsScreenshot as boolean,
archiveAsPDF: account.archiveAsPDF as boolean, archiveAsPDF: user.archiveAsPDF as boolean,
}); });
} }
}; };
@ -97,19 +98,10 @@ export default function Index() {
if (editMode) return setEditMode(false); if (editMode) return setEditMode(false);
}, [router]); }, [router]);
const [viewMode, setViewMode] = useState<string>( const [viewMode, setViewMode] = useState<ViewMode>(
localStorage.getItem("viewMode") || ViewMode.Card (localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card
); );
const linkView = {
[ViewMode.Card]: CardView,
[ViewMode.List]: ListView,
[ViewMode.Masonry]: MasonryView,
};
// @ts-ignore
const LinkComponent = linkView[viewMode];
return ( return (
<MainLayout> <MainLayout>
<div <div
@ -323,16 +315,14 @@ export default function Index() {
</p> </p>
</LinkListOptions> </LinkListOptions>
{links.some((e) => e.collectionId === Number(router.query.id)) ? ( <Links
<LinkComponent editMode={editMode}
editMode={editMode} links={links}
links={links.filter( layout={viewMode}
(e) => e.collection.id === activeCollection?.id placeholderCount={1}
)} useData={data}
/> />
) : ( {!data.isLoading && links && !links[0] && <NoLinksFound />}
<NoLinksFound />
)}
</div> </div>
{activeCollection && ( {activeCollection && (
<> <>

View File

@ -1,4 +1,3 @@
import useCollectionStore from "@/store/collections";
import CollectionCard from "@/components/CollectionCard"; import CollectionCard from "@/components/CollectionCard";
import { useState } from "react"; import { useState } from "react";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
@ -10,11 +9,14 @@ import NewCollectionModal from "@/components/ModalContent/NewCollectionModal";
import PageHeader from "@/components/PageHeader"; import PageHeader from "@/components/PageHeader";
import getServerSideProps from "@/lib/client/getServerSideProps"; import getServerSideProps from "@/lib/client/getServerSideProps";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
export default function Collections() { export default function Collections() {
const { t } = useTranslation(); const { t } = useTranslation();
const { collections } = useCollectionStore(); const { data: collections = [] } = useCollections();
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(
Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst
);
const [sortedCollections, setSortedCollections] = useState(collections); const [sortedCollections, setSortedCollections] = useState(collections);
const { data } = useSession(); const { data } = useSession();

View File

@ -1,9 +1,5 @@
import useLinkStore from "@/store/links";
import useCollectionStore from "@/store/collections";
import useTagStore from "@/store/tags";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import useLinks from "@/hooks/useLinks";
import Link from "next/link"; import Link from "next/link";
import useWindowDimensions from "@/hooks/useWindowDimensions"; import useWindowDimensions from "@/hooks/useWindowDimensions";
import React from "react"; import React from "react";
@ -12,26 +8,25 @@ import { MigrationFormat, MigrationRequest, ViewMode } from "@/types/global";
import DashboardItem from "@/components/DashboardItem"; import DashboardItem from "@/components/DashboardItem";
import NewLinkModal from "@/components/ModalContent/NewLinkModal"; import NewLinkModal from "@/components/ModalContent/NewLinkModal";
import PageHeader from "@/components/PageHeader"; import PageHeader from "@/components/PageHeader";
import CardView from "@/components/LinkViews/Layouts/CardView";
import ListView from "@/components/LinkViews/Layouts/ListView";
import ViewDropdown from "@/components/ViewDropdown"; import ViewDropdown from "@/components/ViewDropdown";
import { dropdownTriggerer } from "@/lib/client/utils"; import { dropdownTriggerer } from "@/lib/client/utils";
import MasonryView from "@/components/LinkViews/Layouts/MasonryView";
import getServerSideProps from "@/lib/client/getServerSideProps"; import getServerSideProps from "@/lib/client/getServerSideProps";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useTags } from "@/hooks/store/tags";
import { useDashboardData } from "@/hooks/store/dashboardData";
import Links from "@/components/LinkViews/Links";
export default function Dashboard() { export default function Dashboard() {
const { t } = useTranslation(); const { t } = useTranslation();
const { collections } = useCollectionStore(); const { data: collections = [] } = useCollections();
const { links } = useLinkStore(); const dashboardData = useDashboardData();
const { tags } = useTagStore(); const { data: tags = [] } = useTags();
const [numberOfLinks, setNumberOfLinks] = useState(0); const [numberOfLinks, setNumberOfLinks] = useState(0);
const [showLinks, setShowLinks] = useState(3); const [showLinks, setShowLinks] = useState(3);
useLinks({ pinnedOnly: true, sort: 0 });
useEffect(() => { useEffect(() => {
setNumberOfLinks( setNumberOfLinks(
collections.reduce( collections.reduce(
@ -81,7 +76,7 @@ export default function Dashboard() {
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
const data = await response.json(); await response.json();
toast.dismiss(load); toast.dismiss(load);
@ -99,20 +94,10 @@ export default function Dashboard() {
const [newLinkModal, setNewLinkModal] = useState(false); const [newLinkModal, setNewLinkModal] = useState(false);
const [viewMode, setViewMode] = useState<string>( const [viewMode, setViewMode] = useState<ViewMode>(
localStorage.getItem("viewMode") || ViewMode.Card (localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card
); );
const linkView = {
[ViewMode.Card]: CardView,
// [ViewMode.Grid]: ,
[ViewMode.List]: ListView,
[ViewMode.Masonry]: MasonryView,
};
// @ts-ignore
const LinkComponent = linkView[viewMode];
return ( return (
<MainLayout> <MainLayout>
<div style={{ flex: "1 1 auto" }} className="p-5 flex flex-col gap-5"> <div style={{ flex: "1 1 auto" }} className="p-5 flex flex-col gap-5">
@ -171,12 +156,30 @@ export default function Dashboard() {
</div> </div>
<div <div
style={{ flex: links[0] ? "0 1 auto" : "1 1 auto" }} style={{
flex:
dashboardData.data || dashboardData.isLoading
? "0 1 auto"
: "1 1 auto",
}}
className="flex flex-col 2xl:flex-row items-start 2xl:gap-2" className="flex flex-col 2xl:flex-row items-start 2xl:gap-2"
> >
{links[0] ? ( {dashboardData.isLoading ? (
<div className="w-full"> <div className="w-full">
<LinkComponent links={links.slice(0, showLinks)} /> <Links
layout={viewMode}
placeholderCount={showLinks / 2}
useData={dashboardData}
/>
</div>
) : dashboardData.data &&
dashboardData.data[0] &&
!dashboardData.isLoading ? (
<div className="w-full">
<Links
links={dashboardData.data.slice(0, showLinks)}
layout={viewMode}
/>
</div> </div>
) : ( ) : (
<div className="sky-shadow flex flex-col justify-center h-full border border-solid border-neutral-content w-full mx-auto p-10 rounded-2xl bg-base-200"> <div className="sky-shadow flex flex-col justify-center h-full border border-solid border-neutral-content w-full mx-auto p-10 rounded-2xl bg-base-200">
@ -300,12 +303,21 @@ export default function Dashboard() {
style={{ flex: "1 1 auto" }} style={{ flex: "1 1 auto" }}
className="flex flex-col 2xl:flex-row items-start 2xl:gap-2" className="flex flex-col 2xl:flex-row items-start 2xl:gap-2"
> >
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? ( {dashboardData.isLoading ? (
<div className="w-full"> <div className="w-full">
<LinkComponent <Links
links={links layout={viewMode}
placeholderCount={showLinks / 2}
useData={dashboardData}
/>
</div>
) : dashboardData.data?.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
<div className="w-full">
<Links
links={dashboardData.data
.filter((e) => e.pinnedBy && e.pinnedBy[0]) .filter((e) => e.pinnedBy && e.pinnedBy[0])
.slice(0, showLinks)} .slice(0, showLinks)}
layout={viewMode}
/> />
</div> </div>
) : ( ) : (

View File

@ -1,26 +1,28 @@
import NoLinksFound from "@/components/NoLinksFound"; import NoLinksFound from "@/components/NoLinksFound";
import useLinks from "@/hooks/useLinks"; import { useLinks } from "@/hooks/store/links";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import useLinkStore from "@/store/links";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import PageHeader from "@/components/PageHeader"; import PageHeader from "@/components/PageHeader";
import { Sort, ViewMode } from "@/types/global"; import { Sort, ViewMode } from "@/types/global";
import CardView from "@/components/LinkViews/Layouts/CardView";
import ListView from "@/components/LinkViews/Layouts/ListView";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import MasonryView from "@/components/LinkViews/Layouts/MasonryView";
import LinkListOptions from "@/components/LinkListOptions"; import LinkListOptions from "@/components/LinkListOptions";
import getServerSideProps from "@/lib/client/getServerSideProps"; import getServerSideProps from "@/lib/client/getServerSideProps";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import Links from "@/components/LinkViews/Links";
export default function Links() { export default function Index() {
const { t } = useTranslation(); const { t } = useTranslation();
const { links } = useLinkStore();
const [viewMode, setViewMode] = useState<string>( const [viewMode, setViewMode] = useState<ViewMode>(
localStorage.getItem("viewMode") || ViewMode.Card (localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card
); );
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(
Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst
);
const { links, data } = useLinks({
sort: sortBy,
});
const router = useRouter(); const router = useRouter();
@ -30,17 +32,6 @@ export default function Links() {
if (editMode) return setEditMode(false); if (editMode) return setEditMode(false);
}, [router]); }, [router]);
useLinks({ sort: sortBy });
const linkView = {
[ViewMode.Card]: CardView,
[ViewMode.List]: ListView,
[ViewMode.Masonry]: MasonryView,
};
// @ts-ignore
const LinkComponent = linkView[viewMode];
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">
@ -60,9 +51,14 @@ export default function Links() {
/> />
</LinkListOptions> </LinkListOptions>
{links[0] ? ( <Links
<LinkComponent editMode={editMode} links={links} /> editMode={editMode}
) : ( links={links}
layout={viewMode}
placeholderCount={1}
useData={data}
/>
{!data.isLoading && links && !links[0] && (
<NoLinksFound text={t("you_have_not_added_any_links")} /> <NoLinksFound text={t("you_have_not_added_any_links")} />
)} )}
</div> </div>

View File

@ -1,45 +1,32 @@
import useLinks from "@/hooks/useLinks";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import useLinkStore from "@/store/links";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import PageHeader from "@/components/PageHeader"; import PageHeader from "@/components/PageHeader";
import { Sort, ViewMode } from "@/types/global"; import { Sort, ViewMode } from "@/types/global";
import CardView from "@/components/LinkViews/Layouts/CardView";
import ListView from "@/components/LinkViews/Layouts/ListView";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import MasonryView from "@/components/LinkViews/Layouts/MasonryView";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import getServerSideProps from "@/lib/client/getServerSideProps"; import getServerSideProps from "@/lib/client/getServerSideProps";
import LinkListOptions from "@/components/LinkListOptions"; import LinkListOptions from "@/components/LinkListOptions";
import { useLinks } from "@/hooks/store/links";
import Links from "@/components/LinkViews/Links";
export default function PinnedLinks() { export default function PinnedLinks() {
const { t } = useTranslation(); const { t } = useTranslation();
const { links } = useLinkStore(); const [viewMode, setViewMode] = useState<ViewMode>(
(localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card
const [viewMode, setViewMode] = useState<string>( );
localStorage.getItem("viewMode") || ViewMode.Card const [sortBy, setSortBy] = useState<Sort>(
Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst
); );
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
useLinks({ sort: sortBy, pinnedOnly: true }); const { links, data } = useLinks({
sort: sortBy,
pinnedOnly: true,
});
const router = useRouter(); const router = useRouter();
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
useEffect(() => {
if (editMode) return setEditMode(false);
}, [router]);
const linkView = {
[ViewMode.Card]: CardView,
[ViewMode.List]: ListView,
[ViewMode.Masonry]: MasonryView,
};
// @ts-ignore
const LinkComponent = linkView[viewMode];
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">
@ -59,9 +46,14 @@ export default function PinnedLinks() {
/> />
</LinkListOptions> </LinkListOptions>
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? ( <Links
<LinkComponent editMode={editMode} links={links} /> editMode={editMode}
) : ( links={links}
layout={viewMode}
placeholderCount={1}
useData={data}
/>
{!data.isLoading && links && !links[0] && (
<div <div
style={{ flex: "1 1 auto" }} style={{ flex: "1 1 auto" }}
className="flex flex-col gap-2 justify-center h-full w-full mx-auto p-10" className="flex flex-col gap-2 justify-center h-full w-full mx-auto p-10"

View File

@ -1,5 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { import {
ArchivedFormat, ArchivedFormat,
@ -7,9 +6,12 @@ import {
} from "@/types/global"; } from "@/types/global";
import ReadableView from "@/components/ReadableView"; import ReadableView from "@/components/ReadableView";
import getServerSideProps from "@/lib/client/getServerSideProps"; import getServerSideProps from "@/lib/client/getServerSideProps";
import { useGetLink, useLinks } from "@/hooks/store/links";
export default function Index() { export default function Index() {
const { links, getLink } = useLinkStore(); const { links } = useLinks();
const getLink = useGetLink();
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>(); const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
@ -18,7 +20,7 @@ export default function Index() {
useEffect(() => { useEffect(() => {
const fetchLink = async () => { const fetchLink = async () => {
if (router.query.id) { if (router.query.id) {
await getLink(Number(router.query.id)); await getLink.mutateAsync(Number(router.query.id));
} }
}; };
@ -26,7 +28,8 @@ export default function Index() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (links[0]) setLink(links.find((e) => e.id === Number(router.query.id))); if (links && links[0])
setLink(links.find((e) => e.id === Number(router.query.id)));
}, [links]); }, [links]);
return ( return (

View File

@ -8,8 +8,6 @@ import {
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import Head from "next/head"; import Head from "next/head";
import useLinks from "@/hooks/useLinks";
import useLinkStore from "@/store/links";
import ProfilePhoto from "@/components/ProfilePhoto"; import ProfilePhoto from "@/components/ProfilePhoto";
import ToggleDarkMode from "@/components/ToggleDarkMode"; import ToggleDarkMode from "@/components/ToggleDarkMode";
import getPublicUserData from "@/lib/client/getPublicUserData"; import getPublicUserData from "@/lib/client/getPublicUserData";
@ -18,21 +16,19 @@ import Link from "next/link";
import useLocalSettingsStore from "@/store/localSettings"; import useLocalSettingsStore from "@/store/localSettings";
import SearchBar from "@/components/SearchBar"; import SearchBar from "@/components/SearchBar";
import EditCollectionSharingModal from "@/components/ModalContent/EditCollectionSharingModal"; import EditCollectionSharingModal from "@/components/ModalContent/EditCollectionSharingModal";
import CardView from "@/components/LinkViews/Layouts/CardView";
import ListView from "@/components/LinkViews/Layouts/ListView";
import MasonryView from "@/components/LinkViews/Layouts/MasonryView";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import getServerSideProps from "@/lib/client/getServerSideProps"; import getServerSideProps from "@/lib/client/getServerSideProps";
import useCollectionStore from "@/store/collections";
import LinkListOptions from "@/components/LinkListOptions"; import LinkListOptions from "@/components/LinkListOptions";
import { useCollections } from "@/hooks/store/collections";
import { usePublicLinks } from "@/hooks/store/publicLinks";
import Links from "@/components/LinkViews/Links";
export default function PublicCollections() { export default function PublicCollections() {
const { t } = useTranslation(); const { t } = useTranslation();
const { links } = useLinkStore();
const { settings } = useLocalSettingsStore(); const { settings } = useLocalSettingsStore();
const { collections } = useCollectionStore(); const { data: collections = [] } = useCollections();
const router = useRouter(); const router = useRouter();
@ -54,9 +50,11 @@ export default function PublicCollections() {
textContent: false, textContent: false,
}); });
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(
Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst
);
useLinks({ const { links, data } = usePublicLinks({
sort: sortBy, sort: sortBy,
searchQueryString: router.query.q searchQueryString: router.query.q
? decodeURIComponent(router.query.q as string) ? decodeURIComponent(router.query.q as string)
@ -91,19 +89,10 @@ export default function PublicCollections() {
const [editCollectionSharingModal, setEditCollectionSharingModal] = const [editCollectionSharingModal, setEditCollectionSharingModal] =
useState(false); useState(false);
const [viewMode, setViewMode] = useState<string>( const [viewMode, setViewMode] = useState<ViewMode>(
localStorage.getItem("viewMode") || ViewMode.Card (localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card
); );
const linkView = {
[ViewMode.Card]: CardView,
[ViewMode.List]: ListView,
[ViewMode.Masonry]: MasonryView,
};
// @ts-ignore
const LinkComponent = linkView[viewMode];
return collection ? ( return collection ? (
<div <div
className="h-96" className="h-96"
@ -227,19 +216,21 @@ export default function PublicCollections() {
/> />
</LinkListOptions> </LinkListOptions>
{links[0] ? ( <Links
<LinkComponent links={
links={links links?.map((e, i) => {
.filter((e) => e.collectionId === Number(router.query.id)) const linkWithCollectionData = {
.map((e, i) => { ...e,
const linkWithCollectionData = { collection: collection, // Append collection data
...e, };
collection: collection, // Append collection data return linkWithCollectionData;
}; }) as any
return linkWithCollectionData; }
})} layout={viewMode}
/> placeholderCount={1}
) : ( useData={data}
/>
{!data.isLoading && links && !links[0] && (
<p>{t("collection_is_empty")}</p> <p>{t("collection_is_empty")}</p>
)} )}

View File

@ -1,5 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { import {
ArchivedFormat, ArchivedFormat,
@ -7,20 +6,20 @@ import {
} from "@/types/global"; } from "@/types/global";
import ReadableView from "@/components/ReadableView"; import ReadableView from "@/components/ReadableView";
import getServerSideProps from "@/lib/client/getServerSideProps"; import getServerSideProps from "@/lib/client/getServerSideProps";
import { useGetLink, useLinks } from "@/hooks/store/links";
export default function Index() { export default function Index() {
const { links, getLink } = useLinkStore(); const { links } = useLinks();
const getLink = useGetLink();
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>(); const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
const router = useRouter(); const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : false;
useEffect(() => { useEffect(() => {
const fetchLink = async () => { const fetchLink = async () => {
if (router.query.id) { if (router.query.id) {
await getLink(Number(router.query.id), isPublic); await getLink.mutateAsync(Number(router.query.id));
} }
}; };
@ -28,7 +27,8 @@ export default function Index() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (links[0]) setLink(links.find((e) => e.id === Number(router.query.id))); if (links && links[0])
setLink(links.find((e) => e.id === Number(router.query.id)));
}, [links]); }, [links]);
return ( return (

View File

@ -1,23 +1,17 @@
import useLinks from "@/hooks/useLinks"; import { useLinks } from "@/hooks/store/links";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import useLinkStore from "@/store/links";
import { Sort, ViewMode } from "@/types/global"; import { Sort, ViewMode } from "@/types/global";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import CardView from "@/components/LinkViews/Layouts/CardView";
import ListView from "@/components/LinkViews/Layouts/ListView";
import PageHeader from "@/components/PageHeader"; import PageHeader from "@/components/PageHeader";
import { GridLoader } from "react-spinners";
import MasonryView from "@/components/LinkViews/Layouts/MasonryView";
import LinkListOptions from "@/components/LinkListOptions"; import LinkListOptions from "@/components/LinkListOptions";
import getServerSideProps from "@/lib/client/getServerSideProps"; import getServerSideProps from "@/lib/client/getServerSideProps";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import Links from "@/components/LinkViews/Links";
export default function Search() { export default function Search() {
const { t } = useTranslation(); const { t } = useTranslation();
const { links } = useLinkStore();
const router = useRouter(); const router = useRouter();
const [searchFilter, setSearchFilter] = useState({ const [searchFilter, setSearchFilter] = useState({
@ -28,11 +22,13 @@ export default function Search() {
textContent: false, textContent: false,
}); });
const [viewMode, setViewMode] = useState<string>( const [viewMode, setViewMode] = useState<ViewMode>(
localStorage.getItem("viewMode") || ViewMode.Card (localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card
); );
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(
Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst
);
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
@ -40,7 +36,17 @@ export default function Search() {
if (editMode) return setEditMode(false); if (editMode) return setEditMode(false);
}, [router]); }, [router]);
const { isLoading } = useLinks({ // const { isLoading } = useLink({
// sort: sortBy,
// searchQueryString: decodeURIComponent(router.query.q as string),
// searchByName: searchFilter.name,
// searchByUrl: searchFilter.url,
// searchByDescription: searchFilter.description,
// searchByTextContent: searchFilter.textContent,
// searchByTags: searchFilter.tags,
// });
const { links, data } = useLinks({
sort: sortBy, sort: sortBy,
searchQueryString: decodeURIComponent(router.query.q as string), searchQueryString: decodeURIComponent(router.query.q as string),
searchByName: searchFilter.name, searchByName: searchFilter.name,
@ -50,15 +56,6 @@ export default function Search() {
searchByTags: searchFilter.tags, searchByTags: searchFilter.tags,
}); });
const linkView = {
[ViewMode.Card]: CardView,
[ViewMode.List]: ListView,
[ViewMode.Masonry]: MasonryView,
};
// @ts-ignore
const LinkComponent = linkView[viewMode];
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">
@ -76,7 +73,9 @@ export default function Search() {
<PageHeader icon={"bi-search"} title={"Search Results"} /> <PageHeader icon={"bi-search"} title={"Search Results"} />
</LinkListOptions> </LinkListOptions>
{!isLoading && !links[0] ? ( {/* {
!isLoading &&
!links[0] ? (
<p>{t("nothing_found")}</p> <p>{t("nothing_found")}</p>
) : links[0] ? ( ) : links[0] ? (
<LinkComponent <LinkComponent
@ -93,7 +92,15 @@ export default function Search() {
className="m-auto py-10" className="m-auto py-10"
/> />
) )
)} )} */}
<Links
editMode={editMode}
links={links}
layout={viewMode}
placeholderCount={1}
useData={data}
/>
</div> </div>
</MainLayout> </MainLayout>
); );

View File

@ -1,11 +1,11 @@
import SettingsLayout from "@/layouts/SettingsLayout"; import SettingsLayout from "@/layouts/SettingsLayout";
import React, { useEffect, useState } from "react"; import React, { useState } from "react";
import NewTokenModal from "@/components/ModalContent/NewTokenModal"; import NewTokenModal from "@/components/ModalContent/NewTokenModal";
import RevokeTokenModal from "@/components/ModalContent/RevokeTokenModal"; import RevokeTokenModal from "@/components/ModalContent/RevokeTokenModal";
import { AccessToken } from "@prisma/client"; import { AccessToken } from "@prisma/client";
import useTokenStore from "@/store/tokens";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import getServerSideProps from "@/lib/client/getServerSideProps"; import getServerSideProps from "@/lib/client/getServerSideProps";
import { useTokens } from "@/hooks/store/tokens";
export default function AccessTokens() { export default function AccessTokens() {
const [newTokenModal, setNewTokenModal] = useState(false); const [newTokenModal, setNewTokenModal] = useState(false);
@ -18,15 +18,7 @@ export default function AccessTokens() {
setRevokeTokenModal(true); setRevokeTokenModal(true);
}; };
const { setTokens, tokens } = useTokenStore(); const { data: tokens = [] } = useTokens();
useEffect(() => {
fetch("/api/v1/tokens")
.then((res) => res.json())
.then((data) => {
if (data.response) setTokens(data.response as AccessToken[]);
});
}, []);
return ( return (
<SettingsLayout> <SettingsLayout>

View File

@ -1,5 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import useAccountStore from "@/store/account";
import { AccountSettings } from "@/types/global"; import { AccountSettings } from "@/types/global";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import SettingsLayout from "@/layouts/SettingsLayout"; import SettingsLayout from "@/layouts/SettingsLayout";
@ -17,6 +16,7 @@ import Button from "@/components/ui/Button";
import { i18n } from "next-i18next.config"; import { i18n } from "next-i18next.config";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import getServerSideProps from "@/lib/client/getServerSideProps"; import getServerSideProps from "@/lib/client/getServerSideProps";
import { useUpdateUser, useUser } from "@/hooks/store/user";
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
@ -24,7 +24,8 @@ export default function Account() {
const [emailChangeVerificationModal, setEmailChangeVerificationModal] = const [emailChangeVerificationModal, setEmailChangeVerificationModal] =
useState(false); useState(false);
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const { account, updateAccount } = useAccountStore(); const { data: account } = useUser();
const updateUser = useUpdateUser();
const [user, setUser] = useState<AccountSettings>( const [user, setUser] = useState<AccountSettings>(
!objectIsEmpty(account) !objectIsEmpty(account)
? account ? account
@ -78,25 +79,38 @@ export default function Account() {
const submit = async (password?: string) => { const submit = async (password?: string) => {
setSubmitLoader(true); setSubmitLoader(true);
const load = toast.loading(t("applying_settings")); const load = toast.loading(t("applying_settings"));
const response = await updateAccount({ await updateUser.mutateAsync(
...user, {
// @ts-ignore ...user,
password: password ? password : undefined, password: password ? password : undefined,
}); },
{
onSuccess: (data) => {
if (data.response.email !== user.email) {
toast.success(t("email_change_request"));
setEmailChangeVerificationModal(false);
}
},
onSettled: (data, error) => {
toast.dismiss(load);
toast.dismiss(load); if (error) {
toast.error(error.message);
} else {
if (data.response.email !== user.email) {
toast.success(t("email_change_request"));
setEmailChangeVerificationModal(false);
}
if (response.ok) { toast.success(t("settings_applied"));
const emailChanged = account.email !== user.email; }
},
toast.success(t("settings_applied"));
if (emailChanged) {
toast.success(t("email_change_request"));
setEmailChangeVerificationModal(false);
} }
} else toast.error(response.data as string); );
setSubmitLoader(false); setSubmitLoader(false);
}; };
@ -189,17 +203,14 @@ export default function Account() {
<div> <div>
<p className="mb-2">{t("language")}</p> <p className="mb-2">{t("language")}</p>
<select <select
value={user.locale || ""}
onChange={(e) => { onChange={(e) => {
setUser({ ...user, locale: e.target.value }); setUser({ ...user, locale: e.target.value });
}} }}
className="select border border-neutral-content focus:outline-none focus:border-primary duration-100 w-full bg-base-200 rounded-[0.375rem] min-h-0 h-[2.625rem] leading-4 p-2" className="select border border-neutral-content focus:outline-none focus:border-primary duration-100 w-full bg-base-200 rounded-[0.375rem] min-h-0 h-[2.625rem] leading-4 p-2"
> >
{i18n.locales.map((locale) => ( {i18n.locales.map((locale) => (
<option <option key={locale} value={locale}>
key={locale}
value={locale}
selected={user.locale === locale}
>
{new Intl.DisplayNames(locale, { type: "language" }).of( {new Intl.DisplayNames(locale, { type: "language" }).of(
locale locale
) || ""} ) || ""}

View File

@ -1,11 +1,11 @@
import SettingsLayout from "@/layouts/SettingsLayout"; import SettingsLayout from "@/layouts/SettingsLayout";
import { useState } from "react"; import { useState } from "react";
import useAccountStore from "@/store/account";
import SubmitButton from "@/components/SubmitButton"; import SubmitButton from "@/components/SubmitButton";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import TextInput from "@/components/TextInput"; import TextInput from "@/components/TextInput";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import getServerSideProps from "@/lib/client/getServerSideProps"; import getServerSideProps from "@/lib/client/getServerSideProps";
import { useUpdateUser, useUser } from "@/hooks/store/user";
export default function Password() { export default function Password() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -13,7 +13,8 @@ export default function Password() {
const [oldPassword, setOldPassword] = useState(""); const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const { account, updateAccount } = useAccountStore(); const { data: account } = useUser();
const updateUser = useUpdateUser();
const submit = async () => { const submit = async () => {
if (newPassword === "" || oldPassword === "") { if (newPassword === "" || oldPassword === "") {
@ -23,23 +24,29 @@ export default function Password() {
setSubmitLoader(true); setSubmitLoader(true);
const load = toast.loading(t("applying_changes")); const load = toast.loading(t("applying_settings"));
const response = await updateAccount({ await updateUser.mutateAsync(
...account, {
newPassword, ...account,
oldPassword, newPassword,
}); oldPassword,
},
{
onSettled: (data, error) => {
toast.dismiss(load);
toast.dismiss(load); if (error) {
toast.error(error.message);
} else {
setNewPassword("");
setOldPassword("");
if (response.ok) { toast.success(t("settings_applied"));
toast.success(t("settings_applied")); }
setNewPassword(""); },
setOldPassword(""); }
} else { );
toast.error(response.data as string);
}
setSubmitLoader(false); setSubmitLoader(false);
}; };

View File

@ -1,6 +1,5 @@
import SettingsLayout from "@/layouts/SettingsLayout"; import SettingsLayout from "@/layouts/SettingsLayout";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import useAccountStore from "@/store/account";
import SubmitButton from "@/components/SubmitButton"; import SubmitButton from "@/components/SubmitButton";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import Checkbox from "@/components/Checkbox"; import Checkbox from "@/components/Checkbox";
@ -8,12 +7,14 @@ import useLocalSettingsStore from "@/store/localSettings";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import getServerSideProps from "@/lib/client/getServerSideProps"; // Import getServerSideProps for server-side data fetching import getServerSideProps from "@/lib/client/getServerSideProps"; // Import getServerSideProps for server-side data fetching
import { LinksRouteTo } from "@prisma/client"; import { LinksRouteTo } from "@prisma/client";
import { useUpdateUser, useUser } from "@/hooks/store/user";
export default function Appearance() { export default function Appearance() {
const { t } = useTranslation(); const { t } = useTranslation();
const { updateSettings } = useLocalSettingsStore(); const { updateSettings } = useLocalSettingsStore();
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const { account, updateAccount } = useAccountStore(); const { data: account } = useUser();
const updateUser = useUpdateUser();
const [user, setUser] = useState(account); const [user, setUser] = useState(account);
const [preventDuplicateLinks, setPreventDuplicateLinks] = useState<boolean>( const [preventDuplicateLinks, setPreventDuplicateLinks] = useState<boolean>(
@ -73,17 +74,23 @@ export default function Appearance() {
const submit = async () => { const submit = async () => {
setSubmitLoader(true); setSubmitLoader(true);
const load = toast.loading(t("applying_changes")); const load = toast.loading(t("applying_settings"));
const response = await updateAccount({ ...user }); await updateUser.mutateAsync(
{ ...user },
{
onSettled: (data, error) => {
toast.dismiss(load);
toast.dismiss(load); if (error) {
toast.error(error.message);
} else {
toast.success(t("settings_applied"));
}
},
}
);
if (response.ok) {
toast.success(t("settings_applied"));
} else {
toast.error(response.data as string);
}
setSubmitLoader(false); setSubmitLoader(false);
}; };

View File

@ -7,7 +7,7 @@ import { Plan } from "@/types/global";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import getServerSideProps from "@/lib/client/getServerSideProps"; import getServerSideProps from "@/lib/client/getServerSideProps";
import { Trans, useTranslation } from "next-i18next"; import { Trans, useTranslation } from "next-i18next";
import useAccountStore from "@/store/account"; import { useUser } from "@/hooks/store/user";
const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true"; const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true";
@ -20,11 +20,11 @@ export default function Subscribe() {
const router = useRouter(); const router = useRouter();
const { account } = useAccountStore(); const { data: user = {} } = useUser();
useEffect(() => { useEffect(() => {
const hasInactiveSubscription = const hasInactiveSubscription =
account.id && !account.subscription?.active && stripeEnabled; user.id && !user.subscription?.active && stripeEnabled;
if (session.status === "authenticated" && !hasInactiveSubscription) { if (session.status === "authenticated" && !hasInactiveSubscription) {
router.push("/dashboard"); router.push("/dashboard");

View File

@ -1,29 +1,29 @@
import useLinkStore from "@/store/links";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import useTagStore from "@/store/tags";
import { Sort, TagIncludingLinkCount, ViewMode } from "@/types/global"; import { Sort, TagIncludingLinkCount, ViewMode } from "@/types/global";
import useLinks from "@/hooks/useLinks"; import { useLinks } from "@/hooks/store/links";
import { toast } from "react-hot-toast";
import CardView from "@/components/LinkViews/Layouts/CardView";
import ListView from "@/components/LinkViews/Layouts/ListView";
import { dropdownTriggerer } from "@/lib/client/utils"; import { dropdownTriggerer } from "@/lib/client/utils";
import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal"; import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal";
import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal"; import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal";
import MasonryView from "@/components/LinkViews/Layouts/MasonryView";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import getServerSideProps from "@/lib/client/getServerSideProps"; import getServerSideProps from "@/lib/client/getServerSideProps";
import LinkListOptions from "@/components/LinkListOptions"; import LinkListOptions from "@/components/LinkListOptions";
import { useRemoveTag, useTags, useUpdateTag } from "@/hooks/store/tags";
import Links from "@/components/LinkViews/Links";
import toast from "react-hot-toast";
export default function Index() { export default function Index() {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const { links } = useLinkStore(); const { data: tags = [] } = useTags();
const { tags, updateTag, removeTag } = useTagStore(); const updateTag = useUpdateTag();
const removeTag = useRemoveTag();
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(
Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst
);
const [renameTag, setRenameTag] = useState(false); const [renameTag, setRenameTag] = useState(false);
const [newTagName, setNewTagName] = useState<string>(); const [newTagName, setNewTagName] = useState<string>();
@ -38,10 +38,13 @@ export default function Index() {
if (editMode) return setEditMode(false); if (editMode) return setEditMode(false);
}, [router]); }, [router]);
useLinks({ tagId: Number(router.query.id), sort: sortBy }); const { links, data } = useLinks({
sort: sortBy,
tagId: Number(router.query.id),
});
useEffect(() => { useEffect(() => {
const tag = tags.find((e) => e.id === Number(router.query.id)); const tag = tags.find((e: any) => e.id === Number(router.query.id));
if (tags.length > 0 && !tag?.id) { if (tags.length > 0 && !tag?.id) {
router.push("/dashboard"); router.push("/dashboard");
@ -72,21 +75,28 @@ export default function Index() {
setSubmitLoader(true); setSubmitLoader(true);
const load = toast.loading(t("applying_changes")); if (activeTag && newTagName) {
const load = toast.loading(t("applying_changes"));
let response; await updateTag.mutateAsync(
{
...activeTag,
name: newTagName,
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (activeTag && newTagName) if (error) {
response = await updateTag({ toast.error(error.message);
...activeTag, } else {
name: newTagName, toast.success(t("tag_renamed"));
}); }
},
}
);
}
toast.dismiss(load);
if (response?.ok) {
toast.success(t("tag_renamed"));
} else toast.error(response?.data as string);
setSubmitLoader(false); setSubmitLoader(false);
setRenameTag(false); setRenameTag(false);
}; };
@ -94,35 +104,31 @@ export default function Index() {
const remove = async () => { const remove = async () => {
setSubmitLoader(true); setSubmitLoader(true);
const load = toast.loading(t("applying_changes")); if (activeTag?.id) {
const load = toast.loading(t("applying_changes"));
let response; await removeTag.mutateAsync(activeTag?.id, {
onSettled: (data, error) => {
toast.dismiss(load);
if (activeTag?.id) response = await removeTag(activeTag?.id); if (error) {
toast.error(error.message);
} else {
toast.success(t("tag_deleted"));
router.push("/links");
}
},
});
}
toast.dismiss(load);
if (response?.ok) {
toast.success(t("tag_deleted"));
router.push("/links");
} else toast.error(response?.data as string);
setSubmitLoader(false); setSubmitLoader(false);
setRenameTag(false); setRenameTag(false);
}; };
const [viewMode, setViewMode] = useState<string>( const [viewMode, setViewMode] = useState<ViewMode>(
localStorage.getItem("viewMode") || ViewMode.Card (localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card
); );
const linkView = {
[ViewMode.Card]: CardView,
[ViewMode.List]: ListView,
[ViewMode.Masonry]: MasonryView,
};
// @ts-ignore
const LinkComponent = linkView[viewMode];
return ( return (
<MainLayout> <MainLayout>
<div className="p-5 flex flex-col gap-5 w-full"> <div className="p-5 flex flex-col gap-5 w-full">
@ -222,11 +228,12 @@ export default function Index() {
</div> </div>
</LinkListOptions> </LinkListOptions>
<LinkComponent <Links
editMode={editMode} editMode={editMode}
links={links.filter((e) => links={links}
e.tags.some((e) => e.id === Number(router.query.id)) layout={viewMode}
)} placeholderCount={1}
useData={data}
/> />
</div> </div>
{bulkDeleteLinksModal && ( {bulkDeleteLinksModal && (

View File

@ -297,6 +297,7 @@
"create_new_collection": "Create a New Collection", "create_new_collection": "Create a New Collection",
"color": "Color", "color": "Color",
"reset": "Reset", "reset": "Reset",
"updating_collection": "Updating Collection...",
"collection_name_placeholder": "e.g. Example Collection", "collection_name_placeholder": "e.g. Example Collection",
"collection_description_placeholder": "The purpose of this Collection...", "collection_description_placeholder": "The purpose of this Collection...",
"create_collection_button": "Create Collection", "create_collection_button": "Create Collection",

View File

@ -1,41 +0,0 @@
import { create } from "zustand";
import { AccountSettings } from "@/types/global";
type ResponseObject = {
ok: boolean;
data: Omit<AccountSettings, "password"> | object | string;
};
type AccountStore = {
account: AccountSettings;
setAccount: (id: number) => void;
updateAccount: (user: AccountSettings) => Promise<ResponseObject>;
};
const useAccountStore = create<AccountStore>()((set) => ({
account: {} as AccountSettings,
setAccount: async (id) => {
const response = await fetch(`/api/v1/users/${id}`);
const data = await response.json();
if (response.ok) set({ account: { ...data.response } });
},
updateAccount: async (user) => {
const response = await fetch(`/api/v1/users/${user.id}`, {
method: "PUT",
body: JSON.stringify(user),
headers: {
"Content-Type": "application/json",
},
});
const data = await response.json();
if (response.ok) set({ account: { ...data.response } });
return { ok: response.ok, data: data.response };
},
}));
export default useAccountStore;

View File

@ -1,66 +0,0 @@
import { User as U } from "@prisma/client";
import { create } from "zustand";
interface User extends U {
subscriptions: {
active: boolean;
};
}
type ResponseObject = {
ok: boolean;
data: object | string;
};
type UserStore = {
users: User[];
setUsers: () => void;
addUser: (body: Partial<U>) => Promise<ResponseObject>;
removeUser: (userId: number) => Promise<ResponseObject>;
};
const useUserStore = create<UserStore>((set) => ({
users: [],
setUsers: async () => {
const response = await fetch("/api/v1/users");
const data = await response.json();
if (response.ok) set({ users: data.response });
else if (response.status === 401) window.location.href = "/dashboard";
},
addUser: async (body) => {
const response = await fetch("/api/v1/users", {
method: "POST",
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
},
});
const data = await response.json();
if (response.ok)
set((state) => ({
users: [...state.users, data.response],
}));
return { ok: response.ok, data: data.response };
},
removeUser: async (userId) => {
const response = await fetch(`/api/v1/users/${userId}`, {
method: "DELETE",
});
const data = await response.json();
if (response.ok)
set((state) => ({
users: state.users.filter((user) => user.id !== userId),
}));
return { ok: response.ok, data: data.response };
},
}));
export default useUserStore;

View File

@ -1,94 +0,0 @@
import { create } from "zustand";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import useTagStore from "./tags";
type ResponseObject = {
ok: boolean;
data: object | string;
};
type CollectionStore = {
collections: CollectionIncludingMembersAndLinkCount[];
setCollections: () => void;
addCollection: (
body: CollectionIncludingMembersAndLinkCount
) => Promise<ResponseObject>;
updateCollection: (
collection: CollectionIncludingMembersAndLinkCount
) => Promise<ResponseObject>;
removeCollection: (collectionId: number) => Promise<ResponseObject>;
};
const useCollectionStore = create<CollectionStore>()((set) => ({
collections: [],
setCollections: async () => {
const response = await fetch("/api/v1/collections");
const data = await response.json();
if (response.ok) set({ collections: data.response });
},
addCollection: async (body) => {
const response = await fetch("/api/v1/collections", {
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const data = await response.json();
if (response.ok)
set((state) => ({
collections: [...state.collections, data.response],
}));
return { ok: response.ok, data: data.response };
},
updateCollection: async (collection) => {
const response = await fetch(`/api/v1/collections/${collection.id}`, {
body: JSON.stringify(collection),
headers: {
"Content-Type": "application/json",
},
method: "PUT",
});
const data = await response.json();
if (response.ok)
set((state) => ({
collections: state.collections.map((e) =>
e.id === data.response.id ? data.response : e
),
}));
return { ok: response.ok, data: data.response };
},
removeCollection: async (collectionId) => {
const response = await fetch(`/api/v1/collections/${collectionId}`, {
headers: {
"Content-Type": "application/json",
},
method: "DELETE",
});
const data = await response.json();
if (response.ok) {
set((state) => ({
collections: state.collections.filter(
(collection) =>
collection.id !== collectionId &&
collection.parentId !== collectionId
),
}));
useTagStore.getState().setTags();
}
return { ok: response.ok, data: data.response };
},
}));
export default useCollectionStore;

View File

@ -1,10 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import { import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import useTagStore from "./tags";
import useCollectionStore from "./collections";
type ResponseObject = { type ResponseObject = {
ok: boolean; ok: boolean;
@ -12,24 +7,8 @@ type ResponseObject = {
}; };
type LinkStore = { type LinkStore = {
links: LinkIncludingShortenedCollectionAndTags[];
selectedLinks: LinkIncludingShortenedCollectionAndTags[]; selectedLinks: LinkIncludingShortenedCollectionAndTags[];
setLinks: (
data: LinkIncludingShortenedCollectionAndTags[],
isInitialCall: boolean
) => void;
setSelectedLinks: (links: LinkIncludingShortenedCollectionAndTags[]) => void; setSelectedLinks: (links: LinkIncludingShortenedCollectionAndTags[]) => void;
addLink: (
body: LinkIncludingShortenedCollectionAndTags
) => Promise<ResponseObject>;
uploadFile: (
link: LinkIncludingShortenedCollectionAndTags,
file: File
) => Promise<ResponseObject>;
getLink: (linkId: number, publicRoute?: boolean) => Promise<ResponseObject>;
updateLink: (
link: LinkIncludingShortenedCollectionAndTags
) => Promise<ResponseObject>;
updateLinks: ( updateLinks: (
links: LinkIncludingShortenedCollectionAndTags[], links: LinkIncludingShortenedCollectionAndTags[],
removePreviousTags: boolean, removePreviousTags: boolean,
@ -38,186 +17,11 @@ type LinkStore = {
"tags" | "collectionId" "tags" | "collectionId"
> >
) => Promise<ResponseObject>; ) => Promise<ResponseObject>;
removeLink: (linkId: number) => Promise<ResponseObject>;
deleteLinksById: (linkIds: number[]) => Promise<ResponseObject>;
resetLinks: () => void;
}; };
const useLinkStore = create<LinkStore>()((set) => ({ const useLinkStore = create<LinkStore>()((set) => ({
links: [],
selectedLinks: [], selectedLinks: [],
setLinks: async (data, isInitialCall) => {
isInitialCall &&
set(() => ({
links: [],
}));
set((state) => ({
// 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;
},
[]
),
}));
},
setSelectedLinks: (links) => set({ selectedLinks: links }), setSelectedLinks: (links) => set({ selectedLinks: links }),
addLink: async (body) => {
const response = await fetch("/api/v1/links", {
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const data = await response.json();
if (response.ok) {
set((state) => ({
links: [data.response, ...state.links],
}));
useTagStore.getState().setTags();
useCollectionStore.getState().setCollections();
}
return { ok: response.ok, data: data.response };
},
uploadFile: async (link, file) => {
let fileType: ArchivedFormat | null = null;
let linkType: "url" | "image" | "pdf" | null = null;
if (file?.type === "image/jpg" || file.type === "image/jpeg") {
fileType = ArchivedFormat.jpeg;
linkType = "image";
} else if (file.type === "image/png") {
fileType = ArchivedFormat.png;
linkType = "image";
} else if (file.type === "application/pdf") {
fileType = ArchivedFormat.pdf;
linkType = "pdf";
} else {
return { ok: false, data: "Invalid file type." };
}
const response = await fetch("/api/v1/links", {
body: JSON.stringify({
...link,
type: linkType,
name: link.name ? link.name : file.name,
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const data = await response.json();
const createdLink: LinkIncludingShortenedCollectionAndTags = data.response;
console.log(data);
if (response.ok) {
const formBody = new FormData();
file && formBody.append("file", file);
await fetch(
`/api/v1/archives/${(data as any).response.id}?format=${fileType}`,
{
body: formBody,
method: "POST",
}
);
// get file extension
const extension = file.name.split(".").pop() || "";
set((state) => ({
links: [
{
...createdLink,
image:
linkType === "image"
? `archives/${createdLink.collectionId}/${
createdLink.id + extension
}`
: null,
pdf:
linkType === "pdf"
? `archives/${createdLink.collectionId}/${
createdLink.id + ".pdf"
}`
: null,
},
...state.links,
],
}));
useTagStore.getState().setTags();
useCollectionStore.getState().setCollections();
}
return { ok: response.ok, data: data.response };
},
getLink: async (linkId, publicRoute) => {
const path = publicRoute
? `/api/v1/public/links/${linkId}`
: `/api/v1/links/${linkId}`;
const response = await fetch(path);
const data = await response.json();
if (response.ok) {
set((state) => {
const linkExists = state.links.some(
(link) => link.id === data.response.id
);
if (linkExists) {
return {
links: state.links.map((e) =>
e.id === data.response.id ? data.response : e
),
};
} else {
return {
links: [...state.links, data.response],
};
}
});
return data;
}
return { ok: response.ok, data: data.response };
},
updateLink: async (link) => {
const response = await fetch(`/api/v1/links/${link.id}`, {
body: JSON.stringify(link),
headers: {
"Content-Type": "application/json",
},
method: "PUT",
});
const data = await response.json();
if (response.ok) {
set((state) => ({
links: state.links.map((e) =>
e.id === data.response.id ? data.response : e
),
}));
useTagStore.getState().setTags();
useCollectionStore.getState().setCollections();
}
return { ok: response.ok, data: data.response };
},
updateLinks: async (links, removePreviousTags, newData) => { updateLinks: async (links, removePreviousTags, newData) => {
const response = await fetch("/api/v1/links", { const response = await fetch("/api/v1/links", {
body: JSON.stringify({ links, removePreviousTags, newData }), body: JSON.stringify({ links, removePreviousTags, newData }),
@ -230,71 +34,11 @@ const useLinkStore = create<LinkStore>()((set) => ({
const data = await response.json(); const data = await response.json();
if (response.ok) { if (response.ok) {
set((state) => ({ // Update the selected links with the new data
links: state.links.map((e) =>
links.some((link) => link.id === e.id)
? {
...e,
collectionId: newData.collectionId ?? e.collectionId,
collection: {
...e.collection,
id: newData.collectionId ?? e.collection.id,
},
tags: removePreviousTags
? [...(newData.tags ?? [])]
: [...e.tags, ...(newData.tags ?? [])],
}
: e
),
}));
useTagStore.getState().setTags();
useCollectionStore.getState().setCollections();
} }
return { ok: response.ok, data: data.response }; return { ok: response.ok, data: data.response };
}, },
removeLink: async (linkId) => {
const response = await fetch(`/api/v1/links/${linkId}`, {
headers: {
"Content-Type": "application/json",
},
method: "DELETE",
});
const data = await response.json();
if (response.ok) {
set((state) => ({
links: state.links.filter((e) => e.id !== linkId),
}));
useTagStore.getState().setTags();
useCollectionStore.getState().setCollections();
}
return { ok: response.ok, data: data.response };
},
deleteLinksById: async (linkIds: number[]) => {
const response = await fetch("/api/v1/links", {
body: JSON.stringify({ linkIds }),
headers: {
"Content-Type": "application/json",
},
method: "DELETE",
});
const data = await response.json();
if (response.ok) {
set((state) => ({
links: state.links.filter((e) => !linkIds.includes(e.id as number)),
}));
useTagStore.getState().setTags();
useCollectionStore.getState().setCollections();
}
return { ok: response.ok, data: data.response };
},
resetLinks: () => set({ links: [] }),
})); }));
export default useLinkStore; export default useLinkStore;

View File

@ -1,8 +1,10 @@
import { Sort } from "@/types/global";
import { create } from "zustand"; import { create } from "zustand";
type LocalSettings = { type LocalSettings = {
theme?: string; theme?: string;
viewMode?: string; viewMode?: string;
sortBy?: Sort;
}; };
type LocalSettingsStore = { type LocalSettingsStore = {
@ -15,10 +17,11 @@ const useLocalSettingsStore = create<LocalSettingsStore>((set) => ({
settings: { settings: {
theme: "", theme: "",
viewMode: "", viewMode: "",
sortBy: Sort.DateNewestFirst,
}, },
updateSettings: async (newSettings) => { updateSettings: async (newSettings) => {
if ( if (
newSettings.theme && newSettings.theme !== undefined &&
newSettings.theme !== localStorage.getItem("theme") newSettings.theme !== localStorage.getItem("theme")
) { ) {
localStorage.setItem("theme", newSettings.theme); localStorage.setItem("theme", newSettings.theme);
@ -29,7 +32,7 @@ const useLocalSettingsStore = create<LocalSettingsStore>((set) => ({
} }
if ( if (
newSettings.viewMode && newSettings.viewMode !== undefined &&
newSettings.viewMode !== localStorage.getItem("viewMode") newSettings.viewMode !== localStorage.getItem("viewMode")
) { ) {
localStorage.setItem("viewMode", newSettings.viewMode); localStorage.setItem("viewMode", newSettings.viewMode);
@ -37,6 +40,13 @@ const useLocalSettingsStore = create<LocalSettingsStore>((set) => ({
// const localTheme = localStorage.getItem("viewMode") || ""; // const localTheme = localStorage.getItem("viewMode") || "";
} }
if (
newSettings.sortBy !== undefined &&
newSettings.sortBy !== Number(localStorage.getItem("sortBy"))
) {
localStorage.setItem("sortBy", newSettings.sortBy.toString());
}
set((state) => ({ settings: { ...state.settings, ...newSettings } })); set((state) => ({ settings: { ...state.settings, ...newSettings } }));
}, },
setSettings: async () => { setSettings: async () => {

View File

@ -1,64 +0,0 @@
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { create } from "zustand";
type Modal =
| {
modal: "LINK";
state: boolean;
method: "CREATE";
active?: LinkIncludingShortenedCollectionAndTags;
}
| {
modal: "LINK";
state: boolean;
method: "UPDATE";
active: LinkIncludingShortenedCollectionAndTags;
}
| {
modal: "LINK";
state: boolean;
method: "FORMATS";
active: LinkIncludingShortenedCollectionAndTags;
}
| {
modal: "COLLECTION";
state: boolean;
method: "UPDATE";
isOwner: boolean;
active: CollectionIncludingMembersAndLinkCount;
defaultIndex?: number;
}
| {
modal: "COLLECTION";
state: boolean;
method: "CREATE";
isOwner?: boolean;
active?: CollectionIncludingMembersAndLinkCount;
defaultIndex?: number;
}
| {
modal: "COLLECTION";
state: boolean;
method: "VIEW_TEAM";
isOwner?: boolean;
active?: CollectionIncludingMembersAndLinkCount;
defaultIndex?: number;
}
| null;
type ModalsStore = {
modal: Modal;
setModal: (modal: Modal) => void;
};
const useModalStore = create<ModalsStore>((set) => ({
modal: null,
setModal: (modal: Modal) => {
set({ modal });
},
}));
export default useModalStore;

View File

@ -1,62 +0,0 @@
import { create } from "zustand";
import { TagIncludingLinkCount } from "@/types/global";
type ResponseObject = {
ok: boolean;
data: object | string;
};
type TagStore = {
tags: TagIncludingLinkCount[];
setTags: () => void;
updateTag: (tag: TagIncludingLinkCount) => Promise<ResponseObject>;
removeTag: (tagId: number) => Promise<ResponseObject>;
};
const useTagStore = create<TagStore>()((set) => ({
tags: [],
setTags: async () => {
const response = await fetch("/api/v1/tags");
const data = await response.json();
if (response.ok) set({ tags: data.response });
},
updateTag: async (tag) => {
const response = await fetch(`/api/v1/tags/${tag.id}`, {
body: JSON.stringify(tag),
headers: {
"Content-Type": "application/json",
},
method: "PUT",
});
const data = await response.json();
if (response.ok) {
set((state) => ({
tags: state.tags.map((e) =>
e.id === data.response.id ? data.response : e
),
}));
}
return { ok: response.ok, data: data.response };
},
removeTag: async (tagId) => {
const response = await fetch(`/api/v1/tags/${tagId}`, {
method: "DELETE",
});
if (response.ok) {
set((state) => ({
tags: state.tags.filter((e) => e.id !== tagId),
}));
}
const data = await response.json();
return { ok: response.ok, data: data.response };
},
}));
export default useTagStore;

View File

@ -1,56 +0,0 @@
import { AccessToken } from "@prisma/client";
import { create } from "zustand";
// Token store
type ResponseObject = {
ok: boolean;
data: object | string;
};
type TokenStore = {
tokens: Partial<AccessToken>[];
setTokens: (data: Partial<AccessToken>[]) => void;
addToken: (body: Partial<AccessToken>[]) => Promise<ResponseObject>;
revokeToken: (tokenId: number) => Promise<ResponseObject>;
};
const useTokenStore = create<TokenStore>((set) => ({
tokens: [],
setTokens: async (data) => {
set(() => ({
tokens: data,
}));
},
addToken: async (body) => {
const response = await fetch("/api/v1/tokens", {
body: JSON.stringify(body),
method: "POST",
});
const data = await response.json();
if (response.ok)
set((state) => ({
tokens: [...state.tokens, data.response.token],
}));
return { ok: response.ok, data: data.response };
},
revokeToken: async (tokenId) => {
const response = await fetch(`/api/v1/tokens/${tokenId}`, {
method: "DELETE",
});
const data = await response.json();
if (response.ok)
set((state) => ({
tokens: state.tokens.filter((token) => token.id !== tokenId),
}));
return { ok: response.ok, data: data.response };
},
}));
export default useTokenStore;

View File

@ -67,7 +67,6 @@ export interface PublicCollectionIncludingLinks extends Collection {
export enum ViewMode { export enum ViewMode {
Card = "card", Card = "card",
Grid = "grid",
List = "list", List = "list",
Masonry = "masonry", Masonry = "masonry",
} }
@ -82,7 +81,7 @@ export enum Sort {
} }
export type LinkRequestQuery = { export type LinkRequestQuery = {
sort: Sort; sort?: Sort;
cursor?: number; cursor?: number;
collectionId?: number; collectionId?: number;
tagId?: number; tagId?: number;

View File

@ -1903,6 +1903,30 @@
dependencies: dependencies:
tslib "^2.4.0" tslib "^2.4.0"
"@tanstack/query-core@5.51.15":
version "5.51.15"
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.51.15.tgz#7aee6a2d5d3f64de3e54096607233b1132dc6afd"
integrity sha512-xyobHDJ0yhPE3+UkSQ2/4X1fLSg7ICJI5J1JyU9yf7F3deQfEwSImCDrB1WSRrauJkMtXW7YIEcC0oA6ZZWt5A==
"@tanstack/query-devtools@5.51.15":
version "5.51.15"
resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.51.15.tgz#81c5c28231adc4b95fe4a5e1004020fdca5ea447"
integrity sha512-1oSCl+PsCa/aBCGVM2ZdcQLuQ0QYmKXJJB264twEMVM1M0n5CI40trtywORPF+wLGuZNIZzkKL7j/98mOLAIag==
"@tanstack/react-query-devtools@^5.51.15":
version "5.51.15"
resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.51.15.tgz#5c4d21305fd25c35dc88bd280304f77a45554fc2"
integrity sha512-bvGvJoncjZ3irEofoFevptj5BPkDpQrp2+dZhtFqPUZXRT6MAKPmOqtSmZPfacLR5jQLpqw/7d3Zxr173z7WDA==
dependencies:
"@tanstack/query-devtools" "5.51.15"
"@tanstack/react-query@^5.51.15":
version "5.51.15"
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.51.15.tgz#059bb2966f828263adb355de81410d107e22b5bc"
integrity sha512-UgFg23SrdIYrmfTSxAUn9g+J64VQy11pb9/EefoY/u2+zWuNMeqEOnvpJhf52XQy0yztQoyM9p6x8PFyTNaxXg==
dependencies:
"@tanstack/query-core" "5.51.15"
"@tokenizer/token@^0.3.0": "@tokenizer/token@^0.3.0":
version "0.3.0" version "0.3.0"
resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276" resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276"
@ -5250,6 +5274,11 @@ react-image-file-resizer@^0.4.8:
resolved "https://registry.yarnpkg.com/react-image-file-resizer/-/react-image-file-resizer-0.4.8.tgz#85f4ae4469fd2867d961568af660ef403d7a79af" resolved "https://registry.yarnpkg.com/react-image-file-resizer/-/react-image-file-resizer-0.4.8.tgz#85f4ae4469fd2867d961568af660ef403d7a79af"
integrity sha512-Ue7CfKnSlsfJ//SKzxNMz8avDgDSpWQDOnTKOp/GNRFJv4dO9L5YGHNEnj40peWkXXAK2OK0eRIoXhOYpUzUTQ== integrity sha512-Ue7CfKnSlsfJ//SKzxNMz8avDgDSpWQDOnTKOp/GNRFJv4dO9L5YGHNEnj40peWkXXAK2OK0eRIoXhOYpUzUTQ==
react-intersection-observer@^9.13.0:
version "9.13.0"
resolved "https://registry.yarnpkg.com/react-intersection-observer/-/react-intersection-observer-9.13.0.tgz#ee10827954cf6ccc204d027f8400a6ddb8df163a"
integrity sha512-y0UvBfjDiXqC8h0EWccyaj4dVBWMxgEx0t5RGNzQsvkfvZwugnKwxpu70StY4ivzYuMajavwUDjH4LJyIki9Lw==
react-is@^16.13.1, react-is@^16.7.0: react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1" version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@ -5311,10 +5340,10 @@ react-select@^5.7.4:
react-transition-group "^4.3.0" react-transition-group "^4.3.0"
use-isomorphic-layout-effect "^1.1.2" use-isomorphic-layout-effect "^1.1.2"
react-spinners@^0.13.8: react-spinners@^0.14.1:
version "0.13.8" version "0.14.1"
resolved "https://registry.yarnpkg.com/react-spinners/-/react-spinners-0.13.8.tgz#5262571be0f745d86bbd49a1e6b49f9f9cb19acc" resolved "https://registry.yarnpkg.com/react-spinners/-/react-spinners-0.14.1.tgz#de7d7d6b3e6d4f29d9620c65495b502c7dd90812"
integrity sha512-3e+k56lUkPj0vb5NDXPVFAOkPC//XyhKPJjvcGjyMNPWsBKpplfeyialP74G7H7+It7KzhtET+MvGqbKgAqpZA== integrity sha512-2Izq+qgQ08HTofCVEdcAQCXFEYfqTDdfeDQJeo/HHQiQJD4imOicNLhkfN2eh1NYEWVOX4D9ok2lhuDB0z3Aag==
react-style-singleton@^2.2.1: react-style-singleton@^2.2.1:
version "2.2.1" version "2.2.1"