Merge pull request #494 from linkwarden/dev

v2.5.0
This commit is contained in:
Daniel 2024-03-05 22:05:54 +03:30 committed by GitHub
commit 3969cc5abd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
118 changed files with 4556 additions and 991 deletions

View File

@ -20,6 +20,7 @@ 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=
@ -34,6 +35,15 @@ 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
.gitignore vendored
View File

@ -47,3 +47,4 @@ prisma/dev.db
# docker # docker
pgdata pgdata
certificates

View File

@ -59,7 +59,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, name, description and multiple tags. - 📂 Organize links by collection, sub-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,6 +70,10 @@ 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 ⭐

View File

@ -9,6 +9,7 @@ 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;
@ -70,6 +71,7 @@ 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>
@ -170,7 +172,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-globe-americas drop-shadow text-neutral" className="bi-globe2 drop-shadow text-neutral"
title="This collection is being shared publicly." title="This collection is being shared publicly."
></i> ></i>
) : undefined} ) : undefined}

View File

@ -0,0 +1,362 @@
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)
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 };
};

View File

@ -1,3 +1,4 @@
import { dropdownTriggerer } from "@/lib/client/utils";
import React from "react"; import React from "react";
type Props = { type Props = {
@ -20,6 +21,7 @@ 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>

View File

@ -4,18 +4,26 @@ 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;
defaultValue: showDefaultValue?: boolean;
defaultValue?:
| { | {
label: string; label: string;
value?: number; value?: number;
} }
| undefined; | undefined;
creatable?: boolean;
}; };
export default function CollectionSelection({ onChange, defaultValue }: Props) { export default function CollectionSelection({
onChange,
defaultValue,
showDefaultValue = true,
creatable = true,
}: Props) {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const router = useRouter(); const router = useRouter();
@ -36,22 +44,87 @@ export default function CollectionSelection({ onChange, defaultValue }: Props) {
useEffect(() => { useEffect(() => {
const formatedCollections = collections.map((e) => { const formatedCollections = collections.map((e) => {
return { value: e.id, label: e.name, ownerId: e.ownerId }; return {
value: e.id,
label: e.name,
ownerId: e.ownerId,
count: e._count,
parentId: e.parentId,
};
}); });
setOptions(formatedCollections); setOptions(formatedCollections);
}, [collections]); }, [collections]);
return ( const getParentNames = (parentId: number): string[] => {
<CreatableSelect const parentNames = [];
isClearable={false} const parent = collections.find((e) => e.id === parentId);
className="react-select-container"
classNamePrefix="react-select" if (parent) {
onChange={onChange} parentNames.push(parent.name);
options={options} if (parent.parentId) {
styles={styles} parentNames.push(...getParentNames(parent.parentId));
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"
/>
);
}
} }

View File

@ -3,13 +3,25 @@ import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
export default function CardView({ export default function CardView({
links, links,
showCheckbox = true,
editMode,
}: { }: {
links: LinkIncludingShortenedCollectionAndTags[]; links: LinkIncludingShortenedCollectionAndTags[];
showCheckbox?: boolean;
editMode?: 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 <LinkCard key={i} link={e} count={i} />; return (
<LinkCard
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})} })}
</div> </div>
); );

View File

