Fix merge conflicts

This commit is contained in:
Isaac Wise 2024-08-18 13:03:09 -05:00
commit 898b126231
No known key found for this signature in database
GPG Key ID: B85FE2D71E84D8B0
95 changed files with 3462 additions and 1934 deletions

View File

@ -1,17 +1,18 @@
<div align="center"> <div align="center">
<img src="./assets/logo.png" width="100px" /> <img src="./assets/logo.png" width="100px" />
<h1>Linkwarden</h1> <h1>Linkwarden</h1>
<h3>Bookmark Preservation for Individuals and Teams</h3>
<a href="https://discord.com/invite/CtuYV47nuJ"><img src="https://img.shields.io/discord/1117993124669702164?logo=discord&style=flat" alt="Discord"></a> <a href="https://discord.com/invite/CtuYV47nuJ"><img src="https://img.shields.io/discord/1117993124669702164?logo=discord&style=flat" alt="Discord"></a>
<a href="https://twitter.com/LinkwardenHQ"><img src="https://img.shields.io/twitter/follow/linkwarden" alt="Twitter"></a> <a href="https://twitter.com/LinkwardenHQ"><img src="https://img.shields.io/twitter/follow/linkwarden" alt="Twitter"></a> <a href="https://news.ycombinator.com/item?id=36942308"><img src="https://img.shields.io/badge/Hacker%20News-280-%23FF6600"></img></a>
<img alt="GitHub commits since latest release" src="https://img.shields.io/github/commits-since/linkwarden/linkwarden/latest/dev?style=for-the-badge&label=COMMITS%20SINCE%20LATEST%20RELEASE">
</div> </div>
<div align='center'> <div align='center'>
[Website](https://linkwarden.app) | [Getting Started](https://docs.linkwarden.app) | [Features](https://github.com/linkwarden/linkwarden#features) | [Roadmap](https://github.com/orgs/linkwarden/projects/1) | [Support ❤](https://github.com/linkwarden/linkwarden#support-) [« LAUNCH DEMO »](https://demo.linkwarden.app)
[Cloud](https://cloud.linkwarden.app) · [Website](https://linkwarden.app) · [Features](https://github.com/linkwarden/linkwarden#features)
</div> </div>
@ -24,7 +25,7 @@ The objective is to organize useful webpages and articles you find across the we
Additionally, Linkwarden is designed with collaboration in mind, sharing links with the public and/or allowing multiple users to work together seamlessly. Additionally, Linkwarden is designed with collaboration in mind, sharing links with the public and/or allowing multiple users to work together seamlessly.
> [!TIP] > [!TIP]
> Our official [Cloud](https://linkwarden.app/#pricing) offering provides the simplest way to begin using Linkwarden and it's the preferred choice for many due to its time-saving benefits. <br> Your subscription supports our hosting infrastructure and ongoing development. <br> Alternatively, if you prefer [self-hosting](https://docs.linkwarden.app/self-hosting/installation) Linkwarden, no problem! You'll still have access to all the premium features. > Our official [Cloud](https://linkwarden.app/#pricing) offering provides the simplest way to begin using Linkwarden and it's the preferred choice for many due to its time-saving benefits. <br> Your subscription supports our hosting infrastructure and ongoing development. <br> Alternatively, if you prefer self-hosting Linkwarden, you can do so by following our [Installation documentation](https://docs.linkwarden.app/self-hosting/installation).
<img src="./assets/dashboard.png" /> <img src="./assets/dashboard.png" />
@ -71,10 +72,14 @@ We've forked the old version from the current repository into [this repo](https:
- ⬇️ Import and export your bookmarks. - ⬇️ Import and export your bookmarks.
- 🔐 SSO integration. (Enterprise and Self-hosted users only) - 🔐 SSO integration. (Enterprise and Self-hosted users only)
- 📦 Installable Progressive Web App (PWA). - 📦 Installable Progressive Web App (PWA).
- 🍏 iOS and MacOS Apps, maintained by [JGeek00](https://github.com/JGeek00).
- 🍎 iOS Shortcut to save links to Linkwarden. - 🍎 iOS Shortcut to save links to Linkwarden.
- 🔑 API keys. - 🔑 API keys.
- ✅ Bulk actions. - ✅ Bulk actions.
- ✨ And so many more features! - 👥 User administration.
- 🌐 Support for Other Languages (i18n).
- 📁 Image and PDF Uploads.
- ✨ And many more features. (Literally!)
## Like what we're doing? Give us a Star ⭐ ## Like what we're doing? Give us a Star ⭐
@ -98,7 +103,7 @@ We _usually_ go after the [popular suggestions](https://github.com/linkwarden/li
Make sure to check out our [public roadmap](https://github.com/orgs/linkwarden/projects/1). Make sure to check out our [public roadmap](https://github.com/orgs/linkwarden/projects/1).
## Docs ## Documentation
For information on how to get started or to set up your own instance, please visit the [documentation](https://docs.linkwarden.app). For information on how to get started or to set up your own instance, please visit the [documentation](https://docs.linkwarden.app).
@ -110,7 +115,7 @@ If you want to contribute, Thanks! Start by checking our [public roadmap](https:
If you found a security vulnerability, please do **not** create a public issue, instead send an email to [security@linkwarden.app](mailto:security@linkwarden.app) stating the vulnerability. Thanks! If you found a security vulnerability, please do **not** create a public issue, instead send an email to [security@linkwarden.app](mailto:security@linkwarden.app) stating the vulnerability. Thanks!
## Support ## Support <3
Other than using our official [Cloud](https://linkwarden.app/#pricing) offering, any [donations](https://opencollective.com/linkwarden) are highly appreciated as well! Other than using our official [Cloud](https://linkwarden.app/#pricing) offering, any [donations](https://opencollective.com/linkwarden) are highly appreciated as well!

View File

@ -8,12 +8,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";
export default function CollectionCard({ export default function CollectionCard({
collection, collection,
@ -22,7 +22,7 @@ export default function CollectionCard({
}) { }) {
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",
@ -41,18 +41,18 @@ export default function CollectionCard({
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, username: user.username as string,
image: account.image, image: user.image as string,
archiveAsScreenshot: account.archiveAsScreenshot, archiveAsScreenshot: user.archiveAsScreenshot as boolean,
archiveAsMonolith: account.archiveAsMonolith, archiveAsMonolith: user.archiveAsMonolith as boolean,
archiveAsPDF: account.archiveAsPDF, archiveAsPDF: user.archiveAsPDF as boolean,
}); });
} }
}; };
@ -76,7 +76,7 @@ export default function CollectionCard({
> >
<i className="bi-three-dots text-xl" title="More"></i> <i className="bi-three-dots text-xl" title="More"></i>
</div> </div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1"> <ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1">
{permissions === true && ( {permissions === true && (
<li> <li>
<div <div
@ -86,6 +86,7 @@ export default function CollectionCard({
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setEditCollectionModal(true); setEditCollectionModal(true);
}} }}
className="whitespace-nowrap"
> >
{t("edit_collection_info")} {t("edit_collection_info")}
</div> </div>
@ -99,6 +100,7 @@ export default function CollectionCard({
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setEditCollectionSharingModal(true); setEditCollectionSharingModal(true);
}} }}
className="whitespace-nowrap"
> >
{permissions === true {permissions === true
? t("share_and_collaborate") ? t("share_and_collaborate")
@ -113,6 +115,7 @@ export default function CollectionCard({
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setDeleteCollectionModal(true); setDeleteCollectionModal(true);
}} }}
className="whitespace-nowrap"
> >
{permissions === true {permissions === true
? t("delete_collection") ? t("delete_collection")
@ -155,11 +158,9 @@ export default function CollectionCard({
<Link <Link
href={`/collections/${collection.id}`} href={`/collections/${collection.id}`}
style={{ style={{
backgroundImage: `linear-gradient(45deg, ${collection.color}30 10%, ${ backgroundImage: `linear-gradient(45deg, ${collection.color}30 10%, ${settings.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
settings.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))" } 50%, ${settings.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
} 50%, ${ } 100%)`,
settings.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
} 100%)`,
}} }}
className="card card-compact shadow-md hover:shadow-none duration-200 border border-neutral-content" className="card card-compact shadow-md hover:shadow-none duration-200 border border-neutral-content"
> >

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

@ -29,7 +29,7 @@ export default function FilterSearchDropdown({
> >
<i className="bi-funnel text-neutral text-2xl"></i> <i className="bi-funnel text-neutral text-2xl"></i>
</div> </div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-56 mt-1"> <ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1">
<li> <li>
<label <label
className="label cursor-pointer flex justify-start" className="label cursor-pointer flex justify-start"
@ -45,7 +45,7 @@ export default function FilterSearchDropdown({
setSearchFilter({ ...searchFilter, name: !searchFilter.name }) setSearchFilter({ ...searchFilter, name: !searchFilter.name })
} }
/> />
<span className="label-text">{t("name")}</span> <span className="label-text whitespace-nowrap">{t("name")}</span>
</label> </label>
</li> </li>
<li> <li>
@ -63,7 +63,7 @@ export default function FilterSearchDropdown({
setSearchFilter({ ...searchFilter, url: !searchFilter.url }) setSearchFilter({ ...searchFilter, url: !searchFilter.url })
} }
/> />
<span className="label-text">{t("link")}</span> <span className="label-text whitespace-nowrap">{t("link")}</span>
</label> </label>
</li> </li>
<li> <li>
@ -84,7 +84,9 @@ export default function FilterSearchDropdown({
}) })
} }
/> />
<span className="label-text">{t("description")}</span> <span className="label-text whitespace-nowrap">
{t("description")}
</span>
</label> </label>
</li> </li>
<li> <li>
@ -102,7 +104,7 @@ export default function FilterSearchDropdown({
setSearchFilter({ ...searchFilter, tags: !searchFilter.tags }) setSearchFilter({ ...searchFilter, tags: !searchFilter.tags })
} }
/> />
<span className="label-text">{t("tags")}</span> <span className="label-text whitespace-nowrap">{t("tags")}</span>
</label> </label>
</li> </li>
<li> <li>
@ -123,8 +125,10 @@ export default function FilterSearchDropdown({
}) })
} }
/> />
<span className="label-text">{t("full_content")}</span> <span className="label-text whitespace-nowrap">
<div className="ml-auto badge badge-sm badge-neutral"> {t("full_content")}
</span>
<div className="ml-auto badge badge-sm badge-neutral whitespace-nowrap">
{t("slower")} {t("slower")}
</div> </div>
</label> </label>

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 (
@ -92,7 +86,7 @@ export default function LinkActions({
<i title="More" className="bi-three-dots text-xl" /> <i title="More" className="bi-three-dots text-xl" />
</div> </div>
<ul <ul
className={`dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1 ${ className={`dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box mr-1 ${
alignToTop ? "" : "translate-y-10" alignToTop ? "" : "translate-y-10"
}`} }`}
> >
@ -104,6 +98,7 @@ export default function LinkActions({
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
pinLink(); pinLink();
}} }}
className="whitespace-nowrap"
> >
{link?.pinnedBy && link.pinnedBy[0] {link?.pinnedBy && link.pinnedBy[0]
? t("unpin") ? t("unpin")
@ -119,6 +114,7 @@ export default function LinkActions({
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
toggleShowInfo(); toggleShowInfo();
}} }}
className="whitespace-nowrap"
> >
{!linkInfo ? t("show_link_details") : t("hide_link_details")} {!linkInfo ? t("show_link_details") : t("hide_link_details")}
</div> </div>
@ -133,6 +129,7 @@ export default function LinkActions({
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setEditLinkModal(true); setEditLinkModal(true);
}} }}
className="whitespace-nowrap"
> >
{t("edit_link")} {t("edit_link")}
</div> </div>
@ -147,6 +144,7 @@ export default function LinkActions({
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setPreservedFormatsModal(true); setPreservedFormatsModal(true);
}} }}
className="whitespace-nowrap"
> >
{t("preserved_formats")} {t("preserved_formats")}
</div> </div>
@ -157,10 +155,27 @@ 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);
}} }}
className="whitespace-nowrap"
> >
{t("delete")} {t("delete")}
</div> </div>
@ -184,7 +199,7 @@ export default function LinkActions({
{preservedFormatsModal && ( {preservedFormatsModal && (
<PreservedFormatsModal <PreservedFormatsModal
onClose={() => setPreservedFormatsModal(false)} onClose={() => setPreservedFormatsModal(false)}
activeLink={link} link={link}
/> />
)} )}
{/* {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

@ -41,7 +41,7 @@ export default function MobileNavigation({}: Props) {
<i className="bi-plus text-5xl pointer-events-none"></i> <i className="bi-plus text-5xl pointer-events-none"></i>
</span> </span>
</div> </div>
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mb-1 -ml-12"> <ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box mb-1 -ml-12">
<li> <li>
<div <div
onClick={() => { onClick={() => {
@ -50,6 +50,7 @@ export default function MobileNavigation({}: Props) {
}} }}
tabIndex={0} tabIndex={0}
role="button" role="button"
className="whitespace-nowrap"
> >
{t("new_link")} {t("new_link")}
</div> </div>
@ -62,6 +63,7 @@ export default function MobileNavigation({}: Props) {
}} }}
tabIndex={0} tabIndex={0}
role="button" role="button"
className="whitespace-nowrap"
> >
{t("upload_file")} {t("upload_file")}
</div> </div>
@ -74,6 +76,7 @@ export default function MobileNavigation({}: Props) {
}} }}
tabIndex={0} tabIndex={0}
role="button" role="button"
className="whitespace-nowrap"
> >
{t("new_collection")} {t("new_collection")}
</div> </div>

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,6 +1,5 @@
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 { import {
AccountSettings, AccountSettings,
@ -8,13 +7,14 @@ import {
Member, Member,
} from "@/types/global"; } 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;
@ -31,7 +31,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) {
@ -40,24 +40,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);
@ -163,7 +165,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,
@ -175,7 +177,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,
@ -273,7 +275,7 @@ export default function EditCollectionSharingModal({
{roleLabel} {roleLabel}
<i className="bi-chevron-down"></i> <i className="bi-chevron-down"></i>
</div> </div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl w-64 mt-1"> <ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl mt-1">
<li> <li>
<label <label
className="label cursor-pointer flex justify-start" className="label cursor-pointer flex justify-start"
@ -312,10 +314,12 @@ export default function EditCollectionSharingModal({
}} }}
/> />
<div> <div>
<p className="font-bold"> <p className="font-bold whitespace-nowrap">
{t("viewer")} {t("viewer")}
</p> </p>
<p>{t("viewer_desc")}</p> <p className="whitespace-nowrap">
{t("viewer_desc")}
</p>
</div> </div>
</label> </label>
</li> </li>
@ -357,10 +361,12 @@ export default function EditCollectionSharingModal({
}} }}
/> />
<div> <div>
<p className="font-bold"> <p className="font-bold whitespace-nowrap">
{t("contributor")} {t("contributor")}
</p> </p>
<p>{t("contributor_desc")}</p> <p className="whitespace-nowrap">
{t("contributor_desc")}
</p>
</div> </div>
</label> </label>
</li> </li>
@ -402,10 +408,12 @@ export default function EditCollectionSharingModal({
}} }}
/> />
<div> <div>
<p className="font-bold"> <p className="font-bold whitespace-nowrap">
{t("admin")} {t("admin")}
</p> </p>
<p>{t("admin_desc")}</p> <p className="whitespace-nowrap">
{t("admin_desc")}
</p>
</div> </div>
</label> </label>
</li> </li>

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);
} }
@ -111,7 +115,7 @@ export default function NewTokenModal({ onClose }: Props) {
> >
{getLabel(token.expires)} {getLabel(token.expires)}
</Button> </Button>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl w-full sm:w-52 mt-1"> <ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl mt-1">
<li> <li>
<label <label
className="label cursor-pointer flex justify-start" className="label cursor-pointer flex justify-start"
@ -131,7 +135,9 @@ export default function NewTokenModal({ onClose }: Props) {
}); });
}} }}
/> />
<span className="label-text">{t("7_days")}</span> <span className="label-text whitespace-nowrap">
{t("7_days")}
</span>
</label> </label>
</li> </li>
<li> <li>
@ -150,7 +156,9 @@ export default function NewTokenModal({ onClose }: Props) {
setToken({ ...token, expires: TokenExpiry.oneMonth }); setToken({ ...token, expires: TokenExpiry.oneMonth });
}} }}
/> />
<span className="label-text">{t("30_days")}</span> <span className="label-text whitespace-nowrap">
{t("30_days")}
</span>
</label> </label>
</li> </li>
<li> <li>
@ -172,7 +180,9 @@ export default function NewTokenModal({ onClose }: Props) {
}); });
}} }}
/> />
<span className="label-text">{t("60_days")}</span> <span className="label-text whitespace-nowrap">
{t("60_days")}
</span>
</label> </label>
</li> </li>
<li> <li>
@ -194,7 +204,9 @@ export default function NewTokenModal({ onClose }: Props) {
}); });
}} }}
/> />
<span className="label-text">{t("90_days")}</span> <span className="label-text whitespace-nowrap">
{t("90_days")}
</span>
</label> </label>
</li> </li>
<li> <li>
@ -213,7 +225,9 @@ export default function NewTokenModal({ onClose }: Props) {
setToken({ ...token, expires: TokenExpiry.never }); setToken({ ...token, expires: TokenExpiry.never });
}} }}
/> />
<span className="label-text">{t("no_expiration")}</span> <span className="label-text whitespace-nowrap">
{t("no_expiration")}
</span>
</label> </label>
</li> </li>
</ul> </ul>

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,
@ -17,23 +16,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;
@ -44,20 +42,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, id: user.id as number,
name: account.name, name: user.name,
username: account.username, username: user.username as string,
image: account.image, image: user.image as string,
archiveAsScreenshot: account.archiveAsScreenshot, archiveAsScreenshot: user.archiveAsScreenshot as boolean,
archiveAsMonolith: account.archiveAsScreenshot, archiveAsMonolith: user.archiveAsScreenshot as boolean,
archiveAsPDF: account.archiveAsPDF, archiveAsPDF: user.archiveAsPDF as boolean,
}); });
} }
}; };
@ -93,20 +91,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: NodeJS.Timeout | null = null; let interval: NodeJS.Timeout | null = null;
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) {
@ -132,10 +124,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);
}; };
@ -145,9 +135,9 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
<p className="text-xl font-thin">{t("preserved_formats")}</p> <p className="text-xl font-thin">{t("preserved_formats")}</p>
<div className="divider mb-2 mt-1"></div> <div className="divider mb-2 mt-1"></div>
{screenshotAvailable(link) || {screenshotAvailable(link) ||
pdfAvailable(link) || pdfAvailable(link) ||
readabilityAvailable(link) || readabilityAvailable(link) ||
monolithAvailable(link) ? ( monolithAvailable(link) ? (
<p className="mb-3">{t("available_formats")}</p> <p className="mb-3">{t("available_formats")}</p>
) : ( ) : (
"" ""
@ -159,7 +149,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}
/> />
)} )}
@ -173,7 +163,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
? ArchivedFormat.png ? ArchivedFormat.png
: ArchivedFormat.jpeg : ArchivedFormat.jpeg
} }
activeLink={link} link={link}
downloadable={true} downloadable={true}
/> />
)} )}
@ -183,7 +173,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}
/> />
)} )}
@ -193,7 +183,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}
/> />
)} )}
@ -224,9 +214,8 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
)} )}
<div <div
className={`flex flex-col sm:flex-row gap-3 items-center justify-center ${ className={`flex flex-col sm:flex-row gap-3 items-center justify-center ${isReady() ? "sm:mt " : ""
isReady() ? "sm:mt " : "" }`}
}`}
> >
<Link <Link
href={`https://web.archive.org/web/${link?.url?.replace( href={`https://web.archive.org/web/${link?.url?.replace(

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

@ -66,7 +66,7 @@ export default function Navbar() {
</span> </span>
</div> </div>
</div> </div>
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1"> <ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1">
<li> <li>
<div <div
onClick={() => { onClick={() => {
@ -75,6 +75,7 @@ export default function Navbar() {
}} }}
tabIndex={0} tabIndex={0}
role="button" role="button"
className="whitespace-nowrap"
> >
{t("new_link")} {t("new_link")}
</div> </div>
@ -87,6 +88,7 @@ export default function Navbar() {
}} }}
tabIndex={0} tabIndex={0}
role="button" role="button"
className="whitespace-nowrap"
> >
{t("upload_file")} {t("upload_file")}
</div> </div>
@ -99,6 +101,7 @@ export default function Navbar() {
}} }}
tabIndex={0} tabIndex={0}
role="button" role="button"
className="whitespace-nowrap"
> >
{t("new_collection")} {t("new_collection")}
</div> </div>

View File

@ -1,5 +1,3 @@
import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import { import {
ArchivedFormat, ArchivedFormat,
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
@ -11,7 +9,7 @@ type Props = {
name: string; name: string;
icon: string; icon: string;
format: ArchivedFormat; format: ArchivedFormat;
activeLink: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
downloadable?: boolean; downloadable?: boolean;
}; };
@ -19,43 +17,13 @@ export default function PreservedFormatRow({
name, name,
icon, icon,
format, format,
activeLink, link,
downloadable, downloadable,
}: Props) { }: Props) {
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 LinkIncludingShortenedCollectionAndTags);
})();
let interval: NodeJS.Timeout | null = null;
if (link?.image === "pending" || link?.pdf === "pending") {
interval = setInterval(async () => {
const { data } = await getLink(link.id as number, isPublic);
setLink(data 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)
@ -90,20 +58,18 @@ export default function PreservedFormatRow({
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
{downloadable || {downloadable || false ? (
(false && ( <div
<div onClick={() => handleDownload()}
onClick={() => handleDownload()} className="btn btn-sm btn-square"
className="btn btn-sm btn-square" >
> <i className="bi-cloud-arrow-down text-xl text-neutral" />
<i className="bi-cloud-arrow-down text-xl text-neutral" /> </div>
</div> ) : undefined}
))}
<Link <Link
href={`${ href={`${isPublic ? "/public" : ""
isPublic ? "/public" : "" }/preserved/${link?.id}?format=${format}`}
}/preserved/${link?.id}?format=${format}`}
target="_blank" target="_blank"
className="btn btn-sm btn-square" className="btn btn-sm btn-square"
> >

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,14 +27,12 @@ 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>
<ul <ul
className={`dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box ${ className={`dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1`}
isAdmin ? "w-48" : "w-40"
} mt-1`}
> >
<li> <li>
<Link <Link
@ -42,6 +40,7 @@ export default function ProfileDropdown() {
onClick={() => (document?.activeElement as HTMLElement)?.blur()} onClick={() => (document?.activeElement as HTMLElement)?.blur()}
tabIndex={0} tabIndex={0}
role="button" role="button"
className="whitespace-nowrap"
> >
{t("settings")} {t("settings")}
</Link> </Link>
@ -54,6 +53,7 @@ export default function ProfileDropdown() {
}} }}
tabIndex={0} tabIndex={0}
role="button" role="button"
className="whitespace-nowrap"
> >
{t("switch_to", { {t("switch_to", {
theme: settings.theme === "light" ? t("dark") : t("light"), theme: settings.theme === "light" ? t("dark") : t("light"),
@ -67,6 +67,7 @@ export default function ProfileDropdown() {
onClick={() => (document?.activeElement as HTMLElement)?.blur()} onClick={() => (document?.activeElement as HTMLElement)?.blur()}
tabIndex={0} tabIndex={0}
role="button" role="button"
className="whitespace-nowrap"
> >
{t("server_administration")} {t("server_administration")}
</Link> </Link>
@ -80,6 +81,7 @@ export default function ProfileDropdown() {
}} }}
tabIndex={0} tabIndex={0}
role="button" role="button"
className="whitespace-nowrap"
> >
{t("logout")} {t("logout")}
</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: NodeJS.Timeout | null = null; let interval: NodeJS.Timeout | null = null;
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

@ -14,6 +14,7 @@ export default function SidebarHighlightLink({
return ( return (
<Link href={href}> <Link href={href}>
<div <div
title={title}
className={`${ className={`${
active || false active || false
? "bg-primary/20" ? "bg-primary/20"
@ -28,7 +29,7 @@ export default function SidebarHighlightLink({
<i className={`${icon} text-primary text-2xl drop-shadow`}></i> <i className={`${icon} text-primary text-2xl drop-shadow`}></i>
</div> </div>
<div className={"mt-1"}> <div className={"mt-1"}>
<p className="truncate w-full font-semibold text-sm">{title}</p> <p className="truncate w-full font-semibold text-xs">{title}</p>
</div> </div>
</div> </div>
</Link> </Link>

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
@ -20,7 +27,7 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
> >
<i className="bi-chevron-expand text-neutral text-2xl"></i> <i className="bi-chevron-expand text-neutral text-2xl"></i>
</div> </div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl w-52 mt-1"> <ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl mt-1">
<li> <li>
<label <label
className="label cursor-pointer flex justify-start" className="label cursor-pointer flex justify-start"
@ -34,7 +41,9 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
checked={sortBy === Sort.DateNewestFirst} checked={sortBy === Sort.DateNewestFirst}
onChange={() => setSort(Sort.DateNewestFirst)} onChange={() => setSort(Sort.DateNewestFirst)}
/> />
<span className="label-text">{t("date_newest_first")}</span> <span className="label-text whitespace-nowrap">
{t("date_newest_first")}
</span>
</label> </label>
</li> </li>
<li> <li>
@ -50,7 +59,9 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
checked={sortBy === Sort.DateOldestFirst} checked={sortBy === Sort.DateOldestFirst}
onChange={() => setSort(Sort.DateOldestFirst)} onChange={() => setSort(Sort.DateOldestFirst)}
/> />
<span className="label-text">{t("date_oldest_first")}</span> <span className="label-text whitespace-nowrap">
{t("date_oldest_first")}
</span>
</label> </label>
</li> </li>
<li> <li>
@ -66,7 +77,7 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
checked={sortBy === Sort.NameAZ} checked={sortBy === Sort.NameAZ}
onChange={() => setSort(Sort.NameAZ)} onChange={() => setSort(Sort.NameAZ)}
/> />
<span className="label-text">{t("name_az")}</span> <span className="label-text whitespace-nowrap">{t("name_az")}</span>
</label> </label>
</li> </li>
<li> <li>
@ -82,7 +93,7 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
checked={sortBy === Sort.NameZA} checked={sortBy === Sort.NameZA}
onChange={() => setSort(Sort.NameZA)} onChange={() => setSort(Sort.NameZA)}
/> />
<span className="label-text">{t("name_za")}</span> <span className="label-text whitespace-nowrap">{t("name_za")}</span>
</label> </label>
</li> </li>
<li> <li>
@ -98,7 +109,9 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
checked={sortBy === Sort.DescriptionAZ} checked={sortBy === Sort.DescriptionAZ}
onChange={() => setSort(Sort.DescriptionAZ)} onChange={() => setSort(Sort.DescriptionAZ)}
/> />
<span className="label-text">{t("description_az")}</span> <span className="label-text whitespace-nowrap">
{t("description_az")}
</span>
</label> </label>
</li> </li>
<li> <li>
@ -114,7 +127,9 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
checked={sortBy === Sort.DescriptionZA} checked={sortBy === Sort.DescriptionZA}
onChange={() => setSort(Sort.DescriptionZA)} onChange={() => setSort(Sort.DescriptionZA)}
/> />
<span className="label-text">{t("description_za")}</span> <span className="label-text whitespace-nowrap">
{t("description_za")}
</span>
</label> </label>
</li> </li>
</ul> </ul>

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,11 +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(() => {
@ -14,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 (
@ -24,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

@ -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,11 +67,19 @@ 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];
(a, b) => new Date(b.id).getTime() - new Date(a.id).getTime()
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)
); );
return { response: links, status: 200 }; return { response: links, status: 200 };

View File

@ -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

@ -86,9 +86,11 @@ export default async function postLink(
else if (contentType === "image/png") imageExtension = "png"; else if (contentType === "image/png") imageExtension = "png";
} }
if (!link.tags) link.tags = [];
const newLink = await prisma.link.create({ const newLink = await prisma.link.create({
data: { data: {
url: link.url?.trim().replace(/\/+$/, "") || null, url: link.url?.trim() || null,
name, name,
description: link.description, description: link.description,
type: linkType, type: linkType,
@ -98,7 +100,7 @@ export default async function postLink(
}, },
}, },
tags: { tags: {
connectOrCreate: link.tags.map((tag) => ({ connectOrCreate: link.tags?.map((tag) => ({
where: { where: {
name_ownerId: { name_ownerId: {
name: tag.name.trim(), name: tag.name.trim(),

View File

@ -49,12 +49,18 @@ export default async function postUser(
// Check username (if email was disabled) // Check username (if email was disabled)
const checkUsername = RegExp("^[a-z0-9_-]{3,31}$"); const checkUsername = RegExp("^[a-z0-9_-]{3,31}$");
if (!emailEnabled && !checkUsername.test(body.username?.toLowerCase() || ""))
const autoGeneratedUsername = "user" + Math.round(Math.random() * 1000000000);
if (body.username && !checkUsername.test(body.username?.toLowerCase()))
return { return {
response: response:
"Username has to be between 3-30 characters, no spaces and special characters are allowed.", "Username has to be between 3-30 characters, no spaces and special characters are allowed.",
status: 400, status: 400,
}; };
else if (!body.username) {
body.username = autoGeneratedUsername;
}
const checkIfUserExists = await prisma.user.findFirst({ const checkIfUserExists = await prisma.user.findFirst({
where: { where: {
@ -89,7 +95,8 @@ export default async function postUser(
data: { data: {
name: body.name, name: body.name,
username: emailEnabled username: emailEnabled
? autoGeneratedUsername ? (body.username as string).toLowerCase().trim() ||
autoGeneratedUsername
: (body.username as string).toLowerCase().trim(), : (body.username as string).toLowerCase().trim(),
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined, email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
password: hashedPassword, password: hashedPassword,

View File

@ -24,7 +24,12 @@ const generatePreview = async (
1024 * 1024 * Number(process.env.PREVIEW_MAX_BUFFER || 0.1) 1024 * 1024 * Number(process.env.PREVIEW_MAX_BUFFER || 0.1)
) { ) {
console.log("Error generating preview: Buffer size exceeded"); console.log("Error generating preview: Buffer size exceeded");
return; return prisma.link.update({
where: { id: linkId },
data: {
preview: "unavailable",
},
});
} }
await createFile({ await createFile({

View File

@ -9,6 +9,9 @@ const getPublicCollectionData = async (
) => { ) => {
const res = await fetch("/api/v1/public/collections/" + collectionId); const res = await fetch("/api/v1/public/collections/" + collectionId);
if (res.status === 400)
return { response: "Collection not found.", status: 400 };
const data = await res.json(); const data = await res.json();
setData(data.response); setData(data.response);

View File

@ -2,7 +2,7 @@
module.exports = { module.exports = {
i18n: { i18n: {
defaultLocale: "en", defaultLocale: "en",
locales: ["en", "it"], locales: ["en", "it", "fr", "zh"],
}, },
reloadOnPrerender: process.env.NODE_ENV === "development", reloadOnPrerender: process.env.NODE_ENV === "development",
}; };

View File

@ -1,6 +1,6 @@
{ {
"name": "linkwarden", "name": "linkwarden",
"version": "v2.6.2", "version": "v2.7.0",
"main": "index.js", "main": "index.js",
"repository": "https://github.com/linkwarden/linkwarden.git", "repository": "https://github.com/linkwarden/linkwarden.git",
"author": "Daniel31X13 <daniel31x13@gmail.com>", "author": "Daniel31X13 <daniel31x13@gmail.com>",
@ -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

@ -1186,7 +1186,7 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
providerAccountId: account?.providerAccountId, providerAccountId: account?.providerAccountId,
}, },
}); });
if (existingUser && newSsoUsersDisabled) { if (!existingUser && newSsoUsersDisabled) {
return false; return false;
} }
} }

View File

@ -1,5 +1,3 @@
import useCollectionStore from "@/store/collections";
import useLinkStore from "@/store/links";
import { import {
AccountSettings, AccountSettings,
CollectionIncludingMembersAndLinkCount, CollectionIncludingMembersAndLinkCount,
@ -10,23 +8,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();
@ -34,25 +31,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<
Partial<AccountSettings> Partial<AccountSettings>
@ -60,20 +61,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, username: user.username as string,
image: account.image, image: user.image as string,
archiveAsScreenshot: account.archiveAsScreenshot, archiveAsScreenshot: user.archiveAsScreenshot as boolean,
archiveAsMonolith: account.archiveAsScreenshot, archiveAsMonolith: user.archiveAsScreenshot as boolean,
archiveAsPDF: account.archiveAsPDF, archiveAsPDF: user.archiveAsPDF as boolean,
}); });
} }
}; };
@ -92,27 +93,17 @@ 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
className="h-[60rem] p-5 flex gap-3 flex-col" className="h-[60rem] p-5 flex gap-3 flex-col"
style={{ style={{
backgroundImage: `linear-gradient(${activeCollection?.color}20 10%, ${ backgroundImage: `linear-gradient(${activeCollection?.color}20 10%, ${settings.theme === "dark" ? "#262626" : "#f3f4f6"
settings.theme === "dark" ? "#262626" : "#f3f4f6" } 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
} 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
}} }}
> >
{activeCollection && ( {activeCollection && (
@ -137,7 +128,7 @@ export default function Index() {
> >
<i className="bi-three-dots text-xl" title="More"></i> <i className="bi-three-dots text-xl" title="More"></i>
</div> </div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1"> <ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1">
{permissions === true && ( {permissions === true && (
<li> <li>
<div <div
@ -147,6 +138,7 @@ export default function Index() {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setEditCollectionModal(true); setEditCollectionModal(true);
}} }}
className="whitespace-nowrap"
> >
{t("edit_collection_info")} {t("edit_collection_info")}
</div> </div>
@ -160,6 +152,7 @@ export default function Index() {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setEditCollectionSharingModal(true); setEditCollectionSharingModal(true);
}} }}
className="whitespace-nowrap"
> >
{permissions === true {permissions === true
? t("share_and_collaborate") ? t("share_and_collaborate")
@ -175,6 +168,7 @@ export default function Index() {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setNewCollectionModal(true); setNewCollectionModal(true);
}} }}
className="whitespace-nowrap"
> >
{t("create_subcollection")} {t("create_subcollection")}
</div> </div>
@ -188,6 +182,7 @@ export default function Index() {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setDeleteCollectionModal(true); setDeleteCollectionModal(true);
}} }}
className="whitespace-nowrap"
> >
{permissions === true {permissions === true
? t("delete_collection") ? t("delete_collection")
@ -236,20 +231,20 @@ export default function Index() {
<p className="text-neutral text-sm"> <p className="text-neutral text-sm">
{activeCollection.members.length > 0 && {activeCollection.members.length > 0 &&
activeCollection.members.length === 1 activeCollection.members.length === 1
? t("by_author_and_other", { ? t("by_author_and_other", {
author: collectionOwner.name,
count: activeCollection.members.length,
})
: activeCollection.members.length > 0 &&
activeCollection.members.length !== 1
? t("by_author_and_others", {
author: collectionOwner.name, author: collectionOwner.name,
count: activeCollection.members.length, count: activeCollection.members.length,
}) })
: activeCollection.members.length > 0 &&
activeCollection.members.length !== 1
? t("by_author_and_others", {
author: collectionOwner.name,
count: activeCollection.members.length,
})
: t("by_author", { : t("by_author", {
author: collectionOwner.name, author: collectionOwner.name,
})} })}
</p> </p>
</div> </div>
</div> </div>
@ -294,15 +289,15 @@ export default function Index() {
setSortBy={setSortBy} setSortBy={setSortBy}
editMode={ editMode={
permissions === true || permissions === true ||
permissions?.canUpdate || permissions?.canUpdate ||
permissions?.canDelete permissions?.canDelete
? editMode ? editMode
: undefined : undefined
} }
setEditMode={ setEditMode={
permissions === true || permissions === true ||
permissions?.canUpdate || permissions?.canUpdate ||
permissions?.canDelete permissions?.canDelete
? setEditMode ? setEditMode
: undefined : undefined
} }
@ -310,24 +305,22 @@ export default function Index() {
<p> <p>
{activeCollection?._count?.links === 1 {activeCollection?._count?.links === 1
? t("showing_count_result", { ? t("showing_count_result", {
count: activeCollection?._count?.links, count: activeCollection?._count?.links,
}) })
: t("showing_count_results", { : t("showing_count_results", {
count: activeCollection?._count?.links, count: activeCollection?._count?.links,
})} })}
</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

@ -43,6 +43,12 @@ export default function EmailConfirmaion() {
<div className="divider my-3"></div> <div className="divider my-3"></div>
{router.query.email && typeof router.query.email === "string" && (
<p className="text-center font-bold mb-3 break-all">
{decodeURIComponent(router.query.email)}
</p>
)}
<p>{t("verification_email_sent_desc")}</p> <p>{t("verification_email_sent_desc")}</p>
<div className="mx-auto w-fit mt-3"> <div className="mx-auto w-fit mt-3">

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(
@ -102,20 +97,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">
@ -174,12 +159,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">
@ -214,13 +217,14 @@ export default function Dashboard() {
<i className="bi-cloud-upload text-xl duration-100"></i> <i className="bi-cloud-upload text-xl duration-100"></i>
<p>{t("import_links")}</p> <p>{t("import_links")}</p>
</div> </div>
<ul className="shadow menu dropdown-content z-[1] bg-base-200 border border-neutral-content rounded-box mt-1 w-60"> <ul className="shadow menu dropdown-content z-[1] bg-base-200 border border-neutral-content rounded-box mt-1">
<li> <li>
<label <label
tabIndex={0} tabIndex={0}
role="button" role="button"
htmlFor="import-linkwarden-file" htmlFor="import-linkwarden-file"
title={t("from_linkwarden")} title={t("from_linkwarden")}
className="whitespace-nowrap"
> >
{t("from_linkwarden")} {t("from_linkwarden")}
<input <input
@ -241,6 +245,7 @@ export default function Dashboard() {
role="button" role="button"
htmlFor="import-html-file" htmlFor="import-html-file"
title={t("from_html")} title={t("from_html")}
className="whitespace-nowrap"
> >
{t("from_html")} {t("from_html")}
<input <input
@ -261,6 +266,7 @@ export default function Dashboard() {
role="button" role="button"
htmlFor="import-wallabag-file" htmlFor="import-wallabag-file"
title={t("from_wallabag")} title={t("from_wallabag")}
className="whitespace-nowrap"
> >
{t("from_wallabag")} {t("from_wallabag")}
<input <input
@ -303,12 +309,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,11 +51,16 @@ export default function Links() {
/> />
</LinkListOptions> </LinkListOptions>
{links[0] ? ( {!data.isLoading && links && !links[0] && (
<LinkComponent editMode={editMode} links={links} />
) : (
<NoLinksFound text={t("you_have_not_added_any_links")} /> <NoLinksFound text={t("you_have_not_added_any_links")} />
)} )}
<Links
editMode={editMode}
links={links}
layout={viewMode}
placeholderCount={1}
useData={data}
/>
</div> </div>
</MainLayout> </MainLayout>
); );

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,7 @@ export default function PinnedLinks() {
/> />
</LinkListOptions> </LinkListOptions>
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? ( {!data.isLoading && links && !links[0] && (
<LinkComponent editMode={editMode} links={links} />
) : (
<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"
@ -82,6 +67,13 @@ export default function PinnedLinks() {
</p> </p>
</div> </div>
)} )}
<Links
editMode={editMode}
links={links}
layout={viewMode}
placeholderCount={1}
useData={data}
/>
</div> </div>
</MainLayout> </MainLayout>
); );

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

@ -9,8 +9,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";
@ -19,21 +17,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();
@ -49,9 +45,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)
@ -68,7 +66,13 @@ export default function PublicCollections() {
useEffect(() => { useEffect(() => {
if (router.query.id) { if (router.query.id) {
getPublicCollectionData(Number(router.query.id), setCollection); getPublicCollectionData(Number(router.query.id), setCollection).then(
(res) => {
if (res.status === 400) {
router.push("/dashboard");
}
}
);
} }
}, [collections]); }, [collections]);
@ -86,8 +90,8 @@ 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 = { const linkView = {
@ -105,9 +109,8 @@ export default function PublicCollections() {
<div <div
className="h-96" className="h-96"
style={{ style={{
backgroundImage: `linear-gradient(${collection?.color}30 10%, ${ backgroundImage: `linear-gradient(${collection?.color}30 10%, ${settings.theme === "dark" ? "#262626" : "#f3f4f6"
settings.theme === "dark" ? "#262626" : "#f3f4f6" } 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
} 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
}} }}
> >
{collection && ( {collection && (
@ -178,20 +181,20 @@ export default function PublicCollections() {
<p className="text-neutral text-sm"> <p className="text-neutral text-sm">
{collection.members.length > 0 && {collection.members.length > 0 &&
collection.members.length === 1 collection.members.length === 1
? t("by_author_and_other", { ? t("by_author_and_other", {
author: collectionOwner.name,
count: collection.members.length,
})
: collection.members.length > 0 &&
collection.members.length !== 1
? t("by_author_and_others", {
author: collectionOwner.name, author: collectionOwner.name,
count: collection.members.length, count: collection.members.length,
}) })
: collection.members.length > 0 &&
collection.members.length !== 1
? t("by_author_and_others", {
author: collectionOwner.name,
count: collection.members.length,
})
: t("by_author", { : t("by_author", {
author: collectionOwner.name, author: collectionOwner.name,
})} })}
</p> </p>
</div> </div>
</div> </div>
@ -215,28 +218,30 @@ export default function PublicCollections() {
placeholder={ placeholder={
collection._count?.links === 1 collection._count?.links === 1
? t("search_count_link", { ? t("search_count_link", {
count: collection._count?.links, count: collection._count?.links,
}) })
: t("search_count_links", { : t("search_count_links", {
count: collection._count?.links, count: collection._count?.links,
}) })
} }
/> />
</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,24 +73,14 @@ export default function Search() {
<PageHeader icon={"bi-search"} title={"Search Results"} /> <PageHeader icon={"bi-search"} title={"Search Results"} />
</LinkListOptions> </LinkListOptions>
{!isLoading && !links[0] ? ( {!data.isLoading && links && !links[0] && <p>{t("nothing_found")}</p>}
<p>{t("nothing_found")}</p> <Links
) : links[0] ? ( editMode={editMode}
<LinkComponent links={links}
editMode={editMode} layout={viewMode}
links={links} placeholderCount={1}
isLoading={isLoading} useData={data}
/> />
) : (
isLoading && (
<GridLoader
color="oklch(var(--p))"
loading={true}
size={20}
className="m-auto py-10"
/>
)
)}
</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, ChangeEvent } from "react"; import { useState, useEffect, ChangeEvent } 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,24 +24,25 @@ 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
: ({ : ({
// @ts-ignore // @ts-ignore
id: null, id: null,
name: "", name: "",
username: "", username: "",
email: "", email: "",
emailVerified: null, emailVerified: null,
password: undefined, password: undefined,
image: "", image: "",
isPrivate: true, isPrivate: true,
// @ts-ignore // @ts-ignore
createdAt: null, createdAt: null,
whitelistedUsers: [], whitelistedUsers: [],
} as unknown as AccountSettings) } as unknown as AccountSettings)
); );
const { t } = useTranslation(); const { t } = useTranslation();
@ -80,25 +81,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);
}; };
@ -195,17 +209,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} className="capitalize">
key={locale}
value={locale}
selected={user.locale === locale}
>
{new Intl.DisplayNames(locale, { type: "language" }).of( {new Intl.DisplayNames(locale, { type: "language" }).of(
locale locale
) || ""} ) || ""}
@ -237,9 +248,13 @@ export default function Account() {
<i className="bi-pencil-square text-md duration-100"></i> <i className="bi-pencil-square text-md duration-100"></i>
{t("edit")} {t("edit")}
</Button> </Button>
<ul className="shadow menu dropdown-content z-[1] bg-base-200 border border-neutral-content rounded-box mt-1 w-60"> <ul className="shadow menu dropdown-content z-[1] bg-base-200 border border-neutral-content rounded-box mt-1">
<li> <li>
<label tabIndex={0} role="button"> <label
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("upload_new_photo")} {t("upload_new_photo")}
<input <input
type="file" type="file"
@ -262,6 +277,7 @@ export default function Account() {
image: "", image: "",
}) })
} }
className="whitespace-nowrap"
> >
{t("remove_photo")} {t("remove_photo")}
</div> </div>
@ -336,13 +352,14 @@ export default function Account() {
{t("import_links")} {t("import_links")}
</Button> </Button>
<ul className="shadow menu dropdown-content z-[1] bg-base-200 border border-neutral-content rounded-box mt-1 w-60"> <ul className="shadow menu dropdown-content z-[1] bg-base-200 border border-neutral-content rounded-box mt-1">
<li> <li>
<label <label
tabIndex={0} tabIndex={0}
role="button" role="button"
htmlFor="import-linkwarden-file" htmlFor="import-linkwarden-file"
title={t("from_linkwarden")} title={t("from_linkwarden")}
className="whitespace-nowrap"
> >
{t("from_linkwarden")} {t("from_linkwarden")}
<input <input
@ -363,6 +380,7 @@ export default function Account() {
role="button" role="button"
htmlFor="import-html-file" htmlFor="import-html-file"
title={t("from_html")} title={t("from_html")}
className="whitespace-nowrap"
> >
{t("from_html")} {t("from_html")}
<input <input
@ -383,6 +401,7 @@ export default function Account() {
role="button" role="button"
htmlFor="import-wallabag-file" htmlFor="import-wallabag-file"
title={t("from_wallabag")} title={t("from_wallabag")}
className="whitespace-nowrap"
> >
{t("from_wallabag")} {t("from_wallabag")}
<input <input

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">
@ -188,7 +194,7 @@ export default function Index() {
className={"bi-three-dots text-neutral text-2xl"} className={"bi-three-dots text-neutral text-2xl"}
></i> ></i>
</div> </div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-36 mt-1"> <ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1">
<li> <li>
<div <div
role="button" role="button"
@ -197,6 +203,7 @@ export default function Index() {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setRenameTag(true); setRenameTag(true);
}} }}
className="whitespace-nowrap"
> >
{t("rename_tag")} {t("rename_tag")}
</div> </div>
@ -209,6 +216,7 @@ export default function Index() {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
remove(); remove();
}} }}
className="whitespace-nowrap"
> >
{t("delete_tag")} {t("delete_tag")}
</div> </div>
@ -222,11 +230,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

@ -298,6 +298,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

@ -0,0 +1,374 @@
{
"user_administration": "Administration des utilisateurs",
"search_users": "Rechercher des utilisateurs",
"no_users_found": "Aucun utilisateur trouvé.",
"no_user_found_in_search": "Aucun utilisateur trouvé avec la requête de recherche donnée.",
"username": "Nom d'utilisateur",
"email": "E-mail",
"subscribed": "Abonné(e)",
"created_at": "Créé le",
"not_available": "S/O",
"check_your_email": "Veuillez vérifier votre adresse e-mail",
"authenticating": "Authentification...",
"verification_email_sent": "Envoi de l'email de vérification.",
"verification_email_sent_desc": "Un lien de connexion a été envoyé à votre adresse électronique. Si vous ne voyez pas l'e-mail, vérifiez votre dossier de courrier indésirable.",
"resend_email": "Renvoyez l'e-mail",
"invalid_credentials": "Informations d'identification invalides.",
"fill_all_fields": "Veuillez remplir tous les champs.",
"enter_credentials": "Entrez vos données d'identification",
"username_or_email": "Nom d'utilisateur ou e-mail",
"password": "Mot de passe",
"confirm_password": "Confirmer le mot de passe",
"forgot_password": "Mot de passe oublié ?",
"login": "Se connecter",
"or_continue_with": "Ou continuer avec",
"new_here": "Vous êtes nouveau ?",
"sign_up": "S'inscrire",
"sign_in_to_your_account": "Connectez-vous à votre compte",
"dashboard_desc": "Un bref aperçu de vos données",
"link": "Lien",
"links": "Liens",
"collection": "Collection",
"collections": "Collections",
"tag": "Étiquette",
"tags": "Étiquettes",
"recent": "Récent",
"recent_links_desc": "Liens récemment ajoutés",
"view_all": "Tout voir",
"view_added_links_here": "Consultez vos liens récemment ajoutés ici !",
"view_added_links_here_desc": "Cette section affichera vos derniers liens ajoutés dans toutes les collections auxquelles vous avez accès.",
"add_link": "Ajouter un nouveau lien",
"import_links": "Importer des liens",
"from_linkwarden": "De Linkwarden",
"from_html": "À partir d'un fichier HTML de signets",
"from_wallabag": "À partir de Wallabag (fichier JSON)",
"pinned": "Épinglé",
"pinned_links_desc": "Vos liens épinglés",
"pin_favorite_links_here": "Épinglez vos liens favoris ici !",
"pin_favorite_links_here_desc": "Vous pouvez épingler vos liens préférés en cliquant sur les trois points sur chaque lien et en cliquant sur Épingler au tableau de bord.",
"sending_password_link": "Envoi du lien de récupération du mot de passe...",
"password_email_prompt": "Entrez votre e-mail afin que nous puissions vous envoyer un lien pour créer un nouveau mot de passe.",
"send_reset_link": "Envoyer le lien de réinitialisation",
"reset_email_sent_desc": "Vérifiez votre e-mail pour trouver un lien permettant de réinitialiser votre mot de passe. S'il n'apparaît pas au bout de quelques minutes, vérifiez votre dossier spam.",
"back_to_login": "Retour à la page de connexion",
"email_sent": "Email envoyé!",
"passwords_mismatch": "Les mots de passe ne correspondent pas.",
"password_too_short": "Les mots de passe doivent comporter au moins 8 caractères.",
"creating_account": "Création d'un compte...",
"account_created": "Compte créé!",
"trial_offer_desc": "Débloquez {{count}} jours de service Premium sans frais !",
"register_desc": "Créer un nouveau compte",
"registration_disabled_desc": "L'inscription est désactivée pour cette instance, veuillez contacter l'administrateur en cas de problème.",
"enter_details": "Entrez vos coordonnées",
"display_name": "Nom affiché",
"sign_up_agreement": "En vous inscrivant, vous acceptez nos <0>Conditions d'utilisation</0> et notre <1>Politique de confidentialité</1>.",
"need_help": "Besoin d'aide?",
"get_in_touch": "Prenez contact avec nous",
"already_registered": "Vous avez déjà un compte?",
"deleting_selections": "Suppression des sélections...",
"links_deleted": "{{count}} Liens supprimés.",
"link_deleted": "1 Lien supprimé.",
"links_selected": "{{count}} Liens sélectionnés",
"link_selected": "1 Lien sélectionné",
"nothing_selected": "Rien de sélectionné",
"edit": "Modifier",
"delete": "Supprimer",
"nothing_found": "Rien n'a été trouvé.",
"redirecting_to_stripe": "Redirection vers Stripe...",
"subscribe_title": "Abonnez-vous à Linkwarden!",
"subscribe_desc": "Vous serez redirigé vers Stripe, n'hésitez pas à nous contacter à <0>support@linkwarden.app</0> en cas de problème.",
"monthly": "Mensuel",
"yearly": "Annuel",
"discount_percent": "{{percent}}% de réduction",
"billed_monthly": "Facturé mensuellement",
"billed_yearly": "Facturé annuellement",
"total": "Total",
"total_annual_desc": "{{count}}-jour d'essai gratuit, puis ${{annualPrice}} annuellement",
"total_monthly_desc": "{{count}}-jour d'essai gratuit, puis ${{monthlyPrice}} par mois",
"plus_tax": "+ TVA si applicable",
"complete_subscription": "Abonnement complet",
"sign_out": "Se déconnecter",
"access_tokens": "Jetons d'accès",
"access_tokens_description": "Les jetons d'accès peuvent être utilisés pour accéder à Linkwarden à partir d'autres applications et services sans divulguer votre nom d'utilisateur et votre mot de passe.",
"new_token": "Nouveau jeton d'accès",
"name": "Nom",
"created_success": "Créé!",
"created": "Créé",
"expires": "Expire",
"accountSettings": "Paramètres du compte",
"language": "Langue",
"profile_photo": "Photo de profil",
"upload_new_photo": "Envoyer une nouvelle photo...",
"remove_photo": "Supprimer la photo",
"make_profile_private": "Rendre le profil privé",
"profile_privacy_info": "Cela limitera le nombre de personnes pouvant vous trouver et vous ajouter à de nouvelles collections..",
"whitelisted_users": "Utilisateurs sur liste blanche",
"whitelisted_users_info": "Veuillez fournir le nom d'utilisateur des utilisateurs auxquels vous souhaitez accorder de la visibilité à votre profil. Séparé par une virgule.",
"whitelisted_users_placeholder": "Votre profil est actuellement caché à tout le monde...",
"save_changes": "Sauvegarder les modifications",
"import_export": "Importer & Exporter",
"import_data": "Importez vos données depuis d'autres plateformes.",
"download_data": "Téléchargez vos données instantanément.",
"export_data": "Exporter des données",
"delete_account": "Supprimer le compte",
"delete_account_warning": "Cela supprimera définitivement TOUS les liens, collections, balises et données archivées que vous possédez.",
"cancel_subscription_notice": "Cela annulera également votre abonnement.",
"account_deletion_page": "Page de suppression de compte",
"applying_settings": "Application des paramètres...",
"settings_applied": "Paramètres appliqués !",
"email_change_request": "Demande de modification par e-mail envoyée. Veuillez vérifier la nouvelle adresse e-mail.",
"image_upload_size_error": "Veuillez sélectionner un fichier PNG ou JPEG de moins de 1 Mo.",
"image_upload_format_error": "Format de fichier invalide.",
"importing_bookmarks": "Importer des favoris...",
"import_success": "Importation des signets ! Rechargement de la page...",
"more_coming_soon": "Plus à venir bientôt!",
"billing_settings": "Paramètres de facturation",
"manage_subscription_intro": "Pour gérer/annuler votre abonnement, visitez le",
"billing_portal": "Portail de facturation",
"help_contact_intro": "Si vous avez encore besoin d'aide ou rencontrez des problèmes, n'hésitez pas à nous contacter à l'adresse suivante :",
"fill_required_fields": "Veuillez remplir les champs requis.",
"deleting_message": "Tout supprimer, veuillez patienter...",
"delete_warning": "Cela supprimera définitivement tous les liens, collections, balises et données archivées que vous possédez. Cela vous déconnectera également. Cette action est irréversible !",
"optional": "Facultatif",
"feedback_help": "(mais ça nous aide vraiment à nous améliorer !)",
"reason_for_cancellation": "Raison pour l'annulation",
"please_specify": "Veuillez préciser",
"customer_service": "Service client",
"low_quality": "Qualité médiocre",
"missing_features": "Fonctionnalités manquantes",
"switched_service": "Service commuté",
"too_complex": "Trop compliqué",
"too_expensive": "Trop cher",
"unused": "Inutilisé",
"other": "Autre",
"more_information": "Plus d'informations (plus il y a de détails, plus cela serait utile)",
"feedback_placeholder": "Par exemple: J'avais besoin d'une fonctionnalité qui...",
"delete_your_account": "Supprimer votre compte",
"change_password": "Changer le mot de passe",
"password_length_error": "Les mots de passe doivent comporter au moins 8 caractères.",
"applying_changes": "Appliquer...",
"password_change_instructions": "Pour modifier votre mot de passe, veuillez remplir ce qui suit. Votre mot de passe doit comporter au moins 8 caractères.",
"old_password": "Ancien mot de passe",
"new_password": "Nouveau mot de passe",
"preference": "Préférence",
"select_theme": "Sélectionner un thème",
"dark": "Sombre",
"light": "Clair",
"archive_settings": "Paramètres d'archivage",
"formats_to_archive": "Formats pour archiver/préserver les pages Web:",
"screenshot": "Capture d'écran",
"pdf": "PDF",
"archive_org_snapshot": "Instantané d'Archive.org",
"link_settings": "Paramètres du lien",
"prevent_duplicate_links": "Empêcher les liens en double",
"clicking_on_links_should": "Cliquer sur les liens devrait:",
"open_original_content": "Ouvrir le contenu original",
"open_pdf_if_available": "Ouvrir le PDF, si disponible",
"open_readable_if_available": "Ouvrir Readable, si disponible",
"open_screenshot_if_available": "Ouvrez la capture d'écran, si disponible",
"open_webpage_if_available": "Ouvrir la copie de la page Web, si disponible",
"tag_renamed": "L'étiquette a été renommée !",
"tag_deleted": "Étiquette supprimée!",
"rename_tag": "Renommer l'étiquette",
"delete_tag": "Supprimer l'étiquette",
"list_created_with_linkwarden": "Liste créée avec Linkwarden",
"by_author": "Par {{author}}.",
"by_author_and_other": "Par {{author}} et {{count}} autre.",
"by_author_and_others": "Par {{author}} et {{count}} autres.",
"search_count_link": "Recherche {{count}} Lien",
"search_count_links": "Recherche {{count}} Liens",
"collection_is_empty": "Cette collection est vide...",
"all_links": "Tous les liens",
"all_links_desc": "Liens de chaque collection",
"you_have_not_added_any_links": "Vous n'avez pas encore créé de liens",
"collections_you_own": "Collections que vous possédez",
"new_collection": "Nouvelle collection",
"other_collections": "Autres collections",
"other_collections_desc": "Collections partagées dont vous êtes membre",
"showing_count_results": "Afficher {{count}} résultats",
"showing_count_result": "Afficher {{count}} résultat",
"edit_collection_info": "Modifier les informations sur la collection",
"share_and_collaborate": "Partager et collaborer",
"view_team": "Voir l'équipe",
"team": "L'équipe",
"create_subcollection": "Créer une sous-collection",
"delete_collection": "Supprimer la collection",
"leave_collection": "Quitter la collection",
"email_verified_signing_out": "Courriel vérifié. Déconnexion...",
"invalid_token": "Jeton non valide.",
"sending_password_recovery_link": "Envoi du lien de récupération du mot de passe...",
"please_fill_all_fields": "Veuillez remplir tous les champs.",
"password_updated": "Mot de passe mis à jour!",
"reset_password": "Réinitialiser le mot de passe",
"enter_email_for_new_password": "Saisissez votre adresse électronique afin que nous puissions vous envoyer un lien pour créer un nouveau mot de passe.",
"update_password": "Mise à jour du mot de passe",
"password_successfully_updated": "Votre mot de passe a été mis à jour avec succès.",
"user_already_member": "L'utilisateur existe déjà.",
"you_are_already_collection_owner": "Vous êtes déjà le propriétaire de la collection.",
"date_newest_first": "Date (la plus récente en premier)",
"date_oldest_first": "Date (la plus ancienne en premier)",
"name_az": "Nom (A-Z)",
"name_za": "Nom (Z-A)",
"description_az": "Description (A-Z)",
"description_za": "Description (Z-A)",
"all_rights_reserved": "© {{date}} <0>Linkwarden</0>. Tous droits réservés.",
"you_have_no_collections": "Vous n'avez pas de Collections...",
"you_have_no_tags": "Vous navez pas détiquettes...",
"cant_change_collection_you_dont_own": "Vous ne pouvez pas modifier une collection dont vous n'êtes pas propriétaire.",
"account": "Compte",
"billing": "Facturation",
"linkwarden_version": "Linkwarden {{version}}",
"help": "Aide",
"github": "GitHub",
"twitter": "Twitter",
"mastodon": "Mastodon",
"link_preservation_in_queue": "La préservation du lien est actuellement dans la file d'attente",
"check_back_later": "Revenez plus tard pour voir le résultat",
"there_are_more_formats": "Il y a plus de formats conservés dans la file d'attente",
"settings": "Paramètres",
"switch_to": "Basculer vers {{theme}}",
"logout": "Déconnexion",
"start_journey": "Commencez votre voyage en créant un nouveau lien!",
"create_new_link": "Créer un nouveau lien",
"new_link": "Nouveau lien",
"create_new": "Créer nouveau...",
"pwa_install_prompt": "Installez Linkwarden sur votre écran d'accueil pour un accès plus rapide et une expérience améliorée. <0>En savoir plus</0>",
"full_content": "Contenu complet",
"slower": "Plus lent",
"new_version_announcement": "Voir ce qu'il y a de nouveau dans <0>Linkwarden {{version}}!</0>",
"creating": "Création...",
"upload_file": "Envoyer fichier",
"file": "Ficher",
"file_types": "PDF, PNG, JPG (Jusqu'à {{size}} MB)",
"description": "Description",
"auto_generated": "Sera généré automatiquement si rien n'est fourni.",
"example_link": "Par exemple: Exemple de lien",
"hide": "Cacher",
"more": "Plus",
"options": "Options",
"description_placeholder": "Notes, réflexions, etc.",
"deleting": "Suppression...",
"token_revoked": "Jeton révoqué.",
"revoke_token": "Révoquer le jeton",
"revoke_confirmation": "Êtes-vous sûr de vouloir révoquer ce jeton d'accès ? Les applications ou services utilisant ce jeton ne pourront plus accéder à Linkwarden en l'utilisant.",
"revoke": "Révoquer",
"sending_request": "Envoi de la demande...",
"link_being_archived": "Le lien est en cours d'archivage...",
"preserved_formats": "Formats conservés",
"available_formats": "Les formats suivants sont disponibles pour ce lien:",
"readable": "Lisible",
"preservation_in_queue": "La préservation du lien est dans la file d'attente",
"view_latest_snapshot": "Voir le dernier instantané sur archive.org",
"refresh_preserved_formats": "Rafraîchir les formats conservés",
"this_deletes_current_preservations": "Cette opération supprime les conservations en cours",
"create_new_user": "Créer un nouvel utilisateur",
"placeholder_johnny": "Johnny",
"placeholder_email": "johnny@exemple.com",
"placeholder_john": "john",
"user_created": "Utilisateur créé!",
"fill_all_fields_error": "Veuillez remplir tous les champs.",
"password_change_note": "<0>Note:</0> Veillez à informer l'utilisateur qu'il doit modifier son mot de passe..",
"create_user": "Créer un utilisateur",
"creating_token": "Création d'un jeton...",
"token_created": "Jeton créé!",
"access_token_created": "Jeton d'accès créé",
"token_creation_notice": "Votre nouveau jeton a été créé. Veuillez le copier et le conserver en lieu sûr. Vous ne pourrez plus le voir.",
"copied_to_clipboard": "Copié dans le presse-papiers !",
"copy_to_clipboard": "Copier dans le presse-papiers",
"create_access_token": "Créer un jeton d'accès",
"expires_in": "Expire dans",
"token_name_placeholder": "Par exemple: Pour le raccourci iOS",
"create_token": "Créer un jeton d'accès",
"7_days": "7 jous",
"30_days": "30 jours",
"60_days": "60 jours",
"90_days": "90 jous",
"no_expiration": "Pas d'expiration",
"creating_link": "Création d'un lien...",
"link_created": "Lien créé!",
"link_name_placeholder": "Sera généré automatiquement s'il n'est pas renseigné.",
"link_url_placeholder": "Par exemple: http://exemple.com/",
"link_description_placeholder": "Notes, réflexions, etc.",
"more_options": "Plus d'options",
"hide_options": "Cacher les options",
"create_link": "Créer un lien",
"new_sub_collection": "Nouvelle sous-collection",
"for_collection": "Pour {{name}}",
"create_new_collection": "Créer une nouvelle collection",
"color": "Couleur",
"reset": "Réinitialiser",
"collection_name_placeholder": "Par exemple: Exemple de collection",
"collection_description_placeholder": "L'objectif de cette collection...",
"create_collection_button": "Créer une collection",
"password_change_warning": "Veuillez confirmer votre mot de passe avant de modifier votre adresse électronique.",
"stripe_update_note": " La mise à jour de ce champ modifiera également votre email de facturation sur Stripe.",
"sso_will_be_removed_warning": "Si vous changez d'adresse électronique, toutes les connexions SSO {{service}} existantes seront supprimées.",
"old_email": "Ancien e-mail",
"new_email": "Nouvel e-mail",
"confirm": "Confirmer",
"edit_link": "Modifier le lien",
"updating": "Mise à jour...",
"updated": "Mis à jour!",
"placeholder_example_link": "Par exemple: Lien d'exemple",
"make_collection_public": "Rendre la collection publique",
"make_collection_public_checkbox": "Faire de cette collection une collection publique",
"make_collection_public_desc": "Cela permettra à n'importe qui de consulter cette collection et ses utilisateurs.",
"sharable_link_guide": "Lien partageable (Cliquez pour copier)",
"copied": "Copié!",
"members": "Membres",
"members_username_placeholder": "Nom d'utilisateur (sans le '@')",
"owner": "Propriétaire",
"admin": "Administrateur",
"contributor": "Contributeur",
"viewer": "Visualiseur",
"viewer_desc": "Accès en lecture seule",
"contributor_desc": "Peut consulter et créer des liens",
"admin_desc": "Accès complet à tous les liens",
"remove_member": "Supprimer le membre",
"placeholder_example_collection": "Par exemple: Exemple de collection",
"placeholder_collection_purpose": "L'objectif de cette collection...",
"deleting_user": "Suppression...",
"user_deleted": "Utilisateur supprimé.",
"delete_user": "Supprimer l'utilisateur",
"confirm_user_deletion": "Êtes-vous sûr de vouloir supprimer cet utilisateur ?",
"irreversible_action_warning": "Cette action est irréversible !",
"delete_confirmation": "Supprimer, je sais ce que je fais",
"delete_link": "Supprimer le lien",
"deleted": "Supprimé.",
"link_deletion_confirmation_message": "Êtes-vous sûr de vouloir supprimer ce lien ?",
"warning": "Attention",
"irreversible_warning": "Cette action est irréversible !",
"shift_key_tip": "Maintenez la touche Majuscule enfoncée tout en cliquant sur « Supprimer » pour éviter cette confirmation à l'avenir.",
"deleting_collection": "Suppression...",
"collection_deleted": "Collection supprimée.",
"confirm_deletion_prompt": "Pour confirmer, tapez \"{{name}}\" dans le champ ci-dessous:",
"type_name_placeholder": "Tapez \"{{name}}\" ici.",
"deletion_warning": "La suppression de cette collection effacera définitivement tout son contenu et deviendra inaccessible à tous, y compris aux membres qui y ont déjà eu accès.",
"leave_prompt": "Cliquez sur le bouton ci-dessous pour quitter la collection actuelle.",
"leave": "Quitter",
"edit_links": "Éditer {{count}} liens",
"move_to_collection": "Déplacer vers la collection",
"add_tags": "Ajouter des étiquettes",
"remove_previous_tags": "Supprimer les étiquettes précédentes",
"delete_links": "Supprimer {{count}} liens",
"links_deletion_confirmation_message": "Êtes-vous sûr de vouloir supprimer {{count}} liens ? ",
"warning_irreversible": "Avertissement : Cette action est irréversible !",
"shift_key_instruction": "Maintenez la touche « Shift » enfoncée tout en cliquant sur « Supprimer » pour éviter cette confirmation à l'avenir.",
"link_selection_error": "Vous n'avez pas l'autorisation de modifier ou de supprimer cet élément.",
"no_description": "Aucune description n'est fournie.",
"applying": "Appliquer...",
"unpin": "Épingler",
"pin_to_dashboard": "Épingler au tableau de bord",
"show_link_details": "Afficher les détails du lien",
"hide_link_details": "Cacher les détails du lien",
"link_pinned": "Lien épinglé !",
"link_unpinned": "Lien désépinglé !",
"webpage": "Page web",
"server_administration": "Administration du serveur",
"all_collections": "Toutes les collections",
"dashboard": "Tableau de bord",
"demo_title": "Démonstration uniquement",
"demo_desc": "Il s'agit d'une instance de démonstration de Linkwarden et les téléchargements sont désactivés.",
"demo_desc_2": "Si vous souhaitez tester la version complète, vous pouvez vous inscrire pour un essai gratuit à l'adresse suivante:",
"demo_button": "Se connecter en tant qu'utilisateur de démonstration"
}

View File

@ -0,0 +1,374 @@
{
"user_administration": "用户管理",
"search_users": "搜索用户",
"no_users_found": "未找到用户。",
"no_user_found_in_search": "在给定的搜索查询中未找到用户。",
"username": "用户名",
"email": "邮箱",
"subscribed": "订阅",
"created_at": "创建时间",
"not_available": "N/A",
"check_your_email": "检查你的邮箱",
"authenticating": "身份验证中...",
"verification_email_sent": "发送验证邮件。",
"verification_email_sent_desc": "登录链接已发送到您的邮箱。如果没有看到邮件,请检查你的垃圾邮件文件夹。",
"resend_email": "重新发送邮件",
"invalid_credentials": "无效凭证",
"fill_all_fields": "请填写所有字段",
"enter_credentials": "账号登录",
"username_or_email": "用户名或邮箱",
"password": "密码",
"confirm_password": "确认密码",
"forgot_password": "忘记密码?",
"login": "登录",
"or_continue_with": "第三方登录",
"new_here": "新用户?",
"sign_up": "注册",
"sign_in_to_your_account": "登录你的账户",
"dashboard_desc": "你的数据概览",
"link": "链接",
"links": "链接",
"collection": "收藏夹",
"collections": "收藏夹",
"tag": "标签",
"tags": "标签",
"recent": "最近",
"recent_links_desc": "最近添加的链接",
"view_all": "查看全部",
"view_added_links_here": "查看最近添加的链接!",
"view_added_links_here_desc": "此部分将查看您有权访问的每个收藏夹中最新添加的链接。",
"add_link": "添加新链接",
"import_links": "导入链接",
"from_linkwarden": "从 Linkwarden",
"from_html": "从书签 HTML 文件",
"from_wallabag": "从 WallabagJSON 文件)",
"pinned": "置顶",
"pinned_links_desc": "你置顶的链接",
"pin_favorite_links_here": "在这里置顶您最喜欢的链接!",
"pin_favorite_links_here_desc": "您可以通过点击每个链接上的三个点,然后点击“置顶到仪表板”来置顶您最喜欢的链接。",
"sending_password_link": "发送重置密码链接",
"password_email_prompt": "输入你的邮箱,我们会给你发送一个重置密码的链接。",
"send_reset_link": "发送重置链接",
"reset_email_sent_desc": "检查你的邮箱以获取重置密码的链接。如果它在几分钟内没有出现,请检查你的垃圾邮件文件夹。",
"back_to_login": "返回登录",
"email_sent": "邮件已发送!",
"passwords_mismatch": "密码错误。",
"password_too_short": "密码需至少 8 个字符。",
"creating_account": "正在创建账户...",
"account_created": "创建账户成功!",
"trial_offer_desc": "免费解锁 {{count}} 天的高级服务!",
"register_desc": "创建新的账户",
"registration_disabled_desc": "此实例已禁用注册,如有任何问题,请与管理员联系。",
"enter_details": "输入您的详细信息",
"display_name": "昵称",
"sign_up_agreement": "注册即表示您同意遵守我们的 <0>服务条款</0> 和 <1>隐私政策</1>。",
"need_help": "需要帮助吗?",
"get_in_touch": "保持联系",
"already_registered": "已有账户?",
"deleting_selections": "正在删除选中的链接...",
"links_deleted": "{{count}} 个链接已删除。",
"link_deleted": "1 个链接已删除。",
"links_selected": "{{count}} 个链接已选中。",
"link_selected": "1 个链接已选中。",
"nothing_selected": "未选中任何内容",
"edit": "编辑",
"delete": "删除",
"nothing_found": "沒有找到任何内容。",
"redirecting_to_stripe": "重定向到 Stripe...",
"subscribe_title": "订阅 Linkwarden",
"subscribe_desc": "你将跳转到 Stripe如有任何问题请随时联系我们 <0>support@linkwarden.app</0> 。",
"monthly": "每月",
"yearly": "每年",
"discount_percent": "{{percent}}% 折扣",
"billed_monthly": "月付",
"billed_yearly": "年付",
"total": "总计",
"total_annual_desc": "{{count}}-天免费体验,然后 ${{annualPrice}} 每年",
"total_monthly_desc": "{{count}}-天免费体验,然后 ${{monthlyPrice}} 每月",
"plus_tax": "+增值税(如适用)",
"complete_subscription": "完成订阅",
"sign_out": "退出",
"access_tokens": "Access Tokens",
"access_tokens_description": "Access Tokens 可用于从其他应用程序和服务访问 Linkwarden而无需泄露您的用户名和密码。",
"new_token": "新 Access Token",
"name": "名称",
"created_success": "创建成功!",
"created": "已创建",
"expires": "过期",
"accountSettings": "账户设置",
"language": "语言",
"profile_photo": "头像",
"upload_new_photo": "上传新头像",
"remove_photo": "移除照片",
"make_profile_private": "将个人资料设为私密",
"profile_privacy_info": "这将限制谁可以找到你并将你添加到新收藏夹。",
"whitelisted_users": "白名单用户",
"whitelisted_users_info": "请提供您希望允许查看您个人资料的用户的用户名,以逗号分隔。",
"whitelisted_users_placeholder": "你的资料现在对所有人都是隐藏的。",
"save_changes": "保存修改",
"import_export": "导入 & 导出",
"import_data": "从其它平台导入",
"download_data": "下载数据",
"export_data": "导出数据",
"delete_account": "删除账户",
"delete_account_warning": "这将永久删除您拥有的所有链接、收藏夹、标签和存档数据。",
"cancel_subscription_notice": "它将取消你的订阅。",
"account_deletion_page": "账户删除页面",
"applying_settings": "应用设置中...",
"settings_applied": "已应用设置!",
"email_change_request": "已发送电子邮件更改请求。请确认新的邮箱地址。",
"image_upload_size_error": "请选择小于 1 MB的 PNG 或 JPEG 文件。",
"image_upload_format_error": "无效的文件格式。",
"importing_bookmarks": "正在导入书签...",
"import_success": "已导入书签!请刷新页面。",
"more_coming_soon": "即将推出...",
"billing_settings": "账单设置",
"manage_subscription_intro": "要管理/取消订阅,请访问",
"billing_portal": "账单管理",
"help_contact_intro": "如果您仍然需要帮助或遇到任何问题,请随时与我们联系:",
"fill_required_fields": "请填写必填字段。",
"deleting_message": "删除所有内容,请稍等...",
"delete_warning": "这将永久删除您拥有的所有链接、收藏夹、标签和存档数据。它还将注销您的账户。此操作不可逆!",
"optional": "可选",
"feedback_help": "(但它确实能帮助我们改善!)",
"reason_for_cancellation": "取消原因",
"please_specify": "请指定",
"customer_service": "客户服务",
"low_quality": "低质量",
"missing_features": "缺失功能",
"switched_service": "选择服务",
"too_complex": "太复杂",
"too_expensive": "太昂贵",
"unused": "从未使用",
"other": "其它",
"more_information": "更多信息(越详细,越有帮助)",
"feedback_placeholder": "例如,我需要一个功能...",
"delete_your_account": "删除账户",
"change_password": "修改密码",
"password_length_error": "密码需至少 8 个字符。",
"applying_changes": "正在申请...",
"password_change_instructions": "要更改您的密码,请填写以下内容。您的密码至少应为 8 个字符。",
"old_password": "旧密码",
"new_password": "新密码",
"preference": "首选项",
"select_theme": "选择主题",
"dark": "Dark",
"light": "Light",
"archive_settings": "存档设置",
"formats_to_archive": "存档/保存网页格式:",
"screenshot": "截图",
"pdf": "PDF",
"archive_org_snapshot": "Archive.org 快照",
"link_settings": "链接设置",
"prevent_duplicate_links": "防止重复链接",
"clicking_on_links_should": "点击链接:",
"open_original_content": "打开原始内容",
"open_pdf_if_available": "打开PDF如果有的话",
"open_readable_if_available": "打开可读视图(如果可用)",
"open_screenshot_if_available": "打开屏幕截图,如果有的话",
"open_webpage_if_available": "打开网页副本,如果有的话",
"tag_renamed": "标签已重命名!",
"tag_deleted": "标签已删除!",
"rename_tag": "重命名标签",
"delete_tag": "删除标签",
"list_created_with_linkwarden": "使用 Linkwarden 创建的列表",
"by_author": "作者 {{author}}",
"by_author_and_other": "作者:{{author}} 和 {{count}} 其他人",
"by_author_and_others": "作者: {{author}} 和 {{count}} 其他人",
"search_count_link": "搜索 {{count}} 个链接",
"search_count_links": "搜索 {{count}} 个链接",
"collection_is_empty": "此收藏夹为空",
"all_links": "全部链接",
"all_links_desc": "所有收藏夹的链接",
"you_have_not_added_any_links": "你还没有创建任何链接",
"collections_you_own": "你的收藏夹",
"new_collection": "新收藏夹",
"other_collections": "其它收藏夹",
"other_collections_desc": "您所属的共享收藏夹",
"showing_count_results": "展示 {{count}} 条结果",
"showing_count_result": "展示 {{count}} 条结果",
"edit_collection_info": "编辑收藏夹信息",
"share_and_collaborate": "共享和协作",
"view_team": "查看团队",
"team": "团队",
"create_subcollection": "创建子收藏夹",
"delete_collection": "删除收藏夹",
"leave_collection": "离开收藏夹",
"email_verified_signing_out": "邮箱已验证。正在退出...",
"invalid_token": "无效 token",
"sending_password_recovery_link": "发送密码重置链接...",
"please_fill_all_fields": "请填写所有字段。",
"password_updated": "密码已修改!",
"reset_password": "重置密码",
"enter_email_for_new_password": "输入您的邮箱,以便我们向您发送创建新密码的链接。",
"update_password": "更新密码",
"password_successfully_updated": "您的密码已成功更新。",
"user_already_member": "用户已存在。",
"you_are_already_collection_owner": "您已经是收藏夹的所有者。",
"date_newest_first": "日期 (最新)",
"date_oldest_first": "日期 (最早)",
"name_az": "名字 (A-Z)",
"name_za": "名字 (Z-A)",
"description_az": "描述 (A-Z)",
"description_za": "描述 (Z-A)",
"all_rights_reserved": "© {{date}} <0>Linkwarden</0> 版权所有",
"you_have_no_collections": "你还没有收藏夹...",
"you_have_no_tags": "你还没有标签...",
"cant_change_collection_you_dont_own": "您无法对不属于您的收藏夹进行更改。",
"account": "账户",
"billing": "帐单",
"linkwarden_version": "Linkwarden {{version}}",
"help": "帮助",
"github": "GitHub",
"twitter": "Twitter",
"mastodon": "Mastodon",
"link_preservation_in_queue": "链接保存正在处理中...",
"check_back_later": "请稍后再查看结果",
"there_are_more_formats": "队列中有更多已保存的格式。",
"settings": "设置",
"switch_to": "切换到 {{theme}}",
"logout": "注销",
"start_journey": "通过创建一个新的链接开始你的旅程!",
"create_new_link": "创建新链接",
"new_link": "新链接",
"create_new": "新建",
"pwa_install_prompt": "安装 Linkwarden 到您的主屏幕,以获得更快的访问和增强的体验。 <0>了解更多</0>",
"full_content": "完整内容",
"slower": "较慢",
"new_version_announcement": "查看 <0>Linkwarden {{version}}</0> 了解新功能。",
"creating": "正在创建...",
"upload_file": "上传文件",
"file": "文件",
"file_types": "PDFPNGJPG (不超过 {{size}} MB",
"description": "描述",
"auto_generated": "若未提供将自动生成。",
"example_link": "例如:示例链接",
"hide": "隐藏",
"more": "更多",
"options": "选项",
"description_placeholder": "笔记,想法等等。",
"deleting": "正在删除...",
"token_revoked": "Token 已撤销。",
"revoke_token": "撤销 Token",
"revoke_confirmation": "确定要撤销此 Access Token 吗?任何使用此 token 的应用或服务将无法再使用它访问 Linkwarden。",
"revoke": "撤销",
"sending_request": "正在发送请求...",
"link_being_archived": "链接正在归档...",
"preserved_formats": "保存格式",
"available_formats": "以下格式可用于此链接:",
"readable": "可读视图",
"preservation_in_queue": "链接保存正在处理中...",
"view_latest_snapshot": "在 archive.org 上查看最新快照",
"refresh_preserved_formats": "刷新保留格式",
"this_deletes_current_preservations": "这将删除当前保存",
"create_new_user": "创建新用户",
"placeholder_johnny": "Johnny",
"placeholder_email": "johnny@example.com",
"placeholder_john": "john",
"user_created": "用户已创建",
"fill_all_fields_error": "请填写所有的字段。",
"password_change_note": "<0>注意:</0> 请务必通知用户他们需要更改密码。",
"create_user": "创建用户",
"creating_token": "创建 Token 中...",
"token_created": "Token 创建完成",
"access_token_created": "Access Token 已创建",
"token_creation_notice": "你的新 token 已创建。 请复制并保存在安全的地方。您将无法再看到它。",
"copied_to_clipboard": "已复制到剪贴板",
"copy_to_clipboard": "复制到剪贴板",
"create_access_token": "创建一个 Access Token",
"expires_in": "有效期:",
"token_name_placeholder": "例如iOS 快捷方式",
"create_token": "创建 Access Token",
"7_days": "7 天",
"30_days": "30 天",
"60_days": "60 天",
"90_days": "90 天",
"no_expiration": "永久",
"creating_link": "正在创建链接...",
"link_created": "链接已创建!",
"link_name_placeholder": "若留空将自动生成。",
"link_url_placeholder": "示例: http://example.com/",
"link_description_placeholder": "笔记,想法等等。",
"more_options": "更多选项",
"hide_options": "隐藏选项",
"create_link": "创建链接",
"new_sub_collection": "新子收藏夹",
"for_collection": "对于 {{name}}",
"create_new_collection": "创建新收藏夹",
"color": "颜色",
"reset": "重置",
"collection_name_placeholder": "例如:示例收藏夹",
"collection_description_placeholder": "该收藏夹用于...",
"create_collection_button": "创建收藏夹",
"password_change_warning": "更改邮箱前,请确认密码。",
"stripe_update_note": " 更新此字段也会更改 Stripe 上的账单邮箱。",
"sso_will_be_removed_warning": "如果您更改电子邮件地址,任何现有的 {{service}} SSO 连接都将被删除。",
"old_email": "旧邮箱",
"new_email": "新邮箱",
"confirm": "确认",
"edit_link": "编辑链接",
"updating": "正在更新...",
"updated": "已更新",
"placeholder_example_link": "例如:示例链接",
"make_collection_public": "公开收藏夹",
"make_collection_public_checkbox": "将其设为公开收藏夹",
"make_collection_public_desc": "这将允许任何人查看该收藏夹及其用户。",
"sharable_link_guide": "可分享的链接(点击复制)",
"copied": "已复制",
"members": "成员",
"members_username_placeholder": "用户名(不带'@'",
"owner": "所有者",
"admin": "管理员",
"contributor": "Contributor",
"viewer": "Viewer",
"viewer_desc": "只读访问",
"contributor_desc": "可以查看和创建链接",
"admin_desc": "完全访问所有链接",
"remove_member": "移除成员",
"placeholder_example_collection": "例如:示例收藏夹",
"placeholder_collection_purpose": "此收藏夹的目的...",
"deleting_user": "正在删除用户...",
"user_deleted": "用户已删除",
"delete_user": "删除用户",
"confirm_user_deletion": "确定删除该用户吗?",
"irreversible_action_warning": "此操作不可逆!",
"delete_confirmation": "删除,我知道我在做什么。",
"delete_link": "删除链接",
"deleted": "已删除",
"link_deletion_confirmation_message": "要删除此链接吗?",
"warning": "警告",
"irreversible_warning": "此操作不可逆!",
"shift_key_tip": "按住 Shift 键并单击“删除”即可在以后绕过此确认。",
"deleting_collection": "删除中...",
"collection_deleted": "收藏夹已删除。",
"confirm_deletion_prompt": "请确认, 在下面的输入框中输入 \"{{name}}\" :",
"type_name_placeholder": "在此输入 \"{{name}}\" 。",
"deletion_warning": "删除此收藏夹将永久清除其所有内容,并且所有人都将无法访问,包括之前具有访问权限的成员。",
"leave_prompt": "单击下面的按钮离开当前收藏夹。",
"leave": "离开",
"edit_links": "编辑 {{count}} 链接",
"move_to_collection": "移至收藏夹",
"add_tags": "添加标签",
"remove_previous_tags": "删除以前的标签",
"delete_links": "删除 {{count}} 个链接",
"links_deletion_confirmation_message": "确定要删除 {{count}} 个链接吗?",
"warning_irreversible": "警告:此操作不可逆!",
"shift_key_instruction": "按住“Shift”键并单击“删除”即可在以后绕过此确认。",
"link_selection_error": "您无权编辑或删除此项目。",
"no_description": "未提供描述",
"applying": "正在申请...",
"unpin": "取消置顶",
"pin_to_dashboard": "置顶到仪表盘",
"show_link_details": "显示链接详细信息",
"hide_link_details": "隐藏链接详细信息",
"link_pinned": "链接已置顶!",
"link_unpinned": "链接已取消置顶!",
"webpage": "网页",
"server_administration": "后台管理",
"all_collections": "全部收藏夹",
"dashboard": "仪表盘",
"demo_title": "仅限演示",
"demo_desc": "这只是 Linkwarden 的演示实例,禁止上传文件。",
"demo_desc_2": "如果你想尝试完整版,你可以注册免费试用:",
"demo_button": "以演示用户登录"
}

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",
} }
@ -84,7 +83,7 @@ export enum Sort {
export type Order = { [key: string]: "asc" | "desc" }; export type Order = { [key: string]: "asc" | "desc" };
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"