Fix merge conflicts
This commit is contained in:
commit
898b126231
21
README.md
21
README.md
|
@ -1,17 +1,18 @@
|
|||
<div align="center">
|
||||
<img src="./assets/logo.png" width="100px" />
|
||||
<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://twitter.com/LinkwardenHQ"><img src="https://img.shields.io/twitter/follow/linkwarden" alt="Twitter"></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">
|
||||
<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>
|
||||
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
|
@ -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.
|
||||
|
||||
> [!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" />
|
||||
|
||||
|
@ -71,10 +72,14 @@ We've forked the old version from the current repository into [this repo](https:
|
|||
- ⬇️ Import and export your bookmarks.
|
||||
- 🔐 SSO integration. (Enterprise and Self-hosted users only)
|
||||
- 📦 Installable Progressive Web App (PWA).
|
||||
- 🍏 iOS and MacOS Apps, maintained by [JGeek00](https://github.com/JGeek00).
|
||||
- 🍎 iOS Shortcut to save links to Linkwarden.
|
||||
- 🔑 API keys.
|
||||
- ✅ 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 ⭐
|
||||
|
||||
|
@ -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).
|
||||
|
||||
## Docs
|
||||
## Documentation
|
||||
|
||||
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!
|
||||
|
||||
## 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!
|
||||
|
||||
|
|
|
@ -8,12 +8,12 @@ import ProfilePhoto from "./ProfilePhoto";
|
|||
import usePermissions from "@/hooks/usePermissions";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import getPublicUserData from "@/lib/client/getPublicUserData";
|
||||
import useAccountStore from "@/store/account";
|
||||
import EditCollectionModal from "./ModalContent/EditCollectionModal";
|
||||
import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal";
|
||||
import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
export default function CollectionCard({
|
||||
collection,
|
||||
|
@ -22,7 +22,7 @@ export default function CollectionCard({
|
|||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useLocalSettingsStore();
|
||||
const { account } = useAccountStore();
|
||||
const { data: user = {} } = useUser();
|
||||
|
||||
const formattedDate = new Date(collection.createdAt as string).toLocaleString(
|
||||
"en-US",
|
||||
|
@ -41,18 +41,18 @@ export default function CollectionCard({
|
|||
|
||||
useEffect(() => {
|
||||
const fetchOwner = async () => {
|
||||
if (collection && collection.ownerId !== account.id) {
|
||||
if (collection && collection.ownerId !== user.id) {
|
||||
const owner = await getPublicUserData(collection.ownerId as number);
|
||||
setCollectionOwner(owner);
|
||||
} else if (collection && collection.ownerId === account.id) {
|
||||
} else if (collection && collection.ownerId === user.id) {
|
||||
setCollectionOwner({
|
||||
id: account.id as number,
|
||||
name: account.name,
|
||||
username: account.username,
|
||||
image: account.image,
|
||||
archiveAsScreenshot: account.archiveAsScreenshot,
|
||||
archiveAsMonolith: account.archiveAsMonolith,
|
||||
archiveAsPDF: account.archiveAsPDF,
|
||||
id: user.id as number,
|
||||
name: user.name,
|
||||
username: user.username as string,
|
||||
image: user.image as string,
|
||||
archiveAsScreenshot: user.archiveAsScreenshot as boolean,
|
||||
archiveAsMonolith: user.archiveAsMonolith as boolean,
|
||||
archiveAsPDF: user.archiveAsPDF as boolean,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -76,7 +76,7 @@ export default function CollectionCard({
|
|||
>
|
||||
<i className="bi-three-dots text-xl" title="More"></i>
|
||||
</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 && (
|
||||
<li>
|
||||
<div
|
||||
|
@ -86,6 +86,7 @@ export default function CollectionCard({
|
|||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setEditCollectionModal(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("edit_collection_info")}
|
||||
</div>
|
||||
|
@ -99,6 +100,7 @@ export default function CollectionCard({
|
|||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setEditCollectionSharingModal(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{permissions === true
|
||||
? t("share_and_collaborate")
|
||||
|
@ -113,6 +115,7 @@ export default function CollectionCard({
|
|||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setDeleteCollectionModal(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{permissions === true
|
||||
? t("delete_collection")
|
||||
|
@ -155,11 +158,9 @@ export default function CollectionCard({
|
|||
<Link
|
||||
href={`/collections/${collection.id}`}
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(45deg, ${collection.color}30 10%, ${
|
||||
settings.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
|
||||
} 50%, ${
|
||||
settings.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
|
||||
} 100%)`,
|
||||
backgroundImage: `linear-gradient(45deg, ${collection.color}30 10%, ${settings.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
|
||||
} 50%, ${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"
|
||||
>
|
||||
|
|
|
@ -9,14 +9,14 @@ import Tree, {
|
|||
TreeSourcePosition,
|
||||
TreeDestinationPosition,
|
||||
} from "@atlaskit/tree";
|
||||
import useCollectionStore from "@/store/collections";
|
||||
import { Collection } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||
import { useRouter } from "next/router";
|
||||
import useAccountStore from "@/store/account";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCollections, useUpdateCollection } from "@/hooks/store/collections";
|
||||
import { useUpdateUser, useUser } from "@/hooks/store/user";
|
||||
|
||||
interface ExtendedTreeItem extends TreeItem {
|
||||
data: Collection;
|
||||
|
@ -24,53 +24,57 @@ interface ExtendedTreeItem extends TreeItem {
|
|||
|
||||
const CollectionListing = () => {
|
||||
const { t } = useTranslation();
|
||||
const { collections, updateCollection } = useCollectionStore();
|
||||
const { account, updateAccount } = useAccountStore();
|
||||
const updateCollection = useUpdateCollection();
|
||||
const { data: collections = [], isLoading } = useCollections();
|
||||
|
||||
const { data: user = {} } = useUser();
|
||||
const updateUser = useUpdateUser();
|
||||
|
||||
const router = useRouter();
|
||||
const currentPath = router.asPath;
|
||||
|
||||
const [tree, setTree] = useState<TreeData | undefined>();
|
||||
|
||||
const initialTree = useMemo(() => {
|
||||
if (collections.length > 0) {
|
||||
if (
|
||||
// !tree &&
|
||||
collections.length > 0
|
||||
) {
|
||||
return buildTreeFromCollections(
|
||||
collections,
|
||||
router,
|
||||
account.collectionOrder
|
||||
user.collectionOrder
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}, [collections, router]);
|
||||
|
||||
const [tree, setTree] = useState(initialTree);
|
||||
} else return undefined;
|
||||
}, [collections, user, router]);
|
||||
|
||||
useEffect(() => {
|
||||
// if (!tree)
|
||||
setTree(initialTree);
|
||||
}, [initialTree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (account.username) {
|
||||
if (user.username) {
|
||||
if (
|
||||
(!account.collectionOrder || account.collectionOrder.length === 0) &&
|
||||
(!user.collectionOrder || user.collectionOrder.length === 0) &&
|
||||
collections.length > 0
|
||||
)
|
||||
updateAccount({
|
||||
...account,
|
||||
updateUser.mutate({
|
||||
...user,
|
||||
collectionOrder: collections
|
||||
.filter(
|
||||
(e) =>
|
||||
e.parentId === null ||
|
||||
!collections.find((i) => i.id === e.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 {
|
||||
const newCollectionOrder: number[] = [
|
||||
...(account.collectionOrder || []),
|
||||
];
|
||||
const newCollectionOrder: number[] = [...(user.collectionOrder || [])];
|
||||
|
||||
// Start with collections that are in both account.collectionOrder and collections
|
||||
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)
|
||||
);
|
||||
|
||||
|
@ -78,7 +82,7 @@ const CollectionListing = () => {
|
|||
collections.forEach((collection) => {
|
||||
if (
|
||||
!filteredCollectionOrder.includes(collection.id as number) &&
|
||||
(!collection.parentId || collection.ownerId === account.id)
|
||||
(!collection.parentId || collection.ownerId === user.id)
|
||||
) {
|
||||
filteredCollectionOrder.push(collection.id as number);
|
||||
}
|
||||
|
@ -87,10 +91,10 @@ const CollectionListing = () => {
|
|||
// check if the newCollectionOrder is the same as the old one
|
||||
if (
|
||||
JSON.stringify(newCollectionOrder) !==
|
||||
JSON.stringify(account.collectionOrder)
|
||||
JSON.stringify(user.collectionOrder)
|
||||
) {
|
||||
updateAccount({
|
||||
...account,
|
||||
updateUser.mutateAsync({
|
||||
...user,
|
||||
collectionOrder: newCollectionOrder,
|
||||
});
|
||||
}
|
||||
|
@ -138,9 +142,9 @@ const CollectionListing = () => {
|
|||
);
|
||||
|
||||
if (
|
||||
(movedCollection?.ownerId !== account.id &&
|
||||
(movedCollection?.ownerId !== user.id &&
|
||||
destination.parentId !== source.parentId) ||
|
||||
(destinationCollection?.ownerId !== account.id &&
|
||||
(destinationCollection?.ownerId !== user.id &&
|
||||
destination.parentId !== "root")
|
||||
) {
|
||||
return toast.error(t("cant_change_collection_you_dont_own"));
|
||||
|
@ -148,18 +152,25 @@ const CollectionListing = () => {
|
|||
|
||||
setTree((currentTree) => moveItemOnTree(currentTree!, source, destination));
|
||||
|
||||
const updatedCollectionOrder = [...account.collectionOrder];
|
||||
const updatedCollectionOrder = [...user.collectionOrder];
|
||||
|
||||
if (source.parentId !== destination.parentId) {
|
||||
await updateCollection({
|
||||
...movedCollection,
|
||||
parentId:
|
||||
destination.parentId && destination.parentId !== "root"
|
||||
? Number(destination.parentId)
|
||||
: destination.parentId === "root"
|
||||
? "root"
|
||||
: null,
|
||||
} as any);
|
||||
await updateCollection.mutateAsync(
|
||||
{
|
||||
...movedCollection,
|
||||
parentId:
|
||||
destination.parentId && destination.parentId !== "root"
|
||||
? Number(destination.parentId)
|
||||
: destination.parentId === "root"
|
||||
? "root"
|
||||
: null,
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
|
@ -172,8 +183,8 @@ const CollectionListing = () => {
|
|||
|
||||
updatedCollectionOrder.splice(destination.index, 0, movedCollectionId);
|
||||
|
||||
await updateAccount({
|
||||
...account,
|
||||
await updateUser.mutateAsync({
|
||||
...user,
|
||||
collectionOrder: updatedCollectionOrder,
|
||||
});
|
||||
} else if (
|
||||
|
@ -182,8 +193,8 @@ const CollectionListing = () => {
|
|||
) {
|
||||
updatedCollectionOrder.splice(destination.index, 0, movedCollectionId);
|
||||
|
||||
await updateAccount({
|
||||
...account,
|
||||
updateUser.mutate({
|
||||
...user,
|
||||
collectionOrder: updatedCollectionOrder,
|
||||
});
|
||||
} else if (
|
||||
|
@ -193,14 +204,22 @@ const CollectionListing = () => {
|
|||
) {
|
||||
updatedCollectionOrder.splice(source.index, 1);
|
||||
|
||||
await updateAccount({
|
||||
...account,
|
||||
await updateUser.mutateAsync({
|
||||
...user,
|
||||
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 (
|
||||
<p className="text-neutral text-xs font-semibold truncate w-full px-2 mt-5 mb-8">
|
||||
{t("you_have_no_collections")}
|
||||
|
|
|
@ -29,7 +29,7 @@ export default function FilterSearchDropdown({
|
|||
>
|
||||
<i className="bi-funnel text-neutral text-2xl"></i>
|
||||
</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>
|
||||
<label
|
||||
className="label cursor-pointer flex justify-start"
|
||||
|
@ -45,7 +45,7 @@ export default function FilterSearchDropdown({
|
|||
setSearchFilter({ ...searchFilter, name: !searchFilter.name })
|
||||
}
|
||||
/>
|
||||
<span className="label-text">{t("name")}</span>
|
||||
<span className="label-text whitespace-nowrap">{t("name")}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
|
@ -63,7 +63,7 @@ export default function FilterSearchDropdown({
|
|||
setSearchFilter({ ...searchFilter, url: !searchFilter.url })
|
||||
}
|
||||
/>
|
||||
<span className="label-text">{t("link")}</span>
|
||||
<span className="label-text whitespace-nowrap">{t("link")}</span>
|
||||
</label>
|
||||
</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>
|
||||
</li>
|
||||
<li>
|
||||
|
@ -102,7 +104,7 @@ export default function FilterSearchDropdown({
|
|||
setSearchFilter({ ...searchFilter, tags: !searchFilter.tags })
|
||||
}
|
||||
/>
|
||||
<span className="label-text">{t("tags")}</span>
|
||||
<span className="label-text whitespace-nowrap">{t("tags")}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
|
@ -123,8 +125,10 @@ export default function FilterSearchDropdown({
|
|||
})
|
||||
}
|
||||
/>
|
||||
<span className="label-text">{t("full_content")}</span>
|
||||
<div className="ml-auto badge badge-sm badge-neutral">
|
||||
<span className="label-text whitespace-nowrap">
|
||||
{t("full_content")}
|
||||
</span>
|
||||
<div className="ml-auto badge badge-sm badge-neutral whitespace-nowrap">
|
||||
{t("slower")}
|
||||
</div>
|
||||
</label>
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import useCollectionStore from "@/store/collections";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { styles } from "./styles";
|
||||
import { Options } from "./types";
|
||||
import CreatableSelect from "react-select/creatable";
|
||||
import Select from "react-select";
|
||||
import { useCollections } from "@/hooks/store/collections";
|
||||
|
||||
type Props = {
|
||||
onChange: any;
|
||||
|
@ -24,7 +24,8 @@ export default function CollectionSelection({
|
|||
showDefaultValue = true,
|
||||
creatable = true,
|
||||
}: Props) {
|
||||
const { collections } = useCollectionStore();
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [options, setOptions] = useState<Options[]>([]);
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import useTagStore from "@/store/tags";
|
||||
import { useEffect, useState } from "react";
|
||||
import CreatableSelect from "react-select/creatable";
|
||||
import { styles } from "./styles";
|
||||
import { Options } from "./types";
|
||||
import { useTags } from "@/hooks/store/tags";
|
||||
|
||||
type Props = {
|
||||
onChange: any;
|
||||
|
@ -13,12 +13,12 @@ type Props = {
|
|||
};
|
||||
|
||||
export default function TagSelection({ onChange, defaultValue }: Props) {
|
||||
const { tags } = useTagStore();
|
||||
const { data: tags = [] } = useTags();
|
||||
|
||||
const [options, setOptions] = useState<Options[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const formatedCollections = tags.map((e) => {
|
||||
const formatedCollections = tags.map((e: any) => {
|
||||
return { value: e.id, label: e.name };
|
||||
});
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ const InstallApp = (props: Props) => {
|
|||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
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">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
|
@ -5,17 +5,18 @@ import ViewDropdown from "./ViewDropdown";
|
|||
import { TFunction } from "i18next";
|
||||
import BulkDeleteLinksModal from "./ModalContent/BulkDeleteLinksModal";
|
||||
import BulkEditLinksModal from "./ModalContent/BulkEditLinksModal";
|
||||
import toast from "react-hot-toast";
|
||||
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
|
||||
import { useRouter } from "next/router";
|
||||
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 = {
|
||||
children: React.ReactNode;
|
||||
t: TFunction<"translation", undefined>;
|
||||
viewMode: string;
|
||||
setViewMode: Dispatch<SetStateAction<string>>;
|
||||
viewMode: ViewMode;
|
||||
setViewMode: Dispatch<SetStateAction<ViewMode>>;
|
||||
searchFilter?: {
|
||||
name: boolean;
|
||||
url: boolean;
|
||||
|
@ -48,8 +49,11 @@ const LinkListOptions = ({
|
|||
editMode,
|
||||
setEditMode,
|
||||
}: Props) => {
|
||||
const { links, selectedLinks, setSelectedLinks, deleteLinksById } =
|
||||
useLinkStore();
|
||||
const { selectedLinks, setSelectedLinks } = useLinkStore();
|
||||
|
||||
const deleteLinksById = useBulkDeleteLinks();
|
||||
|
||||
const { links } = useLinks();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
|
@ -73,23 +77,23 @@ const LinkListOptions = ({
|
|||
};
|
||||
|
||||
const bulkDeleteLinks = async () => {
|
||||
const load = toast.loading(t("deleting_selections"));
|
||||
const load = toast.loading(t("deleting"));
|
||||
|
||||
const response = await deleteLinksById(
|
||||
selectedLinks.map((link) => link.id as number)
|
||||
await deleteLinksById.mutateAsync(
|
||||
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 (
|
||||
|
@ -99,57 +103,64 @@ const LinkListOptions = ({
|
|||
|
||||
<div className="flex gap-3 items-center justify-end">
|
||||
<div className="flex gap-2 items-center mt-2">
|
||||
{links.length > 0 && editMode !== undefined && setEditMode && (
|
||||
<div
|
||||
role="button"
|
||||
onClick={() => {
|
||||
setEditMode(!editMode);
|
||||
setSelectedLinks([]);
|
||||
}}
|
||||
className={`btn btn-square btn-sm btn-ghost ${
|
||||
editMode
|
||||
? "bg-primary/20 hover:bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
}`}
|
||||
>
|
||||
<i className="bi-pencil-fill text-neutral text-xl"></i>
|
||||
</div>
|
||||
)}
|
||||
{links &&
|
||||
links.length > 0 &&
|
||||
editMode !== undefined &&
|
||||
setEditMode && (
|
||||
<div
|
||||
role="button"
|
||||
onClick={() => {
|
||||
setEditMode(!editMode);
|
||||
setSelectedLinks([]);
|
||||
}}
|
||||
className={`btn btn-square btn-sm btn-ghost ${
|
||||
editMode
|
||||
? "bg-primary/20 hover:bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
}`}
|
||||
>
|
||||
<i className="bi-pencil-fill text-neutral text-xl"></i>
|
||||
</div>
|
||||
)}
|
||||
{searchFilter && setSearchFilter && (
|
||||
<FilterSearchDropdown
|
||||
searchFilter={searchFilter}
|
||||
setSearchFilter={setSearchFilter}
|
||||
/>
|
||||
)}
|
||||
<SortDropdown sortBy={sortBy} setSort={setSortBy} t={t} />
|
||||
<SortDropdown
|
||||
sortBy={sortBy}
|
||||
setSort={(value) => {
|
||||
setSortBy(value);
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editMode && links.length > 0 && (
|
||||
{links && editMode && links.length > 0 && (
|
||||
<div className="w-full flex justify-between items-center min-h-[32px]">
|
||||
{links.length > 0 && (
|
||||
<div className="flex gap-3 ml-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
onChange={() => handleSelectAll()}
|
||||
checked={
|
||||
selectedLinks.length === links.length && links.length > 0
|
||||
}
|
||||
/>
|
||||
{selectedLinks.length > 0 ? (
|
||||
<span>
|
||||
{selectedLinks.length === 1
|
||||
? t("link_selected")
|
||||
: t("links_selected", { count: selectedLinks.length })}
|
||||
</span>
|
||||
) : (
|
||||
<span>{t("nothing_selected")}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3 ml-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
onChange={() => handleSelectAll()}
|
||||
checked={
|
||||
selectedLinks.length === links.length && links.length > 0
|
||||
}
|
||||
/>
|
||||
{selectedLinks.length > 0 ? (
|
||||
<span>
|
||||
{selectedLinks.length === 1
|
||||
? t("link_selected")
|
||||
: t("links_selected", { count: selectedLinks.length })}
|
||||
</span>
|
||||
) : (
|
||||
<span>{t("nothing_selected")}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setBulkEditLinksModal(true)}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -7,11 +7,11 @@ import usePermissions from "@/hooks/usePermissions";
|
|||
import EditLinkModal from "@/components/ModalContent/EditLinkModal";
|
||||
import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal";
|
||||
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 { 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 = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
|
@ -39,41 +39,35 @@ export default function LinkActions({
|
|||
const [deleteLinkModal, setDeleteLinkModal] = 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 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({
|
||||
...link,
|
||||
pinnedBy: isAlreadyPinned ? undefined : [{ id: account.id }],
|
||||
});
|
||||
await updateLink.mutateAsync(
|
||||
{
|
||||
...link,
|
||||
pinnedBy: isAlreadyPinned ? undefined : [{ id: user.id }],
|
||||
},
|
||||
{
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
|
||||
toast.dismiss(load);
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(isAlreadyPinned ? t("link_unpinned") : t("link_unpinned"));
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.success(
|
||||
isAlreadyPinned ? t("link_unpinned") : t("link_pinned")
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -92,7 +86,7 @@ export default function LinkActions({
|
|||
<i title="More" className="bi-three-dots text-xl" />
|
||||
</div>
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
|
@ -104,6 +98,7 @@ export default function LinkActions({
|
|||
(document?.activeElement as HTMLElement)?.blur();
|
||||
pinLink();
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{link?.pinnedBy && link.pinnedBy[0]
|
||||
? t("unpin")
|
||||
|
@ -119,6 +114,7 @@ export default function LinkActions({
|
|||
(document?.activeElement as HTMLElement)?.blur();
|
||||
toggleShowInfo();
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{!linkInfo ? t("show_link_details") : t("hide_link_details")}
|
||||
</div>
|
||||
|
@ -133,6 +129,7 @@ export default function LinkActions({
|
|||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setEditLinkModal(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("edit_link")}
|
||||
</div>
|
||||
|
@ -147,6 +144,7 @@ export default function LinkActions({
|
|||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setPreservedFormatsModal(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("preserved_formats")}
|
||||
</div>
|
||||
|
@ -157,10 +155,27 @@ export default function LinkActions({
|
|||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
onClick={async (e) => {
|
||||
(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")}
|
||||
</div>
|
||||
|
@ -184,7 +199,7 @@ export default function LinkActions({
|
|||
{preservedFormatsModal && (
|
||||
<PreservedFormatsModal
|
||||
onClose={() => setPreservedFormatsModal(false)}
|
||||
activeLink={link}
|
||||
link={link}
|
||||
/>
|
||||
)}
|
||||
{/* {expandedLink ? (
|
||||
|
|
|
@ -5,7 +5,6 @@ import {
|
|||
} from "@/types/global";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import useCollectionStore from "@/store/collections";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||
|
@ -13,14 +12,16 @@ import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection
|
|||
import Image from "next/image";
|
||||
import { previewAvailable } from "@/lib/shared/getArchiveValidity";
|
||||
import Link from "next/link";
|
||||
import LinkIcon from "./LinkComponents/LinkIcon";
|
||||
import LinkIcon from "./LinkIcon";
|
||||
import useOnScreen from "@/hooks/useOnScreen";
|
||||
import { generateLinkHref } from "@/lib/client/generateLinkHref";
|
||||
import useAccountStore from "@/store/account";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
|
||||
import LinkTypeBadge from "./LinkTypeBadge";
|
||||
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 = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
|
@ -33,11 +34,16 @@ type Props = {
|
|||
export default function LinkCard({ link, flipDropdown, editMode }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const viewMode = localStorage.getItem("viewMode") || "card";
|
||||
const { collections } = useCollectionStore();
|
||||
const { account } = useAccountStore();
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const { links, getLink, setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
const { data: user = {} } = useUser();
|
||||
|
||||
const { setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
|
||||
const {
|
||||
data: { data: links = [] },
|
||||
} = useLinks();
|
||||
const getLink = useGetLink();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
|
@ -93,7 +99,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
|
|||
link.preview !== "unavailable"
|
||||
) {
|
||||
interval = setInterval(async () => {
|
||||
getLink(link.id as number);
|
||||
getLink.mutateAsync(link.id as number);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
|
@ -131,7 +137,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
|
|||
<div
|
||||
className="rounded-2xl cursor-pointer h-full flex flex-col justify-between"
|
||||
onClick={() =>
|
||||
!editMode && window.open(generateLinkHref(link, account), "_blank")
|
||||
!editMode && window.open(generateLinkHref(link, user), "_blank")
|
||||
}
|
||||
>
|
||||
<div>
|
|
@ -3,7 +3,6 @@ import {
|
|||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@/types/global";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
|
||||
export default function LinkCollection({
|
||||
|
@ -13,22 +12,22 @@ export default function LinkCollection({
|
|||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/collections/${link.collection.id}`}
|
||||
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}
|
||||
>
|
||||
<i
|
||||
className="bi-folder-fill text-lg drop-shadow"
|
||||
style={{ color: collection?.color }}
|
||||
></i>
|
||||
<p className="truncate capitalize">{collection?.name}</p>
|
||||
</Link>
|
||||
<>
|
||||
<Link
|
||||
href={`/collections/${link.collection.id}`}
|
||||
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}
|
||||
>
|
||||
<i
|
||||
className="bi-folder-fill text-lg drop-shadow"
|
||||
style={{ color: collection?.color }}
|
||||
></i>
|
||||
<p className="truncate capitalize">{collection?.name}</p>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@ import {
|
|||
} from "@/types/global";
|
||||
import { useEffect, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import useCollectionStore from "@/store/collections";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||
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 { isPWA } from "@/lib/client/utils";
|
||||
import { generateLinkHref } from "@/lib/client/generateLinkHref";
|
||||
import useAccountStore from "@/store/account";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
|
||||
import LinkTypeBadge from "./LinkTypeBadge";
|
||||
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 = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
|
@ -33,9 +34,12 @@ export default function LinkCardCompact({
|
|||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { collections } = useCollectionStore();
|
||||
const { account } = useAccountStore();
|
||||
const { links, setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const { data: user = {} } = useUser();
|
||||
const { setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
|
||||
const { links } = useLinks();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
|
@ -119,7 +123,7 @@ export default function LinkCardCompact({
|
|||
<div
|
||||
className="flex items-center cursor-pointer w-full"
|
||||
onClick={() =>
|
||||
!editMode && window.open(generateLinkHref(link, account), "_blank")
|
||||
!editMode && window.open(generateLinkHref(link, user), "_blank")
|
||||
}
|
||||
>
|
||||
<div className="shrink-0">
|
|
@ -5,7 +5,6 @@ import {
|
|||
} from "@/types/global";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import useCollectionStore from "@/store/collections";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||
|
@ -13,14 +12,16 @@ import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection
|
|||
import Image from "next/image";
|
||||
import { previewAvailable } from "@/lib/shared/getArchiveValidity";
|
||||
import Link from "next/link";
|
||||
import LinkIcon from "./LinkComponents/LinkIcon";
|
||||
import LinkIcon from "./LinkIcon";
|
||||
import useOnScreen from "@/hooks/useOnScreen";
|
||||
import { generateLinkHref } from "@/lib/client/generateLinkHref";
|
||||
import useAccountStore from "@/store/account";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
|
||||
import LinkTypeBadge from "./LinkTypeBadge";
|
||||
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 = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
|
@ -33,10 +34,13 @@ type Props = {
|
|||
export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { collections } = useCollectionStore();
|
||||
const { account } = useAccountStore();
|
||||
const { data: collections = [] } = useCollections();
|
||||
const { data: user = {} } = useUser();
|
||||
|
||||
const { links, getLink, setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
const { setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
|
||||
const { links } = useLinks();
|
||||
const getLink = useGetLink();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
|
@ -92,7 +96,7 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
|
|||
link.preview !== "unavailable"
|
||||
) {
|
||||
interval = setInterval(async () => {
|
||||
getLink(link.id as number);
|
||||
getLink.mutateAsync(link.id as number);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
|
@ -130,7 +134,7 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
|
|||
<div
|
||||
className="rounded-2xl cursor-pointer"
|
||||
onClick={() =>
|
||||
!editMode && window.open(generateLinkHref(link, account), "_blank")
|
||||
!editMode && window.open(generateLinkHref(link, user), "_blank")
|
||||
}
|
||||
>
|
||||
<div className="relative rounded-t-2xl overflow-hidden">
|
|
@ -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) : [];
|
|
@ -41,7 +41,7 @@ export default function MobileNavigation({}: Props) {
|
|||
<i className="bi-plus text-5xl pointer-events-none"></i>
|
||||
</span>
|
||||
</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>
|
||||
<div
|
||||
onClick={() => {
|
||||
|
@ -50,6 +50,7 @@ export default function MobileNavigation({}: Props) {
|
|||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("new_link")}
|
||||
</div>
|
||||
|
@ -62,6 +63,7 @@ export default function MobileNavigation({}: Props) {
|
|||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("upload_file")}
|
||||
</div>
|
||||
|
@ -74,6 +76,7 @@ export default function MobileNavigation({}: Props) {
|
|||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("new_collection")}
|
||||
</div>
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import React from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import toast from "react-hot-toast";
|
||||
import Modal from "../Modal";
|
||||
import Button from "../ui/Button";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useBulkDeleteLinks } from "@/hooks/store/links";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
|
@ -11,22 +12,29 @@ type Props = {
|
|||
|
||||
export default function BulkDeleteLinksModal({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { selectedLinks, setSelectedLinks, deleteLinksById } = useLinkStore();
|
||||
const { selectedLinks, setSelectedLinks } = useLinkStore();
|
||||
|
||||
const deleteLinksById = useBulkDeleteLinks();
|
||||
|
||||
const deleteLink = async () => {
|
||||
const load = toast.loading(t("deleting"));
|
||||
|
||||
const response = await deleteLinksById(
|
||||
selectedLinks.map((link) => link.id as number)
|
||||
await deleteLinksById.mutateAsync(
|
||||
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 (
|
||||
|
|
|
@ -6,6 +6,7 @@ import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
|||
import toast from "react-hot-toast";
|
||||
import Modal from "../Modal";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useBulkEditLinks } from "@/hooks/store/links";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
|
@ -13,13 +14,14 @@ type Props = {
|
|||
|
||||
export default function BulkEditLinksModal({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { updateLinks, selectedLinks, setSelectedLinks } = useLinkStore();
|
||||
const { selectedLinks, setSelectedLinks } = useLinkStore();
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const [removePreviousTags, setRemovePreviousTags] = useState(false);
|
||||
const [updatedValues, setUpdatedValues] = useState<
|
||||
Pick<LinkIncludingShortenedCollectionAndTags, "tags" | "collectionId">
|
||||
>({ tags: [] });
|
||||
|
||||
const updateLinks = useBulkEditLinks();
|
||||
const setCollection = (e: any) => {
|
||||
const collectionId = e?.value || null;
|
||||
setUpdatedValues((prevValues) => ({ ...prevValues, collectionId }));
|
||||
|
@ -36,22 +38,28 @@ export default function BulkEditLinksModal({ onClose }: Props) {
|
|||
|
||||
const load = toast.loading(t("updating"));
|
||||
|
||||
const response = await updateLinks(
|
||||
selectedLinks,
|
||||
removePreviousTags,
|
||||
updatedValues
|
||||
await updateLinks.mutateAsync(
|
||||
{
|
||||
links: selectedLinks,
|
||||
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);
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import useCollectionStore from "@/store/collections";
|
||||
import toast from "react-hot-toast";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||
import { useRouter } from "next/router";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import Modal from "../Modal";
|
||||
import Button from "../ui/Button";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useDeleteCollection } from "@/hooks/store/collections";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
|
@ -22,7 +22,6 @@ export default function DeleteCollectionModal({
|
|||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const { removeCollection } = useCollectionStore();
|
||||
const router = useRouter();
|
||||
const [inputField, setInputField] = useState("");
|
||||
const permissions = usePermissions(collection.id as number);
|
||||
|
@ -31,6 +30,8 @@ export default function DeleteCollectionModal({
|
|||
setCollection(activeCollection);
|
||||
}, []);
|
||||
|
||||
const deleteCollection = useDeleteCollection();
|
||||
|
||||
const submit = async () => {
|
||||
if (permissions === true && collection.name !== inputField) return;
|
||||
if (!submitLoader) {
|
||||
|
@ -41,17 +42,19 @@ export default function DeleteCollectionModal({
|
|||
|
||||
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 (response.ok) {
|
||||
toast.success(t("deleted"));
|
||||
onClose();
|
||||
router.push("/collections");
|
||||
} else {
|
||||
toast.error(response.data as string);
|
||||
}
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
onClose();
|
||||
toast.success(t("deleted"));
|
||||
router.push("/collections");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
setSubmitLoader(false);
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import toast from "react-hot-toast";
|
||||
import Modal from "../Modal";
|
||||
import { useRouter } from "next/router";
|
||||
import Button from "../ui/Button";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useDeleteLink } from "@/hooks/store/links";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
|
@ -16,31 +16,32 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
|
|||
const { t } = useTranslation();
|
||||
const [link, setLink] =
|
||||
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
|
||||
const { removeLink } = useLinkStore();
|
||||
|
||||
const deleteLink = useDeleteLink();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
setLink(activeLink);
|
||||
}, []);
|
||||
|
||||
const deleteLink = async () => {
|
||||
const submit = async () => {
|
||||
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 (response.ok) {
|
||||
toast.success(t("deleted"));
|
||||
} else {
|
||||
toast.error(response.data as string);
|
||||
}
|
||||
|
||||
if (router.pathname.startsWith("/links/[id]")) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
|
||||
onClose();
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
if (router.pathname.startsWith("/links/[id]")) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
toast.success(t("deleted"));
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -61,7 +62,7 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
|
|||
|
||||
<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" />
|
||||
{t("delete")}
|
||||
</Button>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import toast from "react-hot-toast";
|
||||
import Modal from "../Modal";
|
||||
import useUserStore from "@/store/admin/users";
|
||||
import Button from "../ui/Button";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useDeleteUser } from "@/hooks/store/admin/users";
|
||||
import { useState } from "react";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
|
@ -11,22 +11,22 @@ type Props = {
|
|||
|
||||
export default function DeleteUserModal({ onClose, userId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { removeUser } = useUserStore();
|
||||
|
||||
const deleteUser = async () => {
|
||||
const load = toast.loading(t("deleting_user"));
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
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) {
|
||||
toast.success(t("user_deleted"));
|
||||
} else {
|
||||
toast.error(response.data as string);
|
||||
setSubmitLoader(false);
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -45,7 +45,7 @@ export default function DeleteUserModal({ onClose, userId }: Props) {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<Button className="ml-auto" intent="destructive" onClick={deleteUser}>
|
||||
<Button className="ml-auto" intent="destructive" onClick={submit}>
|
||||
<i className="bi-trash text-xl" />
|
||||
{t("delete_confirmation")}
|
||||
</Button>
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import React, { useState } from "react";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import useCollectionStore from "@/store/collections";
|
||||
import toast from "react-hot-toast";
|
||||
import { HexColorPicker } from "react-colorful";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||
import Modal from "../Modal";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useUpdateCollection } from "@/hooks/store/collections";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
|
@ -21,7 +21,7 @@ export default function EditCollectionModal({
|
|||
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
|
||||
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const { updateCollection } = useCollectionStore();
|
||||
const updateCollection = useUpdateCollection();
|
||||
|
||||
const submit = async () => {
|
||||
if (!submitLoader) {
|
||||
|
@ -32,14 +32,18 @@ export default function EditCollectionModal({
|
|||
|
||||
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 (response.ok) {
|
||||
toast.success(t("updated"));
|
||||
onClose();
|
||||
} else toast.error(response.data as string);
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
onClose();
|
||||
toast.success(t("updated"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
setSubmitLoader(false);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import useCollectionStore from "@/store/collections";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
AccountSettings,
|
||||
|
@ -8,13 +7,14 @@ import {
|
|||
Member,
|
||||
} from "@/types/global";
|
||||
import getPublicUserData from "@/lib/client/getPublicUserData";
|
||||
import useAccountStore from "@/store/account";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import ProfilePhoto from "../ProfilePhoto";
|
||||
import addMemberToCollection from "@/lib/client/addMemberToCollection";
|
||||
import Modal from "../Modal";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useUpdateCollection } from "@/hooks/store/collections";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
|
@ -31,7 +31,7 @@ export default function EditCollectionSharingModal({
|
|||
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
|
||||
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const { updateCollection } = useCollectionStore();
|
||||
const updateCollection = useUpdateCollection();
|
||||
|
||||
const submit = async () => {
|
||||
if (!submitLoader) {
|
||||
|
@ -40,24 +40,26 @@ export default function EditCollectionSharingModal({
|
|||
|
||||
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);
|
||||
|
||||
toast.dismiss(load);
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(t("updated"));
|
||||
onClose();
|
||||
} else toast.error(response.data as string);
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
onClose();
|
||||
toast.success(t("updated"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
setSubmitLoader(false);
|
||||
}
|
||||
};
|
||||
|
||||
const { account } = useAccountStore();
|
||||
const { data: user = {} } = useUser();
|
||||
const permissions = usePermissions(collection.id as number);
|
||||
|
||||
const currentURL = new URL(document.URL);
|
||||
|
@ -163,7 +165,7 @@ export default function EditCollectionSharingModal({
|
|||
onKeyDown={(e) =>
|
||||
e.key === "Enter" &&
|
||||
addMemberToCollection(
|
||||
account.username as string,
|
||||
user.username as string,
|
||||
memberUsername || "",
|
||||
collection,
|
||||
setMemberState,
|
||||
|
@ -175,7 +177,7 @@ export default function EditCollectionSharingModal({
|
|||
<div
|
||||
onClick={() =>
|
||||
addMemberToCollection(
|
||||
account.username as string,
|
||||
user.username as string,
|
||||
memberUsername || "",
|
||||
collection,
|
||||
setMemberState,
|
||||
|
@ -273,7 +275,7 @@ export default function EditCollectionSharingModal({
|
|||
{roleLabel}
|
||||
<i className="bi-chevron-down"></i>
|
||||
</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>
|
||||
<label
|
||||
className="label cursor-pointer flex justify-start"
|
||||
|
@ -312,10 +314,12 @@ export default function EditCollectionSharingModal({
|
|||
}}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-bold">
|
||||
<p className="font-bold whitespace-nowrap">
|
||||
{t("viewer")}
|
||||
</p>
|
||||
<p>{t("viewer_desc")}</p>
|
||||
<p className="whitespace-nowrap">
|
||||
{t("viewer_desc")}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
|
@ -357,10 +361,12 @@ export default function EditCollectionSharingModal({
|
|||
}}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-bold">
|
||||
<p className="font-bold whitespace-nowrap">
|
||||
{t("contributor")}
|
||||
</p>
|
||||
<p>{t("contributor_desc")}</p>
|
||||
<p className="whitespace-nowrap">
|
||||
{t("contributor_desc")}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
|
@ -402,10 +408,12 @@ export default function EditCollectionSharingModal({
|
|||
}}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-bold">
|
||||
<p className="font-bold whitespace-nowrap">
|
||||
{t("admin")}
|
||||
</p>
|
||||
<p>{t("admin_desc")}</p>
|
||||
<p className="whitespace-nowrap">
|
||||
{t("admin_desc")}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
|
|
|
@ -3,12 +3,12 @@ import CollectionSelection from "@/components/InputSelect/CollectionSelection";
|
|||
import TagSelection from "@/components/InputSelect/TagSelection";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import useLinkStore from "@/store/links";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import toast from "react-hot-toast";
|
||||
import Link from "next/link";
|
||||
import Modal from "../Modal";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useUpdateLink } from "@/hooks/store/links";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
|
@ -27,9 +27,10 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
|
|||
console.log(error);
|
||||
}
|
||||
|
||||
const { updateLink } = useLinkStore();
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
|
||||
const updateLink = useUpdateLink();
|
||||
|
||||
const setCollection = (e: any) => {
|
||||
if (e?.__isNew__) e.value = null;
|
||||
setLink({
|
||||
|
@ -50,19 +51,23 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
|
|||
const submit = async () => {
|
||||
if (!submitLoader) {
|
||||
setSubmitLoader(true);
|
||||
const load = toast.loading(t("updating"));
|
||||
let response = await updateLink(link);
|
||||
toast.dismiss(load);
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(t("updated"));
|
||||
onClose();
|
||||
} else {
|
||||
toast.error(response.data as string);
|
||||
}
|
||||
const load = toast.loading(t("updating"));
|
||||
|
||||
await updateLink.mutateAsync(link, {
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
onClose();
|
||||
toast.success(t("updated"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
setSubmitLoader(false);
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -1,14 +1,12 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import useCollectionStore from "@/store/collections";
|
||||
import toast from "react-hot-toast";
|
||||
import { HexColorPicker } from "react-colorful";
|
||||
import { Collection } from "@prisma/client";
|
||||
import Modal from "../Modal";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||
import useAccountStore from "@/store/account";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCreateCollection } from "@/hooks/store/collections";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
|
@ -25,15 +23,14 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
|
|||
} as Partial<Collection>;
|
||||
|
||||
const [collection, setCollection] = useState<Partial<Collection>>(initial);
|
||||
const { setAccount } = useAccountStore();
|
||||
const { data } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
setCollection(initial);
|
||||
}, []);
|
||||
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const { addCollection } = useCollectionStore();
|
||||
|
||||
const createCollection = useCreateCollection();
|
||||
|
||||
const submit = async () => {
|
||||
if (submitLoader) return;
|
||||
|
@ -43,16 +40,18 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
|
|||
|
||||
const load = toast.loading(t("creating"));
|
||||
|
||||
let response = await addCollection(collection as any);
|
||||
toast.dismiss(load);
|
||||
await createCollection.mutateAsync(collection, {
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(t("created_success"));
|
||||
if (response.data) {
|
||||
setAccount(data?.user.id as number);
|
||||
onClose();
|
||||
}
|
||||
} else toast.error(response.data as string);
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
onClose();
|
||||
toast.success(t("created"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
setSubmitLoader(false);
|
||||
};
|
||||
|
|
|
@ -1,17 +1,16 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
|
||||
import TagSelection from "@/components/InputSelect/TagSelection";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import useCollectionStore from "@/store/collections";
|
||||
import useLinkStore from "@/store/links";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import toast from "react-hot-toast";
|
||||
import Modal from "../Modal";
|
||||
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 = {
|
||||
onClose: Function;
|
||||
|
@ -40,11 +39,13 @@ export default function NewLinkModal({ onClose }: Props) {
|
|||
|
||||
const [link, setLink] =
|
||||
useState<LinkIncludingShortenedCollectionAndTags>(initial);
|
||||
const { addLink } = useLinkStore();
|
||||
|
||||
const addLink = useAddLink();
|
||||
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const [optionsExpanded, setOptionsExpanded] = useState(false);
|
||||
const router = useRouter();
|
||||
const { collections } = useCollectionStore();
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const setCollection = (e: any) => {
|
||||
if (e?.__isNew__) e.value = null;
|
||||
|
@ -87,15 +88,22 @@ export default function NewLinkModal({ onClose }: Props) {
|
|||
const submit = async () => {
|
||||
if (!submitLoader) {
|
||||
setSubmitLoader(true);
|
||||
|
||||
const load = toast.loading(t("creating_link"));
|
||||
const response = await addLink(link);
|
||||
toast.dismiss(load);
|
||||
if (response.ok) {
|
||||
toast.success(t("link_created"));
|
||||
onClose();
|
||||
} else {
|
||||
toast.error(response.data as string);
|
||||
}
|
||||
|
||||
await addLink.mutateAsync(link, {
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
onClose();
|
||||
toast.success(t("link_created"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
setSubmitLoader(false);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -3,10 +3,10 @@ import TextInput from "@/components/TextInput";
|
|||
import { TokenExpiry } from "@/types/global";
|
||||
import toast from "react-hot-toast";
|
||||
import Modal from "../Modal";
|
||||
import useTokenStore from "@/store/tokens";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
import Button from "../ui/Button";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useAddToken } from "@/hooks/store/tokens";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
|
@ -15,7 +15,7 @@ type Props = {
|
|||
export default function NewTokenModal({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [newToken, setNewToken] = useState("");
|
||||
const { addToken } = useTokenStore();
|
||||
const addToken = useAddToken();
|
||||
|
||||
const initial = {
|
||||
name: "",
|
||||
|
@ -28,16 +28,20 @@ export default function NewTokenModal({ onClose }: Props) {
|
|||
const submit = async () => {
|
||||
if (!submitLoader) {
|
||||
setSubmitLoader(true);
|
||||
|
||||
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 (ok) {
|
||||
toast.success(t("token_created"));
|
||||
setNewToken((data as any).secretKey);
|
||||
} else toast.error(data as string);
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
setNewToken(data.secretKey);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
setSubmitLoader(false);
|
||||
}
|
||||
|
@ -111,7 +115,7 @@ export default function NewTokenModal({ onClose }: Props) {
|
|||
>
|
||||
{getLabel(token.expires)}
|
||||
</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>
|
||||
<label
|
||||
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>
|
||||
</li>
|
||||
<li>
|
||||
|
@ -150,7 +156,9 @@ export default function NewTokenModal({ onClose }: Props) {
|
|||
setToken({ ...token, expires: TokenExpiry.oneMonth });
|
||||
}}
|
||||
/>
|
||||
<span className="label-text">{t("30_days")}</span>
|
||||
<span className="label-text whitespace-nowrap">
|
||||
{t("30_days")}
|
||||
</span>
|
||||
</label>
|
||||
</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>
|
||||
</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>
|
||||
</li>
|
||||
<li>
|
||||
|
@ -213,7 +225,9 @@ export default function NewTokenModal({ onClose }: Props) {
|
|||
setToken({ ...token, expires: TokenExpiry.never });
|
||||
}}
|
||||
/>
|
||||
<span className="label-text">{t("no_expiration")}</span>
|
||||
<span className="label-text whitespace-nowrap">
|
||||
{t("no_expiration")}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import toast from "react-hot-toast";
|
||||
import Modal from "../Modal";
|
||||
import useUserStore from "@/store/admin/users";
|
||||
import TextInput from "../TextInput";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useTranslation, Trans } from "next-i18next";
|
||||
import { useAddUser } from "@/hooks/store/admin/users";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
|
@ -20,7 +20,9 @@ const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true";
|
|||
|
||||
export default function NewUserModal({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { addUser } = useUserStore();
|
||||
|
||||
const addUser = useAddUser();
|
||||
|
||||
const [form, setForm] = useState<FormData>({
|
||||
name: "",
|
||||
username: "",
|
||||
|
@ -44,24 +46,15 @@ export default function NewUserModal({ onClose }: Props) {
|
|||
};
|
||||
|
||||
if (checkFields()) {
|
||||
if (form.password.length < 8)
|
||||
return toast.error(t("password_length_error"));
|
||||
|
||||
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);
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(t("user_created"));
|
||||
onClose();
|
||||
} else {
|
||||
toast.error(response.data as string);
|
||||
}
|
||||
} else {
|
||||
toast.error(t("fill_all_fields_error"));
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
ArchivedFormat,
|
||||
|
@ -17,23 +16,22 @@ import {
|
|||
screenshotAvailable,
|
||||
} from "@/lib/shared/getArchiveValidity";
|
||||
import PreservedFormatRow from "@/components/PreserverdFormatRow";
|
||||
import useAccountStore from "@/store/account";
|
||||
import getPublicUserData from "@/lib/client/getPublicUserData";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { BeatLoader } from "react-spinners";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { useGetLink } from "@/hooks/store/links";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
activeLink: LinkIncludingShortenedCollectionAndTags;
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
};
|
||||
|
||||
export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
|
||||
export default function PreservedFormatsModal({ onClose, link }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const { getLink } = useLinkStore();
|
||||
const { account } = useAccountStore();
|
||||
const [link, setLink] =
|
||||
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
|
||||
const getLink = useGetLink();
|
||||
const { data: user = {} } = useUser();
|
||||
const router = useRouter();
|
||||
|
||||
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
|
||||
|
@ -44,20 +42,20 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
|
|||
|
||||
useEffect(() => {
|
||||
const fetchOwner = async () => {
|
||||
if (link.collection.ownerId !== account.id) {
|
||||
if (link.collection.ownerId !== user.id) {
|
||||
const owner = await getPublicUserData(
|
||||
link.collection.ownerId as number
|
||||
);
|
||||
setCollectionOwner(owner);
|
||||
} else if (link.collection.ownerId === account.id) {
|
||||
} else if (link.collection.ownerId === user.id) {
|
||||
setCollectionOwner({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
username: account.username,
|
||||
image: account.image,
|
||||
archiveAsScreenshot: account.archiveAsScreenshot,
|
||||
archiveAsMonolith: account.archiveAsScreenshot,
|
||||
archiveAsPDF: account.archiveAsPDF,
|
||||
id: user.id as number,
|
||||
name: user.name,
|
||||
username: user.username as string,
|
||||
image: user.image as string,
|
||||
archiveAsScreenshot: user.archiveAsScreenshot as boolean,
|
||||
archiveAsMonolith: user.archiveAsScreenshot as boolean,
|
||||
archiveAsPDF: user.archiveAsPDF as boolean,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -93,20 +91,14 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
|
|||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const data = await getLink(link.id as number, isPublic);
|
||||
setLink(
|
||||
(data as any).response as LinkIncludingShortenedCollectionAndTags
|
||||
);
|
||||
await getLink.mutateAsync(link.id as number);
|
||||
})();
|
||||
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
|
||||
if (!isReady()) {
|
||||
interval = setInterval(async () => {
|
||||
const data = await getLink(link.id as number, isPublic);
|
||||
setLink(
|
||||
(data as any).response as LinkIncludingShortenedCollectionAndTags
|
||||
);
|
||||
await getLink.mutateAsync(link.id as number);
|
||||
}, 5000);
|
||||
} else {
|
||||
if (interval) {
|
||||
|
@ -132,10 +124,8 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
|
|||
toast.dismiss(load);
|
||||
|
||||
if (response.ok) {
|
||||
const newLink = await getLink(link?.id as number);
|
||||
setLink(
|
||||
(newLink as any).response as LinkIncludingShortenedCollectionAndTags
|
||||
);
|
||||
await getLink.mutateAsync(link?.id as number);
|
||||
|
||||
toast.success(t("link_being_archived"));
|
||||
} 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>
|
||||
<div className="divider mb-2 mt-1"></div>
|
||||
{screenshotAvailable(link) ||
|
||||
pdfAvailable(link) ||
|
||||
readabilityAvailable(link) ||
|
||||
monolithAvailable(link) ? (
|
||||
pdfAvailable(link) ||
|
||||
readabilityAvailable(link) ||
|
||||
monolithAvailable(link) ? (
|
||||
<p className="mb-3">{t("available_formats")}</p>
|
||||
) : (
|
||||
""
|
||||
|
@ -159,7 +149,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
|
|||
name={t("webpage")}
|
||||
icon={"bi-filetype-html"}
|
||||
format={ArchivedFormat.monolith}
|
||||
activeLink={link}
|
||||
link={link}
|
||||
downloadable={true}
|
||||
/>
|
||||
)}
|
||||
|
@ -173,7 +163,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
|
|||
? ArchivedFormat.png
|
||||
: ArchivedFormat.jpeg
|
||||
}
|
||||
activeLink={link}
|
||||
link={link}
|
||||
downloadable={true}
|
||||
/>
|
||||
)}
|
||||
|
@ -183,7 +173,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
|
|||
name={t("pdf")}
|
||||
icon={"bi-file-earmark-pdf"}
|
||||
format={ArchivedFormat.pdf}
|
||||
activeLink={link}
|
||||
link={link}
|
||||
downloadable={true}
|
||||
/>
|
||||
)}
|
||||
|
@ -193,7 +183,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
|
|||
name={t("readable")}
|
||||
icon={"bi-file-earmark-text"}
|
||||
format={ArchivedFormat.readability}
|
||||
activeLink={link}
|
||||
link={link}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
@ -224,9 +214,8 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
|
|||
)}
|
||||
|
||||
<div
|
||||
className={`flex flex-col sm:flex-row gap-3 items-center justify-center ${
|
||||
isReady() ? "sm:mt " : ""
|
||||
}`}
|
||||
className={`flex flex-col sm:flex-row gap-3 items-center justify-center ${isReady() ? "sm:mt " : ""
|
||||
}`}
|
||||
>
|
||||
<Link
|
||||
href={`https://web.archive.org/web/${link?.url?.replace(
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import useTokenStore from "@/store/tokens";
|
||||
import toast from "react-hot-toast";
|
||||
import Modal from "../Modal";
|
||||
import Button from "../ui/Button";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { AccessToken } from "@prisma/client";
|
||||
import { useRevokeToken } from "@/hooks/store/tokens";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
|
@ -15,7 +15,7 @@ export default function DeleteTokenModal({ onClose, activeToken }: Props) {
|
|||
const { t } = useTranslation();
|
||||
const [token, setToken] = useState<AccessToken>(activeToken);
|
||||
|
||||
const { revokeToken } = useTokenStore();
|
||||
const revokeToken = useRevokeToken();
|
||||
|
||||
useEffect(() => {
|
||||
setToken(activeToken);
|
||||
|
@ -24,17 +24,18 @@ export default function DeleteTokenModal({ onClose, activeToken }: Props) {
|
|||
const deleteLink = async () => {
|
||||
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 (response.ok) {
|
||||
toast.success(t("token_revoked"));
|
||||
} else {
|
||||
toast.error(response.data as string);
|
||||
}
|
||||
|
||||
onClose();
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
onClose();
|
||||
toast.success(t("token_revoked"));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -3,8 +3,6 @@ import CollectionSelection from "@/components/InputSelect/CollectionSelection";
|
|||
import TagSelection from "@/components/InputSelect/TagSelection";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import useCollectionStore from "@/store/collections";
|
||||
import useLinkStore from "@/store/links";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
ArchivedFormat,
|
||||
|
@ -14,6 +12,8 @@ import { useRouter } from "next/router";
|
|||
import toast from "react-hot-toast";
|
||||
import Modal from "../Modal";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCollections } from "@/hooks/store/collections";
|
||||
import { useUploadFile } from "@/hooks/store/links";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
|
@ -45,11 +45,11 @@ export default function UploadFileModal({ onClose }: Props) {
|
|||
useState<LinkIncludingShortenedCollectionAndTags>(initial);
|
||||
const [file, setFile] = useState<File>();
|
||||
|
||||
const { uploadFile } = useLinkStore();
|
||||
const uploadFile = useUploadFile();
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const [optionsExpanded, setOptionsExpanded] = useState(false);
|
||||
const router = useRouter();
|
||||
const { collections } = useCollectionStore();
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const setCollection = (e: any) => {
|
||||
if (e?.__isNew__) e.value = null;
|
||||
|
@ -115,20 +115,26 @@ export default function UploadFileModal({ onClose }: Props) {
|
|||
// }
|
||||
|
||||
setSubmitLoader(true);
|
||||
|
||||
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 (response.ok) {
|
||||
toast.success(t("created_success"));
|
||||
onClose();
|
||||
} else {
|
||||
toast.error(response.data as string);
|
||||
}
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
onClose();
|
||||
toast.success(t("created_success"));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setSubmitLoader(false);
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -66,7 +66,7 @@ export default function Navbar() {
|
|||
</span>
|
||||
</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>
|
||||
<div
|
||||
onClick={() => {
|
||||
|
@ -75,6 +75,7 @@ export default function Navbar() {
|
|||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("new_link")}
|
||||
</div>
|
||||
|
@ -87,6 +88,7 @@ export default function Navbar() {
|
|||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("upload_file")}
|
||||
</div>
|
||||
|
@ -99,6 +101,7 @@ export default function Navbar() {
|
|||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("new_collection")}
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import {
|
||||
ArchivedFormat,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
|
@ -11,7 +9,7 @@ type Props = {
|
|||
name: string;
|
||||
icon: string;
|
||||
format: ArchivedFormat;
|
||||
activeLink: LinkIncludingShortenedCollectionAndTags;
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
downloadable?: boolean;
|
||||
};
|
||||
|
||||
|
@ -19,43 +17,13 @@ export default function PreservedFormatRow({
|
|||
name,
|
||||
icon,
|
||||
format,
|
||||
activeLink,
|
||||
link,
|
||||
downloadable,
|
||||
}: Props) {
|
||||
const { getLink } = useLinkStore();
|
||||
|
||||
const [link, setLink] =
|
||||
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
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 path = `/api/v1/archives/${link?.id}?format=${format}`;
|
||||
fetch(path)
|
||||
|
@ -90,20 +58,18 @@ export default function PreservedFormatRow({
|
|||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
{downloadable ||
|
||||
(false && (
|
||||
<div
|
||||
onClick={() => handleDownload()}
|
||||
className="btn btn-sm btn-square"
|
||||
>
|
||||
<i className="bi-cloud-arrow-down text-xl text-neutral" />
|
||||
</div>
|
||||
))}
|
||||
{downloadable || false ? (
|
||||
<div
|
||||
onClick={() => handleDownload()}
|
||||
className="btn btn-sm btn-square"
|
||||
>
|
||||
<i className="bi-cloud-arrow-down text-xl text-neutral" />
|
||||
</div>
|
||||
) : undefined}
|
||||
|
||||
<Link
|
||||
href={`${
|
||||
isPublic ? "/public" : ""
|
||||
}/preserved/${link?.id}?format=${format}`}
|
||||
href={`${isPublic ? "/public" : ""
|
||||
}/preserved/${link?.id}?format=${format}`}
|
||||
target="_blank"
|
||||
className="btn btn-sm btn-square"
|
||||
>
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
import ProfilePhoto from "./ProfilePhoto";
|
||||
import useAccountStore from "@/store/account";
|
||||
import Link from "next/link";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
export default function ProfileDropdown() {
|
||||
const { t } = useTranslation();
|
||||
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 newTheme = settings.theme === "dark" ? "light" : "dark";
|
||||
|
@ -27,14 +27,12 @@ export default function ProfileDropdown() {
|
|||
className="btn btn-circle btn-ghost"
|
||||
>
|
||||
<ProfilePhoto
|
||||
src={account.image ? account.image : undefined}
|
||||
src={user.image ? user.image : undefined}
|
||||
priority={true}
|
||||
/>
|
||||
</div>
|
||||
<ul
|
||||
className={`dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box ${
|
||||
isAdmin ? "w-48" : "w-40"
|
||||
} mt-1`}
|
||||
className={`dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1`}
|
||||
>
|
||||
<li>
|
||||
<Link
|
||||
|
@ -42,6 +40,7 @@ export default function ProfileDropdown() {
|
|||
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("settings")}
|
||||
</Link>
|
||||
|
@ -54,6 +53,7 @@ export default function ProfileDropdown() {
|
|||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("switch_to", {
|
||||
theme: settings.theme === "light" ? t("dark") : t("light"),
|
||||
|
@ -67,6 +67,7 @@ export default function ProfileDropdown() {
|
|||
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("server_administration")}
|
||||
</Link>
|
||||
|
@ -80,6 +81,7 @@ export default function ProfileDropdown() {
|
|||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("logout")}
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import { readabilityAvailable } from "@/lib/shared/getArchiveValidity";
|
||||
import isValidUrl from "@/lib/shared/isValidUrl";
|
||||
import useLinkStore from "@/store/links";
|
||||
import {
|
||||
ArchivedFormat,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
|
@ -14,8 +13,9 @@ import Link from "next/link";
|
|||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import LinkActions from "./LinkViews/LinkComponents/LinkActions";
|
||||
import useCollectionStore from "@/store/collections";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCollections } from "@/hooks/store/collections";
|
||||
import { useGetLink } from "@/hooks/store/links";
|
||||
|
||||
type LinkContent = {
|
||||
title: string;
|
||||
|
@ -45,8 +45,8 @@ export default function ReadableView({ link }: Props) {
|
|||
|
||||
const router = useRouter();
|
||||
|
||||
const { getLink } = useLinkStore();
|
||||
const { collections } = useCollectionStore();
|
||||
const getLink = useGetLink();
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const collection = useMemo(() => {
|
||||
return collections.find(
|
||||
|
@ -73,7 +73,7 @@ export default function ReadableView({ link }: Props) {
|
|||
}, [link]);
|
||||
|
||||
useEffect(() => {
|
||||
if (link) getLink(link?.id as number);
|
||||
if (link) getLink.mutateAsync(link?.id as number);
|
||||
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
if (
|
||||
|
@ -87,7 +87,10 @@ export default function ReadableView({ link }: Props) {
|
|||
!link?.readable ||
|
||||
!link?.monolith)
|
||||
) {
|
||||
interval = setInterval(() => getLink(link.id as number), 5000);
|
||||
interval = setInterval(
|
||||
() => getLink.mutateAsync(link.id as number),
|
||||
5000
|
||||
);
|
||||
} else {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import useCollectionStore from "@/store/collections";
|
||||
import useTagStore from "@/store/tags";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
|
@ -7,6 +5,8 @@ import { Disclosure, Transition } from "@headlessui/react";
|
|||
import SidebarHighlightLink from "@/components/SidebarHighlightLink";
|
||||
import CollectionListing from "@/components/CollectionListing";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCollections } from "@/hooks/store/collections";
|
||||
import { useTags } from "@/hooks/store/tags";
|
||||
|
||||
export default function Sidebar({ className }: { className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
@ -22,8 +22,9 @@ export default function Sidebar({ className }: { className?: string }) {
|
|||
}
|
||||
);
|
||||
|
||||
const { collections } = useCollectionStore();
|
||||
const { tags } = useTagStore();
|
||||
const { data: collections } = useCollections();
|
||||
|
||||
const { data: tags = [], isLoading } = useTags();
|
||||
const [active, setActive] = useState("");
|
||||
|
||||
const router = useRouter();
|
||||
|
@ -127,10 +128,16 @@ export default function Sidebar({ className }: { className?: string }) {
|
|||
leaveTo="transform opacity-0 -translate-y-3"
|
||||
>
|
||||
<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
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((e, i) => {
|
||||
.sort((a: any, b: any) => a.name.localeCompare(b.name))
|
||||
.map((e: any, i: any) => {
|
||||
return (
|
||||
<Link key={i} href={`/tags/${e.id}`}>
|
||||
<div
|
||||
|
|
|
@ -14,6 +14,7 @@ export default function SidebarHighlightLink({
|
|||
return (
|
||||
<Link href={href}>
|
||||
<div
|
||||
title={title}
|
||||
className={`${
|
||||
active || false
|
||||
? "bg-primary/20"
|
||||
|
@ -28,7 +29,7 @@ export default function SidebarHighlightLink({
|
|||
<i className={`${icon} text-primary text-2xl drop-shadow`}></i>
|
||||
</div>
|
||||
<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>
|
||||
</Link>
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import React, { Dispatch, SetStateAction } from "react";
|
||||
import React, { Dispatch, SetStateAction, useEffect } from "react";
|
||||
import { Sort } from "@/types/global";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
import { TFunction } from "i18next";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
|
||||
type Props = {
|
||||
sortBy: Sort;
|
||||
|
@ -10,6 +11,12 @@ type Props = {
|
|||
};
|
||||
|
||||
export default function SortDropdown({ sortBy, setSort, t }: Props) {
|
||||
const { updateSettings } = useLocalSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
updateSettings({ sortBy });
|
||||
}, [sortBy]);
|
||||
|
||||
return (
|
||||
<div className="dropdown dropdown-bottom dropdown-end">
|
||||
<div
|
||||
|
@ -20,7 +27,7 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
|
|||
>
|
||||
<i className="bi-chevron-expand text-neutral text-2xl"></i>
|
||||
</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>
|
||||
<label
|
||||
className="label cursor-pointer flex justify-start"
|
||||
|
@ -34,7 +41,9 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
|
|||
checked={sortBy === 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>
|
||||
</li>
|
||||
<li>
|
||||
|
@ -50,7 +59,9 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
|
|||
checked={sortBy === 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>
|
||||
</li>
|
||||
<li>
|
||||
|
@ -66,7 +77,7 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
|
|||
checked={sortBy === 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>
|
||||
</li>
|
||||
<li>
|
||||
|
@ -82,7 +93,7 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
|
|||
checked={sortBy === 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>
|
||||
</li>
|
||||
<li>
|
||||
|
@ -98,7 +109,9 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
|
|||
checked={sortBy === 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>
|
||||
</li>
|
||||
<li>
|
||||
|
@ -114,7 +127,9 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
|
|||
checked={sortBy === 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>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
@ -4,8 +4,8 @@ import useLocalSettingsStore from "@/store/localSettings";
|
|||
import { ViewMode } from "@/types/global";
|
||||
|
||||
type Props = {
|
||||
viewMode: string;
|
||||
setViewMode: Dispatch<SetStateAction<string>>;
|
||||
viewMode: ViewMode;
|
||||
setViewMode: Dispatch<SetStateAction<ViewMode>>;
|
||||
};
|
||||
|
||||
export default function ViewDropdown({ viewMode, setViewMode }: Props) {
|
||||
|
@ -19,7 +19,7 @@ export default function ViewDropdown({ viewMode, setViewMode }: Props) {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateSettings({ viewMode: viewMode as ViewMode });
|
||||
updateSettings({ viewMode });
|
||||
}, [viewMode]);
|
||||
|
||||
return (
|
||||
|
|
|
@ -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;
|
|
@ -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 };
|
|
@ -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,
|
||||
};
|
|
@ -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 };
|
|
@ -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,
|
||||
};
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -1,11 +1,12 @@
|
|||
import useAccountStore from "@/store/account";
|
||||
import useCollectionStore from "@/store/collections";
|
||||
import { Member } from "@/types/global";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCollections } from "./store/collections";
|
||||
import { useUser } from "./store/user";
|
||||
|
||||
export default function useCollectivePermissions(collectionIds: number[]) {
|
||||
const { collections } = useCollectionStore();
|
||||
const { account } = useAccountStore();
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const { data: user = {} } = useUser();
|
||||
|
||||
const [permissions, setPermissions] = useState<Member | true>();
|
||||
useEffect(() => {
|
||||
|
@ -14,7 +15,7 @@ export default function useCollectivePermissions(collectionIds: number[]) {
|
|||
|
||||
if (collection) {
|
||||
let getPermission: Member | undefined = collection.members.find(
|
||||
(e) => e.userId === account.id
|
||||
(e) => e.userId === user.id
|
||||
);
|
||||
|
||||
if (
|
||||
|
@ -24,10 +25,10 @@ export default function useCollectivePermissions(collectionIds: number[]) {
|
|||
)
|
||||
getPermission = undefined;
|
||||
|
||||
setPermissions(account.id === collection.ownerId || getPermission);
|
||||
setPermissions(user.id === collection.ownerId || getPermission);
|
||||
}
|
||||
}
|
||||
}, [account, collections, collectionIds]);
|
||||
}, [user, collections, collectionIds]);
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
|
|
@ -1,34 +1,14 @@
|
|||
import useCollectionStore from "@/store/collections";
|
||||
import { useEffect } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import useTagStore from "@/store/tags";
|
||||
import useAccountStore from "@/store/account";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
|
||||
export default function useInitialData() {
|
||||
const { status, data } = useSession();
|
||||
const { setCollections } = useCollectionStore();
|
||||
const { setTags } = useTagStore();
|
||||
// const { setLinks } = useLinkStore();
|
||||
const { account, setAccount } = useAccountStore();
|
||||
const { setSettings } = useLocalSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
setSettings();
|
||||
if (status === "authenticated") {
|
||||
// Get account info
|
||||
setAccount(data?.user.id as number);
|
||||
}
|
||||
}, [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;
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
}
|
|
@ -1,12 +1,12 @@
|
|||
import useAccountStore from "@/store/account";
|
||||
import useCollectionStore from "@/store/collections";
|
||||
import { Member } from "@/types/global";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCollections } from "./store/collections";
|
||||
import { useUser } from "./store/user";
|
||||
|
||||
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>();
|
||||
useEffect(() => {
|
||||
|
@ -14,7 +14,7 @@ export default function usePermissions(collectionId: number) {
|
|||
|
||||
if (collection) {
|
||||
let getPermission: Member | undefined = collection.members.find(
|
||||
(e) => e.userId === account.id
|
||||
(e) => e.userId === user.id
|
||||
);
|
||||
|
||||
if (
|
||||
|
@ -24,9 +24,9 @@ export default function usePermissions(collectionId: number) {
|
|||
)
|
||||
getPermission = undefined;
|
||||
|
||||
setPermissions(account.id === collection.ownerId || getPermission);
|
||||
setPermissions(user.id === collection.ownerId || getPermission);
|
||||
}
|
||||
}, [account, collections, collectionId]);
|
||||
}, [user, collections, collectionId]);
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import { ReactNode, useEffect, useState } from "react";
|
|||
import { useRouter } from "next/router";
|
||||
import { useSession } from "next-auth/react";
|
||||
import useInitialData from "@/hooks/useInitialData";
|
||||
import useAccountStore from "@/store/account";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
|
@ -14,7 +14,7 @@ export default function AuthRedirect({ children }: Props) {
|
|||
const router = useRouter();
|
||||
const { status } = useSession();
|
||||
const [shouldRenderChildren, setShouldRenderChildren] = useState(false);
|
||||
const { account } = useAccountStore();
|
||||
const { data: user = {} } = useUser();
|
||||
|
||||
useInitialData();
|
||||
|
||||
|
@ -23,7 +23,7 @@ export default function AuthRedirect({ children }: Props) {
|
|||
const isUnauthenticated = status === "unauthenticated";
|
||||
const isPublicPage = router.pathname.startsWith("/public");
|
||||
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
|
||||
const routes = [
|
||||
|
@ -63,7 +63,7 @@ export default function AuthRedirect({ children }: Props) {
|
|||
setShouldRenderChildren(true);
|
||||
}
|
||||
}
|
||||
}, [status, account, router.pathname]);
|
||||
}, [status, user, router.pathname]);
|
||||
|
||||
function redirectTo(destination: string) {
|
||||
router.push(destination).then(() => setShouldRenderChildren(true));
|
||||
|
|
|
@ -42,7 +42,7 @@ export default async function getDashboardData(
|
|||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: order || { id: "desc" },
|
||||
orderBy: order,
|
||||
});
|
||||
|
||||
const recentlyAddedLinks = await prisma.link.findMany({
|
||||
|
@ -67,11 +67,19 @@ export default async function getDashboardData(
|
|||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: order || { id: "desc" },
|
||||
orderBy: order,
|
||||
});
|
||||
|
||||
const links = [...recentlyAddedLinks, ...pinnedLinks].sort(
|
||||
(a, b) => new Date(b.id).getTime() - new Date(a.id).getTime()
|
||||
const combinedLinks = [...recentlyAddedLinks, ...pinnedLinks];
|
||||
|
||||
const uniqueLinks = Array.from(
|
||||
combinedLinks
|
||||
.reduce((map, item) => map.set(item.id, item), new Map())
|
||||
.values()
|
||||
);
|
||||
|
||||
const links = uniqueLinks.sort(
|
||||
(a, b) => (new Date(b.id) as any) - (new Date(a.id) as any)
|
||||
);
|
||||
|
||||
return { response: links, status: 200 };
|
||||
|
|
|
@ -146,7 +146,7 @@ export default async function getLink(userId: number, query: LinkRequestQuery) {
|
|||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: order || { id: "desc" },
|
||||
orderBy: order,
|
||||
});
|
||||
|
||||
return { response: links, status: 200 };
|
||||
|
|
|
@ -86,9 +86,11 @@ export default async function postLink(
|
|||
else if (contentType === "image/png") imageExtension = "png";
|
||||
}
|
||||
|
||||
if (!link.tags) link.tags = [];
|
||||
|
||||
const newLink = await prisma.link.create({
|
||||
data: {
|
||||
url: link.url?.trim().replace(/\/+$/, "") || null,
|
||||
url: link.url?.trim() || null,
|
||||
name,
|
||||
description: link.description,
|
||||
type: linkType,
|
||||
|
@ -98,7 +100,7 @@ export default async function postLink(
|
|||
},
|
||||
},
|
||||
tags: {
|
||||
connectOrCreate: link.tags.map((tag) => ({
|
||||
connectOrCreate: link.tags?.map((tag) => ({
|
||||
where: {
|
||||
name_ownerId: {
|
||||
name: tag.name.trim(),
|
||||
|
|
|
@ -49,12 +49,18 @@ export default async function postUser(
|
|||
|
||||
// Check username (if email was disabled)
|
||||
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 {
|
||||
response:
|
||||
"Username has to be between 3-30 characters, no spaces and special characters are allowed.",
|
||||
status: 400,
|
||||
};
|
||||
else if (!body.username) {
|
||||
body.username = autoGeneratedUsername;
|
||||
}
|
||||
|
||||
const checkIfUserExists = await prisma.user.findFirst({
|
||||
where: {
|
||||
|
@ -89,7 +95,8 @@ export default async function postUser(
|
|||
data: {
|
||||
name: body.name,
|
||||
username: emailEnabled
|
||||
? autoGeneratedUsername
|
||||
? (body.username as string).toLowerCase().trim() ||
|
||||
autoGeneratedUsername
|
||||
: (body.username as string).toLowerCase().trim(),
|
||||
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
|
||||
password: hashedPassword,
|
||||
|
|
|
@ -24,7 +24,12 @@ const generatePreview = async (
|
|||
1024 * 1024 * Number(process.env.PREVIEW_MAX_BUFFER || 0.1)
|
||||
) {
|
||||
console.log("Error generating preview: Buffer size exceeded");
|
||||
return;
|
||||
return prisma.link.update({
|
||||
where: { id: linkId },
|
||||
data: {
|
||||
preview: "unavailable",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await createFile({
|
||||
|
|
|
@ -9,6 +9,9 @@ const getPublicCollectionData = async (
|
|||
) => {
|
||||
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();
|
||||
|
||||
setData(data.response);
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
module.exports = {
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en", "it"],
|
||||
locales: ["en", "it", "fr", "zh"],
|
||||
},
|
||||
reloadOnPrerender: process.env.NODE_ENV === "development",
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "linkwarden",
|
||||
"version": "v2.6.2",
|
||||
"version": "v2.7.0",
|
||||
"main": "index.js",
|
||||
"repository": "https://github.com/linkwarden/linkwarden.git",
|
||||
"author": "Daniel31X13 <daniel31x13@gmail.com>",
|
||||
|
@ -27,6 +27,8 @@
|
|||
"@mozilla/readability": "^0.4.4",
|
||||
"@prisma/client": "^4.16.2",
|
||||
"@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/formidable": "^3.4.5",
|
||||
"@types/node": "^20.10.4",
|
||||
|
@ -67,9 +69,10 @@
|
|||
"react-hot-toast": "^2.4.1",
|
||||
"react-i18next": "^14.1.2",
|
||||
"react-image-file-resizer": "^0.4.8",
|
||||
"react-intersection-observer": "^9.13.0",
|
||||
"react-masonry-css": "^1.0.16",
|
||||
"react-select": "^5.7.4",
|
||||
"react-spinners": "^0.13.8",
|
||||
"react-spinners": "^0.14.1",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
"stripe": "^12.13.0",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
|
|
155
pages/_app.tsx
155
pages/_app.tsx
|
@ -11,7 +11,16 @@ import { Session } from "next-auth";
|
|||
import { isPWA } from "@/lib/client/utils";
|
||||
// import useInitialData from "@/hooks/useInitialData";
|
||||
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({
|
||||
Component,
|
||||
|
@ -29,82 +38,76 @@ function App({
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<SessionProvider
|
||||
session={pageProps.session}
|
||||
refetchOnWindowFocus={false}
|
||||
basePath="/api/v1/auth"
|
||||
>
|
||||
<Head>
|
||||
<title>Linkwarden</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
</Head>
|
||||
<AuthRedirect>
|
||||
{/* <GetData> */}
|
||||
<Toaster
|
||||
position="top-center"
|
||||
reverseOrder={false}
|
||||
toastOptions={{
|
||||
className:
|
||||
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white",
|
||||
}}
|
||||
>
|
||||
{(t) => (
|
||||
<ToastBar toast={t}>
|
||||
{({ icon, message }) => (
|
||||
<div
|
||||
className="flex flex-row"
|
||||
data-testid="toast-message-container"
|
||||
data-type={t.type}
|
||||
>
|
||||
{icon}
|
||||
<span data-testid="toast-message">{message}</span>
|
||||
{t.type !== "loading" && (
|
||||
<button
|
||||
className="btn btn-xs outline-none btn-circle btn-ghost"
|
||||
data-testid="close-toast-button"
|
||||
onClick={() => toast.dismiss(t.id)}
|
||||
>
|
||||
<i className="bi bi-x"></i>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ToastBar>
|
||||
)}
|
||||
</Toaster>
|
||||
<Component {...pageProps} />
|
||||
{/* </GetData> */}
|
||||
</AuthRedirect>
|
||||
</SessionProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SessionProvider
|
||||
session={pageProps.session}
|
||||
refetchOnWindowFocus={false}
|
||||
basePath="/api/v1/auth"
|
||||
>
|
||||
<Head>
|
||||
<title>Linkwarden</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
</Head>
|
||||
<AuthRedirect>
|
||||
{/* <GetData> */}
|
||||
<Toaster
|
||||
position="top-center"
|
||||
reverseOrder={false}
|
||||
toastOptions={{
|
||||
className:
|
||||
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white",
|
||||
}}
|
||||
>
|
||||
{(t) => (
|
||||
<ToastBar toast={t}>
|
||||
{({ icon, message }) => (
|
||||
<div
|
||||
className="flex flex-row"
|
||||
data-testid="toast-message-container"
|
||||
data-type={t.type}
|
||||
>
|
||||
{icon}
|
||||
<span data-testid="toast-message">{message}</span>
|
||||
{t.type !== "loading" && (
|
||||
<button
|
||||
className="btn btn-xs outline-none btn-circle btn-ghost"
|
||||
data-testid="close-toast-button"
|
||||
onClick={() => toast.dismiss(t.id)}
|
||||
>
|
||||
<i className="bi bi-x"></i>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ToastBar>
|
||||
)}
|
||||
</Toaster>
|
||||
<Component {...pageProps} />
|
||||
{/* </GetData> */}
|
||||
</AuthRedirect>
|
||||
</SessionProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default appWithTranslation(App);
|
||||
|
||||
// function GetData({ children }: { children: React.ReactNode }) {
|
||||
// const status = useInitialData();
|
||||
// return typeof window !== "undefined" && status !== "loading" ? (
|
||||
// children
|
||||
// ) : (
|
||||
// <></>
|
||||
// );
|
||||
// }
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import NewUserModal from "@/components/ModalContent/NewUserModal";
|
||||
import useUserStore from "@/store/admin/users";
|
||||
import { User as U } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import UserListing from "@/components/UserListing";
|
||||
import { useUsers } from "@/hooks/store/admin/users";
|
||||
|
||||
interface User extends U {
|
||||
subscriptions: {
|
||||
|
@ -21,7 +21,7 @@ type UserModal = {
|
|||
export default function Admin() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { users, setUsers } = useUserStore();
|
||||
const { data: users = [] } = useUsers();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filteredUsers, setFilteredUsers] = useState<User[]>();
|
||||
|
@ -33,10 +33,6 @@ export default function Admin() {
|
|||
|
||||
const [newUserModal, setNewUserModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setUsers();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-5">
|
||||
<div className="flex sm:flex-row flex-col justify-between gap-2">
|
||||
|
@ -71,7 +67,7 @@ export default function Admin() {
|
|||
|
||||
if (users) {
|
||||
setFilteredUsers(
|
||||
users.filter((user) =>
|
||||
users.filter((user: any) =>
|
||||
JSON.stringify(user)
|
||||
.toLowerCase()
|
||||
.includes(e.target.value.toLowerCase())
|
||||
|
|
|
@ -1186,7 +1186,7 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
|||
providerAccountId: account?.providerAccountId,
|
||||
},
|
||||
});
|
||||
if (existingUser && newSsoUsersDisabled) {
|
||||
if (!existingUser && newSsoUsersDisabled) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import useCollectionStore from "@/store/collections";
|
||||
import useLinkStore from "@/store/links";
|
||||
import {
|
||||
AccountSettings,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
|
@ -10,23 +8,22 @@ import { useRouter } from "next/router";
|
|||
import React, { useEffect, useState } from "react";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import ProfilePhoto from "@/components/ProfilePhoto";
|
||||
import useLinks from "@/hooks/useLinks";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import NoLinksFound from "@/components/NoLinksFound";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import useAccountStore from "@/store/account";
|
||||
import getPublicUserData from "@/lib/client/getPublicUserData";
|
||||
import EditCollectionModal from "@/components/ModalContent/EditCollectionModal";
|
||||
import EditCollectionSharingModal from "@/components/ModalContent/EditCollectionSharingModal";
|
||||
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 NewCollectionModal from "@/components/ModalContent/NewCollectionModal";
|
||||
import MasonryView from "@/components/LinkViews/Layouts/MasonryView";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useTranslation } from "next-i18next";
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
|
@ -34,25 +31,29 @@ export default function Index() {
|
|||
|
||||
const router = useRouter();
|
||||
|
||||
const { links } = useLinkStore();
|
||||
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 { links, data } = useLinks({
|
||||
sort: sortBy,
|
||||
collectionId: Number(router.query.id),
|
||||
});
|
||||
|
||||
const [activeCollection, setActiveCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>();
|
||||
|
||||
const permissions = usePermissions(activeCollection?.id as number);
|
||||
|
||||
useLinks({ collectionId: Number(router.query.id), sort: sortBy });
|
||||
|
||||
useEffect(() => {
|
||||
setActiveCollection(
|
||||
collections.find((e) => e.id === Number(router.query.id))
|
||||
);
|
||||
}, [router, collections]);
|
||||
|
||||
const { account } = useAccountStore();
|
||||
const { data: user = {} } = useUser();
|
||||
|
||||
const [collectionOwner, setCollectionOwner] = useState<
|
||||
Partial<AccountSettings>
|
||||
|
@ -60,20 +61,20 @@ export default function Index() {
|
|||
|
||||
useEffect(() => {
|
||||
const fetchOwner = async () => {
|
||||
if (activeCollection && activeCollection.ownerId !== account.id) {
|
||||
if (activeCollection && activeCollection.ownerId !== user.id) {
|
||||
const owner = await getPublicUserData(
|
||||
activeCollection.ownerId as number
|
||||
);
|
||||
setCollectionOwner(owner);
|
||||
} else if (activeCollection && activeCollection.ownerId === account.id) {
|
||||
} else if (activeCollection && activeCollection.ownerId === user.id) {
|
||||
setCollectionOwner({
|
||||
id: account.id as number,
|
||||
name: account.name,
|
||||
username: account.username,
|
||||
image: account.image,
|
||||
archiveAsScreenshot: account.archiveAsScreenshot,
|
||||
archiveAsMonolith: account.archiveAsScreenshot,
|
||||
archiveAsPDF: account.archiveAsPDF,
|
||||
id: user.id as number,
|
||||
name: user.name,
|
||||
username: user.username as string,
|
||||
image: user.image as string,
|
||||
archiveAsScreenshot: user.archiveAsScreenshot as boolean,
|
||||
archiveAsMonolith: user.archiveAsScreenshot as boolean,
|
||||
archiveAsPDF: user.archiveAsPDF as boolean,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -92,27 +93,17 @@ export default function Index() {
|
|||
if (editMode) return setEditMode(false);
|
||||
}, [router]);
|
||||
|
||||
const [viewMode, setViewMode] = useState<string>(
|
||||
localStorage.getItem("viewMode") || ViewMode.Card
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(
|
||||
(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 (
|
||||
<MainLayout>
|
||||
<div
|
||||
className="h-[60rem] p-5 flex gap-3 flex-col"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(${activeCollection?.color}20 10%, ${
|
||||
settings.theme === "dark" ? "#262626" : "#f3f4f6"
|
||||
} 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
|
||||
backgroundImage: `linear-gradient(${activeCollection?.color}20 10%, ${settings.theme === "dark" ? "#262626" : "#f3f4f6"
|
||||
} 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
|
||||
}}
|
||||
>
|
||||
{activeCollection && (
|
||||
|
@ -137,7 +128,7 @@ export default function Index() {
|
|||
>
|
||||
<i className="bi-three-dots text-xl" title="More"></i>
|
||||
</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 && (
|
||||
<li>
|
||||
<div
|
||||
|
@ -147,6 +138,7 @@ export default function Index() {
|
|||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setEditCollectionModal(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("edit_collection_info")}
|
||||
</div>
|
||||
|
@ -160,6 +152,7 @@ export default function Index() {
|
|||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setEditCollectionSharingModal(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{permissions === true
|
||||
? t("share_and_collaborate")
|
||||
|
@ -175,6 +168,7 @@ export default function Index() {
|
|||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setNewCollectionModal(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("create_subcollection")}
|
||||
</div>
|
||||
|
@ -188,6 +182,7 @@ export default function Index() {
|
|||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setDeleteCollectionModal(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{permissions === true
|
||||
? t("delete_collection")
|
||||
|
@ -236,20 +231,20 @@ export default function Index() {
|
|||
|
||||
<p className="text-neutral text-sm">
|
||||
{activeCollection.members.length > 0 &&
|
||||
activeCollection.members.length === 1
|
||||
activeCollection.members.length === 1
|
||||
? 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,
|
||||
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", {
|
||||
author: collectionOwner.name,
|
||||
})}
|
||||
author: collectionOwner.name,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -294,15 +289,15 @@ export default function Index() {
|
|||
setSortBy={setSortBy}
|
||||
editMode={
|
||||
permissions === true ||
|
||||
permissions?.canUpdate ||
|
||||
permissions?.canDelete
|
||||
permissions?.canUpdate ||
|
||||
permissions?.canDelete
|
||||
? editMode
|
||||
: undefined
|
||||
}
|
||||
setEditMode={
|
||||
permissions === true ||
|
||||
permissions?.canUpdate ||
|
||||
permissions?.canDelete
|
||||
permissions?.canUpdate ||
|
||||
permissions?.canDelete
|
||||
? setEditMode
|
||||
: undefined
|
||||
}
|
||||
|
@ -310,24 +305,22 @@ export default function Index() {
|
|||
<p>
|
||||
{activeCollection?._count?.links === 1
|
||||
? t("showing_count_result", {
|
||||
count: activeCollection?._count?.links,
|
||||
})
|
||||
count: activeCollection?._count?.links,
|
||||
})
|
||||
: t("showing_count_results", {
|
||||
count: activeCollection?._count?.links,
|
||||
})}
|
||||
count: activeCollection?._count?.links,
|
||||
})}
|
||||
</p>
|
||||
</LinkListOptions>
|
||||
|
||||
{links.some((e) => e.collectionId === Number(router.query.id)) ? (
|
||||
<LinkComponent
|
||||
editMode={editMode}
|
||||
links={links.filter(
|
||||
(e) => e.collection.id === activeCollection?.id
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<NoLinksFound />
|
||||
)}
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
placeholderCount={1}
|
||||
useData={data}
|
||||
/>
|
||||
{!data.isLoading && links && !links[0] && <NoLinksFound />}
|
||||
</div>
|
||||
{activeCollection && (
|
||||
<>
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import useCollectionStore from "@/store/collections";
|
||||
import CollectionCard from "@/components/CollectionCard";
|
||||
import { useState } from "react";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
|
@ -10,11 +9,14 @@ import NewCollectionModal from "@/components/ModalContent/NewCollectionModal";
|
|||
import PageHeader from "@/components/PageHeader";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCollections } from "@/hooks/store/collections";
|
||||
|
||||
export default function Collections() {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useCollectionStore();
|
||||
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
||||
const { data: collections = [] } = useCollections();
|
||||
const [sortBy, setSortBy] = useState<Sort>(
|
||||
Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst
|
||||
);
|
||||
const [sortedCollections, setSortedCollections] = useState(collections);
|
||||
|
||||
const { data } = useSession();
|
||||
|
|
|
@ -43,6 +43,12 @@ export default function EmailConfirmaion() {
|
|||
|
||||
<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>
|
||||
|
||||
<div className="mx-auto w-fit mt-3">
|
||||
|
|
|
@ -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 { useEffect, useState } from "react";
|
||||
import useLinks from "@/hooks/useLinks";
|
||||
import Link from "next/link";
|
||||
import useWindowDimensions from "@/hooks/useWindowDimensions";
|
||||
import React from "react";
|
||||
|
@ -12,26 +8,25 @@ import { MigrationFormat, MigrationRequest, ViewMode } from "@/types/global";
|
|||
import DashboardItem from "@/components/DashboardItem";
|
||||
import NewLinkModal from "@/components/ModalContent/NewLinkModal";
|
||||
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 { dropdownTriggerer } from "@/lib/client/utils";
|
||||
import MasonryView from "@/components/LinkViews/Layouts/MasonryView";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useCollectionStore();
|
||||
const { links } = useLinkStore();
|
||||
const { tags } = useTagStore();
|
||||
const { data: collections = [] } = useCollections();
|
||||
const dashboardData = useDashboardData();
|
||||
const { data: tags = [] } = useTags();
|
||||
|
||||
const [numberOfLinks, setNumberOfLinks] = useState(0);
|
||||
|
||||
const [showLinks, setShowLinks] = useState(3);
|
||||
|
||||
useLinks({ pinnedOnly: true, sort: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
setNumberOfLinks(
|
||||
collections.reduce(
|
||||
|
@ -102,20 +97,10 @@ export default function Dashboard() {
|
|||
|
||||
const [newLinkModal, setNewLinkModal] = useState(false);
|
||||
|
||||
const [viewMode, setViewMode] = useState<string>(
|
||||
localStorage.getItem("viewMode") || ViewMode.Card
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(
|
||||
(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 (
|
||||
<MainLayout>
|
||||
<div style={{ flex: "1 1 auto" }} className="p-5 flex flex-col gap-5">
|
||||
|
@ -174,12 +159,30 @@ export default function Dashboard() {
|
|||
</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"
|
||||
>
|
||||
{links[0] ? (
|
||||
{dashboardData.isLoading ? (
|
||||
<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 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>
|
||||
<p>{t("import_links")}</p>
|
||||
</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>
|
||||
<label
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
htmlFor="import-linkwarden-file"
|
||||
title={t("from_linkwarden")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("from_linkwarden")}
|
||||
<input
|
||||
|
@ -241,6 +245,7 @@ export default function Dashboard() {
|
|||
role="button"
|
||||
htmlFor="import-html-file"
|
||||
title={t("from_html")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("from_html")}
|
||||
<input
|
||||
|
@ -261,6 +266,7 @@ export default function Dashboard() {
|
|||
role="button"
|
||||
htmlFor="import-wallabag-file"
|
||||
title={t("from_wallabag")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("from_wallabag")}
|
||||
<input
|
||||
|
@ -303,12 +309,21 @@ export default function Dashboard() {
|
|||
style={{ flex: "1 1 auto" }}
|
||||
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">
|
||||
<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])
|
||||
.slice(0, showLinks)}
|
||||
layout={viewMode}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
@ -1,26 +1,28 @@
|
|||
import NoLinksFound from "@/components/NoLinksFound";
|
||||
import useLinks from "@/hooks/useLinks";
|
||||
import { useLinks } from "@/hooks/store/links";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import useLinkStore from "@/store/links";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
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 MasonryView from "@/components/LinkViews/Layouts/MasonryView";
|
||||
import LinkListOptions from "@/components/LinkListOptions";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import Links from "@/components/LinkViews/Links";
|
||||
|
||||
export default function Links() {
|
||||
export default function Index() {
|
||||
const { t } = useTranslation();
|
||||
const { links } = useLinkStore();
|
||||
|
||||
const [viewMode, setViewMode] = useState<string>(
|
||||
localStorage.getItem("viewMode") || ViewMode.Card
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(
|
||||
(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();
|
||||
|
||||
|
@ -30,17 +32,6 @@ export default function Links() {
|
|||
if (editMode) return setEditMode(false);
|
||||
}, [router]);
|
||||
|
||||
useLinks({ sort: sortBy });
|
||||
|
||||
const linkView = {
|
||||
[ViewMode.Card]: CardView,
|
||||
[ViewMode.List]: ListView,
|
||||
[ViewMode.Masonry]: MasonryView,
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const LinkComponent = linkView[viewMode];
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
|
@ -60,11 +51,16 @@ export default function Links() {
|
|||
/>
|
||||
</LinkListOptions>
|
||||
|
||||
{links[0] ? (
|
||||
<LinkComponent editMode={editMode} links={links} />
|
||||
) : (
|
||||
{!data.isLoading && links && !links[0] && (
|
||||
<NoLinksFound text={t("you_have_not_added_any_links")} />
|
||||
)}
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
placeholderCount={1}
|
||||
useData={data}
|
||||
/>
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
|
|
|
@ -1,45 +1,32 @@
|
|||
import useLinks from "@/hooks/useLinks";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import useLinkStore from "@/store/links";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
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 MasonryView from "@/components/LinkViews/Layouts/MasonryView";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import LinkListOptions from "@/components/LinkListOptions";
|
||||
import { useLinks } from "@/hooks/store/links";
|
||||
import Links from "@/components/LinkViews/Links";
|
||||
|
||||
export default function PinnedLinks() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { links } = useLinkStore();
|
||||
|
||||
const [viewMode, setViewMode] = useState<string>(
|
||||
localStorage.getItem("viewMode") || ViewMode.Card
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(
|
||||
(localStorage.getItem("viewMode") as 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 [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 (
|
||||
<MainLayout>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
|
@ -59,9 +46,7 @@ export default function PinnedLinks() {
|
|||
/>
|
||||
</LinkListOptions>
|
||||
|
||||
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
|
||||
<LinkComponent editMode={editMode} links={links} />
|
||||
) : (
|
||||
{!data.isLoading && links && !links[0] && (
|
||||
<div
|
||||
style={{ flex: "1 1 auto" }}
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
placeholderCount={1}
|
||||
useData={data}
|
||||
/>
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
ArchivedFormat,
|
||||
|
@ -7,9 +6,12 @@ import {
|
|||
} from "@/types/global";
|
||||
import ReadableView from "@/components/ReadableView";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useGetLink, useLinks } from "@/hooks/store/links";
|
||||
|
||||
export default function Index() {
|
||||
const { links, getLink } = useLinkStore();
|
||||
const { links } = useLinks();
|
||||
|
||||
const getLink = useGetLink();
|
||||
|
||||
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
|
||||
|
||||
|
@ -18,7 +20,7 @@ export default function Index() {
|
|||
useEffect(() => {
|
||||
const fetchLink = async () => {
|
||||
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(() => {
|
||||
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]);
|
||||
|
||||
return (
|
||||
|
|
|
@ -9,8 +9,6 @@ import {
|
|||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Head from "next/head";
|
||||
import useLinks from "@/hooks/useLinks";
|
||||
import useLinkStore from "@/store/links";
|
||||
import ProfilePhoto from "@/components/ProfilePhoto";
|
||||
import ToggleDarkMode from "@/components/ToggleDarkMode";
|
||||
import getPublicUserData from "@/lib/client/getPublicUserData";
|
||||
|
@ -19,21 +17,19 @@ import Link from "next/link";
|
|||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import SearchBar from "@/components/SearchBar";
|
||||
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 getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import useCollectionStore from "@/store/collections";
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const { links } = useLinkStore();
|
||||
|
||||
const { settings } = useLocalSettingsStore();
|
||||
|
||||
const { collections } = useCollectionStore();
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
|
@ -49,9 +45,11 @@ export default function PublicCollections() {
|
|||
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,
|
||||
searchQueryString: router.query.q
|
||||
? decodeURIComponent(router.query.q as string)
|
||||
|
@ -68,7 +66,13 @@ export default function PublicCollections() {
|
|||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
|
@ -86,8 +90,8 @@ export default function PublicCollections() {
|
|||
const [editCollectionSharingModal, setEditCollectionSharingModal] =
|
||||
useState(false);
|
||||
|
||||
const [viewMode, setViewMode] = useState<string>(
|
||||
localStorage.getItem("viewMode") || ViewMode.Card
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(
|
||||
(localStorage.getItem("viewMode") as ViewMode) || ViewMode.Card
|
||||
);
|
||||
|
||||
const linkView = {
|
||||
|
@ -105,9 +109,8 @@ export default function PublicCollections() {
|
|||
<div
|
||||
className="h-96"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(${collection?.color}30 10%, ${
|
||||
settings.theme === "dark" ? "#262626" : "#f3f4f6"
|
||||
} 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
|
||||
backgroundImage: `linear-gradient(${collection?.color}30 10%, ${settings.theme === "dark" ? "#262626" : "#f3f4f6"
|
||||
} 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
|
||||
}}
|
||||
>
|
||||
{collection && (
|
||||
|
@ -178,20 +181,20 @@ export default function PublicCollections() {
|
|||
|
||||
<p className="text-neutral text-sm">
|
||||
{collection.members.length > 0 &&
|
||||
collection.members.length === 1
|
||||
collection.members.length === 1
|
||||
? 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,
|
||||
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", {
|
||||
author: collectionOwner.name,
|
||||
})}
|
||||
author: collectionOwner.name,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -215,28 +218,30 @@ export default function PublicCollections() {
|
|||
placeholder={
|
||||
collection._count?.links === 1
|
||||
? t("search_count_link", {
|
||||
count: collection._count?.links,
|
||||
})
|
||||
count: collection._count?.links,
|
||||
})
|
||||
: t("search_count_links", {
|
||||
count: collection._count?.links,
|
||||
})
|
||||
count: collection._count?.links,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</LinkListOptions>
|
||||
|
||||
{links[0] ? (
|
||||
<LinkComponent
|
||||
links={links
|
||||
.filter((e) => e.collectionId === Number(router.query.id))
|
||||
.map((e, i) => {
|
||||
const linkWithCollectionData = {
|
||||
...e,
|
||||
collection: collection, // Append collection data
|
||||
};
|
||||
return linkWithCollectionData;
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Links
|
||||
links={
|
||||
links?.map((e, i) => {
|
||||
const linkWithCollectionData = {
|
||||
...e,
|
||||
collection: collection, // Append collection data
|
||||
};
|
||||
return linkWithCollectionData;
|
||||
}) as any
|
||||
}
|
||||
layout={viewMode}
|
||||
placeholderCount={1}
|
||||
useData={data}
|
||||
/>
|
||||
{!data.isLoading && links && !links[0] && (
|
||||
<p>{t("collection_is_empty")}</p>
|
||||
)}
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
ArchivedFormat,
|
||||
|
@ -7,20 +6,20 @@ import {
|
|||
} from "@/types/global";
|
||||
import ReadableView from "@/components/ReadableView";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useGetLink, useLinks } from "@/hooks/store/links";
|
||||
|
||||
export default function Index() {
|
||||
const { links, getLink } = useLinkStore();
|
||||
const { links } = useLinks();
|
||||
const getLink = useGetLink();
|
||||
|
||||
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
let isPublic = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLink = async () => {
|
||||
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(() => {
|
||||
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]);
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,23 +1,17 @@
|
|||
import useLinks from "@/hooks/useLinks";
|
||||
import { useLinks } from "@/hooks/store/links";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import useLinkStore from "@/store/links";
|
||||
import { Sort, ViewMode } from "@/types/global";
|
||||
import { useRouter } from "next/router";
|
||||
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 { GridLoader } from "react-spinners";
|
||||
import MasonryView from "@/components/LinkViews/Layouts/MasonryView";
|
||||
import LinkListOptions from "@/components/LinkListOptions";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import Links from "@/components/LinkViews/Links";
|
||||
|
||||
export default function Search() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { links } = useLinkStore();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [searchFilter, setSearchFilter] = useState({
|
||||
|
@ -28,11 +22,13 @@ export default function Search() {
|
|||
textContent: false,
|
||||
});
|
||||
|
||||
const [viewMode, setViewMode] = useState<string>(
|
||||
localStorage.getItem("viewMode") || ViewMode.Card
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(
|
||||
(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);
|
||||
|
||||
|
@ -40,7 +36,17 @@ export default function Search() {
|
|||
if (editMode) return setEditMode(false);
|
||||
}, [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,
|
||||
searchQueryString: decodeURIComponent(router.query.q as string),
|
||||
searchByName: searchFilter.name,
|
||||
|
@ -50,15 +56,6 @@ export default function Search() {
|
|||
searchByTags: searchFilter.tags,
|
||||
});
|
||||
|
||||
const linkView = {
|
||||
[ViewMode.Card]: CardView,
|
||||
[ViewMode.List]: ListView,
|
||||
[ViewMode.Masonry]: MasonryView,
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const LinkComponent = linkView[viewMode];
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<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"} />
|
||||
</LinkListOptions>
|
||||
|
||||
{!isLoading && !links[0] ? (
|
||||
<p>{t("nothing_found")}</p>
|
||||
) : links[0] ? (
|
||||
<LinkComponent
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
) : (
|
||||
isLoading && (
|
||||
<GridLoader
|
||||
color="oklch(var(--p))"
|
||||
loading={true}
|
||||
size={20}
|
||||
className="m-auto py-10"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{!data.isLoading && links && !links[0] && <p>{t("nothing_found")}</p>}
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
placeholderCount={1}
|
||||
useData={data}
|
||||
/>
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import SettingsLayout from "@/layouts/SettingsLayout";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import NewTokenModal from "@/components/ModalContent/NewTokenModal";
|
||||
import RevokeTokenModal from "@/components/ModalContent/RevokeTokenModal";
|
||||
import { AccessToken } from "@prisma/client";
|
||||
import useTokenStore from "@/store/tokens";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useTokens } from "@/hooks/store/tokens";
|
||||
|
||||
export default function AccessTokens() {
|
||||
const [newTokenModal, setNewTokenModal] = useState(false);
|
||||
|
@ -18,15 +18,7 @@ export default function AccessTokens() {
|
|||
setRevokeTokenModal(true);
|
||||
};
|
||||
|
||||
const { setTokens, tokens } = useTokenStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/v1/tokens")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.response) setTokens(data.response as AccessToken[]);
|
||||
});
|
||||
}, []);
|
||||
const { data: tokens = [] } = useTokens();
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { useState, useEffect, ChangeEvent } from "react";
|
||||
import useAccountStore from "@/store/account";
|
||||
import { AccountSettings } from "@/types/global";
|
||||
import { toast } from "react-hot-toast";
|
||||
import SettingsLayout from "@/layouts/SettingsLayout";
|
||||
|
@ -17,6 +16,7 @@ import Button from "@/components/ui/Button";
|
|||
import { i18n } from "next-i18next.config";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useUpdateUser, useUser } from "@/hooks/store/user";
|
||||
|
||||
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
|
||||
|
||||
|
@ -24,24 +24,25 @@ export default function Account() {
|
|||
const [emailChangeVerificationModal, setEmailChangeVerificationModal] =
|
||||
useState(false);
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const { account, updateAccount } = useAccountStore();
|
||||
const { data: account } = useUser();
|
||||
const updateUser = useUpdateUser();
|
||||
const [user, setUser] = useState<AccountSettings>(
|
||||
!objectIsEmpty(account)
|
||||
? account
|
||||
: ({
|
||||
// @ts-ignore
|
||||
id: null,
|
||||
name: "",
|
||||
username: "",
|
||||
email: "",
|
||||
emailVerified: null,
|
||||
password: undefined,
|
||||
image: "",
|
||||
isPrivate: true,
|
||||
// @ts-ignore
|
||||
createdAt: null,
|
||||
whitelistedUsers: [],
|
||||
} as unknown as AccountSettings)
|
||||
// @ts-ignore
|
||||
id: null,
|
||||
name: "",
|
||||
username: "",
|
||||
email: "",
|
||||
emailVerified: null,
|
||||
password: undefined,
|
||||
image: "",
|
||||
isPrivate: true,
|
||||
// @ts-ignore
|
||||
createdAt: null,
|
||||
whitelistedUsers: [],
|
||||
} as unknown as AccountSettings)
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
@ -80,25 +81,38 @@ export default function Account() {
|
|||
|
||||
const submit = async (password?: string) => {
|
||||
setSubmitLoader(true);
|
||||
|
||||
const load = toast.loading(t("applying_settings"));
|
||||
|
||||
const response = await updateAccount({
|
||||
...user,
|
||||
// @ts-ignore
|
||||
password: password ? password : undefined,
|
||||
});
|
||||
await updateUser.mutateAsync(
|
||||
{
|
||||
...user,
|
||||
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) {
|
||||
const emailChanged = account.email !== user.email;
|
||||
|
||||
toast.success(t("settings_applied"));
|
||||
if (emailChanged) {
|
||||
toast.success(t("email_change_request"));
|
||||
setEmailChangeVerificationModal(false);
|
||||
toast.success(t("settings_applied"));
|
||||
}
|
||||
},
|
||||
}
|
||||
} else toast.error(response.data as string);
|
||||
);
|
||||
|
||||
setSubmitLoader(false);
|
||||
};
|
||||
|
||||
|
@ -195,17 +209,14 @@ export default function Account() {
|
|||
<div>
|
||||
<p className="mb-2">{t("language")}</p>
|
||||
<select
|
||||
value={user.locale || ""}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
>
|
||||
{i18n.locales.map((locale) => (
|
||||
<option
|
||||
key={locale}
|
||||
value={locale}
|
||||
selected={user.locale === locale}
|
||||
>
|
||||
<option key={locale} value={locale} className="capitalize">
|
||||
{new Intl.DisplayNames(locale, { type: "language" }).of(
|
||||
locale
|
||||
) || ""}
|
||||
|
@ -237,9 +248,13 @@ export default function Account() {
|
|||
<i className="bi-pencil-square text-md duration-100"></i>
|
||||
{t("edit")}
|
||||
</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>
|
||||
<label tabIndex={0} role="button">
|
||||
<label
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("upload_new_photo")}
|
||||
<input
|
||||
type="file"
|
||||
|
@ -262,6 +277,7 @@ export default function Account() {
|
|||
image: "",
|
||||
})
|
||||
}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("remove_photo")}
|
||||
</div>
|
||||
|
@ -336,13 +352,14 @@ export default function Account() {
|
|||
{t("import_links")}
|
||||
</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>
|
||||
<label
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
htmlFor="import-linkwarden-file"
|
||||
title={t("from_linkwarden")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("from_linkwarden")}
|
||||
<input
|
||||
|
@ -363,6 +380,7 @@ export default function Account() {
|
|||
role="button"
|
||||
htmlFor="import-html-file"
|
||||
title={t("from_html")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("from_html")}
|
||||
<input
|
||||
|
@ -383,6 +401,7 @@ export default function Account() {
|
|||
role="button"
|
||||
htmlFor="import-wallabag-file"
|
||||
title={t("from_wallabag")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("from_wallabag")}
|
||||
<input
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import SettingsLayout from "@/layouts/SettingsLayout";
|
||||
import { useState } from "react";
|
||||
import useAccountStore from "@/store/account";
|
||||
import SubmitButton from "@/components/SubmitButton";
|
||||
import { toast } from "react-hot-toast";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useUpdateUser, useUser } from "@/hooks/store/user";
|
||||
|
||||
export default function Password() {
|
||||
const { t } = useTranslation();
|
||||
|
@ -13,7 +13,8 @@ export default function Password() {
|
|||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const { account, updateAccount } = useAccountStore();
|
||||
const { data: account } = useUser();
|
||||
const updateUser = useUpdateUser();
|
||||
|
||||
const submit = async () => {
|
||||
if (newPassword === "" || oldPassword === "") {
|
||||
|
@ -23,23 +24,29 @@ export default function Password() {
|
|||
|
||||
setSubmitLoader(true);
|
||||
|
||||
const load = toast.loading(t("applying_changes"));
|
||||
const load = toast.loading(t("applying_settings"));
|
||||
|
||||
const response = await updateAccount({
|
||||
...account,
|
||||
newPassword,
|
||||
oldPassword,
|
||||
});
|
||||
await updateUser.mutateAsync(
|
||||
{
|
||||
...account,
|
||||
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"));
|
||||
setNewPassword("");
|
||||
setOldPassword("");
|
||||
} else {
|
||||
toast.error(response.data as string);
|
||||
}
|
||||
toast.success(t("settings_applied"));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setSubmitLoader(false);
|
||||
};
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import SettingsLayout from "@/layouts/SettingsLayout";
|
||||
import { useState, useEffect } from "react";
|
||||
import useAccountStore from "@/store/account";
|
||||
import SubmitButton from "@/components/SubmitButton";
|
||||
import { toast } from "react-hot-toast";
|
||||
import Checkbox from "@/components/Checkbox";
|
||||
|
@ -8,12 +7,14 @@ import useLocalSettingsStore from "@/store/localSettings";
|
|||
import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps"; // Import getServerSideProps for server-side data fetching
|
||||
import { LinksRouteTo } from "@prisma/client";
|
||||
import { useUpdateUser, useUser } from "@/hooks/store/user";
|
||||
|
||||
export default function Appearance() {
|
||||
const { t } = useTranslation();
|
||||
const { updateSettings } = useLocalSettingsStore();
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const { account, updateAccount } = useAccountStore();
|
||||
const { data: account } = useUser();
|
||||
const updateUser = useUpdateUser();
|
||||
const [user, setUser] = useState(account);
|
||||
|
||||
const [preventDuplicateLinks, setPreventDuplicateLinks] = useState<boolean>(
|
||||
|
@ -73,17 +74,23 @@ export default function Appearance() {
|
|||
const submit = async () => {
|
||||
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);
|
||||
};
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Plan } from "@/types/global";
|
|||
import Button from "@/components/ui/Button";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
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";
|
||||
|
||||
|
@ -20,11 +20,11 @@ export default function Subscribe() {
|
|||
|
||||
const router = useRouter();
|
||||
|
||||
const { account } = useAccountStore();
|
||||
const { data: user = {} } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
const hasInactiveSubscription =
|
||||
account.id && !account.subscription?.active && stripeEnabled;
|
||||
user.id && !user.subscription?.active && stripeEnabled;
|
||||
|
||||
if (session.status === "authenticated" && !hasInactiveSubscription) {
|
||||
router.push("/dashboard");
|
||||
|
|
|
@ -1,29 +1,29 @@
|
|||
import useLinkStore from "@/store/links";
|
||||
import { useRouter } from "next/router";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import useTagStore from "@/store/tags";
|
||||
import { Sort, TagIncludingLinkCount, ViewMode } from "@/types/global";
|
||||
import useLinks from "@/hooks/useLinks";
|
||||
import { toast } from "react-hot-toast";
|
||||
import CardView from "@/components/LinkViews/Layouts/CardView";
|
||||
import ListView from "@/components/LinkViews/Layouts/ListView";
|
||||
import { useLinks } from "@/hooks/store/links";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal";
|
||||
import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal";
|
||||
import MasonryView from "@/components/LinkViews/Layouts/MasonryView";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
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() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const { links } = useLinkStore();
|
||||
const { tags, updateTag, removeTag } = useTagStore();
|
||||
const { data: tags = [] } = useTags();
|
||||
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 [newTagName, setNewTagName] = useState<string>();
|
||||
|
@ -38,10 +38,13 @@ export default function Index() {
|
|||
if (editMode) return setEditMode(false);
|
||||
}, [router]);
|
||||
|
||||
useLinks({ tagId: Number(router.query.id), sort: sortBy });
|
||||
const { links, data } = useLinks({
|
||||
sort: sortBy,
|
||||
tagId: Number(router.query.id),
|
||||
});
|
||||
|
||||
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) {
|
||||
router.push("/dashboard");
|
||||
|
@ -72,21 +75,28 @@ export default function Index() {
|
|||
|
||||
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)
|
||||
response = await updateTag({
|
||||
...activeTag,
|
||||
name: newTagName,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
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);
|
||||
setRenameTag(false);
|
||||
};
|
||||
|
@ -94,35 +104,31 @@ export default function Index() {
|
|||
const remove = async () => {
|
||||
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);
|
||||
setRenameTag(false);
|
||||
};
|
||||
|
||||
const [viewMode, setViewMode] = useState<string>(
|
||||
localStorage.getItem("viewMode") || ViewMode.Card
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(
|
||||
(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 (
|
||||
<MainLayout>
|
||||
<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"}
|
||||
></i>
|
||||
</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>
|
||||
<div
|
||||
role="button"
|
||||
|
@ -197,6 +203,7 @@ export default function Index() {
|
|||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setRenameTag(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("rename_tag")}
|
||||
</div>
|
||||
|
@ -209,6 +216,7 @@ export default function Index() {
|
|||
(document?.activeElement as HTMLElement)?.blur();
|
||||
remove();
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("delete_tag")}
|
||||
</div>
|
||||
|
@ -222,11 +230,12 @@ export default function Index() {
|
|||
</div>
|
||||
</LinkListOptions>
|
||||
|
||||
<LinkComponent
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links.filter((e) =>
|
||||
e.tags.some((e) => e.id === Number(router.query.id))
|
||||
)}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
placeholderCount={1}
|
||||
useData={data}
|
||||
/>
|
||||
</div>
|
||||
{bulkDeleteLinksModal && (
|
||||
|
|
|
@ -298,6 +298,7 @@
|
|||
"create_new_collection": "Create a New Collection",
|
||||
"color": "Color",
|
||||
"reset": "Reset",
|
||||
"updating_collection": "Updating Collection...",
|
||||
"collection_name_placeholder": "e.g. Example Collection",
|
||||
"collection_description_placeholder": "The purpose of this Collection...",
|
||||
"create_collection_button": "Create Collection",
|
||||
|
|
|
@ -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 n’avez 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"
|
||||
}
|
|
@ -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": "从 Wallabag(JSON 文件)",
|
||||
"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": "PDF,PNG,JPG (不超过 {{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": "以演示用户登录"
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
260
store/links.ts
260
store/links.ts
|
@ -1,10 +1,5 @@
|
|||
import { create } from "zustand";
|
||||
import {
|
||||
ArchivedFormat,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@/types/global";
|
||||
import useTagStore from "./tags";
|
||||
import useCollectionStore from "./collections";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
|
||||
type ResponseObject = {
|
||||
ok: boolean;
|
||||
|
@ -12,24 +7,8 @@ type ResponseObject = {
|
|||
};
|
||||
|
||||
type LinkStore = {
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
selectedLinks: LinkIncludingShortenedCollectionAndTags[];
|
||||
setLinks: (
|
||||
data: LinkIncludingShortenedCollectionAndTags[],
|
||||
isInitialCall: boolean
|
||||
) => 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: (
|
||||
links: LinkIncludingShortenedCollectionAndTags[],
|
||||
removePreviousTags: boolean,
|
||||
|
@ -38,186 +17,11 @@ type LinkStore = {
|
|||
"tags" | "collectionId"
|
||||
>
|
||||
) => Promise<ResponseObject>;
|
||||
removeLink: (linkId: number) => Promise<ResponseObject>;
|
||||
deleteLinksById: (linkIds: number[]) => Promise<ResponseObject>;
|
||||
resetLinks: () => void;
|
||||
};
|
||||
|
||||
const useLinkStore = create<LinkStore>()((set) => ({
|
||||
links: [],
|
||||
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 }),
|
||||
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) => {
|
||||
const response = await fetch("/api/v1/links", {
|
||||
body: JSON.stringify({ links, removePreviousTags, newData }),
|
||||
|
@ -230,71 +34,11 @@ const useLinkStore = create<LinkStore>()((set) => ({
|
|||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
set((state) => ({
|
||||
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();
|
||||
// Update the selected links with the new data
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { Sort } from "@/types/global";
|
||||
import { create } from "zustand";
|
||||
|
||||
type LocalSettings = {
|
||||
theme?: string;
|
||||
viewMode?: string;
|
||||
sortBy?: Sort;
|
||||
};
|
||||
|
||||
type LocalSettingsStore = {
|
||||
|
@ -15,10 +17,11 @@ const useLocalSettingsStore = create<LocalSettingsStore>((set) => ({
|
|||
settings: {
|
||||
theme: "",
|
||||
viewMode: "",
|
||||
sortBy: Sort.DateNewestFirst,
|
||||
},
|
||||
updateSettings: async (newSettings) => {
|
||||
if (
|
||||
newSettings.theme &&
|
||||
newSettings.theme !== undefined &&
|
||||
newSettings.theme !== localStorage.getItem("theme")
|
||||
) {
|
||||
localStorage.setItem("theme", newSettings.theme);
|
||||
|
@ -29,7 +32,7 @@ const useLocalSettingsStore = create<LocalSettingsStore>((set) => ({
|
|||
}
|
||||
|
||||
if (
|
||||
newSettings.viewMode &&
|
||||
newSettings.viewMode !== undefined &&
|
||||
newSettings.viewMode !== localStorage.getItem("viewMode")
|
||||
) {
|
||||
localStorage.setItem("viewMode", newSettings.viewMode);
|
||||
|
@ -37,6 +40,13 @@ const useLocalSettingsStore = create<LocalSettingsStore>((set) => ({
|
|||
// 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 } }));
|
||||
},
|
||||
setSettings: async () => {
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -67,7 +67,6 @@ export interface PublicCollectionIncludingLinks extends Collection {
|
|||
|
||||
export enum ViewMode {
|
||||
Card = "card",
|
||||
Grid = "grid",
|
||||
List = "list",
|
||||
Masonry = "masonry",
|
||||
}
|
||||
|
@ -84,7 +83,7 @@ export enum Sort {
|
|||
export type Order = { [key: string]: "asc" | "desc" };
|
||||
|
||||
export type LinkRequestQuery = {
|
||||
sort: Sort;
|
||||
sort?: Sort;
|
||||
cursor?: number;
|
||||
collectionId?: number;
|
||||
tagId?: number;
|
||||
|
|
37
yarn.lock
37
yarn.lock
|
@ -1903,6 +1903,30 @@
|
|||
dependencies:
|
||||
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":
|
||||
version "0.3.0"
|
||||
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"
|
||||
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:
|
||||
version "16.13.1"
|
||||
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"
|
||||
use-isomorphic-layout-effect "^1.1.2"
|
||||
|
||||
react-spinners@^0.13.8:
|
||||
version "0.13.8"
|
||||
resolved "https://registry.yarnpkg.com/react-spinners/-/react-spinners-0.13.8.tgz#5262571be0f745d86bbd49a1e6b49f9f9cb19acc"
|
||||
integrity sha512-3e+k56lUkPj0vb5NDXPVFAOkPC//XyhKPJjvcGjyMNPWsBKpplfeyialP74G7H7+It7KzhtET+MvGqbKgAqpZA==
|
||||
react-spinners@^0.14.1:
|
||||
version "0.14.1"
|
||||
resolved "https://registry.yarnpkg.com/react-spinners/-/react-spinners-0.14.1.tgz#de7d7d6b3e6d4f29d9620c65495b502c7dd90812"
|
||||
integrity sha512-2Izq+qgQ08HTofCVEdcAQCXFEYfqTDdfeDQJeo/HHQiQJD4imOicNLhkfN2eh1NYEWVOX4D9ok2lhuDB0z3Aag==
|
||||
|
||||
react-style-singleton@^2.2.1:
|
||||
version "2.2.1"
|
||||
|
|
Ŝarĝante…
Reference in New Issue