@ -3,13 +3,23 @@ import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
export default function ListView({ export default function ListView({
links, links,
editMode,
}: { }: {
links: LinkIncludingShortenedCollectionAndTags[]; links: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
}) { }) {
return ( return (
<div className="flex flex-col"> <div className="flex gap-1 flex-col">
{links.map((e, i) => { {links.map((e, i) => {
return <LinkList key={i} link={e} count={i} />; return (
<LinkList
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})} })}
</div> </div>
); );

View File

@ -14,19 +14,35 @@ 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 LinkGrid({ link, count, className }: Props) { export default function LinkCard({ link, flipDropdown, editMode }: Props) {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const { account } = useAccountStore();
const { links, getLink } = useLinkStore(); const { links, getLink, setSelectedLinks, selectedLinks } = useLinkStore();
const handleCheckboxClick = (
link: LinkIncludingShortenedCollectionAndTags
) => {
if (selectedLinks.includes(link)) {
setSelectedLinks(selectedLinks.filter((e) => e !== link));
} else {
setSelectedLinks([...selectedLinks, link]);
}
};
let shortendURL; let shortendURL;
@ -53,6 +69,7 @@ export default function LinkGrid({ link, count, className }: 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;
@ -76,135 +93,261 @@ export default function LinkGrid({ link, count, className }: 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);
return ( return (
<div <div
ref={ref} ref={ref}
className="border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative" className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative`}
onClick={() =>
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(
"You don't have permission to edit or delete this item."
)
: undefined
}
> >
<Link {!editMode ? (
href={link.url || ""} <>
target="_blank"
className="rounded-2xl cursor-pointer"
>
<div className="relative rounded-t-2xl h-40 overflow-hidden">
{previewAvailable(link) ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
width={1280}
height={720}
alt=""
className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105"
style={{ filter: "blur(2px)" }}
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
) : link.preview === "unavailable" ? (
<div className="bg-gray-50 duration-100 h-40 bg-opacity-80"></div>
) : (
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
)}
<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} />
</div>
</div>
<hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" />
<div className="p-3 mt-1">
<p className="truncate w-full pr-8 text-primary">
{unescapeString(link.name || link.description) || link.url}
</p>
<Link <Link
href={link.url || ""} href={generateLinkHref(link, account)}
target="_blank" target="_blank"
title={link.url || ""} className="rounded-2xl cursor-pointer"
className="w-fit"
> >
<div className="flex gap-1 item-center select-none text-neutral mt-1"> <div className="relative rounded-t-2xl h-40 overflow-hidden">
<i className="bi-link-45deg text-lg mt-[0.15rem] leading-none"></i> {previewAvailable(link) ? (
<p className="text-sm truncate">{shortendURL}</p> <Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
width={1280}
height={720}
alt=""
className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105"
style={{ filter: "blur(2px)" }}
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
) : link.preview === "unavailable" ? (
<div className="bg-gray-50 duration-100 h-40 bg-opacity-80"></div>
) : (
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
)}
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md">
<LinkIcon link={link} />
</div>
</div> </div>
</Link>
</div>
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" /> <hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" />
<div className="flex justify-between text-xs text-neutral px-3 pb-1"> <div className="p-3 mt-1">
<div className="cursor-pointer w-fit"> <p className="truncate w-full pr-8 text-primary">
{collection ? ( {unescapeString(link.name || link.description) || link.url}
<LinkCollection link={link} collection={collection} /> </p>
) : undefined}
</div>
<LinkDate link={link} />
</div>
</Link>
{showInfo ? ( <div title={link.url || ""} className="w-fit">
<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="flex gap-1 item-center select-none text-neutral mt-1">
<div <i className="bi-link-45deg text-lg mt-[0.15rem] leading-none"></i>
onClick={() => setShowInfo(!showInfo)} <p className="text-sm truncate">{shortendURL}</p>
className=" float-right btn btn-sm outline-none btn-circle btn-ghost z-10"
>
<i className="bi-x text-neutral text-2xl"></i>
</div>
<p className="text-neutral text-lg font-semibold">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>
</div> </div>
</> </div>
) : undefined}
</div>
) : undefined}
<LinkActions <hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
link={link}
collection={collection} <div className="flex justify-between text-xs text-neutral px-3 pb-1">
position="top-[10.75rem] right-3" <div className="cursor-pointer w-fit">
toggleShowInfo={() => setShowInfo(!showInfo)} {collection && (
linkInfo={showInfo} <LinkCollection link={link} collection={collection} />
/> )}
</div>
<LinkDate link={link} />
</div>
</Link>
{showInfo && (
<div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-2xl fade-in overflow-y-auto">
<div
onClick={() => setShowInfo(!showInfo)}
className=" float-right btn btn-sm outline-none btn-circle btn-ghost z-10"
>
<i className="bi-x text-neutral text-2xl"></i>
</div>
<p className="text-neutral text-lg font-semibold">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>
</>
)}
</div>
)}
<LinkActions
link={link}
collection={collection}
position="top-[10.75rem] right-3"
toggleShowInfo={() => setShowInfo(!showInfo)}
linkInfo={showInfo}
flipDropdown={flipDropdown}
/>
</>
) : (
<>
<div className="rounded-2xl cursor-pointer">
<div className="relative rounded-t-2xl h-40 overflow-hidden">
{previewAvailable(link) ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
width={1280}
height={720}
alt=""
className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105"
style={{ filter: "blur(2px)" }}
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
) : link.preview === "unavailable" ? (
<div className="bg-gray-50 duration-100 h-40 bg-opacity-80"></div>
) : (
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
)}
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md">
<LinkIcon link={link} />
</div>
</div>
<hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" />
<div className="p-3 mt-1">
<p className="truncate w-full pr-8 text-primary">
{unescapeString(link.name || link.description) || link.url}
</p>
<div title={link.url || ""} className="w-fit">
<div className="flex gap-1 item-center select-none text-neutral mt-1">
<i className="bi-link-45deg text-lg mt-[0.15rem] leading-none"></i>
<p className="text-sm truncate">{shortendURL}</p>
</div>
</div>
</div>
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
<div className="flex justify-between text-xs text-neutral px-3 pb-1">
<div className="cursor-pointer w-fit">
{collection && (
<LinkCollection link={link} collection={collection} />
)}
</div>
<LinkDate link={link} />
</div>
</div>
{showInfo && (
<div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-2xl fade-in overflow-y-auto">
<div
onClick={() => setShowInfo(!showInfo)}
className=" float-right btn btn-sm outline-none btn-circle btn-ghost z-10"
>
<i className="bi-x text-neutral text-2xl"></i>
</div>
<p className="text-neutral text-lg font-semibold">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>
</>
)}
</div>
)}
<LinkActions
link={link}
collection={collection}
position="top-[10.75rem] right-3"
toggleShowInfo={() => setShowInfo(!showInfo)}
linkInfo={showInfo}
flipDropdown={flipDropdown}
/>
</>
)}
</div> </div>
); );
} }

View File

@ -10,6 +10,7 @@ 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;
@ -17,6 +18,7 @@ type Props = {
position?: string; position?: string;
toggleShowInfo?: () => void; toggleShowInfo?: () => void;
linkInfo?: boolean; linkInfo?: boolean;
flipDropdown?: boolean;
}; };
export default function LinkActions({ export default function LinkActions({
@ -24,6 +26,7 @@ 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);
@ -64,34 +67,33 @@ export default function LinkActions({
return ( return (
<> <>
<div <div
className={`dropdown dropdown-left absolute ${ className={`dropdown dropdown-left dropdown-end 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"> <ul className="dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1 translate-y-10">
{permissions === true ? ( <li>
<li> <div
<div role="button"
role="button" tabIndex={0}
tabIndex={0} onClick={() => {
onClick={() => { (document?.activeElement as HTMLElement)?.blur();
(document?.activeElement as HTMLElement)?.blur(); pinLink();
pinLink(); }}
}} >
> {link?.pinnedBy && link.pinnedBy[0]
{link?.pinnedBy && link.pinnedBy[0] ? "Unpin"
? "Unpin" : "Pin to Dashboard"}
: "Pin to Dashboard"} </div>
</div> </li>
</li>
) : undefined}
{linkInfo !== undefined && toggleShowInfo ? ( {linkInfo !== undefined && toggleShowInfo ? (
<li> <li>
<div <div

View File

@ -18,7 +18,7 @@ type Props = {
className?: string; className?: string;
}; };
export default function LinkGrid({ link, count, className }: Props) { export default function LinkGrid({ link }: Props) {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const { links } = useLinkStore(); const { links } = useLinkStore();

View File

@ -11,17 +11,44 @@ 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({ link, count, className }: Props) { export default function LinkCardCompact({
link,
flipDropdown,
editMode,
}: Props) {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const { account } = useAccountStore();
const { links, setSelectedLinks, selectedLinks } = useLinkStore();
const { links } = useLinkStore(); 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;
@ -46,107 +73,186 @@ export default function LinkCardCompact({ link, count, className }: Props) {
); );
}, [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={`border-neutral-content relative ${ className={`${selectedStyle} border relative items-center flex ${
!showInfo ? "hover:bg-base-300" : "" !showInfo && !isPWA() ? "hover:bg-base-300 p-3" : "py-3"
} 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
}
> >
<Link {/* {showCheckbox &&
href={link.url || ""} editMode &&
target="_blank" (permissions === true ||
className="flex items-center cursor-pointer py-3 px-3" permissions?.canCreate ||
> permissions?.canDelete) && (
<div className="shrink-0"> <input
<LinkIcon link={link} width="sm:w-12 w-8" /> type="checkbox"
</div> className="checkbox checkbox-primary my-auto mr-2"
checked={selectedLinks.some(
<div className="w-[calc(100%-56px)] ml-2"> (selectedLink) => selectedLink.id === link.id
<p className="line-clamp-1 mr-8 text-primary"> )}
{unescapeString(link.name || link.description) || link.url} onChange={() => handleCheckboxClick(link)}
</p> />
)} */}
<div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral"> {!editMode ? (
<div className="flex items-center gap-2"> <>
{collection ? ( <Link
<> href={generateLinkHref(link, account)}
<LinkCollection link={link} collection={collection} /> target="_blank"
&middot; className="flex items-center cursor-pointer"
</> >
) : undefined} <div className="shrink-0">
{link.url ? ( <LinkIcon link={link} width="sm:w-12 w-8 mt-1 sm:mt-0" />
<div className="flex items-center gap-1 max-w-full w-fit text-neutral">
<i className="bi-link-45deg text-base" />
<p className="truncate w-full">{shortendURL}</p>
</div>
) : (
<div className="badge badge-primary badge-sm my-1">
{link.type}
</div>
)}
</div> </div>
<span className="hidden sm:block">&middot;</span>
<LinkDate link={link} />
</div>
</div>
</Link>
<LinkActions <div className="w-[calc(100%-56px)] ml-2">
link={link} <p className="line-clamp-1 mr-8 text-primary">
collection={collection} {unescapeString(link.name || link.description) || link.url}
position="top-3 right-3" </p>
// toggleShowInfo={() => setShowInfo(!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]" /> <div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral">
<p> <div className="flex items-center gap-x-3 w-fit text-neutral flex-wrap">
{link.description ? ( {collection && (
unescapeString(link.description) <LinkCollection link={link} collection={collection} />
) : ( )}
<span className="text-neutral text-sm"> {link.url ? (
No description provided. <div className="flex items-center gap-1 w-fit text-neutral truncate">
</span> <i className="bi-link-45deg text-lg" />
)} <p className="truncate w-full">{shortendURL}</p>
</p> </div>
{link.tags[0] ? ( ) : (
<> <div className="badge badge-primary badge-sm my-1">
<p className="text-neutral text-lg mt-3 font-semibold"> {link.type}
Tags </div>
)}
<LinkDate link={link} />
</div>
</div>
</div>
</Link>
<LinkActions
link={link}
collection={collection}
position="top-3 right-3"
flipDropdown={flipDropdown}
// toggleShowInfo={() => setShowInfo(!showInfo)}
// linkInfo={showInfo}
/>
{showInfo && (
<div>
<div className="pb-3 mt-1 px-3">
<p className="text-neutral text-lg font-semibold">
Description
</p> </p>
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" /> <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>
<div className="flex gap-3 items-center flex-wrap mt-2 truncate relative"> <hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
<div className="flex gap-1 items-center flex-wrap">
{link.tags.map((e, i) => ( <div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
<Link <div className="flex gap-1 items-center flex-wrap">
href={"/tags/" + e.id} {link.tags.map((e, i) => (
key={i} <Link
onClick={(e) => { href={"/tags/" + e.id}
e.stopPropagation(); key={i}
}} onClick={(e) => {
className="btn btn-xs btn-ghost truncate max-w-[19rem]" e.stopPropagation();
> }}
#{e.name} className="btn btn-xs btn-ghost truncate max-w-[19rem]"
</Link> >
))} #{e.name}
</div> </Link>
))}
</div>
</div>
</>
)}
</div>
</div>
)}
</>
) : (
<>
<div className="flex items-center cursor-pointer">
<div className="shrink-0">
<LinkIcon link={link} width="sm:w-12 w-8 mt-1 sm:mt-0" />
</div>
<div className="w-[calc(100%-56px)] ml-2">
<p className="line-clamp-1 mr-8 text-primary select-none">
{unescapeString(link.name || link.description) || link.url}
</p>
<div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral">
<div className="flex items-center gap-x-3 w-fit text-neutral flex-wrap">
{collection ? (
<LinkCollection link={link} collection={collection} />
) : undefined}
{link.url ? (
<div className="flex items-center gap-1 w-fit text-neutral truncate">
<i className="bi-link-45deg text-lg" />
<p className="truncate w-full select-none">
{shortendURL}
</p>
</div>
) : (
<div className="badge badge-primary badge-sm my-1 select-none">
{link.type}
</div>
)}
<LinkDate link={link} />
</div> </div>
</> </div>
) : undefined} </div>
</div> </div>
</div> <LinkActions
) : undefined} link={link}
collection={collection}
position="top-3 right-3"
flipDropdown={flipDropdown}
// toggleShowInfo={() => setShowInfo(!showInfo)}
// linkInfo={showInfo}
/>
</>
)}
</div> </div>
<div className="divider my-0 last:hidden h-[1px]"></div> <div className="divider my-0 last:hidden h-[1px]"></div>
</> </>
); );

View File

@ -0,0 +1,96 @@
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}
</>
);
}

View File

@ -0,0 +1,45 @@
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>
);
}

View File

@ -1,5 +1,6 @@
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;
@ -8,31 +9,59 @@ type Props = {
}; };
export default function Modal({ toggleModal, className, children }: Props) { export default function Modal({ toggleModal, className, children }: Props) {
useEffect(() => { const [drawerIsOpen, setDrawerIsOpen] = React.useState(true);
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "auto";
};
});
return ( useEffect(() => {
<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"> if (window.innerWidth >= 640) {
<ClickAwayHandler document.body.style.overflow = "hidden";
onClickOutside={toggleModal} document.body.style.position = "relative";
className={`w-full mt-auto sm:m-auto sm:w-11/12 sm:max-w-2xl ${ return () => {
className || "" document.body.style.overflow = "auto";
}`} document.body.style.position = "";
};
}
}, []);
if (window.innerWidth < 640) {
return (
<Drawer.Root
open={drawerIsOpen}
onClose={() => setTimeout(() => toggleModal(), 100)}
> >
<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.Portal>
<div <Drawer.Overlay className="fixed inset-0 bg-black/40" />
onClick={toggleModal as MouseEventHandler<HTMLDivElement>} <ClickAwayHandler onClickOutside={() => setDrawerIsOpen(false)}>
className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10" <Drawer.Content className="flex flex-col rounded-t-2xl h-[90%] mt-24 fixed bottom-0 left-0 right-0 z-30">
> <div className="p-4 pb-32 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto">
<i className="bi-x text-neutral text-2xl"></i> <div className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-neutral mb-5" />
{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>
{children} </ClickAwayHandler>
</div> </div>
</ClickAwayHandler> );
</div> }
);
} }

View File

@ -0,0 +1,75 @@
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>
);
}

View File

@ -0,0 +1,102 @@
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>
);
}

View File

@ -15,7 +15,6 @@ 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();

View File

@ -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 Save Changes
</button> </button>
</div> </div>
</Modal> </Modal>

View File

@ -9,6 +9,7 @@ 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;
@ -233,11 +234,8 @@ export default function EditCollectionSharingModal({
: undefined; : undefined;
return ( return (
<> <React.Fragment key={i}>
<div <div className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between border-none">
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"}
> >
@ -264,6 +262,7 @@ 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}
@ -431,7 +430,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>
@ -443,7 +442,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 Save Changes
</button> </button>
)} )}
</div> </div>

View File

@ -124,6 +124,7 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
label: "Unorganized", label: "Unorganized",
} }
} }
creatable={false}
/> />
) : null} ) : null}
</div> </div>
@ -157,7 +158,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 Save Changes
</button> </button>
</div> </div>
</Modal> </Modal>

View File

@ -5,19 +5,26 @@ 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 }: Props) { export default function NewCollectionModal({ onClose, parent }: 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);
@ -39,7 +46,11 @@ export default function NewCollectionModal({ onClose }: Props) {
if (response.ok) { if (response.ok) {
toast.success("Created!"); toast.success("Created!");
onClose(); if (response.data) {
// 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);
@ -47,7 +58,14 @@ export default function NewCollectionModal({ onClose }: Props) {
return ( return (
<Modal toggleModal={onClose}> <Modal toggleModal={onClose}>
<p className="text-xl font-thin">Create a New Collection</p> {parent?.id ? (
<>
<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>

View File

@ -109,7 +109,6 @@ 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;
@ -179,7 +178,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-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100" 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"
/> />
</div> </div>
</div> </div>

View File

@ -0,0 +1,227 @@
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>
);
}

View File

@ -217,10 +217,7 @@ 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 <div className={`btn btn-outline`} onClick={() => updateArchive()}>
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">

View File

@ -0,0 +1,62 @@
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>
);
}

View File

@ -13,6 +13,8 @@ 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();
@ -35,14 +37,12 @@ export default function Navbar() {
useEffect(() => { useEffect(() => {
setSidebar(false); setSidebar(false);
}, [width]); document.body.style.overflow = "auto";
}, [width, router]);
useEffect(() => {
setSidebar(false);
}, [router]);
const toggleSidebar = () => { const toggleSidebar = () => {
setSidebar(!sidebar); setSidebar(false);
document.body.style.overflow = "auto";
}; };
const [newLinkModal, setNewLinkModal] = useState(false); const [newLinkModal, setNewLinkModal] = useState(false);
@ -52,8 +52,11 @@ 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={toggleSidebar} onClick={() => {
className="text-neutral btn btn-square btn-sm btn-ghost lg:hidden" setSidebar(true);
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>
@ -61,11 +64,12 @@ 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"> <div className="dropdown dropdown-end sm:inline-block hidden">
<div className="tooltip tooltip-bottom" data-tip="Create New..."> <div className="tooltip tooltip-bottom z-10" 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>
@ -117,7 +121,12 @@ export default function Navbar() {
</div> </div>
<div className="dropdown dropdown-end"> <div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-circle btn-ghost"> <div
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}
@ -134,7 +143,7 @@ export default function Navbar() {
Settings Settings
</Link> </Link>
</li> </li>
<li> <li className="block sm:hidden">
<div <div
onClick={() => { onClick={() => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
@ -161,6 +170,9 @@ 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}>

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-8 h-8"}`} } ${large ? "w-28 h-28" : "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

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.4.9"; const LINKWARDEN_VERSION = "v2.5.0";
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
@ -37,43 +37,30 @@ export default function SettingsSidebar({ className }: { className?: string }) {
</div> </div>
</Link> </Link>
<Link href="/settings/appearance"> <Link href="/settings/preference">
<div <div
className={`${ className={`${
active === `/settings/appearance` active === `/settings/preference`
? "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-palette text-primary text-2xl"></i> <i className="bi-sliders text-primary text-2xl"></i>
<p className="truncate w-full pr-7">Appearance</p> <p className="truncate w-full pr-7">Preference</p>
</div> </div>
</Link> </Link>
<Link href="/settings/archive"> <Link href="/settings/access-tokens">
<div <div
className={`${ className={`${
active === `/settings/archive` active === `/settings/access-tokens`
? "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">API Keys</p> <p className="truncate w-full pr-7">Access Tokens</p>
</div> </div>
</Link> </Link>

View File

@ -5,6 +5,7 @@ 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>(() => {
@ -21,11 +22,10 @@ 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-72 lg: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-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 ${
className || "" className || ""
}`} }`}
> >
@ -97,48 +97,8 @@ 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 className="flex flex-col gap-1"> <Disclosure.Panel>
{collections[0] ? ( <CollectionListing />
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>

View File

@ -1,5 +1,6 @@
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;
@ -12,7 +13,8 @@ export default function SortDropdown({ sortBy, setSort }: Props) {
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
className="btn btn-sm btn-square btn-ghost" onMouseDown={dropdownTriggerer}
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>

View File

@ -8,6 +8,7 @@ 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({
@ -18,9 +19,11 @@ 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}

View File

@ -1,4 +1,4 @@
import React, { Dispatch, SetStateAction, useEffect, useState } from "react"; import React, { Dispatch, SetStateAction, useEffect } from "react";
import useLocalSettingsStore from "@/store/localSettings"; import useLocalSettingsStore from "@/store/localSettings";
import { ViewMode } from "@/types/global"; import { ViewMode } from "@/types/global";

View File

@ -0,0 +1,34 @@
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;
}

View File

@ -18,7 +18,8 @@ export default function useLinks(
searchByTextContent, searchByTextContent,
}: LinkRequestQuery = { sort: 0 } }: LinkRequestQuery = { sort: 0 }
) { ) {
const { links, setLinks, resetLinks } = useLinkStore(); const { links, setLinks, resetLinks, selectedLinks, setSelectedLinks } =
useLinkStore();
const router = useRouter(); const router = useRouter();
const { reachedBottom, setReachedBottom } = useDetectPageBottom(); const { reachedBottom, setReachedBottom } = useDetectPageBottom();
@ -68,8 +69,12 @@ export default function useLinks(
}; };
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,

View File

@ -46,7 +46,7 @@ export default function MainLayout({ children }: Props) {
</div> </div>
<div <div
className={`w-full flex flex-col min-h-${ className={`w-full sm:pb-0 pb-20 flex flex-col min-h-${
showAnnouncement ? "full" : "screen" showAnnouncement ? "full" : "screen"
} lg:ml-80 ${showAnnouncement ? "mt-10" : ""}`} } lg:ml-80 ${showAnnouncement ? "mt-10" : ""}`}
> >

View File

@ -1,4 +1,4 @@
import { chromium, devices } from "playwright"; import { LaunchOptions, 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,8 +20,23 @@ 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) {
const browser = await chromium.launch(); // allow user to configure a proxy
const context = await browser.newContext(devices["Desktop Chrome"]); let browserOptions: LaunchOptions = {};
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) => {
@ -238,6 +253,13 @@ 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
@ -245,7 +267,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
width: "1366px", width: "1366px",
height: "1931px", height: "1931px",
printBackground: true, printBackground: true,
margin: { top: "15px", bottom: "15px" }, margin: margins,
}) })
.then((pdf) => { .then((pdf) => {
return createFile({ return createFile({

View File

@ -31,12 +31,16 @@ 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: {
@ -53,7 +57,9 @@ export default async function deleteCollection(
}, },
}); });
removeFolder({ filePath: `archives/${collectionId}` }); await removeFolder({ filePath: `archives/${collectionId}` });
await removeFromOrders(userId, collectionId);
return await prisma.collection.delete({ return await prisma.collection.delete({
where: { where: {
@ -64,3 +70,60 @@ 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
),
},
},
});
}

View File

@ -0,0 +1,34 @@
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 };
}

View File

@ -1,7 +1,6 @@
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,
@ -19,6 +18,32 @@ 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: {
@ -32,12 +57,23 @@ 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 } },

View File

@ -12,6 +12,12 @@ 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: {

View File

@ -12,23 +12,25 @@ export default async function postCollection(
status: 400, status: 400,
}; };
const findCollection = await prisma.user.findUnique({ if (collection.parentId) {
where: { const findParentCollection = await prisma.collection.findUnique({
id: userId, where: {
}, id: collection.parentId,
select: {
collections: {
where: {
name: collection.name,
},
}, },
}, select: {
}); ownerId: true,
},
});
const checkIfCollectionExists = findCollection?.collections[0]; if (
findParentCollection?.ownerId !== userId ||
if (checkIfCollectionExists) typeof collection.parentId !== "number"
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: {
@ -40,6 +42,13 @@ 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: {
@ -58,6 +67,17 @@ 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 };

View File

@ -0,0 +1,58 @@
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 };
}

View File

@ -0,0 +1,50 @@
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 };
}
}

View File

@ -1,5 +1,5 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { Collection, Link, UsersAndCollections } from "@prisma/client"; import { 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";

View File

@ -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 { Collection, Link, UsersAndCollections } from "@prisma/client"; import { 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,13 +17,70 @@ 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 isCollectionOwner = const targetCollectionsAccessible =
collectionIsAccessible?.ownerId === data.collection.ownerId && targetCollectionIsAccessible?.ownerId === userId;
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;

View File

@ -22,8 +22,114 @@ export default async function postLink(
}; };
} }
if (!link.collection.name) { if (!link.collection.id && 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({
@ -42,22 +148,6 @@ 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
@ -81,22 +171,13 @@ export default async function postLink(
const newLink = await prisma.link.create({ const newLink = await prisma.link.create({
data: { data: {
url: link.url, url: link.url?.trim(),
name: link.name, name: link.name,
description, description,
type: linkType, type: linkType,
collection: { collection: {
connectOrCreate: { connect: {
where: { id: link.collection.id,
name_ownerId: {
ownerId: link.collection.ownerId,
name: link.collection.name,
},
},
create: {
name: link.collection.name.trim(),
ownerId: userId,
},
}, },
}, },
tags: { tags: {

View File

@ -13,6 +13,8 @@ export default async function exportData(userId: number) {
}, },
}, },
}, },
pinnedLinks: true,
whitelistedUsers: true,
}, },
}); });

View File

@ -1,6 +1,7 @@
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;
@ -11,6 +12,11 @@ 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;
@ -28,94 +34,165 @@ export default async function importFromHTMLFile(
status: 400, status: 400,
}; };
const folders = document.querySelectorAll("H3"); const jsonData = parse(document.documentElement.outerHTML);
await prisma for (const item of jsonData) {
.$transaction( console.log(item);
async () => { await processBookmarks(userId, item as Element);
// @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,
},
});
};

View File

@ -37,41 +37,20 @@ 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 findCollection = await prisma.user.findUnique({ const newCollection = await prisma.collection.create({
where: { data: {
id: userId, owner: {
}, connect: {
select: { id: userId,
collections: {
where: {
name: e.name,
}, },
}, },
name: e.name,
description: e.description,
color: e.color,
}, },
}); });
const checkIfCollectionExists = findCollection?.collections[0]; createFolder({ filePath: `archives/${newCollection.id}` });
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) {
@ -82,7 +61,7 @@ export default async function importFromLinkwarden(
description: link.description, description: link.description,
collection: { collection: {
connect: { connect: {
id: collectionId, id: newCollection.id,
}, },
}, },
// Import Tags // Import Tags

View File

@ -0,0 +1,21 @@
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,
};
}

View File

@ -0,0 +1,92 @@
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,
};
}

View File

@ -0,0 +1,24 @@
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 };
}

View File

@ -183,9 +183,14 @@ 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

View File

@ -3,12 +3,14 @@ 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) {
@ -24,10 +26,11 @@ export default async function getPermission({
}); });
return check; return check;
} else if (collectionId) { } else if (collectionId || collectionName) {
const check = await prisma.collection.findFirst({ const check = await prisma.collection.findFirst({
where: { where: {
id: collectionId, id: collectionId || undefined,
name: collectionName || undefined,
OR: [{ ownerId: userId }, { members: { some: { userId } } }], OR: [{ ownerId: userId }, { members: { some: { userId } } }],
}, },
include: { members: true }, include: { members: true },

36
lib/api/verifyToken.ts Normal file
View File

@ -0,0 +1,36 @@
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;
}

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,14 +15,15 @@ export default async function verifyUser({
req, req,
res, res,
}: Props): Promise<User | null> { }: Props): Promise<User | null> {
const token = await getToken({ req }); const token = await verifyToken({ req });
const userId = token?.id;
if (!userId) { if (typeof token === "string") {
res.status(401).json({ response: "You must be logged in." }); res.status(401).json({ response: token });
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,

View File

@ -0,0 +1,39 @@
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
lib/client/utils.ts Normal file
View File

@ -0,0 +1,20 @@
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);
}
}

View File

@ -1,4 +1,8 @@
export function screenshotAvailable(link: any) { import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
export function screenshotAvailable(
link: LinkIncludingShortenedCollectionAndTags
) {
return ( return (
link && link &&
link.image && link.image &&
@ -7,13 +11,15 @@ export function screenshotAvailable(link: any) {
); );
} }
export function pdfAvailable(link: any) { export function pdfAvailable(link: LinkIncludingShortenedCollectionAndTags) {
return ( return (
link && link.pdf && link.pdf !== "pending" && link.pdf !== "unavailable" link && link.pdf && link.pdf !== "pending" && link.pdf !== "unavailable"
); );
} }
export function readabilityAvailable(link: any) { export function readabilityAvailable(
link: LinkIncludingShortenedCollectionAndTags
) {
return ( return (
link && link &&
link.readable && link.readable &&

View File

@ -1,5 +1,7 @@
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({
@ -7,9 +9,26 @@ export default async function getTitle(url: string) {
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true, process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
}); });
const response = await fetch(url, { // fetchOpts allows a proxy to be defined
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

View File

@ -1,6 +1,6 @@
{ {
"name": "linkwarden", "name": "linkwarden",
"version": "2.4.9", "version": "2.5.0",
"main": "index.js", "main": "index.js",
"repository": "https://github.com/linkwarden/linkwarden.git", "repository": "https://github.com/linkwarden/linkwarden.git",
"author": "Daniel31X13 <daniel31x13@gmail.com>", "author": "Daniel31X13 <daniel31x13@gmail.com>",
@ -19,6 +19,7 @@
"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",
@ -44,12 +45,14 @@
"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",
@ -58,7 +61,9 @@
"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",
"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": {

View File

@ -1,4 +1,4 @@
import React from "react"; import React, { useEffect } from "react";
import "@/styles/globals.css"; import "@/styles/globals.css";
import "bootstrap-icons/font/bootstrap-icons.css"; import "bootstrap-icons/font/bootstrap-icons.css";
import { SessionProvider } from "next-auth/react"; import { SessionProvider } from "next-auth/react";
@ -7,6 +7,7 @@ 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,
@ -14,6 +15,15 @@ export default function App({
}: 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 ( return (
<SessionProvider <SessionProvider
session={pageProps.session} session={pageProps.session}
@ -23,6 +33,7 @@ export default function App({
<Head> <Head>
<title>Linkwarden</title> <title>Linkwarden</title>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link <link
rel="apple-touch-icon" rel="apple-touch-icon"
sizes="180x180" sizes="180x180"

View File

@ -1,6 +1,5 @@
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";
@ -9,6 +8,7 @@ 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 getToken({ req }); const token = await verifyToken({ req });
const userId = token?.id; const userId = typeof token === "string" ? undefined : token?.id;
const collectionIsAccessible = await prisma.collection.findFirst({ const collectionIsAccessible = await prisma.collection.findFirst({
where: { where: {

View File

@ -65,6 +65,7 @@ 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;
@ -1059,60 +1060,60 @@ if (process.env.NEXT_PUBLIC_ZOOM_ENABLED_ENABLED === "true") {
}; };
} }
export const authOptions: AuthOptions = { export default async function auth(req: NextApiRequest, res: NextApiResponse) {
adapter: adapter as Adapter, return await NextAuth(req, res, {
session: { adapter: adapter as Adapter,
strategy: "jwt", session: {
maxAge: 30 * 24 * 60 * 60, // 30 days strategy: "jwt",
}, maxAge: 30 * 24 * 60 * 60, // 30 days
providers, },
pages: { providers,
signIn: "/login", pages: {
verifyRequest: "/confirmation", signIn: "/login",
}, verifyRequest: "/confirmation",
callbacks: { },
async signIn({ user, account, profile, email, credentials }) { callbacks: {
if (account?.provider !== "credentials") { async signIn({ user, account, profile, email, credentials }) {
// registration via SSO can be separately disabled if (account?.provider !== "credentials") {
const existingUser = await prisma.account.findFirst({ // registration via SSO can be separately disabled
where: { const existingUser = await prisma.account.findFirst({
providerAccountId: account?.providerAccountId, where: {
}, providerAccountId: account?.providerAccountId,
}); },
if (existingUser && newSsoUsersDisabled) { });
return false; if (existingUser && newSsoUsersDisabled) {
return false;
}
} }
} return true;
return true; },
}, async jwt({ token, trigger, user }) {
async jwt({ token, trigger, user }) { token.sub = token.sub ? Number(token.sub) : undefined;
token.sub = token.sub ? Number(token.sub) : undefined; if (trigger === "signIn" || trigger === "signUp")
if (trigger === "signIn" || trigger === "signUp") token.id = user?.id as number;
token.id = user?.id as number;
return token; return token;
}, },
async session({ session, token }) { async session({ session, token }) {
session.user.id = token.id; session.user.id = token.id;
if (STRIPE_SECRET_KEY) { if (STRIPE_SECRET_KEY) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
id: token.id, id: token.id,
}, },
include: { include: {
subscriptions: true, subscriptions: true,
}, },
}); });
if (user) { if (user) {
const subscribedUser = await verifySubscription(user); const subscribedUser = await verifySubscription(user);
}
} }
}
return session; return session;
},
}, },
}, });
}; }
export default NextAuth(authOptions);

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 { getToken } from "next-auth/jwt"; import verifyToken from "@/lib/api/verifyToken";
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 getToken({ req }); const token = await verifyToken({ req });
const userId = token?.id; const userId = typeof token === "string" ? undefined : token?.id;
if (req.method === "GET") { if (req.method === "GET") {
const targetUser = await prisma.user.findUnique({ const targetUser = await prisma.user.findUnique({

View File

@ -1,4 +1,5 @@
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";
@ -10,18 +11,18 @@ export default async function collections(
const user = await verifyUser({ req, res }); const user = await verifyUser({ req, res });
if (!user) return; if (!user) return;
if (req.method === "PUT") { const collectionId = Number(req.query.id);
const updated = await updateCollectionById(
user.id, if (req.method === "GET") {
Number(req.query.id) as number, const collections = await getCollectionById(user.id, collectionId);
req.body return res
); .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( const deleted = await deleteCollectionById(user.id, collectionId);
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 });
} }
} }

View File

@ -3,6 +3,8 @@ 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 });
@ -39,5 +41,20 @@ 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,
});
} }
} }

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 { getToken } from "next-auth/jwt"; import verifyToken from "@/lib/api/verifyToken";
export default async function users(req: NextApiRequest, res: NextApiResponse) { export default async function users(req: NextApiRequest, res: NextApiResponse) {
const token = await getToken({ req }); const token = await verifyToken({ req });
const requestingId = token?.id; const requestingId = typeof token === "string" ? undefined : token?.id;
const lookupId = req.query.id as string; const lookupId = req.query.id as string;

View File

@ -0,0 +1,13 @@
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 });
}
}

View File

@ -0,0 +1,20 @@
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 });
}
}

View File

@ -2,20 +2,22 @@ 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 getToken({ req }); const token = await verifyToken({ req });
const userId = token?.id;
if (!userId) { if (typeof token === "string") {
return res.status(401).json({ response: "You must be logged in." }); res.status(401).json({ response: token });
return null;
} }
const userId = token?.id;
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." });

View File

@ -23,13 +23,19 @@ 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 } = useLinkStore(); const { links, selectedLinks, setSelectedLinks, deleteLinksById } =
useLinkStore();
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
@ -78,12 +84,24 @@ 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(() => {
return () => {
setEditMode(false);
};
}, [router]);
const [viewMode, setViewMode] = useState<string>( const [viewMode, setViewMode] = useState<string>(
localStorage.getItem("viewMode") || ViewMode.Card localStorage.getItem("viewMode") || ViewMode.Card
@ -98,6 +116,35 @@ 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
@ -125,12 +172,13 @@ 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"
@ -143,7 +191,7 @@ export default function Index() {
Edit Collection Info Edit Collection Info
</div> </div>
</li> </li>
) : undefined} )}
<li> <li>
<div <div
role="button" role="button"
@ -158,6 +206,20 @@ 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"
@ -177,7 +239,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
@ -213,31 +275,127 @@ 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-end gap-5"> <div className="flex justify-between items-center 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
)} )}
@ -246,28 +404,48 @@ 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} )}
{deleteCollectionModal ? ( {newCollectionModal && (
<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>
); );
} }

