Compare commits

..

3 Commits

Author SHA1 Message Date
Daniel ae6656e0ec Merge pull request #386 from treyg/global-theming-2.4
Global theming support
2024-01-02 07:30:01 -05:00
Trey Gordon 7e9eae0ef2 style: change to neutral to handle new themes 2023-12-29 12:29:10 -05:00
Trey Gordon 6b28abc405 feat: add new theming options 2023-12-29 12:28:43 -05:00
130 changed files with 1554 additions and 4812 deletions
+1 -11
View File
@@ -1,5 +1,5 @@
NEXTAUTH_SECRET=very_sensitive_secret NEXTAUTH_SECRET=very_sensitive_secret
NEXTAUTH_URL=http://localhost:3000/api/v1/auth NEXTAUTH_URL=http://localhost:3000
# Manual installation database settings # Manual installation database settings
DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
@@ -20,7 +20,6 @@ MAX_LINKS_PER_USER=
ARCHIVE_TAKE_COUNT= ARCHIVE_TAKE_COUNT=
BROWSER_TIMEOUT= BROWSER_TIMEOUT=
IGNORE_UNAUTHORIZED_CA= IGNORE_UNAUTHORIZED_CA=
IGNORE_HTTPS_ERRORS=
# AWS S3 Settings # AWS S3 Settings
SPACES_KEY= SPACES_KEY=
@@ -35,15 +34,6 @@ NEXT_PUBLIC_EMAIL_PROVIDER=
EMAIL_FROM= EMAIL_FROM=
EMAIL_SERVER= EMAIL_SERVER=
# Proxy settings
PROXY=
PROXY_USERNAME=
PROXY_PASSWORD=
PROXY_BYPASS=
# PDF archive settings
PDF_MARGIN_TOP=
PDF_MARGIN_BOTTOM=
# #
# SSO Providers # SSO Providers
-1
View File
@@ -47,4 +47,3 @@ prisma/dev.db
# docker # docker
pgdata pgdata
certificates
+1 -1
View File
@@ -9,7 +9,7 @@ WORKDIR /data
COPY ./package.json ./yarn.lock ./playwright.config.ts ./ COPY ./package.json ./yarn.lock ./playwright.config.ts ./
# Increase timeout to pass github actions arm64 build # Increase timeout to pass github actions arm64 build
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn yarn install --network-timeout 10000000 RUN yarn install --network-timeout 10000000
RUN npx playwright install-deps && \ RUN npx playwright install-deps && \
apt-get clean && \ apt-get clean && \
+2 -8
View File
@@ -17,9 +17,7 @@
## Intro & motivation ## Intro & motivation
**Linkwarden is a self-hosted, open-source collaborative bookmark manager to collect, organize and archive webpages.** **Linkwarden is a self-hosted, open-source collaborative bookmark manager to collect, organize and archive webpages.** The objective is to organize useful webpages and articles you find across the web in one place, and since useful webpages can go away (see the inevitability of [Link Rot](https://www.howtogeek.com/786227/what-is-link-rot-and-how-does-it-threaten-the-web/)), Linkwarden also saves a copy of each webpage as a Screenshot and PDF, ensuring accessibility even if the original content is no longer available.
The objective is to organize useful webpages and articles you find across the web in one place, and since useful webpages can go away (see the inevitability of [Link Rot](https://www.howtogeek.com/786227/what-is-link-rot-and-how-does-it-threaten-the-web/)), Linkwarden also saves a copy of each webpage as a Screenshot and PDF, ensuring accessibility even if the original content is no longer available.
Additionally, Linkwarden is designed with collaboration in mind, sharing links with the public and/or allowing multiple users to work together seamlessly. Additionally, Linkwarden is designed with collaboration in mind, sharing links with the public and/or allowing multiple users to work together seamlessly.
@@ -59,7 +57,7 @@ We've forked the old version from the current repository into [this repo](https:
- 📸 Auto capture a screenshot, PDF, and readable view of each webpage. - 📸 Auto capture a screenshot, PDF, and readable view of each webpage.
- 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional) - 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional)
- 📂 Organize links by collection, sub-collection, name, description and multiple tags. - 📂 Organize links by collection, name, description and multiple tags.
- 👥 Collaborate on gathering links in a collection. - 👥 Collaborate on gathering links in a collection.
- 🎛️ Customize the permissions of each member. - 🎛️ Customize the permissions of each member.
- 🌐 Share your collected links and preserved formats with the world. - 🌐 Share your collected links and preserved formats with the world.
@@ -70,10 +68,6 @@ We've forked the old version from the current repository into [this repo](https:
- 🧩 Browser extension, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension) - 🧩 Browser extension, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension)
- ⬇️ Import and export your bookmarks. - ⬇️ Import and export your bookmarks.
- 🔐 SSO integration. (Enterprise and Self-hosted users only) - 🔐 SSO integration. (Enterprise and Self-hosted users only)
- 📦 Installable Progressive Web App (PWA).
- 🍎 iOS Shortcut to save links to Linkwarden.
- 🔑 API keys.
- ✅ Bulk actions.
- ✨ And so many more features! - ✨ And so many more features!
## Like what we're doing? Give us a Star ⭐ ## Like what we're doing? Give us a Star ⭐
+2 -2
View File
@@ -12,11 +12,11 @@ export default function AnnouncementBar({ toggleAnnouncementBar }: Props) {
<div className="w-fit font-semibold"> <div className="w-fit font-semibold">
🎉 See what&apos;s new in{" "} 🎉 See what&apos;s new in{" "}
<Link <Link
href="https://blog.linkwarden.app/releases/v2.5" href="https://blog.linkwarden.app/releases/v2.4"
target="_blank" target="_blank"
className="underline hover:opacity-50 duration-100" className="underline hover:opacity-50 duration-100"
> >
Linkwarden v2.5 Linkwarden v2.4
</Link> </Link>
! 🥳 ! 🥳
</div> </div>
+1 -3
View File
@@ -9,7 +9,6 @@ import useAccountStore from "@/store/account";
import EditCollectionModal from "./ModalContent/EditCollectionModal"; import EditCollectionModal from "./ModalContent/EditCollectionModal";
import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal"; import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal";
import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal"; import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal";
import { dropdownTriggerer } from "@/lib/client/utils";
type Props = { type Props = {
collection: CollectionIncludingMembersAndLinkCount; collection: CollectionIncludingMembersAndLinkCount;
@@ -71,7 +70,6 @@ export default function CollectionCard({ collection, className }: Props) {
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-ghost btn-sm btn-square text-neutral" className="btn btn-ghost btn-sm btn-square text-neutral"
> >
<i className="bi-three-dots text-xl" title="More"></i> <i className="bi-three-dots text-xl" title="More"></i>
@@ -172,7 +170,7 @@ export default function CollectionCard({ collection, className }: Props) {
<div className="font-bold text-sm flex justify-end gap-1 items-center"> <div className="font-bold text-sm flex justify-end gap-1 items-center">
{collection.isPublic ? ( {collection.isPublic ? (
<i <i
className="bi-globe2 drop-shadow text-neutral" className="bi-globe-americas drop-shadow text-neutral"
title="This collection is being shared publicly." title="This collection is being shared publicly."
></i> ></i>
) : undefined} ) : undefined}
-365
View File
@@ -1,365 +0,0 @@
import React, { useEffect, useMemo, useState } from "react";
import Tree, {
mutateTree,
moveItemOnTree,
RenderItemParams,
TreeItem,
TreeData,
ItemId,
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";
interface ExtendedTreeItem extends TreeItem {
data: Collection;
}
const CollectionListing = () => {
const { collections, updateCollection } = useCollectionStore();
const { account, updateAccount } = useAccountStore();
const router = useRouter();
const currentPath = router.asPath;
const initialTree = useMemo(() => {
if (collections.length > 0) {
return buildTreeFromCollections(
collections,
router,
account.collectionOrder
);
}
return undefined;
}, [collections, router]);
const [tree, setTree] = useState(initialTree);
useEffect(() => {
setTree(initialTree);
}, [initialTree]);
useEffect(() => {
if (account.username) {
if (
(!account.collectionOrder || account.collectionOrder.length === 0) &&
collections.length > 0
)
updateAccount({
...account,
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
});
else {
const newCollectionOrder: number[] = [
...(account.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) =>
existingCollectionIds.includes(id)
);
// Add new collections that are not in account.collectionOrder and meet the specific conditions
collections.forEach((collection) => {
if (
!filteredCollectionOrder.includes(collection.id as number) &&
(!collection.parentId || collection.ownerId === account.id)
) {
filteredCollectionOrder.push(collection.id as number);
}
});
// check if the newCollectionOrder is the same as the old one
if (
JSON.stringify(newCollectionOrder) !==
JSON.stringify(account.collectionOrder)
) {
updateAccount({
...account,
collectionOrder: newCollectionOrder,
});
}
}
}
}, [collections]);
const onExpand = (movedCollectionId: ItemId) => {
setTree((currentTree) =>
mutateTree(currentTree!, movedCollectionId, { isExpanded: true })
);
};
const onCollapse = (movedCollectionId: ItemId) => {
setTree((currentTree) =>
mutateTree(currentTree as TreeData, movedCollectionId, {
isExpanded: false,
})
);
};
const onDragEnd = async (
source: TreeSourcePosition,
destination: TreeDestinationPosition | undefined
) => {
if (!destination || !tree) {
return;
}
if (
source.index === destination.index &&
source.parentId === destination.parentId
) {
return;
}
const movedCollectionId = Number(
tree.items[source.parentId].children[source.index]
);
const movedCollection = collections.find((c) => c.id === movedCollectionId);
const destinationCollection = collections.find(
(c) => c.id === Number(destination.parentId)
);
if (
(movedCollection?.ownerId !== account.id &&
destination.parentId !== source.parentId) ||
(destinationCollection?.ownerId !== account.id &&
destination.parentId !== "root")
) {
return toast.error(
"You can't make change to a collection you don't own."
);
}
setTree((currentTree) => moveItemOnTree(currentTree!, source, destination));
const updatedCollectionOrder = [...account.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);
}
if (
destination.index !== undefined &&
destination.parentId === source.parentId &&
source.parentId === "root"
) {
updatedCollectionOrder.includes(movedCollectionId) &&
updatedCollectionOrder.splice(source.index, 1);
updatedCollectionOrder.splice(destination.index, 0, movedCollectionId);
await updateAccount({
...account,
collectionOrder: updatedCollectionOrder,
});
} else if (
destination.index !== undefined &&
destination.parentId === "root"
) {
updatedCollectionOrder.splice(destination.index, 0, movedCollectionId);
await updateAccount({
...account,
collectionOrder: updatedCollectionOrder,
});
} else if (
source.parentId === "root" &&
destination.parentId &&
destination.parentId !== "root"
) {
updatedCollectionOrder.splice(source.index, 1);
await updateAccount({
...account,
collectionOrder: updatedCollectionOrder,
});
}
};
if (!tree) {
return <></>;
} else
return (
<Tree
tree={tree}
renderItem={(itemProps) => renderItem({ ...itemProps }, currentPath)}
onExpand={onExpand}
onCollapse={onCollapse}
onDragEnd={onDragEnd}
isDragEnabled
isNestingEnabled
/>
);
};
export default CollectionListing;
const renderItem = (
{ item, onExpand, onCollapse, provided }: RenderItemParams,
currentPath: string
) => {
const collection = item.data;
return (
<div ref={provided.innerRef} {...provided.draggableProps} className="mb-1">
<div
className={`${
currentPath === `/collections/${collection.id}`
? "bg-primary/20 is-active"
: "hover:bg-neutral/20"
} duration-100 flex gap-1 items-center pr-2 pl-1 rounded-md`}
>
{Icon(item as ExtendedTreeItem, onExpand, onCollapse)}
<Link
href={`/collections/${collection.id}`}
className="w-full"
{...provided.dragHandleProps}
>
<div
className={`py-1 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<i
className="bi-folder-fill text-2xl drop-shadow"
style={{ color: collection.color }}
></i>
<p className="truncate w-full">{collection.name}</p>
{collection.isPublic ? (
<i
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
title="This collection is being shared publicly."
></i>
) : undefined}
<div className="drop-shadow text-neutral text-xs">
{collection._count?.links}
</div>
</div>
</Link>
</div>
</div>
);
};
const Icon = (
item: ExtendedTreeItem,
onExpand: (id: ItemId) => void,
onCollapse: (id: ItemId) => void
) => {
if (item.children && item.children.length > 0) {
return item.isExpanded ? (
<button onClick={() => onCollapse(item.id)}>
<div className="bi-caret-down-fill opacity-50 hover:opacity-100 duration-200"></div>
</button>
) : (
<button onClick={() => onExpand(item.id)}>
<div className="bi-caret-right-fill opacity-40 hover:opacity-100 duration-200"></div>
</button>
);
}
// return <span>&bull;</span>;
return <div></div>;
};
const buildTreeFromCollections = (
collections: CollectionIncludingMembersAndLinkCount[],
router: ReturnType<typeof useRouter>,
order?: number[]
): TreeData => {
if (order) {
collections.sort((a: any, b: any) => {
return order.indexOf(a.id) - order.indexOf(b.id);
});
}
const items: { [key: string]: ExtendedTreeItem } = collections.reduce(
(acc: any, collection) => {
acc[collection.id as number] = {
id: collection.id,
children: [],
hasChildren: false,
isExpanded: false,
data: {
id: collection.id,
parentId: collection.parentId,
name: collection.name,
description: collection.description,
color: collection.color,
isPublic: collection.isPublic,
ownerId: collection.ownerId,
createdAt: collection.createdAt,
updatedAt: collection.updatedAt,
_count: {
links: collection._count?.links,
},
},
};
return acc;
},
{}
);
const activeCollectionId = Number(router.asPath.split("/collections/")[1]);
if (activeCollectionId) {
for (const item in items) {
const collection = items[item];
if (Number(item) === activeCollectionId && collection.data.parentId) {
// get all the parents of the active collection recursively until root and set isExpanded to true
let parentId = collection.data.parentId || null;
while (parentId && items[parentId]) {
items[parentId].isExpanded = true;
parentId = items[parentId].data.parentId;
}
}
}
}
collections.forEach((collection) => {
const parentId = collection.parentId;
if (parentId && items[parentId] && collection.id) {
items[parentId].children.push(collection.id);
items[parentId].hasChildren = true;
}
});
const rootId = "root";
items[rootId] = {
id: rootId,
children: (collections
.filter(
(c) =>
c.parentId === null || !collections.find((i) => i.id === c.parentId)
)
.map((c) => c.id) || "") as unknown as string[],
hasChildren: true,
isExpanded: true,
data: { name: "Root" } as Collection,
};
return { rootId, items };
};
+22 -26
View File
@@ -1,4 +1,3 @@
import { dropdownTriggerer } from "@/lib/client/utils";
import React from "react"; import React from "react";
type Props = { type Props = {
@@ -21,12 +20,11 @@ export default function FilterSearchDropdown({
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-sm btn-square btn-ghost" className="btn btn-sm btn-square btn-ghost"
> >
<i className="bi-funnel text-neutral text-2xl"></i> <i className="bi-funnel text-neutral text-2xl"></i>
</div> </div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-56 mt-1"> <ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mt-1">
<li> <li>
<label <label
className="label cursor-pointer flex justify-start" className="label cursor-pointer flex justify-start"
@@ -84,6 +82,27 @@ export default function FilterSearchDropdown({
<span className="label-text">Description</span> <span className="label-text">Description</span>
</label> </label>
</li> </li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.textContent}
onChange={() => {
setSearchFilter({
...searchFilter,
textContent: !searchFilter.textContent,
});
}}
/>
<span className="label-text">Full Content</span>
</label>
</li>
<li> <li>
<label <label
className="label cursor-pointer flex justify-start" className="label cursor-pointer flex justify-start"
@@ -105,29 +124,6 @@ export default function FilterSearchDropdown({
<span className="label-text">Tags</span> <span className="label-text">Tags</span>
</label> </label>
</li> </li>
<li>
<label
className="label cursor-pointer flex justify-between"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.textContent}
onChange={() => {
setSearchFilter({
...searchFilter,
textContent: !searchFilter.textContent,
});
}}
/>
<span className="label-text">Full Content</span>
<div className="ml-auto badge badge-sm badge-neutral">Slower</div>
</label>
</li>
</ul> </ul>
</div> </div>
); );
+15 -88
View File
@@ -4,26 +4,18 @@ import { useEffect, useState } from "react";
import { styles } from "./styles"; import { styles } from "./styles";
import { Options } from "./types"; import { Options } from "./types";
import CreatableSelect from "react-select/creatable"; import CreatableSelect from "react-select/creatable";
import Select from "react-select";
type Props = { type Props = {
onChange: any; onChange: any;
showDefaultValue?: boolean; defaultValue:
defaultValue?:
| { | {
label: string; label: string;
value?: number; value?: number;
} }
| undefined; | undefined;
creatable?: boolean;
}; };
export default function CollectionSelection({ export default function CollectionSelection({ onChange, defaultValue }: Props) {
onChange,
defaultValue,
showDefaultValue = true,
creatable = true,
}: Props) {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const router = useRouter(); const router = useRouter();
@@ -44,87 +36,22 @@ export default function CollectionSelection({
useEffect(() => { useEffect(() => {
const formatedCollections = collections.map((e) => { const formatedCollections = collections.map((e) => {
return { return { value: e.id, label: e.name, ownerId: e.ownerId };
value: e.id,
label: e.name,
ownerId: e.ownerId,
count: e._count,
parentId: e.parentId,
};
}); });
setOptions(formatedCollections); setOptions(formatedCollections);
}, [collections]); }, [collections]);
const getParentNames = (parentId: number): string[] => { return (
const parentNames = []; <CreatableSelect
const parent = collections.find((e) => e.id === parentId); isClearable={false}
className="react-select-container"
if (parent) { classNamePrefix="react-select"
parentNames.push(parent.name); onChange={onChange}
if (parent.parentId) { options={options}
parentNames.push(...getParentNames(parent.parentId)); styles={styles}
} defaultValue={defaultValue}
} // menuPosition="fixed"
/>
// Have the top level parent at beginning );
return parentNames.reverse();
};
const customOption = ({ data, innerProps }: any) => {
return (
<div
{...innerProps}
className="px-2 py-2 last:border-0 border-b border-neutral-content hover:bg-neutral-content cursor-pointer"
>
<div className="flex w-full justify-between items-center">
<span>{data.label}</span>
<span className="text-sm text-neutral">{data.count?.links}</span>
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
{getParentNames(data?.parentId).length > 0 ? (
<>
{getParentNames(data.parentId).join(" > ")} {">"} {data.label}
</>
) : (
data.label
)}
</div>
</div>
);
};
if (creatable) {
return (
<CreatableSelect
isClearable={false}
className="react-select-container"
classNamePrefix="react-select"
onChange={onChange}
options={options}
styles={styles}
defaultValue={showDefaultValue ? defaultValue : null}
components={{
Option: customOption,
}}
// menuPosition="fixed"
/>
);
} else {
return (
<Select
isClearable={false}
className="react-select-container"
classNamePrefix="react-select"
onChange={onChange}
options={options}
styles={styles}
defaultValue={showDefaultValue ? defaultValue : null}
components={{
Option: customOption,
}}
// menuPosition="fixed"
/>
);
}
} }
+1 -24
View File
@@ -1,39 +1,16 @@
import LinkCard from "@/components/LinkViews/LinkCard"; import LinkCard from "@/components/LinkViews/LinkCard";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { link } from "fs";
import { GridLoader } from "react-spinners";
export default function CardView({ export default function CardView({
links, links,
editMode,
isLoading,
}: { }: {
links: LinkIncludingShortenedCollectionAndTags[]; links: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
}) { }) {
return ( return (
<div className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5"> <div className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
{links.map((e, i) => { {links.map((e, i) => {
return ( return <LinkCard key={i} link={e} count={i} />;
<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> </div>
); );
} }
+2 -24
View File
@@ -1,38 +1,16 @@
import LinkList from "@/components/LinkViews/LinkList"; import LinkList from "@/components/LinkViews/LinkList";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { GridLoader } from "react-spinners";
export default function ListView({ export default function ListView({
links, links,
editMode,
isLoading,
}: { }: {
links: LinkIncludingShortenedCollectionAndTags[]; links: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
}) { }) {
return ( return (
<div className="flex gap-1 flex-col"> <div className="flex flex-col">
{links.map((e, i) => { {links.map((e, i) => {
return ( return <LinkList key={i} link={e} count={i} />;
<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> </div>
); );
} }
+28 -66
View File
@@ -14,41 +14,19 @@ import Image from "next/image";
import { previewAvailable } from "@/lib/shared/getArchiveValidity"; import { previewAvailable } from "@/lib/shared/getArchiveValidity";
import Link from "next/link"; import Link from "next/link";
import LinkIcon from "./LinkComponents/LinkIcon"; import LinkIcon from "./LinkComponents/LinkIcon";
import LinkGroupedIconURL from "./LinkComponents/LinkGroupedIconURL";
import useOnScreen from "@/hooks/useOnScreen"; 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";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
count: number; count: number;
className?: string; className?: string;
flipDropdown?: boolean;
editMode?: boolean;
}; };
export default function LinkCard({ link, flipDropdown, editMode }: Props) { export default function LinkGrid({ link, count, className }: Props) {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const { account } = useAccountStore();
const { links, getLink, setSelectedLinks, selectedLinks } = useLinkStore(); const { links, getLink } = 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; let shortendURL;
@@ -75,7 +53,6 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const isVisible = useOnScreen(ref); const isVisible = useOnScreen(ref);
const permissions = usePermissions(collection?.id as number);
useEffect(() => { useEffect(() => {
let interval: any; let interval: any;
@@ -99,36 +76,15 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
const [showInfo, setShowInfo] = useState(false); 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);
// window.open ('www.yourdomain.com', '_ blank');
return ( return (
<div <div
ref={ref} ref={ref}
className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative`} className="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(
"You don't have permission to edit or delete this item."
)
: undefined
}
> >
<div <Link
href={link.url || ""}
target="_blank"
className="rounded-2xl cursor-pointer" className="rounded-2xl cursor-pointer"
onClick={() =>
!editMode && window.open(generateLinkHref(link, account), "_blank")
}
> >
<div className="relative rounded-t-2xl h-40 overflow-hidden"> <div className="relative rounded-t-2xl h-40 overflow-hidden">
{previewAvailable(link) ? ( {previewAvailable(link) ? (
@@ -150,7 +106,15 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
) : ( ) : (
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div> <div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
)} )}
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md"> <div
style={
{
// background:
// "radial-gradient(circle, rgba(255, 255, 255, 0.5), transparent)",
}
}
className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md"
>
<LinkIcon link={link} /> <LinkIcon link={link} />
</div> </div>
</div> </div>
@@ -166,13 +130,12 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
href={link.url || ""} href={link.url || ""}
target="_blank" target="_blank"
title={link.url || ""} title={link.url || ""}
onClick={(e) => { className="w-fit"
e.stopPropagation();
}}
className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-70 duration-100"
> >
<i className="bi-link-45deg text-lg mt-[0.10rem] leading-none"></i> <div className="flex gap-1 item-center select-none text-neutral mt-1">
<p className="text-sm truncate">{shortendURL}</p> <i className="bi-link-45deg text-lg mt-[0.15rem] leading-none"></i>
<p className="text-sm truncate">{shortendURL}</p>
</div>
</Link> </Link>
</div> </div>
@@ -180,15 +143,15 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
<div className="flex justify-between text-xs text-neutral px-3 pb-1"> <div className="flex justify-between text-xs text-neutral px-3 pb-1">
<div className="cursor-pointer w-fit"> <div className="cursor-pointer w-fit">
{collection && ( {collection ? (
<LinkCollection link={link} collection={collection} /> <LinkCollection link={link} collection={collection} />
)} ) : undefined}
</div> </div>
<LinkDate link={link} /> <LinkDate link={link} />
</div> </div>
</div> </Link>
{showInfo && ( {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 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 <div
onClick={() => setShowInfo(!showInfo)} onClick={() => setShowInfo(!showInfo)}
@@ -208,7 +171,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
</span> </span>
)} )}
</p> </p>
{link.tags[0] && ( {link.tags[0] ? (
<> <>
<p className="text-neutral text-lg mt-3 font-semibold">Tags</p> <p className="text-neutral text-lg mt-3 font-semibold">Tags</p>
@@ -231,9 +194,9 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
</div> </div>
</div> </div>
</> </>
)} ) : undefined}
</div> </div>
)} ) : undefined}
<LinkActions <LinkActions
link={link} link={link}
@@ -241,7 +204,6 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
position="top-[10.75rem] right-3" position="top-[10.75rem] right-3"
toggleShowInfo={() => setShowInfo(!showInfo)} toggleShowInfo={() => setShowInfo(!showInfo)}
linkInfo={showInfo} linkInfo={showInfo}
flipDropdown={flipDropdown}
/> />
</div> </div>
); );
@@ -10,7 +10,6 @@ import PreservedFormatsModal from "@/components/ModalContent/PreservedFormatsMod
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import useAccountStore from "@/store/account"; import useAccountStore from "@/store/account";
import { dropdownTriggerer } from "@/lib/client/utils";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
@@ -18,7 +17,6 @@ type Props = {
position?: string; position?: string;
toggleShowInfo?: () => void; toggleShowInfo?: () => void;
linkInfo?: boolean; linkInfo?: boolean;
flipDropdown?: boolean;
}; };
export default function LinkActions({ export default function LinkActions({
@@ -26,7 +24,6 @@ export default function LinkActions({
toggleShowInfo, toggleShowInfo,
position, position,
linkInfo, linkInfo,
flipDropdown,
}: Props) { }: Props) {
const permissions = usePermissions(link.collection.id as number); const permissions = usePermissions(link.collection.id as number);
@@ -67,33 +64,34 @@ export default function LinkActions({
return ( return (
<> <>
<div <div
className={`dropdown dropdown-left dropdown-end absolute ${ className={`dropdown dropdown-left absolute ${
position || "top-3 right-3" position || "top-3 right-3"
} z-20`} } z-20`}
> >
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-ghost btn-sm btn-square text-neutral" className="btn btn-ghost btn-sm btn-square text-neutral"
> >
<i title="More" className="bi-three-dots text-xl" /> <i title="More" className="bi-three-dots 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 translate-y-10"> <ul className="dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1">
<li> {permissions === true ? (
<div <li>
role="button" <div
tabIndex={0} role="button"
onClick={() => { tabIndex={0}
(document?.activeElement as HTMLElement)?.blur(); onClick={() => {
pinLink(); (document?.activeElement as HTMLElement)?.blur();
}} pinLink();
> }}
{link?.pinnedBy && link.pinnedBy[0] >
? "Unpin" {link?.pinnedBy && link.pinnedBy[0]
: "Pin to Dashboard"} ? "Unpin"
</div> : "Pin to Dashboard"}
</li> </div>
</li>
) : undefined}
{linkInfo !== undefined && toggleShowInfo ? ( {linkInfo !== undefined && toggleShowInfo ? (
<li> <li>
<div <div
@@ -2,7 +2,6 @@ import {
CollectionIncludingMembersAndLinkCount, CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
} from "@/types/global"; } from "@/types/global";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React from "react"; import React from "react";
@@ -16,12 +15,12 @@ export default function LinkCollection({
const router = useRouter(); const router = useRouter();
return ( return (
<Link <div
href={`/collections/${link.collection.id}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.preventDefault();
router.push(`/collections/${link.collection.id}`);
}} }}
className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100 select-none" className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100"
title={collection?.name} title={collection?.name}
> >
<i <i
@@ -29,6 +28,6 @@ export default function LinkCollection({
style={{ color: collection?.color }} style={{ color: collection?.color }}
></i> ></i>
<p className="truncate capitalize">{collection?.name}</p> <p className="truncate capitalize">{collection?.name}</p>
</Link> </div>
); );
} }
+1 -1
View File
@@ -18,7 +18,7 @@ type Props = {
className?: string; className?: string;
}; };
export default function LinkGrid({ link }: Props) { export default function LinkGrid({ link, count, className }: Props) {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const { links } = useLinkStore(); const { links } = useLinkStore();
+69 -97
View File
@@ -11,50 +11,17 @@ import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection"; import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon"; import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
import Link from "next/link"; import Link from "next/link";
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";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
count: number; count: number;
className?: string; className?: string;
flipDropdown?: boolean;
editMode?: boolean;
}; };
export default function LinkCardCompact({ export default function LinkCardCompact({ link, count, className }: Props) {
link,
flipDropdown,
editMode,
}: Props) {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const { account } = useAccountStore();
const { links, setSelectedLinks, selectedLinks } = useLinkStore();
useEffect(() => { const { links } = useLinkStore();
if (!editMode) {
setSelectedLinks([]);
}
}, [editMode]);
const handleCheckboxClick = (
link: LinkIncludingShortenedCollectionAndTags
) => {
const linkIndex = selectedLinks.findIndex(
(selectedLink) => selectedLink.id === link.id
);
if (linkIndex !== -1) {
const updatedLinks = [...selectedLinks];
updatedLinks.splice(linkIndex, 1);
setSelectedLinks(updatedLinks);
} else {
setSelectedLinks([...selectedLinks, link]);
}
};
let shortendURL; let shortendURL;
@@ -79,102 +46,107 @@ export default function LinkCardCompact({
); );
}, [collections, links]); }, [collections, links]);
const permissions = usePermissions(collection?.id as number);
const [showInfo, setShowInfo] = useState(false); const [showInfo, setShowInfo] = useState(false);
const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)
? "border border-primary bg-base-300"
: "border-transparent";
const selectable =
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
return ( return (
<> <>
<div <div
className={`${selectedStyle} border relative items-center flex ${ className={`border-neutral-content relative ${
!showInfo && !isPWA() ? "hover:bg-base-300 p-3" : "py-3" !showInfo ? "hover:bg-base-300" : ""
} duration-200 rounded-lg`} } duration-200 rounded-lg`}
onClick={() =>
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(
"You don't have permission to edit or delete this item."
)
: undefined
}
> >
{/* {showCheckbox && <Link
editMode && href={link.url || ""}
(permissions === true || target="_blank"
permissions?.canCreate || className="flex items-center cursor-pointer py-3 px-3"
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"
onClick={() =>
!editMode && window.open(generateLinkHref(link, account), "_blank")
}
> >
<div className="shrink-0"> <div className="shrink-0">
<LinkIcon link={link} width="sm:w-12 w-8 mt-1 sm:mt-0" /> <LinkIcon link={link} width="sm:w-12 w-8" />
</div> </div>
<div className="w-[calc(100%-56px)] ml-2"> <div className="w-[calc(100%-56px)] ml-2">
<p className="line-clamp-1 mr-8 text-primary select-none"> <p className="line-clamp-1 mr-8 text-primary">
{unescapeString(link.name || link.description) || link.url} {unescapeString(link.name || link.description) || link.url}
</p> </p>
<div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral"> <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 w-fit text-neutral flex-wrap"> <div className="flex items-center gap-2">
{collection ? ( {collection ? (
<LinkCollection link={link} collection={collection} /> <>
<LinkCollection link={link} collection={collection} />
&middot;
</>
) : undefined} ) : undefined}
{link.url ? ( {link.url ? (
<Link <div className="flex items-center gap-1 max-w-full w-fit text-neutral">
href={link.url || ""} <i className="bi-link-45deg text-base" />
target="_blank" <p className="truncate w-full">{shortendURL}</p>
title={link.url || ""} </div>
onClick={(e) => {
e.stopPropagation();
}}
className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-70 duration-100"
>
<i className="bi-link-45deg text-lg mt-[0.1rem] leading-none"></i>
<p className="text-sm truncate">{shortendURL}</p>
</Link>
) : ( ) : (
<div className="badge badge-primary badge-sm my-1 select-none"> <div className="badge badge-primary badge-sm my-1">
{link.type} {link.type}
</div> </div>
)} )}
<LinkDate link={link} />
</div> </div>
<span className="hidden sm:block">&middot;</span>
<LinkDate link={link} />
</div> </div>
</div> </div>
</div> </Link>
<LinkActions <LinkActions
link={link} link={link}
collection={collection} collection={collection}
position="top-3 right-3" position="top-3 right-3"
flipDropdown={flipDropdown}
// toggleShowInfo={() => setShowInfo(!showInfo)} // toggleShowInfo={() => setShowInfo(!showInfo)}
// linkInfo={showInfo} // linkInfo={showInfo}
/> />
{showInfo ? (
<div>
<div className="pb-3 mt-1 px-3">
<p className="text-neutral text-lg font-semibold">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">
No description provided.
</span>
)}
</p>
{link.tags[0] ? (
<>
<p className="text-neutral text-lg mt-3 font-semibold">
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>
</>
) : undefined}
</div>
</div>
) : undefined}
</div> </div>
<div className="divider my-0 last:hidden h-[1px]"></div> <div className="divider my-0 last:hidden h-[1px]"></div>
</> </>
); );
-96
View File
@@ -1,96 +0,0 @@
import { dropdownTriggerer, isIphone } from "@/lib/client/utils";
import React from "react";
import { useState } from "react";
import NewLinkModal from "./ModalContent/NewLinkModal";
import NewCollectionModal from "./ModalContent/NewCollectionModal";
import UploadFileModal from "./ModalContent/UploadFileModal";
import MobileNavigationButton from "./MobileNavigationButton";
type Props = {};
export default function MobileNavigation({}: Props) {
const [newLinkModal, setNewLinkModal] = useState(false);
const [newCollectionModal, setNewCollectionModal] = useState(false);
const [uploadFileModal, setUploadFileModal] = useState(false);
return (
<>
<div
className={`fixed bottom-0 left-0 right-0 z-30 duration-200 sm:hidden`}
>
<div
className={`w-full flex bg-base-100 ${
isIphone() ? "pb-5" : ""
} border-solid border-t-neutral-content border-t`}
>
<MobileNavigationButton href={`/dashboard`} icon={"bi-house"} />
<MobileNavigationButton
href={`/links/pinned`}
icon={"bi-pin-angle"}
/>
<div className="dropdown dropdown-top -mt-4">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className={`flex items-center btn btn-accent dark:border-violet-400 text-white btn-circle w-20 h-20 px-2 relative`}
>
<span>
<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">
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewLinkModal(true);
}}
tabIndex={0}
role="button"
>
New Link
</div>
</li>
{/* <li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setUploadFileModal(true);
}}
tabIndex={0}
role="button"
>
Upload File
</div>
</li> */}
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewCollectionModal(true);
}}
tabIndex={0}
role="button"
>
New Collection
</div>
</li>
</ul>
</div>
<MobileNavigationButton href={`/links`} icon={"bi-link-45deg"} />
<MobileNavigationButton href={`/collections`} icon={"bi-folder"} />
</div>
</div>
{newLinkModal ? (
<NewLinkModal onClose={() => setNewLinkModal(false)} />
) : undefined}
{newCollectionModal ? (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
) : undefined}
{uploadFileModal ? (
<UploadFileModal onClose={() => setUploadFileModal(false)} />
) : undefined}
</>
);
}
-45
View File
@@ -1,45 +0,0 @@
import { isPWA } from "@/lib/client/utils";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
export default function MobileNavigationButton({
href,
icon,
}: {
href: string;
icon: string;
}) {
const router = useRouter();
const [active, setActive] = useState(false);
useEffect(() => {
setActive(href === router.asPath);
}, [router]);
return (
<Link
href={href}
className="w-full active:scale-[80%] duration-200 select-none"
draggable="false"
style={{ WebkitTouchCallout: "none" }}
onContextMenu={(e) => {
if (isPWA()) {
e.preventDefault();
e.stopPropagation();
return false;
} else return null;
}}
>
<div
className={`py-2 cursor-pointer gap-2 w-full rounded-full capitalize flex items-center justify-center`}
>
<i
className={`${icon} text-primary text-3xl drop-shadow duration-200 rounded-full w-14 h-14 text-center pt-[0.65rem] ${
active || false ? "bg-primary/20" : ""
}`}
></i>
</div>
</Link>
);
}
+23 -52
View File
@@ -1,6 +1,5 @@
import React, { MouseEventHandler, ReactNode, useEffect } from "react"; import React, { MouseEventHandler, ReactNode, useEffect } from "react";
import ClickAwayHandler from "@/components/ClickAwayHandler"; import ClickAwayHandler from "@/components/ClickAwayHandler";
import { Drawer } from "vaul";
type Props = { type Props = {
toggleModal: Function; toggleModal: Function;
@@ -9,59 +8,31 @@ type Props = {
}; };
export default function Modal({ toggleModal, className, children }: Props) { export default function Modal({ toggleModal, className, children }: Props) {
const [drawerIsOpen, setDrawerIsOpen] = React.useState(true);
useEffect(() => { useEffect(() => {
if (window.innerWidth >= 640) { document.body.style.overflow = "hidden";
document.body.style.overflow = "hidden"; return () => {
document.body.style.position = "relative"; document.body.style.overflow = "auto";
return () => { };
document.body.style.overflow = "auto"; });
document.body.style.position = "";
};
}
}, []);
if (window.innerWidth < 640) { return (
return ( <div className="overflow-y-auto pt-2 sm:py-2 fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex justify-center items-center fade-in z-40">
<Drawer.Root <ClickAwayHandler
open={drawerIsOpen} onClickOutside={toggleModal}
onClose={() => setTimeout(() => toggleModal(), 100)} className={`w-full mt-auto sm:m-auto sm:w-11/12 sm:max-w-2xl ${
className || ""
}`}
> >
<Drawer.Portal> <div className="slide-up mt-auto sm:m-auto relative border-neutral-content rounded-t-2xl sm:rounded-2xl border-t sm:border shadow-2xl p-5 bg-base-100 overflow-y-auto sm:overflow-y-visible">
<Drawer.Overlay className="fixed inset-0 bg-black/40" /> <div
<ClickAwayHandler onClickOutside={() => setDrawerIsOpen(false)}> onClick={toggleModal as MouseEventHandler<HTMLDivElement>}
<Drawer.Content className="flex flex-col rounded-t-2xl h-[90%] mt-24 fixed bottom-0 left-0 right-0 z-30"> className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10"
<div className="p-4 pb-32 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto"> >
<div className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-neutral mb-5" /> <i className="bi-x text-neutral text-2xl"></i>
{children}
</div>
</Drawer.Content>
</ClickAwayHandler>
</Drawer.Portal>
</Drawer.Root>
);
} else {
return (
<div className="overflow-y-auto pt-2 sm:py-2 fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex justify-center items-center fade-in z-40">
<ClickAwayHandler
onClickOutside={toggleModal}
className={`w-full mt-auto sm:m-auto sm:w-11/12 sm:max-w-2xl ${
className || ""
}`}
>
<div className="slide-up mt-auto sm:m-auto relative border-neutral-content rounded-t-2xl sm:rounded-2xl border-t sm:border shadow-2xl p-5 bg-base-100 overflow-y-auto sm:overflow-y-visible">
<div
onClick={toggleModal as MouseEventHandler<HTMLDivElement>}
className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10"
>
<i className="bi-x text-neutral text-2xl"></i>
</div>
{children}
</div> </div>
</ClickAwayHandler> {children}
</div> </div>
); </ClickAwayHandler>
} </div>
);
} }
@@ -1,75 +0,0 @@
import React from "react";
import useLinkStore from "@/store/links";
import toast from "react-hot-toast";
import Modal from "../Modal";
type Props = {
onClose: Function;
};
export default function BulkDeleteLinksModal({ onClose }: Props) {
const { selectedLinks, setSelectedLinks, deleteLinksById } = useLinkStore();
const deleteLink = async () => {
const load = toast.loading(
`Deleting ${selectedLinks.length} Link${
selectedLinks.length > 1 ? "s" : ""
}...`
);
const response = await deleteLinksById(
selectedLinks.map((link) => link.id as number)
);
toast.dismiss(load);
if (response.ok) {
toast.success(
`Deleted ${selectedLinks.length} Link${
selectedLinks.length > 1 ? "s" : ""
}`
);
setSelectedLinks([]);
onClose();
} else toast.error(response.data as string);
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">
Delete {selectedLinks.length} Link{selectedLinks.length > 1 ? "s" : ""}
</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
{selectedLinks.length > 1 ? (
<p>Are you sure you want to delete {selectedLinks.length} links?</p>
) : (
<p>Are you sure you want to delete this link?</p>
)}
<div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl" />
<span>
<b>Warning:</b> This action is irreversible!
</span>
</div>
<p>
Hold the <kbd className="kbd kbd-sm">Shift</kbd> key while clicking
&apos;Delete&apos; to bypass this confirmation in the future.
</p>
<button
className={`ml-auto btn w-fit text-white flex items-center gap-2 duration-100 bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer`}
onClick={deleteLink}
>
<i className="bi-trash text-xl" />
Delete
</button>
</div>
</Modal>
);
}
@@ -1,102 +0,0 @@
import React, { useState } from "react";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast";
import Modal from "../Modal";
type Props = {
onClose: Function;
};
export default function BulkEditLinksModal({ onClose }: Props) {
const { updateLinks, selectedLinks, setSelectedLinks } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false);
const [removePreviousTags, setRemovePreviousTags] = useState(false);
const [updatedValues, setUpdatedValues] = useState<
Pick<LinkIncludingShortenedCollectionAndTags, "tags" | "collectionId">
>({ tags: [] });
const setCollection = (e: any) => {
const collectionId = e?.value || null;
console.log(updatedValues);
setUpdatedValues((prevValues) => ({ ...prevValues, collectionId }));
};
const setTags = (e: any) => {
const tags = e.map((tag: any) => ({ name: tag.label }));
setUpdatedValues((prevValues) => ({ ...prevValues, tags }));
};
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading("Updating...");
const response = await updateLinks(
selectedLinks,
removePreviousTags,
updatedValues
);
toast.dismiss(load);
if (response.ok) {
toast.success(`Updated!`);
setSelectedLinks([]);
onClose();
} else toast.error(response.data as string);
setSubmitLoader(false);
return response;
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">
Edit {selectedLinks.length} Link{selectedLinks.length > 1 ? "s" : ""}
</p>
<div className="divider mb-3 mt-1"></div>
<div className="mt-5">
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">Move to Collection</p>
<CollectionSelection
showDefaultValue={false}
onChange={setCollection}
creatable={false}
/>
</div>
<div>
<p className="mb-2">Add Tags</p>
<TagSelection onChange={setTags} />
</div>
</div>
<div className="sm:ml-auto w-1/2 p-3">
<label className="flex items-center gap-2 ">
<input
type="checkbox"
className="checkbox checkbox-primary"
checked={removePreviousTags}
onChange={(e) => setRemovePreviousTags(e.target.checked)}
/>
Remove previous tags
</label>
</div>
</div>
<div className="flex justify-end items-center mt-5">
<button
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
Save Changes
</button>
</div>
</Modal>
);
}
@@ -15,6 +15,7 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
useState<LinkIncludingShortenedCollectionAndTags>(activeLink); useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const { removeLink } = useLinkStore(); const { removeLink } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter(); const router = useRouter();
@@ -110,7 +110,7 @@ export default function EditCollectionModal({
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto" className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto"
onClick={submit} onClick={submit}
> >
Save Changes Save
</button> </button>
</div> </div>
</Modal> </Modal>
@@ -9,7 +9,6 @@ import usePermissions from "@/hooks/usePermissions";
import ProfilePhoto from "../ProfilePhoto"; import ProfilePhoto from "../ProfilePhoto";
import addMemberToCollection from "@/lib/client/addMemberToCollection"; import addMemberToCollection from "@/lib/client/addMemberToCollection";
import Modal from "../Modal"; import Modal from "../Modal";
import { dropdownTriggerer } from "@/lib/client/utils";
type Props = { type Props = {
onClose: Function; onClose: Function;
@@ -234,8 +233,11 @@ export default function EditCollectionSharingModal({
: undefined; : undefined;
return ( return (
<React.Fragment key={i}> <>
<div className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between border-none"> <div
key={i}
className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between border-none"
>
<div <div
className={"flex items-center justify-between w-full"} className={"flex items-center justify-between w-full"}
> >
@@ -262,7 +264,6 @@ export default function EditCollectionSharingModal({
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-sm btn-primary font-normal" className="btn btn-sm btn-primary font-normal"
> >
{roleLabel} {roleLabel}
@@ -430,7 +431,7 @@ export default function EditCollectionSharingModal({
</div> </div>
</div> </div>
<div className="divider my-0 last:hidden h-[3px]"></div> <div className="divider my-0 last:hidden h-[3px]"></div>
</React.Fragment> </>
); );
})} })}
</div> </div>
@@ -442,7 +443,7 @@ export default function EditCollectionSharingModal({
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto mt-3" className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto mt-3"
onClick={submit} onClick={submit}
> >
Save Changes Save
</button> </button>
)} )}
</div> </div>
+1 -2
View File
@@ -124,7 +124,6 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
label: "Unorganized", label: "Unorganized",
} }
} }
creatable={false}
/> />
) : null} ) : null}
</div> </div>
@@ -158,7 +157,7 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
className="btn btn-accent dark:border-violet-400 text-white" className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit} onClick={submit}
> >
Save Changes Save
</button> </button>
</div> </div>
</Modal> </Modal>
+4 -22
View File
@@ -5,26 +5,19 @@ import toast from "react-hot-toast";
import { HexColorPicker } from "react-colorful"; import { HexColorPicker } from "react-colorful";
import { Collection } from "@prisma/client"; import { Collection } from "@prisma/client";
import Modal from "../Modal"; import Modal from "../Modal";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import useAccountStore from "@/store/account";
import { useSession } from "next-auth/react";
type Props = { type Props = {
onClose: Function; onClose: Function;
parent?: CollectionIncludingMembersAndLinkCount;
}; };
export default function NewCollectionModal({ onClose, parent }: Props) { export default function NewCollectionModal({ onClose }: Props) {
const initial = { const initial = {
parentId: parent?.id,
name: "", name: "",
description: "", description: "",
color: "#0ea5e9", color: "#0ea5e9",
} as Partial<Collection>; };
const [collection, setCollection] = useState<Partial<Collection>>(initial); const [collection, setCollection] = useState<Partial<Collection>>(initial);
const { setAccount } = useAccountStore();
const { data } = useSession();
useEffect(() => { useEffect(() => {
setCollection(initial); setCollection(initial);
@@ -46,11 +39,7 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
if (response.ok) { if (response.ok) {
toast.success("Created!"); toast.success("Created!");
if (response.data) { onClose();
// If the collection was created successfully, we need to get the new collection order
setAccount(data?.user.id as number);
onClose();
}
} else toast.error(response.data as string); } else toast.error(response.data as string);
setSubmitLoader(false); setSubmitLoader(false);
@@ -58,14 +47,7 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
return ( return (
<Modal toggleModal={onClose}> <Modal toggleModal={onClose}>
{parent?.id ? ( <p className="text-xl font-thin">Create a New Collection</p>
<>
<p className="text-xl font-thin">New Sub-Collection</p>
<p className="capitalize text-sm">For {parent.name}</p>
</>
) : (
<p className="text-xl font-thin">Create a New Collection</p>
)}
<div className="divider mb-3 mt-1"></div> <div className="divider mb-3 mt-1"></div>
+2 -1
View File
@@ -109,6 +109,7 @@ export default function NewLinkModal({ onClose }: Props) {
toast.success(`Created!`); toast.success(`Created!`);
onClose(); onClose();
} else toast.error(response.data as string); } else toast.error(response.data as string);
setSubmitLoader(false); setSubmitLoader(false);
return response; return response;
@@ -178,7 +179,7 @@ export default function NewLinkModal({ onClose }: Props) {
setLink({ ...link, description: e.target.value }) setLink({ ...link, description: e.target.value })
} }
placeholder="Will be auto generated if nothing is provided." placeholder="Will be auto generated if nothing is provided."
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100" className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100"
/> />
</div> </div>
</div> </div>
-227
View File
@@ -1,227 +0,0 @@
import React, { useState } from "react";
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";
type Props = {
onClose: Function;
};
export default function NewTokenModal({ onClose }: Props) {
const [newToken, setNewToken] = useState("");
const { addToken } = useTokenStore();
const initial = {
name: "",
expires: 0 as TokenExpiry,
};
const [token, setToken] = useState(initial as any);
const [submitLoader, setSubmitLoader] = useState(false);
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading("Creating...");
const { ok, data } = await addToken(token);
toast.dismiss(load);
if (ok) {
toast.success(`Created!`);
setNewToken((data as any).secretKey);
} else toast.error(data as string);
setSubmitLoader(false);
}
};
return (
<Modal toggleModal={onClose}>
{newToken ? (
<div className="flex flex-col justify-center space-y-4">
<p className="text-xl font-thin">Access Token Created</p>
<p>
Your new token has been created. Please copy it and store it
somewhere safe. You will not be able to see it again.
</p>
<TextInput
spellCheck={false}
value={newToken}
onChange={() => {}}
className="w-full"
/>
<button
onClick={() => {
navigator.clipboard.writeText(newToken);
toast.success("Copied to clipboard!");
}}
className="btn btn-primary w-fit mx-auto"
>
Copy to Clipboard
</button>
</div>
) : (
<>
<p className="text-xl font-thin">Create an Access Token</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex sm:flex-row flex-col gap-2 items-center">
<div className="w-full">
<p className="mb-2">Name</p>
<TextInput
value={token.name}
onChange={(e) => setToken({ ...token, name: e.target.value })}
placeholder="e.g. For the iOS shortcut"
className="bg-base-200"
/>
</div>
<div className="w-full sm:w-fit">
<p className="mb-2">Expires in</p>
<div className="dropdown dropdown-bottom dropdown-end w-full">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-outline w-full sm:w-36 flex items-center btn-sm h-10"
>
{token.expires === TokenExpiry.sevenDays && "7 Days"}
{token.expires === TokenExpiry.oneMonth && "30 Days"}
{token.expires === TokenExpiry.twoMonths && "60 Days"}
{token.expires === TokenExpiry.threeMonths && "90 Days"}
{token.expires === TokenExpiry.never && "No Expiration"}
</div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl w-full sm:w-52 mt-1">
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={token.expires === TokenExpiry.sevenDays}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({
...token,
expires: TokenExpiry.sevenDays,
});
}}
/>
<span className="label-text">7 Days</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={token.expires === TokenExpiry.oneMonth}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({ ...token, expires: TokenExpiry.oneMonth });
}}
/>
<span className="label-text">30 Days</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={token.expires === TokenExpiry.twoMonths}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({
...token,
expires: TokenExpiry.twoMonths,
});
}}
/>
<span className="label-text">60 Days</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={token.expires === TokenExpiry.threeMonths}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({
...token,
expires: TokenExpiry.threeMonths,
});
}}
/>
<span className="label-text">90 Days</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={token.expires === TokenExpiry.never}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({ ...token, expires: TokenExpiry.never });
}}
/>
<span className="label-text">No Expiration</span>
</label>
</li>
</ul>
</div>
</div>
</div>
<div className="flex justify-end items-center mt-5">
<button
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
Create Access Token
</button>
</div>
</>
)}
</Modal>
);
}
@@ -69,13 +69,11 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
const isReady = () => { const isReady = () => {
return ( return (
collectionOwner.archiveAsScreenshot ===
(link && link.pdf && link.pdf !== "pending") &&
collectionOwner.archiveAsPDF ===
(link && link.pdf && link.pdf !== "pending") &&
link && link &&
(collectionOwner.archiveAsScreenshot === true
? link.pdf && link.pdf !== "pending"
: true) &&
(collectionOwner.archiveAsPDF === true
? link.pdf && link.pdf !== "pending"
: true) &&
link.readable && link.readable &&
link.readable !== "pending" link.readable !== "pending"
); );
@@ -217,7 +215,10 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
<i className="bi-box-arrow-up-right" /> <i className="bi-box-arrow-up-right" />
</Link> </Link>
{link?.collection.ownerId === session.data?.user.id ? ( {link?.collection.ownerId === session.data?.user.id ? (
<div className={`btn btn-outline`} onClick={() => updateArchive()}> <div
className={`btn w-1/2 btn-outline`}
onClick={() => updateArchive()}
>
<div> <div>
<p>Refresh Preserved Formats</p> <p>Refresh Preserved Formats</p>
<p className="text-xs"> <p className="text-xs">
@@ -1,62 +0,0 @@
import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { useRouter } from "next/router";
import { AccessToken } from "@prisma/client";
import useTokenStore from "@/store/tokens";
type Props = {
onClose: Function;
activeToken: AccessToken;
};
export default function DeleteTokenModal({ onClose, activeToken }: Props) {
const [token, setToken] = useState<AccessToken>(activeToken);
const { revokeToken } = useTokenStore();
const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter();
useEffect(() => {
setToken(activeToken);
}, []);
const deleteLink = async () => {
console.log(token);
const load = toast.loading("Deleting...");
const response = await revokeToken(token.id as number);
toast.dismiss(load);
response.ok && toast.success(`Token Revoked.`);
onClose();
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">Revoke Token</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>
Are you sure you want to revoke this Access Token? Any apps or
services using this token will no longer be able to access Linkwarden
using it.
</p>
<button
className={`ml-auto btn w-fit text-white flex items-center gap-2 duration-100 bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer`}
onClick={deleteLink}
>
<i className="bi-trash text-xl" />
Revoke
</button>
</div>
</Modal>
);
}
+26 -38
View File
@@ -13,8 +13,6 @@ import NewLinkModal from "./ModalContent/NewLinkModal";
import NewCollectionModal from "./ModalContent/NewCollectionModal"; import NewCollectionModal from "./ModalContent/NewCollectionModal";
import Link from "next/link"; import Link from "next/link";
import UploadFileModal from "./ModalContent/UploadFileModal"; import UploadFileModal from "./ModalContent/UploadFileModal";
import { dropdownTriggerer } from "@/lib/client/utils";
import MobileNavigation from "./MobileNavigation";
export default function Navbar() { export default function Navbar() {
const { settings, updateSettings } = useLocalSettingsStore(); const { settings, updateSettings } = useLocalSettingsStore();
@@ -28,21 +26,23 @@ export default function Navbar() {
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const handleToggle = () => { const handleToggle = () => {
if (settings.theme === "dark") { const [colorTheme, mode] = (settings.theme || "default-light").split('-');
updateSettings({ theme: "light" }); const newMode = mode === "dark" ? "light" : "dark";
} else { const newTheme = `${colorTheme}-${newMode}`;
updateSettings({ theme: "dark" }); updateSettings({ theme: newTheme });
}
}; };
useEffect(() => { useEffect(() => {
setSidebar(false); setSidebar(false);
document.body.style.overflow = "auto"; }, [width]);
}, [width, router]);
useEffect(() => {
setSidebar(false);
}, [router]);
const toggleSidebar = () => { const toggleSidebar = () => {
setSidebar(false); setSidebar(!sidebar);
document.body.style.overflow = "auto";
}; };
const [newLinkModal, setNewLinkModal] = useState(false); const [newLinkModal, setNewLinkModal] = useState(false);
@@ -52,11 +52,8 @@ export default function Navbar() {
return ( return (
<div className="flex justify-between gap-2 items-center pl-3 pr-4 py-2 border-solid border-b-neutral-content border-b"> <div className="flex justify-between gap-2 items-center pl-3 pr-4 py-2 border-solid border-b-neutral-content border-b">
<div <div
onClick={() => { onClick={toggleSidebar}
setSidebar(true); className="text-neutral btn btn-square btn-sm btn-ghost lg:hidden"
document.body.style.overflow = "hidden";
}}
className="text-neutral btn btn-square btn-sm btn-ghost lg:hidden sm:inline-flex"
> >
<i className="bi-list text-2xl leading-none"></i> <i className="bi-list text-2xl leading-none"></i>
</div> </div>
@@ -64,12 +61,11 @@ export default function Navbar() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ToggleDarkMode className="hidden sm:inline-grid" /> <ToggleDarkMode className="hidden sm:inline-grid" />
<div className="dropdown dropdown-end sm:inline-block hidden"> <div className="dropdown dropdown-end">
<div className="tooltip tooltip-bottom" data-tip="Create New..."> <div className="tooltip tooltip-bottom" data-tip="Create New...">
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer}
className="flex min-w-[3.4rem] items-center btn btn-accent dark:border-violet-400 text-white btn-sm max-h-[2rem] px-2 relative" className="flex min-w-[3.4rem] items-center btn btn-accent dark:border-violet-400 text-white btn-sm max-h-[2rem] px-2 relative"
> >
<span> <span>
@@ -121,12 +117,7 @@ export default function Navbar() {
</div> </div>
<div className="dropdown dropdown-end"> <div className="dropdown dropdown-end">
<div <div tabIndex={0} role="button" className="btn btn-circle btn-ghost">
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-circle btn-ghost"
>
<ProfilePhoto <ProfilePhoto
src={account.image ? account.image : undefined} src={account.image ? account.image : undefined}
priority={true} priority={true}
@@ -143,17 +134,17 @@ export default function Navbar() {
Settings Settings
</Link> </Link>
</li> </li>
<li className="block sm:hidden"> <li>
<div <div
onClick={() => { onClick={() => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
handleToggle(); handleToggle();
}} }}
tabIndex={0} tabIndex={0}
role="button" role="button"
> >
Switch to {settings.theme === "light" ? "Dark" : "Light"} Switch to {(settings.theme || "default-light").endsWith("-dark") ? "Light" : "Dark"}
</div> </div>
</li> </li>
<li> <li>
<div <div
@@ -170,9 +161,6 @@ export default function Navbar() {
</ul> </ul>
</div> </div>
</div> </div>
<MobileNavigation />
{sidebar ? ( {sidebar ? (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-40"> <div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-40">
<ClickAwayHandler className="h-full" onClickOutside={toggleSidebar}> <ClickAwayHandler className="h-full" onClickOutside={toggleSidebar}>
+1 -1
View File
@@ -45,7 +45,7 @@ export default function ProfilePhoto({
<div <div
className={`avatar skeleton rounded-full drop-shadow-md ${ className={`avatar skeleton rounded-full drop-shadow-md ${
className || "" className || ""
} ${large ? "w-28 h-28" : "w-8 h-8"}`} } ${large || "w-8 h-8"}`}
> >
<div className="rounded-full w-full h-full ring-2 ring-neutral-content"> <div className="rounded-full w-full h-full ring-2 ring-neutral-content">
<Image <Image
+21 -8
View File
@@ -4,7 +4,7 @@ import { useRouter } from "next/router";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
export default function SettingsSidebar({ className }: { className?: string }) { export default function SettingsSidebar({ className }: { className?: string }) {
const LINKWARDEN_VERSION = "v2.5.1"; const LINKWARDEN_VERSION = "v2.4.7";
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
@@ -37,30 +37,43 @@ export default function SettingsSidebar({ className }: { className?: string }) {
</div> </div>
</Link> </Link>
<Link href="/settings/preference"> <Link href="/settings/appearance">
<div <div
className={`${ className={`${
active === `/settings/preference` active === `/settings/appearance`
? "bg-primary/20" ? "bg-primary/20"
: "hover:bg-neutral/20" : "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} } duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<i className="bi-sliders text-primary text-2xl"></i> <i className="bi-palette text-primary text-2xl"></i>
<p className="truncate w-full pr-7">Preference</p> <p className="truncate w-full pr-7">Appearance</p>
</div> </div>
</Link> </Link>
<Link href="/settings/access-tokens"> <Link href="/settings/archive">
<div <div
className={`${ className={`${
active === `/settings/access-tokens` active === `/settings/archive`
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-archive text-primary text-2xl"></i>
<p className="truncate w-full pr-7">Archive</p>
</div>
</Link>
<Link href="/settings/api">
<div
className={`${
active === `/settings/api`
? "bg-primary/20" ? "bg-primary/20"
: "hover:bg-neutral/20" : "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} } duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<i className="bi-key text-primary text-2xl"></i> <i className="bi-key text-primary text-2xl"></i>
<p className="truncate w-full pr-7">Access Tokens</p> <p className="truncate w-full pr-7">API Keys</p>
</div> </div>
</Link> </Link>
+45 -5
View File
@@ -5,7 +5,6 @@ import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Disclosure, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
import SidebarHighlightLink from "@/components/SidebarHighlightLink"; import SidebarHighlightLink from "@/components/SidebarHighlightLink";
import CollectionListing from "@/components/CollectionListing";
export default function Sidebar({ className }: { className?: string }) { export default function Sidebar({ className }: { className?: string }) {
const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => { const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => {
@@ -22,10 +21,11 @@ export default function Sidebar({ className }: { className?: string }) {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const { tags } = useTagStore(); const { tags } = useTagStore();
const [active, setActive] = useState("");
const router = useRouter(); const router = useRouter();
const [active, setActive] = useState("");
useEffect(() => { useEffect(() => {
localStorage.setItem("tagDisclosure", tagDisclosure ? "true" : "false"); localStorage.setItem("tagDisclosure", tagDisclosure ? "true" : "false");
}, [tagDisclosure]); }, [tagDisclosure]);
@@ -44,7 +44,7 @@ export default function Sidebar({ className }: { className?: string }) {
return ( return (
<div <div
id="sidebar" id="sidebar"
className={`bg-base-200 h-full w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 ${ className={`bg-base-200 h-full w-72 lg:w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 ${
className || "" className || ""
}`} }`}
> >
@@ -97,8 +97,48 @@ export default function Sidebar({ className }: { className?: string }) {
leaveFrom="transform opacity-100 translate-y-0" leaveFrom="transform opacity-100 translate-y-0"
leaveTo="transform opacity-0 -translate-y-3" leaveTo="transform opacity-0 -translate-y-3"
> >
<Disclosure.Panel> <Disclosure.Panel className="flex flex-col gap-1">
<CollectionListing /> {collections[0] ? (
collections
.sort((a, b) => a.name.localeCompare(b.name))
.map((e, i) => {
return (
<Link key={i} href={`/collections/${e.id}`}>
<div
className={`${
active === `/collections/${e.id}`
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<i
className="bi-folder-fill text-2xl drop-shadow"
style={{ color: e.color }}
></i>
<p className="truncate w-full">{e.name}</p>
{e.isPublic ? (
<i
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
title="This collection is being shared publicly."
></i>
) : undefined}
<div className="drop-shadow text-neutral text-xs">
{e._count?.links}
</div>
</div>
</Link>
);
})
) : (
<div
className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<p className="text-neutral text-xs font-semibold truncate w-full pr-7">
You Have No Collections...
</p>
</div>
)}
</Disclosure.Panel> </Disclosure.Panel>
</Transition> </Transition>
</Disclosure> </Disclosure>
+1 -3
View File
@@ -1,6 +1,5 @@
import React, { Dispatch, SetStateAction } from "react"; import React, { Dispatch, SetStateAction } from "react";
import { Sort } from "@/types/global"; import { Sort } from "@/types/global";
import { dropdownTriggerer } from "@/lib/client/utils";
type Props = { type Props = {
sortBy: Sort; sortBy: Sort;
@@ -13,8 +12,7 @@ export default function SortDropdown({ sortBy, setSort }: Props) {
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer} className="btn btn-sm btn-square btn-ghost"
className="btn btn-sm btn-square btn-ghost border-none"
> >
<i className="bi-chevron-expand text-neutral text-2xl"></i> <i className="bi-chevron-expand text-neutral text-2xl"></i>
</div> </div>
-3
View File
@@ -8,7 +8,6 @@ type Props = {
onChange: ChangeEventHandler<HTMLInputElement>; onChange: ChangeEventHandler<HTMLInputElement>;
onKeyDown?: KeyboardEventHandler<HTMLInputElement> | undefined; onKeyDown?: KeyboardEventHandler<HTMLInputElement> | undefined;
className?: string; className?: string;
spellCheck?: boolean;
}; };
export default function TextInput({ export default function TextInput({
@@ -19,11 +18,9 @@ export default function TextInput({
onChange, onChange,
onKeyDown, onKeyDown,
className, className,
spellCheck,
}: Props) { }: Props) {
return ( return (
<input <input
spellCheck={spellCheck}
autoFocus={autoFocus} autoFocus={autoFocus}
type={type ? type : "text"} type={type ? type : "text"}
placeholder={placeholder} placeholder={placeholder}
+35 -28
View File
@@ -2,39 +2,46 @@ import useLocalSettingsStore from "@/store/localSettings";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
type Props = { type Props = {
className?: string; className?: string;
}; };
export default function ToggleDarkMode({ className }: Props) { export default function ToggleDarkMode({ className }: Props) {
const { settings, updateSettings } = useLocalSettingsStore(); const { updateSettings } = useLocalSettingsStore();
const [theme, setTheme] = useState('default-light');
const [theme, setTheme] = useState(localStorage.getItem("theme")); useEffect(() => {
const storedTheme = localStorage.getItem("theme");
if (storedTheme) {
setTheme(storedTheme);
} else {
// Default theme if not set in localStorage
localStorage.setItem("theme", "default-light");
setTheme("default-light");
}
console.log("Initial theme from localStorage:", storedTheme || "default-light");
}, []);
const handleToggle = (e: any) => { const handleToggle = () => {
setTheme(e.target.checked ? "dark" : "light"); const [currentColorTheme, currentMode] = theme.split('-');
}; const newMode = currentMode === 'light' ? 'dark' : 'light';
const newTheme = `${currentColorTheme}-${newMode}`;
useEffect(() => { setTheme(newTheme);
updateSettings({ theme: theme as string }); localStorage.setItem("theme", newTheme);
}, [theme]); document.documentElement.setAttribute('data-theme', newTheme);
updateSettings({ theme: newTheme });
console.log("New theme set:", newTheme);
};
return ( const isDarkMode = theme.endsWith('-dark');
<div
className="tooltip tooltip-bottom" return (
data-tip={`Switch to ${settings.theme === "light" ? "Dark" : "Light"}`} <div className="tooltip tooltip-bottom" data-tip={`Switch to ${isDarkMode ? "Light" : "Dark"}`}>
> <label className={`swap swap-rotate btn-square text-neutral btn btn-ghost btn-sm ${className}`}>
<label <input type="checkbox" onChange={handleToggle} className="theme-controller" checked={isDarkMode} />
className={`swap swap-rotate btn-square text-neutral btn btn-ghost btn-sm ${className}`} <i className="bi-sun-fill text-xl swap-on"></i>
> <i className="bi-moon-fill text-xl swap-off"></i>
<input </label>
type="checkbox" </div>
onChange={handleToggle} );
className="theme-controller"
checked={localStorage.getItem("theme") === "light" ? false : true}
/>
<i className="bi-sun-fill text-xl swap-on"></i>
<i className="bi-moon-fill text-xl swap-off"></i>
</label>
</div>
);
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import React, { Dispatch, SetStateAction, useEffect } from "react"; import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
import useLocalSettingsStore from "@/store/localSettings"; import useLocalSettingsStore from "@/store/localSettings";
import { ViewMode } from "@/types/global"; import { ViewMode } from "@/types/global";
-34
View File
@@ -1,34 +0,0 @@
import useAccountStore from "@/store/account";
import useCollectionStore from "@/store/collections";
import { Member } from "@/types/global";
import { useEffect, useState } from "react";
export default function useCollectivePermissions(collectionIds: number[]) {
const { collections } = useCollectionStore();
const { account } = useAccountStore();
const [permissions, setPermissions] = useState<Member | true>();
useEffect(() => {
for (const collectionId of collectionIds) {
const collection = collections.find((e) => e.id === collectionId);
if (collection) {
let getPermission: Member | undefined = collection.members.find(
(e) => e.userId === account.id
);
if (
getPermission?.canCreate === false &&
getPermission?.canUpdate === false &&
getPermission?.canDelete === false
)
getPermission = undefined;
setPermissions(account.id === collection.ownerId || getPermission);
}
}
}, [account, collections, collectionIds]);
return permissions;
}
+2 -15
View File
@@ -1,5 +1,5 @@
import { LinkRequestQuery } from "@/types/global"; import { LinkRequestQuery } from "@/types/global";
import { useEffect, useState } from "react"; import { useEffect } from "react";
import useDetectPageBottom from "./useDetectPageBottom"; import useDetectPageBottom from "./useDetectPageBottom";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
@@ -18,12 +18,9 @@ export default function useLinks(
searchByTextContent, searchByTextContent,
}: LinkRequestQuery = { sort: 0 } }: LinkRequestQuery = { sort: 0 }
) { ) {
const { links, setLinks, resetLinks, selectedLinks, setSelectedLinks } = const { links, setLinks, resetLinks } = useLinkStore();
useLinkStore();
const router = useRouter(); const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const { reachedBottom, setReachedBottom } = useDetectPageBottom(); const { reachedBottom, setReachedBottom } = useDetectPageBottom();
const getLinks = async (isInitialCall: boolean, cursor?: number) => { const getLinks = async (isInitialCall: boolean, cursor?: number) => {
@@ -63,24 +60,16 @@ export default function useLinks(
basePath = "/api/v1/public/collections/links"; basePath = "/api/v1/public/collections/links";
} else basePath = "/api/v1/links"; } else basePath = "/api/v1/links";
setIsLoading(true);
const response = await fetch(`${basePath}?${queryString}`); const response = await fetch(`${basePath}?${queryString}`);
const data = await response.json(); const data = await response.json();
setIsLoading(false);
if (response.ok) setLinks(data.response, isInitialCall); if (response.ok) setLinks(data.response, isInitialCall);
}; };
useEffect(() => { useEffect(() => {
// Save the selected links before resetting the links
// and then restore the selected links after resetting the links
const previouslySelected = selectedLinks;
resetLinks(); resetLinks();
setSelectedLinks(previouslySelected);
getLinks(true); getLinks(true);
}, [ }, [
router, router,
@@ -98,6 +87,4 @@ export default function useLinks(
setReachedBottom(false); setReachedBottom(false);
}, [reachedBottom]); }, [reachedBottom]);
return { isLoading };
} }
+1 -1
View File
@@ -46,7 +46,7 @@ export default function MainLayout({ children }: Props) {
</div> </div>
<div <div
className={`w-full sm:pb-0 pb-20 flex flex-col min-h-${ className={`w-full flex flex-col min-h-${
showAnnouncement ? "full" : "screen" showAnnouncement ? "full" : "screen"
} lg:ml-80 ${showAnnouncement ? "mt-10" : ""}`} } lg:ml-80 ${showAnnouncement ? "mt-10" : ""}`}
> >
+7 -30
View File
@@ -1,4 +1,4 @@
import { LaunchOptions, chromium, devices } from "playwright"; import { chromium, devices } from "playwright";
import { prisma } from "./db"; import { prisma } from "./db";
import createFile from "./storage/createFile"; import createFile from "./storage/createFile";
import sendToWayback from "./sendToWayback"; import sendToWayback from "./sendToWayback";
@@ -20,23 +20,8 @@ type LinksAndCollectionAndOwner = Link & {
const BROWSER_TIMEOUT = Number(process.env.BROWSER_TIMEOUT) || 5; const BROWSER_TIMEOUT = Number(process.env.BROWSER_TIMEOUT) || 5;
export default async function archiveHandler(link: LinksAndCollectionAndOwner) { export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
// allow user to configure a proxy const browser = await chromium.launch();
let browserOptions: LaunchOptions = {}; const context = await browser.newContext(devices["Desktop Chrome"]);
if (process.env.PROXY) {
browserOptions.proxy = {
server: process.env.PROXY,
bypass: process.env.PROXY_BYPASS,
username: process.env.PROXY_USERNAME,
password: process.env.PROXY_PASSWORD,
};
}
const browser = await chromium.launch(browserOptions);
const context = await browser.newContext({
...devices["Desktop Chrome"],
ignoreHTTPSErrors: process.env.IGNORE_HTTPS_ERRORS === "true",
});
const page = await context.newPage(); const page = await context.newPage();
const timeoutPromise = new Promise((_, reject) => { const timeoutPromise = new Promise((_, reject) => {
@@ -58,8 +43,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
? await validateUrlSize(link.url) ? await validateUrlSize(link.url)
: undefined; : undefined;
if (validatedUrl === null) if (validatedUrl === null) throw "File is too large to be stored.";
throw "Something went wrong while retrieving the file size.";
const contentType = validatedUrl?.get("content-type"); const contentType = validatedUrl?.get("content-type");
let linkType = "url"; let linkType = "url";
@@ -85,11 +69,11 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
image: image:
user.archiveAsScreenshot && !link.image?.startsWith("archive") user.archiveAsScreenshot && !link.image?.startsWith("archive")
? "pending" ? "pending"
: "unavailable", : undefined,
pdf: pdf:
user.archiveAsPDF && !link.pdf?.startsWith("archive") user.archiveAsPDF && !link.pdf?.startsWith("archive")
? "pending" ? "pending"
: "unavailable", : undefined,
readable: !link.readable?.startsWith("archive") readable: !link.readable?.startsWith("archive")
? "pending" ? "pending"
: undefined, : undefined,
@@ -253,13 +237,6 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
}) })
); );
} }
// apply administrator's defined pdf margins or default to 15px
const margins = {
top: process.env.PDF_MARGIN_TOP || "15px",
bottom: process.env.PDF_MARGIN_BOTTOM || "15px",
};
if (user.archiveAsPDF && !link.pdf?.startsWith("archive")) { if (user.archiveAsPDF && !link.pdf?.startsWith("archive")) {
processingPromises.push( processingPromises.push(
page page
@@ -267,7 +244,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
width: "1366px", width: "1366px",
height: "1931px", height: "1931px",
printBackground: true, printBackground: true,
margin: margins, margin: { top: "15px", bottom: "15px" },
}) })
.then((pdf) => { .then((pdf) => {
return createFile({ return createFile({
+6 -7
View File
@@ -32,12 +32,11 @@ export default async function checkSubscriptionByEmail(email: string) {
customer.subscriptions?.data.some((subscription) => { customer.subscriptions?.data.some((subscription) => {
subscription.current_period_end; subscription.current_period_end;
active = active = subscription.items.data.some(
subscription.items.data.some( (e) =>
(e) => (e.price.id === MONTHLY_PRICE_ID && e.price.active === true) ||
(e.price.id === MONTHLY_PRICE_ID && e.price.active === true) || (e.price.id === YEARLY_PRICE_ID && e.price.active === true)
(e.price.id === YEARLY_PRICE_ID && e.price.active === true) );
) || false;
stripeSubscriptionId = subscription.id; stripeSubscriptionId = subscription.id;
currentPeriodStart = subscription.current_period_start * 1000; currentPeriodStart = subscription.current_period_start * 1000;
currentPeriodEnd = subscription.current_period_end * 1000; currentPeriodEnd = subscription.current_period_end * 1000;
@@ -45,7 +44,7 @@ export default async function checkSubscriptionByEmail(email: string) {
}); });
return { return {
active: active || false, active,
stripeSubscriptionId, stripeSubscriptionId,
currentPeriodStart, currentPeriodStart,
currentPeriodEnd, currentPeriodEnd,
@@ -31,16 +31,12 @@ export default async function deleteCollection(
}, },
}); });
await removeFromOrders(userId, collectionId);
return { response: deletedUsersAndCollectionsRelation, status: 200 }; return { response: deletedUsersAndCollectionsRelation, status: 200 };
} else if (collectionIsAccessible?.ownerId !== userId) { } else if (collectionIsAccessible?.ownerId !== userId) {
return { response: "Collection is not accessible.", status: 401 }; return { response: "Collection is not accessible.", status: 401 };
} }
const deletedCollection = await prisma.$transaction(async () => { const deletedCollection = await prisma.$transaction(async () => {
await deleteSubCollections(collectionId);
await prisma.usersAndCollections.deleteMany({ await prisma.usersAndCollections.deleteMany({
where: { where: {
collection: { collection: {
@@ -57,9 +53,7 @@ export default async function deleteCollection(
}, },
}); });
await removeFolder({ filePath: `archives/${collectionId}` }); removeFolder({ filePath: `archives/${collectionId}` });
await removeFromOrders(userId, collectionId);
return await prisma.collection.delete({ return await prisma.collection.delete({
where: { where: {
@@ -70,60 +64,3 @@ export default async function deleteCollection(
return { response: deletedCollection, status: 200 }; return { response: deletedCollection, status: 200 };
} }
async function deleteSubCollections(collectionId: number) {
const subCollections = await prisma.collection.findMany({
where: { parentId: collectionId },
});
for (const subCollection of subCollections) {
await deleteSubCollections(subCollection.id);
await prisma.usersAndCollections.deleteMany({
where: {
collection: {
id: subCollection.id,
},
},
});
await prisma.link.deleteMany({
where: {
collection: {
id: subCollection.id,
},
},
});
await prisma.collection.delete({
where: { id: subCollection.id },
});
await removeFolder({ filePath: `archives/${subCollection.id}` });
}
}
async function removeFromOrders(userId: number, collectionId: number) {
const userCollectionOrder = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
collectionOrder: true,
},
});
if (userCollectionOrder)
await prisma.user.update({
where: {
id: userId,
},
data: {
collectionOrder: {
set: userCollectionOrder.collectionOrder.filter(
(e: number) => e !== collectionId
),
},
},
});
}
@@ -1,34 +0,0 @@
import { prisma } from "@/lib/api/db";
export default async function getCollectionById(
userId: number,
collectionId: number
) {
const collections = await prisma.collection.findFirst({
where: {
id: collectionId,
OR: [
{ ownerId: userId },
{ members: { some: { user: { id: userId } } } },
],
},
include: {
_count: {
select: { links: true },
},
members: {
include: {
user: {
select: {
username: true,
name: true,
image: true,
},
},
},
},
},
});
return { response: collections, status: 200 };
}
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import getPermission from "@/lib/api/getPermission"; import getPermission from "@/lib/api/getPermission";
import { Collection, UsersAndCollections } from "@prisma/client";
export default async function updateCollection( export default async function updateCollection(
userId: number, userId: number,
@@ -18,32 +19,6 @@ export default async function updateCollection(
if (!(collectionIsAccessible?.ownerId === userId)) if (!(collectionIsAccessible?.ownerId === userId))
return { response: "Collection is not accessible.", status: 401 }; return { response: "Collection is not accessible.", status: 401 };
console.log(data);
if (data.parentId) {
if (data.parentId !== ("root" as any)) {
const findParentCollection = await prisma.collection.findUnique({
where: {
id: data.parentId,
},
select: {
ownerId: true,
parentId: true,
},
});
if (
findParentCollection?.ownerId !== userId ||
typeof data.parentId !== "number" ||
findParentCollection?.parentId === data.parentId
)
return {
response: "You are not authorized to create a sub-collection here.",
status: 403,
};
}
}
const updatedCollection = await prisma.$transaction(async () => { const updatedCollection = await prisma.$transaction(async () => {
await prisma.usersAndCollections.deleteMany({ await prisma.usersAndCollections.deleteMany({
where: { where: {
@@ -57,23 +32,12 @@ export default async function updateCollection(
where: { where: {
id: collectionId, id: collectionId,
}, },
data: { data: {
name: data.name.trim(), name: data.name.trim(),
description: data.description, description: data.description,
color: data.color, color: data.color,
isPublic: data.isPublic, isPublic: data.isPublic,
parent:
data.parentId && data.parentId !== ("root" as any)
? {
connect: {
id: data.parentId,
},
}
: data.parentId === ("root" as any)
? {
disconnect: true,
}
: undefined,
members: { members: {
create: data.members.map((e) => ({ create: data.members.map((e) => ({
user: { connect: { id: e.user.id || e.userId } }, user: { connect: { id: e.user.id || e.userId } },
@@ -12,12 +12,6 @@ export default async function getCollection(userId: number) {
_count: { _count: {
select: { links: true }, select: { links: true },
}, },
parent: {
select: {
id: true,
name: true,
},
},
members: { members: {
include: { include: {
user: { user: {
@@ -12,25 +12,23 @@ export default async function postCollection(
status: 400, status: 400,
}; };
if (collection.parentId) { const findCollection = await prisma.user.findUnique({
const findParentCollection = await prisma.collection.findUnique({ where: {
where: { id: userId,
id: collection.parentId, },
select: {
collections: {
where: {
name: collection.name,
},
}, },
select: { },
ownerId: true, });
},
});
if ( const checkIfCollectionExists = findCollection?.collections[0];
findParentCollection?.ownerId !== userId ||
typeof collection.parentId !== "number" if (checkIfCollectionExists)
) return { response: "Collection already exists.", status: 400 };
return {
response: "You are not authorized to create a sub-collection here.",
status: 403,
};
}
const newCollection = await prisma.collection.create({ const newCollection = await prisma.collection.create({
data: { data: {
@@ -42,13 +40,6 @@ export default async function postCollection(
name: collection.name.trim(), name: collection.name.trim(),
description: collection.description, description: collection.description,
color: collection.color, color: collection.color,
parent: collection.parentId
? {
connect: {
id: collection.parentId,
},
}
: undefined,
}, },
include: { include: {
_count: { _count: {
@@ -67,17 +58,6 @@ export default async function postCollection(
}, },
}); });
await prisma.user.update({
where: {
id: userId,
},
data: {
collectionOrder: {
push: newCollection.id,
},
},
});
createFolder({ filePath: `archives/${newCollection.id}` }); createFolder({ filePath: `archives/${newCollection.id}` });
return { response: newCollection, status: 200 }; return { response: newCollection, status: 200 };
@@ -1,58 +0,0 @@
import { prisma } from "@/lib/api/db";
import { UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
import removeFile from "@/lib/api/storage/removeFile";
export default async function deleteLinksById(
userId: number,
linkIds: number[]
) {
if (!linkIds || linkIds.length === 0) {
return { response: "Please choose valid links.", status: 401 };
}
const collectionIsAccessibleArray = [];
// Check if the user has access to the collection of each link
// if any of the links are not accessible, return an error
// if all links are accessible, continue with the deletion
// and add the collection to the collectionIsAccessibleArray
for (const linkId of linkIds) {
const collectionIsAccessible = await getPermission({ userId, linkId });
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canDelete
);
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess)) {
return { response: "Collection is not accessible.", status: 401 };
}
collectionIsAccessibleArray.push(collectionIsAccessible);
}
const deletedLinks = await prisma.link.deleteMany({
where: {
id: { in: linkIds },
},
});
// Loop through each link and delete the associated files
// if the user has access to the collection
for (let i = 0; i < linkIds.length; i++) {
const linkId = linkIds[i];
const collectionIsAccessible = collectionIsAccessibleArray[i];
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
});
}
return { response: deletedLinks, status: 200 };
}
@@ -1,50 +0,0 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import updateLinkById from "../linkId/updateLinkById";
export default async function updateLinks(
userId: number,
links: LinkIncludingShortenedCollectionAndTags[],
removePreviousTags: boolean,
newData: Pick<
LinkIncludingShortenedCollectionAndTags,
"tags" | "collectionId"
>
) {
let allUpdatesSuccessful = true;
// Have to use a loop here rather than updateMany, see the following:
// https://github.com/prisma/prisma/issues/3143
for (const link of links) {
let updatedTags = [...link.tags, ...(newData.tags ?? [])];
if (removePreviousTags) {
// If removePreviousTags is true, replace the existing tags with new tags
updatedTags = [...(newData.tags ?? [])];
}
const updatedData: LinkIncludingShortenedCollectionAndTags = {
...link,
tags: updatedTags,
collection: {
...link.collection,
id: newData.collectionId ?? link.collection.id,
},
};
const updatedLink = await updateLinkById(
userId,
link.id as number,
updatedData
);
if (updatedLink.status !== 200) {
allUpdatesSuccessful = false;
}
}
if (allUpdatesSuccessful) {
return { response: "All links updated successfully", status: 200 };
} else {
return { response: "Some links failed to update", status: 400 };
}
}
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { Link, UsersAndCollections } from "@prisma/client"; import { Collection, Link, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission"; import getPermission from "@/lib/api/getPermission";
import removeFile from "@/lib/api/storage/removeFile"; import removeFile from "@/lib/api/storage/removeFile";
@@ -1,6 +1,6 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { UsersAndCollections } from "@prisma/client"; import { Collection, Link, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission"; import getPermission from "@/lib/api/getPermission";
import moveFile from "@/lib/api/storage/moveFile"; import moveFile from "@/lib/api/storage/moveFile";
@@ -17,70 +17,13 @@ export default async function updateLinkById(
const collectionIsAccessible = await getPermission({ userId, linkId }); const collectionIsAccessible = await getPermission({ userId, linkId });
const isCollectionOwner =
collectionIsAccessible?.ownerId === data.collection.ownerId &&
data.collection.ownerId === userId;
const canPinPermission = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId
);
// If the user is able to create a link, they can pin it to their dashboard only.
if (canPinPermission) {
const updatedLink = await prisma.link.update({
where: {
id: linkId,
},
data: {
pinnedBy:
data?.pinnedBy && data.pinnedBy[0]
? { connect: { id: userId } }
: { disconnect: { id: userId } },
},
include: {
collection: true,
pinnedBy: isCollectionOwner
? {
where: { id: userId },
select: { id: true },
}
: undefined,
},
});
return { response: updatedLink, status: 200 };
}
const targetCollectionIsAccessible = await getPermission({
userId,
collectionId: data.collection.id,
});
const memberHasAccess = collectionIsAccessible?.members.some( const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canUpdate (e: UsersAndCollections) => e.userId === userId && e.canUpdate
); );
const targetCollectionsAccessible = const isCollectionOwner =
targetCollectionIsAccessible?.ownerId === userId; collectionIsAccessible?.ownerId === data.collection.ownerId &&
data.collection.ownerId === userId;
const targetCollectionMatchesData = data.collection.id
? data.collection.id === targetCollectionIsAccessible?.id
: true && data.collection.name
? data.collection.name === targetCollectionIsAccessible?.name
: true && data.collection.ownerId
? data.collection.ownerId === targetCollectionIsAccessible?.ownerId
: true;
if (!targetCollectionsAccessible)
return {
response: "Target collection is not accessible.",
status: 401,
};
else if (!targetCollectionMatchesData)
return {
response: "Target collection does not match the data.",
status: 401,
};
const unauthorizedSwitchCollection = const unauthorizedSwitchCollection =
!isCollectionOwner && collectionIsAccessible?.id !== data.collection.id; !isCollectionOwner && collectionIsAccessible?.id !== data.collection.id;
+35 -110
View File
@@ -22,114 +22,8 @@ export default async function postLink(
}; };
} }
if (!link.collection.id && link.collection.name) { if (!link.collection.name) {
link.collection.name = link.collection.name.trim();
// find the collection with the name and the user's id
const findCollection = await prisma.collection.findFirst({
where: {
name: link.collection.name,
ownerId: userId,
parentId: link.collection.parentId,
},
});
if (findCollection) {
const collectionIsAccessible = await getPermission({
userId,
collectionId: findCollection.id,
});
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canCreate
);
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
return { response: "Collection is not accessible.", status: 401 };
link.collection.id = findCollection.id;
} else {
const collection = await prisma.collection.create({
data: {
name: link.collection.name,
ownerId: userId,
},
});
link.collection.id = collection.id;
await prisma.user.update({
where: {
id: userId,
},
data: {
collectionOrder: {
push: link.collection.id,
},
},
});
}
} else if (link.collection.id) {
const collectionIsAccessible = await getPermission({
userId,
collectionId: link.collection.id,
});
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canCreate
);
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
return { response: "Collection is not accessible.", status: 401 };
} else if (!link.collection.id) {
link.collection.name = "Unorganized"; link.collection.name = "Unorganized";
link.collection.parentId = null;
// find the collection with the name "Unorganized" and the user's id
const unorganizedCollection = await prisma.collection.findFirst({
where: {
name: "Unorganized",
ownerId: userId,
},
});
link.collection.id = unorganizedCollection?.id;
await prisma.user.update({
where: {
id: userId,
},
data: {
collectionOrder: {
push: link.collection.id,
},
},
});
} else {
return { response: "Uncaught error.", status: 500 };
}
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (user?.preventDuplicateLinks) {
const existingLink = await prisma.link.findFirst({
where: {
url: link.url?.trim(),
collection: {
ownerId: userId,
},
},
});
if (existingLink)
return {
response: "Link already exists",
status: 409,
};
} }
const numberOfLinksTheUserHas = await prisma.link.count({ const numberOfLinksTheUserHas = await prisma.link.count({
@@ -148,6 +42,22 @@ export default async function postLink(
link.collection.name = link.collection.name.trim(); link.collection.name = link.collection.name.trim();
if (link.collection.id) {
const collectionIsAccessible = await getPermission({
userId,
collectionId: link.collection.id,
});
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canCreate
);
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
return { response: "Collection is not accessible.", status: 401 };
} else {
link.collection.ownerId = userId;
}
const description = const description =
link.description && link.description !== "" link.description && link.description !== ""
? link.description ? link.description
@@ -157,6 +67,12 @@ export default async function postLink(
const validatedUrl = link.url ? await validateUrlSize(link.url) : undefined; const validatedUrl = link.url ? await validateUrlSize(link.url) : undefined;
if (validatedUrl === null)
return {
response: "Something went wrong while retrieving the file size.",
status: 400,
};
const contentType = validatedUrl?.get("content-type"); const contentType = validatedUrl?.get("content-type");
let linkType = "url"; let linkType = "url";
let imageExtension = "png"; let imageExtension = "png";
@@ -171,13 +87,22 @@ export default async function postLink(
const newLink = await prisma.link.create({ const newLink = await prisma.link.create({
data: { data: {
url: link.url?.trim(), url: link.url,
name: link.name, name: link.name,
description, description,
type: linkType, type: linkType,
collection: { collection: {
connect: { connectOrCreate: {
id: link.collection.id, where: {
name_ownerId: {
ownerId: link.collection.ownerId,
name: link.collection.name,
},
},
create: {
name: link.collection.name.trim(),
ownerId: userId,
},
}, },
}, },
tags: { tags: {
@@ -13,8 +13,6 @@ export default async function exportData(userId: number) {
}, },
}, },
}, },
pinnedLinks: true,
whitelistedUsers: true,
}, },
}); });
@@ -1,7 +1,6 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import createFolder from "@/lib/api/storage/createFolder"; import createFolder from "@/lib/api/storage/createFolder";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import { parse, Node, Element, TextNode } from "himalaya";
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000; const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
@@ -12,11 +11,6 @@ export default async function importFromHTMLFile(
const dom = new JSDOM(rawData); const dom = new JSDOM(rawData);
const document = dom.window.document; const document = dom.window.document;
// remove bad tags
document.querySelectorAll("meta").forEach((e) => (e.outerHTML = e.innerHTML));
document.querySelectorAll("META").forEach((e) => (e.outerHTML = e.innerHTML));
document.querySelectorAll("P").forEach((e) => (e.outerHTML = e.innerHTML));
const bookmarks = document.querySelectorAll("A"); const bookmarks = document.querySelectorAll("A");
const totalImports = bookmarks.length; const totalImports = bookmarks.length;
@@ -34,165 +28,94 @@ export default async function importFromHTMLFile(
status: 400, status: 400,
}; };
const jsonData = parse(document.documentElement.outerHTML); const folders = document.querySelectorAll("H3");
for (const item of jsonData) { await prisma
console.log(item); .$transaction(
await processBookmarks(userId, item as Element); async () => {
} // @ts-ignore
for (const folder of folders) {
const findCollection = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
collections: {
where: {
name: folder.textContent.trim(),
},
},
},
});
const checkIfCollectionExists = findCollection?.collections[0];
let collectionId = findCollection?.collections[0]?.id;
if (!checkIfCollectionExists || !collectionId) {
const newCollection = await prisma.collection.create({
data: {
name: folder.textContent.trim(),
description: "",
color: "#0ea5e9",
isPublic: false,
ownerId: userId,
},
});
createFolder({ filePath: `archives/${newCollection.id}` });
collectionId = newCollection.id;
}
createFolder({ filePath: `archives/${collectionId}` });
const bookmarks = folder.nextElementSibling.querySelectorAll("A");
for (const bookmark of bookmarks) {
await prisma.link.create({
data: {
name: bookmark.textContent.trim(),
url: bookmark.getAttribute("HREF"),
tags: bookmark.getAttribute("TAGS")
? {
connectOrCreate: bookmark
.getAttribute("TAGS")
.split(",")
.map((tag: string) =>
tag
? {
where: {
name_ownerId: {
name: tag.trim(),
ownerId: userId,
},
},
create: {
name: tag.trim(),
owner: {
connect: {
id: userId,
},
},
},
}
: undefined
),
}
: undefined,
description: bookmark.getAttribute("DESCRIPTION")
? bookmark.getAttribute("DESCRIPTION")
: "",
collectionId: collectionId,
createdAt: new Date(),
},
});
}
}
},
{ timeout: 30000 }
)
.catch((err) => console.log(err));
return { response: "Success.", status: 200 }; return { response: "Success.", status: 200 };
} }
async function processBookmarks(
userId: number,
data: Node,
parentCollectionId?: number
) {
if (data.type === "element") {
for (const item of data.children) {
if (item.type === "element" && item.tagName === "dt") {
// process collection or sub-collection
let collectionId;
const collectionName = item.children.find(
(e) => e.type === "element" && e.tagName === "h3"
) as Element;
if (collectionName) {
collectionId = await createCollection(
userId,
(collectionName.children[0] as TextNode).content,
parentCollectionId
);
}
await processBookmarks(
userId,
item,
collectionId || parentCollectionId
);
} else if (item.type === "element" && item.tagName === "a") {
// process link
const linkUrl = item?.attributes.find((e) => e.key === "href")?.value;
const linkName = (
item?.children.find((e) => e.type === "text") as TextNode
)?.content;
const linkTags = item?.attributes
.find((e) => e.key === "tags")
?.value.split(",");
if (linkUrl && parentCollectionId) {
await createLink(
userId,
linkUrl,
parentCollectionId,
linkName,
"",
linkTags
);
} else if (linkUrl) {
// create a collection named "Imported Bookmarks" and add the link to it
const collectionId = await createCollection(userId, "Imports");
await createLink(
userId,
linkUrl,
collectionId,
linkName,
"",
linkTags
);
}
await processBookmarks(userId, item, parentCollectionId);
} else {
// process anything else
await processBookmarks(userId, item, parentCollectionId);
}
}
}
}
const createCollection = async (
userId: number,
collectionName: string,
parentId?: number
) => {
const findCollection = await prisma.collection.findFirst({
where: {
parentId,
name: collectionName,
ownerId: userId,
},
});
if (findCollection) {
return findCollection.id;
}
const collectionId = await prisma.collection.create({
data: {
name: collectionName,
parent: parentId
? {
connect: {
id: parentId,
},
}
: undefined,
owner: {
connect: {
id: userId,
},
},
},
});
createFolder({ filePath: `archives/${collectionId.id}` });
return collectionId.id;
};
const createLink = async (
userId: number,
url: string,
collectionId: number,
name?: string,
description?: string,
tags?: string[]
) => {
await prisma.link.create({
data: {
name: name || "",
url,
description,
collectionId,
tags:
tags && tags[0]
? {
connectOrCreate: tags.map((tag: string) => {
return (
{
where: {
name_ownerId: {
name: tag.trim(),
ownerId: userId,
},
},
create: {
name: tag.trim(),
owner: {
connect: {
id: userId,
},
},
},
} || undefined
);
}),
}
: undefined,
},
});
};
@@ -37,20 +37,41 @@ export default async function importFromLinkwarden(
for (const e of data.collections) { for (const e of data.collections) {
e.name = e.name.trim(); e.name = e.name.trim();
const newCollection = await prisma.collection.create({ const findCollection = await prisma.user.findUnique({
data: { where: {
owner: { id: userId,
connect: { },
id: userId, select: {
collections: {
where: {
name: e.name,
}, },
}, },
name: e.name,
description: e.description,
color: e.color,
}, },
}); });
createFolder({ filePath: `archives/${newCollection.id}` }); const checkIfCollectionExists = findCollection?.collections[0];
let collectionId = findCollection?.collections[0]?.id;
if (!checkIfCollectionExists) {
const newCollection = await prisma.collection.create({
data: {
owner: {
connect: {
id: userId,
},
},
name: e.name,
description: e.description,
color: e.color,
},
});
createFolder({ filePath: `archives/${newCollection.id}` });
collectionId = newCollection.id;
}
// Import Links // Import Links
for (const link of e.links) { for (const link of e.links) {
@@ -61,7 +82,7 @@ export default async function importFromLinkwarden(
description: link.description, description: link.description,
collection: { collection: {
connect: { connect: {
id: newCollection.id, id: collectionId,
}, },
}, },
// Import Tags // Import Tags
-21
View File
@@ -1,21 +0,0 @@
import { prisma } from "@/lib/api/db";
export default async function getToken(userId: number) {
const getTokens = await prisma.accessToken.findMany({
where: {
userId,
revoked: false,
},
select: {
id: true,
name: true,
expires: true,
createdAt: true,
},
});
return {
response: getTokens,
status: 200,
};
}
-92
View File
@@ -1,92 +0,0 @@
import { prisma } from "@/lib/api/db";
import { TokenExpiry } from "@/types/global";
import crypto from "crypto";
import { decode, encode } from "next-auth/jwt";
export default async function postToken(
body: {
name: string;
expires: TokenExpiry;
},
userId: number
) {
console.log(body);
const checkHasEmptyFields = !body.name || body.expires === undefined;
if (checkHasEmptyFields)
return {
response: "Please fill out all the fields.",
status: 400,
};
const checkIfTokenExists = await prisma.accessToken.findFirst({
where: {
name: body.name,
revoked: false,
userId,
},
});
if (checkIfTokenExists) {
return {
response: "Token with that name already exists.",
status: 400,
};
}
const now = Date.now();
let expiryDate = new Date();
const oneDayInSeconds = 86400;
let expiryDateSecond = 7 * oneDayInSeconds;
if (body.expires === TokenExpiry.oneMonth) {
expiryDate.setDate(expiryDate.getDate() + 30);
expiryDateSecond = 30 * oneDayInSeconds;
} else if (body.expires === TokenExpiry.twoMonths) {
expiryDate.setDate(expiryDate.getDate() + 60);
expiryDateSecond = 60 * oneDayInSeconds;
} else if (body.expires === TokenExpiry.threeMonths) {
expiryDate.setDate(expiryDate.getDate() + 90);
expiryDateSecond = 90 * oneDayInSeconds;
} else if (body.expires === TokenExpiry.never) {
expiryDate.setDate(expiryDate.getDate() + 73000); // 200 years (not really never)
expiryDateSecond = 73050 * oneDayInSeconds;
} else {
expiryDate.setDate(expiryDate.getDate() + 7);
expiryDateSecond = 7 * oneDayInSeconds;
}
const token = await encode({
token: {
id: userId,
iat: now / 1000,
exp: (expiryDate as any) / 1000,
jti: crypto.randomUUID(),
},
maxAge: expiryDateSecond || 604800,
secret: process.env.NEXTAUTH_SECRET,
});
const tokenBody = await decode({
token,
secret: process.env.NEXTAUTH_SECRET,
});
const createToken = await prisma.accessToken.create({
data: {
name: body.name,
userId,
token: tokenBody?.jti as string,
expires: expiryDate,
},
});
return {
response: {
secretKey: token,
token: createToken,
},
status: 200,
};
}
@@ -1,24 +0,0 @@
import { prisma } from "@/lib/api/db";
export default async function deleteToken(userId: number, tokenId: number) {
if (!tokenId)
return { response: "Please choose a valid token.", status: 401 };
const tokenExists = await prisma.accessToken.findFirst({
where: {
id: tokenId,
userId,
},
});
const revokedToken = await prisma.accessToken.update({
where: {
id: tokenExists?.id,
},
data: {
revoked: true,
},
});
return { response: revokedToken, status: 200 };
}
@@ -183,14 +183,9 @@ export default async function updateUserById(
email: data.email?.toLowerCase().trim(), email: data.email?.toLowerCase().trim(),
isPrivate: data.isPrivate, isPrivate: data.isPrivate,
image: data.image ? `uploads/avatar/${userId}.jpg` : "", image: data.image ? `uploads/avatar/${userId}.jpg` : "",
collectionOrder: data.collectionOrder.filter(
(value, index, self) => self.indexOf(value) === index
),
archiveAsScreenshot: data.archiveAsScreenshot, archiveAsScreenshot: data.archiveAsScreenshot,
archiveAsPDF: data.archiveAsPDF, archiveAsPDF: data.archiveAsPDF,
archiveAsWaybackMachine: data.archiveAsWaybackMachine, archiveAsWaybackMachine: data.archiveAsWaybackMachine,
linksRouteTo: data.linksRouteTo,
preventDuplicateLinks: data.preventDuplicateLinks,
password: password:
data.newPassword && data.newPassword !== "" data.newPassword && data.newPassword !== ""
? newHashedPassword ? newHashedPassword
+2 -5
View File
@@ -3,14 +3,12 @@ import { prisma } from "@/lib/api/db";
type Props = { type Props = {
userId: number; userId: number;
collectionId?: number; collectionId?: number;
collectionName?: string;
linkId?: number; linkId?: number;
}; };
export default async function getPermission({ export default async function getPermission({
userId, userId,
collectionId, collectionId,
collectionName,
linkId, linkId,
}: Props) { }: Props) {
if (linkId) { if (linkId) {
@@ -26,11 +24,10 @@ export default async function getPermission({
}); });
return check; return check;
} else if (collectionId || collectionName) { } else if (collectionId) {
const check = await prisma.collection.findFirst({ const check = await prisma.collection.findFirst({
where: { where: {
id: collectionId || undefined, id: collectionId,
name: collectionName || undefined,
OR: [{ ownerId: userId }, { members: { some: { userId } } }], OR: [{ ownerId: userId }, { members: { some: { userId } } }],
}, },
include: { members: true }, include: { members: true },
+4 -7
View File
@@ -16,11 +16,8 @@ export default async function paymentCheckout(
const isExistingCustomer = listByEmail?.data[0]?.id || undefined; const isExistingCustomer = listByEmail?.data[0]?.id || undefined;
console.log("isExistingCustomer", listByEmail?.data[0]);
const NEXT_PUBLIC_TRIAL_PERIOD_DAYS = const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
Number(process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS) || 14; process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS;
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
customer: isExistingCustomer ? isExistingCustomer : undefined, customer: isExistingCustomer ? isExistingCustomer : undefined,
line_items: [ line_items: [
@@ -37,9 +34,9 @@ export default async function paymentCheckout(
enabled: true, enabled: true,
}, },
subscription_data: { subscription_data: {
trial_period_days: isExistingCustomer trial_period_days: NEXT_PUBLIC_TRIAL_PERIOD_DAYS
? undefined ? Number(NEXT_PUBLIC_TRIAL_PERIOD_DAYS)
: NEXT_PUBLIC_TRIAL_PERIOD_DAYS, : 14,
}, },
}); });
+17 -15
View File
@@ -17,7 +17,15 @@ export default async function verifySubscription(
const currentDate = new Date(); const currentDate = new Date();
if (!subscription?.active || currentDate > subscription.currentPeriodEnd) { if (
subscription &&
currentDate > subscription.currentPeriodEnd &&
!subscription.active
) {
return null;
}
if (!subscription || currentDate > subscription.currentPeriodEnd) {
const { const {
active, active,
stripeSubscriptionId, stripeSubscriptionId,
@@ -51,21 +59,15 @@ export default async function verifySubscription(
}, },
}) })
.catch((err) => console.log(err)); .catch((err) => console.log(err));
} else if (!active) { }
const subscription = await prisma.subscription.findFirst({
where: {
userId: user.id,
},
});
if (subscription) if (!active) {
await prisma.subscription.delete({ if (user.username)
where: { // await prisma.user.update({
userId: user.id, // where: { id: user.id },
}, // data: { username: null },
}); // });
return null;
return null;
} }
} }
-36
View File
@@ -1,36 +0,0 @@
import { NextApiRequest } from "next";
import { JWT, getToken } from "next-auth/jwt";
import { prisma } from "./db";
type Props = {
req: NextApiRequest;
};
export default async function verifyToken({
req,
}: Props): Promise<JWT | string> {
const token = await getToken({ req });
const userId = token?.id;
if (!userId) {
return "You must be logged in.";
}
if (token.exp < Date.now() / 1000) {
return "Your session has expired, please log in again.";
}
// check if token is revoked
const revoked = await prisma.accessToken.findFirst({
where: {
token: token.jti,
revoked: true,
},
});
if (revoked) {
return "Your session has expired, please log in again.";
}
return token;
}
+6 -7
View File
@@ -1,8 +1,8 @@
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import { getToken } from "next-auth/jwt";
import { prisma } from "./db"; import { prisma } from "./db";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import verifySubscription from "./verifySubscription"; import verifySubscription from "./verifySubscription";
import verifyToken from "./verifyToken";
type Props = { type Props = {
req: NextApiRequest; req: NextApiRequest;
@@ -15,15 +15,14 @@ export default async function verifyUser({
req, req,
res, res,
}: Props): Promise<User | null> { }: Props): Promise<User | null> {
const token = await verifyToken({ req }); const token = await getToken({ req });
const userId = token?.id;
if (typeof token === "string") { if (!userId) {
res.status(401).json({ response: token }); res.status(401).json({ response: "You must be logged in." });
return null; return null;
} }
const userId = token?.id;
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
id: userId, id: userId,
@@ -52,7 +51,7 @@ export default async function verifyUser({
} }
if (STRIPE_SECRET_KEY) { if (STRIPE_SECRET_KEY) {
const subscribedUser = await verifySubscription(user); const subscribedUser = verifySubscription(user);
if (!subscribedUser) { if (!subscribedUser) {
res.status(401).json({ res.status(401).json({
-39
View File
@@ -1,39 +0,0 @@
import {
AccountSettings,
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { LinksRouteTo } from "@prisma/client";
import {
pdfAvailable,
readabilityAvailable,
screenshotAvailable,
} from "../shared/getArchiveValidity";
export const generateLinkHref = (
link: LinkIncludingShortenedCollectionAndTags,
account: AccountSettings
): string => {
// Return the links href based on the account's preference
// If the user's preference is not available, return the original link
switch (account.linksRouteTo) {
case LinksRouteTo.ORIGINAL:
return link.url || "";
case LinksRouteTo.PDF:
if (!pdfAvailable(link)) return link.url || "";
return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`;
case LinksRouteTo.READABLE:
if (!readabilityAvailable(link)) return link.url || "";
return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`;
case LinksRouteTo.SCREENSHOT:
if (!screenshotAvailable(link)) return link.url || "";
return `/preserved/${link?.id}?format=${
link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg
}`;
default:
return link.url || "";
}
};
-20
View File
@@ -1,20 +0,0 @@
export function isPWA() {
return (
window.matchMedia("(display-mode: standalone)").matches ||
(window.navigator as any).standalone ||
document.referrer.includes("android-app://")
);
}
export function isIphone() {
return /iPhone/.test(navigator.userAgent) && !(window as any).MSStream;
}
export function dropdownTriggerer(e: any) {
let targetEl = e.currentTarget;
if (targetEl && targetEl.matches(":focus")) {
setTimeout(function () {
targetEl.blur();
}, 0);
}
}
+3 -9
View File
@@ -1,8 +1,4 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; export function screenshotAvailable(link: any) {
export function screenshotAvailable(
link: LinkIncludingShortenedCollectionAndTags
) {
return ( return (
link && link &&
link.image && link.image &&
@@ -11,15 +7,13 @@ export function screenshotAvailable(
); );
} }
export function pdfAvailable(link: LinkIncludingShortenedCollectionAndTags) { export function pdfAvailable(link: any) {
return ( return (
link && link.pdf && link.pdf !== "pending" && link.pdf !== "unavailable" link && link.pdf && link.pdf !== "pending" && link.pdf !== "unavailable"
); );
} }
export function readabilityAvailable( export function readabilityAvailable(link: any) {
link: LinkIncludingShortenedCollectionAndTags
) {
return ( return (
link && link &&
link.readable && link.readable &&
+2 -21
View File
@@ -1,7 +1,5 @@
import fetch from "node-fetch"; import fetch from "node-fetch";
import https from "https"; import https from "https";
import { SocksProxyAgent } from "socks-proxy-agent";
export default async function getTitle(url: string) { export default async function getTitle(url: string) {
try { try {
const httpsAgent = new https.Agent({ const httpsAgent = new https.Agent({
@@ -9,26 +7,9 @@ export default async function getTitle(url: string) {
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true, process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
}); });
// fetchOpts allows a proxy to be defined const response = await fetch(url, {
let fetchOpts = {
agent: httpsAgent, agent: httpsAgent,
}; });
if (process.env.PROXY) {
// parse proxy url
let proxy = new URL(process.env.PROXY);
// if authentication set, apply to proxy URL
if (process.env.PROXY_USERNAME) {
proxy.username = process.env.PROXY_USERNAME;
proxy.password = process.env.PROXY_PASSWORD || "";
}
// add socks5 proxy to fetchOpts
fetchOpts = { agent: new SocksProxyAgent(proxy.toString()) }; //TODO: add support for http/https proxies
}
const response = await fetch(url, fetchOpts);
const text = await response.text(); const text = await response.text();
// regular expression to find the <title> tag // regular expression to find the <title> tag
+3 -9
View File
@@ -1,6 +1,6 @@
{ {
"name": "linkwarden", "name": "linkwarden",
"version": "2.5.1", "version": "2.4.7",
"main": "index.js", "main": "index.js",
"repository": "https://github.com/linkwarden/linkwarden.git", "repository": "https://github.com/linkwarden/linkwarden.git",
"author": "Daniel31X13 <daniel31x13@gmail.com>", "author": "Daniel31X13 <daniel31x13@gmail.com>",
@@ -10,16 +10,15 @@
"seed": "node ./prisma/seed.js" "seed": "node ./prisma/seed.js"
}, },
"scripts": { "scripts": {
"dev": "concurrently -k -P \"next dev {@}\" \"yarn worker:dev\" --", "dev": "concurrently -k \"next dev\" \"yarn worker:dev\"",
"worker:dev": "nodemon --skip-project scripts/worker.ts", "worker:dev": "nodemon --skip-project scripts/worker.ts",
"worker:prod": "ts-node --transpile-only --skip-project scripts/worker.ts", "worker:prod": "ts-node --transpile-only --skip-project scripts/worker.ts",
"start": "concurrently -k -P \"next start {@}\" \"yarn worker:prod\" --", "start": "concurrently \"next start\" \"yarn worker:prod\"",
"build": "next build", "build": "next build",
"lint": "next lint", "lint": "next lint",
"format": "prettier --write \"**/*.{ts,tsx,js,json,md}\"" "format": "prettier --write \"**/*.{ts,tsx,js,json,md}\""
}, },
"dependencies": { "dependencies": {
"@atlaskit/tree": "^8.8.7",
"@auth/prisma-adapter": "^1.0.1", "@auth/prisma-adapter": "^1.0.1",
"@aws-sdk/client-s3": "^3.379.1", "@aws-sdk/client-s3": "^3.379.1",
"@headlessui/react": "^1.7.15", "@headlessui/react": "^1.7.15",
@@ -45,14 +44,12 @@
"eslint-config-next": "13.4.9", "eslint-config-next": "13.4.9",
"formidable": "^3.5.1", "formidable": "^3.5.1",
"framer-motion": "^10.16.4", "framer-motion": "^10.16.4",
"himalaya": "^1.1.0",
"jimp": "^0.22.10", "jimp": "^0.22.10",
"jsdom": "^22.1.0", "jsdom": "^22.1.0",
"lottie-web": "^5.12.2", "lottie-web": "^5.12.2",
"micro": "^10.0.1", "micro": "^10.0.1",
"next": "13.4.12", "next": "13.4.12",
"next-auth": "^4.22.1", "next-auth": "^4.22.1",
"node-fetch": "^2.7.0",
"nodemailer": "^6.9.3", "nodemailer": "^6.9.3",
"playwright": "^1.35.1", "playwright": "^1.35.1",
"react": "18.2.0", "react": "18.2.0",
@@ -61,10 +58,7 @@
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"react-image-file-resizer": "^0.4.8", "react-image-file-resizer": "^0.4.8",
"react-select": "^5.7.4", "react-select": "^5.7.4",
"react-spinners": "^0.13.8",
"socks-proxy-agent": "^8.0.2",
"stripe": "^12.13.0", "stripe": "^12.13.0",
"vaul": "^0.8.8",
"zustand": "^4.3.8" "zustand": "^4.3.8"
}, },
"devDependencies": { "devDependencies": {
+55 -54
View File
@@ -7,63 +7,64 @@ import Head from "next/head";
import AuthRedirect from "@/layouts/AuthRedirect"; import AuthRedirect from "@/layouts/AuthRedirect";
import { Toaster } from "react-hot-toast"; import { Toaster } from "react-hot-toast";
import { Session } from "next-auth"; import { Session } from "next-auth";
import { isPWA } from "@/lib/client/utils";
export default function App({ export default function App({
Component, Component,
pageProps, pageProps,
}: AppProps<{ }: AppProps<{
session: Session; session: Session;
}>) { }>) {
useEffect(() => {
if (isPWA()) {
const meta = document.createElement("meta");
meta.name = "viewport";
meta.content = "width=device-width, initial-scale=1, maximum-scale=1";
document.getElementsByTagName("head")[0].appendChild(meta);
}
}, []);
return ( useEffect(() => {
<SessionProvider let theme = localStorage.getItem("theme");
session={pageProps.session} if (!theme || !theme.includes("-")) {
refetchOnWindowFocus={false} theme = "default-dark"; // Default theme
basePath="/api/v1/auth" localStorage.setItem("theme", theme);
> }
<Head>
<title>Linkwarden</title> document.documentElement.setAttribute('data-theme', theme);
<meta name="viewport" content="width=device-width, initial-scale=1" /> }, []);
<meta name="theme-color" content="#000000" />
<link
rel="apple-touch-icon" return (
sizes="180x180" <SessionProvider
href="/apple-touch-icon.png" session={pageProps.session}
/> refetchOnWindowFocus={false}
<link basePath="/api/v1/auth"
rel="icon" >
type="image/png" <Head>
sizes="32x32" <title>Linkwarden</title>
href="/favicon-32x32.png" <meta name="viewport" content="width=device-width, initial-scale=1" />
/> <link
<link rel="apple-touch-icon"
rel="icon" sizes="180x180"
type="image/png" href="/apple-touch-icon.png"
sizes="16x16" />
href="/favicon-16x16.png" <link
/> rel="icon"
<link rel="manifest" href="/site.webmanifest" /> type="image/png"
</Head> sizes="32x32"
<AuthRedirect> href="/favicon-32x32.png"
<Toaster />
position="top-center" <link
reverseOrder={false} rel="icon"
toastOptions={{ type="image/png"
className: sizes="16x16"
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white", href="/favicon-16x16.png"
}} />
/> <link rel="manifest" href="/site.webmanifest" />
<Component {...pageProps} /> </Head>
</AuthRedirect> <AuthRedirect>
</SessionProvider> <Toaster
); position="top-center"
reverseOrder={false}
toastOptions={{
className:
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white",
}}
/>
<Component {...pageProps} />
</AuthRedirect>
</SessionProvider>
);
} }
+3 -3
View File
@@ -1,5 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import readFile from "@/lib/api/storage/readFile"; import readFile from "@/lib/api/storage/readFile";
import { getToken } from "next-auth/jwt";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { ArchivedFormat } from "@/types/global"; import { ArchivedFormat } from "@/types/global";
import verifyUser from "@/lib/api/verifyUser"; import verifyUser from "@/lib/api/verifyUser";
@@ -8,7 +9,6 @@ import { UsersAndCollections } from "@prisma/client";
import formidable from "formidable"; import formidable from "formidable";
import createFile from "@/lib/api/storage/createFile"; import createFile from "@/lib/api/storage/createFile";
import fs from "fs"; import fs from "fs";
import verifyToken from "@/lib/api/verifyToken";
export const config = { export const config = {
api: { api: {
@@ -33,8 +33,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
return res.status(401).json({ response: "Invalid parameters." }); return res.status(401).json({ response: "Invalid parameters." });
if (req.method === "GET") { if (req.method === "GET") {
const token = await verifyToken({ req }); const token = await getToken({ req });
const userId = typeof token === "string" ? undefined : token?.id; const userId = token?.id;
const collectionIsAccessible = await prisma.collection.findFirst({ const collectionIsAccessible = await prisma.collection.findFirst({
where: { where: {
+55 -56
View File
@@ -65,7 +65,6 @@ import ZitadelProvider from "next-auth/providers/zitadel";
import ZohoProvider from "next-auth/providers/zoho"; import ZohoProvider from "next-auth/providers/zoho";
import ZoomProvider from "next-auth/providers/zoom"; import ZoomProvider from "next-auth/providers/zoom";
import * as process from "process"; import * as process from "process";
import type { NextApiRequest, NextApiResponse } from "next";
const emailEnabled = const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
@@ -1060,60 +1059,60 @@ if (process.env.NEXT_PUBLIC_ZOOM_ENABLED_ENABLED === "true") {
}; };
} }
export default async function auth(req: NextApiRequest, res: NextApiResponse) { export const authOptions: AuthOptions = {
return await NextAuth(req, res, { adapter: adapter as Adapter,
adapter: adapter as Adapter, session: {
session: { strategy: "jwt",
strategy: "jwt", maxAge: 30 * 24 * 60 * 60, // 30 days
maxAge: 30 * 24 * 60 * 60, // 30 days },
}, providers,
providers, pages: {
pages: { signIn: "/login",
signIn: "/login", verifyRequest: "/confirmation",
verifyRequest: "/confirmation", },
}, callbacks: {
callbacks: { async signIn({ user, account, profile, email, credentials }) {
async signIn({ user, account, profile, email, credentials }) { if (account?.provider !== "credentials") {
if (account?.provider !== "credentials") { // registration via SSO can be separately disabled
// registration via SSO can be separately disabled const existingUser = await prisma.account.findFirst({
const existingUser = await prisma.account.findFirst({ where: {
where: { providerAccountId: account?.providerAccountId,
providerAccountId: account?.providerAccountId, },
}, });
}); if (existingUser && newSsoUsersDisabled) {
if (existingUser && newSsoUsersDisabled) { return false;
return false;
}
} }
return true; }
}, return true;
async jwt({ token, trigger, user }) {
token.sub = token.sub ? Number(token.sub) : undefined;
if (trigger === "signIn" || trigger === "signUp")
token.id = user?.id as number;
return token;
},
async session({ session, token }) {
session.user.id = token.id;
if (STRIPE_SECRET_KEY) {
const user = await prisma.user.findUnique({
where: {
id: token.id,
},
include: {
subscriptions: true,
},
});
if (user) {
const subscribedUser = await verifySubscription(user);
}
}
return session;
},
}, },
}); async jwt({ token, trigger, user }) {
} token.sub = token.sub ? Number(token.sub) : undefined;
if (trigger === "signIn" || trigger === "signUp")
token.id = user?.id as number;
return token;
},
async session({ session, token }) {
session.user.id = token.id;
if (STRIPE_SECRET_KEY) {
const user = await prisma.user.findUnique({
where: {
id: token.id,
},
include: {
subscriptions: true,
},
});
if (user) {
const subscribedUser = await verifySubscription(user);
}
}
return session;
},
},
};
export default NextAuth(authOptions);
+3 -3
View File
@@ -1,7 +1,7 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import readFile from "@/lib/api/storage/readFile"; import readFile from "@/lib/api/storage/readFile";
import verifyToken from "@/lib/api/verifyToken"; import { getToken } from "next-auth/jwt";
export default async function Index(req: NextApiRequest, res: NextApiResponse) { export default async function Index(req: NextApiRequest, res: NextApiResponse) {
const queryId = Number(req.query.id); const queryId = Number(req.query.id);
@@ -12,8 +12,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
.status(401) .status(401)
.send("Invalid parameters."); .send("Invalid parameters.");
const token = await verifyToken({ req }); const token = await getToken({ req });
const userId = typeof token === "string" ? undefined : token?.id; const userId = token?.id;
if (req.method === "GET") { if (req.method === "GET") {
const targetUser = await prisma.user.findUnique({ const targetUser = await prisma.user.findUnique({
+10 -11
View File
@@ -1,5 +1,4 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import getCollectionById from "@/lib/api/controllers/collections/collectionId/getCollectionById";
import updateCollectionById from "@/lib/api/controllers/collections/collectionId/updateCollectionById"; import updateCollectionById from "@/lib/api/controllers/collections/collectionId/updateCollectionById";
import deleteCollectionById from "@/lib/api/controllers/collections/collectionId/deleteCollectionById"; import deleteCollectionById from "@/lib/api/controllers/collections/collectionId/deleteCollectionById";
import verifyUser from "@/lib/api/verifyUser"; import verifyUser from "@/lib/api/verifyUser";
@@ -11,18 +10,18 @@ export default async function collections(
const user = await verifyUser({ req, res }); const user = await verifyUser({ req, res });
if (!user) return; if (!user) return;
const collectionId = Number(req.query.id); if (req.method === "PUT") {
const updated = await updateCollectionById(
if (req.method === "GET") { user.id,
const collections = await getCollectionById(user.id, collectionId); Number(req.query.id) as number,
return res req.body
.status(collections.status) );
.json({ response: collections.response });
} else if (req.method === "PUT") {
const updated = await updateCollectionById(user.id, collectionId, req.body);
return res.status(updated.status).json({ response: updated.response }); return res.status(updated.status).json({ response: updated.response });
} else if (req.method === "DELETE") { } else if (req.method === "DELETE") {
const deleted = await deleteCollectionById(user.id, collectionId); const deleted = await deleteCollectionById(
user.id,
Number(req.query.id) as number
);
return res.status(deleted.status).json({ response: deleted.response }); return res.status(deleted.status).json({ response: deleted.response });
} }
} }
-17
View File
@@ -3,8 +3,6 @@ import getLinks from "@/lib/api/controllers/links/getLinks";
import postLink from "@/lib/api/controllers/links/postLink"; import postLink from "@/lib/api/controllers/links/postLink";
import { LinkRequestQuery } from "@/types/global"; import { LinkRequestQuery } from "@/types/global";
import verifyUser from "@/lib/api/verifyUser"; import verifyUser from "@/lib/api/verifyUser";
import deleteLinksById from "@/lib/api/controllers/links/bulk/deleteLinksById";
import updateLinks from "@/lib/api/controllers/links/bulk/updateLinks";
export default async function links(req: NextApiRequest, res: NextApiResponse) { export default async function links(req: NextApiRequest, res: NextApiResponse) {
const user = await verifyUser({ req, res }); const user = await verifyUser({ req, res });
@@ -41,20 +39,5 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
return res.status(newlink.status).json({ return res.status(newlink.status).json({
response: newlink.response, response: newlink.response,
}); });
} else if (req.method === "PUT") {
const updated = await updateLinks(
user.id,
req.body.links,
req.body.removePreviousTags,
req.body.newData
);
return res.status(updated.status).json({
response: updated.response,
});
} else if (req.method === "DELETE") {
const deleted = await deleteLinksById(user.id, req.body.linkIds);
return res.status(deleted.status).json({
response: deleted.response,
});
} }
} }
+3 -3
View File
@@ -1,10 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import getPublicUser from "@/lib/api/controllers/public/users/getPublicUser"; import getPublicUser from "@/lib/api/controllers/public/users/getPublicUser";
import verifyToken from "@/lib/api/verifyToken"; import { getToken } from "next-auth/jwt";
export default async function users(req: NextApiRequest, res: NextApiResponse) { export default async function users(req: NextApiRequest, res: NextApiResponse) {
const token = await verifyToken({ req }); const token = await getToken({ req });
const requestingId = typeof token === "string" ? undefined : token?.id; const requestingId = token?.id;
const lookupId = req.query.id as string; const lookupId = req.query.id as string;
-13
View File
@@ -1,13 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import verifyUser from "@/lib/api/verifyUser";
import deleteToken from "@/lib/api/controllers/tokens/tokenId/deleteTokenById";
export default async function token(req: NextApiRequest, res: NextApiResponse) {
const user = await verifyUser({ req, res });
if (!user) return;
if (req.method === "DELETE") {
const deleted = await deleteToken(user.id, Number(req.query.id) as number);
return res.status(deleted.status).json({ response: deleted.response });
}
}
-20
View File
@@ -1,20 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import verifyUser from "@/lib/api/verifyUser";
import postToken from "@/lib/api/controllers/tokens/postToken";
import getTokens from "@/lib/api/controllers/tokens/getTokens";
export default async function tokens(
req: NextApiRequest,
res: NextApiResponse
) {
const user = await verifyUser({ req, res });
if (!user) return;
if (req.method === "POST") {
const token = await postToken(JSON.parse(req.body), user.id);
return res.status(token.status).json({ response: token.response });
} else if (req.method === "GET") {
const token = await getTokens(user.id);
return res.status(token.status).json({ response: token.response });
}
}
+6 -8
View File
@@ -2,22 +2,20 @@ import type { NextApiRequest, NextApiResponse } from "next";
import getUserById from "@/lib/api/controllers/users/userId/getUserById"; import getUserById from "@/lib/api/controllers/users/userId/getUserById";
import updateUserById from "@/lib/api/controllers/users/userId/updateUserById"; import updateUserById from "@/lib/api/controllers/users/userId/updateUserById";
import deleteUserById from "@/lib/api/controllers/users/userId/deleteUserById"; import deleteUserById from "@/lib/api/controllers/users/userId/deleteUserById";
import { getToken } from "next-auth/jwt";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import verifySubscription from "@/lib/api/verifySubscription"; import verifySubscription from "@/lib/api/verifySubscription";
import verifyToken from "@/lib/api/verifyToken";
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
export default async function users(req: NextApiRequest, res: NextApiResponse) { export default async function users(req: NextApiRequest, res: NextApiResponse) {
const token = await verifyToken({ req }); const token = await getToken({ req });
if (typeof token === "string") {
res.status(401).json({ response: token });
return null;
}
const userId = token?.id; const userId = token?.id;
if (!userId) {
return res.status(401).json({ response: "You must be logged in." });
}
if (userId !== Number(req.query.id)) if (userId !== Number(req.query.id))
return res.status(401).json({ response: "Permission denied." }); return res.status(401).json({ response: "Permission denied." });
+27 -202
View File
@@ -23,19 +23,13 @@ import ViewDropdown from "@/components/ViewDropdown";
import CardView from "@/components/LinkViews/Layouts/CardView"; import CardView from "@/components/LinkViews/Layouts/CardView";
// import GridView from "@/components/LinkViews/Layouts/GridView"; // import GridView from "@/components/LinkViews/Layouts/GridView";
import ListView from "@/components/LinkViews/Layouts/ListView"; import ListView from "@/components/LinkViews/Layouts/ListView";
import { dropdownTriggerer } from "@/lib/client/utils";
import NewCollectionModal from "@/components/ModalContent/NewCollectionModal";
import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal";
import toast from "react-hot-toast";
import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal";
export default function Index() { export default function Index() {
const { settings } = useLocalSettingsStore(); const { settings } = useLocalSettingsStore();
const router = useRouter(); const router = useRouter();
const { links, selectedLinks, setSelectedLinks, deleteLinksById } = const { links } = useLinkStore();
useLinkStore();
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
@@ -84,23 +78,12 @@ export default function Index() {
}; };
fetchOwner(); fetchOwner();
// When the collection changes, reset the selected links
setSelectedLinks([]);
}, [activeCollection]); }, [activeCollection]);
const [editCollectionModal, setEditCollectionModal] = useState(false); const [editCollectionModal, setEditCollectionModal] = useState(false);
const [newCollectionModal, setNewCollectionModal] = useState(false);
const [editCollectionSharingModal, setEditCollectionSharingModal] = const [editCollectionSharingModal, setEditCollectionSharingModal] =
useState(false); useState(false);
const [deleteCollectionModal, setDeleteCollectionModal] = useState(false); const [deleteCollectionModal, setDeleteCollectionModal] = useState(false);
const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false);
const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false);
const [editMode, setEditMode] = useState(false);
useEffect(() => {
if (editMode) return setEditMode(false);
}, [router]);
const [viewMode, setViewMode] = useState<string>( const [viewMode, setViewMode] = useState<string>(
localStorage.getItem("viewMode") || ViewMode.Card localStorage.getItem("viewMode") || ViewMode.Card
@@ -115,44 +98,17 @@ export default function Index() {
// @ts-ignore // @ts-ignore
const LinkComponent = linkView[viewMode]; const LinkComponent = linkView[viewMode];
const handleSelectAll = () => {
if (selectedLinks.length === links.length) {
setSelectedLinks([]);
} else {
setSelectedLinks(links.map((link) => link));
}
};
const bulkDeleteLinks = async () => {
const load = toast.loading(
`Deleting ${selectedLinks.length} Link${
selectedLinks.length > 1 ? "s" : ""
}...`
);
const response = await deleteLinksById(
selectedLinks.map((link) => link.id as number)
);
toast.dismiss(load);
response.ok &&
toast.success(
`Deleted ${selectedLinks.length} Link${
selectedLinks.length > 1 ? "s" : ""
}!`
);
};
return ( return (
<MainLayout> <MainLayout>
<div <div
className="h-[60rem] p-5 flex gap-3 flex-col" className="h-[60rem] p-5 flex gap-3 flex-col"
style={{ style={{
backgroundImage: `linear-gradient(${activeCollection?.color}20 10%, ${ backgroundImage: `linear-gradient(${activeCollection?.color}20 10%, ${
settings.theme === "dark" ? "#262626" : "#f3f4f6" (settings.theme || "default-light").endsWith("-dark") ? "#262626" : "#f3f4f6"
} 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`, } 13rem, ${
}} (settings.theme || "default-light").endsWith("-dark") ? "#171717" : "#ffffff"
} 100%)`,
}}
> >
{activeCollection && ( {activeCollection && (
<div className="flex gap-3 items-start justify-between"> <div className="flex gap-3 items-start justify-between">
@@ -171,13 +127,12 @@ export default function Index() {
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-ghost btn-sm btn-square text-neutral" className="btn btn-ghost btn-sm btn-square text-neutral"
> >
<i className="bi-three-dots text-xl" title="More"></i> <i className="bi-three-dots text-xl" title="More"></i>
</div> </div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1"> <ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1">
{permissions === true && ( {permissions === true ? (
<li> <li>
<div <div
role="button" role="button"
@@ -190,7 +145,7 @@ export default function Index() {
Edit Collection Info Edit Collection Info
</div> </div>
</li> </li>
)} ) : undefined}
<li> <li>
<div <div
role="button" role="button"
@@ -205,20 +160,6 @@ export default function Index() {
: "View Team"} : "View Team"}
</div> </div>
</li> </li>
{permissions === true && (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewCollectionModal(true);
}}
>
Create Sub-Collection
</div>
</li>
)}
<li> <li>
<div <div
role="button" role="button"
@@ -238,7 +179,7 @@ export default function Index() {
</div> </div>
)} )}
{activeCollection && ( {activeCollection ? (
<div className={`min-w-[15rem]`}> <div className={`min-w-[15rem]`}>
<div className="flex gap-1 justify-center sm:justify-end items-center w-fit"> <div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
<div <div
@@ -274,127 +215,31 @@ export default function Index() {
</div> </div>
<p className="text-neutral text-sm font-semibold"> <p className="text-neutral text-sm font-semibold">
By {collectionOwner.name} By {collectionOwner.name}
{activeCollection.members.length > 0 && {activeCollection.members.length > 0
` and ${activeCollection.members.length} others`} ? ` and ${activeCollection.members.length} others`
: undefined}
. .
</p> </p>
</div> </div>
</div> </div>
)} ) : undefined}
{activeCollection?.description && ( {activeCollection?.description ? (
<p>{activeCollection?.description}</p> <p>{activeCollection?.description}</p>
)} ) : undefined}
{/* {collections.some((e) => e.parentId === activeCollection.id) ? (
<fieldset className="border rounded-md p-2 border-neutral-content">
<legend className="text-sm ml-2">Sub-Collections</legend>
<div className="flex gap-3">
{collections
.filter((e) => e.parentId === activeCollection?.id)
.map((e, i) => {
return (
<Link
key={i}
className="flex gap-1 items-center btn btn-ghost btn-sm"
href={`/collections/${e.id}`}
>
<i
className="bi-folder-fill text-2xl drop-shadow"
style={{ color: e.color }}
></i>
<p className="text-xs">{e.name}</p>
</Link>
);
})}
</div>
</fieldset>
) : undefined} */}
<div className="divider my-0"></div> <div className="divider my-0"></div>
<div className="flex justify-between items-center gap-5"> <div className="flex justify-between items-end gap-5">
<p>Showing {activeCollection?._count?.links} results</p> <p>Showing {activeCollection?._count?.links} results</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{links.length > 0 &&
(permissions === true ||
permissions?.canUpdate ||
permissions?.canDelete) && (
<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>
)}
<SortDropdown sortBy={sortBy} setSort={setSortBy} /> <SortDropdown sortBy={sortBy} setSort={setSortBy} />
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} /> <ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
</div> </div>
</div> </div>
{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}{" "}
{selectedLinks.length === 1 ? "link" : "links"} selected
</span>
) : (
<span>Nothing selected</span>
)}
</div>
)}
<div className="flex gap-3">
<button
onClick={() => setBulkEditLinksModal(true)}
className="btn btn-sm btn-accent text-white w-fit ml-auto"
disabled={
selectedLinks.length === 0 ||
!(permissions === true || permissions?.canUpdate)
}
>
Edit
</button>
<button
onClick={(e) => {
(document?.activeElement as HTMLElement)?.blur();
e.shiftKey
? bulkDeleteLinks()
: setBulkDeleteLinksModal(true);
}}
className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto"
disabled={
selectedLinks.length === 0 ||
!(permissions === true || permissions?.canDelete)
}
>
Delete
</button>
</div>
</div>
)}
{links.some((e) => e.collectionId === Number(router.query.id)) ? ( {links.some((e) => e.collectionId === Number(router.query.id)) ? (
<LinkComponent <LinkComponent
editMode={editMode}
links={links.filter( links={links.filter(
(e) => e.collection.id === activeCollection?.id (e) => e.collection.id === activeCollection?.id
)} )}
@@ -403,48 +248,28 @@ export default function Index() {
<NoLinksFound /> <NoLinksFound />
)} )}
</div> </div>
{activeCollection && ( {activeCollection ? (
<> <>
{editCollectionModal && ( {editCollectionModal ? (
<EditCollectionModal <EditCollectionModal
onClose={() => setEditCollectionModal(false)} onClose={() => setEditCollectionModal(false)}
activeCollection={activeCollection} activeCollection={activeCollection}
/> />
)} ) : undefined}
{editCollectionSharingModal && ( {editCollectionSharingModal ? (
<EditCollectionSharingModal <EditCollectionSharingModal
onClose={() => setEditCollectionSharingModal(false)} onClose={() => setEditCollectionSharingModal(false)}
activeCollection={activeCollection} activeCollection={activeCollection}
/> />
)} ) : undefined}
{newCollectionModal && ( {deleteCollectionModal ? (
<NewCollectionModal
onClose={() => setNewCollectionModal(false)}
parent={activeCollection}
/>
)}
{deleteCollectionModal && (
<DeleteCollectionModal <DeleteCollectionModal
onClose={() => setDeleteCollectionModal(false)} onClose={() => setDeleteCollectionModal(false)}
activeCollection={activeCollection} activeCollection={activeCollection}
/> />
)} ) : undefined}
{bulkDeleteLinksModal && (
<BulkDeleteLinksModal
onClose={() => {
setBulkDeleteLinksModal(false);
}}
/>
)}
{bulkEditLinksModal && (
<BulkEditLinksModal
onClose={() => {
setBulkEditLinksModal(false);
}}
/>
)}
</> </>
)} ) : undefined}
</MainLayout> </MainLayout>
); );
} }
+2 -1
View File
@@ -11,6 +11,7 @@ import PageHeader from "@/components/PageHeader";
export default function Collections() { export default function Collections() {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const [expandDropdown, setExpandDropdown] = useState(false);
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
const [sortedCollections, setSortedCollections] = useState(collections); const [sortedCollections, setSortedCollections] = useState(collections);
@@ -39,7 +40,7 @@ export default function Collections() {
<div className="grid min-[1900px]:grid-cols-4 2xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5"> <div className="grid min-[1900px]:grid-cols-4 2xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
{sortedCollections {sortedCollections
.filter((e) => e.ownerId === data?.user.id && e.parentId === null) .filter((e) => e.ownerId === data?.user.id)
.map((e, i) => { .map((e, i) => {
return <CollectionCard key={i} collection={e} />; return <CollectionCard key={i} collection={e} />;
})} })}
+259 -257
View File
@@ -2,6 +2,7 @@ import useLinkStore from "@/store/links";
import useCollectionStore from "@/store/collections"; import useCollectionStore from "@/store/collections";
import useTagStore from "@/store/tags"; import useTagStore from "@/store/tags";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import LinkCard from "@/components/LinkViews/LinkCard";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import useLinks from "@/hooks/useLinks"; import useLinks from "@/hooks/useLinks";
import Link from "next/link"; import Link from "next/link";
@@ -15,295 +16,296 @@ import PageHeader from "@/components/PageHeader";
import CardView from "@/components/LinkViews/Layouts/CardView"; import CardView from "@/components/LinkViews/Layouts/CardView";
import ListView from "@/components/LinkViews/Layouts/ListView"; import ListView from "@/components/LinkViews/Layouts/ListView";
import ViewDropdown from "@/components/ViewDropdown"; import ViewDropdown from "@/components/ViewDropdown";
import { dropdownTriggerer } from "@/lib/client/utils";
// import GridView from "@/components/LinkViews/Layouts/GridView"; // import GridView from "@/components/LinkViews/Layouts/GridView";
export default function Dashboard() { export default function Dashboard() {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const { links } = useLinkStore(); const { links } = useLinkStore();
const { tags } = useTagStore(); const { tags } = useTagStore();
const [numberOfLinks, setNumberOfLinks] = useState(0); const [numberOfLinks, setNumberOfLinks] = useState(0);
const [showLinks, setShowLinks] = useState(3); const [showLinks, setShowLinks] = useState(3);
useLinks({ pinnedOnly: true, sort: 0 }); useLinks({ pinnedOnly: true, sort: 0 });
useEffect(() => { useEffect(() => {
setNumberOfLinks( setNumberOfLinks(
collections.reduce( collections.reduce(
(accumulator, collection) => (accumulator, collection) =>
accumulator + (collection._count as any).links, accumulator + (collection._count as any).links,
0 0
) )
); );
}, [collections]); }, [collections]);
const handleNumberOfLinksToShow = () => { const handleNumberOfLinksToShow = () => {
if (window.innerWidth > 1900) { if (window.innerWidth > 1900) {
setShowLinks(8); setShowLinks(8);
} else if (window.innerWidth > 1280) { } else if (window.innerWidth > 1280) {
setShowLinks(6); setShowLinks(6);
} else if (window.innerWidth > 650) { } else if (window.innerWidth > 650) {
setShowLinks(4); setShowLinks(4);
} else setShowLinks(3); } else setShowLinks(3);
}; };
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
useEffect(() => { useEffect(() => {
handleNumberOfLinksToShow(); handleNumberOfLinksToShow();
}, [width]); }, [width]);
const importBookmarks = async (e: any, format: MigrationFormat) => { const importBookmarks = async (e: any, format: MigrationFormat) => {
const file: File = e.target.files[0]; const file: File = e.target.files[0];
if (file) { if (file) {
var reader = new FileReader(); var reader = new FileReader();
reader.readAsText(file, "UTF-8"); reader.readAsText(file, "UTF-8");
reader.onload = async function (e) { reader.onload = async function (e) {
const load = toast.loading("Importing..."); const load = toast.loading("Importing...");
const request: string = e.target?.result as string; const request: string = e.target?.result as string;
const body: MigrationRequest = { const body: MigrationRequest = {
format, format,
data: request, data: request,
}; };
const response = await fetch("/api/v1/migration", { const response = await fetch("/api/v1/migration", {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
const data = await response.json(); const data = await response.json();
toast.dismiss(load); toast.dismiss(load);
toast.success("Imported the Bookmarks! Reloading the page..."); toast.success("Imported the Bookmarks! Reloading the page...");
setTimeout(() => { setTimeout(() => {
location.reload(); location.reload();
}, 2000); }, 2000);
}; };
reader.onerror = function (e) { reader.onerror = function (e) {
console.log("Error:", e); console.log("Error:", e);
}; };
} }
}; };
const [newLinkModal, setNewLinkModal] = useState(false); const [newLinkModal, setNewLinkModal] = useState(false);
const [viewMode, setViewMode] = useState<string>( const [viewMode, setViewMode] = useState<string>(
localStorage.getItem("viewMode") || ViewMode.Card localStorage.getItem("viewMode") || ViewMode.Card
); );
const linkView = { const linkView = {
[ViewMode.Card]: CardView, [ViewMode.Card]: CardView,
// [ViewMode.Grid]: GridView, // [ViewMode.Grid]: GridView,
[ViewMode.List]: ListView, [ViewMode.List]: ListView,
}; };
// @ts-ignore // @ts-ignore
const LinkComponent = linkView[viewMode]; const LinkComponent = linkView[viewMode];
return ( return (
<MainLayout> <MainLayout>
<div style={{ flex: "1 1 auto" }} className="p-5 flex flex-col gap-5"> <div style={{ flex: "1 1 auto" }} className="p-5 flex flex-col gap-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<PageHeader <PageHeader
icon={"bi-house "} icon={"bi-house "}
title={"Dashboard"} title={"Dashboard"}
description={"A brief overview of your data"} description={"A brief overview of your data"}
/> />
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} /> <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 ? "Link" : "Links"}
value={numberOfLinks}
icon={"bi-link-45deg"}
/>
<div className="divider xl:divider-horizontal"></div>
<DashboardItem
name={collections.length === 1 ? "Collection" : "Collections"}
value={collections.length}
icon={"bi-folder"}
/>
<div className="divider xl:divider-horizontal"></div>
<DashboardItem
name={tags.length === 1 ? "Tag" : "Tags"}
value={tags.length}
icon={"bi-hash"}
/>
</div>
</div>
<div className="flex justify-between items-center">
<div className="flex gap-2 items-center">
<PageHeader
icon={"bi-clock-history"}
title={"Recent"}
description={"Recently added Links"}
/>
</div>
<Link
href="/links"
className="flex items-center text-sm text-black/75 dark:text-white/75 gap-2 cursor-pointer"
>
View All
<i className="bi-chevron-right text-sm"></i>
</Link>
</div>
<div
style={{ flex: "0 1 auto" }}
className="flex flex-col 2xl:flex-row items-start 2xl:gap-2"
>
{links[0] ? (
<div className="w-full">
<LinkComponent links={links.slice(0, showLinks)} />
</div> </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 ? "Link" : "Links"}
value={numberOfLinks}
icon={"bi-link-45deg"}
/>
<div className="divider xl:divider-horizontal"></div>
<DashboardItem
name={collections.length === 1 ? "Collection" : "Collections"}
value={collections.length}
icon={"bi-folder"}
/>
<div className="divider xl:divider-horizontal"></div>
<DashboardItem
name={tags.length === 1 ? "Tag" : "Tags"}
value={tags.length}
icon={"bi-hash"}
/>
</div>
</div>
<div className="flex justify-between items-center">
<div className="flex gap-2 items-center">
<PageHeader
icon={"bi-clock-history"}
title={"Recent"}
description={"Recently added Links"}
/>
</div>
<Link
href="/links"
className="flex items-center text-sm text-neutral gap-2 cursor-pointer"
>
View All
<i className="bi-chevron-right text-sm"></i>
</Link>
</div>
<div <div
style={{ flex: "1 1 auto" }} style={{ flex: "0 1 auto" }}
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" className="flex flex-col 2xl:flex-row items-start 2xl:gap-2"
> >
<p className="text-center text-2xl"> {links[0] ? (
View Your Recently Added Links Here! <div className="w-full">
</p> <LinkComponent links={links.slice(0, showLinks)} />
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm mt-2">
This section will view your latest added Links across every
Collections you have access to.
</p>
<div className="text-center w-full mt-4 flex flex-wrap gap-4 justify-center">
<div
onClick={() => {
setNewLinkModal(true);
}}
className="inline-flex items-center gap-2 text-sm btn btn-accent dark:border-violet-400 text-white"
>
<i className="bi-plus-lg text-xl duration-100"></i>
<span className="group-hover:opacity-0 text-right duration-100">
Add New Link
</span>
</div>
<div className="dropdown dropdown-bottom">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="inline-flex items-center gap-2 text-sm btn btn-outline btn-neutral"
id="import-dropdown"
>
<i className="bi-cloud-upload text-xl duration-100"></i>
<p>Import From</p>
</div> </div>
<ul className="shadow menu dropdown-content z-[1] bg-base-200 border border-neutral-content rounded-box mt-1 w-60"> ) : (
<li> <div
<label style={{ flex: "1 1 auto" }}
tabIndex={0} 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"
role="button" >
htmlFor="import-linkwarden-file" <p className="text-center text-2xl">
title="JSON File" View Your Recently Added Links Here!
> </p>
From Linkwarden <p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm mt-2">
<input This section will view your latest added Links across every
type="file" Collections you have access to.
name="photo" </p>
id="import-linkwarden-file"
accept=".json"
className="hidden"
onChange={(e) =>
importBookmarks(e, MigrationFormat.linkwarden)
}
/>
</label>
</li>
<li>
<label
tabIndex={0}
role="button"
htmlFor="import-html-file"
title="HTML File"
>
From Bookmarks HTML file
<input
type="file"
name="photo"
id="import-html-file"
accept=".html"
className="hidden"
onChange={(e) =>
importBookmarks(e, MigrationFormat.htmlFile)
}
/>
</label>
</li>
</ul>
</div>
</div>
</div>
)}
</div>
<div className="flex justify-between items-center"> <div className="text-center w-full mt-4 flex flex-wrap gap-4 justify-center">
<div className="flex gap-2 items-center"> <div
<PageHeader onClick={() => {
icon={"bi-pin-angle"} setNewLinkModal(true);
title={"Pinned"} }}
description={"Your pinned Links"} className="inline-flex items-center gap-2 text-sm btn btn-accent dark:border-violet-400 text-white"
/> >
</div> <i className="bi-plus-lg text-xl duration-100"></i>
<Link <span className="group-hover:opacity-0 text-right duration-100">
href="/links/pinned" Add New Link
className="flex items-center text-sm text-black/75 dark:text-white/75 gap-2 cursor-pointer" </span>
> </div>
View All
<i className="bi-chevron-right text-sm "></i>
</Link>
</div>
<div <div className="dropdown dropdown-bottom">
style={{ flex: "1 1 auto" }} <div
className="flex flex-col 2xl:flex-row items-start 2xl:gap-2" tabIndex={0}
> role="button"
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? ( className="inline-flex items-center gap-2 text-sm btn btn-outline btn-neutral"
<div className="w-full"> id="import-dropdown"
<LinkComponent >
links={links <i className="bi-cloud-upload text-xl duration-100"></i>
.filter((e) => e.pinnedBy && e.pinnedBy[0]) <p>Import From</p>
.slice(0, showLinks)} </div>
/> <ul className="shadow menu dropdown-content z-[1] bg-base-200 border border-neutral-content rounded-box mt-1 w-60">
<li>
<label
tabIndex={0}
role="button"
htmlFor="import-linkwarden-file"
title="JSON File"
>
From Linkwarden
<input
type="file"
name="photo"
id="import-linkwarden-file"
accept=".json"
className="hidden"
onChange={(e) =>
importBookmarks(e, MigrationFormat.linkwarden)
}
/>
</label>
</li>
<li>
<label
tabIndex={0}
role="button"
htmlFor="import-html-file"
title="HTML File"
>
From Bookmarks HTML file
<input
type="file"
name="photo"
id="import-html-file"
accept=".html"
className="hidden"
onChange={(e) =>
importBookmarks(e, MigrationFormat.htmlFile)
}
/>
</label>
</li>
</ul>
</div>
</div>
</div>
)}
</div> </div>
) : (
<div className="flex justify-between items-center">
<div className="flex gap-2 items-center">
<PageHeader
icon={"bi-pin-angle"}
title={"Pinned"}
description={"Your pinned Links"}
/>
</div>
<Link
href="/links/pinned"
className="flex items-center text-sm text-neutral gap-2 cursor-pointer"
>
View All
<i className="bi-chevron-right text-sm "></i>
</Link>
</div>
<div <div
style={{ flex: "1 1 auto" }} style={{ flex: "1 1 auto" }}
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" className="flex flex-col 2xl:flex-row items-start 2xl:gap-2"
> >
<p className="text-center text-2xl"> {links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
Pin Your Favorite Links Here! <div className="w-full">
</p> <div
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm mt-2"> className={`grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5 w-full`}
You can Pin your favorite Links by clicking on the three dots on >
each Link and clicking{" "} {links
<span className="font-semibold">Pin to Dashboard</span>. .filter((e) => e.pinnedBy && e.pinnedBy[0])
</p> .map((e, i) => <LinkCard key={i} link={e} count={i} />)
.slice(0, showLinks)}
</div>
</div>
) : (
<div
style={{ flex: "1 1 auto" }}
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"
>
<p className="text-center text-2xl">
Pin Your Favorite Links Here!
</p>
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm mt-2">
You can Pin your favorite Links by clicking on the three dots on
each Link and clicking{" "}
<span className="font-semibold">Pin to Dashboard</span>.
</p>
</div>
)}
</div> </div>
)} </div>
</div> {newLinkModal ? (
</div> <NewLinkModal onClose={() => setNewLinkModal(false)} />
{newLinkModal ? ( ) : undefined}
<NewLinkModal onClose={() => setNewLinkModal(false)} /> </MainLayout>
) : undefined} );
</MainLayout>
);
} }
+4 -141
View File
@@ -3,73 +3,24 @@ import SortDropdown from "@/components/SortDropdown";
import useLinks from "@/hooks/useLinks"; import useLinks from "@/hooks/useLinks";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import React, { useEffect, useState } from "react"; import React, { useState } from "react";
import PageHeader from "@/components/PageHeader"; import PageHeader from "@/components/PageHeader";
import { Member, Sort, ViewMode } from "@/types/global"; import { Sort, ViewMode } from "@/types/global";
import ViewDropdown from "@/components/ViewDropdown"; import ViewDropdown from "@/components/ViewDropdown";
import CardView from "@/components/LinkViews/Layouts/CardView"; import CardView from "@/components/LinkViews/Layouts/CardView";
import ListView from "@/components/LinkViews/Layouts/ListView"; import ListView from "@/components/LinkViews/Layouts/ListView";
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
import toast from "react-hot-toast";
import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal";
import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal";
// import GridView from "@/components/LinkViews/Layouts/GridView"; // import GridView from "@/components/LinkViews/Layouts/GridView";
import { useRouter } from "next/router";
export default function Links() { export default function Links() {
const { links, selectedLinks, deleteLinksById, setSelectedLinks } = const { links } = useLinkStore();
useLinkStore();
const [viewMode, setViewMode] = useState<string>( const [viewMode, setViewMode] = useState<string>(
localStorage.getItem("viewMode") || ViewMode.Card localStorage.getItem("viewMode") || ViewMode.Card
); );
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
const router = useRouter();
const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false);
const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false);
const [editMode, setEditMode] = useState(false);
useEffect(() => {
if (editMode) return setEditMode(false);
}, [router]);
const collectivePermissions = useCollectivePermissions(
selectedLinks.map((link) => link.collectionId as number)
);
useLinks({ sort: sortBy }); useLinks({ sort: sortBy });
const handleSelectAll = () => {
if (selectedLinks.length === links.length) {
setSelectedLinks([]);
} else {
setSelectedLinks(links.map((link) => link));
}
};
const bulkDeleteLinks = async () => {
const load = toast.loading(
`Deleting ${selectedLinks.length} Link${
selectedLinks.length > 1 ? "s" : ""
}...`
);
const response = await deleteLinksById(
selectedLinks.map((link) => link.id as number)
);
toast.dismiss(load);
response.ok &&
toast.success(
`Deleted ${selectedLinks.length} Link${
selectedLinks.length > 1 ? "s" : ""
}!`
);
};
const linkView = { const linkView = {
[ViewMode.Card]: CardView, [ViewMode.Card]: CardView,
// [ViewMode.Grid]: GridView, // [ViewMode.Grid]: GridView,
@@ -90,105 +41,17 @@ export default function Links() {
/> />
<div className="mt-2 flex items-center justify-end gap-2"> <div className="mt-2 flex items-center justify-end gap-2">
{links.length > 0 && (
<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>
)}
<SortDropdown sortBy={sortBy} setSort={setSortBy} /> <SortDropdown sortBy={sortBy} setSort={setSortBy} />
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} /> <ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
</div> </div>
</div> </div>
{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}{" "}
{selectedLinks.length === 1 ? "link" : "links"} selected
</span>
) : (
<span>Nothing selected</span>
)}
</div>
)}
<div className="flex gap-3">
<button
onClick={() => setBulkEditLinksModal(true)}
className="btn btn-sm btn-accent text-white w-fit ml-auto"
disabled={
selectedLinks.length === 0 ||
!(
collectivePermissions === true ||
collectivePermissions?.canUpdate
)
}
>
Edit
</button>
<button
onClick={(e) => {
(document?.activeElement as HTMLElement)?.blur();
e.shiftKey
? bulkDeleteLinks()
: setBulkDeleteLinksModal(true);
}}
className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto"
disabled={
selectedLinks.length === 0 ||
!(
collectivePermissions === true ||
collectivePermissions?.canDelete
)
}
>
Delete
</button>
</div>
</div>
)}
{links[0] ? ( {links[0] ? (
<LinkComponent editMode={editMode} links={links} /> <LinkComponent links={links} />
) : ( ) : (
<NoLinksFound text="You Haven't Created Any Links Yet" /> <NoLinksFound text="You Haven't Created Any Links Yet" />
)} )}
</div> </div>
{bulkDeleteLinksModal && (
<BulkDeleteLinksModal
onClose={() => {
setBulkDeleteLinksModal(false);
}}
/>
)}
{bulkEditLinksModal && (
<BulkEditLinksModal
onClose={() => {
setBulkEditLinksModal(false);
}}
/>
)}
</MainLayout> </MainLayout>
); );
} }
+3 -139
View File
@@ -2,22 +2,16 @@ import SortDropdown from "@/components/SortDropdown";
import useLinks from "@/hooks/useLinks"; import useLinks from "@/hooks/useLinks";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import React, { useEffect, useState } from "react"; import React, { useState } from "react";
import PageHeader from "@/components/PageHeader"; import PageHeader from "@/components/PageHeader";
import { Sort, ViewMode } from "@/types/global"; import { Sort, ViewMode } from "@/types/global";
import ViewDropdown from "@/components/ViewDropdown"; import ViewDropdown from "@/components/ViewDropdown";
import CardView from "@/components/LinkViews/Layouts/CardView"; import CardView from "@/components/LinkViews/Layouts/CardView";
import ListView from "@/components/LinkViews/Layouts/ListView"; import ListView from "@/components/LinkViews/Layouts/ListView";
import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal";
import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal";
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
import toast from "react-hot-toast";
// import GridView from "@/components/LinkViews/Layouts/GridView"; // import GridView from "@/components/LinkViews/Layouts/GridView";
import { useRouter } from "next/router";
export default function PinnedLinks() { export default function PinnedLinks() {
const { links, selectedLinks, deleteLinksById, setSelectedLinks } = const { links } = useLinkStore();
useLinkStore();
const [viewMode, setViewMode] = useState<string>( const [viewMode, setViewMode] = useState<string>(
localStorage.getItem("viewMode") || ViewMode.Card localStorage.getItem("viewMode") || ViewMode.Card
@@ -26,48 +20,6 @@ export default function PinnedLinks() {
useLinks({ sort: sortBy, pinnedOnly: true }); useLinks({ sort: sortBy, pinnedOnly: true });
const router = useRouter();
const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false);
const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false);
const [editMode, setEditMode] = useState(false);
useEffect(() => {
if (editMode) return setEditMode(false);
}, [router]);
const collectivePermissions = useCollectivePermissions(
selectedLinks.map((link) => link.collectionId as number)
);
const handleSelectAll = () => {
if (selectedLinks.length === links.length) {
setSelectedLinks([]);
} else {
setSelectedLinks(links.map((link) => link));
}
};
const bulkDeleteLinks = async () => {
const load = toast.loading(
`Deleting ${selectedLinks.length} Link${
selectedLinks.length > 1 ? "s" : ""
}...`
);
const response = await deleteLinksById(
selectedLinks.map((link) => link.id as number)
);
toast.dismiss(load);
response.ok &&
toast.success(
`Deleted ${selectedLinks.length} Link${
selectedLinks.length > 1 ? "s" : ""
}!`
);
};
const linkView = { const linkView = {
[ViewMode.Card]: CardView, [ViewMode.Card]: CardView,
// [ViewMode.Grid]: GridView, // [ViewMode.Grid]: GridView,
@@ -87,87 +39,13 @@ export default function PinnedLinks() {
description={"Pinned Links from your Collections"} description={"Pinned Links from your Collections"}
/> />
<div className="mt-2 flex items-center justify-end gap-2"> <div className="mt-2 flex items-center justify-end gap-2">
{!(links.length === 0) && (
<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>
)}
<SortDropdown sortBy={sortBy} setSort={setSortBy} /> <SortDropdown sortBy={sortBy} setSort={setSortBy} />
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} /> <ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
</div> </div>
</div> </div>
{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}{" "}
{selectedLinks.length === 1 ? "link" : "links"} selected
</span>
) : (
<span>Nothing selected</span>
)}
</div>
)}
<div className="flex gap-3">
<button
onClick={() => setBulkEditLinksModal(true)}
className="btn btn-sm btn-accent text-white w-fit ml-auto"
disabled={
selectedLinks.length === 0 ||
!(
collectivePermissions === true ||
collectivePermissions?.canUpdate
)
}
>
Edit
</button>
<button
onClick={(e) => {
(document?.activeElement as HTMLElement)?.blur();
e.shiftKey
? bulkDeleteLinks()
: setBulkDeleteLinksModal(true);
}}
className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto"
disabled={
selectedLinks.length === 0 ||
!(
collectivePermissions === true ||
collectivePermissions?.canDelete
)
}
>
Delete
</button>
</div>
</div>
)}
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? ( {links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
<LinkComponent editMode={editMode} links={links} /> <LinkComponent links={links} />
) : ( ) : (
<div <div
style={{ flex: "1 1 auto" }} style={{ flex: "1 1 auto" }}
@@ -184,20 +62,6 @@ export default function PinnedLinks() {
</div> </div>
)} )}
</div> </div>
{bulkDeleteLinksModal && (
<BulkDeleteLinksModal
onClose={() => {
setBulkDeleteLinksModal(false);
}}
/>
)}
{bulkEditLinksModal && (
<BulkEditLinksModal
onClose={() => {
setBulkEditLinksModal(false);
}}
/>
)}
</MainLayout> </MainLayout>
); );
} }
-7
View File
@@ -170,13 +170,6 @@ export default function Login({
{displayLoginCredential()} {displayLoginCredential()}
{displayLoginExternalButton()} {displayLoginExternalButton()}
{displayRegistration()} {displayRegistration()}
<Link
href="https://docs.linkwarden.app/getting-started/pwa-installation"
className="underline text-center"
target="_blank"
>
You can install Linkwarden onto your device
</Link>
</div> </div>
</form> </form>
</CenteredForm> </CenteredForm>
+1 -1
View File
@@ -60,8 +60,8 @@ export default function PublicCollections() {
name: true, name: true,
url: true, url: true,
description: true, description: true,
textContent: true,
tags: true, tags: true,
textContent: false,
}); });
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
+9 -21
View File
@@ -5,13 +5,12 @@ import MainLayout from "@/layouts/MainLayout";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import { Sort, ViewMode } from "@/types/global"; import { Sort, ViewMode } from "@/types/global";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React, { useEffect, useState } from "react"; import React, { useState } from "react";
import ViewDropdown from "@/components/ViewDropdown"; import ViewDropdown from "@/components/ViewDropdown";
import CardView from "@/components/LinkViews/Layouts/CardView"; import CardView from "@/components/LinkViews/Layouts/CardView";
// import GridView from "@/components/LinkViews/Layouts/GridView"; // import GridView from "@/components/LinkViews/Layouts/GridView";
import ListView from "@/components/LinkViews/Layouts/ListView"; import ListView from "@/components/LinkViews/Layouts/ListView";
import PageHeader from "@/components/PageHeader"; import PageHeader from "@/components/PageHeader";
import { GridLoader, PropagateLoader } from "react-spinners";
export default function Search() { export default function Search() {
const { links } = useLinkStore(); const { links } = useLinkStore();
@@ -22,16 +21,18 @@ export default function Search() {
name: true, name: true,
url: true, url: true,
description: true, description: true,
textContent: true,
tags: true, tags: true,
textContent: false,
}); });
const [filterDropdown, setFilterDropdown] = useState(false);
const [viewMode, setViewMode] = useState<string>( const [viewMode, setViewMode] = useState<string>(
localStorage.getItem("viewMode") || ViewMode.Card localStorage.getItem("viewMode") || ViewMode.Card
); );
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
const { isLoading } = useLinks({ useLinks({
sort: sortBy, sort: sortBy,
searchQueryString: decodeURIComponent(router.query.q as string), searchQueryString: decodeURIComponent(router.query.q as string),
searchByName: searchFilter.name, searchByName: searchFilter.name,
@@ -41,10 +42,6 @@ export default function Search() {
searchByTags: searchFilter.tags, searchByTags: searchFilter.tags,
}); });
useEffect(() => {
console.log("isLoading", isLoading);
}, [isLoading]);
const linkView = { const linkView = {
[ViewMode.Card]: CardView, [ViewMode.Card]: CardView,
// [ViewMode.Grid]: GridView, // [ViewMode.Grid]: GridView,
@@ -56,7 +53,7 @@ export default function Search() {
return ( return (
<MainLayout> <MainLayout>
<div className="p-5 flex flex-col gap-5 w-full h-full"> <div className="p-5 flex flex-col gap-5 w-full">
<div className="flex justify-between"> <div className="flex justify-between">
<PageHeader icon={"bi-search"} title={"Search Results"} /> <PageHeader icon={"bi-search"} title={"Search Results"} />
@@ -72,24 +69,15 @@ export default function Search() {
</div> </div>
</div> </div>
{!isLoading && !links[0] ? ( {links[0] ? (
<LinkComponent links={links} />
) : (
<p> <p>
Nothing found.{" "} Nothing found.{" "}
<span className="font-bold text-xl" title="Shruggie"> <span className="font-bold text-xl" title="Shruggie">
¯\_()_/¯ ¯\_()_/¯
</span> </span>
</p> </p>
) : links[0] ? (
<LinkComponent links={links} isLoading={isLoading} />
) : (
isLoading && (
<GridLoader
color="oklch(var(--p))"
loading={true}
size={20}
className="m-auto py-10"
/>
)
)} )}
</div> </div>
</MainLayout> </MainLayout>
-107
View File
@@ -1,107 +0,0 @@
import SettingsLayout from "@/layouts/SettingsLayout";
import React, { useEffect, useState } from "react";
import NewTokenModal from "@/components/ModalContent/NewTokenModal";
import RevokeTokenModal from "@/components/ModalContent/RevokeTokenModal";
import { AccessToken } from "@prisma/client";
import useTokenStore from "@/store/tokens";
export default function AccessTokens() {
const [newTokenModal, setNewTokenModal] = useState(false);
const [revokeTokenModal, setRevokeTokenModal] = useState(false);
const [selectedToken, setSelectedToken] = useState<AccessToken | null>(null);
const openRevokeModal = (token: AccessToken) => {
setSelectedToken(token);
setRevokeTokenModal(true);
};
const { setTokens, tokens } = useTokenStore();
useEffect(() => {
fetch("/api/v1/tokens")
.then((res) => res.json())
.then((data) => {
if (data.response) setTokens(data.response as AccessToken[]);
});
}, []);
return (
<SettingsLayout>
<p className="capitalize text-3xl font-thin inline">Access Tokens</p>
<div className="divider my-3"></div>
<div className="flex flex-col gap-3">
<p>
Access Tokens can be used to access Linkwarden from other apps and
services without giving away your Username and Password.
</p>
<button
className={`btn ml-auto btn-accent dark:border-violet-400 text-white tracking-wider w-fit flex items-center gap-2`}
onClick={() => {
setNewTokenModal(true);
}}
>
New Access Token
</button>
{tokens.length > 0 ? (
<>
<div className="divider my-0"></div>
<table className="table">
{/* head */}
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Created</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody>
{tokens.map((token, i) => (
<React.Fragment key={i}>
<tr>
<th>{i + 1}</th>
<td>{token.name}</td>
<td>
{new Date(token.createdAt || "").toLocaleDateString()}
</td>
<td>
{new Date(token.expires || "").toLocaleDateString()}
</td>
<td>
<button
className="btn btn-sm btn-ghost btn-square hover:bg-red-500"
onClick={() => openRevokeModal(token as AccessToken)}
>
<i className="bi-x text-lg"></i>
</button>
</td>
</tr>
</React.Fragment>
))}
</tbody>
</table>
</>
) : undefined}
</div>
{newTokenModal ? (
<NewTokenModal onClose={() => setNewTokenModal(false)} />
) : undefined}
{revokeTokenModal && selectedToken && (
<RevokeTokenModal
onClose={() => {
setRevokeTokenModal(false);
setSelectedToken(null);
}}
activeToken={selectedToken}
/>
)}
</SettingsLayout>
);
}
+5 -7
View File
@@ -11,7 +11,6 @@ import React from "react";
import { MigrationFormat, MigrationRequest } from "@/types/global"; import { MigrationFormat, MigrationRequest } from "@/types/global";
import Link from "next/link"; import Link from "next/link";
import Checkbox from "@/components/Checkbox"; import Checkbox from "@/components/Checkbox";
import { dropdownTriggerer } from "@/lib/client/utils";
export default function Account() { export default function Account() {
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
@@ -192,8 +191,8 @@ export default function Account() {
) : undefined} ) : undefined}
</div> </div>
<div className="sm:row-span-2 sm:justify-self-center my-3"> <div className="sm:row-span-2 sm:justify-self-center mx-auto my-3">
<p className="mb-2 sm:text-center">Profile Photo</p> <p className="mb-2 text-center">Profile Photo</p>
<div className="w-28 h-28 flex items-center justify-center rounded-full relative"> <div className="w-28 h-28 flex items-center justify-center rounded-full relative">
<ProfilePhoto <ProfilePhoto
priority={true} priority={true}
@@ -246,7 +245,6 @@ export default function Account() {
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer}
className="flex gap-2 text-sm btn btn-outline btn-neutral group" className="flex gap-2 text-sm btn btn-outline btn-neutral group"
id="import-dropdown" id="import-dropdown"
> >
@@ -349,8 +347,8 @@ export default function Account() {
<SubmitButton <SubmitButton
onClick={submit} onClick={submit}
loading={submitLoader} loading={submitLoader}
label="Save Changes" label="Save"
className="mt-2 w-full sm:w-fit" className="mt-2 mx-auto lg:mx-0"
/> />
<div> <div>
@@ -375,7 +373,7 @@ export default function Account() {
<Link <Link
href="/settings/delete" href="/settings/delete"
className="text-white w-full sm:w-fit flex items-center gap-2 py-2 px-4 rounded-md text-lg tracking-wide select-none font-semibold duration-100 bg-red-500 hover:bg-red-400 cursor-pointer" className="mx-auto lg:mx-0 text-white flex items-center gap-2 py-1 px-3 rounded-md text-lg tracking-wide select-none font-semibold duration-100 w-fit bg-red-500 hover:bg-red-400 cursor-pointer"
> >
<p className="text-center w-full">Delete Your Account</p> <p className="text-center w-full">Delete Your Account</p>
</Link> </Link>
+78
View File
@@ -0,0 +1,78 @@
import Checkbox from "@/components/Checkbox";
import SubmitButton from "@/components/SubmitButton";
import SettingsLayout from "@/layouts/SettingsLayout";
import React, { useEffect, useState } from "react";
import useAccountStore from "@/store/account";
import { toast } from "react-hot-toast";
import { AccountSettings } from "@/types/global";
import TextInput from "@/components/TextInput";
export default function Api() {
const [submitLoader, setSubmitLoader] = useState(false);
const { account, updateAccount } = useAccountStore();
const [user, setUser] = useState<AccountSettings>(account);
const [archiveAsScreenshot, setArchiveAsScreenshot] =
useState<boolean>(false);
const [archiveAsPDF, setArchiveAsPDF] = useState<boolean>(false);
const [archiveAsWaybackMachine, setArchiveAsWaybackMachine] =
useState<boolean>(false);
useEffect(() => {
setUser({
...account,
archiveAsScreenshot,
archiveAsPDF,
archiveAsWaybackMachine,
});
}, [account, archiveAsScreenshot, archiveAsPDF, archiveAsWaybackMachine]);
function objectIsEmpty(obj: object) {
return Object.keys(obj).length === 0;
}
useEffect(() => {
if (!objectIsEmpty(account)) {
setArchiveAsScreenshot(account.archiveAsScreenshot);
setArchiveAsPDF(account.archiveAsPDF);
setArchiveAsWaybackMachine(account.archiveAsWaybackMachine);
}
}, [account]);
const submit = async () => {
// setSubmitLoader(true);
// const load = toast.loading("Applying...");
// const response = await updateAccount({
// ...user,
// });
// toast.dismiss(load);
// if (response.ok) {
// toast.success("Settings Applied!");
// } else toast.error(response.data as string);
// setSubmitLoader(false);
};
return (
<SettingsLayout>
<p className="capitalize text-3xl font-thin inline">API Keys (Soon)</p>
<div className="divider my-3"></div>
<div className="flex flex-col gap-3">
<div className="badge badge-warning rounded-md w-fit">
Status: Under Development
</div>
<p>This page will be for creating and managing your API keys.</p>
<p>
For now, you can <i>temporarily</i> use your{" "}
<code className="text-xs whitespace-nowrap bg-black/40 rounded-md px-2 py-1">
next-auth.session-token
</code>{" "}
in your browser cookies as the API key for your integrations.
</p>
</div>
</SettingsLayout>
);
}
+125
View File
@@ -0,0 +1,125 @@
import SettingsLayout from "@/layouts/SettingsLayout";
import { useState, useEffect } from "react";
import useAccountStore from "@/store/account";
import { AccountSettings } from "@/types/global";
import { toast } from "react-hot-toast";
import useLocalSettingsStore from "@/store/localSettings";
export default function Appearance() {
const { updateSettings } = useLocalSettingsStore();
const { account, updateAccount } = useAccountStore();
const submit = async () => {
setSubmitLoader(true);
const load = toast.loading("Applying...");
const response = await updateAccount({
...user,
});
toast.dismiss(load);
if (response.ok) {
toast.success("Settings Applied!");
} else toast.error(response.data as string);
setSubmitLoader(false);
};
const [submitLoader, setSubmitLoader] = useState(false);
const [user, setUser] = useState<AccountSettings>(
!objectIsEmpty(account)
? account
: ({
// @ts-ignore
id: null,
name: "",
username: "",
email: "",
emailVerified: null,
blurredFavicons: null,
image: "",
isPrivate: true,
// @ts-ignore
createdAt: null,
whitelistedUsers: [],
} as unknown as AccountSettings)
);
// Combine colorTheme and mode into a single state
const [theme, setTheme] = useState(localStorage.getItem("theme") || "default-dark");
function objectIsEmpty(obj: object) {
return Object.keys(obj).length === 0;
}
useEffect(() => {
if (!objectIsEmpty(account)) setUser({ ...account });
}, [account]);
const handleThemeChange = (newThemePart: string, isColorTheme: boolean) => {
const currentTheme = localStorage.getItem("theme") || "default-light";
const [currentColorTheme, currentMode] = currentTheme.split('-');
const newTheme = isColorTheme ? `${newThemePart}-${currentMode}` : `${currentColorTheme}-${newThemePart}`;
localStorage.setItem("theme", newTheme);
document.documentElement.setAttribute('data-theme', newTheme);
updateSettings({ theme: newTheme });
// Update the theme state
setTheme(newTheme);
};
return (
<SettingsLayout>
<p className="capitalize text-3xl font-thin inline">Appearance</p>
<div className="divider my-3"></div>
<div className="flex flex-col gap-5">
<div>
<p className="mb-3">Select Mode</p>
<div className="grid grid-cols-2 gap-3">
{["light", "dark"].map((modeOption) => (
<button
key={modeOption}
onClick={() => handleThemeChange(modeOption, false)}
className={`w-full text-center h-36 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none ${theme.endsWith(modeOption) ? "ring-2 ring-primary" : "ring-2 ring-neutral"}`}
>
{modeOption === 'light' ?
<i className={`bi-sun-fill text-6xl ${theme.endsWith(modeOption) ? "text-primary" : ""}`}></i> :
<i className={`bi-moon-fill text-6xl ${theme.endsWith(modeOption) ? "text-primary" : ""}`}></i>}
<p className="ml-2 text-2xl">{modeOption.charAt(0).toUpperCase() + modeOption.slice(1)}</p>
</button>
))}
</div>
</div>
<div>
<p className="mb-3">Select Color Theme</p>
<div className="grid grid-cols-4 gap-3">
{["default", "red", "green", "orange"].map((colorTheme) => (
<button
key={colorTheme}
onClick={() => handleThemeChange(colorTheme, true)}
className={`w-full text-center h-36 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none ${theme.startsWith(colorTheme) ? "ring-2 ring-primary" : "ring-2 ring-neutral"}`}
>
{colorTheme.charAt(0).toUpperCase() + colorTheme.slice(1)}
</button>
))}
</div>
</div>
<button
onClick={submit}
disabled={submitLoader}
className="mt-2 mx-auto lg:mx-0 bg-primary text-white rounded-md px-4 py-2"
>
{submitLoader ? "Saving..." : "Save"}
</button>
</div>
</SettingsLayout>
);
}
+93
View File
@@ -0,0 +1,93 @@
import Checkbox from "@/components/Checkbox";
import SubmitButton from "@/components/SubmitButton";
import SettingsLayout from "@/layouts/SettingsLayout";
import React, { useEffect, useState } from "react";
import useAccountStore from "@/store/account";
import { toast } from "react-hot-toast";
import { AccountSettings } from "@/types/global";
export default function Archive() {
const [submitLoader, setSubmitLoader] = useState(false);
const { account, updateAccount } = useAccountStore();
const [user, setUser] = useState<AccountSettings>(account);
const [archiveAsScreenshot, setArchiveAsScreenshot] =
useState<boolean>(false);
const [archiveAsPDF, setArchiveAsPDF] = useState<boolean>(false);
const [archiveAsWaybackMachine, setArchiveAsWaybackMachine] =
useState<boolean>(false);
useEffect(() => {
setUser({
...account,
archiveAsScreenshot,
archiveAsPDF,
archiveAsWaybackMachine,
});
}, [account, archiveAsScreenshot, archiveAsPDF, archiveAsWaybackMachine]);
function objectIsEmpty(obj: object) {
return Object.keys(obj).length === 0;
}
useEffect(() => {
if (!objectIsEmpty(account)) {
setArchiveAsScreenshot(account.archiveAsScreenshot);
setArchiveAsPDF(account.archiveAsPDF);
setArchiveAsWaybackMachine(account.archiveAsWaybackMachine);
}
}, [account]);
const submit = async () => {
setSubmitLoader(true);
const load = toast.loading("Applying...");
const response = await updateAccount({
...user,
});
toast.dismiss(load);
if (response.ok) {
toast.success("Settings Applied!");
} else toast.error(response.data as string);
setSubmitLoader(false);
};
return (
<SettingsLayout>
<p className="capitalize text-3xl font-thin inline">Archive Settings</p>
<div className="divider my-3"></div>
<p>Formats to Archive/Preserve webpages:</p>
<div className="p-3">
<Checkbox
label="Screenshot"
state={archiveAsScreenshot}
onClick={() => setArchiveAsScreenshot(!archiveAsScreenshot)}
/>
<Checkbox
label="PDF"
state={archiveAsPDF}
onClick={() => setArchiveAsPDF(!archiveAsPDF)}
/>
<Checkbox
label="Archive.org Snapshot"
state={archiveAsWaybackMachine}
onClick={() => setArchiveAsWaybackMachine(!archiveAsWaybackMachine)}
/>
</div>
<SubmitButton
onClick={submit}
loading={submitLoader}
label="Save"
className="mt-2 mx-auto lg:mx-0"
/>
</SettingsLayout>
);
}
+2 -2
View File
@@ -77,8 +77,8 @@ export default function Password() {
<SubmitButton <SubmitButton
onClick={submit} onClick={submit}
loading={submitLoader} loading={submitLoader}
label="Save Changes" label="Save"
className="mt-2 w-full sm:w-fit" className="mt-2 mx-auto lg:mx-0"
/> />
</div> </div>
</SettingsLayout> </SettingsLayout>
-237
View File
@@ -1,237 +0,0 @@
import SettingsLayout from "@/layouts/SettingsLayout";
import { useState, useEffect } from "react";
import useAccountStore from "@/store/account";
import { AccountSettings } from "@/types/global";
import { toast } from "react-hot-toast";
import React from "react";
import useLocalSettingsStore from "@/store/localSettings";
import Checkbox from "@/components/Checkbox";
import SubmitButton from "@/components/SubmitButton";
import { LinksRouteTo } from "@prisma/client";
export default function Appearance() {
const { updateSettings } = useLocalSettingsStore();
const [submitLoader, setSubmitLoader] = useState(false);
const { account, updateAccount } = useAccountStore();
const [user, setUser] = useState<AccountSettings>(account);
const [preventDuplicateLinks, setPreventDuplicateLinks] =
useState<boolean>(false);
const [archiveAsScreenshot, setArchiveAsScreenshot] =
useState<boolean>(false);
const [archiveAsPDF, setArchiveAsPDF] = useState<boolean>(false);
const [archiveAsWaybackMachine, setArchiveAsWaybackMachine] =
useState<boolean>(false);
const [linksRouteTo, setLinksRouteTo] = useState<LinksRouteTo>(
user.linksRouteTo
);
useEffect(() => {
setUser({
...account,
archiveAsScreenshot,
archiveAsPDF,
archiveAsWaybackMachine,
linksRouteTo,
preventDuplicateLinks,
});
}, [
account,
archiveAsScreenshot,
archiveAsPDF,
archiveAsWaybackMachine,
linksRouteTo,
preventDuplicateLinks,
]);
function objectIsEmpty(obj: object) {
return Object.keys(obj).length === 0;
}
useEffect(() => {
if (!objectIsEmpty(account)) {
setArchiveAsScreenshot(account.archiveAsScreenshot);
setArchiveAsPDF(account.archiveAsPDF);
setArchiveAsWaybackMachine(account.archiveAsWaybackMachine);
setLinksRouteTo(account.linksRouteTo);
setPreventDuplicateLinks(account.preventDuplicateLinks);
}
}, [account]);
const submit = async () => {
setSubmitLoader(true);
const load = toast.loading("Applying...");
const response = await updateAccount({
...user,
});
toast.dismiss(load);
if (response.ok) {
toast.success("Settings Applied!");
} else toast.error(response.data as string);
setSubmitLoader(false);
};
return (
<SettingsLayout>
<p className="capitalize text-3xl font-thin inline">Preference</p>
<div className="divider my-3"></div>
<div className="flex flex-col gap-5">
<div>
<p className="mb-3">Select Theme</p>
<div className="flex gap-3 w-full">
<div
className={`w-full text-center outline-solid outline-neutral-content outline dark:outline-neutral-700 h-36 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none bg-black ${
localStorage.getItem("theme") === "dark"
? "dark:outline-primary text-primary"
: "text-white"
}`}
onClick={() => updateSettings({ theme: "dark" })}
>
<i className="bi-moon-fill text-6xl"></i>
<p className="ml-2 text-2xl">Dark</p>
{/* <hr className="my-3 outline-1 outline-neutral-content dark:outline-neutral-700" /> */}
</div>
<div
className={`w-full text-center outline-solid outline-neutral-content outline dark:outline-neutral-700 h-36 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none bg-white ${
localStorage.getItem("theme") === "light"
? "outline-primary text-primary"
: "text-black"
}`}
onClick={() => updateSettings({ theme: "light" })}
>
<i className="bi-sun-fill text-6xl"></i>
<p className="ml-2 text-2xl">Light</p>
{/* <hr className="my-3 outline-1 outline-neutral-content dark:outline-neutral-700" /> */}
</div>
</div>
</div>
<div>
<p className="capitalize text-3xl font-thin inline">
Archive Settings
</p>
<div className="divider my-3"></div>
<p>Formats to Archive/Preserve webpages:</p>
<div className="p-3">
<Checkbox
label="Screenshot"
state={archiveAsScreenshot}
onClick={() => setArchiveAsScreenshot(!archiveAsScreenshot)}
/>
<Checkbox
label="PDF"
state={archiveAsPDF}
onClick={() => setArchiveAsPDF(!archiveAsPDF)}
/>
<Checkbox
label="Archive.org Snapshot"
state={archiveAsWaybackMachine}
onClick={() =>
setArchiveAsWaybackMachine(!archiveAsWaybackMachine)
}
/>
</div>
</div>
<div>
<p className="capitalize text-3xl font-thin inline">Link Settings</p>
<div className="divider my-3"></div>
<div className="mb-3">
<Checkbox
label="Prevent duplicate links"
state={preventDuplicateLinks}
onClick={() => setPreventDuplicateLinks(!preventDuplicateLinks)}
/>
</div>
<p>Clicking on Links should:</p>
<div className="p-3">
<label
className="label cursor-pointer flex gap-2 justify-start w-fit"
tabIndex={0}
role="button"
>
<input
type="radio"
name="link-preference-radio"
className="radio checked:bg-primary"
value="Original"
checked={linksRouteTo === LinksRouteTo.ORIGINAL}
onChange={() => setLinksRouteTo(LinksRouteTo.ORIGINAL)}
/>
<span className="label-text">Open the original content</span>
</label>
<label
className="label cursor-pointer flex gap-2 justify-start w-fit"
tabIndex={0}
role="button"
>
<input
type="radio"
name="link-preference-radio"
className="radio checked:bg-primary"
value="PDF"
checked={linksRouteTo === LinksRouteTo.PDF}
onChange={() => setLinksRouteTo(LinksRouteTo.PDF)}
/>
<span className="label-text">Open PDF, if available</span>
</label>
<label
className="label cursor-pointer flex gap-2 justify-start w-fit"
tabIndex={0}
role="button"
>
<input
type="radio"
name="link-preference-radio"
className="radio checked:bg-primary"
value="Readable"
checked={linksRouteTo === LinksRouteTo.READABLE}
onChange={() => setLinksRouteTo(LinksRouteTo.READABLE)}
/>
<span className="label-text">Open Readable, if available</span>
</label>
<label
className="label cursor-pointer flex gap-2 justify-start w-fit"
tabIndex={0}
role="button"
>
<input
type="radio"
name="link-preference-radio"
className="radio checked:bg-primary"
value="Screenshot"
checked={linksRouteTo === LinksRouteTo.SCREENSHOT}
onChange={() => setLinksRouteTo(LinksRouteTo.SCREENSHOT)}
/>
<span className="label-text">Open Screenshot, if available</span>
</label>
</div>
</div>
<SubmitButton
onClick={submit}
loading={submitLoader}
label="Save Changes"
className="mt-2 w-full sm:w-fit"
/>
</div>
</SettingsLayout>
);
}
+10 -20
View File
@@ -5,7 +5,6 @@ import { useRouter } from "next/router";
import CenteredForm from "@/layouts/CenteredForm"; import CenteredForm from "@/layouts/CenteredForm";
import { Plan } from "@/types/global"; import { Plan } from "@/types/global";
import AccentSubmitButton from "@/components/AccentSubmitButton"; import AccentSubmitButton from "@/components/AccentSubmitButton";
import useAccountStore from "@/store/account";
export default function Subscribe() { export default function Subscribe() {
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
@@ -13,8 +12,6 @@ export default function Subscribe() {
const [plan, setPlan] = useState<Plan>(1); const [plan, setPlan] = useState<Plan>(1);
const { account } = useAccountStore();
const router = useRouter(); const router = useRouter();
async function submit() { async function submit() {
@@ -30,13 +27,9 @@ export default function Subscribe() {
return ( return (
<CenteredForm <CenteredForm
text={ text={`Start with a ${
account.username process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14
? "" }-day free trial, cancel anytime!`}
: `Start with a ${
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14
}-day free trial, cancel anytime!`
}
> >
<div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-2xl shadow-md border border-neutral-content"> <div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-2xl shadow-md border border-neutral-content">
<p className="sm:text-3xl text-2xl text-center font-extralight"> <p className="sm:text-3xl text-2xl text-center font-extralight">
@@ -44,6 +37,7 @@ export default function Subscribe() {
</p> </p>
<div className="divider my-0"></div> <div className="divider my-0"></div>
<div> <div>
<p> <p>
You will be redirected to Stripe, feel free to reach out to us at{" "} You will be redirected to Stripe, feel free to reach out to us at{" "}
@@ -53,6 +47,7 @@ export default function Subscribe() {
in case of any issue. in case of any issue.
</p> </p>
</div> </div>
<div className="flex gap-3 border border-solid border-neutral-content w-4/5 mx-auto p-1 rounded-xl relative"> <div className="flex gap-3 border border-solid border-neutral-content w-4/5 mx-auto p-1 rounded-xl relative">
<button <button
onClick={() => setPlan(Plan.monthly)} onClick={() => setPlan(Plan.monthly)}
@@ -79,6 +74,7 @@ export default function Subscribe() {
25% Off 25% Off
</div> </div>
</div> </div>
<div className="flex flex-col gap-2 justify-center items-center"> <div className="flex flex-col gap-2 justify-center items-center">
<p className="text-3xl"> <p className="text-3xl">
${plan === Plan.monthly ? "4" : "3"} ${plan === Plan.monthly ? "4" : "3"}
@@ -93,20 +89,13 @@ export default function Subscribe() {
</legend> </legend>
<p className="text-sm"> <p className="text-sm">
{account.username {process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS}-day free trial, then $
? "" {plan === Plan.monthly ? "4 per month" : "36 annually"}
: `${process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS}-day free trial, then `}
${plan === Plan.monthly ? "4 per month" : "36 annually"}
</p> </p>
<p className="text-sm">+ VAT if applicable</p> <p className="text-sm">+ VAT if applicable</p>
</fieldset> </fieldset>
<p className="text-sm mb-5">
{account.username
? "Please note that since your trial has been previously ended, your subscription will start immediately. You can cancel anytime."
: ""}
</p>
</div> </div>
<AccentSubmitButton <AccentSubmitButton
type="button" type="button"
label="Complete Subscription!" label="Complete Subscription!"
@@ -114,6 +103,7 @@ export default function Subscribe() {
onClick={submit} onClick={submit}
loading={submitLoader} loading={submitLoader}
/> />
<div <div
onClick={() => signOut()} onClick={() => signOut()}
className="w-fit mx-auto cursor-pointer text-neutral font-semibold " className="w-fit mx-auto cursor-pointer text-neutral font-semibold "
+4 -144
View File
@@ -1,6 +1,6 @@
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { FormEvent, use, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import useTagStore from "@/store/tags"; import useTagStore from "@/store/tags";
import SortDropdown from "@/components/SortDropdown"; import SortDropdown from "@/components/SortDropdown";
@@ -11,16 +11,11 @@ import ViewDropdown from "@/components/ViewDropdown";
import CardView from "@/components/LinkViews/Layouts/CardView"; import CardView from "@/components/LinkViews/Layouts/CardView";
// import GridView from "@/components/LinkViews/Layouts/GridView"; // import GridView from "@/components/LinkViews/Layouts/GridView";
import ListView from "@/components/LinkViews/Layouts/ListView"; import ListView from "@/components/LinkViews/Layouts/ListView";
import { dropdownTriggerer } from "@/lib/client/utils";
import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal";
import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal";
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
export default function Index() { export default function Index() {
const router = useRouter(); const router = useRouter();
const { links, selectedLinks, deleteLinksById, setSelectedLinks } = const { links } = useLinkStore();
useLinkStore();
const { tags, updateTag, removeTag } = useTagStore(); const { tags, updateTag, removeTag } = useTagStore();
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
@@ -30,30 +25,11 @@ export default function Index() {
const [activeTag, setActiveTag] = useState<TagIncludingLinkCount>(); const [activeTag, setActiveTag] = useState<TagIncludingLinkCount>();
const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false);
const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false);
const [editMode, setEditMode] = useState(false);
useEffect(() => {
if (editMode) return setEditMode(false);
}, [router]);
const collectivePermissions = useCollectivePermissions(
selectedLinks.map((link) => link.collectionId as number)
);
useLinks({ tagId: Number(router.query.id), sort: sortBy }); useLinks({ tagId: Number(router.query.id), sort: sortBy });
useEffect(() => { useEffect(() => {
const tag = tags.find((e) => e.id === Number(router.query.id)); setActiveTag(tags.find((e) => e.id === Number(router.query.id)));
}, [router, tags]);
if (tags.length > 0 && !tag?.id) {
router.push("/dashboard");
return;
}
setActiveTag(tag);
}, [router, tags, Number(router.query.id), setActiveTag]);
useEffect(() => { useEffect(() => {
setNewTagName(activeTag?.name); setNewTagName(activeTag?.name);
@@ -114,35 +90,6 @@ export default function Index() {
setRenameTag(false); setRenameTag(false);
}; };
const handleSelectAll = () => {
if (selectedLinks.length === links.length) {
setSelectedLinks([]);
} else {
setSelectedLinks(links.map((link) => link));
}
};
const bulkDeleteLinks = async () => {
const load = toast.loading(
`Deleting ${selectedLinks.length} Link${
selectedLinks.length > 1 ? "s" : ""
}...`
);
const response = await deleteLinksById(
selectedLinks.map((link) => link.id as number)
);
toast.dismiss(load);
response.ok &&
toast.success(
`Deleted ${selectedLinks.length} Link${
selectedLinks.length > 1 ? "s" : ""
}!`
);
};
const [viewMode, setViewMode] = useState<string>( const [viewMode, setViewMode] = useState<string>(
localStorage.getItem("viewMode") || ViewMode.Card localStorage.getItem("viewMode") || ViewMode.Card
); );
@@ -206,7 +153,6 @@ export default function Index() {
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-ghost btn-sm btn-square text-neutral" className="btn btn-ghost btn-sm btn-square text-neutral"
> >
<i <i
@@ -247,102 +193,16 @@ export default function Index() {
</div> </div>
<div className="flex gap-2 items-center mt-2"> <div className="flex gap-2 items-center mt-2">
<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>
<SortDropdown sortBy={sortBy} setSort={setSortBy} /> <SortDropdown sortBy={sortBy} setSort={setSortBy} />
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} /> <ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
</div> </div>
</div> </div>
{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}{" "}
{selectedLinks.length === 1 ? "link" : "links"} selected
</span>
) : (
<span>Nothing selected</span>
)}
</div>
)}
<div className="flex gap-3">
<button
onClick={() => setBulkEditLinksModal(true)}
className="btn btn-sm btn-accent text-white w-fit ml-auto"
disabled={
selectedLinks.length === 0 ||
!(
collectivePermissions === true ||
collectivePermissions?.canUpdate
)
}
>
Edit
</button>
<button
onClick={(e) => {
(document?.activeElement as HTMLElement)?.blur();
e.shiftKey
? bulkDeleteLinks()
: setBulkDeleteLinksModal(true);
}}
className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto"
disabled={
selectedLinks.length === 0 ||
!(
collectivePermissions === true ||
collectivePermissions?.canDelete
)
}
>
Delete
</button>
</div>
</div>
)}
<LinkComponent <LinkComponent
editMode={editMode}
links={links.filter((e) => links={links.filter((e) =>
e.tags.some((e) => e.id === Number(router.query.id)) e.tags.some((e) => e.id === Number(router.query.id))
)} )}
/> />
</div> </div>
{bulkDeleteLinksModal && (
<BulkDeleteLinksModal
onClose={() => {
setBulkDeleteLinksModal(false);
}}
/>
)}
{bulkEditLinksModal && (
<BulkEditLinksModal
onClose={() => {
setBulkEditLinksModal(false);
}}
/>
)}
</MainLayout> </MainLayout>
); );
} }
@@ -1,8 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[name]` on the table `ApiKey` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_name_key" ON "ApiKey"("name");
@@ -1,14 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[name,userId]` on the table `ApiKey` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "ApiKey_name_key";
-- DropIndex
DROP INDEX "ApiKey_token_userId_key";
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_name_userId_key" ON "ApiKey"("name", "userId");
@@ -1,35 +0,0 @@
/*
Warnings:
- You are about to drop the `ApiKey` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "ApiKey" DROP CONSTRAINT "ApiKey_userId_fkey";
-- DropTable
DROP TABLE "ApiKey";
-- CreateTable
CREATE TABLE "AccessToken" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"token" TEXT NOT NULL,
"revoked" BOOLEAN NOT NULL DEFAULT false,
"expires" TIMESTAMP(3) NOT NULL,
"lastUsedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AccessToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AccessToken_token_key" ON "AccessToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "AccessToken_name_userId_key" ON "AccessToken"("name", "userId");
-- AddForeignKey
ALTER TABLE "AccessToken" ADD CONSTRAINT "AccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

Some files were not shown because too many files have changed in this diff Show More