mirror of
https://github.com/linkwarden/linkwarden.git
synced 2026-03-10 01:17:02 +00:00
Compare commits
262 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e14149dfe | ||
|
|
a4c83dc82f | ||
|
|
46f81ebf25 | ||
|
|
0ac5009a4a | ||
|
|
6842da4283 | ||
|
|
78ecf3ddb5 | ||
|
|
e39645e135 | ||
|
|
836360f99d | ||
|
|
9c9fd969bc | ||
|
|
213105942b | ||
|
|
0b7acb35b7 | ||
|
|
9b58ea5c98 | ||
|
|
c85c3bb0d7 | ||
|
|
7ca574b76f | ||
|
|
8593df4673 | ||
|
|
ddc2079f4b | ||
|
|
0de5caffa1 | ||
|
|
b14e77bdf9 | ||
|
|
8d366ae7d8 | ||
|
|
a18938ba2a | ||
|
|
6eac8423f8 | ||
|
|
cbf93dcf06 | ||
|
|
2993347dc7 | ||
|
|
cc45c8fc3e | ||
|
|
d5602a09cd | ||
|
|
736e98ac7d | ||
|
|
7eaff332a9 | ||
|
|
7931e2d7b6 | ||
|
|
ac3888f9b3 | ||
|
|
ac8add8c5d | ||
|
|
a6a0f6965b | ||
|
|
b2c5c3c6dd | ||
|
|
4555874725 | ||
|
|
0f5b70eda7 | ||
|
|
d1c3748681 | ||
|
|
dbd096ab76 | ||
|
|
e37702aa14 | ||
|
|
9103f67db5 | ||
|
|
2524139113 | ||
|
|
6c2b86fc4b | ||
|
|
d0e0526655 | ||
|
|
43e94ebd0b | ||
|
|
aeafe6e15d | ||
|
|
5ec221d87d | ||
|
|
d6d6442bc4 | ||
|
|
d12d12518e | ||
|
|
02ced62832 | ||
|
|
4febe1ace5 | ||
|
|
2e1e94112f | ||
|
|
d86bbcd940 | ||
|
|
eed80ca812 | ||
|
|
394251c1f1 | ||
|
|
68cdde91ad | ||
|
|
1ef286a38c | ||
|
|
508844dd9d | ||
|
|
fa1f9873d5 | ||
|
|
891803547e | ||
|
|
24d45f8e8e | ||
|
|
f95350405c | ||
|
|
665019dc59 | ||
|
|
b09de5a8af | ||
|
|
cfd33e9bd1 | ||
|
|
d3d2d5069e | ||
|
|
cffc74caa4 | ||
|
|
3cd8eadee3 | ||
|
|
d146ec296c | ||
|
|
fb4aa42eef | ||
|
|
f68582e28c | ||
|
|
d042c82cb0 | ||
|
|
8738dd45e9 | ||
|
|
839de18d7a | ||
|
|
2ba0851fee | ||
|
|
d99972a335 | ||
|
|
e071b9eb07 | ||
|
|
eb00d151b7 | ||
|
|
22aaa52b3e | ||
|
|
4541277b28 | ||
|
|
39faece9d7 | ||
|
|
a21b0760de | ||
|
|
04149fe86b | ||
|
|
ff6e71d494 | ||
|
|
5b02c1cfc9 | ||
|
|
1ff13e8aa0 | ||
|
|
eaf4524598 | ||
|
|
a276065288 | ||
|
|
1cf7421b76 | ||
|
|
ed4a334024 | ||
|
|
a5b1952e0d | ||
|
|
01826b1634 | ||
|
|
3b17d4ddfe | ||
|
|
f104fa095f | ||
|
|
b08e6690f3 | ||
|
|
33a654d21a | ||
|
|
e1262142f8 | ||
|
|
0a43279665 | ||
|
|
5491ac74a5 | ||
|
|
bbcfca4cde | ||
|
|
bf9a7d4fa0 | ||
|
|
edf4e489ec | ||
|
|
20c5a20851 | ||
|
|
6f47a20e87 | ||
|
|
384937e210 | ||
|
|
d22d989c91 | ||
|
|
4e0294322f | ||
|
|
75d5061bdf | ||
|
|
0150a9a6e3 | ||
|
|
87b79ffbac | ||
|
|
5a40677191 | ||
|
|
95ce2f30a8 | ||
|
|
e6a0ecbab5 | ||
|
|
e4c9cf8a38 | ||
|
|
eaca3d7453 | ||
|
|
fbe3642be4 | ||
|
|
bc32abbb92 | ||
|
|
38f731f313 | ||
|
|
aaf3590542 | ||
|
|
8bb6e32bfa | ||
|
|
7bd3872195 | ||
|
|
906779010e | ||
|
|
b0f87e8659 | ||
|
|
653b1bc396 | ||
|
|
9b1506a64e | ||
|
|
fb1869ca7a | ||
|
|
5e7835b4d5 | ||
|
|
0a91c47f83 | ||
|
|
dc9db05e75 | ||
|
|
e1149c2733 | ||
|
|
0591d7c134 | ||
|
|
4602269dd8 | ||
|
|
9ae6a22236 | ||
|
|
442da02956 | ||
|
|
dfcc271343 | ||
|
|
43d50dfd1b | ||
|
|
40bb3e6fae | ||
|
|
3e077fa247 | ||
|
|
3de8872f26 | ||
|
|
e9072bba51 | ||
|
|
d20c915970 | ||
|
|
1a378de267 | ||
|
|
d594159c15 | ||
|
|
aee10fa406 | ||
|
|
820d686c37 | ||
|
|
4189062c4c | ||
|
|
1461caf68a | ||
|
|
e7c7fedf8b | ||
|
|
b7adbbc86f | ||
|
|
975716937f | ||
|
|
2d0e52f65b | ||
|
|
e9afe0ef25 | ||
|
|
a38133d618 | ||
|
|
6498ae794b | ||
|
|
0371695eb3 | ||
|
|
9ae9c7c81a | ||
|
|
642374c2e5 | ||
|
|
f368c2aa81 | ||
|
|
fae9e95fa9 | ||
|
|
03639adc22 | ||
|
|
9fe829771d | ||
|
|
ed7b268c2b | ||
|
|
bf1a6efd2e | ||
|
|
6df2e44213 | ||
|
|
ae2324ecd3 | ||
|
|
accbd4cbfa | ||
|
|
5f4e0d4262 | ||
|
|
c072fed99f | ||
|
|
b4a9f917b5 | ||
|
|
078e5ba95f | ||
|
|
495509c888 | ||
|
|
dc388ebba5 | ||
|
|
21578bac8d | ||
|
|
1062e07065 | ||
|
|
2893d3caf2 | ||
|
|
9f74f62330 | ||
|
|
c6e3147bb6 | ||
|
|
1260e8c093 | ||
|
|
5cb4bdced3 | ||
|
|
03b4240b8b | ||
|
|
9a3e82470a | ||
|
|
ee2319996b | ||
|
|
c979adfe69 | ||
|
|
2b83522eaa | ||
|
|
8c738d4a99 | ||
|
|
63678b7f1e | ||
|
|
b73e845299 | ||
|
|
898b126231 | ||
|
|
17d1cb45e3 | ||
|
|
0aad2d9e4b | ||
|
|
c18a5f4162 | ||
|
|
df7814385a | ||
|
|
d568f22e00 | ||
|
|
6bd1c90417 | ||
|
|
a40026040c | ||
|
|
334ad9f3dc | ||
|
|
f944345745 | ||
|
|
6b647573f0 | ||
|
|
d81493e021 | ||
|
|
03f4523d57 | ||
|
|
c24e76adac | ||
|
|
5d26617251 | ||
|
|
0e47ad9920 | ||
|
|
ca45076b6c | ||
|
|
3bf6dcad2f | ||
|
|
23860b8511 | ||
|
|
8758976f8d | ||
|
|
550dbd2bf0 | ||
|
|
04d2b3c6b2 | ||
|
|
cc1c17363b | ||
|
|
7bd0e29538 | ||
|
|
5baf55694c | ||
|
|
193a70c6e8 | ||
|
|
5b430cf31e | ||
|
|
684609a1dd | ||
|
|
ebb2016915 | ||
|
|
c103b66694 | ||
|
|
863bcc3838 | ||
|
|
66b0aacc3f | ||
|
|
299498ffa6 | ||
|
|
8031432995 | ||
|
|
9cc3a7206e | ||
|
|
d15d965139 | ||
|
|
bc04ea0fe8 | ||
|
|
bd34dacf21 | ||
|
|
80f366cd7b | ||
|
|
c5602dc79f | ||
|
|
0158e58d90 | ||
|
|
602f399119 | ||
|
|
012caab606 | ||
|
|
102690fc10 | ||
|
|
a73e5fa6c6 | ||
|
|
75b1ae738f | ||
|
|
8563a09a07 | ||
|
|
da8dc83b8f | ||
|
|
e889509697 | ||
|
|
237499fd03 | ||
|
|
9a287d1aef | ||
|
|
299a2331ff | ||
|
|
be5400f7cb | ||
|
|
099bc9e054 | ||
|
|
5c5dd967c4 | ||
|
|
d1ed33b532 | ||
|
|
05c5bdf63c | ||
|
|
a1248fe62f | ||
|
|
8f7e0b8d09 | ||
|
|
9d91d2064b | ||
|
|
d631754b50 | ||
|
|
94be3a7448 | ||
|
|
4faf389a2b | ||
|
|
ff31732ba3 | ||
|
|
fa051c0d4d | ||
|
|
02cb93065f | ||
|
|
0b8a9b4310 | ||
|
|
ce1aa5a0ec | ||
|
|
e79b98d3b0 | ||
|
|
7d43ed52a4 | ||
|
|
614653bf29 | ||
|
|
1b9dafbe47 | ||
|
|
abc93f1bf9 | ||
|
|
c23964a46d | ||
|
|
abb73f80bd | ||
|
|
e8d0cce58a | ||
|
|
e045c18b7d | ||
|
|
a1f48bbd79 |
@@ -1,11 +1,12 @@
|
||||
NEXTAUTH_SECRET=very_sensitive_secret
|
||||
NEXTAUTH_URL=http://localhost:3000/api/v1/auth
|
||||
NEXTAUTH_SECRET=
|
||||
|
||||
# Manual installation database settings
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
|
||||
# Example: DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
|
||||
DATABASE_URL=
|
||||
|
||||
# Docker installation database settings
|
||||
POSTGRES_PASSWORD=super_secret_password
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
# Additional Optional Settings
|
||||
PAGINATION_TAKE_COUNT=
|
||||
@@ -33,6 +34,8 @@ SCREENSHOT_MAX_BUFFER=
|
||||
READABILITY_MAX_BUFFER=
|
||||
PREVIEW_MAX_BUFFER=
|
||||
IMPORT_LIMIT=
|
||||
PLAYWRIGHT_LAUNCH_OPTIONS_EXECUTABLE_PATH=
|
||||
MAX_WORKERS=
|
||||
|
||||
# AWS S3 Settings
|
||||
SPACES_KEY=
|
||||
|
||||
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!
|
||||
|
||||
|
||||
@@ -5,12 +5,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";
|
||||
|
||||
type Props = {
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
@@ -20,7 +20,7 @@ type Props = {
|
||||
export default function CollectionCard({ collection, className }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useLocalSettingsStore();
|
||||
const { account } = useAccountStore();
|
||||
const { data: user = {} } = useUser();
|
||||
|
||||
const formattedDate = new Date(collection.createdAt as string).toLocaleString(
|
||||
"en-US",
|
||||
@@ -45,18 +45,18 @@ export default function CollectionCard({ collection, className }: Props) {
|
||||
|
||||
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 as string,
|
||||
image: account.image as string,
|
||||
archiveAsScreenshot: account.archiveAsScreenshot as boolean,
|
||||
archiveAsMonolith: account.archiveAsMonolith as boolean,
|
||||
archiveAsPDF: account.archiveAsPDF as boolean,
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -80,7 +80,7 @@ export default function CollectionCard({ collection, className }: Props) {
|
||||
>
|
||||
<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
|
||||
@@ -90,6 +90,7 @@ export default function CollectionCard({ collection, className }: Props) {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setEditCollectionModal(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("edit_collection_info")}
|
||||
</div>
|
||||
@@ -103,6 +104,7 @@ export default function CollectionCard({ collection, className }: Props) {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setEditCollectionSharingModal(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{permissions === true
|
||||
? t("share_and_collaborate")
|
||||
@@ -117,6 +119,7 @@ export default function CollectionCard({ collection, className }: Props) {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setDeleteCollectionModal(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{permissions === true
|
||||
? t("delete_collection")
|
||||
|
||||
@@ -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,50 @@ 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 = {}, refetch } = useUser();
|
||||
const updateUser = useUpdateUser();
|
||||
|
||||
const router = useRouter();
|
||||
const currentPath = router.asPath;
|
||||
|
||||
const [tree, setTree] = useState<TreeData | undefined>();
|
||||
|
||||
const initialTree = useMemo(() => {
|
||||
if (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(() => {
|
||||
setTree(initialTree);
|
||||
}, [initialTree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (account.username) {
|
||||
if (user.username) {
|
||||
refetch();
|
||||
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
|
||||
.filter((e) => e.parentId === null)
|
||||
.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 +75,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,16 +84,16 @@ 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [collections]);
|
||||
}, [user, collections]);
|
||||
|
||||
const onExpand = (movedCollectionId: ItemId) => {
|
||||
setTree((currentTree) =>
|
||||
@@ -138,9 +135,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 +145,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 +176,8 @@ const CollectionListing = () => {
|
||||
|
||||
updatedCollectionOrder.splice(destination.index, 0, movedCollectionId);
|
||||
|
||||
await updateAccount({
|
||||
...account,
|
||||
await updateUser.mutateAsync({
|
||||
...user,
|
||||
collectionOrder: updatedCollectionOrder,
|
||||
});
|
||||
} else if (
|
||||
@@ -182,8 +186,8 @@ const CollectionListing = () => {
|
||||
) {
|
||||
updatedCollectionOrder.splice(destination.index, 0, movedCollectionId);
|
||||
|
||||
await updateAccount({
|
||||
...account,
|
||||
updateUser.mutate({
|
||||
...user,
|
||||
collectionOrder: updatedCollectionOrder,
|
||||
});
|
||||
} else if (
|
||||
@@ -193,14 +197,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")}
|
||||
|
||||
@@ -8,13 +8,15 @@ export default function dashboardItem({
|
||||
icon: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="w-[4rem] aspect-square flex justify-center items-center bg-primary/20 rounded-xl select-none">
|
||||
<div className="flex items-center justify-between w-full rounded-2xl border border-neutral-content p-3 bg-gradient-to-tr from-neutral-content/70 to-50% to-base-200">
|
||||
<div className="w-14 aspect-square flex justify-center items-center bg-primary/20 rounded-xl select-none">
|
||||
<i className={`${icon} text-primary text-3xl drop-shadow`}></i>
|
||||
</div>
|
||||
<div className="ml-4 flex flex-col justify-center">
|
||||
<p className="text-neutral text-xs tracking-wider">{name}</p>
|
||||
<p className="font-thin text-5xl text-primary mt-0.5">{value}</p>
|
||||
<p className="text-neutral text-xs tracking-wider text-right">{name}</p>
|
||||
<p className="font-thin text-4xl text-primary mt-0.5 text-right">
|
||||
{value || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
81
components/Drawer.tsx
Normal file
81
components/Drawer.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { ReactNode, useEffect } from "react";
|
||||
import { Drawer as D } from "vaul";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = {
|
||||
toggleDrawer: Function;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
dismissible?: boolean;
|
||||
};
|
||||
|
||||
export default function Drawer({
|
||||
toggleDrawer,
|
||||
className,
|
||||
children,
|
||||
dismissible = true,
|
||||
}: Props) {
|
||||
const [drawerIsOpen, setDrawerIsOpen] = React.useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.innerWidth >= 640) {
|
||||
document.body.style.overflow = "hidden";
|
||||
document.body.style.position = "relative";
|
||||
return () => {
|
||||
document.body.style.overflow = "auto";
|
||||
document.body.style.position = "";
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (window.innerWidth < 640) {
|
||||
return (
|
||||
<D.Root
|
||||
open={drawerIsOpen}
|
||||
onClose={() => dismissible && setDrawerIsOpen(false)}
|
||||
onAnimationEnd={(isOpen) => !isOpen && toggleDrawer()}
|
||||
dismissible={dismissible}
|
||||
>
|
||||
<D.Portal>
|
||||
<D.Overlay className="fixed inset-0 bg-black/40" />
|
||||
<D.Content className="flex flex-col rounded-t-2xl mt-24 fixed bottom-0 left-0 right-0 z-30 h-[90%] !select-auto focus:outline-none">
|
||||
<div
|
||||
className={clsx(
|
||||
"p-4 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
data-testid="mobile-modal-container"
|
||||
>
|
||||
<div data-testid="mobile-modal-slider" />
|
||||
{children}
|
||||
</div>
|
||||
</D.Content>
|
||||
</D.Portal>
|
||||
</D.Root>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<D.Root
|
||||
open={drawerIsOpen}
|
||||
onClose={() => dismissible && setDrawerIsOpen(false)}
|
||||
onAnimationEnd={(isOpen) => !isOpen && toggleDrawer()}
|
||||
dismissible={dismissible}
|
||||
direction="right"
|
||||
>
|
||||
<D.Portal>
|
||||
<D.Overlay className="fixed inset-0 bg-black/10 z-20" />
|
||||
<D.Content className="bg-white flex flex-col h-full w-2/5 min-w-[30rem] mt-24 fixed bottom-0 right-0 z-40 !select-auto focus:outline-none">
|
||||
<div
|
||||
className={clsx(
|
||||
"p-4 bg-base-100 flex-1 border-neutral-content border-l overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</D.Content>
|
||||
</D.Portal>
|
||||
</D.Root>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { resetInfiniteQueryPagination } from "@/hooks/store/links";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
type Props = {
|
||||
setSearchFilter: Function;
|
||||
@@ -18,6 +20,7 @@ export default function FilterSearchDropdown({
|
||||
searchFilter,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return (
|
||||
<div className="dropdown dropdown-bottom dropdown-end">
|
||||
@@ -29,7 +32,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"
|
||||
@@ -41,11 +44,12 @@ export default function FilterSearchDropdown({
|
||||
name="search-filter-checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
checked={searchFilter.name}
|
||||
onChange={() =>
|
||||
setSearchFilter({ ...searchFilter, name: !searchFilter.name })
|
||||
}
|
||||
onChange={() => {
|
||||
resetInfiniteQueryPagination(queryClient, ["links"]);
|
||||
setSearchFilter({ ...searchFilter, name: !searchFilter.name });
|
||||
}}
|
||||
/>
|
||||
<span className="label-text">{t("name")}</span>
|
||||
<span className="label-text whitespace-nowrap">{t("name")}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
@@ -59,11 +63,12 @@ export default function FilterSearchDropdown({
|
||||
name="search-filter-checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
checked={searchFilter.url}
|
||||
onChange={() =>
|
||||
setSearchFilter({ ...searchFilter, url: !searchFilter.url })
|
||||
}
|
||||
onChange={() => {
|
||||
resetInfiniteQueryPagination(queryClient, ["links"]);
|
||||
setSearchFilter({ ...searchFilter, url: !searchFilter.url });
|
||||
}}
|
||||
/>
|
||||
<span className="label-text">{t("link")}</span>
|
||||
<span className="label-text whitespace-nowrap">{t("link")}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
@@ -77,14 +82,17 @@ export default function FilterSearchDropdown({
|
||||
name="search-filter-checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
checked={searchFilter.description}
|
||||
onChange={() =>
|
||||
onChange={() => {
|
||||
resetInfiniteQueryPagination(queryClient, ["links"]);
|
||||
setSearchFilter({
|
||||
...searchFilter,
|
||||
description: !searchFilter.description,
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className="label-text">{t("description")}</span>
|
||||
<span className="label-text whitespace-nowrap">
|
||||
{t("description")}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
@@ -98,11 +106,12 @@ export default function FilterSearchDropdown({
|
||||
name="search-filter-checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
checked={searchFilter.tags}
|
||||
onChange={() =>
|
||||
setSearchFilter({ ...searchFilter, tags: !searchFilter.tags })
|
||||
}
|
||||
onChange={() => {
|
||||
resetInfiniteQueryPagination(queryClient, ["links"]);
|
||||
setSearchFilter({ ...searchFilter, tags: !searchFilter.tags });
|
||||
}}
|
||||
/>
|
||||
<span className="label-text">{t("tags")}</span>
|
||||
<span className="label-text whitespace-nowrap">{t("tags")}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
@@ -116,15 +125,18 @@ export default function FilterSearchDropdown({
|
||||
name="search-filter-checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
checked={searchFilter.textContent}
|
||||
onChange={() =>
|
||||
onChange={() => {
|
||||
resetInfiniteQueryPagination(queryClient, ["links"]);
|
||||
setSearchFilter({
|
||||
...searchFilter,
|
||||
textContent: !searchFilter.textContent,
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
|
||||
91
components/IconGrid.tsx
Normal file
91
components/IconGrid.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { icons } from "@/lib/client/icons";
|
||||
import Fuse from "fuse.js";
|
||||
import { forwardRef, useMemo } from "react";
|
||||
import { FixedSizeGrid as Grid } from "react-window";
|
||||
|
||||
const fuse = new Fuse(icons, {
|
||||
keys: [{ name: "name", weight: 4 }, "tags", "categories"],
|
||||
threshold: 0.2,
|
||||
useExtendedSearch: true,
|
||||
});
|
||||
|
||||
type Props = {
|
||||
query: string;
|
||||
color: string;
|
||||
weight: "light" | "regular" | "bold" | "fill" | "duotone" | "thin";
|
||||
iconName?: string;
|
||||
setIconName: Function;
|
||||
};
|
||||
|
||||
const IconGrid = ({ query, color, weight, iconName, setIconName }: Props) => {
|
||||
const filteredIcons = useMemo(() => {
|
||||
if (!query) {
|
||||
return icons;
|
||||
}
|
||||
return fuse.search(query).map((result) => result.item);
|
||||
}, [query]);
|
||||
|
||||
const columnCount = 6;
|
||||
const rowCount = Math.ceil(filteredIcons.length / columnCount);
|
||||
const GUTTER_SIZE = 5;
|
||||
|
||||
const Cell = ({ columnIndex, rowIndex, style }: any) => {
|
||||
const index = rowIndex * columnCount + columnIndex;
|
||||
if (index >= filteredIcons.length) return null; // Prevent overflow
|
||||
|
||||
const icon = filteredIcons[index];
|
||||
const IconComponent = icon.Icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
left: style.left + GUTTER_SIZE,
|
||||
top: style.top + GUTTER_SIZE,
|
||||
width: style.width - GUTTER_SIZE,
|
||||
height: style.height - GUTTER_SIZE,
|
||||
}}
|
||||
onClick={() => setIconName(icon.pascal_name)}
|
||||
className={`cursor-pointer p-[6px] rounded-lg bg-base-100 w-full ${
|
||||
icon.pascal_name === iconName
|
||||
? "outline outline-1 outline-primary"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<IconComponent size={32} weight={weight} color={color} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const InnerElementType = forwardRef(({ style, ...rest }: any, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
paddingLeft: GUTTER_SIZE,
|
||||
paddingTop: GUTTER_SIZE,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
));
|
||||
|
||||
InnerElementType.displayName = "InnerElementType";
|
||||
|
||||
return (
|
||||
<Grid
|
||||
columnCount={columnCount}
|
||||
rowCount={rowCount}
|
||||
columnWidth={50}
|
||||
rowHeight={50}
|
||||
innerElementType={InnerElementType}
|
||||
width={320}
|
||||
height={158}
|
||||
itemData={filteredIcons}
|
||||
className="hide-scrollbar ml-[4px] w-fit"
|
||||
>
|
||||
{Cell}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconGrid;
|
||||
147
components/IconPopover.tsx
Normal file
147
components/IconPopover.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import React, { useState } from "react";
|
||||
import TextInput from "./TextInput";
|
||||
import Popover from "./Popover";
|
||||
import { HexColorPicker } from "react-colorful";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import IconGrid from "./IconGrid";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = {
|
||||
alignment?: string;
|
||||
color: string;
|
||||
setColor: Function;
|
||||
iconName?: string;
|
||||
setIconName: Function;
|
||||
weight: "light" | "regular" | "bold" | "fill" | "duotone" | "thin";
|
||||
setWeight: Function;
|
||||
reset: Function;
|
||||
className?: string;
|
||||
onClose: Function;
|
||||
};
|
||||
|
||||
const IconPopover = ({
|
||||
alignment,
|
||||
color,
|
||||
setColor,
|
||||
iconName,
|
||||
setIconName,
|
||||
weight,
|
||||
setWeight,
|
||||
reset,
|
||||
className,
|
||||
onClose,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onClose={() => onClose()}
|
||||
className={clsx(
|
||||
className,
|
||||
"fade-in bg-base-200 border border-neutral-content p-3 w-[22.5rem] rounded-lg shadow-md"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-3 w-full h-full">
|
||||
<TextInput
|
||||
className="p-2 rounded w-full h-7 text-sm"
|
||||
placeholder={t("search")}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-6 gap-1 w-full overflow-y-auto h-44 border border-neutral-content bg-base-100 rounded-md p-2">
|
||||
<IconGrid
|
||||
query={query}
|
||||
color={color}
|
||||
weight={weight}
|
||||
iconName={iconName}
|
||||
setIconName={setIconName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 color-picker w-full justify-between">
|
||||
<HexColorPicker
|
||||
color={color}
|
||||
onChange={(e) => setColor(e)}
|
||||
className="border border-neutral-content rounded-lg"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
className="radio radio-primary mr-2"
|
||||
value="regular"
|
||||
checked={weight === "regular"}
|
||||
onChange={() => setWeight("regular")}
|
||||
/>
|
||||
{t("regular")}
|
||||
</label>
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
className="radio radio-primary mr-2"
|
||||
value="thin"
|
||||
checked={weight === "thin"}
|
||||
onChange={() => setWeight("thin")}
|
||||
/>
|
||||
{t("thin")}
|
||||
</label>
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
className="radio radio-primary mr-2"
|
||||
value="light"
|
||||
checked={weight === "light"}
|
||||
onChange={() => setWeight("light")}
|
||||
/>
|
||||
{t("light_icon")}
|
||||
</label>
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
className="radio radio-primary mr-2"
|
||||
value="bold"
|
||||
checked={weight === "bold"}
|
||||
onChange={() => setWeight("bold")}
|
||||
/>
|
||||
{t("bold")}
|
||||
</label>
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
className="radio radio-primary mr-2"
|
||||
value="fill"
|
||||
checked={weight === "fill"}
|
||||
onChange={() => setWeight("fill")}
|
||||
/>
|
||||
{t("fill")}
|
||||
</label>
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
className="radio radio-primary mr-2"
|
||||
value="duotone"
|
||||
checked={weight === "duotone"}
|
||||
onChange={() => setWeight("duotone")}
|
||||
/>
|
||||
{t("duotone")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 justify-between items-center mt-2">
|
||||
<div
|
||||
className="btn btn-ghost btn-xs w-fit"
|
||||
onClick={reset as React.MouseEventHandler<HTMLDivElement>}
|
||||
>
|
||||
{t("reset_defaults")}
|
||||
</div>
|
||||
<p className="text-neutral text-xs">{t("click_out_to_apply")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconPopover;
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ export const styles: StylesConfig = {
|
||||
},
|
||||
transition: "all 50ms",
|
||||
}),
|
||||
menu: (styles) => ({
|
||||
...styles,
|
||||
zIndex: 10,
|
||||
}),
|
||||
control: (styles, state) => ({
|
||||
...styles,
|
||||
fontFamily: font,
|
||||
|
||||
@@ -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">
|
||||
<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"
|
||||
|
||||
687
components/LinkDetails.tsx
Normal file
687
components/LinkDetails.tsx
Normal file
@@ -0,0 +1,687 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
ArchivedFormat,
|
||||
} from "@/types/global";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
pdfAvailable,
|
||||
readabilityAvailable,
|
||||
monolithAvailable,
|
||||
screenshotAvailable,
|
||||
previewAvailable,
|
||||
} from "@/lib/shared/getArchiveValidity";
|
||||
import PreservedFormatRow from "@/components/PreserverdFormatRow";
|
||||
import getPublicUserData from "@/lib/client/getPublicUserData";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { BeatLoader } from "react-spinners";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import {
|
||||
useGetLink,
|
||||
useUpdateLink,
|
||||
useUpdatePreview,
|
||||
} from "@/hooks/store/links";
|
||||
import LinkIcon from "./LinkViews/LinkComponents/LinkIcon";
|
||||
import CopyButton from "./CopyButton";
|
||||
import { useRouter } from "next/router";
|
||||
import Icon from "./Icon";
|
||||
import { IconWeight } from "@phosphor-icons/react";
|
||||
import Image from "next/image";
|
||||
import clsx from "clsx";
|
||||
import toast from "react-hot-toast";
|
||||
import CollectionSelection from "./InputSelect/CollectionSelection";
|
||||
import TagSelection from "./InputSelect/TagSelection";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import IconPopover from "./IconPopover";
|
||||
import TextInput from "./TextInput";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
activeLink: LinkIncludingShortenedCollectionAndTags;
|
||||
standalone?: boolean;
|
||||
mode?: "view" | "edit";
|
||||
setMode?: Function;
|
||||
onUpdateArchive?: Function;
|
||||
};
|
||||
|
||||
export default function LinkDetails({
|
||||
className,
|
||||
activeLink,
|
||||
standalone,
|
||||
mode = "view",
|
||||
setMode,
|
||||
onUpdateArchive,
|
||||
}: Props) {
|
||||
const [link, setLink] =
|
||||
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
|
||||
|
||||
useEffect(() => {
|
||||
setLink(activeLink);
|
||||
}, [activeLink]);
|
||||
|
||||
const permissions = usePermissions(link.collection.id as number);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const getLink = useGetLink();
|
||||
const { data: user = {} } = useUser();
|
||||
|
||||
const [collectionOwner, setCollectionOwner] = useState({
|
||||
id: null as unknown as number,
|
||||
name: "",
|
||||
username: "",
|
||||
image: "",
|
||||
archiveAsScreenshot: undefined as unknown as boolean,
|
||||
archiveAsMonolith: undefined as unknown as boolean,
|
||||
archiveAsPDF: undefined as unknown as boolean,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOwner = async () => {
|
||||
if (link.collection.ownerId !== user.id) {
|
||||
const owner = await getPublicUserData(
|
||||
link.collection.ownerId as number
|
||||
);
|
||||
setCollectionOwner(owner);
|
||||
} else if (link.collection.ownerId === user.id) {
|
||||
setCollectionOwner({
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fetchOwner();
|
||||
}, [link.collection.ownerId]);
|
||||
|
||||
const isReady = () => {
|
||||
return (
|
||||
link &&
|
||||
(collectionOwner.archiveAsScreenshot === true
|
||||
? link.pdf && link.pdf !== "pending"
|
||||
: true) &&
|
||||
(collectionOwner.archiveAsMonolith === true
|
||||
? link.monolith && link.monolith !== "pending"
|
||||
: true) &&
|
||||
(collectionOwner.archiveAsPDF === true
|
||||
? link.pdf && link.pdf !== "pending"
|
||||
: true) &&
|
||||
link.readable &&
|
||||
link.readable !== "pending"
|
||||
);
|
||||
};
|
||||
|
||||
const atLeastOneFormatAvailable = () => {
|
||||
return (
|
||||
screenshotAvailable(link) ||
|
||||
pdfAvailable(link) ||
|
||||
readabilityAvailable(link) ||
|
||||
monolithAvailable(link)
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await getLink.mutateAsync({
|
||||
id: link.id as number,
|
||||
});
|
||||
})();
|
||||
|
||||
let interval: any;
|
||||
|
||||
if (!isReady()) {
|
||||
interval = setInterval(async () => {
|
||||
await getLink.mutateAsync({
|
||||
id: link.id as number,
|
||||
});
|
||||
}, 5000);
|
||||
} else {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [link.monolith]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
const updateLink = useUpdateLink();
|
||||
const updatePreview = useUpdatePreview();
|
||||
|
||||
const submit = async (e?: any) => {
|
||||
e?.preventDefault();
|
||||
|
||||
const { updatedAt: b, ...oldLink } = activeLink;
|
||||
const { updatedAt: a, ...newLink } = link;
|
||||
|
||||
if (JSON.stringify(oldLink) === JSON.stringify(newLink)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const load = toast.loading(t("updating"));
|
||||
|
||||
await updateLink.mutateAsync(link, {
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.success(t("updated"));
|
||||
setMode && setMode("view");
|
||||
setLink(data);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const setCollection = (e: any) => {
|
||||
if (e?.__isNew__) e.value = null;
|
||||
setLink({
|
||||
...link,
|
||||
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
|
||||
});
|
||||
};
|
||||
|
||||
const setTags = (e: any) => {
|
||||
const tagNames = e.map((e: any) => ({ name: e.label }));
|
||||
setLink({ ...link, tags: tagNames });
|
||||
};
|
||||
|
||||
const [iconPopover, setIconPopover] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={clsx(className)} data-vaul-no-drag>
|
||||
<div
|
||||
className={clsx(
|
||||
standalone && "sm:border sm:border-neutral-content sm:rounded-2xl p-5"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"overflow-hidden select-none relative group h-40 opacity-80",
|
||||
standalone
|
||||
? "sm:max-w-xl -mx-5 -mt-5 sm:rounded-t-2xl"
|
||||
: "-mx-4 -mt-4"
|
||||
)}
|
||||
>
|
||||
{previewAvailable(link) ? (
|
||||
<Image
|
||||
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`}
|
||||
width={1280}
|
||||
height={720}
|
||||
alt=""
|
||||
className="object-cover scale-105 object-center h-full"
|
||||
style={{
|
||||
filter: "blur(1px)",
|
||||
}}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : link.preview === "unavailable" ? (
|
||||
<div className="bg-gray-50 duration-100 h-40"></div>
|
||||
) : (
|
||||
<div className="duration-100 h-40 skeleton rounded-none"></div>
|
||||
)}
|
||||
|
||||
{!standalone &&
|
||||
(permissions === true || permissions?.canUpdate) &&
|
||||
!isPublicRoute && (
|
||||
<div className="absolute top-0 bottom-0 left-0 right-0 opacity-0 group-hover:opacity-100 duration-100 flex justify-end items-end">
|
||||
<label className="btn btn-xs mb-2 mr-3 opacity-50 hover:opacity-100">
|
||||
{t("upload_preview_image")}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpg, image/jpeg, image/png"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const load = toast.loading(t("updating"));
|
||||
|
||||
await updatePreview.mutateAsync(
|
||||
{
|
||||
linkId: link.id as number,
|
||||
file,
|
||||
},
|
||||
{
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.success(t("updated"));
|
||||
setLink({ updatedAt: data.updatedAt, ...link });
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!standalone &&
|
||||
(permissions === true || permissions?.canUpdate) &&
|
||||
!isPublicRoute ? (
|
||||
<div className="-mt-14 ml-8 relative w-fit pb-2">
|
||||
<div className="tooltip tooltip-bottom" data-tip={t("change_icon")}>
|
||||
<LinkIcon
|
||||
link={link}
|
||||
className="hover:bg-opacity-70 duration-100 cursor-pointer"
|
||||
onClick={() => setIconPopover(true)}
|
||||
/>
|
||||
</div>
|
||||
{iconPopover && (
|
||||
<IconPopover
|
||||
color={link.color || "#006796"}
|
||||
setColor={(color: string) => setLink({ ...link, color })}
|
||||
weight={(link.iconWeight || "regular") as IconWeight}
|
||||
setWeight={(iconWeight: string) =>
|
||||
setLink({ ...link, iconWeight })
|
||||
}
|
||||
iconName={link.icon as string}
|
||||
setIconName={(icon: string) => setLink({ ...link, icon })}
|
||||
reset={() =>
|
||||
setLink({
|
||||
...link,
|
||||
color: "",
|
||||
icon: "",
|
||||
iconWeight: "",
|
||||
})
|
||||
}
|
||||
className="top-12"
|
||||
onClose={() => {
|
||||
setIconPopover(false);
|
||||
submit();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="-mt-14 ml-8 relative w-fit pb-2">
|
||||
<LinkIcon link={link} onClick={() => setIconPopover(true)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-xl sm:px-8 p-5 pb-8 pt-2">
|
||||
{mode === "view" && (
|
||||
<div className="text-xl mt-2 pr-7">
|
||||
<p
|
||||
className={clsx("relative w-fit", !link.name && "text-neutral")}
|
||||
>
|
||||
{unescapeString(link.name) || t("untitled")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "edit" && (
|
||||
<>
|
||||
<br />
|
||||
|
||||
<div>
|
||||
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
|
||||
{t("name")}
|
||||
</p>
|
||||
<TextInput
|
||||
value={link.name}
|
||||
onChange={(e) => setLink({ ...link, name: e.target.value })}
|
||||
placeholder={t("placeholder_example_link")}
|
||||
className="bg-base-200"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{link.url && mode === "view" ? (
|
||||
<>
|
||||
<br />
|
||||
|
||||
<p className="text-sm mb-2 text-neutral">{t("link")}</p>
|
||||
|
||||
<div className="relative">
|
||||
<div className="rounded-md p-2 bg-base-200 hide-scrollbar overflow-x-auto whitespace-nowrap flex justify-between items-center gap-2 pr-14">
|
||||
<Link href={link.url} title={link.url} target="_blank">
|
||||
{link.url}
|
||||
</Link>
|
||||
<div className="absolute right-0 px-2 bg-base-200">
|
||||
<CopyButton text={link.url} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : activeLink.url ? (
|
||||
<>
|
||||
<br />
|
||||
|
||||
<div>
|
||||
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
|
||||
{t("link")}
|
||||
</p>
|
||||
<TextInput
|
||||
value={link.url || ""}
|
||||
onChange={(e) => setLink({ ...link, url: e.target.value })}
|
||||
placeholder={t("placeholder_example_link")}
|
||||
className="bg-base-200"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : undefined}
|
||||
|
||||
<br />
|
||||
|
||||
<div className="relative">
|
||||
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
|
||||
{t("collection")}
|
||||
</p>
|
||||
|
||||
{mode === "view" ? (
|
||||
<div className="relative">
|
||||
<Link
|
||||
href={
|
||||
isPublicRoute
|
||||
? `/public/collections/${link.collection.id}`
|
||||
: `/collections/${link.collection.id}`
|
||||
}
|
||||
className="rounded-md p-2 bg-base-200 border border-base-200 hide-scrollbar overflow-x-auto whitespace-nowrap flex justify-between items-center gap-2 pr-14"
|
||||
>
|
||||
<p>{link.collection.name}</p>
|
||||
<div className="absolute right-0 px-2 bg-base-200">
|
||||
{link.collection.icon ? (
|
||||
<Icon
|
||||
icon={link.collection.icon}
|
||||
size={30}
|
||||
weight={
|
||||
(link.collection.iconWeight ||
|
||||
"regular") as IconWeight
|
||||
}
|
||||
color={link.collection.color}
|
||||
/>
|
||||
) : (
|
||||
<i
|
||||
className="bi-folder-fill text-2xl"
|
||||
style={{ color: link.collection.color }}
|
||||
></i>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<CollectionSelection
|
||||
onChange={setCollection}
|
||||
defaultValue={
|
||||
link.collection.id
|
||||
? { value: link.collection.id, label: link.collection.name }
|
||||
: { value: null as unknown as number, label: "Unorganized" }
|
||||
}
|
||||
creatable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<div className="relative">
|
||||
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
|
||||
{t("tags")}
|
||||
</p>
|
||||
|
||||
{mode === "view" ? (
|
||||
<div className="flex gap-2 flex-wrap rounded-md p-2 bg-base-200 border border-base-200 w-full text-xs">
|
||||
{link.tags && link.tags[0] ? (
|
||||
link.tags.map((tag) =>
|
||||
isPublicRoute ? (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="bg-base-200 p-1 hover:bg-neutral-content rounded-md duration-100"
|
||||
>
|
||||
{tag.name}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href={"/tags/" + tag.id}
|
||||
key={tag.id}
|
||||
className="bg-base-200 p-1 hover:bg-neutral-content btn btn-xs btn-ghost rounded-md"
|
||||
>
|
||||
{tag.name}
|
||||
</Link>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<div className="text-neutral text-base">{t("no_tags")}</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<TagSelection
|
||||
onChange={setTags}
|
||||
defaultValue={link.tags.map((e) => ({
|
||||
label: e.name,
|
||||
value: e.id,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<div className="relative">
|
||||
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
|
||||
{t("description")}
|
||||
</p>
|
||||
|
||||
{mode === "view" ? (
|
||||
<div className="rounded-md p-2 bg-base-200 hyphens-auto">
|
||||
{link.description ? (
|
||||
<p>{link.description}</p>
|
||||
) : (
|
||||
<p className="text-neutral">{t("no_description_provided")}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
value={unescapeString(link.description) as string}
|
||||
onChange={(e) =>
|
||||
setLink({ ...link, description: e.target.value })
|
||||
}
|
||||
placeholder={t("link_description_placeholder")}
|
||||
className="resize-none w-full rounded-md p-2 h-32 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mode === "view" && (
|
||||
<div>
|
||||
<br />
|
||||
|
||||
<div className="flex gap-1 items-center mb-2">
|
||||
<p
|
||||
className="text-sm text-neutral"
|
||||
title={t("available_formats")}
|
||||
>
|
||||
{link.url ? t("preserved_formats") : t("file")}
|
||||
</p>
|
||||
|
||||
{onUpdateArchive &&
|
||||
(permissions === true || permissions?.canUpdate) &&
|
||||
!isPublicRoute && (
|
||||
<div
|
||||
className="tooltip tooltip-bottom"
|
||||
data-tip={t("refresh_preserved_formats")}
|
||||
>
|
||||
<button
|
||||
className="btn btn-xs btn-ghost btn-square text-neutral"
|
||||
onClick={() => onUpdateArchive()}
|
||||
>
|
||||
<i className="bi-arrow-clockwise text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`flex flex-col rounded-md p-3 bg-base-200`}>
|
||||
{monolithAvailable(link) ? (
|
||||
<>
|
||||
<PreservedFormatRow
|
||||
name={t("webpage")}
|
||||
icon={"bi-filetype-html"}
|
||||
format={ArchivedFormat.monolith}
|
||||
link={link}
|
||||
downloadable={true}
|
||||
/>
|
||||
<hr className="m-3 border-t border-neutral-content" />
|
||||
</>
|
||||
) : undefined}
|
||||
|
||||
{screenshotAvailable(link) ? (
|
||||
<>
|
||||
<PreservedFormatRow
|
||||
name={t("screenshot")}
|
||||
icon={"bi-file-earmark-image"}
|
||||
format={
|
||||
link?.image?.endsWith("png")
|
||||
? ArchivedFormat.png
|
||||
: ArchivedFormat.jpeg
|
||||
}
|
||||
link={link}
|
||||
downloadable={true}
|
||||
/>
|
||||
<hr className="m-3 border-t border-neutral-content" />
|
||||
</>
|
||||
) : undefined}
|
||||
|
||||
{pdfAvailable(link) ? (
|
||||
<>
|
||||
<PreservedFormatRow
|
||||
name={t("pdf")}
|
||||
icon={"bi-file-earmark-pdf"}
|
||||
format={ArchivedFormat.pdf}
|
||||
link={link}
|
||||
downloadable={true}
|
||||
/>
|
||||
<hr className="m-3 border-t border-neutral-content" />
|
||||
</>
|
||||
) : undefined}
|
||||
|
||||
{readabilityAvailable(link) ? (
|
||||
<>
|
||||
<PreservedFormatRow
|
||||
name={t("readable")}
|
||||
icon={"bi-file-earmark-text"}
|
||||
format={ArchivedFormat.readability}
|
||||
link={link}
|
||||
/>
|
||||
<hr className="m-3 border-t border-neutral-content" />
|
||||
</>
|
||||
) : undefined}
|
||||
|
||||
{!isReady() && !atLeastOneFormatAvailable() ? (
|
||||
<div
|
||||
className={`w-full h-full flex flex-col justify-center p-10`}
|
||||
>
|
||||
<BeatLoader
|
||||
color="oklch(var(--p))"
|
||||
className="mx-auto mb-3"
|
||||
size={30}
|
||||
/>
|
||||
|
||||
<p className="text-center text-2xl">
|
||||
{t("preservation_in_queue")}
|
||||
</p>
|
||||
<p className="text-center text-lg">
|
||||
{t("check_back_later")}
|
||||
</p>
|
||||
</div>
|
||||
) : link.url && !isReady() && atLeastOneFormatAvailable() ? (
|
||||
<div
|
||||
className={`w-full h-full flex flex-col justify-center p-5`}
|
||||
>
|
||||
<BeatLoader
|
||||
color="oklch(var(--p))"
|
||||
className="mx-auto mb-3"
|
||||
size={20}
|
||||
/>
|
||||
<p className="text-center">{t("there_are_more_formats")}</p>
|
||||
<p className="text-center text-sm">
|
||||
{t("check_back_later")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined}
|
||||
|
||||
{link.url && (
|
||||
<Link
|
||||
href={`https://web.archive.org/web/${link?.url?.replace(
|
||||
/(^\w+:|^)\/\//,
|
||||
""
|
||||
)}`}
|
||||
target="_blank"
|
||||
className="text-neutral mx-auto duration-100 hover:opacity-60 flex gap-2 w-1/2 justify-center items-center text-sm"
|
||||
>
|
||||
<p className="whitespace-nowrap">
|
||||
{t("view_latest_snapshot")}
|
||||
</p>
|
||||
<i className="bi-box-arrow-up-right" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "view" ? (
|
||||
<>
|
||||
<br />
|
||||
|
||||
<p className="text-neutral text-xs text-center">
|
||||
{t("saved")}{" "}
|
||||
{new Date(link.createdAt || "").toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}{" "}
|
||||
at{" "}
|
||||
{new Date(link.createdAt || "").toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<br />
|
||||
<div className="flex justify-end items-center">
|
||||
<button
|
||||
className={clsx(
|
||||
"btn btn-accent text-white",
|
||||
JSON.stringify(activeLink) === JSON.stringify(link)
|
||||
? "btn-disabled"
|
||||
: "dark:border-violet-400"
|
||||
)}
|
||||
onClick={submit}
|
||||
>
|
||||
{t("save_changes")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
import {
|
||||
ArchivedFormat,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} 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";
|
||||
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 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 { useTranslation } from "next-i18next";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
count: number;
|
||||
className?: string;
|
||||
flipDropdown?: boolean;
|
||||
editMode?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkCard({ link, flipDropdown, editMode }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const viewMode = localStorage.getItem("viewMode") || "card";
|
||||
const { collections } = useCollectionStore();
|
||||
const { account } = useAccountStore();
|
||||
|
||||
const { links, getLink, setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
setSelectedLinks([]);
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
const handleCheckboxClick = (
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
) => {
|
||||
if (selectedLinks.includes(link)) {
|
||||
setSelectedLinks(selectedLinks.filter((e) => e !== link));
|
||||
} else {
|
||||
setSelectedLinks([...selectedLinks, link]);
|
||||
}
|
||||
};
|
||||
|
||||
let shortendURL;
|
||||
|
||||
try {
|
||||
if (link.url) {
|
||||
shortendURL = new URL(link.url).host.toLowerCase();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>(
|
||||
collections.find(
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCollection(
|
||||
collections.find(
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
}, [collections, links]);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isVisible = useOnScreen(ref);
|
||||
const permissions = usePermissions(collection?.id as number);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: any;
|
||||
|
||||
if (
|
||||
isVisible &&
|
||||
!link.preview?.startsWith("archives") &&
|
||||
link.preview !== "unavailable"
|
||||
) {
|
||||
interval = setInterval(async () => {
|
||||
getLink(link.id as number);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [isVisible, link.preview]);
|
||||
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
||||
const selectedStyle = selectedLinks.some(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
)
|
||||
? "border-primary bg-base-300"
|
||||
: "border-neutral-content";
|
||||
|
||||
const selectable =
|
||||
editMode &&
|
||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative`}
|
||||
onClick={() =>
|
||||
selectable
|
||||
? handleCheckboxClick(link)
|
||||
: editMode
|
||||
? toast.error(t("link_selection_error"))
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="rounded-2xl cursor-pointer h-full flex flex-col justify-between"
|
||||
onClick={() =>
|
||||
!editMode && window.open(generateLinkHref(link, account), "_blank")
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div className="relative rounded-t-2xl h-40 overflow-hidden">
|
||||
{previewAvailable(link) ? (
|
||||
<Image
|
||||
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
|
||||
width={1280}
|
||||
height={720}
|
||||
alt=""
|
||||
className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105"
|
||||
style={
|
||||
link.type !== "image" ? { filter: "blur(1px)" } : undefined
|
||||
}
|
||||
draggable="false"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : link.preview === "unavailable" ? (
|
||||
<div className="bg-gray-50 duration-100 h-40 bg-opacity-80"></div>
|
||||
) : (
|
||||
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
|
||||
)}
|
||||
{link.type !== "image" && (
|
||||
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md">
|
||||
<LinkIcon link={link} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<hr className="divider my-0 border-t border-neutral-content h-[1px]" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-between h-full">
|
||||
<div className="p-3 flex flex-col gap-2">
|
||||
<p className="truncate w-full pr-9 text-primary text-sm">
|
||||
{unescapeString(link.name)}
|
||||
</p>
|
||||
|
||||
<LinkTypeBadge link={link} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
|
||||
<div className="flex justify-between text-xs text-neutral px-3 pb-1 gap-2">
|
||||
<div className="cursor-pointer truncate">
|
||||
{collection && (
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
)}
|
||||
</div>
|
||||
<LinkDate link={link} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showInfo && (
|
||||
<div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-[0.9rem] fade-in overflow-y-auto">
|
||||
<div
|
||||
onClick={() => setShowInfo(!showInfo)}
|
||||
className=" float-right btn btn-sm outline-none btn-circle btn-ghost z-10"
|
||||
>
|
||||
<i className="bi-x text-neutral text-2xl"></i>
|
||||
</div>
|
||||
<p className="text-neutral text-lg font-semibold">
|
||||
{t("description")}
|
||||
</p>
|
||||
|
||||
<hr className="divider my-2 border-t border-neutral-content h-[1px]" />
|
||||
<p>
|
||||
{link.description ? (
|
||||
unescapeString(link.description)
|
||||
) : (
|
||||
<span className="text-neutral text-sm">
|
||||
{t("no_description")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{link.tags && link.tags[0] && (
|
||||
<>
|
||||
<p className="text-neutral text-lg mt-3 font-semibold">
|
||||
{t("tags")}
|
||||
</p>
|
||||
|
||||
<hr className="divider my-2 border-t border-neutral-content h-[1px]" />
|
||||
|
||||
<div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
|
||||
<div className="flex gap-1 items-center flex-wrap">
|
||||
{link.tags.map((e, i) => (
|
||||
<Link
|
||||
href={"/tags/" + e.id}
|
||||
key={i}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
|
||||
>
|
||||
#{e.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
position="top-[10.75rem] right-3"
|
||||
toggleShowInfo={() => setShowInfo(!showInfo)}
|
||||
linkInfo={showInfo}
|
||||
flipDropdown={flipDropdown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,192 +4,189 @@ import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@/types/global";
|
||||
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 { useDeleteLink, useGetLink } from "@/hooks/store/links";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkModal from "@/components/ModalContent/LinkModal";
|
||||
import { useRouter } from "next/router";
|
||||
import clsx from "clsx";
|
||||
import usePinLink from "@/lib/client/pinLink";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
position?: string;
|
||||
toggleShowInfo?: () => void;
|
||||
linkInfo?: boolean;
|
||||
alignToTop?: boolean;
|
||||
flipDropdown?: boolean;
|
||||
btnStyle?: string;
|
||||
};
|
||||
|
||||
export default function LinkActions({
|
||||
link,
|
||||
toggleShowInfo,
|
||||
position,
|
||||
linkInfo,
|
||||
alignToTop,
|
||||
flipDropdown,
|
||||
}: Props) {
|
||||
export default function LinkActions({ link, btnStyle }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const permissions = usePermissions(link.collection.id as number);
|
||||
const getLink = useGetLink();
|
||||
|
||||
const pinLink = usePinLink();
|
||||
|
||||
const [editLinkModal, setEditLinkModal] = useState(false);
|
||||
const [linkModal, setLinkModal] = useState(false);
|
||||
const [deleteLinkModal, setDeleteLinkModal] = useState(false);
|
||||
const [preservedFormatsModal, setPreservedFormatsModal] = useState(false);
|
||||
|
||||
const { account } = useAccountStore();
|
||||
const deleteLink = useDeleteLink();
|
||||
|
||||
const { removeLink, updateLink } = useLinkStore();
|
||||
const updateArchive = async () => {
|
||||
const load = toast.loading(t("sending_request"));
|
||||
|
||||
const pinLink = async () => {
|
||||
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0];
|
||||
|
||||
const load = toast.loading(t("applying"));
|
||||
|
||||
const response = await updateLink({
|
||||
...link,
|
||||
pinnedBy: isAlreadyPinned ? undefined : [{ id: account.id }],
|
||||
const response = await fetch(`/api/v1/links/${link?.id}/archive`, {
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
toast.dismiss(load);
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(isAlreadyPinned ? t("link_unpinned") : t("link_unpinned"));
|
||||
} else {
|
||||
toast.error(response.data as string);
|
||||
}
|
||||
await getLink.mutateAsync({ id: link.id as number });
|
||||
|
||||
toast.success(t("link_being_archived"));
|
||||
} else toast.error(data.response);
|
||||
};
|
||||
|
||||
const deleteLink = async () => {
|
||||
const load = toast.loading(t("deleting"));
|
||||
const router = useRouter();
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`dropdown dropdown-left absolute ${
|
||||
position || "top-3 right-3"
|
||||
} ${alignToTop ? "" : "dropdown-end"} z-20`}
|
||||
>
|
||||
{isPublicRoute ? (
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100"
|
||||
onMouseDown={dropdownTriggerer}
|
||||
className="btn btn-ghost btn-sm btn-square text-neutral"
|
||||
onClick={() => setLinkModal(true)}
|
||||
>
|
||||
<i title="More" className="bi-three-dots text-xl" />
|
||||
<div className={clsx("btn btn-sm btn-square text-neutral", btnStyle)}>
|
||||
<i title="More" className="bi-info-circle text-xl" />
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
className={`dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1 ${
|
||||
alignToTop ? "" : "translate-y-10"
|
||||
}`}
|
||||
) : (
|
||||
<div
|
||||
className={`dropdown dropdown-end absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 z-20`}
|
||||
>
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
pinLink();
|
||||
}}
|
||||
>
|
||||
{link?.pinnedBy && link.pinnedBy[0]
|
||||
? t("unpin")
|
||||
: t("pin_to_dashboard")}
|
||||
</div>
|
||||
</li>
|
||||
{linkInfo !== undefined && toggleShowInfo ? (
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onMouseDown={dropdownTriggerer}
|
||||
className={clsx("btn btn-sm btn-square text-neutral", btnStyle)}
|
||||
>
|
||||
<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 mt-1"
|
||||
}
|
||||
>
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
toggleShowInfo();
|
||||
pinLink(link);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{!linkInfo ? t("show_link_details") : t("hide_link_details")}
|
||||
{link?.pinnedBy && link.pinnedBy[0]
|
||||
? t("unpin")
|
||||
: t("pin_to_dashboard")}
|
||||
</div>
|
||||
</li>
|
||||
) : undefined}
|
||||
{permissions === true || permissions?.canUpdate ? (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setEditLinkModal(true);
|
||||
setLinkModal(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("edit_link")}
|
||||
{t("show_link_details")}
|
||||
</div>
|
||||
</li>
|
||||
) : undefined}
|
||||
{link.type === "url" && (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setPreservedFormatsModal(true);
|
||||
}}
|
||||
>
|
||||
{t("preserved_formats")}
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
{permissions === true || permissions?.canDelete ? (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
e.shiftKey ? deleteLink() : setDeleteLinkModal(true);
|
||||
}}
|
||||
>
|
||||
{t("delete")}
|
||||
</div>
|
||||
</li>
|
||||
) : undefined}
|
||||
</ul>
|
||||
</div>
|
||||
{(permissions === true || permissions?.canUpdate) && (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setEditLinkModal(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("edit_link")}
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
{(permissions === true || permissions?.canDelete) && (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={async (e) => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
console.log(e.shiftKey);
|
||||
e.shiftKey
|
||||
? (async () => {
|
||||
const load = toast.loading(t("deleting"));
|
||||
|
||||
{editLinkModal ? (
|
||||
<EditLinkModal
|
||||
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>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{editLinkModal && (
|
||||
<LinkModal
|
||||
onClose={() => setEditLinkModal(false)}
|
||||
activeLink={link}
|
||||
onPin={() => pinLink(link)}
|
||||
onUpdateArchive={updateArchive}
|
||||
onDelete={() => setDeleteLinkModal(true)}
|
||||
link={link}
|
||||
activeMode="edit"
|
||||
/>
|
||||
) : undefined}
|
||||
{deleteLinkModal ? (
|
||||
)}
|
||||
{deleteLinkModal && (
|
||||
<DeleteLinkModal
|
||||
onClose={() => setDeleteLinkModal(false)}
|
||||
activeLink={link}
|
||||
/>
|
||||
) : undefined}
|
||||
{preservedFormatsModal ? (
|
||||
<PreservedFormatsModal
|
||||
onClose={() => setPreservedFormatsModal(false)}
|
||||
activeLink={link}
|
||||
)}
|
||||
{linkModal && (
|
||||
<LinkModal
|
||||
onClose={() => setLinkModal(false)}
|
||||
onPin={() => pinLink(link)}
|
||||
onUpdateArchive={updateArchive}
|
||||
onDelete={() => setDeleteLinkModal(true)}
|
||||
link={link}
|
||||
/>
|
||||
) : undefined}
|
||||
{/* {expandedLink ? (
|
||||
<ExpandedLink onClose={() => setExpandedLink(false)} link={link} />
|
||||
) : undefined} */}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
241
components/LinkViews/LinkComponents/LinkCard.tsx
Normal file
241
components/LinkViews/LinkComponents/LinkCard.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import {
|
||||
ArchivedFormat,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@/types/global";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
|
||||
import Image from "next/image";
|
||||
import { previewAvailable } from "@/lib/shared/getArchiveValidity";
|
||||
import LinkIcon from "./LinkIcon";
|
||||
import useOnScreen from "@/hooks/useOnScreen";
|
||||
import { generateLinkHref } from "@/lib/client/generateLinkHref";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
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";
|
||||
import { useRouter } from "next/router";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import LinkPin from "./LinkPin";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
count: number;
|
||||
columns: number;
|
||||
className?: string;
|
||||
editMode?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const heightMap = {
|
||||
1: "h-44",
|
||||
2: "h-40",
|
||||
3: "h-36",
|
||||
4: "h-32",
|
||||
5: "h-28",
|
||||
6: "h-24",
|
||||
7: "h-20",
|
||||
8: "h-20",
|
||||
};
|
||||
|
||||
const imageHeightClass = useMemo(
|
||||
() => (columns ? heightMap[columns as keyof typeof heightMap] : "h-40"),
|
||||
[columns]
|
||||
);
|
||||
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const { data: user = {} } = useUser();
|
||||
|
||||
const { setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
|
||||
const {
|
||||
settings: { show },
|
||||
} = useLocalSettingsStore();
|
||||
|
||||
const {
|
||||
data: { data: links = [] },
|
||||
} = useLinks();
|
||||
const getLink = useGetLink();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
setSelectedLinks([]);
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
const handleCheckboxClick = (
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
) => {
|
||||
if (selectedLinks.includes(link)) {
|
||||
setSelectedLinks(selectedLinks.filter((e) => e !== link));
|
||||
} else {
|
||||
setSelectedLinks([...selectedLinks, link]);
|
||||
}
|
||||
};
|
||||
|
||||
let shortendURL;
|
||||
|
||||
try {
|
||||
if (link.url) {
|
||||
shortendURL = new URL(link.url).host.toLowerCase();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>(
|
||||
collections.find(
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCollection(
|
||||
collections.find(
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
}, [collections, links]);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isVisible = useOnScreen(ref);
|
||||
const permissions = usePermissions(collection?.id as number);
|
||||
|
||||
const router = useRouter();
|
||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
|
||||
if (
|
||||
isVisible &&
|
||||
!link.preview?.startsWith("archives") &&
|
||||
link.preview !== "unavailable"
|
||||
) {
|
||||
interval = setInterval(async () => {
|
||||
getLink.mutateAsync({
|
||||
id: link.id as number,
|
||||
isPublicRoute: isPublicRoute,
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [isVisible, link.preview]);
|
||||
|
||||
const selectedStyle = selectedLinks.some(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
)
|
||||
? "border-primary bg-base-300"
|
||||
: "border-neutral-content";
|
||||
|
||||
const selectable =
|
||||
editMode &&
|
||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative group`}
|
||||
onClick={() =>
|
||||
selectable
|
||||
? handleCheckboxClick(link)
|
||||
: editMode
|
||||
? toast.error(t("link_selection_error"))
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="rounded-2xl cursor-pointer h-full flex flex-col justify-between"
|
||||
onClick={() =>
|
||||
!editMode && window.open(generateLinkHref(link, user), "_blank")
|
||||
}
|
||||
>
|
||||
{show.image && (
|
||||
<div>
|
||||
<div
|
||||
className={`relative rounded-t-2xl ${imageHeightClass} overflow-hidden`}
|
||||
>
|
||||
{previewAvailable(link) ? (
|
||||
<Image
|
||||
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`}
|
||||
width={1280}
|
||||
height={720}
|
||||
alt=""
|
||||
className={`rounded-t-2xl select-none object-cover z-10 ${imageHeightClass} w-full shadow opacity-80 scale-105`}
|
||||
style={show.icon ? { filter: "blur(1px)" } : undefined}
|
||||
draggable="false"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : link.preview === "unavailable" ? (
|
||||
<div
|
||||
className={`bg-gray-50 ${imageHeightClass} bg-opacity-80`}
|
||||
></div>
|
||||
) : (
|
||||
<div
|
||||
className={`${imageHeightClass} bg-opacity-80 skeleton rounded-none`}
|
||||
></div>
|
||||
)}
|
||||
{show.icon && (
|
||||
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center rounded-md">
|
||||
<LinkIcon link={link} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<hr className="divider my-0 border-t border-neutral-content h-[1px]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col justify-between h-full min-h-24">
|
||||
<div className="p-3 flex flex-col gap-2">
|
||||
{show.name && (
|
||||
<p className="truncate w-full text-primary text-sm">
|
||||
{unescapeString(link.name)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{show.link && <LinkTypeBadge link={link} />}
|
||||
</div>
|
||||
|
||||
{(show.collection || show.date) && (
|
||||
<div>
|
||||
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
|
||||
<div className="flex justify-between items-center text-xs text-neutral px-3 pb-1 gap-2">
|
||||
{show.collection && !isPublicRoute && (
|
||||
<div className="cursor-pointer truncate">
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
</div>
|
||||
)}
|
||||
{show.date && <LinkDate link={link} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overlay on hover */}
|
||||
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-2xl duration-100"></div>
|
||||
<LinkActions link={link} collection={collection} />
|
||||
{!isPublicRoute && <LinkPin link={link} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,20 +15,24 @@ export default function LinkCollection({
|
||||
}) {
|
||||
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>
|
||||
);
|
||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
return !isPublicRoute && collection?.name ? (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -1,73 +1,75 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import Image from "next/image";
|
||||
import isValidUrl from "@/lib/shared/isValidUrl";
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import Icon from "@/components/Icon";
|
||||
import { IconWeight } from "@phosphor-icons/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export default function LinkIcon({
|
||||
link,
|
||||
className,
|
||||
size,
|
||||
hideBackground,
|
||||
onClick,
|
||||
}: {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
className?: string;
|
||||
size?: "small" | "medium";
|
||||
hideBackground?: boolean;
|
||||
onClick?: Function;
|
||||
}) {
|
||||
let iconClasses: string =
|
||||
"bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10 " +
|
||||
(className || "");
|
||||
|
||||
let dimension;
|
||||
|
||||
switch (size) {
|
||||
case "small":
|
||||
dimension = " w-8 h-8";
|
||||
break;
|
||||
case "medium":
|
||||
dimension = " w-12 h-12";
|
||||
break;
|
||||
default:
|
||||
size = "medium";
|
||||
dimension = " w-12 h-12";
|
||||
break;
|
||||
}
|
||||
let iconClasses: string = clsx(
|
||||
"rounded flex item-center justify-center shadow select-none z-10 w-12 h-12",
|
||||
!hideBackground && "rounded-md bg-white backdrop-blur-lg bg-opacity-50 p-1",
|
||||
className
|
||||
);
|
||||
|
||||
const url =
|
||||
isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined;
|
||||
|
||||
const [showFavicon, setShowFavicon] = React.useState<boolean>(true);
|
||||
const [faviconLoaded, setFaviconLoaded] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{link.type === "url" && url ? (
|
||||
showFavicon ? (
|
||||
<div onClick={() => onClick && onClick()}>
|
||||
{link.icon ? (
|
||||
<div className={iconClasses}>
|
||||
<Icon
|
||||
icon={link.icon}
|
||||
size={30}
|
||||
weight={(link.iconWeight || "regular") as IconWeight}
|
||||
color={link.color || "#006796"}
|
||||
className="m-auto"
|
||||
/>
|
||||
</div>
|
||||
) : link.type === "url" && url ? (
|
||||
<>
|
||||
<Image
|
||||
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
|
||||
width={64}
|
||||
height={64}
|
||||
alt=""
|
||||
className={iconClasses + dimension}
|
||||
className={clsx(
|
||||
iconClasses,
|
||||
faviconLoaded ? "" : "absolute opacity-0"
|
||||
)}
|
||||
draggable="false"
|
||||
onError={() => {
|
||||
setShowFavicon(false);
|
||||
}}
|
||||
onLoadingComplete={() => setFaviconLoaded(true)}
|
||||
onError={() => setFaviconLoaded(false)}
|
||||
/>
|
||||
) : (
|
||||
<LinkPlaceholderIcon
|
||||
iconClasses={iconClasses + dimension}
|
||||
size={size}
|
||||
icon="bi-link-45deg"
|
||||
/>
|
||||
)
|
||||
{!faviconLoaded && (
|
||||
<LinkPlaceholderIcon
|
||||
iconClasses={iconClasses}
|
||||
icon="bi-link-45deg"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : link.type === "pdf" ? (
|
||||
<LinkPlaceholderIcon
|
||||
iconClasses={iconClasses + dimension}
|
||||
size={size}
|
||||
iconClasses={iconClasses}
|
||||
icon="bi-file-earmark-pdf"
|
||||
/>
|
||||
) : link.type === "image" ? (
|
||||
<LinkPlaceholderIcon
|
||||
iconClasses={iconClasses + dimension}
|
||||
size={size}
|
||||
iconClasses={iconClasses}
|
||||
icon="bi-file-earmark-image"
|
||||
/>
|
||||
) : // : link.type === "monolith" ? (
|
||||
@@ -78,25 +80,19 @@ export default function LinkIcon({
|
||||
// />
|
||||
// )
|
||||
undefined}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LinkPlaceholderIcon = ({
|
||||
iconClasses,
|
||||
size,
|
||||
icon,
|
||||
}: {
|
||||
iconClasses: string;
|
||||
size?: "small" | "medium";
|
||||
icon: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
size === "small" ? "text-2xl" : "text-4xl"
|
||||
} text-black aspect-square ${iconClasses}`}
|
||||
>
|
||||
<div className={clsx(iconClasses, "aspect-square text-4xl text-[#006796]")}>
|
||||
<i className={`${icon} m-auto`}></i>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,30 +11,37 @@ 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";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import LinkPin from "./LinkPin";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
count: number;
|
||||
className?: string;
|
||||
flipDropdown?: boolean;
|
||||
editMode?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkCardCompact({
|
||||
link,
|
||||
flipDropdown,
|
||||
editMode,
|
||||
}: Props) {
|
||||
export default function LinkCardCompact({ link, editMode }: 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 {
|
||||
settings: { show },
|
||||
} = useLocalSettingsStore();
|
||||
|
||||
const { links } = useLinks();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
@@ -76,8 +82,6 @@ export default function LinkCardCompact({
|
||||
|
||||
const permissions = usePermissions(collection?.id as number);
|
||||
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
||||
const selectedStyle = selectedLinks.some(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
)
|
||||
@@ -88,12 +92,15 @@ export default function LinkCardCompact({
|
||||
editMode &&
|
||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`${selectedStyle} border relative items-center flex ${
|
||||
!showInfo && !isPWA() ? "hover:bg-base-300 p-3" : "py-3"
|
||||
} duration-200 rounded-lg w-full`}
|
||||
className={`${selectedStyle} rounded-md border relative group items-center flex ${
|
||||
!isPWA() ? "hover:bg-base-300 px-2 py-1" : "py-1"
|
||||
} duration-200 w-full`}
|
||||
onClick={() =>
|
||||
selectable
|
||||
? handleCheckboxClick(link)
|
||||
@@ -102,67 +109,40 @@ export default function LinkCardCompact({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* {showCheckbox &&
|
||||
editMode &&
|
||||
(permissions === true ||
|
||||
permissions?.canCreate ||
|
||||
permissions?.canDelete) && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary my-auto mr-2"
|
||||
checked={selectedLinks.some(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
)}
|
||||
onChange={() => handleCheckboxClick(link)}
|
||||
/>
|
||||
)} */}
|
||||
<div
|
||||
className="flex items-center cursor-pointer w-full"
|
||||
className="flex items-center cursor-pointer w-full min-h-12"
|
||||
onClick={() =>
|
||||
!editMode && window.open(generateLinkHref(link, account), "_blank")
|
||||
!editMode && window.open(generateLinkHref(link, user), "_blank")
|
||||
}
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<LinkIcon link={link} className="w-12 h-12 text-4xl" />
|
||||
</div>
|
||||
{show.icon && (
|
||||
<div className="shrink-0">
|
||||
<LinkIcon link={link} hideBackground />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-[calc(100%-56px)] ml-2">
|
||||
<p className="line-clamp-1 mr-8 text-primary select-none">
|
||||
{link.name ? (
|
||||
unescapeString(link.name)
|
||||
) : (
|
||||
<div className="mt-2">
|
||||
<LinkTypeBadge link={link} />
|
||||
</div>
|
||||
)}
|
||||
</p>
|
||||
{show.name && (
|
||||
<p className="line-clamp-1 mr-8 text-primary select-none">
|
||||
{unescapeString(link.name)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral">
|
||||
<div className="flex items-center gap-x-3 text-neutral flex-wrap">
|
||||
{collection ? (
|
||||
{show.link && <LinkTypeBadge link={link} />}
|
||||
{show.collection && (
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
) : undefined}
|
||||
{link.name && <LinkTypeBadge link={link} />}
|
||||
<LinkDate link={link} />
|
||||
)}
|
||||
{show.date && <LinkDate link={link} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
position="top-3 right-3"
|
||||
flipDropdown={flipDropdown}
|
||||
// toggleShowInfo={() => setShowInfo(!showInfo)}
|
||||
// linkInfo={showInfo}
|
||||
/>
|
||||
{!isPublic && <LinkPin link={link} btnStyle="btn-ghost" />}
|
||||
<LinkActions link={link} collection={collection} btnStyle="btn-ghost" />
|
||||
</div>
|
||||
<div
|
||||
className="last:hidden rounded-none"
|
||||
style={{
|
||||
borderTop: "1px solid var(--fallback-bc,oklch(var(--bc)/0.1))",
|
||||
}}
|
||||
></div>
|
||||
<div className="last:hidden rounded-none my-0 mx-1 border-t border-base-300 h-[1px]"></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
252
components/LinkViews/LinkComponents/LinkMasonry.tsx
Normal file
252
components/LinkViews/LinkComponents/LinkMasonry.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
ArchivedFormat,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@/types/global";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||
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 "./LinkIcon";
|
||||
import useOnScreen from "@/hooks/useOnScreen";
|
||||
import { generateLinkHref } from "@/lib/client/generateLinkHref";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
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";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import clsx from "clsx";
|
||||
import LinkPin from "./LinkPin";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
columns: number;
|
||||
editMode?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const heightMap = {
|
||||
1: "h-44",
|
||||
2: "h-40",
|
||||
3: "h-36",
|
||||
4: "h-32",
|
||||
5: "h-28",
|
||||
6: "h-24",
|
||||
7: "h-20",
|
||||
8: "h-20",
|
||||
};
|
||||
|
||||
const imageHeightClass = useMemo(
|
||||
() => (columns ? heightMap[columns as keyof typeof heightMap] : "h-40"),
|
||||
[columns]
|
||||
);
|
||||
|
||||
const { data: collections = [] } = useCollections();
|
||||
const { data: user = {} } = useUser();
|
||||
|
||||
const { setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
|
||||
const {
|
||||
settings: { show },
|
||||
} = useLocalSettingsStore();
|
||||
|
||||
const { links } = useLinks();
|
||||
const getLink = useGetLink();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
setSelectedLinks([]);
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
const handleCheckboxClick = (
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
) => {
|
||||
if (selectedLinks.includes(link)) {
|
||||
setSelectedLinks(selectedLinks.filter((e) => e !== link));
|
||||
} else {
|
||||
setSelectedLinks([...selectedLinks, link]);
|
||||
}
|
||||
};
|
||||
|
||||
let shortendURL;
|
||||
|
||||
try {
|
||||
if (link.url) {
|
||||
shortendURL = new URL(link.url).host.toLowerCase();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>(
|
||||
collections.find(
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCollection(
|
||||
collections.find(
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
}, [collections, links]);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isVisible = useOnScreen(ref);
|
||||
const permissions = usePermissions(collection?.id as number);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
|
||||
if (
|
||||
isVisible &&
|
||||
!link.preview?.startsWith("archives") &&
|
||||
link.preview !== "unavailable"
|
||||
) {
|
||||
interval = setInterval(async () => {
|
||||
getLink.mutateAsync({ id: link.id as number });
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [isVisible, link.preview]);
|
||||
|
||||
const selectedStyle = selectedLinks.some(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
)
|
||||
? "border-primary bg-base-300"
|
||||
: "border-neutral-content";
|
||||
|
||||
const selectable =
|
||||
editMode &&
|
||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative group`}
|
||||
onClick={() =>
|
||||
selectable
|
||||
? handleCheckboxClick(link)
|
||||
: editMode
|
||||
? toast.error(t("link_selection_error"))
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="rounded-2xl cursor-pointer"
|
||||
onClick={() =>
|
||||
!editMode && window.open(generateLinkHref(link, user), "_blank")
|
||||
}
|
||||
>
|
||||
{show.image && previewAvailable(link) && (
|
||||
<div>
|
||||
<div className="relative rounded-t-2xl overflow-hidden">
|
||||
{previewAvailable(link) ? (
|
||||
<Image
|
||||
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`}
|
||||
width={1280}
|
||||
height={720}
|
||||
alt=""
|
||||
className={`rounded-t-2xl select-none object-cover z-10 ${imageHeightClass} w-full shadow opacity-80 scale-105`}
|
||||
style={show.icon ? { filter: "blur(1px)" } : undefined}
|
||||
draggable="false"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : link.preview === "unavailable" ? null : (
|
||||
<div
|
||||
className={`duration-100 ${imageHeightClass} bg-opacity-80 skeleton rounded-none`}
|
||||
></div>
|
||||
)}
|
||||
{show.icon && (
|
||||
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center rounded-md">
|
||||
<LinkIcon link={link} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr className="divider my-0 border-t border-neutral-content h-[1px]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-3 flex flex-col gap-2 h-full min-h-14">
|
||||
{show.name && (
|
||||
<p className="hyphens-auto w-full text-primary text-sm">
|
||||
{unescapeString(link.name)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{show.link && <LinkTypeBadge link={link} />}
|
||||
|
||||
{show.description && link.description && (
|
||||
<p className={clsx("hyphens-auto text-sm w-full")}>
|
||||
{unescapeString(link.description)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{show.tags && link.tags && link.tags[0] && (
|
||||
<div className="flex gap-1 items-center flex-wrap">
|
||||
{link.tags.map((e, i) => (
|
||||
<Link
|
||||
href={"/tags/" + e.id}
|
||||
key={i}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
|
||||
>
|
||||
#{e.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(show.collection || show.date) && (
|
||||
<div>
|
||||
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
|
||||
<div className="flex flex-wrap justify-between items-center text-xs text-neutral px-3 pb-1 w-full gap-x-2">
|
||||
{!isPublic && show.collection && (
|
||||
<div className="cursor-pointer truncate">
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
</div>
|
||||
)}
|
||||
{show.date && <LinkDate link={link} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Overlay on hover */}
|
||||
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-2xl duration-100"></div>
|
||||
<LinkActions link={link} collection={collection} />
|
||||
{!isPublic && <LinkPin link={link} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
import {
|
||||
ArchivedFormat,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} 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";
|
||||
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 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 { useTranslation } from "next-i18next";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
count: number;
|
||||
className?: string;
|
||||
flipDropdown?: boolean;
|
||||
editMode?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { collections } = useCollectionStore();
|
||||
const { account } = useAccountStore();
|
||||
|
||||
const { links, getLink, setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
setSelectedLinks([]);
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
const handleCheckboxClick = (
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
) => {
|
||||
if (selectedLinks.includes(link)) {
|
||||
setSelectedLinks(selectedLinks.filter((e) => e !== link));
|
||||
} else {
|
||||
setSelectedLinks([...selectedLinks, link]);
|
||||
}
|
||||
};
|
||||
|
||||
let shortendURL;
|
||||
|
||||
try {
|
||||
if (link.url) {
|
||||
shortendURL = new URL(link.url).host.toLowerCase();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>(
|
||||
collections.find(
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCollection(
|
||||
collections.find(
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
}, [collections, links]);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isVisible = useOnScreen(ref);
|
||||
const permissions = usePermissions(collection?.id as number);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: any;
|
||||
|
||||
if (
|
||||
isVisible &&
|
||||
!link.preview?.startsWith("archives") &&
|
||||
link.preview !== "unavailable"
|
||||
) {
|
||||
interval = setInterval(async () => {
|
||||
getLink(link.id as number);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [isVisible, link.preview]);
|
||||
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
||||
const selectedStyle = selectedLinks.some(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
)
|
||||
? "border-primary bg-base-300"
|
||||
: "border-neutral-content";
|
||||
|
||||
const selectable =
|
||||
editMode &&
|
||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative`}
|
||||
onClick={() =>
|
||||
selectable
|
||||
? handleCheckboxClick(link)
|
||||
: editMode
|
||||
? toast.error(t("link_selection_error"))
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="rounded-2xl cursor-pointer"
|
||||
onClick={() =>
|
||||
!editMode && window.open(generateLinkHref(link, account), "_blank")
|
||||
}
|
||||
>
|
||||
<div className="relative rounded-t-2xl overflow-hidden">
|
||||
{previewAvailable(link) ? (
|
||||
<Image
|
||||
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
|
||||
width={1280}
|
||||
height={720}
|
||||
alt=""
|
||||
className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105"
|
||||
style={
|
||||
link.type !== "image" ? { filter: "blur(1px)" } : undefined
|
||||
}
|
||||
draggable="false"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : link.preview === "unavailable" ? null : (
|
||||
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
|
||||
)}
|
||||
{link.type !== "image" && (
|
||||
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md">
|
||||
<LinkIcon link={link} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{link.preview !== "unavailable" && (
|
||||
<hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
)}
|
||||
|
||||
<div className="p-3 flex flex-col gap-2">
|
||||
<p className="hyphens-auto w-full pr-9 text-primary text-sm">
|
||||
{unescapeString(link.name)}
|
||||
</p>
|
||||
|
||||
<LinkTypeBadge link={link} />
|
||||
|
||||
{link.description && (
|
||||
<p className="hyphens-auto text-sm">
|
||||
{unescapeString(link.description)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{link.tags && link.tags[0] && (
|
||||
<div className="flex gap-1 items-center flex-wrap">
|
||||
{link.tags.map((e, i) => (
|
||||
<Link
|
||||
href={"/tags/" + e.id}
|
||||
key={i}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
|
||||
>
|
||||
#{e.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
|
||||
<div className="flex flex-wrap justify-between text-xs text-neutral px-3 pb-1 w-full gap-x-2">
|
||||
{collection && <LinkCollection link={link} collection={collection} />}
|
||||
<LinkDate link={link} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showInfo && (
|
||||
<div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-2xl fade-in overflow-y-auto">
|
||||
<div
|
||||
onClick={() => setShowInfo(!showInfo)}
|
||||
className=" float-right btn btn-sm outline-none btn-circle btn-ghost z-10"
|
||||
>
|
||||
<i className="bi-x text-neutral text-2xl"></i>
|
||||
</div>
|
||||
<p className="text-neutral text-lg font-semibold">
|
||||
{t("description")}
|
||||
</p>
|
||||
|
||||
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
<p>
|
||||
{link.description ? (
|
||||
unescapeString(link.description)
|
||||
) : (
|
||||
<span className="text-neutral text-sm">
|
||||
{t("no_description")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{link.tags && link.tags[0] && (
|
||||
<>
|
||||
<p className="text-neutral text-lg mt-3 font-semibold">
|
||||
{t("tags")}
|
||||
</p>
|
||||
|
||||
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
|
||||
<div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
|
||||
<div className="flex gap-1 items-center flex-wrap">
|
||||
{link.tags.map((e, i) => (
|
||||
<Link
|
||||
href={"/tags/" + e.id}
|
||||
key={i}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
|
||||
>
|
||||
#{e.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
position={
|
||||
link.preview !== "unavailable"
|
||||
? "top-[10.75rem] right-3"
|
||||
: "top-[.75rem] right-3"
|
||||
}
|
||||
toggleShowInfo={() => setShowInfo(!showInfo)}
|
||||
linkInfo={showInfo}
|
||||
flipDropdown={flipDropdown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
238
components/LinkViews/Links.tsx
Normal file
238
components/LinkViews/Links.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import LinkCard from "@/components/LinkViews/LinkComponents/LinkCard";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
ViewMode,
|
||||
} from "@/types/global";
|
||||
import { useEffect } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry";
|
||||
import Masonry from "react-masonry-css";
|
||||
import resolveConfig from "tailwindcss/resolveConfig";
|
||||
import tailwindConfig from "../../tailwind.config.js";
|
||||
import { useMemo } from "react";
|
||||
import LinkList from "@/components/LinkViews/LinkComponents/LinkList";
|
||||
|
||||
export function CardView({
|
||||
links,
|
||||
editMode,
|
||||
isLoading,
|
||||
placeholders,
|
||||
hasNextPage,
|
||||
placeHolderRef,
|
||||
}: {
|
||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
isLoading?: boolean;
|
||||
placeholders?: number[];
|
||||
hasNextPage?: boolean;
|
||||
placeHolderRef?: any;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid min-[1901px]:grid-cols-5 min-[1501px]:grid-cols-4 min-[881px]:grid-cols-3 min-[551px]:grid-cols-2 grid-cols-1 gap-5 pb-5">
|
||||
{links?.map((e, i) => {
|
||||
return (
|
||||
<LinkCard
|
||||
key={i}
|
||||
link={e}
|
||||
count={i}
|
||||
flipDropdown={i === links.length - 1}
|
||||
editMode={editMode}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{(hasNextPage || isLoading) &&
|
||||
placeholders?.map((e, i) => {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-4"
|
||||
ref={e === 1 ? placeHolderRef : undefined}
|
||||
key={i}
|
||||
>
|
||||
<div className="skeleton h-40 w-full"></div>
|
||||
<div className="skeleton h-3 w-2/3"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-1/3"></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MasonryView({
|
||||
links,
|
||||
editMode,
|
||||
isLoading,
|
||||
placeholders,
|
||||
hasNextPage,
|
||||
placeHolderRef,
|
||||
}: {
|
||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
isLoading?: boolean;
|
||||
placeholders?: number[];
|
||||
hasNextPage?: boolean;
|
||||
placeHolderRef?: any;
|
||||
}) {
|
||||
const fullConfig = resolveConfig(tailwindConfig as any);
|
||||
|
||||
const breakpointColumnsObj = useMemo(() => {
|
||||
return {
|
||||
default: 5,
|
||||
1900: 4,
|
||||
1500: 3,
|
||||
880: 2,
|
||||
550: 1,
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Masonry
|
||||
breakpointCols={breakpointColumnsObj}
|
||||
columnClassName="flex flex-col gap-5 !w-full"
|
||||
className="grid min-[1901px]:grid-cols-5 min-[1501px]:grid-cols-4 min-[881px]:grid-cols-3 min-[551px]:grid-cols-2 grid-cols-1 gap-5 pb-5"
|
||||
>
|
||||
{links?.map((e, i) => {
|
||||
return (
|
||||
<LinkMasonry
|
||||
key={i}
|
||||
link={e}
|
||||
count={i}
|
||||
flipDropdown={i === links.length - 1}
|
||||
editMode={editMode}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{(hasNextPage || isLoading) &&
|
||||
placeholders?.map((e, i) => {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-4"
|
||||
ref={e === 1 ? placeHolderRef : undefined}
|
||||
key={i}
|
||||
>
|
||||
<div className="skeleton h-40 w-full"></div>
|
||||
<div className="skeleton h-3 w-2/3"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-1/3"></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Masonry>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListView({
|
||||
links,
|
||||
editMode,
|
||||
isLoading,
|
||||
placeholders,
|
||||
hasNextPage,
|
||||
placeHolderRef,
|
||||
}: {
|
||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
isLoading?: boolean;
|
||||
placeholders?: number[];
|
||||
hasNextPage?: boolean;
|
||||
placeHolderRef?: any;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-1 flex-col">
|
||||
{links?.map((e, i) => {
|
||||
return (
|
||||
<LinkList
|
||||
key={i}
|
||||
link={e}
|
||||
count={i}
|
||||
flipDropdown={i === links.length - 1}
|
||||
editMode={editMode}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{(hasNextPage || isLoading) &&
|
||||
placeholders?.map((e, i) => {
|
||||
return (
|
||||
<div
|
||||
ref={e === 1 ? placeHolderRef : undefined}
|
||||
key={i}
|
||||
className="flex gap-4 p-4"
|
||||
>
|
||||
<div className="skeleton h-16 w-16"></div>
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="skeleton h-3 w-2/3"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Links({
|
||||
layout,
|
||||
links,
|
||||
editMode,
|
||||
placeholderCount,
|
||||
useData,
|
||||
}: {
|
||||
layout: ViewMode;
|
||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
placeholderCount?: number;
|
||||
useData?: any;
|
||||
}) {
|
||||
const { ref, inView } = useInView();
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && useData?.fetchNextPage && useData?.hasNextPage) {
|
||||
useData.fetchNextPage();
|
||||
}
|
||||
}, [useData, inView]);
|
||||
|
||||
if (layout === ViewMode.List) {
|
||||
return (
|
||||
<ListView
|
||||
links={links}
|
||||
editMode={editMode}
|
||||
isLoading={useData?.isLoading}
|
||||
placeholders={placeholderCountToArray(placeholderCount)}
|
||||
hasNextPage={useData?.hasNextPage}
|
||||
placeHolderRef={ref}
|
||||
/>
|
||||
);
|
||||
} else if (layout === ViewMode.Masonry) {
|
||||
return (
|
||||
<MasonryView
|
||||
links={links}
|
||||
editMode={editMode}
|
||||
isLoading={useData?.isLoading}
|
||||
placeholders={placeholderCountToArray(placeholderCount)}
|
||||
hasNextPage={useData?.hasNextPage}
|
||||
placeHolderRef={ref}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// Default to card view
|
||||
return (
|
||||
<CardView
|
||||
links={links}
|
||||
editMode={editMode}
|
||||
isLoading={useData?.isLoading}
|
||||
placeholders={placeholderCountToArray(placeholderCount)}
|
||||
hasNextPage={useData?.hasNextPage}
|
||||
placeHolderRef={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const placeholderCountToArray = (num?: number) =>
|
||||
num ? Array.from({ length: num }, (_, i) => i + 1) : [];
|
||||
@@ -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>
|
||||
|
||||
@@ -32,28 +32,25 @@ export default function Modal({
|
||||
return (
|
||||
<Drawer.Root
|
||||
open={drawerIsOpen}
|
||||
onClose={() => dismissible && setTimeout(() => toggleModal(), 100)}
|
||||
onClose={() => dismissible && setDrawerIsOpen(false)}
|
||||
onAnimationEnd={(isOpen) => !isOpen && toggleModal()}
|
||||
dismissible={dismissible}
|
||||
>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
|
||||
<ClickAwayHandler
|
||||
onClickOutside={() => dismissible && setDrawerIsOpen(false)}
|
||||
>
|
||||
<Drawer.Content className="flex flex-col rounded-t-2xl min-h-max mt-24 fixed bottom-0 left-0 right-0 z-30">
|
||||
<Drawer.Content className="flex flex-col rounded-t-2xl h-[90%] mt-24 fixed bottom-0 left-0 right-0 z-30">
|
||||
<div
|
||||
className="p-4 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto"
|
||||
data-testid="mobile-modal-container"
|
||||
>
|
||||
<div
|
||||
className="p-4 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto"
|
||||
data-testid="mobile-modal-container"
|
||||
>
|
||||
<div
|
||||
className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-neutral mb-5"
|
||||
data-testid="mobile-modal-slider"
|
||||
/>
|
||||
className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-neutral mb-5"
|
||||
data-testid="mobile-modal-slider"
|
||||
/>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</ClickAwayHandler>
|
||||
{children}
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
);
|
||||
|
||||
@@ -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,16 +1,22 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import useCollectionStore from "@/store/collections";
|
||||
import toast from "react-hot-toast";
|
||||
import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
|
||||
import {
|
||||
AccountSettings,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
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";
|
||||
import CopyButton from "../CopyButton";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
@@ -27,7 +33,7 @@ export default function EditCollectionSharingModal({
|
||||
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
|
||||
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const { updateCollection } = useCollectionStore();
|
||||
const updateCollection = useUpdateCollection();
|
||||
|
||||
const submit = async () => {
|
||||
if (!submitLoader) {
|
||||
@@ -36,41 +42,36 @@ 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) => {
|
||||
setSubmitLoader(false);
|
||||
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);
|
||||
|
||||
setSubmitLoader(false);
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
onClose();
|
||||
toast.success(t("updated"));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const { account } = useAccountStore();
|
||||
const { data: user = {} } = useUser();
|
||||
const permissions = usePermissions(collection.id as number);
|
||||
|
||||
const currentURL = new URL(document.URL);
|
||||
|
||||
const publicCollectionURL = `${currentURL.origin}/public/collections/${collection.id}`;
|
||||
|
||||
const [memberUsername, setMemberUsername] = useState("");
|
||||
const [memberIdentifier, setMemberIdentifier] = useState("");
|
||||
|
||||
const [collectionOwner, setCollectionOwner] = useState({
|
||||
id: null as unknown as number,
|
||||
name: "",
|
||||
username: "",
|
||||
image: "",
|
||||
archiveAsScreenshot: undefined as unknown as boolean,
|
||||
archiveAsMonolith: undefined as unknown as boolean,
|
||||
archiveAsPDF: undefined as unknown as boolean,
|
||||
});
|
||||
const [collectionOwner, setCollectionOwner] = useState<
|
||||
Partial<AccountSettings>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOwner = async () => {
|
||||
@@ -91,19 +92,25 @@ export default function EditCollectionSharingModal({
|
||||
members: [...collection.members, newMember],
|
||||
});
|
||||
|
||||
setMemberUsername("");
|
||||
setMemberIdentifier("");
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
return (
|
||||
<Modal toggleModal={onClose}>
|
||||
<p className="text-xl font-thin">
|
||||
{permissions === true ? t("share_and_collaborate") : t("team")}
|
||||
{permissions === true && !isPublicRoute
|
||||
? t("share_and_collaborate")
|
||||
: t("team")}
|
||||
</p>
|
||||
|
||||
<div className="divider mb-3 mt-1"></div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{permissions === true && (
|
||||
{permissions === true && !isPublicRoute && (
|
||||
<div>
|
||||
<p>{t("make_collection_public")}</p>
|
||||
|
||||
@@ -130,43 +137,35 @@ export default function EditCollectionSharingModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{collection.isPublic ? (
|
||||
<div className={permissions === true ? "pl-5" : ""}>
|
||||
<p className="mb-2">{t("sharable_link_guide")}</p>
|
||||
<div
|
||||
onClick={() => {
|
||||
try {
|
||||
navigator.clipboard
|
||||
.writeText(publicCollectionURL)
|
||||
.then(() => toast.success(t("copied")));
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}}
|
||||
className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 bg-base-200 border-neutral-content border-solid border outline-none hover:border-primary dark:hover:border-primary duration-100 cursor-text"
|
||||
>
|
||||
{collection.isPublic && (
|
||||
<div>
|
||||
<p className="mb-2">{t("sharable_link")}</p>
|
||||
<div className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 bg-base-200 border-neutral-content border-solid border flex items-center gap-2 justify-between">
|
||||
{publicCollectionURL}
|
||||
<CopyButton text={publicCollectionURL} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{permissions === true && <div className="divider my-3"></div>}
|
||||
{permissions === true && !isPublicRoute && (
|
||||
<div className="divider my-3"></div>
|
||||
)}
|
||||
|
||||
{permissions === true && (
|
||||
{permissions === true && !isPublicRoute && (
|
||||
<>
|
||||
<p>{t("members")}</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<TextInput
|
||||
value={memberUsername || ""}
|
||||
value={memberIdentifier || ""}
|
||||
className="bg-base-200"
|
||||
placeholder={t("members_username_placeholder")}
|
||||
onChange={(e) => setMemberUsername(e.target.value)}
|
||||
placeholder={t("add_member_placeholder")}
|
||||
onChange={(e) => setMemberIdentifier(e.target.value)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" &&
|
||||
addMemberToCollection(
|
||||
account.username as string,
|
||||
memberUsername || "",
|
||||
user,
|
||||
memberIdentifier.replace(/^@/, "") || "",
|
||||
collection,
|
||||
setMemberState,
|
||||
t
|
||||
@@ -177,8 +176,8 @@ export default function EditCollectionSharingModal({
|
||||
<div
|
||||
onClick={() =>
|
||||
addMemberToCollection(
|
||||
account.username as string,
|
||||
memberUsername || "",
|
||||
user,
|
||||
memberIdentifier.replace(/^@/, "") || "",
|
||||
collection,
|
||||
setMemberState,
|
||||
t
|
||||
@@ -264,7 +263,7 @@ export default function EditCollectionSharingModal({
|
||||
</div>
|
||||
|
||||
<div className={"flex items-center gap-2"}>
|
||||
{permissions === true ? (
|
||||
{permissions === true && !isPublicRoute ? (
|
||||
<div className="dropdown dropdown-bottom dropdown-end">
|
||||
<div
|
||||
tabIndex={0}
|
||||
@@ -275,7 +274,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"
|
||||
@@ -314,10 +313,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>
|
||||
@@ -359,10 +360,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>
|
||||
@@ -404,10 +407,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>
|
||||
@@ -419,7 +424,7 @@ export default function EditCollectionSharingModal({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{permissions === true && (
|
||||
{permissions === true && !isPublicRoute && (
|
||||
<i
|
||||
className={
|
||||
"bi-x text-xl btn btn-sm btn-square btn-ghost text-neutral hover:text-red-500 dark:hover:text-red-500 duration-100 cursor-pointer"
|
||||
@@ -450,7 +455,7 @@ export default function EditCollectionSharingModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{permissions === true && (
|
||||
{permissions === true && !isPublicRoute && (
|
||||
<button
|
||||
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto mt-3"
|
||||
onClick={submit}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
131
components/ModalContent/InviteModal.tsx
Normal file
131
components/ModalContent/InviteModal.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import toast from "react-hot-toast";
|
||||
import Modal from "../Modal";
|
||||
import TextInput from "../TextInput";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useTranslation, Trans } from "next-i18next";
|
||||
import { useAddUser } from "@/hooks/store/admin/users";
|
||||
import Link from "next/link";
|
||||
import { signIn } from "next-auth/react";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
};
|
||||
|
||||
type FormData = {
|
||||
username?: string;
|
||||
email?: string;
|
||||
invite: boolean;
|
||||
};
|
||||
|
||||
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true";
|
||||
|
||||
export default function InviteModal({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const addUser = useAddUser();
|
||||
|
||||
const [form, setForm] = useState<FormData>({
|
||||
username: emailEnabled ? undefined : "",
|
||||
email: emailEnabled ? "" : undefined,
|
||||
invite: true,
|
||||
});
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!submitLoader) {
|
||||
const checkFields = () => {
|
||||
if (emailEnabled) {
|
||||
return form.email !== "";
|
||||
} else {
|
||||
return form.username !== "";
|
||||
}
|
||||
};
|
||||
|
||||
if (checkFields()) {
|
||||
setSubmitLoader(true);
|
||||
|
||||
await addUser.mutateAsync(form, {
|
||||
onSettled: () => {
|
||||
setSubmitLoader(false);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await signIn("invite", {
|
||||
email: form.email,
|
||||
callbackUrl: "/member-onboarding",
|
||||
redirect: false,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
toast.error(t("fill_all_fields_error"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal toggleModal={onClose}>
|
||||
<p className="text-xl font-thin">{t("invite_user")}</p>
|
||||
<div className="divider mb-3 mt-1"></div>
|
||||
<p className="mb-3">{t("invite_user_desc")}</p>
|
||||
<form onSubmit={submit}>
|
||||
{emailEnabled ? (
|
||||
<div>
|
||||
<TextInput
|
||||
placeholder={t("placeholder_email")}
|
||||
className="bg-base-200"
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
value={form.email}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="mb-2">
|
||||
{t("username")}{" "}
|
||||
{emailEnabled && (
|
||||
<span className="text-xs text-neutral">{t("optional")}</span>
|
||||
)}
|
||||
</p>
|
||||
<TextInput
|
||||
placeholder={t("placeholder_john")}
|
||||
className="bg-base-200"
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
value={form.username}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div role="note" className="alert alert-note mt-5">
|
||||
<i className="bi-exclamation-triangle text-xl" />
|
||||
<span>
|
||||
<p>{t("invite_user_note")}</p>
|
||||
<p className="mb-1">
|
||||
{t("invite_user_price", {
|
||||
price: 4,
|
||||
priceAnnual: 36,
|
||||
})}
|
||||
</p>
|
||||
<Link
|
||||
href="https://docs.linkwarden.app/billing/seats#how-seats-affect-billing"
|
||||
className="font-semibold whitespace-nowrap hover:opacity-80 duration-100"
|
||||
target="_blank"
|
||||
>
|
||||
{t("learn_more")} <i className="bi-box-arrow-up-right"></i>
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-5">
|
||||
<button
|
||||
className="btn btn-accent dark:border-violet-400 text-white ml-auto"
|
||||
type="submit"
|
||||
>
|
||||
{t("send_invitation")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
186
components/ModalContent/LinkModal.tsx
Normal file
186
components/ModalContent/LinkModal.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import React, { useState } from "react";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useDeleteLink } from "@/hooks/store/links";
|
||||
import Drawer from "../Drawer";
|
||||
import LinkDetails from "../LinkDetails";
|
||||
import Link from "next/link";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import { useRouter } from "next/router";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
import toast from "react-hot-toast";
|
||||
import clsx from "clsx";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
onDelete: Function;
|
||||
onUpdateArchive: Function;
|
||||
onPin: Function;
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
activeMode?: "view" | "edit";
|
||||
};
|
||||
|
||||
export default function LinkModal({
|
||||
onClose,
|
||||
onDelete,
|
||||
onUpdateArchive,
|
||||
onPin,
|
||||
link,
|
||||
activeMode,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const permissions = usePermissions(link.collection.id as number);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
const deleteLink = useDeleteLink();
|
||||
|
||||
const [mode, setMode] = useState<"view" | "edit">(activeMode || "view");
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
toggleDrawer={onClose}
|
||||
className="sm:h-screen items-center relative"
|
||||
>
|
||||
<div className="absolute top-3 left-0 right-0 flex justify-between px-3">
|
||||
<div
|
||||
className="bi-x text-xl btn btn-sm btn-circle text-base-content opacity-50 hover:opacity-100 z-10"
|
||||
onClick={() => onClose()}
|
||||
></div>
|
||||
|
||||
{(permissions === true || permissions?.canUpdate) && !isPublicRoute && (
|
||||
<div className="flex gap-1 h-8 rounded-full bg-neutral-content bg-opacity-50 text-base-content p-1 text-xs duration-100 select-none z-10">
|
||||
<div
|
||||
className={clsx(
|
||||
"py-1 px-2 cursor-pointer duration-100 rounded-full font-semibold",
|
||||
mode === "view" && "bg-primary bg-opacity-50"
|
||||
)}
|
||||
onClick={() => {
|
||||
setMode("view");
|
||||
}}
|
||||
>
|
||||
View
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"py-1 px-2 cursor-pointer duration-100 rounded-full font-semibold",
|
||||
mode === "edit" && "bg-primary bg-opacity-50"
|
||||
)}
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{!isPublicRoute && (
|
||||
<div className={`dropdown dropdown-end z-20`}>
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onMouseDown={dropdownTriggerer}
|
||||
className="btn btn-sm btn-circle text-base-content opacity-50 hover:opacity-100 z-10"
|
||||
>
|
||||
<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`}
|
||||
>
|
||||
{
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
onPin();
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{link?.pinnedBy && link.pinnedBy[0]
|
||||
? t("unpin")
|
||||
: t("pin_to_dashboard")}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
{link.type === "url" &&
|
||||
(permissions === true || permissions?.canUpdate) && (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
onUpdateArchive();
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("refresh_preserved_formats")}
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
{(permissions === true || permissions?.canDelete) && (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={async (e) => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
console.log(e.shiftKey);
|
||||
if (e.shiftKey) {
|
||||
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"));
|
||||
}
|
||||
},
|
||||
});
|
||||
onClose();
|
||||
} else {
|
||||
onDelete();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("delete")}
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{link.url && (
|
||||
<Link
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
className="bi-box-arrow-up-right btn-circle text-base-content opacity-50 hover:opacity-100 btn btn-sm select-none z-10"
|
||||
></Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<LinkDetails
|
||||
activeLink={link}
|
||||
className="sm:mt-0 -mt-11"
|
||||
mode={mode}
|
||||
setMode={(mode: "view" | "edit") => setMode(mode)}
|
||||
onUpdateArchive={onUpdateArchive}
|
||||
/>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
@@ -16,23 +15,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;
|
||||
@@ -49,20 +47,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 as number,
|
||||
name: account.name,
|
||||
username: account.username as string,
|
||||
image: account.image as string,
|
||||
archiveAsScreenshot: account.archiveAsScreenshot as boolean,
|
||||
archiveAsMonolith: account.archiveAsScreenshot as boolean,
|
||||
archiveAsPDF: account.archiveAsPDF as boolean,
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -98,20 +96,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: any;
|
||||
|
||||
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) {
|
||||
@@ -137,10 +129,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);
|
||||
};
|
||||
@@ -164,7 +154,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
|
||||
name={t("webpage")}
|
||||
icon={"bi-filetype-html"}
|
||||
format={ArchivedFormat.monolith}
|
||||
activeLink={link}
|
||||
link={link}
|
||||
downloadable={true}
|
||||
/>
|
||||
) : undefined}
|
||||
@@ -178,7 +168,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
|
||||
? ArchivedFormat.png
|
||||
: ArchivedFormat.jpeg
|
||||
}
|
||||
activeLink={link}
|
||||
link={link}
|
||||
downloadable={true}
|
||||
/>
|
||||
) : undefined}
|
||||
@@ -188,7 +178,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}
|
||||
/>
|
||||
) : undefined}
|
||||
@@ -198,7 +188,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
|
||||
name={t("readable")}
|
||||
icon={"bi-file-earmark-text"}
|
||||
format={ArchivedFormat.readability}
|
||||
activeLink={link}
|
||||
link={link}
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
67
components/ModalContent/SurveyModal.tsx
Normal file
67
components/ModalContent/SurveyModal.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { useState } from "react";
|
||||
import Modal from "../Modal";
|
||||
import Button from "../ui/Button";
|
||||
import { useTranslation } from "next-i18next";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
submit: Function;
|
||||
};
|
||||
|
||||
export default function SurveyModal({ onClose, submit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [referer, setReferrer] = useState("rather_not_say");
|
||||
const [other, setOther] = useState("");
|
||||
|
||||
return (
|
||||
<Modal toggleModal={onClose}>
|
||||
<p className="text-xl font-thin">{t("quick_survey")}</p>
|
||||
|
||||
<div className="divider mb-3 mt-1"></div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<p>{t("how_did_you_discover_linkwarden")}</p>
|
||||
|
||||
<select
|
||||
onChange={(e) => {
|
||||
setReferrer(e.target.value);
|
||||
setOther("");
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<option value="rather_not_say">{t("rather_not_say")}</option>
|
||||
<option value="search_engine">{t("search_engine")}</option>
|
||||
<option value="people_recommendation">
|
||||
{t("people_recommendation")}
|
||||
</option>
|
||||
<option value="reddit">{t("reddit")}</option>
|
||||
<option value="github">{t("github")}</option>
|
||||
<option value="twitter">{t("twitter")}</option>
|
||||
<option value="mastodon">{t("mastodon")}</option>
|
||||
<option value="lemmy">{t("lemmy")}</option>
|
||||
<option value="other">{t("other")}</option>
|
||||
</select>
|
||||
|
||||
{referer === "other" && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("please_specify")}
|
||||
onChange={(e) => {
|
||||
setOther(e.target.value);
|
||||
}}
|
||||
value={other}
|
||||
className="input border border-neutral-content focus:border-primary focus:outline-none duration-100 w-full bg-base-200 rounded-[0.375rem] min-h-0 h-[2.625rem] leading-4 p-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="ml-auto mt-3"
|
||||
intent="accent"
|
||||
onClick={() => submit(referer, other)}
|
||||
>
|
||||
{t("submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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,19 +1,16 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import {
|
||||
ArchivedFormat,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@/types/global";
|
||||
import toast from "react-hot-toast";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useGetLink } from "@/hooks/store/links";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
icon: string;
|
||||
format: ArchivedFormat;
|
||||
activeLink: LinkIncludingShortenedCollectionAndTags;
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
downloadable?: boolean;
|
||||
};
|
||||
|
||||
@@ -21,48 +18,15 @@ export default function PreservedFormatRow({
|
||||
name,
|
||||
icon,
|
||||
format,
|
||||
activeLink,
|
||||
link,
|
||||
downloadable,
|
||||
}: Props) {
|
||||
const session = useSession();
|
||||
const { getLink } = useLinkStore();
|
||||
|
||||
const [link, setLink] =
|
||||
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
|
||||
const getLink = useGetLink();
|
||||
|
||||
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 any).response as LinkIncludingShortenedCollectionAndTags
|
||||
);
|
||||
})();
|
||||
|
||||
let interval: any;
|
||||
if (link?.image === "pending" || link?.pdf === "pending") {
|
||||
interval = setInterval(async () => {
|
||||
const data = await getLink(link.id as number, isPublic);
|
||||
setLink(
|
||||
(data as any).response as LinkIncludingShortenedCollectionAndTags
|
||||
);
|
||||
}, 5000);
|
||||
} else {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [link?.image, link?.pdf, link?.readable, link?.monolith]);
|
||||
|
||||
const handleDownload = () => {
|
||||
const path = `/api/v1/archives/${link?.id}?format=${format}`;
|
||||
fetch(path)
|
||||
|
||||
@@ -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,24 +53,26 @@ export default function ProfileDropdown() {
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("switch_to", {
|
||||
theme: settings.theme === "light" ? t("dark") : t("light"),
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
{isAdmin ? (
|
||||
{isAdmin && (
|
||||
<li>
|
||||
<Link
|
||||
href="/admin"
|
||||
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("server_administration")}
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
)}
|
||||
<li>
|
||||
<div
|
||||
onClick={() => {
|
||||
@@ -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: any;
|
||||
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,10 @@
|
||||
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";
|
||||
import { resetInfiniteQueryPagination } from "@/hooks/store/links";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
type Props = {
|
||||
sortBy: Sort;
|
||||
@@ -10,6 +13,13 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function SortDropdown({ sortBy, setSort, t }: Props) {
|
||||
const { updateSettings } = useLocalSettingsStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
updateSettings({ sortBy });
|
||||
}, [sortBy]);
|
||||
|
||||
return (
|
||||
<div className="dropdown dropdown-bottom dropdown-end">
|
||||
<div
|
||||
@@ -20,7 +30,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"
|
||||
@@ -32,9 +42,14 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
|
||||
name="sort-radio"
|
||||
className="radio checked:bg-primary"
|
||||
checked={sortBy === Sort.DateNewestFirst}
|
||||
onChange={() => setSort(Sort.DateNewestFirst)}
|
||||
onChange={() => {
|
||||
resetInfiniteQueryPagination(queryClient, ["links"]);
|
||||
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>
|
||||
@@ -48,9 +63,14 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
|
||||
name="sort-radio"
|
||||
className="radio checked:bg-primary"
|
||||
checked={sortBy === Sort.DateOldestFirst}
|
||||
onChange={() => setSort(Sort.DateOldestFirst)}
|
||||
onChange={() => {
|
||||
resetInfiniteQueryPagination(queryClient, ["links"]);
|
||||
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>
|
||||
@@ -64,9 +84,12 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
|
||||
name="sort-radio"
|
||||
className="radio checked:bg-primary"
|
||||
checked={sortBy === Sort.NameAZ}
|
||||
onChange={() => setSort(Sort.NameAZ)}
|
||||
onChange={() => {
|
||||
resetInfiniteQueryPagination(queryClient, ["links"]);
|
||||
setSort(Sort.NameAZ);
|
||||
}}
|
||||
/>
|
||||
<span className="label-text">{t("name_az")}</span>
|
||||
<span className="label-text whitespace-nowrap">{t("name_az")}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
@@ -80,9 +103,12 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
|
||||
name="sort-radio"
|
||||
className="radio checked:bg-primary"
|
||||
checked={sortBy === Sort.NameZA}
|
||||
onChange={() => setSort(Sort.NameZA)}
|
||||
onChange={() => {
|
||||
resetInfiniteQueryPagination(queryClient, ["links"]);
|
||||
setSort(Sort.NameZA);
|
||||
}}
|
||||
/>
|
||||
<span className="label-text">{t("name_za")}</span>
|
||||
<span className="label-text whitespace-nowrap">{t("name_za")}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
@@ -96,9 +122,14 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
|
||||
name="sort-radio"
|
||||
className="radio checked:bg-primary"
|
||||
checked={sortBy === Sort.DescriptionAZ}
|
||||
onChange={() => setSort(Sort.DescriptionAZ)}
|
||||
onChange={() => {
|
||||
resetInfiniteQueryPagination(queryClient, ["links"]);
|
||||
setSort(Sort.DescriptionAZ);
|
||||
}}
|
||||
/>
|
||||
<span className="label-text">{t("description_az")}</span>
|
||||
<span className="label-text whitespace-nowrap">
|
||||
{t("description_az")}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
@@ -112,9 +143,14 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
|
||||
name="sort-radio"
|
||||
className="radio checked:bg-primary"
|
||||
checked={sortBy === Sort.DescriptionZA}
|
||||
onChange={() => setSort(Sort.DescriptionZA)}
|
||||
onChange={() => {
|
||||
resetInfiniteQueryPagination(queryClient, ["links"]);
|
||||
setSort(Sort.DescriptionZA);
|
||||
}}
|
||||
/>
|
||||
<span className="label-text">{t("description_za")}</span>
|
||||
<span className="label-text whitespace-nowrap">
|
||||
{t("description_za")}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,72 +1,147 @@
|
||||
import React, { Dispatch, SetStateAction, useEffect } from "react";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
|
||||
import { ViewMode } from "@/types/global";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
import { useTranslation } from "next-i18next";
|
||||
|
||||
type Props = {
|
||||
viewMode: string;
|
||||
setViewMode: Dispatch<SetStateAction<string>>;
|
||||
viewMode: ViewMode;
|
||||
setViewMode: Dispatch<SetStateAction<ViewMode>>;
|
||||
};
|
||||
|
||||
export default function ViewDropdown({ viewMode, setViewMode }: Props) {
|
||||
const { updateSettings } = useLocalSettingsStore();
|
||||
|
||||
const onChangeViewMode = (
|
||||
e: React.MouseEvent<HTMLButtonElement>,
|
||||
viewMode: ViewMode
|
||||
) => {
|
||||
setViewMode(viewMode);
|
||||
};
|
||||
const { settings, updateSettings } = useLocalSettingsStore((state) => state);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
updateSettings({ viewMode: viewMode as ViewMode });
|
||||
}, [viewMode]);
|
||||
updateSettings({ viewMode });
|
||||
}, [viewMode, updateSettings]);
|
||||
|
||||
const onChangeViewMode = (mode: ViewMode) => {
|
||||
setViewMode(mode);
|
||||
updateSettings({ viewMode });
|
||||
};
|
||||
|
||||
const toggleShowSetting = (setting: keyof typeof settings.show) => {
|
||||
const newShowSettings = {
|
||||
...settings.show,
|
||||
[setting]: !settings.show[setting],
|
||||
};
|
||||
updateSettings({ show: newShowSettings });
|
||||
};
|
||||
|
||||
const onColumnsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateSettings({ columns: Number(e.target.value) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-1 flex flex-row gap-1 border border-neutral-content rounded-[0.625rem]">
|
||||
<button
|
||||
onClick={(e) => onChangeViewMode(e, ViewMode.Card)}
|
||||
className={`btn btn-square btn-sm btn-ghost ${
|
||||
viewMode == ViewMode.Card
|
||||
? "bg-primary/20 hover:bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
}`}
|
||||
<div className="dropdown dropdown-bottom dropdown-end">
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onMouseDown={dropdownTriggerer}
|
||||
className="btn btn-sm btn-square btn-ghost border-none"
|
||||
>
|
||||
<i className="bi-grid w-4 h-4 text-neutral"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => onChangeViewMode(e, ViewMode.Masonry)}
|
||||
className={`btn btn-square btn-sm btn-ghost ${
|
||||
viewMode == ViewMode.Masonry
|
||||
? "bg-primary/20 hover:bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
}`}
|
||||
{viewMode === ViewMode.Card ? (
|
||||
<i className="bi-grid w-4 h-4 text-neutral"></i>
|
||||
) : viewMode === ViewMode.Masonry ? (
|
||||
<i className="bi-columns-gap w-4 h-4 text-neutral"></i>
|
||||
) : (
|
||||
<i className="bi-view-stacked w-4 h-4 text-neutral"></i>
|
||||
)}
|
||||
</div>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="dropdown-content z-[30] menu shadow bg-base-200 min-w-52 border border-neutral-content rounded-xl mt-1"
|
||||
>
|
||||
<i className="bi bi-columns-gap w-4 h-4 text-neutral"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => onChangeViewMode(e, ViewMode.List)}
|
||||
className={`btn btn-square btn-sm btn-ghost ${
|
||||
viewMode == ViewMode.List
|
||||
? "bg-primary/20 hover:bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
}`}
|
||||
>
|
||||
<i className="bi bi-view-stacked w-4 h-4 text-neutral"></i>
|
||||
</button>
|
||||
|
||||
{/* <button
|
||||
onClick={(e) => onChangeViewMode(e, ViewMode.Grid)}
|
||||
className={`btn btn-square btn-sm btn-ghost ${
|
||||
viewMode == ViewMode.Grid
|
||||
? "bg-primary/20 hover:bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
}`}
|
||||
>
|
||||
<i className="bi-columns-gap w-4 h-4 text-neutral"></i>
|
||||
</button> */}
|
||||
<p className="mb-1 text-sm text-neutral">{t("view")}</p>
|
||||
<div className="p-1 flex w-full justify-between gap-1 border border-neutral-content rounded-[0.625rem]">
|
||||
<button
|
||||
onClick={(e) => onChangeViewMode(ViewMode.Card)}
|
||||
className={`btn w-[31%] btn-sm btn-ghost ${
|
||||
viewMode === ViewMode.Card
|
||||
? "bg-primary/20 hover:bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
}`}
|
||||
>
|
||||
<i className="bi-grid text-lg text-neutral"></i>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => onChangeViewMode(ViewMode.Masonry)}
|
||||
className={`btn w-[31%] btn-sm btn-ghost ${
|
||||
viewMode === ViewMode.Masonry
|
||||
? "bg-primary/20 hover:bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
}`}
|
||||
>
|
||||
<i className="bi-columns-gap text-lg text-neutral"></i>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => onChangeViewMode(ViewMode.List)}
|
||||
className={`btn w-[31%] btn-sm btn-ghost ${
|
||||
viewMode === ViewMode.List
|
||||
? "bg-primary/20 hover:bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
}`}
|
||||
>
|
||||
<i className="bi-view-stacked text-lg text-neutral"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p className="mb-1 mt-2 text-sm text-neutral">{t("show")}</p>
|
||||
{Object.entries(settings.show)
|
||||
.filter((e) =>
|
||||
settings.viewMode === ViewMode.List // Hide tags, image, and description checkboxes in list view
|
||||
? e[0] !== "tags" && e[0] !== "image" && e[0] !== "description"
|
||||
: settings.viewMode === ViewMode.Card // Hide tags and description checkboxes in card view
|
||||
? e[0] !== "tags" && e[0] !== "description"
|
||||
: true
|
||||
)
|
||||
.map(([key, value]) => (
|
||||
<li key={key}>
|
||||
<label className="label cursor-pointer flex justify-start">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
checked={value}
|
||||
onChange={() =>
|
||||
toggleShowSetting(key as keyof typeof settings.show)
|
||||
}
|
||||
/>
|
||||
<span className="label-text whitespace-nowrap">{t(key)}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
{settings.viewMode !== ViewMode.List && (
|
||||
<>
|
||||
<p className="mb-1 mt-2 text-sm text-neutral">
|
||||
{t("columns")}:{" "}
|
||||
{settings.columns === 0 ? t("default") : settings.columns}
|
||||
</p>
|
||||
<div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max="8"
|
||||
value={settings.columns}
|
||||
onChange={(e) => onColumnsChange(e)}
|
||||
className="range range-xs range-primary"
|
||||
step="1"
|
||||
/>
|
||||
<div className="flex w-full justify-between px-2 text-xs text-neutral select-none">
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
<span>|</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
17
components/ui/Divider.tsx
Normal file
17
components/ui/Divider.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
vertical?: boolean;
|
||||
};
|
||||
|
||||
function Divider({ className, vertical = false }: Props) {
|
||||
return vertical ? (
|
||||
<hr className={clsx("border-neutral-content border-l h-full", className)} />
|
||||
) : (
|
||||
<hr className={clsx("border-neutral-content border-t", className)} />
|
||||
);
|
||||
}
|
||||
|
||||
export default Divider;
|
||||
272
components/ui/Loader.tsx
Normal file
272
components/ui/Loader.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
color: string;
|
||||
size: string;
|
||||
};
|
||||
|
||||
const Loader = (props: Props) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
width={props.size}
|
||||
height={props.size}
|
||||
className={props.className}
|
||||
style={{
|
||||
shapeRendering: "auto",
|
||||
display: "block",
|
||||
background: "rgba(255, 255, 255, 0)",
|
||||
}}
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
>
|
||||
<g>
|
||||
<g transform="rotate(0 50 50)">
|
||||
<rect
|
||||
fill={props.color}
|
||||
height="12"
|
||||
width="6"
|
||||
ry="1.8"
|
||||
rx="1.8"
|
||||
y="24"
|
||||
x="47"
|
||||
>
|
||||
<animate
|
||||
repeatCount="indefinite"
|
||||
begin="-0.9166666666666666s"
|
||||
dur="1s"
|
||||
keyTimes="0;1"
|
||||
values="1;0"
|
||||
attributeName="opacity"
|
||||
></animate>
|
||||
</rect>
|
||||
</g>
|
||||
<g transform="rotate(30 50 50)">
|
||||
<rect
|
||||
fill={props.color}
|
||||
height="12"
|
||||
width="6"
|
||||
ry="1.8"
|
||||
rx="1.8"
|
||||
y="24"
|
||||
x="47"
|
||||
>
|
||||
<animate
|
||||
repeatCount="indefinite"
|
||||
begin="-0.8333333333333334s"
|
||||
dur="1s"
|
||||
keyTimes="0;1"
|
||||
values="1;0"
|
||||
attributeName="opacity"
|
||||
></animate>
|
||||
</rect>
|
||||
</g>
|
||||
<g transform="rotate(60 50 50)">
|
||||
<rect
|
||||
fill={props.color}
|
||||
height="12"
|
||||
width="6"
|
||||
ry="1.8"
|
||||
rx="1.8"
|
||||
y="24"
|
||||
x="47"
|
||||
>
|
||||
<animate
|
||||
repeatCount="indefinite"
|
||||
begin="-0.75s"
|
||||
dur="1s"
|
||||
keyTimes="0;1"
|
||||
values="1;0"
|
||||
attributeName="opacity"
|
||||
></animate>
|
||||
</rect>
|
||||
</g>
|
||||
<g transform="rotate(90 50 50)">
|
||||
<rect
|
||||
fill={props.color}
|
||||
height="12"
|
||||
width="6"
|
||||
ry="1.8"
|
||||
rx="1.8"
|
||||
y="24"
|
||||
x="47"
|
||||
>
|
||||
<animate
|
||||
repeatCount="indefinite"
|
||||
begin="-0.6666666666666666s"
|
||||
dur="1s"
|
||||
keyTimes="0;1"
|
||||
values="1;0"
|
||||
attributeName="opacity"
|
||||
></animate>
|
||||
</rect>
|
||||
</g>
|
||||
<g transform="rotate(120 50 50)">
|
||||
<rect
|
||||
fill={props.color}
|
||||
height="12"
|
||||
width="6"
|
||||
ry="1.8"
|
||||
rx="1.8"
|
||||
y="24"
|
||||
x="47"
|
||||
>
|
||||
<animate
|
||||
repeatCount="indefinite"
|
||||
begin="-0.5833333333333334s"
|
||||
dur="1s"
|
||||
keyTimes="0;1"
|
||||
values="1;0"
|
||||
attributeName="opacity"
|
||||
></animate>
|
||||
</rect>
|
||||
</g>
|
||||
<g transform="rotate(150 50 50)">
|
||||
<rect
|
||||
fill={props.color}
|
||||
height="12"
|
||||
width="6"
|
||||
ry="1.8"
|
||||
rx="1.8"
|
||||
y="24"
|
||||
x="47"
|
||||
>
|
||||
<animate
|
||||
repeatCount="indefinite"
|
||||
begin="-0.5s"
|
||||
dur="1s"
|
||||
keyTimes="0;1"
|
||||
values="1;0"
|
||||
attributeName="opacity"
|
||||
></animate>
|
||||
</rect>
|
||||
</g>
|
||||
<g transform="rotate(180 50 50)">
|
||||
<rect
|
||||
fill={props.color}
|
||||
height="12"
|
||||
width="6"
|
||||
ry="1.8"
|
||||
rx="1.8"
|
||||
y="24"
|
||||
x="47"
|
||||
>
|
||||
<animate
|
||||
repeatCount="indefinite"
|
||||
begin="-0.4166666666666667s"
|
||||
dur="1s"
|
||||
keyTimes="0;1"
|
||||
values="1;0"
|
||||
attributeName="opacity"
|
||||
></animate>
|
||||
</rect>
|
||||
</g>
|
||||
<g transform="rotate(210 50 50)">
|
||||
<rect
|
||||
fill={props.color}
|
||||
height="12"
|
||||
width="6"
|
||||
ry="1.8"
|
||||
rx="1.8"
|
||||
y="24"
|
||||
x="47"
|
||||
>
|
||||
<animate
|
||||
repeatCount="indefinite"
|
||||
begin="-0.3333333333333333s"
|
||||
dur="1s"
|
||||
keyTimes="0;1"
|
||||
values="1;0"
|
||||
attributeName="opacity"
|
||||
></animate>
|
||||
</rect>
|
||||
</g>
|
||||
<g transform="rotate(240 50 50)">
|
||||
<rect
|
||||
fill={props.color}
|
||||
height="12"
|
||||
width="6"
|
||||
ry="1.8"
|
||||
rx="1.8"
|
||||
y="24"
|
||||
x="47"
|
||||
>
|
||||
<animate
|
||||
repeatCount="indefinite"
|
||||
begin="-0.25s"
|
||||
dur="1s"
|
||||
keyTimes="0;1"
|
||||
values="1;0"
|
||||
attributeName="opacity"
|
||||
></animate>
|
||||
</rect>
|
||||
</g>
|
||||
<g transform="rotate(270 50 50)">
|
||||
<rect
|
||||
fill={props.color}
|
||||
height="12"
|
||||
width="6"
|
||||
ry="1.8"
|
||||
rx="1.8"
|
||||
y="24"
|
||||
x="47"
|
||||
>
|
||||
<animate
|
||||
repeatCount="indefinite"
|
||||
begin="-0.16666666666666666s"
|
||||
dur="1s"
|
||||
keyTimes="0;1"
|
||||
values="1;0"
|
||||
attributeName="opacity"
|
||||
></animate>
|
||||
</rect>
|
||||
</g>
|
||||
<g transform="rotate(300 50 50)">
|
||||
<rect
|
||||
fill={props.color}
|
||||
height="12"
|
||||
width="6"
|
||||
ry="1.8"
|
||||
rx="1.8"
|
||||
y="24"
|
||||
x="47"
|
||||
>
|
||||
<animate
|
||||
repeatCount="indefinite"
|
||||
begin="-0.08333333333333333s"
|
||||
dur="1s"
|
||||
keyTimes="0;1"
|
||||
values="1;0"
|
||||
attributeName="opacity"
|
||||
></animate>
|
||||
</rect>
|
||||
</g>
|
||||
<g transform="rotate(330 50 50)">
|
||||
<rect
|
||||
fill={props.color}
|
||||
height="12"
|
||||
width="6"
|
||||
ry="1.8"
|
||||
rx="1.8"
|
||||
y="24"
|
||||
x="47"
|
||||
>
|
||||
<animate
|
||||
repeatCount="indefinite"
|
||||
begin="0s"
|
||||
dur="1s"
|
||||
keyTimes="0;1"
|
||||
values="1;0"
|
||||
attributeName="opacity"
|
||||
></animate>
|
||||
</rect>
|
||||
</g>
|
||||
<g></g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loader;
|
||||
93
hooks/store/admin/users.tsx
Normal file
93
hooks/store/admin/users.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
const useUsers = () => {
|
||||
const { status } = useSession();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch("/api/v1/users");
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
window.location.href = "/dashboard";
|
||||
}
|
||||
throw new Error("Failed to fetch users.");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.response;
|
||||
},
|
||||
enabled: status === "authenticated",
|
||||
});
|
||||
};
|
||||
|
||||
const useAddUser = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (body: any) => {
|
||||
if (body.password.length < 8) throw new Error(t("password_length_error"));
|
||||
|
||||
const load = toast.loading(t("creating_account"));
|
||||
|
||||
const response = await fetch("/api/v1/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.response);
|
||||
|
||||
toast.dismiss(load);
|
||||
|
||||
return data.response;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(["users"], (oldData: any) => [...oldData, data]);
|
||||
toast.success(t("user_created"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const useDeleteUser = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (userId: number) => {
|
||||
const load = toast.loading(t("deleting_user"));
|
||||
|
||||
const response = await fetch(`/api/v1/users/${userId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.response);
|
||||
|
||||
toast.dismiss(load);
|
||||
|
||||
return data.response;
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.setQueryData(["users"], (oldData: any) =>
|
||||
oldData.filter((user: any) => user.id !== variables)
|
||||
);
|
||||
toast.success(t("user_deleted"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export { useUsers, useAddUser, useDeleteUser };
|
||||
116
hooks/store/collections.tsx
Normal file
116
hooks/store/collections.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
const useCollections = () => {
|
||||
const { status } = useSession();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["collections"],
|
||||
queryFn: async (): Promise<CollectionIncludingMembersAndLinkCount[]> => {
|
||||
const response = await fetch("/api/v1/collections");
|
||||
const data = await response.json();
|
||||
return data.response;
|
||||
},
|
||||
enabled: status === "authenticated",
|
||||
});
|
||||
};
|
||||
|
||||
const useCreateCollection = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (body: any) => {
|
||||
const response = await fetch("/api/v1/collections", {
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) throw new Error(data.response);
|
||||
|
||||
return data.response;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
return queryClient.setQueryData(["collections"], (oldData: any) => {
|
||||
return [...oldData, data];
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const useUpdateCollection = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (body: any) => {
|
||||
const response = await fetch(`/api/v1/collections/${body.id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) throw new Error(data.response);
|
||||
|
||||
return data.response;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
{
|
||||
return queryClient.setQueryData(["collections"], (oldData: any) => {
|
||||
return oldData.map((collection: any) =>
|
||||
collection.id === data.id ? data : collection
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
// onMutate: async (data) => {
|
||||
// await queryClient.cancelQueries({ queryKey: ["collections"] });
|
||||
// queryClient.setQueryData(["collections"], (oldData: any) => {
|
||||
// return oldData.map((collection: any) =>
|
||||
// collection.id === data.id ? data : collection
|
||||
// )
|
||||
// });
|
||||
// },
|
||||
});
|
||||
};
|
||||
|
||||
const useDeleteCollection = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const response = await fetch(`/api/v1/collections/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) throw new Error(data.response);
|
||||
|
||||
return data.response;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
return queryClient.setQueryData(["collections"], (oldData: any) => {
|
||||
return oldData.filter((collection: any) => collection.id !== data.id);
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
useCollections,
|
||||
useCreateCollection,
|
||||
useUpdateCollection,
|
||||
useDeleteCollection,
|
||||
};
|
||||
20
hooks/store/dashboardData.tsx
Normal file
20
hooks/store/dashboardData.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
const useDashboardData = () => {
|
||||
const { status } = useSession();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["dashboardData"],
|
||||
queryFn: async (): Promise<LinkIncludingShortenedCollectionAndTags[]> => {
|
||||
const response = await fetch("/api/v1/dashboard");
|
||||
const data = await response.json();
|
||||
|
||||
return data.response;
|
||||
},
|
||||
enabled: status === "authenticated",
|
||||
});
|
||||
};
|
||||
|
||||
export { useDashboardData };
|
||||
457
hooks/store/links.tsx
Normal file
457
hooks/store/links.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
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: ["links"] }); // Temporary workaround
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboardData"] }); // Temporary workaround
|
||||
|
||||
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 }) => {
|
||||
// TODO: Fix these
|
||||
// queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||
// if (!oldData) return undefined;
|
||||
// return oldData.map((e: any) =>
|
||||
// data.find((d: any) => d.id === e.id) ? data : e
|
||||
// );
|
||||
// });
|
||||
// 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: ["dashboardData"] }); // Temporary workaround
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["collections"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["tags"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const resetInfiniteQueryPagination = async (
|
||||
queryClient: any,
|
||||
queryKey: any
|
||||
) => {
|
||||
queryClient.setQueriesData({ queryKey }, (oldData: any) => {
|
||||
if (!oldData) return undefined;
|
||||
|
||||
return {
|
||||
pages: oldData.pages.slice(0, 1),
|
||||
pageParams: oldData.pageParams.slice(0, 1),
|
||||
};
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries(queryKey);
|
||||
};
|
||||
|
||||
export {
|
||||
useLinks,
|
||||
useAddLink,
|
||||
useUpdateLink,
|
||||
useDeleteLink,
|
||||
useBulkDeleteLinks,
|
||||
useUploadFile,
|
||||
useGetLink,
|
||||
useBulkEditLinks,
|
||||
resetInfiniteQueryPagination,
|
||||
};
|
||||
93
hooks/store/publicLinks.tsx
Normal file
93
hooks/store/publicLinks.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
InfiniteData,
|
||||
useInfiniteQuery,
|
||||
UseInfiniteQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
LinkRequestQuery,
|
||||
} from "@/types/global";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
const usePublicLinks = (params: LinkRequestQuery = {}) => {
|
||||
const router = useRouter();
|
||||
|
||||
const queryParamsObject = {
|
||||
sort: params.sort ?? Number(window.localStorage.getItem("sortBy")) ?? 0,
|
||||
collectionId: params.collectionId ?? router.query.id,
|
||||
tagId:
|
||||
params.tagId ?? router.pathname === "/tags/[id]"
|
||||
? router.query.id
|
||||
: undefined,
|
||||
pinnedOnly:
|
||||
params.pinnedOnly ?? router.pathname === "/links/pinned"
|
||||
? true
|
||||
: undefined,
|
||||
searchQueryString: params.searchQueryString,
|
||||
searchByName: params.searchByName,
|
||||
searchByUrl: params.searchByUrl,
|
||||
searchByDescription: params.searchByDescription,
|
||||
searchByTextContent: params.searchByTextContent,
|
||||
searchByTags: params.searchByTags,
|
||||
} as LinkRequestQuery;
|
||||
|
||||
const queryString = buildQueryString(queryParamsObject);
|
||||
|
||||
const { data, ...rest } = useFetchLinks(queryString);
|
||||
|
||||
const links = useMemo(() => {
|
||||
return data?.pages.reduce((acc, page) => {
|
||||
return [...acc, ...page];
|
||||
}, []);
|
||||
}, [data]);
|
||||
|
||||
return {
|
||||
links,
|
||||
data: { ...data, ...rest },
|
||||
} as {
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
data: UseInfiniteQueryResult<InfiniteData<any, unknown>, Error>;
|
||||
};
|
||||
};
|
||||
|
||||
const useFetchLinks = (params: string) => {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["links", { params }],
|
||||
queryFn: async (params) => {
|
||||
const response = await fetch(
|
||||
"/api/v1/public/collections/links?cursor=" +
|
||||
params.pageParam +
|
||||
((params.queryKey[1] as any).params
|
||||
? "&" + (params.queryKey[1] as any).params
|
||||
: "")
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return data.response;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
refetchOnWindowFocus: false,
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return lastPage.at(-1).id;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const buildQueryString = (params: LinkRequestQuery) => {
|
||||
return Object.keys(params)
|
||||
.filter((key) => params[key as keyof LinkRequestQuery] !== undefined)
|
||||
.map(
|
||||
(key) =>
|
||||
`${encodeURIComponent(key)}=${encodeURIComponent(
|
||||
params[key as keyof LinkRequestQuery] as string
|
||||
)}`
|
||||
)
|
||||
.join("&");
|
||||
};
|
||||
|
||||
export { usePublicLinks };
|
||||
29
hooks/store/publicTags.tsx
Normal file
29
hooks/store/publicTags.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Tag } from "@prisma/client";
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
type TagIncludingCount = Tag & { _count: { links: number } };
|
||||
|
||||
const usePublicTags = (): UseQueryResult<TagIncludingCount[]> => {
|
||||
const { status } = useSession();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["tags"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(
|
||||
"/api/v1/public/collections/tags" +
|
||||
"?collectionId=" +
|
||||
router.query.id || ""
|
||||
);
|
||||
if (!response.ok) throw new Error("Failed to fetch tags.");
|
||||
|
||||
const data = await response.json();
|
||||
return data.response;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export { usePublicTags };
|
||||
71
hooks/store/tags.tsx
Normal file
71
hooks/store/tags.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { TagIncludingLinkCount } from "@/types/global";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
const useTags = () => {
|
||||
const { status } = useSession();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["tags"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch("/api/v1/tags");
|
||||
if (!response.ok) throw new Error("Failed to fetch tags.");
|
||||
|
||||
const data = await response.json();
|
||||
return data.response;
|
||||
},
|
||||
enabled: status === "authenticated",
|
||||
});
|
||||
};
|
||||
|
||||
const useUpdateTag = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (tag: TagIncludingLinkCount) => {
|
||||
const response = await fetch(`/api/v1/tags/${tag.id}`, {
|
||||
body: JSON.stringify(tag),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.response);
|
||||
|
||||
return data.response;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(["tags"], (oldData: any) =>
|
||||
oldData.map((tag: TagIncludingLinkCount) =>
|
||||
tag.id === data.id ? data : tag
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const useRemoveTag = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (tagId: number) => {
|
||||
const response = await fetch(`/api/v1/tags/${tagId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.response);
|
||||
|
||||
return data.response;
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.setQueryData(["tags"], (oldData: any) =>
|
||||
oldData.filter((tag: TagIncludingLinkCount) => tag.id !== variables)
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export { useTags, useUpdateTag, useRemoveTag };
|
||||
68
hooks/store/tokens.tsx
Normal file
68
hooks/store/tokens.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { AccessToken } from "@prisma/client";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
const useTokens = () => {
|
||||
const { status } = useSession();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["tokens"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch("/api/v1/tokens");
|
||||
|
||||
if (!response.ok) throw new Error("Failed to fetch tokens.");
|
||||
|
||||
const data = await response.json();
|
||||
return data.response as AccessToken[];
|
||||
},
|
||||
enabled: status === "authenticated",
|
||||
});
|
||||
};
|
||||
|
||||
const useAddToken = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (body: Partial<AccessToken>) => {
|
||||
const response = await fetch("/api/v1/tokens", {
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.response);
|
||||
|
||||
return data.response;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(["tokens"], (oldData: AccessToken[]) => [
|
||||
...oldData,
|
||||
data.token,
|
||||
]);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const useRevokeToken = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (tokenId: number) => {
|
||||
const response = await fetch(`/api/v1/tokens/${tokenId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.response);
|
||||
|
||||
return data.response;
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.setQueryData(["tokens"], (oldData: AccessToken[]) =>
|
||||
oldData.filter((token: Partial<AccessToken>) => token.id !== variables)
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export { useTokens, useAddToken, useRevokeToken };
|
||||
53
hooks/store/user.tsx
Normal file
53
hooks/store/user.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
const useUser = () => {
|
||||
const { data, status } = useSession();
|
||||
|
||||
const userId = data?.user.id;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["user"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`/api/v1/users/${userId}`);
|
||||
if (!response.ok) throw new Error("Failed to fetch user data.");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return data.response;
|
||||
},
|
||||
enabled: !!userId && status === "authenticated",
|
||||
placeholderData: {},
|
||||
});
|
||||
};
|
||||
|
||||
const useUpdateUser = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (user: any) => {
|
||||
const response = await fetch(`/api/v1/users/${user.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(user),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) throw new Error(data.response);
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(["user"], data.response);
|
||||
},
|
||||
onMutate: async (user) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["user"] });
|
||||
queryClient.setQueryData(["user"], (oldData: any) => {
|
||||
return { ...oldData, ...user };
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export { useUser, useUpdateUser };
|
||||
@@ -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 useCollectivePermissions(collectionIds: number[]) {
|
||||
const { collections } = useCollectionStore();
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const { account } = useAccountStore();
|
||||
const { data: user = {} } = useUser();
|
||||
|
||||
const [permissions, setPermissions] = useState<Member | true>();
|
||||
useEffect(() => {
|
||||
@@ -15,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 (
|
||||
@@ -25,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));
|
||||
|
||||
@@ -43,6 +43,9 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
||||
password: process.env.PROXY_PASSWORD,
|
||||
};
|
||||
}
|
||||
if (process.env.PLAYWRIGHT_LAUNCH_OPTIONS_EXECUTABLE_PATH) {
|
||||
browserOptions.executablePath = process.env.PLAYWRIGHT_LAUNCH_OPTIONS_EXECUTABLE_PATH;
|
||||
}
|
||||
|
||||
const browser = await chromium.launch(browserOptions);
|
||||
const context = await browser.newContext({
|
||||
|
||||
@@ -57,8 +57,8 @@ export default async function deleteCollection(
|
||||
},
|
||||
});
|
||||
|
||||
await removeFolder({ filePath: `archives/${collectionId}` });
|
||||
await removeFolder({ filePath: `archives/preview/${collectionId}` });
|
||||
removeFolder({ filePath: `archives/${collectionId}` });
|
||||
removeFolder({ filePath: `archives/preview/${collectionId}` });
|
||||
|
||||
await removeFromOrders(userId, collectionId);
|
||||
|
||||
@@ -100,8 +100,8 @@ async function deleteSubCollections(collectionId: number) {
|
||||
where: { id: subCollection.id },
|
||||
});
|
||||
|
||||
await removeFolder({ filePath: `archives/${subCollection.id}` });
|
||||
await removeFolder({ filePath: `archives/preview/${subCollection.id}` });
|
||||
removeFolder({ filePath: `archives/${subCollection.id}` });
|
||||
removeFolder({ filePath: `archives/preview/${subCollection.id}` });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export default async function getDashboardData(
|
||||
userId: number,
|
||||
query: LinkRequestQuery
|
||||
) {
|
||||
let order: any;
|
||||
let order: any = { id: "desc" };
|
||||
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
|
||||
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
|
||||
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
|
||||
@@ -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,10 +67,18 @@ export default async function getDashboardData(
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: order || { id: "desc" },
|
||||
orderBy: order,
|
||||
});
|
||||
|
||||
const links = [...recentlyAddedLinks, ...pinnedLinks].sort(
|
||||
const combinedLinks = [...recentlyAddedLinks, ...pinnedLinks];
|
||||
|
||||
const uniqueLinks = Array.from(
|
||||
combinedLinks
|
||||
.reduce((map, item) => map.set(item.id, item), new Map())
|
||||
.values()
|
||||
);
|
||||
|
||||
const links = uniqueLinks.sort(
|
||||
(a, b) => (new Date(b.id) as any) - (new Date(a.id) as any)
|
||||
);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export default async function getLink(userId: number, query: LinkRequestQuery) {
|
||||
const POSTGRES_IS_ENABLED =
|
||||
process.env.DATABASE_URL?.startsWith("postgresql");
|
||||
|
||||
let order: any;
|
||||
let order: any = { id: "desc" };
|
||||
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
|
||||
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
|
||||
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
|
||||
@@ -146,7 +146,7 @@ export default async function getLink(userId: number, query: LinkRequestQuery) {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
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(),
|
||||
|
||||
@@ -63,7 +63,8 @@ async function processBookmarks(
|
||||
) as Element;
|
||||
|
||||
if (collectionName) {
|
||||
const collectionNameContent = (collectionName.children[0] as TextNode)?.content;
|
||||
const collectionNameContent = (collectionName.children[0] as TextNode)
|
||||
?.content;
|
||||
if (collectionNameContent) {
|
||||
collectionId = await createCollection(
|
||||
userId,
|
||||
@@ -274,4 +275,3 @@ function processNodes(nodes: Node[]) {
|
||||
nodes.forEach(findAndProcessDL);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,46 +1,68 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
|
||||
export default async function getTags(userId: number) {
|
||||
// Remove empty tags
|
||||
await prisma.tag.deleteMany({
|
||||
where: {
|
||||
ownerId: userId,
|
||||
links: {
|
||||
none: {},
|
||||
export default async function getTags({
|
||||
userId,
|
||||
collectionId,
|
||||
}: {
|
||||
userId?: number;
|
||||
collectionId?: number;
|
||||
}) {
|
||||
if (userId) {
|
||||
// Remove empty tags
|
||||
await prisma.tag.deleteMany({
|
||||
where: {
|
||||
ownerId: userId,
|
||||
links: {
|
||||
none: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const tags = await prisma.tag.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: userId }, // Tags owned by the user
|
||||
{
|
||||
links: {
|
||||
some: {
|
||||
collection: {
|
||||
members: {
|
||||
some: {
|
||||
userId, // Tags from collections where the user is a member
|
||||
const tags = await prisma.tag.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: userId }, // Tags owned by the user
|
||||
{
|
||||
links: {
|
||||
some: {
|
||||
collection: {
|
||||
members: {
|
||||
some: {
|
||||
userId, // Tags from collections where the user is a member
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { links: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
// orderBy: {
|
||||
// links: {
|
||||
// _count: "desc",
|
||||
// },
|
||||
// },
|
||||
});
|
||||
include: {
|
||||
_count: {
|
||||
select: { links: true },
|
||||
},
|
||||
},
|
||||
// orderBy: {
|
||||
// links: {
|
||||
// _count: "desc",
|
||||
// },
|
||||
// },
|
||||
});
|
||||
|
||||
return { response: tags, status: 200 };
|
||||
return { response: tags, status: 200 };
|
||||
} else if (collectionId) {
|
||||
const tags = await prisma.tag.findMany({
|
||||
where: {
|
||||
links: {
|
||||
some: {
|
||||
collection: {
|
||||
id: collectionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { response: tags, status: 200 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import bcrypt from "bcrypt";
|
||||
import isServerAdmin from "../../isServerAdmin";
|
||||
import { PostUserSchema } from "@/lib/shared/schemaValidation";
|
||||
import isAuthenticatedRequest from "../../isAuthenticatedRequest";
|
||||
import { Subscription, User } from "@prisma/client";
|
||||
|
||||
const emailEnabled =
|
||||
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
||||
@@ -12,60 +14,59 @@ interface Data {
|
||||
status: number;
|
||||
}
|
||||
|
||||
interface User {
|
||||
name: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export default async function postUser(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
): Promise<Data> {
|
||||
let isAdmin = await isServerAdmin({ req });
|
||||
const parentUser = await isAuthenticatedRequest({ req });
|
||||
const isAdmin =
|
||||
parentUser && parentUser.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1);
|
||||
|
||||
if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" && !isAdmin) {
|
||||
return { response: "Registration is disabled.", status: 400 };
|
||||
}
|
||||
|
||||
const body: User = req.body;
|
||||
const dataValidation = PostUserSchema().safeParse(req.body);
|
||||
|
||||
const checkHasEmptyFields = emailEnabled
|
||||
? !body.password || !body.name || !body.email
|
||||
: !body.username || !body.password || !body.name;
|
||||
|
||||
if (!body.password || body.password.length < 8)
|
||||
return { response: "Password must be at least 8 characters.", status: 400 };
|
||||
|
||||
if (checkHasEmptyFields)
|
||||
return { response: "Please fill out all the fields.", status: 400 };
|
||||
|
||||
// Check email (if enabled)
|
||||
const checkEmail =
|
||||
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
|
||||
if (emailEnabled && !checkEmail.test(body.email?.toLowerCase() || ""))
|
||||
return { response: "Please enter a valid email.", status: 400 };
|
||||
|
||||
// Check username (if email was disabled)
|
||||
const checkUsername = RegExp("^[a-z0-9_-]{3,31}$");
|
||||
if (!emailEnabled && !checkUsername.test(body.username?.toLowerCase() || ""))
|
||||
if (!dataValidation.success) {
|
||||
return {
|
||||
response:
|
||||
"Username has to be between 3-30 characters, no spaces and special characters are allowed.",
|
||||
response: `Error: ${
|
||||
dataValidation.error.issues[0].message
|
||||
} [${dataValidation.error.issues[0].path.join(", ")}]`,
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
const { name, email, password, invite } = dataValidation.data;
|
||||
let { username } = dataValidation.data;
|
||||
|
||||
if (invite && (!stripeEnabled || !emailEnabled)) {
|
||||
return { response: "You are not authorized to invite users.", status: 401 };
|
||||
} else if (invite && !parentUser) {
|
||||
return { response: "You must be logged in to invite users.", status: 401 };
|
||||
}
|
||||
|
||||
const autoGeneratedUsername = "user" + Math.round(Math.random() * 1000000000);
|
||||
|
||||
if (!username) {
|
||||
username = autoGeneratedUsername;
|
||||
}
|
||||
|
||||
if (!emailEnabled && !password) {
|
||||
return {
|
||||
response: "Password is required.",
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
const checkIfUserExists = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
email: body.email ? body.email.toLowerCase().trim() : undefined,
|
||||
email: email ? email.toLowerCase().trim() : undefined,
|
||||
},
|
||||
{
|
||||
username: body.username
|
||||
? body.username.toLowerCase().trim()
|
||||
: undefined,
|
||||
username: username ? username.toLowerCase().trim() : undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -77,64 +78,57 @@ export default async function postUser(
|
||||
|
||||
const saltRounds = 10;
|
||||
|
||||
const hashedPassword = bcrypt.hashSync(body.password, saltRounds);
|
||||
const hashedPassword = bcrypt.hashSync(password || "", saltRounds);
|
||||
|
||||
// Subscription dates
|
||||
const currentPeriodStart = new Date();
|
||||
const currentPeriodEnd = new Date();
|
||||
currentPeriodEnd.setFullYear(currentPeriodEnd.getFullYear() + 1000); // end date is in 1000 years...
|
||||
|
||||
if (isAdmin) {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: body.name,
|
||||
username: emailEnabled
|
||||
? autoGeneratedUsername
|
||||
: (body.username as string).toLowerCase().trim(),
|
||||
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
|
||||
password: hashedPassword,
|
||||
emailVerified: new Date(),
|
||||
subscriptions: stripeEnabled
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: name,
|
||||
username: emailEnabled ? username || autoGeneratedUsername : username,
|
||||
email: emailEnabled ? email : undefined,
|
||||
emailVerified: isAdmin ? new Date() : undefined,
|
||||
password: password ? hashedPassword : undefined,
|
||||
parentSubscription:
|
||||
parentUser && invite
|
||||
? {
|
||||
connect: {
|
||||
id: (parentUser.subscriptions as Subscription).id,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
subscriptions:
|
||||
stripeEnabled && isAdmin
|
||||
? {
|
||||
create: {
|
||||
stripeSubscriptionId:
|
||||
"fake_sub_" + Math.round(Math.random() * 10000000000000),
|
||||
active: true,
|
||||
currentPeriodStart,
|
||||
currentPeriodEnd,
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: new Date(
|
||||
new Date().setFullYear(new Date().getFullYear() + 1000)
|
||||
), // 1000 years from now
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
subscriptions: {
|
||||
select: {
|
||||
active: true,
|
||||
},
|
||||
select: isAdmin
|
||||
? {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
password: true,
|
||||
subscriptions: {
|
||||
select: {
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
createdAt: true,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return { response: user, status: 201 };
|
||||
} else {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
name: body.name,
|
||||
username: emailEnabled
|
||||
? autoGeneratedUsername
|
||||
: (body.username as string).toLowerCase().trim(),
|
||||
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
|
||||
return { response: "User successfully created.", status: 201 };
|
||||
}
|
||||
const { password: pass, ...userWithoutPassword } = user as User;
|
||||
return { response: userWithoutPassword, status: 201 };
|
||||
} else {
|
||||
return { response: "Email or Username already exists.", status: 400 };
|
||||
}
|
||||
|
||||
@@ -4,15 +4,28 @@ import removeFolder from "@/lib/api/storage/removeFolder";
|
||||
import Stripe from "stripe";
|
||||
import { DeleteUserBody } from "@/types/global";
|
||||
import removeFile from "@/lib/api/storage/removeFile";
|
||||
import updateSeats from "@/lib/api/stripe/updateSeats";
|
||||
|
||||
export default async function deleteUserById(
|
||||
userId: number,
|
||||
body: DeleteUserBody,
|
||||
isServerAdmin?: boolean
|
||||
isServerAdmin: boolean,
|
||||
queryId: number
|
||||
) {
|
||||
// First, we retrieve the user from the database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
subscriptions: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
parentSubscription: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@@ -23,24 +36,70 @@ export default async function deleteUserById(
|
||||
}
|
||||
|
||||
if (!isServerAdmin) {
|
||||
if (user.password) {
|
||||
const isPasswordValid = bcrypt.compareSync(
|
||||
body.password,
|
||||
user.password as string
|
||||
);
|
||||
if (queryId === userId) {
|
||||
if (user.password) {
|
||||
const isPasswordValid = bcrypt.compareSync(
|
||||
body.password,
|
||||
user.password
|
||||
);
|
||||
|
||||
if (!isPasswordValid && !isServerAdmin) {
|
||||
if (!isPasswordValid && !isServerAdmin) {
|
||||
return {
|
||||
response: "Invalid credentials.",
|
||||
status: 401,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
response: "Invalid credentials.",
|
||||
status: 401, // Unauthorized
|
||||
response:
|
||||
"User has no password. Please reset your password from the forgot password page.",
|
||||
status: 401,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
response:
|
||||
"User has no password. Please reset your password from the forgot password page.",
|
||||
status: 401, // Unauthorized
|
||||
};
|
||||
if (user.parentSubscriptionId) {
|
||||
return {
|
||||
response: "Permission denied.",
|
||||
status: 401,
|
||||
};
|
||||
} else {
|
||||
if (!user.subscriptions) {
|
||||
return {
|
||||
response: "User has no subscription.",
|
||||
status: 401,
|
||||
};
|
||||
}
|
||||
|
||||
const findChild = await prisma.user.findFirst({
|
||||
where: { id: queryId, parentSubscriptionId: user.subscriptions?.id },
|
||||
});
|
||||
|
||||
if (!findChild)
|
||||
return {
|
||||
response: "Permission denied.",
|
||||
status: 401,
|
||||
};
|
||||
|
||||
const removeUser = await prisma.user.update({
|
||||
where: { id: findChild.id },
|
||||
data: {
|
||||
parentSubscription: {
|
||||
disconnect: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (removeUser.emailVerified)
|
||||
await updateSeats(
|
||||
user.subscriptions.stripeSubscriptionId,
|
||||
user.subscriptions.quantity - 1
|
||||
);
|
||||
|
||||
return {
|
||||
response: "Account removed from subscription.",
|
||||
status: 200,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,27 +109,27 @@ export default async function deleteUserById(
|
||||
async (prisma) => {
|
||||
// Delete Access Tokens
|
||||
await prisma.accessToken.deleteMany({
|
||||
where: { userId },
|
||||
where: { userId: queryId },
|
||||
});
|
||||
|
||||
// Delete whitelisted users
|
||||
await prisma.whitelistedUser.deleteMany({
|
||||
where: { userId },
|
||||
where: { userId: queryId },
|
||||
});
|
||||
|
||||
// Delete links
|
||||
await prisma.link.deleteMany({
|
||||
where: { collection: { ownerId: userId } },
|
||||
where: { collection: { ownerId: queryId } },
|
||||
});
|
||||
|
||||
// Delete tags
|
||||
await prisma.tag.deleteMany({
|
||||
where: { ownerId: userId },
|
||||
where: { ownerId: queryId },
|
||||
});
|
||||
|
||||
// Find collections that the user owns
|
||||
const collections = await prisma.collection.findMany({
|
||||
where: { ownerId: userId },
|
||||
where: { ownerId: queryId },
|
||||
});
|
||||
|
||||
for (const collection of collections) {
|
||||
@@ -89,29 +148,29 @@ export default async function deleteUserById(
|
||||
|
||||
// Delete collections after cleaning up related data
|
||||
await prisma.collection.deleteMany({
|
||||
where: { ownerId: userId },
|
||||
where: { ownerId: queryId },
|
||||
});
|
||||
|
||||
// Delete subscription
|
||||
if (process.env.STRIPE_SECRET_KEY)
|
||||
await prisma.subscription
|
||||
.delete({
|
||||
where: { userId },
|
||||
where: { userId: queryId },
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
|
||||
await prisma.usersAndCollections.deleteMany({
|
||||
where: {
|
||||
OR: [{ userId: userId }, { collection: { ownerId: userId } }],
|
||||
OR: [{ userId: queryId }, { collection: { ownerId: queryId } }],
|
||||
},
|
||||
});
|
||||
|
||||
// Delete user's avatar
|
||||
await removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
|
||||
await removeFile({ filePath: `uploads/avatar/${queryId}.jpg` });
|
||||
|
||||
// Finally, delete the user
|
||||
await prisma.user.delete({
|
||||
where: { id: userId },
|
||||
where: { id: queryId },
|
||||
});
|
||||
},
|
||||
{ timeout: 20000 }
|
||||
@@ -124,24 +183,36 @@ export default async function deleteUserById(
|
||||
});
|
||||
|
||||
try {
|
||||
const listByEmail = await stripe.customers.list({
|
||||
email: user.email?.toLowerCase(),
|
||||
expand: ["data.subscriptions"],
|
||||
});
|
||||
if (user.subscriptions?.id) {
|
||||
const listByEmail = await stripe.customers.list({
|
||||
email: user.email?.toLowerCase(),
|
||||
expand: ["data.subscriptions"],
|
||||
});
|
||||
|
||||
if (listByEmail.data[0].subscriptions?.data[0].id) {
|
||||
const deleted = await stripe.subscriptions.cancel(
|
||||
listByEmail.data[0].subscriptions?.data[0].id,
|
||||
{
|
||||
cancellation_details: {
|
||||
comment: body.cancellation_details?.comment,
|
||||
feedback: body.cancellation_details?.feedback,
|
||||
},
|
||||
}
|
||||
if (listByEmail.data[0].subscriptions?.data[0].id) {
|
||||
const deleted = await stripe.subscriptions.cancel(
|
||||
listByEmail.data[0].subscriptions?.data[0].id,
|
||||
{
|
||||
cancellation_details: {
|
||||
comment: body.cancellation_details?.comment,
|
||||
feedback: body.cancellation_details?.feedback,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
response: deleted,
|
||||
status: 200,
|
||||
};
|
||||
}
|
||||
} else if (user.parentSubscription?.id && user && user.emailVerified) {
|
||||
await updateSeats(
|
||||
user.parentSubscription.stripeSubscriptionId,
|
||||
user.parentSubscription.quantity - 1
|
||||
);
|
||||
|
||||
return {
|
||||
response: deleted,
|
||||
response: "User account and all related data deleted successfully.",
|
||||
status: 200,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,6 +212,8 @@ export default async function updateUserById(
|
||||
archiveAsWaybackMachine: data.archiveAsWaybackMachine,
|
||||
linksRouteTo: data.linksRouteTo,
|
||||
preventDuplicateLinks: data.preventDuplicateLinks,
|
||||
referredBy:
|
||||
!user?.referredBy && data.referredBy ? data.referredBy : undefined,
|
||||
password:
|
||||
data.newPassword && data.newPassword !== ""
|
||||
? newHashedPassword
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -39,7 +39,7 @@ const handleMonolith = async (link: Link, content: string) => {
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.log("Error running MONOLITH:", err);
|
||||
console.log("Uncaught Monolith error...");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
|
||||
const getPublicCollectionData = async (
|
||||
collectionId: number,
|
||||
setData: Dispatch<
|
||||
SetStateAction<CollectionIncludingMembersAndLinkCount | undefined>
|
||||
>
|
||||
) => {
|
||||
const getPublicCollectionData = async (collectionId: number) => {
|
||||
const res = await fetch("/api/v1/public/collections/" + collectionId);
|
||||
|
||||
const data = await res.json();
|
||||
if (res.status === 400)
|
||||
return { response: "Collection not found.", status: 400 };
|
||||
|
||||
setData(data.response);
|
||||
const data = await res.json();
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
211
lib/shared/schemaValidation.ts
Normal file
211
lib/shared/schemaValidation.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { ArchivedFormat, TokenExpiry } from "@/types/global";
|
||||
import { LinksRouteTo } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
// const stringField = z.string({
|
||||
// errorMap: (e) => ({
|
||||
// message: `Invalid ${e.path}.`,
|
||||
// }),
|
||||
// });
|
||||
|
||||
export const ForgotPasswordSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export const ResetPasswordSchema = z.object({
|
||||
token: z.string(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
export const VerifyEmailSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export const PostTokenSchema = z.object({
|
||||
name: z.string().max(50),
|
||||
expires: z.nativeEnum(TokenExpiry),
|
||||
});
|
||||
|
||||
export type PostTokenSchemaType = z.infer<typeof PostTokenSchema>;
|
||||
|
||||
export const PostUserSchema = () => {
|
||||
const emailEnabled =
|
||||
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
||||
|
||||
return z.object({
|
||||
name: z.string().trim().min(1).max(50).optional(),
|
||||
password: z.string().min(8).max(2048).optional(),
|
||||
email: emailEnabled
|
||||
? z.string().trim().email().toLowerCase()
|
||||
: z.string().optional(),
|
||||
username: emailEnabled
|
||||
? z.string().optional()
|
||||
: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.min(3)
|
||||
.max(50)
|
||||
.regex(/^[a-z0-9_-]{3,50}$/),
|
||||
invite: z.boolean().optional(),
|
||||
});
|
||||
};
|
||||
|
||||
export const UpdateUserSchema = () => {
|
||||
const emailEnabled =
|
||||
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
||||
|
||||
return z.object({
|
||||
name: z.string().trim().min(1).max(50).optional(),
|
||||
email: emailEnabled
|
||||
? z.string().trim().email().toLowerCase()
|
||||
: z.string().optional(),
|
||||
username: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.min(3)
|
||||
.max(30)
|
||||
.regex(/^[a-z0-9_-]{3,30}$/),
|
||||
image: z.string().nullish(),
|
||||
password: z.string().min(8).max(2048).optional(),
|
||||
newPassword: z.string().min(8).max(2048).optional(),
|
||||
oldPassword: z.string().min(8).max(2048).optional(),
|
||||
archiveAsScreenshot: z.boolean().optional(),
|
||||
archiveAsPDF: z.boolean().optional(),
|
||||
archiveAsMonolith: z.boolean().optional(),
|
||||
archiveAsWaybackMachine: z.boolean().optional(),
|
||||
locale: z.string().max(20).optional(),
|
||||
isPrivate: z.boolean().optional(),
|
||||
preventDuplicateLinks: z.boolean().optional(),
|
||||
collectionOrder: z.array(z.number()).optional(),
|
||||
linksRouteTo: z.nativeEnum(LinksRouteTo).optional(),
|
||||
whitelistedUsers: z.array(z.string().max(50)).optional(),
|
||||
referredBy: z.string().max(100).nullish(),
|
||||
});
|
||||
};
|
||||
|
||||
export const PostSessionSchema = z.object({
|
||||
username: z.string().min(3).max(50),
|
||||
password: z.string().min(8),
|
||||
sessionName: z.string().trim().max(50).optional(),
|
||||
});
|
||||
|
||||
export const PostLinkSchema = z.object({
|
||||
type: z.enum(["url", "pdf", "image"]),
|
||||
url: z.string().trim().max(2048).url().optional(),
|
||||
name: z.string().trim().max(2048).optional(),
|
||||
description: z.string().trim().max(2048).optional(),
|
||||
collection: z
|
||||
.object({
|
||||
id: z.number().optional(),
|
||||
name: z.string().trim().max(2048).optional(),
|
||||
})
|
||||
.optional(),
|
||||
tags:
|
||||
z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
name: z.string().trim().max(50),
|
||||
})
|
||||
)
|
||||
.optional() || [],
|
||||
});
|
||||
|
||||
export type PostLinkSchemaType = z.infer<typeof PostLinkSchema>;
|
||||
|
||||
export const UpdateLinkSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string().trim().max(2048).optional(),
|
||||
url: z.string().trim().max(2048).optional(),
|
||||
description: z.string().trim().max(2048).optional(),
|
||||
icon: z.string().trim().max(50).nullish(),
|
||||
iconWeight: z.string().trim().max(50).nullish(),
|
||||
color: z.string().trim().max(10).nullish(),
|
||||
collection: z.object({
|
||||
id: z.number(),
|
||||
ownerId: z.number(),
|
||||
}),
|
||||
tags: z.array(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
name: z.string().trim().max(50),
|
||||
})
|
||||
),
|
||||
pinnedBy: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.number().optional(),
|
||||
})
|
||||
.optional()
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type UpdateLinkSchemaType = z.infer<typeof UpdateLinkSchema>;
|
||||
|
||||
const ACCEPTED_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"application/pdf",
|
||||
];
|
||||
const NEXT_PUBLIC_MAX_FILE_BUFFER = Number(
|
||||
process.env.NEXT_PUBLIC_MAX_FILE_BUFFER || 10
|
||||
);
|
||||
const MAX_FILE_SIZE = NEXT_PUBLIC_MAX_FILE_BUFFER * 1024 * 1024;
|
||||
export const UploadFileSchema = z.object({
|
||||
file: z
|
||||
.any()
|
||||
.refine((files) => files?.length == 1, "File is required.")
|
||||
.refine(
|
||||
(files) => files?.[0]?.size <= MAX_FILE_SIZE,
|
||||
`Max file size is ${MAX_FILE_SIZE}MB.`
|
||||
)
|
||||
.refine(
|
||||
(files) => ACCEPTED_TYPES.includes(files?.[0]?.mimetype),
|
||||
`Only ${ACCEPTED_TYPES.join(", ")} files are accepted.`
|
||||
),
|
||||
id: z.number(),
|
||||
format: z.nativeEnum(ArchivedFormat),
|
||||
});
|
||||
|
||||
export const PostCollectionSchema = z.object({
|
||||
name: z.string().trim().max(2048),
|
||||
description: z.string().trim().max(2048).optional(),
|
||||
color: z.string().trim().max(10).optional(),
|
||||
icon: z.string().trim().max(50).optional(),
|
||||
iconWeight: z.string().trim().max(50).optional(),
|
||||
parentId: z.number().optional(),
|
||||
});
|
||||
|
||||
export type PostCollectionSchemaType = z.infer<typeof PostCollectionSchema>;
|
||||
|
||||
export const UpdateCollectionSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string().trim().max(2048),
|
||||
description: z.string().trim().max(2048).optional(),
|
||||
color: z.string().trim().max(10).optional(),
|
||||
isPublic: z.boolean().optional(),
|
||||
icon: z.string().trim().max(50).nullish(),
|
||||
iconWeight: z.string().trim().max(50).nullish(),
|
||||
parentId: z.union([z.number(), z.literal("root")]).nullish(),
|
||||
members: z.array(
|
||||
z.object({
|
||||
userId: z.number(),
|
||||
canCreate: z.boolean(),
|
||||
canUpdate: z.boolean(),
|
||||
canDelete: z.boolean(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export type UpdateCollectionSchemaType = z.infer<typeof UpdateCollectionSchema>;
|
||||
|
||||
export const UpdateTagSchema = z.object({
|
||||
name: z.string().trim().max(50),
|
||||
});
|
||||
|
||||
export type UpdateTagSchemaType = z.infer<typeof UpdateTagSchema>;
|
||||
@@ -2,7 +2,7 @@
|
||||
module.exports = {
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en","it"],
|
||||
locales: ["en", "it", "fr", "zh"],
|
||||
},
|
||||
reloadOnPrerender: process.env.NODE_ENV === "development",
|
||||
};
|
||||
|
||||
21
package.json
21
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkwarden",
|
||||
"version": "v2.6.2",
|
||||
"version": "v2.8.0",
|
||||
"main": "index.js",
|
||||
"repository": "https://github.com/linkwarden/linkwarden.git",
|
||||
"author": "Daniel31X13 <daniel31x13@gmail.com>",
|
||||
@@ -25,8 +25,12 @@
|
||||
"@aws-sdk/client-s3": "^3.379.1",
|
||||
"@headlessui/react": "^1.7.15",
|
||||
"@mozilla/readability": "^0.4.4",
|
||||
"@prisma/client": "^4.16.2",
|
||||
"@phosphor-icons/core": "^2.1.1",
|
||||
"@phosphor-icons/react": "^2.1.7",
|
||||
"@prisma/client": "^5.21.1",
|
||||
"@stripe/stripe-js": "^1.54.1",
|
||||
"@tanstack/react-query": "^5.51.15",
|
||||
"@tanstack/react-query-devtools": "^5.51.15",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/formidable": "^3.4.5",
|
||||
"@types/node": "^20.10.4",
|
||||
@@ -48,6 +52,7 @@
|
||||
"eslint-config-next": "13.4.9",
|
||||
"formidable": "^3.5.1",
|
||||
"framer-motion": "^10.16.4",
|
||||
"fuse.js": "^7.0.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"himalaya": "^1.1.0",
|
||||
"i18next": "^23.11.5",
|
||||
@@ -67,13 +72,16 @@
|
||||
"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",
|
||||
"react-window": "^1.8.10",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
"stripe": "^12.13.0",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"vaul": "^0.8.8",
|
||||
"vaul": "^1.1.1",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -82,14 +90,15 @@
|
||||
"@types/dompurify": "^3.0.4",
|
||||
"@types/jsdom": "^21.1.3",
|
||||
"@types/node-fetch": "^2.6.10",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/shelljs": "^0.8.15",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"daisyui": "^4.4.2",
|
||||
"nodemon": "^3.0.2",
|
||||
"postcss": "^8.4.26",
|
||||
"prettier": "3.1.1",
|
||||
"prisma": "^4.16.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"prisma": "^5.21.1",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "4.9.4"
|
||||
}
|
||||
|
||||
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,10 +1186,42 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||
providerAccountId: account?.providerAccountId,
|
||||
},
|
||||
});
|
||||
if (existingUser && newSsoUsersDisabled) {
|
||||
|
||||
if (!existingUser && newSsoUsersDisabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If user is already registered, link the provider
|
||||
if (user.email && account) {
|
||||
const findUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: user.email,
|
||||
},
|
||||
include: {
|
||||
accounts: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (findUser && findUser.accounts.length === 0) {
|
||||
await prisma.account.create({
|
||||
data: {
|
||||
userId: findUser.id,
|
||||
type: account.type,
|
||||
provider: account.provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
id_token: account.id_token,
|
||||
access_token: account.access_token,
|
||||
refresh_token: account.refresh_token,
|
||||
expires_at: account.expires_at,
|
||||
token_type: account.token_type,
|
||||
scope: account.scope,
|
||||
session_state: account.session_state,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
async jwt({ token, trigger, user }) {
|
||||
@@ -1198,13 +1230,28 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||
token.id = user?.id as number;
|
||||
|
||||
if (trigger === "signUp") {
|
||||
const checkIfUserExists = await prisma.user.findUnique({
|
||||
const userExists = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: token.id,
|
||||
},
|
||||
include: {
|
||||
accounts: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (checkIfUserExists && !checkIfUserExists.username) {
|
||||
// Verify SSO user email
|
||||
if (userExists && userExists.accounts.length > 0) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userExists.id,
|
||||
},
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (userExists && !userExists.username) {
|
||||
const autoGeneratedUsername =
|
||||
"user" + Math.round(Math.random() * 1000000000);
|
||||
|
||||
@@ -1217,6 +1264,22 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (trigger === "signIn") {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: token.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (user && !user.username) {
|
||||
const autoGeneratedUsername =
|
||||
"user" + Math.round(Math.random() * 1000000000);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { username: autoGeneratedUsername },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return token;
|
||||
@@ -1224,6 +1287,8 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||
async session({ session, token }) {
|
||||
session.user.id = token.id;
|
||||
|
||||
console.log("session", session);
|
||||
|
||||
if (STRIPE_SECRET_KEY) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
@@ -1235,6 +1300,7 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||
});
|
||||
|
||||
if (user) {
|
||||
//
|
||||
const subscribedUser = await verifySubscription(user);
|
||||
}
|
||||
}
|
||||
|
||||
42
pages/api/v1/public/collections/tags/index.ts
Normal file
42
pages/api/v1/public/collections/tags/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import getTags from "@/lib/api/controllers/tags/getTags";
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import { LinkRequestQuery } from "@/types/global";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function collections(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method === "GET") {
|
||||
// Convert the type of the request query to "LinkRequestQuery"
|
||||
const convertedData: Omit<LinkRequestQuery, "tagId"> = {
|
||||
sort: Number(req.query.sort as string),
|
||||
collectionId: req.query.collectionId
|
||||
? Number(req.query.collectionId as string)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (!convertedData.collectionId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ response: "Please choose a valid collection." });
|
||||
}
|
||||
|
||||
const collection = await prisma.collection.findFirst({
|
||||
where: {
|
||||
id: convertedData.collectionId,
|
||||
isPublic: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!collection) {
|
||||
return res.status(404).json({ response: "Collection not found." });
|
||||
}
|
||||
|
||||
const tags = await getTags({
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
return res.status(tags?.status || 500).json({ response: tags?.response });
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ export default async function tags(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!user) return;
|
||||
|
||||
if (req.method === "GET") {
|
||||
const tags = await getTags(user.id);
|
||||
return res.status(tags.status).json({ response: tags.response });
|
||||
const tags = await getTags({
|
||||
userId: user.id,
|
||||
});
|
||||
return res.status(tags?.status || 500).json({ response: tags?.response });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import useCollectionStore from "@/store/collections";
|
||||
import useLinkStore from "@/store/links";
|
||||
import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
Sort,
|
||||
@@ -9,23 +7,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();
|
||||
@@ -33,25 +30,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({
|
||||
id: null as unknown as number,
|
||||
@@ -65,20 +66,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 as string,
|
||||
image: account.image as string,
|
||||
archiveAsScreenshot: account.archiveAsScreenshot as boolean,
|
||||
archiveAsMonolith: account.archiveAsScreenshot as boolean,
|
||||
archiveAsPDF: account.archiveAsPDF as boolean,
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -97,19 +98,10 @@ 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
|
||||
@@ -142,7 +134,22 @@ 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">
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
for (const link of links) {
|
||||
if (link.url) window.open(link.url, "_blank");
|
||||
}
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("open_all_links")}
|
||||
</div>
|
||||
</li>
|
||||
{permissions === true && (
|
||||
<li>
|
||||
<div
|
||||
@@ -152,6 +159,7 @@ export default function Index() {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setEditCollectionModal(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("edit_collection_info")}
|
||||
</div>
|
||||
@@ -165,6 +173,7 @@ export default function Index() {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setEditCollectionSharingModal(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{permissions === true
|
||||
? t("share_and_collaborate")
|
||||
@@ -180,6 +189,7 @@ export default function Index() {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setNewCollectionModal(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("create_subcollection")}
|
||||
</div>
|
||||
@@ -193,6 +203,7 @@ export default function Index() {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setDeleteCollectionModal(true);
|
||||
}}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{permissions === true
|
||||
? t("delete_collection")
|
||||
@@ -323,16 +334,14 @@ export default function Index() {
|
||||
</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,36 +1,37 @@
|
||||
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 { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import useWindowDimensions from "@/hooks/useWindowDimensions";
|
||||
import React from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
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";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import { useUpdateUser, useUser } from "@/hooks/store/user";
|
||||
import SurveyModal from "@/components/ModalContent/SurveyModal";
|
||||
|
||||
export default function Dashboard() {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useCollectionStore();
|
||||
const { links } = useLinkStore();
|
||||
const { tags } = useTagStore();
|
||||
const { data: collections = [] } = useCollections();
|
||||
const {
|
||||
data: { links = [], numberOfPinnedLinks } = { links: [] },
|
||||
...dashboardData
|
||||
} = useDashboardData();
|
||||
const { data: tags = [] } = useTags();
|
||||
const { data: account = [] } = useUser();
|
||||
|
||||
const [numberOfLinks, setNumberOfLinks] = useState(0);
|
||||
|
||||
const [showLinks, setShowLinks] = useState(3);
|
||||
|
||||
useLinks({ pinnedOnly: true, sort: 0 });
|
||||
const { settings } = useLocalSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
setNumberOfLinks(
|
||||
@@ -42,29 +43,44 @@ export default function Dashboard() {
|
||||
);
|
||||
}, [collections]);
|
||||
|
||||
const handleNumberOfLinksToShow = () => {
|
||||
if (window.innerWidth > 1900) {
|
||||
setShowLinks(10);
|
||||
} else if (window.innerWidth > 1500) {
|
||||
setShowLinks(8);
|
||||
} else if (window.innerWidth > 880) {
|
||||
setShowLinks(6);
|
||||
} else if (window.innerWidth > 550) {
|
||||
setShowLinks(4);
|
||||
} else setShowLinks(2);
|
||||
};
|
||||
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
useEffect(() => {
|
||||
handleNumberOfLinksToShow();
|
||||
}, [width]);
|
||||
if (
|
||||
process.env.NEXT_PUBLIC_STRIPE === "true" &&
|
||||
account &&
|
||||
account.id &&
|
||||
account.referredBy === null &&
|
||||
// if user is using Linkwarden for more than 3 days
|
||||
new Date().getTime() - new Date(account.createdAt).getTime() >
|
||||
3 * 24 * 60 * 60 * 1000
|
||||
) {
|
||||
setTimeout(() => {
|
||||
setShowsSurveyModal(true);
|
||||
}, 1000);
|
||||
}
|
||||
}, [account]);
|
||||
|
||||
const importBookmarks = async (e: any, format: MigrationFormat) => {
|
||||
const file: File = e.target.files[0];
|
||||
const numberOfLinksToShow = useMemo(() => {
|
||||
if (window.innerWidth > 1900) {
|
||||
return 10;
|
||||
} else if (window.innerWidth > 1500) {
|
||||
return 8;
|
||||
} else if (window.innerWidth > 880) {
|
||||
return 6;
|
||||
} else if (window.innerWidth > 550) {
|
||||
return 4;
|
||||
} else {
|
||||
return 2;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const importBookmarks = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
format: MigrationFormat
|
||||
) => {
|
||||
const file: File | null = e.target.files && e.target.files[0];
|
||||
|
||||
if (file) {
|
||||
var reader = new FileReader();
|
||||
const reader = new FileReader();
|
||||
reader.readAsText(file, "UTF-8");
|
||||
reader.onload = async function (e) {
|
||||
const load = toast.loading("Importing...");
|
||||
@@ -76,42 +92,89 @@ export default function Dashboard() {
|
||||
data: request,
|
||||
};
|
||||
|
||||
const response = await fetch("/api/v1/migration", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
try {
|
||||
const response = await fetch("/api/v1/migration", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
toast.dismiss(load);
|
||||
|
||||
toast.dismiss(load);
|
||||
toast.error(
|
||||
errorData.response ||
|
||||
"Failed to import bookmarks. Please try again."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Imported the Bookmarks! Reloading the page...");
|
||||
await response.json();
|
||||
toast.dismiss(load);
|
||||
toast.success("Imported the Bookmarks! Reloading the page...");
|
||||
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 2000);
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error("Request failed", error);
|
||||
toast.dismiss(load);
|
||||
toast.error(
|
||||
"An error occurred while importing bookmarks. Please check the logs for more info."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = function (e) {
|
||||
console.log("Error:", e);
|
||||
console.log("Error reading file:", e);
|
||||
toast.error(
|
||||
"Failed to read the file. Please make sure the file is correct and try again."
|
||||
);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
const [showSurveyModal, setShowsSurveyModal] = useState(false);
|
||||
|
||||
// @ts-ignore
|
||||
const LinkComponent = linkView[viewMode];
|
||||
const { data: user } = useUser();
|
||||
const updateUser = useUpdateUser();
|
||||
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
|
||||
const submitSurvey = async (referer: string, other?: string) => {
|
||||
if (submitLoader) return;
|
||||
|
||||
setSubmitLoader(true);
|
||||
|
||||
const load = toast.loading(t("applying"));
|
||||
|
||||
await updateUser.mutateAsync(
|
||||
{
|
||||
...user,
|
||||
referredBy: referer === "other" ? "Other: " + other : referer,
|
||||
},
|
||||
{
|
||||
onSettled: (data, error) => {
|
||||
console.log(data, error);
|
||||
setSubmitLoader(false);
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.success(t("thanks_for_feedback"));
|
||||
setShowsSurveyModal(false);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
@@ -125,32 +188,30 @@ export default function Dashboard() {
|
||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-evenly flex-col xl:flex-row xl:items-center gap-2 xl:w-full h-full rounded-2xl p-8 border border-neutral-content bg-base-200">
|
||||
<DashboardItem
|
||||
name={numberOfLinks === 1 ? t("link") : t("links")}
|
||||
value={numberOfLinks}
|
||||
icon={"bi-link-45deg"}
|
||||
/>
|
||||
<div className="xl:flex flex flex-col sm:grid grid-cols-2 gap-3 xl:flex-row xl:justify-evenly xl:w-full h-full">
|
||||
<DashboardItem
|
||||
name={numberOfLinks === 1 ? t("link") : t("links")}
|
||||
value={numberOfLinks}
|
||||
icon={"bi-link-45deg"}
|
||||
/>
|
||||
|
||||
<div className="divider xl:divider-horizontal"></div>
|
||||
<DashboardItem
|
||||
name={collections.length === 1 ? t("collection") : t("collections")}
|
||||
value={collections.length}
|
||||
icon={"bi-folder"}
|
||||
/>
|
||||
|
||||
<DashboardItem
|
||||
name={
|
||||
collections.length === 1 ? t("collection") : t("collections")
|
||||
}
|
||||
value={collections.length}
|
||||
icon={"bi-folder"}
|
||||
/>
|
||||
<DashboardItem
|
||||
name={tags.length === 1 ? t("tag") : t("tags")}
|
||||
value={tags.length}
|
||||
icon={"bi-hash"}
|
||||
/>
|
||||
|
||||
<div className="divider xl:divider-horizontal"></div>
|
||||
|
||||
<DashboardItem
|
||||
name={tags.length === 1 ? t("tag") : t("tags")}
|
||||
value={tags.length}
|
||||
icon={"bi-hash"}
|
||||
/>
|
||||
</div>
|
||||
<DashboardItem
|
||||
name={t("pinned")}
|
||||
value={numberOfPinnedLinks}
|
||||
icon={"bi-pin-angle"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -171,15 +232,31 @@ export default function Dashboard() {
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ flex: links[0] ? "0 1 auto" : "1 1 auto" }}
|
||||
style={{
|
||||
flex: links || 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={settings.columns || 1}
|
||||
useData={dashboardData}
|
||||
/>
|
||||
</div>
|
||||
) : links && links[0] && !dashboardData.isLoading ? (
|
||||
<div className="w-full">
|
||||
<Links
|
||||
links={links.slice(
|
||||
0,
|
||||
settings.columns ? settings.columns * 2 : numberOfLinksToShow
|
||||
)}
|
||||
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">
|
||||
<div className="flex flex-col justify-center h-full border border-solid border-neutral-content w-full mx-auto p-10 rounded-2xl bg-base-200 bg-gradient-to-tr from-neutral-content/70 to-50% to-base-200">
|
||||
<p className="text-center text-2xl">
|
||||
{t("view_added_links_here")}
|
||||
</p>
|
||||
@@ -211,13 +288,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
|
||||
@@ -238,6 +316,7 @@ export default function Dashboard() {
|
||||
role="button"
|
||||
htmlFor="import-html-file"
|
||||
title={t("from_html")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("from_html")}
|
||||
<input
|
||||
@@ -258,6 +337,7 @@ export default function Dashboard() {
|
||||
role="button"
|
||||
htmlFor="import-wallabag-file"
|
||||
title={t("from_wallabag")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t("from_wallabag")}
|
||||
<input
|
||||
@@ -300,18 +380,32 @@ 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
|
||||
layout={viewMode}
|
||||
placeholderCount={settings.columns || 1}
|
||||
useData={dashboardData}
|
||||
/>
|
||||
</div>
|
||||
) : links?.some((e: any) => e.pinnedBy && e.pinnedBy[0]) ? (
|
||||
<div className="w-full">
|
||||
<Links
|
||||
links={links
|
||||
.filter((e) => e.pinnedBy && e.pinnedBy[0])
|
||||
.slice(0, showLinks)}
|
||||
.filter((e: any) => e.pinnedBy && e.pinnedBy[0])
|
||||
.slice(
|
||||
0,
|
||||
settings.columns
|
||||
? settings.columns * 2
|
||||
: numberOfLinksToShow
|
||||
)}
|
||||
layout={viewMode}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{ flex: "1 1 auto" }}
|
||||
className="flex flex-col gap-2 justify-center h-full border border-solid border-neutral-content w-full mx-auto p-10 rounded-2xl bg-base-200"
|
||||
className="flex flex-col gap-2 justify-center h-full border border-solid border-neutral-content w-full mx-auto p-10 rounded-2xl bg-base-200 bg-gradient-to-tr from-neutral-content/70 to-50% to-base-200"
|
||||
>
|
||||
<i className="bi-pin mx-auto text-6xl text-primary"></i>
|
||||
<p className="text-center text-2xl">
|
||||
@@ -324,9 +418,15 @@ export default function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{newLinkModal ? (
|
||||
<NewLinkModal onClose={() => setNewLinkModal(false)} />
|
||||
) : undefined}
|
||||
{showSurveyModal && (
|
||||
<SurveyModal
|
||||
submit={submitSurvey}
|
||||
onClose={() => {
|
||||
setShowsSurveyModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,67 +1,70 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import React, { useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
ArchivedFormat,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@/types/global";
|
||||
import { ArchivedFormat } from "@/types/global";
|
||||
import ReadableView from "@/components/ReadableView";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useGetLink } from "@/hooks/store/links";
|
||||
import clsx from "clsx";
|
||||
|
||||
export default function Index() {
|
||||
const { links, getLink } = useLinkStore();
|
||||
|
||||
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
|
||||
const getLink = useGetLink();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLink = async () => {
|
||||
if (router.query.id) {
|
||||
await getLink(Number(router.query.id));
|
||||
}
|
||||
};
|
||||
|
||||
fetchLink();
|
||||
if (router.query.id) {
|
||||
getLink.mutateAsync({ id: Number(router.query.id) });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (links[0]) setLink(links.find((e) => e.id === Number(router.query.id)));
|
||||
}, [links]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className={clsx(getLink.isPending ? "flex h-screen" : "relative")}>
|
||||
{/* <div className="fixed left-1/2 transform -translate-x-1/2 w-fit py-1 px-3 bg-base-200 border border-neutral-content rounded-md">
|
||||
Readable
|
||||
</div> */}
|
||||
{link && Number(router.query.format) === ArchivedFormat.readability && (
|
||||
<ReadableView link={link} />
|
||||
)}
|
||||
{link && Number(router.query.format) === ArchivedFormat.monolith && (
|
||||
{getLink.data?.id &&
|
||||
Number(router.query.format) === ArchivedFormat.readability ? (
|
||||
<ReadableView link={getLink.data} />
|
||||
) : getLink.data?.id &&
|
||||
Number(router.query.format) === ArchivedFormat.monolith ? (
|
||||
<iframe
|
||||
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.monolith}`}
|
||||
src={`/api/v1/archives/${getLink.data.id}?format=${ArchivedFormat.monolith}`}
|
||||
className="w-full h-screen border-none"
|
||||
></iframe>
|
||||
)}
|
||||
{link && Number(router.query.format) === ArchivedFormat.pdf && (
|
||||
) : getLink.data?.id &&
|
||||
Number(router.query.format) === ArchivedFormat.pdf ? (
|
||||
<iframe
|
||||
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.pdf}`}
|
||||
src={`/api/v1/archives/${getLink.data.id}?format=${ArchivedFormat.pdf}`}
|
||||
className="w-full h-screen border-none"
|
||||
></iframe>
|
||||
)}
|
||||
{link && Number(router.query.format) === ArchivedFormat.png && (
|
||||
) : getLink.data?.id &&
|
||||
Number(router.query.format) === ArchivedFormat.png ? (
|
||||
<img
|
||||
alt=""
|
||||
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.png}`}
|
||||
src={`/api/v1/archives/${getLink.data.id}?format=${ArchivedFormat.png}`}
|
||||
className="w-fit mx-auto"
|
||||
/>
|
||||
)}
|
||||
{link && Number(router.query.format) === ArchivedFormat.jpeg && (
|
||||
) : getLink.data?.id &&
|
||||
Number(router.query.format) === ArchivedFormat.jpeg ? (
|
||||
<img
|
||||
alt=""
|
||||
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}`}
|
||||
src={`/api/v1/archives/${getLink.data.id}?format=${ArchivedFormat.jpeg}`}
|
||||
className="w-fit mx-auto"
|
||||
/>
|
||||
) : getLink.error ? (
|
||||
<p>404 - Not found</p>
|
||||
) : (
|
||||
<div className="max-w-3xl p-5 m-auto w-full flex flex-col items-center gap-5">
|
||||
<div className="w-full mr-auto h-4 skeleton rounded-md"></div>
|
||||
<div className="w-full mr-auto h-4 skeleton rounded-md"></div>
|
||||
<div className="w-full mr-auto h-4 skeleton rounded-md"></div>
|
||||
<div className="w-3/4 mr-auto h-4 skeleton rounded-md"></div>
|
||||
<div className="w-5/6 mr-auto h-4 skeleton rounded-md"></div>
|
||||
<div className="w-3/4 mr-auto h-4 skeleton rounded-md"></div>
|
||||
<div className="w-full mr-auto h-4 skeleton rounded-md"></div>
|
||||
<div className="w-full mr-auto h-4 skeleton rounded-md"></div>
|
||||
<div className="w-5/6 mr-auto h-4 skeleton rounded-md"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import getPublicCollectionData from "@/lib/client/getPublicCollectionData";
|
||||
import {
|
||||
AccountSettings,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
Sort,
|
||||
ViewMode,
|
||||
@@ -8,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";
|
||||
@@ -18,33 +17,49 @@ 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 { usePublicLinks } from "@/hooks/store/publicLinks";
|
||||
import Links from "@/components/LinkViews/Links";
|
||||
import { usePublicTags } from "@/hooks/store/publicTags";
|
||||
|
||||
export default function PublicCollections() {
|
||||
const { t } = useTranslation();
|
||||
const { links } = useLinkStore();
|
||||
|
||||
const { settings } = useLocalSettingsStore();
|
||||
|
||||
const { collections } = useCollectionStore();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [collectionOwner, setCollectionOwner] = useState({
|
||||
id: null as unknown as number,
|
||||
name: "",
|
||||
username: "",
|
||||
image: "",
|
||||
archiveAsScreenshot: undefined as unknown as boolean,
|
||||
archiveAsMonolith: undefined as unknown as boolean,
|
||||
archiveAsPDF: undefined as unknown as boolean,
|
||||
});
|
||||
const [collectionOwner, setCollectionOwner] = useState<
|
||||
Partial<AccountSettings>
|
||||
>({});
|
||||
|
||||
const handleTagSelection = (tag: string | undefined) => {
|
||||
if (tag) {
|
||||
Object.keys(searchFilter).forEach(
|
||||
(v) =>
|
||||
(searchFilter[
|
||||
v as keyof {
|
||||
name: boolean;
|
||||
url: boolean;
|
||||
description: boolean;
|
||||
tags: boolean;
|
||||
textContent: boolean;
|
||||
}
|
||||
] = false)
|
||||
);
|
||||
searchFilter.tags = true;
|
||||
return router.push(
|
||||
"/public/collections/" +
|
||||
router.query.id +
|
||||
"?q=" +
|
||||
encodeURIComponent(tag || "")
|
||||
);
|
||||
} else {
|
||||
return router.push("/public/collections/" + router.query.id);
|
||||
}
|
||||
};
|
||||
|
||||
const [searchFilter, setSearchFilter] = useState({
|
||||
name: true,
|
||||
@@ -54,9 +69,13 @@ 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 { data: tags } = usePublicTags();
|
||||
|
||||
const { links, data } = usePublicLinks({
|
||||
sort: sortBy,
|
||||
searchQueryString: router.query.q
|
||||
? decodeURIComponent(router.query.q as string)
|
||||
@@ -67,197 +86,233 @@ export default function PublicCollections() {
|
||||
searchByTextContent: searchFilter.textContent,
|
||||
searchByTags: searchFilter.tags,
|
||||
});
|
||||
|
||||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>();
|
||||
|
||||
useEffect(() => {
|
||||
if (router.query.id) {
|
||||
getPublicCollectionData(Number(router.query.id), setCollection);
|
||||
getPublicCollectionData(Number(router.query.id)).then((res) => {
|
||||
if (res.status === 400) {
|
||||
router.push("/dashboard");
|
||||
} else {
|
||||
setCollection(res.response);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [collections]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOwner = async () => {
|
||||
if (collection) {
|
||||
const owner = await getPublicUserData(collection.ownerId as number);
|
||||
setCollectionOwner(owner);
|
||||
}
|
||||
};
|
||||
|
||||
fetchOwner();
|
||||
if (collection) {
|
||||
getPublicUserData(collection.ownerId as number).then((owner) =>
|
||||
setCollectionOwner(owner)
|
||||
);
|
||||
}
|
||||
}, [collection]);
|
||||
|
||||
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 = {
|
||||
[ViewMode.Card]: CardView,
|
||||
[ViewMode.List]: ListView,
|
||||
[ViewMode.Masonry]: MasonryView,
|
||||
};
|
||||
if (!collection) return <></>;
|
||||
else
|
||||
return (
|
||||
<div
|
||||
className="h-96"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(${collection?.color}30 10%, ${
|
||||
settings.theme === "dark" ? "#262626" : "#f3f4f6"
|
||||
} 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
|
||||
}}
|
||||
>
|
||||
{collection && (
|
||||
<Head>
|
||||
<title>{collection.name} | Linkwarden</title>
|
||||
<meta
|
||||
property="og:title"
|
||||
content={`${collection.name} | Linkwarden`}
|
||||
key="title"
|
||||
/>
|
||||
</Head>
|
||||
)}
|
||||
<div className="lg:w-3/4 w-full mx-auto p-5 bg">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-4xl font-thin mb-2 capitalize mt-10">
|
||||
{collection.name}
|
||||
</p>
|
||||
<div className="flex gap-2 items-center mt-8 min-w-fit">
|
||||
<ToggleDarkMode />
|
||||
|
||||
// @ts-ignore
|
||||
const LinkComponent = linkView[viewMode];
|
||||
|
||||
return collection ? (
|
||||
<div
|
||||
className="h-96"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(${collection?.color}30 10%, ${
|
||||
settings.theme === "dark" ? "#262626" : "#f3f4f6"
|
||||
} 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
|
||||
}}
|
||||
>
|
||||
{collection ? (
|
||||
<Head>
|
||||
<title>{collection.name} | Linkwarden</title>
|
||||
<meta
|
||||
property="og:title"
|
||||
content={`${collection.name} | Linkwarden`}
|
||||
key="title"
|
||||
/>
|
||||
</Head>
|
||||
) : undefined}
|
||||
<div className="lg:w-3/4 w-full mx-auto p-5 bg">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-4xl font-thin mb-2 capitalize mt-10">
|
||||
{collection.name}
|
||||
</p>
|
||||
<div className="flex gap-2 items-center mt-8 min-w-fit">
|
||||
<ToggleDarkMode />
|
||||
|
||||
<Link href="https://linkwarden.app/" target="_blank">
|
||||
<Image
|
||||
src={`/icon.png`}
|
||||
width={551}
|
||||
height={551}
|
||||
alt="Linkwarden"
|
||||
title={t("list_created_with_linkwarden")}
|
||||
className="h-8 w-fit mx-auto rounded"
|
||||
/>
|
||||
</Link>
|
||||
<Link href="https://linkwarden.app/" target="_blank">
|
||||
<Image
|
||||
src={`/icon.png`}
|
||||
width={551}
|
||||
height={551}
|
||||
alt="Linkwarden"
|
||||
title={t("list_created_with_linkwarden")}
|
||||
className="h-8 w-fit mx-auto rounded"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className={`min-w-[15rem]`}>
|
||||
<div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
|
||||
<div
|
||||
className="flex items-center btn px-2 btn-ghost rounded-full"
|
||||
onClick={() => setEditCollectionSharingModal(true)}
|
||||
>
|
||||
{collectionOwner.id ? (
|
||||
<ProfilePhoto
|
||||
src={collectionOwner.image || undefined}
|
||||
name={collectionOwner.name}
|
||||
/>
|
||||
) : undefined}
|
||||
{collection.members
|
||||
.sort((a, b) => (a.userId as number) - (b.userId as number))
|
||||
.map((e, i) => {
|
||||
return (
|
||||
<ProfilePhoto
|
||||
key={i}
|
||||
src={e.user.image ? e.user.image : undefined}
|
||||
className="-ml-3"
|
||||
name={e.user.name}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.slice(0, 3)}
|
||||
{collection.members.length - 3 > 0 ? (
|
||||
<div className={`avatar drop-shadow-md placeholder -ml-3`}>
|
||||
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
|
||||
<span>+{collection.members.length - 3}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="text-neutral text-sm">
|
||||
{collection.members.length > 0 &&
|
||||
collection.members.length === 1
|
||||
? t("by_author_and_other", {
|
||||
author: collectionOwner.name,
|
||||
count: collection.members.length,
|
||||
<div className="mt-3">
|
||||
<div className={`min-w-[15rem]`}>
|
||||
<div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
|
||||
<div
|
||||
className="flex items-center btn px-2 btn-ghost rounded-full"
|
||||
onClick={() => setEditCollectionSharingModal(true)}
|
||||
>
|
||||
{collectionOwner.id && (
|
||||
<ProfilePhoto
|
||||
src={collectionOwner.image || undefined}
|
||||
name={collectionOwner.name}
|
||||
/>
|
||||
)}
|
||||
{collection.members
|
||||
.sort((a, b) => (a.userId as number) - (b.userId as number))
|
||||
.map((e, i) => {
|
||||
return (
|
||||
<ProfilePhoto
|
||||
key={i}
|
||||
src={e.user.image ? e.user.image : undefined}
|
||||
className="-ml-3"
|
||||
name={e.user.name}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: collection.members.length > 0 &&
|
||||
collection.members.length !== 1
|
||||
? t("by_author_and_others", {
|
||||
.slice(0, 3)}
|
||||
{collection.members.length - 3 > 0 && (
|
||||
<div className={`avatar drop-shadow-md placeholder -ml-3`}>
|
||||
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
|
||||
<span>+{collection.members.length - 3}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-neutral text-sm">
|
||||
{collection.members.length > 0 &&
|
||||
collection.members.length === 1
|
||||
? t("by_author_and_other", {
|
||||
author: collectionOwner.name,
|
||||
count: collection.members.length,
|
||||
})
|
||||
: t("by_author", {
|
||||
author: collectionOwner.name,
|
||||
})}
|
||||
</p>
|
||||
: 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,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-5">{collection.description}</p>
|
||||
<p className="mt-5">{collection.description}</p>
|
||||
|
||||
<div className="divider mt-5 mb-0"></div>
|
||||
<div className="divider mt-5 mb-0"></div>
|
||||
|
||||
<div className="flex mb-5 mt-10 flex-col gap-5">
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
searchFilter={searchFilter}
|
||||
setSearchFilter={setSearchFilter}
|
||||
>
|
||||
<SearchBar
|
||||
placeholder={
|
||||
collection._count?.links === 1
|
||||
? t("search_count_link", {
|
||||
count: collection._count?.links,
|
||||
})
|
||||
: t("search_count_links", {
|
||||
count: collection._count?.links,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</LinkListOptions>
|
||||
|
||||
{links[0] ? (
|
||||
<LinkComponent
|
||||
links={links
|
||||
.filter((e) => e.collectionId === Number(router.query.id))
|
||||
.map((e, i) => {
|
||||
<div className="flex mb-5 mt-10 flex-col gap-5">
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
searchFilter={searchFilter}
|
||||
setSearchFilter={setSearchFilter}
|
||||
>
|
||||
<SearchBar
|
||||
placeholder={
|
||||
collection._count?.links === 1
|
||||
? t("search_count_link", {
|
||||
count: collection._count?.links,
|
||||
})
|
||||
: t("search_count_links", {
|
||||
count: collection._count?.links,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</LinkListOptions>
|
||||
{tags && tags[0] && (
|
||||
<div className="flex gap-2 mt-2 mb-6 flex-wrap">
|
||||
<button
|
||||
className="max-w-full"
|
||||
onClick={() => handleTagSelection(undefined)}
|
||||
>
|
||||
<div
|
||||
className={`${
|
||||
!router.query.q
|
||||
? "bg-primary/20"
|
||||
: "bg-neutral-content/20 hover:bg-neutral/20"
|
||||
} duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 rounded-md h-8`}
|
||||
>
|
||||
<p className="truncate px-3">{t("all_links")}</p>
|
||||
</div>
|
||||
</button>
|
||||
{tags
|
||||
.map((t) => t.name)
|
||||
.filter((item, pos, self) => self.indexOf(item) === pos)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((e, i) => {
|
||||
const active = router.query.q === e;
|
||||
return (
|
||||
<button
|
||||
className="max-w-full"
|
||||
key={i}
|
||||
onClick={() => handleTagSelection(e)}
|
||||
>
|
||||
<div
|
||||
className={`${
|
||||
active
|
||||
? "bg-primary/20"
|
||||
: "bg-neutral-content/20 hover:bg-neutral/20"
|
||||
} duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 rounded-md h-8`}
|
||||
>
|
||||
<i className="bi-hash text-2xl text-primary drop-shadow"></i>
|
||||
<p className="truncate pr-3">{e}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Links
|
||||
links={
|
||||
links?.map((e, i) => {
|
||||
const linkWithCollectionData = {
|
||||
...e,
|
||||
collection: collection, // Append collection data
|
||||
};
|
||||
return linkWithCollectionData;
|
||||
})}
|
||||
}) as any
|
||||
}
|
||||
layout={viewMode}
|
||||
placeholderCount={1}
|
||||
useData={data}
|
||||
/>
|
||||
) : (
|
||||
<p>{t("collection_is_empty")}</p>
|
||||
)}
|
||||
{!data.isLoading && links && !links[0] && (
|
||||
<p>{t("nothing_found")}</p>
|
||||
)}
|
||||
|
||||
{/* <p className="text-center text-neutral">
|
||||
{/* <p className="text-center text-neutral">
|
||||
List created with <span className="text-black">Linkwarden.</span>
|
||||
</p> */}
|
||||
</div>
|
||||
</div>
|
||||
{editCollectionSharingModal && (
|
||||
<EditCollectionSharingModal
|
||||
onClose={() => setEditCollectionSharingModal(false)}
|
||||
activeCollection={collection}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{editCollectionSharingModal ? (
|
||||
<EditCollectionSharingModal
|
||||
onClose={() => setEditCollectionSharingModal(false)}
|
||||
activeCollection={collection}
|
||||
/>
|
||||
) : undefined}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user