View File

@ -11,7 +11,6 @@ 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);
@ -40,7 +39,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) .filter((e) => e.ownerId === data?.user.id && e.parentId === null)
.map((e, i) => { .map((e, i) => {
return <CollectionCard key={i} collection={e} />; return <CollectionCard key={i} collection={e} />;
})} })}

View File

@ -2,7 +2,6 @@ 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";
@ -16,6 +15,7 @@ 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() {
@ -168,7 +168,10 @@ export default function Dashboard() {
> >
{links[0] ? ( {links[0] ? (
<div className="w-full"> <div className="w-full">
<LinkComponent links={links.slice(0, showLinks)} /> <LinkComponent
links={links.slice(0, showLinks)}
showCheckbox={false}
/>
</div> </div>
) : ( ) : (
<div <div
@ -200,6 +203,7 @@ export default function Dashboard() {
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer}
className="inline-flex items-center gap-2 text-sm btn btn-outline btn-neutral" className="inline-flex items-center gap-2 text-sm btn btn-outline btn-neutral"
id="import-dropdown" id="import-dropdown"
> >
@ -277,14 +281,12 @@ export default function Dashboard() {
> >
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? ( {links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
<div className="w-full"> <div className="w-full">
<div <LinkComponent
className={`grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5 w-full`} showCheckbox={false}
> links={links
{links
.filter((e) => e.pinnedBy && e.pinnedBy[0]) .filter((e) => e.pinnedBy && e.pinnedBy[0])
.map((e, i) => <LinkCard key={i} link={e} count={i} />)
.slice(0, showLinks)} .slice(0, showLinks)}
</div> />
</div> </div>
) : ( ) : (
<div <div

View File

@ -3,24 +3,74 @@ 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, { useState } from "react"; import React, { useEffect, useState } from "react";
import PageHeader from "@/components/PageHeader"; import PageHeader from "@/components/PageHeader";
import { Sort, ViewMode } from "@/types/global"; import { Member, 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 } = useLinkStore(); const { links, selectedLinks, deleteLinksById, setSelectedLinks } =
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(() => {
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,
@ -41,17 +91,105 @@ 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 links={links} /> <LinkComponent editMode={editMode} 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>
); );
} }

View File

@ -2,16 +2,22 @@ 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, { useState } from "react"; import React, { useEffect, useState } from "react";
import PageHeader from "@/components/PageHeader"; import PageHeader from "@/components/PageHeader";
import { Sort, ViewMode } from "@/types/global"; import { Sort, ViewMode } from "@/types/global";
import 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 } = useLinkStore(); const { links, selectedLinks, deleteLinksById, setSelectedLinks } =
useLinkStore();
const [viewMode, setViewMode] = useState<string>( const [viewMode, setViewMode] = useState<string>(
localStorage.getItem("viewMode") || ViewMode.Card localStorage.getItem("viewMode") || ViewMode.Card
@ -20,6 +26,49 @@ 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(() => {
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,
@ -39,13 +88,87 @@ 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 links={links} /> <LinkComponent editMode={editMode} links={links} />
) : ( ) : (
<div <div
style={{ flex: "1 1 auto" }} style={{ flex: "1 1 auto" }}
@ -62,6 +185,20 @@ export default function PinnedLinks() {
</div> </div>
)} )}
</div> </div>
{bulkDeleteLinksModal && (
<BulkDeleteLinksModal
onClose={() => {
setBulkDeleteLinksModal(false);
}}
/>
)}
{bulkEditLinksModal && (
<BulkEditLinksModal
onClose={() => {
setBulkEditLinksModal(false);
}}
/>
)}
</MainLayout> </MainLayout>
); );
} }

View File

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

View File

@ -25,8 +25,6 @@ export default function Search() {
tags: true, tags: true,
}); });
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
); );

View File

@ -0,0 +1,107 @@
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>
);
}

View File

@ -11,6 +11,7 @@ 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;
@ -191,8 +192,8 @@ export default function Account() {
) : undefined} ) : undefined}
</div> </div>
<div className="sm:row-span-2 sm:justify-self-center mx-auto my-3"> <div className="sm:row-span-2 sm:justify-self-center my-3">
<p className="mb-2 text-center">Profile Photo</p> <p className="mb-2 sm: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}
@ -245,6 +246,7 @@ 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"
> >
@ -347,8 +349,8 @@ export default function Account() {
<SubmitButton <SubmitButton
onClick={submit} onClick={submit}
loading={submitLoader} loading={submitLoader}
label="Save" label="Save Changes"
className="mt-2 mx-auto lg:mx-0" className="mt-2 w-full sm:w-fit"
/> />
<div> <div>
@ -373,7 +375,7 @@ export default function Account() {
<Link <Link
href="/settings/delete" href="/settings/delete"
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" 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"
> >
<p className="text-center w-full">Delete Your Account</p> <p className="text-center w-full">Delete Your Account</p>
</Link> </Link>

View File

@ -1,78 +0,0 @@
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>
);
}

View File

@ -1,106 +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";
export default function Appearance() {
const { updateSettings } = useLocalSettingsStore();
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 { account, updateAccount } = useAccountStore();
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)
);
function objectIsEmpty(obj: object) {
return Object.keys(obj).length === 0;
}
useEffect(() => {
if (!objectIsEmpty(account)) setUser({ ...account });
}, [account]);
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 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>
{/* <SubmitButton
onClick={submit}
loading={submitLoader}
label="Save"
className="mt-2 mx-auto lg:mx-0"
/> */}
</div>
</SettingsLayout>
);
}

View File

@ -1,93 +0,0 @@
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>
);
}

View File

@ -77,8 +77,8 @@ export default function Password() {
<SubmitButton <SubmitButton
onClick={submit} onClick={submit}
loading={submitLoader} loading={submitLoader}
label="Save" label="Save Changes"
className="mt-2 mx-auto lg:mx-0" className="mt-2 w-full sm:w-fit"
/> />
</div> </div>
</SettingsLayout> </SettingsLayout>

View File

@ -0,0 +1,237 @@
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>
);
}

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, useEffect, useState } from "react"; import { FormEvent, use, 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,11 +11,16 @@ 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 } = useLinkStore(); const { links, selectedLinks, deleteLinksById, setSelectedLinks } =
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);
@ -25,11 +30,31 @@ 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(() => {
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(() => {
setActiveTag(tags.find((e) => e.id === Number(router.query.id))); const tag = 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);
@ -90,6 +115,35 @@ 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
); );
@ -153,6 +207,7 @@ 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
@ -193,16 +248,102 @@ 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>
); );
} }

View File

@ -0,0 +1,8 @@
/*
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");

View File

@ -0,0 +1,14 @@
/*
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");

View File

@ -0,0 +1,35 @@
/*
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;

View File

@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "AccessToken_name_userId_key";

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Collection" ADD COLUMN "parentId" INTEGER;
-- AddForeignKey
ALTER TABLE "Collection" ADD CONSTRAINT "Collection_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Collection"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "LinksRouteTo" AS ENUM ('ORIGINAL', 'PDF', 'READABLE', 'SCREENSHOT');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "linksRouteTo" "LinksRouteTo" NOT NULL DEFAULT 'ORIGINAL';

View File

@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "Collection_name_ownerId_key";

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "collectionOrder" INTEGER[] DEFAULT ARRAY[]::INTEGER[];

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "preventDuplicateLinks" BOOLEAN NOT NULL DEFAULT false;

View File

@ -38,9 +38,12 @@ model User {
tags Tag[] tags Tag[]
pinnedLinks Link[] pinnedLinks Link[]
collectionsJoined UsersAndCollections[] collectionsJoined UsersAndCollections[]
collectionOrder Int[] @default([])
whitelistedUsers WhitelistedUser[] whitelistedUsers WhitelistedUser[]
apiKeys ApiKey[] accessTokens AccessToken[]
subscriptions Subscription? subscriptions Subscription?
linksRouteTo LinksRouteTo @default(ORIGINAL)
preventDuplicateLinks Boolean @default(false)
archiveAsScreenshot Boolean @default(true) archiveAsScreenshot Boolean @default(true)
archiveAsPDF Boolean @default(true) archiveAsPDF Boolean @default(true)
archiveAsWaybackMachine Boolean @default(false) archiveAsWaybackMachine Boolean @default(false)
@ -49,6 +52,13 @@ model User {
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
} }
enum LinksRouteTo {
ORIGINAL
PDF
READABLE
SCREENSHOT
}
model WhitelistedUser { model WhitelistedUser {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
username String @default("") username String @default("")
@ -69,19 +79,20 @@ model VerificationToken {
} }
model Collection { model Collection {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
description String @default("") description String @default("")
color String @default("#0ea5e9") color String @default("#0ea5e9")
isPublic Boolean @default(false) parentId Int?
owner User @relation(fields: [ownerId], references: [id]) parent Collection? @relation("SubCollections", fields: [parentId], references: [id])
ownerId Int subCollections Collection[] @relation("SubCollections")
members UsersAndCollections[] isPublic Boolean @default(false)
links Link[] owner User @relation(fields: [ownerId], references: [id])
createdAt DateTime @default(now()) ownerId Int
updatedAt DateTime @default(now()) @updatedAt members UsersAndCollections[]
links Link[]
@@unique([name, ownerId]) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
} }
model UsersAndCollections { model UsersAndCollections {
@ -142,16 +153,15 @@ model Subscription {
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
} }
model ApiKey { model AccessToken {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
userId Int userId Int
token String @unique token String @unique
revoked Boolean @default(false)
expires DateTime expires DateTime
lastUsedAt DateTime? lastUsedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
@@unique([token, userId])
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 255 KiB

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