commit
3969cc5abd
10
.env.sample
10
.env.sample
|
@ -20,6 +20,7 @@ MAX_LINKS_PER_USER=
|
|||
ARCHIVE_TAKE_COUNT=
|
||||
BROWSER_TIMEOUT=
|
||||
IGNORE_UNAUTHORIZED_CA=
|
||||
IGNORE_HTTPS_ERRORS=
|
||||
|
||||
# AWS S3 Settings
|
||||
SPACES_KEY=
|
||||
|
@ -34,6 +35,15 @@ NEXT_PUBLIC_EMAIL_PROVIDER=
|
|||
EMAIL_FROM=
|
||||
EMAIL_SERVER=
|
||||
|
||||
# Proxy settings
|
||||
PROXY=
|
||||
PROXY_USERNAME=
|
||||
PROXY_PASSWORD=
|
||||
PROXY_BYPASS=
|
||||
|
||||
# PDF archive settings
|
||||
PDF_MARGIN_TOP=
|
||||
PDF_MARGIN_BOTTOM=
|
||||
|
||||
#
|
||||
# SSO Providers
|
||||
|
|
|
@ -47,3 +47,4 @@ prisma/dev.db
|
|||
|
||||
# docker
|
||||
pgdata
|
||||
certificates
|
|
@ -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.
|
||||
- 🏛️ 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.
|
||||
- 🎛️ Customize the permissions of each member.
|
||||
- 🌐 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)
|
||||
- ⬇️ Import and export your bookmarks.
|
||||
- 🔐 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!
|
||||
|
||||
## Like what we're doing? Give us a Star ⭐
|
||||
|
|
|
@ -9,6 +9,7 @@ import useAccountStore from "@/store/account";
|
|||
import EditCollectionModal from "./ModalContent/EditCollectionModal";
|
||||
import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal";
|
||||
import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
|
||||
type Props = {
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
|
@ -70,6 +71,7 @@ export default function CollectionCard({ collection, className }: Props) {
|
|||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onMouseDown={dropdownTriggerer}
|
||||
className="btn btn-ghost btn-sm btn-square text-neutral"
|
||||
>
|
||||
<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">
|
||||
{collection.isPublic ? (
|
||||
<i
|
||||
className="bi-globe-americas drop-shadow text-neutral"
|
||||
className="bi-globe2 drop-shadow text-neutral"
|
||||
title="This collection is being shared publicly."
|
||||
></i>
|
||||
) : undefined}
|
||||
|
|
|
@ -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>•</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 };
|
||||
};
|
|
@ -1,3 +1,4 @@
|
|||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
|
@ -20,6 +21,7 @@ export default function FilterSearchDropdown({
|
|||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onMouseDown={dropdownTriggerer}
|
||||
className="btn btn-sm btn-square btn-ghost"
|
||||
>
|
||||
<i className="bi-funnel text-neutral text-2xl"></i>
|
||||
|
|
|
@ -4,18 +4,26 @@ import { useEffect, useState } from "react";
|
|||
import { styles } from "./styles";
|
||||
import { Options } from "./types";
|
||||
import CreatableSelect from "react-select/creatable";
|
||||
import Select from "react-select";
|
||||
|
||||
type Props = {
|
||||
onChange: any;
|
||||
defaultValue:
|
||||
showDefaultValue?: boolean;
|
||||
defaultValue?:
|
||||
| {
|
||||
label: string;
|
||||
value?: number;
|
||||
}
|
||||
| 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 router = useRouter();
|
||||
|
||||
|
@ -36,12 +44,57 @@ export default function CollectionSelection({ onChange, defaultValue }: Props) {
|
|||
|
||||
useEffect(() => {
|
||||
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);
|
||||
}, [collections]);
|
||||
|
||||
const getParentNames = (parentId: number): string[] => {
|
||||
const parentNames = [];
|
||||
const parent = collections.find((e) => e.id === parentId);
|
||||
|
||||
if (parent) {
|
||||
parentNames.push(parent.name);
|
||||
if (parent.parentId) {
|
||||
parentNames.push(...getParentNames(parent.parentId));
|
||||
}
|
||||
}
|
||||
|
||||
// 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}
|
||||
|
@ -50,8 +103,28 @@ export default function CollectionSelection({ onChange, defaultValue }: Props) {
|
|||
onChange={onChange}
|
||||
options={options}
|
||||
styles={styles}
|
||||
defaultValue={defaultValue}
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,13 +3,25 @@ import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
|||
|
||||
export default function CardView({
|
||||
links,
|
||||
showCheckbox = true,
|
||||
editMode,
|
||||
}: {
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
showCheckbox?: boolean;
|
||||
editMode?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<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) => {
|
||||
return <LinkCard key={i} link={e} count={i} />;
|
||||
return (
|
||||
<LinkCard
|
||||
key={i}
|
||||
link={e}
|
||||
count={i}
|
||||
flipDropdown={i === links.length - 1}
|
||||
editMode={editMode}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -3,13 +3,23 @@ import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
|||
|
||||
export default function ListView({
|
||||
links,
|
||||
editMode,
|
||||
}: {
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex gap-1 flex-col">
|
||||
{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>
|
||||
);
|
||||
|
|
|
@ -14,19 +14,35 @@ import Image from "next/image";
|
|||
import { previewAvailable } from "@/lib/shared/getArchiveValidity";
|
||||
import Link from "next/link";
|
||||
import LinkIcon from "./LinkComponents/LinkIcon";
|
||||
import LinkGroupedIconURL from "./LinkComponents/LinkGroupedIconURL";
|
||||
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 = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
count: number;
|
||||
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 { 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;
|
||||
|
||||
|
@ -53,6 +69,7 @@ export default function LinkGrid({ link, count, className }: Props) {
|
|||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isVisible = useOnScreen(ref);
|
||||
const permissions = usePermissions(collection?.id as number);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: any;
|
||||
|
@ -76,13 +93,34 @@ export default function LinkGrid({ link, count, className }: Props) {
|
|||
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
||||
const selectedStyle = selectedLinks.some(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
)
|
||||
? "border-primary bg-base-300"
|
||||
: "border-neutral-content";
|
||||
|
||||
const selectable =
|
||||
editMode &&
|
||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="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
|
||||
}
|
||||
>
|
||||
{!editMode ? (
|
||||
<>
|
||||
<Link
|
||||
href={link.url || ""}
|
||||
href={generateLinkHref(link, account)}
|
||||
target="_blank"
|
||||
className="rounded-2xl cursor-pointer"
|
||||
>
|
||||
|
@ -106,15 +144,7 @@ export default function LinkGrid({ link, count, className }: Props) {
|
|||
) : (
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
|
@ -126,32 +156,27 @@ export default function LinkGrid({ link, count, className }: Props) {
|
|||
{unescapeString(link.name || link.description) || link.url}
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href={link.url || ""}
|
||||
target="_blank"
|
||||
title={link.url || ""}
|
||||
className="w-fit"
|
||||
>
|
||||
<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>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
|
||||
<div className="flex justify-between text-xs text-neutral px-3 pb-1">
|
||||
<div className="cursor-pointer w-fit">
|
||||
{collection ? (
|
||||
{collection && (
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
) : undefined}
|
||||
)}
|
||||
</div>
|
||||
<LinkDate link={link} />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{showInfo ? (
|
||||
{showInfo && (
|
||||
<div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-2xl fade-in overflow-y-auto">
|
||||
<div
|
||||
onClick={() => setShowInfo(!showInfo)}
|
||||
|
@ -171,9 +196,11 @@ export default function LinkGrid({ link, count, className }: Props) {
|
|||
</span>
|
||||
)}
|
||||
</p>
|
||||
{link.tags[0] ? (
|
||||
{link.tags[0] && (
|
||||
<>
|
||||
<p className="text-neutral text-lg mt-3 font-semibold">Tags</p>
|
||||
<p className="text-neutral text-lg mt-3 font-semibold">
|
||||
Tags
|
||||
</p>
|
||||
|
||||
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
|
||||
|
@ -194,9 +221,9 @@ export default function LinkGrid({ link, count, className }: Props) {
|
|||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : undefined}
|
||||
)}
|
||||
</div>
|
||||
) : undefined}
|
||||
)}
|
||||
|
||||
<LinkActions
|
||||
link={link}
|
||||
|
@ -204,7 +231,123 @@ export default function LinkGrid({ link, count, className }: Props) {
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import PreservedFormatsModal from "@/components/ModalContent/PreservedFormatsMod
|
|||
import useLinkStore from "@/store/links";
|
||||
import { toast } from "react-hot-toast";
|
||||
import useAccountStore from "@/store/account";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
|
@ -17,6 +18,7 @@ type Props = {
|
|||
position?: string;
|
||||
toggleShowInfo?: () => void;
|
||||
linkInfo?: boolean;
|
||||
flipDropdown?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkActions({
|
||||
|
@ -24,6 +26,7 @@ export default function LinkActions({
|
|||
toggleShowInfo,
|
||||
position,
|
||||
linkInfo,
|
||||
flipDropdown,
|
||||
}: Props) {
|
||||
const permissions = usePermissions(link.collection.id as number);
|
||||
|
||||
|
@ -64,19 +67,19 @@ export default function LinkActions({
|
|||
return (
|
||||
<>
|
||||
<div
|
||||
className={`dropdown dropdown-left absolute ${
|
||||
className={`dropdown dropdown-left dropdown-end absolute ${
|
||||
position || "top-3 right-3"
|
||||
} z-20`}
|
||||
>
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onMouseDown={dropdownTriggerer}
|
||||
className="btn btn-ghost btn-sm btn-square text-neutral"
|
||||
>
|
||||
<i title="More" className="bi-three-dots text-xl" />
|
||||
</div>
|
||||
<ul className="dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1">
|
||||
{permissions === true ? (
|
||||
<ul className="dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1 translate-y-10">
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
|
@ -91,7 +94,6 @@ export default function LinkActions({
|
|||
: "Pin to Dashboard"}
|
||||
</div>
|
||||
</li>
|
||||
) : undefined}
|
||||
{linkInfo !== undefined && toggleShowInfo ? (
|
||||
<li>
|
||||
<div
|
||||
|
|
|
@ -18,7 +18,7 @@ type Props = {
|
|||
className?: string;
|
||||
};
|
||||
|
||||
export default function LinkGrid({ link, count, className }: Props) {
|
||||
export default function LinkGrid({ link }: Props) {
|
||||
const { collections } = useCollectionStore();
|
||||
|
||||
const { links } = useLinkStore();
|
||||
|
|
|
@ -11,17 +11,44 @@ import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
|||
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
|
||||
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
|
||||
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 = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
count: number;
|
||||
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 { 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;
|
||||
|
||||
|
@ -46,22 +73,59 @@ export default function LinkCardCompact({ link, count, className }: Props) {
|
|||
);
|
||||
}, [collections, links]);
|
||||
|
||||
const permissions = usePermissions(collection?.id as number);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
className={`border-neutral-content relative ${
|
||||
!showInfo ? "hover:bg-base-300" : ""
|
||||
className={`${selectedStyle} border relative items-center flex ${
|
||||
!showInfo && !isPWA() ? "hover:bg-base-300 p-3" : "py-3"
|
||||
} duration-200 rounded-lg`}
|
||||
onClick={() =>
|
||||
selectable
|
||||
? handleCheckboxClick(link)
|
||||
: editMode
|
||||
? toast.error(
|
||||
"You don't have permission to edit or delete this item."
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* {showCheckbox &&
|
||||
editMode &&
|
||||
(permissions === true ||
|
||||
permissions?.canCreate ||
|
||||
permissions?.canDelete) && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary my-auto mr-2"
|
||||
checked={selectedLinks.some(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
)}
|
||||
onChange={() => handleCheckboxClick(link)}
|
||||
/>
|
||||
)} */}
|
||||
{!editMode ? (
|
||||
<>
|
||||
<Link
|
||||
href={link.url || ""}
|
||||
href={generateLinkHref(link, account)}
|
||||
target="_blank"
|
||||
className="flex items-center cursor-pointer py-3 px-3"
|
||||
className="flex items-center cursor-pointer"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<LinkIcon link={link} width="sm:w-12 w-8" />
|
||||
<LinkIcon link={link} width="sm:w-12 w-8 mt-1 sm:mt-0" />
|
||||
</div>
|
||||
|
||||
<div className="w-[calc(100%-56px)] ml-2">
|
||||
|
@ -70,16 +134,13 @@ export default function LinkCardCompact({ link, count, className }: Props) {
|
|||
</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-2">
|
||||
{collection ? (
|
||||
<>
|
||||
<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 max-w-full w-fit text-neutral">
|
||||
<i className="bi-link-45deg text-base" />
|
||||
<div className="flex items-center gap-1 w-fit text-neutral truncate">
|
||||
<i className="bi-link-45deg text-lg" />
|
||||
<p className="truncate w-full">{shortendURL}</p>
|
||||
</div>
|
||||
) : (
|
||||
|
@ -87,24 +148,25 @@ export default function LinkCardCompact({ link, count, className }: Props) {
|
|||
{link.type}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden sm:block">·</span>
|
||||
<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 ? (
|
||||
{showInfo && (
|
||||
<div>
|
||||
<div className="pb-3 mt-1 px-3">
|
||||
<p className="text-neutral text-lg font-semibold">Description</p>
|
||||
<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>
|
||||
|
@ -116,7 +178,7 @@ export default function LinkCardCompact({ link, count, className }: Props) {
|
|||
</span>
|
||||
)}
|
||||
</p>
|
||||
{link.tags[0] ? (
|
||||
{link.tags[0] && (
|
||||
<>
|
||||
<p className="text-neutral text-lg mt-3 font-semibold">
|
||||
Tags
|
||||
|
@ -141,12 +203,56 @@ export default function LinkCardCompact({ link, count, className }: Props) {
|
|||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : undefined}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : undefined}
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
position="top-3 right-3"
|
||||
flipDropdown={flipDropdown}
|
||||
// toggleShowInfo={() => setShowInfo(!showInfo)}
|
||||
// linkInfo={showInfo}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="divider my-0 last:hidden h-[1px]"></div>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import React, { MouseEventHandler, ReactNode, useEffect } from "react";
|
||||
import ClickAwayHandler from "@/components/ClickAwayHandler";
|
||||
import { Drawer } from "vaul";
|
||||
|
||||
type Props = {
|
||||
toggleModal: Function;
|
||||
|
@ -8,13 +9,40 @@ type Props = {
|
|||
};
|
||||
|
||||
export default function Modal({ toggleModal, className, children }: Props) {
|
||||
const [drawerIsOpen, setDrawerIsOpen] = React.useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.innerWidth >= 640) {
|
||||
document.body.style.overflow = "hidden";
|
||||
document.body.style.position = "relative";
|
||||
return () => {
|
||||
document.body.style.overflow = "auto";
|
||||
document.body.style.position = "";
|
||||
};
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (window.innerWidth < 640) {
|
||||
return (
|
||||
<Drawer.Root
|
||||
open={drawerIsOpen}
|
||||
onClose={() => setTimeout(() => toggleModal(), 100)}
|
||||
>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
|
||||
<ClickAwayHandler onClickOutside={() => setDrawerIsOpen(false)}>
|
||||
<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">
|
||||
<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
|
||||
|
@ -36,3 +64,4 @@ export default function Modal({ toggleModal, className, children }: Props) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
'Delete' 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -15,7 +15,6 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
|
|||
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
|
||||
|
||||
const { removeLink } = useLinkStore();
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
|
|
|
@ -110,7 +110,7 @@ export default function EditCollectionModal({
|
|||
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto"
|
||||
onClick={submit}
|
||||
>
|
||||
Save
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
|
@ -9,6 +9,7 @@ import usePermissions from "@/hooks/usePermissions";
|
|||
import ProfilePhoto from "../ProfilePhoto";
|
||||
import addMemberToCollection from "@/lib/client/addMemberToCollection";
|
||||
import Modal from "../Modal";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
|
@ -233,11 +234,8 @@ export default function EditCollectionSharingModal({
|
|||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
key={i}
|
||||
className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between border-none"
|
||||
>
|
||||
<React.Fragment key={i}>
|
||||
<div className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between border-none">
|
||||
<div
|
||||
className={"flex items-center justify-between w-full"}
|
||||
>
|
||||
|
@ -264,6 +262,7 @@ export default function EditCollectionSharingModal({
|
|||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onMouseDown={dropdownTriggerer}
|
||||
className="btn btn-sm btn-primary font-normal"
|
||||
>
|
||||
{roleLabel}
|
||||
|
@ -431,7 +430,7 @@ export default function EditCollectionSharingModal({
|
|||
</div>
|
||||
</div>
|
||||
<div className="divider my-0 last:hidden h-[3px]"></div>
|
||||
</>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</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"
|
||||
onClick={submit}
|
||||
>
|
||||
Save
|
||||
Save Changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -124,6 +124,7 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
|
|||
label: "Unorganized",
|
||||
}
|
||||
}
|
||||
creatable={false}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
@ -157,7 +158,7 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
|
|||
className="btn btn-accent dark:border-violet-400 text-white"
|
||||
onClick={submit}
|
||||
>
|
||||
Save
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
|
@ -5,19 +5,26 @@ import toast from "react-hot-toast";
|
|||
import { HexColorPicker } from "react-colorful";
|
||||
import { Collection } from "@prisma/client";
|
||||
import Modal from "../Modal";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||
import useAccountStore from "@/store/account";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
parent?: CollectionIncludingMembersAndLinkCount;
|
||||
};
|
||||
|
||||
export default function NewCollectionModal({ onClose }: Props) {
|
||||
export default function NewCollectionModal({ onClose, parent }: Props) {
|
||||
const initial = {
|
||||
parentId: parent?.id,
|
||||
name: "",
|
||||
description: "",
|
||||
color: "#0ea5e9",
|
||||
};
|
||||
} as Partial<Collection>;
|
||||
|
||||
const [collection, setCollection] = useState<Partial<Collection>>(initial);
|
||||
const { setAccount } = useAccountStore();
|
||||
const { data } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
setCollection(initial);
|
||||
|
@ -39,7 +46,11 @@ export default function NewCollectionModal({ onClose }: Props) {
|
|||
|
||||
if (response.ok) {
|
||||
toast.success("Created!");
|
||||
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);
|
||||
|
||||
setSubmitLoader(false);
|
||||
|
@ -47,7 +58,14 @@ export default function NewCollectionModal({ onClose }: Props) {
|
|||
|
||||
return (
|
||||
<Modal toggleModal={onClose}>
|
||||
{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>
|
||||
|
||||
|
|
|
@ -109,7 +109,6 @@ export default function NewLinkModal({ onClose }: Props) {
|
|||
toast.success(`Created!`);
|
||||
onClose();
|
||||
} else toast.error(response.data as string);
|
||||
|
||||
setSubmitLoader(false);
|
||||
|
||||
return response;
|
||||
|
@ -179,7 +178,7 @@ export default function NewLinkModal({ onClose }: Props) {
|
|||
setLink({ ...link, description: e.target.value })
|
||||
}
|
||||
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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -217,10 +217,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
|
|||
<i className="bi-box-arrow-up-right" />
|
||||
</Link>
|
||||
{link?.collection.ownerId === session.data?.user.id ? (
|
||||
<div
|
||||
className={`btn w-1/2 btn-outline`}
|
||||
onClick={() => updateArchive()}
|
||||
>
|
||||
<div className={`btn btn-outline`} onClick={() => updateArchive()}>
|
||||
<div>
|
||||
<p>Refresh Preserved Formats</p>
|
||||
<p className="text-xs">
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -13,6 +13,8 @@ import NewLinkModal from "./ModalContent/NewLinkModal";
|
|||
import NewCollectionModal from "./ModalContent/NewCollectionModal";
|
||||
import Link from "next/link";
|
||||
import UploadFileModal from "./ModalContent/UploadFileModal";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
import MobileNavigation from "./MobileNavigation";
|
||||
|
||||
export default function Navbar() {
|
||||
const { settings, updateSettings } = useLocalSettingsStore();
|
||||
|
@ -35,14 +37,12 @@ export default function Navbar() {
|
|||
|
||||
useEffect(() => {
|
||||
setSidebar(false);
|
||||
}, [width]);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebar(false);
|
||||
}, [router]);
|
||||
document.body.style.overflow = "auto";
|
||||
}, [width, router]);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setSidebar(!sidebar);
|
||||
setSidebar(false);
|
||||
document.body.style.overflow = "auto";
|
||||
};
|
||||
|
||||
const [newLinkModal, setNewLinkModal] = useState(false);
|
||||
|
@ -52,8 +52,11 @@ export default function Navbar() {
|
|||
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
|
||||
onClick={toggleSidebar}
|
||||
className="text-neutral btn btn-square btn-sm btn-ghost lg:hidden"
|
||||
onClick={() => {
|
||||
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>
|
||||
</div>
|
||||
|
@ -61,11 +64,12 @@ export default function Navbar() {
|
|||
<div className="flex items-center gap-2">
|
||||
<ToggleDarkMode className="hidden sm:inline-grid" />
|
||||
|
||||
<div className="dropdown dropdown-end">
|
||||
<div className="tooltip tooltip-bottom" data-tip="Create New...">
|
||||
<div className="dropdown dropdown-end sm:inline-block hidden">
|
||||
<div className="tooltip tooltip-bottom z-10" data-tip="Create New...">
|
||||
<div
|
||||
tabIndex={0}
|
||||
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"
|
||||
>
|
||||
<span>
|
||||
|
@ -117,7 +121,12 @@ export default function Navbar() {
|
|||
</div>
|
||||
|
||||
<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
|
||||
src={account.image ? account.image : undefined}
|
||||
priority={true}
|
||||
|
@ -134,7 +143,7 @@ export default function Navbar() {
|
|||
Settings
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<li className="block sm:hidden">
|
||||
<div
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
|
@ -161,6 +170,9 @@ export default function Navbar() {
|
|||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MobileNavigation />
|
||||
|
||||
{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">
|
||||
<ClickAwayHandler className="h-full" onClickOutside={toggleSidebar}>
|
||||
|
|
|
@ -45,7 +45,7 @@ export default function ProfilePhoto({
|
|||
<div
|
||||
className={`avatar skeleton rounded-full drop-shadow-md ${
|
||||
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">
|
||||
<Image
|
||||
|
|
|
@ -4,7 +4,7 @@ import { useRouter } from "next/router";
|
|||
import React, { useEffect, useState } from "react";
|
||||
|
||||
export default function SettingsSidebar({ className }: { className?: string }) {
|
||||
const LINKWARDEN_VERSION = "v2.4.9";
|
||||
const LINKWARDEN_VERSION = "v2.5.0";
|
||||
|
||||
const { collections } = useCollectionStore();
|
||||
|
||||
|
@ -37,43 +37,30 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
|||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/appearance">
|
||||
<Link href="/settings/preference">
|
||||
<div
|
||||
className={`${
|
||||
active === `/settings/appearance`
|
||||
active === `/settings/preference`
|
||||
? "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-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>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/archive">
|
||||
<Link href="/settings/access-tokens">
|
||||
<div
|
||||
className={`${
|
||||
active === `/settings/archive`
|
||||
? "bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
>
|
||||
<i className="bi-archive text-primary text-2xl"></i>
|
||||
<p className="truncate w-full pr-7">Archive</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/api">
|
||||
<div
|
||||
className={`${
|
||||
active === `/settings/api`
|
||||
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-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>
|
||||
</Link>
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ import { useRouter } from "next/router";
|
|||
import { useEffect, useState } from "react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
import SidebarHighlightLink from "@/components/SidebarHighlightLink";
|
||||
import CollectionListing from "@/components/CollectionListing";
|
||||
|
||||
export default function Sidebar({ className }: { className?: string }) {
|
||||
const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => {
|
||||
|
@ -21,11 +22,10 @@ export default function Sidebar({ className }: { className?: string }) {
|
|||
|
||||
const { collections } = useCollectionStore();
|
||||
const { tags } = useTagStore();
|
||||
const [active, setActive] = useState("");
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [active, setActive] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("tagDisclosure", tagDisclosure ? "true" : "false");
|
||||
}, [tagDisclosure]);
|
||||
|
@ -44,7 +44,7 @@ export default function Sidebar({ className }: { className?: string }) {
|
|||
return (
|
||||
<div
|
||||
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 || ""
|
||||
}`}
|
||||
>
|
||||
|
@ -97,48 +97,8 @@ export default function Sidebar({ className }: { className?: string }) {
|
|||
leaveFrom="transform opacity-100 translate-y-0"
|
||||
leaveTo="transform opacity-0 -translate-y-3"
|
||||
>
|
||||
<Disclosure.Panel className="flex flex-col gap-1">
|
||||
{collections[0] ? (
|
||||
collections
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((e, i) => {
|
||||
return (
|
||||
<Link key={i} href={`/collections/${e.id}`}>
|
||||
<div
|
||||
className={`${
|
||||
active === `/collections/${e.id}`
|
||||
? "bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
} duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
|
||||
>
|
||||
<i
|
||||
className="bi-folder-fill text-2xl drop-shadow"
|
||||
style={{ color: e.color }}
|
||||
></i>
|
||||
<p className="truncate w-full">{e.name}</p>
|
||||
|
||||
{e.isPublic ? (
|
||||
<i
|
||||
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
|
||||
title="This collection is being shared publicly."
|
||||
></i>
|
||||
) : undefined}
|
||||
<div className="drop-shadow text-neutral text-xs">
|
||||
{e._count?.links}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div
|
||||
className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`}
|
||||
>
|
||||
<p className="text-neutral text-xs font-semibold truncate w-full pr-7">
|
||||
You Have No Collections...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Disclosure.Panel>
|
||||
<CollectionListing />
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</Disclosure>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import React, { Dispatch, SetStateAction } from "react";
|
||||
import { Sort } from "@/types/global";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
|
||||
type Props = {
|
||||
sortBy: Sort;
|
||||
|
@ -12,7 +13,8 @@ export default function SortDropdown({ sortBy, setSort }: Props) {
|
|||
<div
|
||||
tabIndex={0}
|
||||
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>
|
||||
</div>
|
||||
|
|
|
@ -8,6 +8,7 @@ type Props = {
|
|||
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLInputElement> | undefined;
|
||||
className?: string;
|
||||
spellCheck?: boolean;
|
||||
};
|
||||
|
||||
export default function TextInput({
|
||||
|
@ -18,9 +19,11 @@ export default function TextInput({
|
|||
onChange,
|
||||
onKeyDown,
|
||||
className,
|
||||
spellCheck,
|
||||
}: Props) {
|
||||
return (
|
||||
<input
|
||||
spellCheck={spellCheck}
|
||||
autoFocus={autoFocus}
|
||||
type={type ? type : "text"}
|
||||
placeholder={placeholder}
|
||||
|
|
|
@ -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 { ViewMode } from "@/types/global";
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -18,7 +18,8 @@ export default function useLinks(
|
|||
searchByTextContent,
|
||||
}: LinkRequestQuery = { sort: 0 }
|
||||
) {
|
||||
const { links, setLinks, resetLinks } = useLinkStore();
|
||||
const { links, setLinks, resetLinks, selectedLinks, setSelectedLinks } =
|
||||
useLinkStore();
|
||||
const router = useRouter();
|
||||
|
||||
const { reachedBottom, setReachedBottom } = useDetectPageBottom();
|
||||
|
@ -68,8 +69,12 @@ export default function useLinks(
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Save the selected links before resetting the links
|
||||
// and then restore the selected links after resetting the links
|
||||
const previouslySelected = selectedLinks;
|
||||
resetLinks();
|
||||
|
||||
setSelectedLinks(previouslySelected);
|
||||
getLinks(true);
|
||||
}, [
|
||||
router,
|
||||
|
|
|
@ -46,7 +46,7 @@ export default function MainLayout({ children }: Props) {
|
|||
</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"
|
||||
} lg:ml-80 ${showAnnouncement ? "mt-10" : ""}`}
|
||||
>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { chromium, devices } from "playwright";
|
||||
import { LaunchOptions, chromium, devices } from "playwright";
|
||||
import { prisma } from "./db";
|
||||
import createFile from "./storage/createFile";
|
||||
import sendToWayback from "./sendToWayback";
|
||||
|
@ -20,8 +20,23 @@ type LinksAndCollectionAndOwner = Link & {
|
|||
const BROWSER_TIMEOUT = Number(process.env.BROWSER_TIMEOUT) || 5;
|
||||
|
||||
export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext(devices["Desktop Chrome"]);
|
||||
// allow user to configure a proxy
|
||||
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 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")) {
|
||||
processingPromises.push(
|
||||
page
|
||||
|
@ -245,7 +267,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
|||
width: "1366px",
|
||||
height: "1931px",
|
||||
printBackground: true,
|
||||
margin: { top: "15px", bottom: "15px" },
|
||||
margin: margins,
|
||||
})
|
||||
.then((pdf) => {
|
||||
return createFile({
|
||||
|
|
|
@ -31,12 +31,16 @@ export default async function deleteCollection(
|
|||
},
|
||||
});
|
||||
|
||||
await removeFromOrders(userId, collectionId);
|
||||
|
||||
return { response: deletedUsersAndCollectionsRelation, status: 200 };
|
||||
} else if (collectionIsAccessible?.ownerId !== userId) {
|
||||
return { response: "Collection is not accessible.", status: 401 };
|
||||
}
|
||||
|
||||
const deletedCollection = await prisma.$transaction(async () => {
|
||||
await deleteSubCollections(collectionId);
|
||||
|
||||
await prisma.usersAndCollections.deleteMany({
|
||||
where: {
|
||||
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({
|
||||
where: {
|
||||
|
@ -64,3 +70,60 @@ export default async function deleteCollection(
|
|||
|
||||
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
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
import { prisma } from "@/lib/api/db";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||
import getPermission from "@/lib/api/getPermission";
|
||||
import { Collection, UsersAndCollections } from "@prisma/client";
|
||||
|
||||
export default async function updateCollection(
|
||||
userId: number,
|
||||
|
@ -19,6 +18,32 @@ export default async function updateCollection(
|
|||
if (!(collectionIsAccessible?.ownerId === userId))
|
||||
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 () => {
|
||||
await prisma.usersAndCollections.deleteMany({
|
||||
where: {
|
||||
|
@ -32,12 +57,23 @@ export default async function updateCollection(
|
|||
where: {
|
||||
id: collectionId,
|
||||
},
|
||||
|
||||
data: {
|
||||
name: data.name.trim(),
|
||||
description: data.description,
|
||||
color: data.color,
|
||||
isPublic: data.isPublic,
|
||||
parent:
|
||||
data.parentId && data.parentId !== ("root" as any)
|
||||
? {
|
||||
connect: {
|
||||
id: data.parentId,
|
||||
},
|
||||
}
|
||||
: data.parentId === ("root" as any)
|
||||
? {
|
||||
disconnect: true,
|
||||
}
|
||||
: undefined,
|
||||
members: {
|
||||
create: data.members.map((e) => ({
|
||||
user: { connect: { id: e.user.id || e.userId } },
|
||||
|
|
|
@ -12,6 +12,12 @@ export default async function getCollection(userId: number) {
|
|||
_count: {
|
||||
select: { links: true },
|
||||
},
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
|
|
|
@ -12,23 +12,25 @@ export default async function postCollection(
|
|||
status: 400,
|
||||
};
|
||||
|
||||
const findCollection = await prisma.user.findUnique({
|
||||
if (collection.parentId) {
|
||||
const findParentCollection = await prisma.collection.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
id: collection.parentId,
|
||||
},
|
||||
select: {
|
||||
collections: {
|
||||
where: {
|
||||
name: collection.name,
|
||||
},
|
||||
},
|
||||
ownerId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const checkIfCollectionExists = findCollection?.collections[0];
|
||||
|
||||
if (checkIfCollectionExists)
|
||||
return { response: "Collection already exists.", status: 400 };
|
||||
if (
|
||||
findParentCollection?.ownerId !== userId ||
|
||||
typeof collection.parentId !== "number"
|
||||
)
|
||||
return {
|
||||
response: "You are not authorized to create a sub-collection here.",
|
||||
status: 403,
|
||||
};
|
||||
}
|
||||
|
||||
const newCollection = await prisma.collection.create({
|
||||
data: {
|
||||
|
@ -40,6 +42,13 @@ export default async function postCollection(
|
|||
name: collection.name.trim(),
|
||||
description: collection.description,
|
||||
color: collection.color,
|
||||
parent: collection.parentId
|
||||
? {
|
||||
connect: {
|
||||
id: collection.parentId,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: {
|
||||
_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}` });
|
||||
|
||||
return { response: newCollection, status: 200 };
|
||||
|
|
|
@ -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 };
|
||||
}
|
|
@ -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 };
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
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 removeFile from "@/lib/api/storage/removeFile";
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { prisma } from "@/lib/api/db";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import { Collection, Link, UsersAndCollections } from "@prisma/client";
|
||||
import { UsersAndCollections } from "@prisma/client";
|
||||
import getPermission from "@/lib/api/getPermission";
|
||||
import moveFile from "@/lib/api/storage/moveFile";
|
||||
|
||||
|
@ -17,13 +17,70 @@ export default async function updateLinkById(
|
|||
|
||||
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(
|
||||
(e: UsersAndCollections) => e.userId === userId && e.canUpdate
|
||||
);
|
||||
|
||||
const isCollectionOwner =
|
||||
collectionIsAccessible?.ownerId === data.collection.ownerId &&
|
||||
data.collection.ownerId === userId;
|
||||
const targetCollectionsAccessible =
|
||||
targetCollectionIsAccessible?.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 =
|
||||
!isCollectionOwner && collectionIsAccessible?.id !== data.collection.id;
|
||||
|
|
|
@ -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.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({
|
||||
|
@ -42,22 +148,6 @@ export default async function postLink(
|
|||
|
||||
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 =
|
||||
link.description && link.description !== ""
|
||||
? link.description
|
||||
|
@ -81,22 +171,13 @@ export default async function postLink(
|
|||
|
||||
const newLink = await prisma.link.create({
|
||||
data: {
|
||||
url: link.url,
|
||||
url: link.url?.trim(),
|
||||
name: link.name,
|
||||
description,
|
||||
type: linkType,
|
||||
collection: {
|
||||
connectOrCreate: {
|
||||
where: {
|
||||
name_ownerId: {
|
||||
ownerId: link.collection.ownerId,
|
||||
name: link.collection.name,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
name: link.collection.name.trim(),
|
||||
ownerId: userId,
|
||||
},
|
||||
connect: {
|
||||
id: link.collection.id,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
|
|
|
@ -13,6 +13,8 @@ export default async function exportData(userId: number) {
|
|||
},
|
||||
},
|
||||
},
|
||||
pinnedLinks: true,
|
||||
whitelistedUsers: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { prisma } from "@/lib/api/db";
|
||||
import createFolder from "@/lib/api/storage/createFolder";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { parse, Node, Element, TextNode } from "himalaya";
|
||||
|
||||
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 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 totalImports = bookmarks.length;
|
||||
|
||||
|
@ -28,62 +34,146 @@ export default async function importFromHTMLFile(
|
|||
status: 400,
|
||||
};
|
||||
|
||||
const folders = document.querySelectorAll("H3");
|
||||
const jsonData = parse(document.documentElement.outerHTML);
|
||||
|
||||
await prisma
|
||||
.$transaction(
|
||||
async () => {
|
||||
// @ts-ignore
|
||||
for (const folder of folders) {
|
||||
const findCollection = await prisma.user.findUnique({
|
||||
for (const item of jsonData) {
|
||||
console.log(item);
|
||||
await processBookmarks(userId, item as Element);
|
||||
}
|
||||
|
||||
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: {
|
||||
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,
|
||||
parentId,
|
||||
name: collectionName,
|
||||
ownerId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
createFolder({ filePath: `archives/${newCollection.id}` });
|
||||
|
||||
collectionId = newCollection.id;
|
||||
if (findCollection) {
|
||||
return findCollection.id;
|
||||
}
|
||||
|
||||
createFolder({ filePath: `archives/${collectionId}` });
|
||||
const collectionId = await prisma.collection.create({
|
||||
data: {
|
||||
name: collectionName,
|
||||
parent: parentId
|
||||
? {
|
||||
connect: {
|
||||
id: parentId,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
owner: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const bookmarks = folder.nextElementSibling.querySelectorAll("A");
|
||||
for (const bookmark of bookmarks) {
|
||||
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: bookmark.textContent.trim(),
|
||||
url: bookmark.getAttribute("HREF"),
|
||||
tags: bookmark.getAttribute("TAGS")
|
||||
? {
|
||||
connectOrCreate: bookmark
|
||||
.getAttribute("TAGS")
|
||||
.split(",")
|
||||
.map((tag: string) =>
|
||||
tag
|
||||
name: name || "",
|
||||
url,
|
||||
description,
|
||||
collectionId,
|
||||
tags:
|
||||
tags && tags[0]
|
||||
? {
|
||||
connectOrCreate: tags.map((tag: string) => {
|
||||
return (
|
||||
{
|
||||
where: {
|
||||
name_ownerId: {
|
||||
name: tag.trim(),
|
||||
|
@ -98,24 +188,11 @@ export default async function importFromHTMLFile(
|
|||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
),
|
||||
} || 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 };
|
||||
}
|
||||
};
|
||||
|
|
|
@ -37,24 +37,6 @@ export default async function importFromLinkwarden(
|
|||
for (const e of data.collections) {
|
||||
e.name = e.name.trim();
|
||||
|
||||
const findCollection = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
collections: {
|
||||
where: {
|
||||
name: e.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const checkIfCollectionExists = findCollection?.collections[0];
|
||||
|
||||
let collectionId = findCollection?.collections[0]?.id;
|
||||
|
||||
if (!checkIfCollectionExists) {
|
||||
const newCollection = await prisma.collection.create({
|
||||
data: {
|
||||
owner: {
|
||||
|
@ -70,9 +52,6 @@ export default async function importFromLinkwarden(
|
|||
|
||||
createFolder({ filePath: `archives/${newCollection.id}` });
|
||||
|
||||
collectionId = newCollection.id;
|
||||
}
|
||||
|
||||
// Import Links
|
||||
for (const link of e.links) {
|
||||
const newLink = await prisma.link.create({
|
||||
|
@ -82,7 +61,7 @@ export default async function importFromLinkwarden(
|
|||
description: link.description,
|
||||
collection: {
|
||||
connect: {
|
||||
id: collectionId,
|
||||
id: newCollection.id,
|
||||
},
|
||||
},
|
||||
// Import Tags
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -183,9 +183,14 @@ export default async function updateUserById(
|
|||
email: data.email?.toLowerCase().trim(),
|
||||
isPrivate: data.isPrivate,
|
||||
image: data.image ? `uploads/avatar/${userId}.jpg` : "",
|
||||
collectionOrder: data.collectionOrder.filter(
|
||||
(value, index, self) => self.indexOf(value) === index
|
||||
),
|
||||
archiveAsScreenshot: data.archiveAsScreenshot,
|
||||
archiveAsPDF: data.archiveAsPDF,
|
||||
archiveAsWaybackMachine: data.archiveAsWaybackMachine,
|
||||
linksRouteTo: data.linksRouteTo,
|
||||
preventDuplicateLinks: data.preventDuplicateLinks,
|
||||
password:
|
||||
data.newPassword && data.newPassword !== ""
|
||||
? newHashedPassword
|
||||
|
|
|
@ -3,12 +3,14 @@ import { prisma } from "@/lib/api/db";
|
|||
type Props = {
|
||||
userId: number;
|
||||
collectionId?: number;
|
||||
collectionName?: string;
|
||||
linkId?: number;
|
||||
};
|
||||
|
||||
export default async function getPermission({
|
||||
userId,
|
||||
collectionId,
|
||||
collectionName,
|
||||
linkId,
|
||||
}: Props) {
|
||||
if (linkId) {
|
||||
|
@ -24,10 +26,11 @@ export default async function getPermission({
|
|||
});
|
||||
|
||||
return check;
|
||||
} else if (collectionId) {
|
||||
} else if (collectionId || collectionName) {
|
||||
const check = await prisma.collection.findFirst({
|
||||
where: {
|
||||
id: collectionId,
|
||||
id: collectionId || undefined,
|
||||
name: collectionName || undefined,
|
||||
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
|
||||
},
|
||||
include: { members: true },
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import { prisma } from "./db";
|
||||
import { User } from "@prisma/client";
|
||||
import verifySubscription from "./verifySubscription";
|
||||
import verifyToken from "./verifyToken";
|
||||
|
||||
type Props = {
|
||||
req: NextApiRequest;
|
||||
|
@ -15,14 +15,15 @@ export default async function verifyUser({
|
|||
req,
|
||||
res,
|
||||
}: Props): Promise<User | null> {
|
||||
const token = await getToken({ req });
|
||||
const userId = token?.id;
|
||||
const token = await verifyToken({ req });
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({ response: "You must be logged in." });
|
||||
if (typeof token === "string") {
|
||||
res.status(401).json({ response: token });
|
||||
return null;
|
||||
}
|
||||
|
||||
const userId = token?.id;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
|
|
|
@ -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 || "";
|
||||
}
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,8 @@
|
|||
export function screenshotAvailable(link: any) {
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
|
||||
export function screenshotAvailable(
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
) {
|
||||
return (
|
||||
link &&
|
||||
link.image &&
|
||||
|
@ -7,13 +11,15 @@ export function screenshotAvailable(link: any) {
|
|||
);
|
||||
}
|
||||
|
||||
export function pdfAvailable(link: any) {
|
||||
export function pdfAvailable(link: LinkIncludingShortenedCollectionAndTags) {
|
||||
return (
|
||||
link && link.pdf && link.pdf !== "pending" && link.pdf !== "unavailable"
|
||||
);
|
||||
}
|
||||
|
||||
export function readabilityAvailable(link: any) {
|
||||
export function readabilityAvailable(
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
) {
|
||||
return (
|
||||
link &&
|
||||
link.readable &&
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import fetch from "node-fetch";
|
||||
import https from "https";
|
||||
import { SocksProxyAgent } from "socks-proxy-agent";
|
||||
|
||||
export default async function getTitle(url: string) {
|
||||
try {
|
||||
const httpsAgent = new https.Agent({
|
||||
|
@ -7,9 +9,26 @@ export default async function getTitle(url: string) {
|
|||
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
// fetchOpts allows a proxy to be defined
|
||||
let fetchOpts = {
|
||||
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();
|
||||
|
||||
// regular expression to find the <title> tag
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "linkwarden",
|
||||
"version": "2.4.9",
|
||||
"version": "2.5.0",
|
||||
"main": "index.js",
|
||||
"repository": "https://github.com/linkwarden/linkwarden.git",
|
||||
"author": "Daniel31X13 <daniel31x13@gmail.com>",
|
||||
|
@ -19,6 +19,7 @@
|
|||
"format": "prettier --write \"**/*.{ts,tsx,js,json,md}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlaskit/tree": "^8.8.7",
|
||||
"@auth/prisma-adapter": "^1.0.1",
|
||||
"@aws-sdk/client-s3": "^3.379.1",
|
||||
"@headlessui/react": "^1.7.15",
|
||||
|
@ -44,12 +45,14 @@
|
|||
"eslint-config-next": "13.4.9",
|
||||
"formidable": "^3.5.1",
|
||||
"framer-motion": "^10.16.4",
|
||||
"himalaya": "^1.1.0",
|
||||
"jimp": "^0.22.10",
|
||||
"jsdom": "^22.1.0",
|
||||
"lottie-web": "^5.12.2",
|
||||
"micro": "^10.0.1",
|
||||
"next": "13.4.12",
|
||||
"next-auth": "^4.22.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^6.9.3",
|
||||
"playwright": "^1.35.1",
|
||||
"react": "18.2.0",
|
||||
|
@ -58,7 +61,9 @@
|
|||
"react-hot-toast": "^2.4.1",
|
||||
"react-image-file-resizer": "^0.4.8",
|
||||
"react-select": "^5.7.4",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
"stripe": "^12.13.0",
|
||||
"vaul": "^0.8.8",
|
||||
"zustand": "^4.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import "@/styles/globals.css";
|
||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
@ -7,6 +7,7 @@ import Head from "next/head";
|
|||
import AuthRedirect from "@/layouts/AuthRedirect";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { Session } from "next-auth";
|
||||
import { isPWA } from "@/lib/client/utils";
|
||||
|
||||
export default function App({
|
||||
Component,
|
||||
|
@ -14,6 +15,15 @@ export default function App({
|
|||
}: AppProps<{
|
||||
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 (
|
||||
<SessionProvider
|
||||
session={pageProps.session}
|
||||
|
@ -23,6 +33,7 @@ export default function App({
|
|||
<Head>
|
||||
<title>Linkwarden</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import readFile from "@/lib/api/storage/readFile";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import { ArchivedFormat } from "@/types/global";
|
||||
import verifyUser from "@/lib/api/verifyUser";
|
||||
|
@ -9,6 +8,7 @@ import { UsersAndCollections } from "@prisma/client";
|
|||
import formidable from "formidable";
|
||||
import createFile from "@/lib/api/storage/createFile";
|
||||
import fs from "fs";
|
||||
import verifyToken from "@/lib/api/verifyToken";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
|
@ -33,8 +33,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
|||
return res.status(401).json({ response: "Invalid parameters." });
|
||||
|
||||
if (req.method === "GET") {
|
||||
const token = await getToken({ req });
|
||||
const userId = token?.id;
|
||||
const token = await verifyToken({ req });
|
||||
const userId = typeof token === "string" ? undefined : token?.id;
|
||||
|
||||
const collectionIsAccessible = await prisma.collection.findFirst({
|
||||
where: {
|
||||
|
|
|
@ -65,6 +65,7 @@ import ZitadelProvider from "next-auth/providers/zitadel";
|
|||
import ZohoProvider from "next-auth/providers/zoho";
|
||||
import ZoomProvider from "next-auth/providers/zoom";
|
||||
import * as process from "process";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
const emailEnabled =
|
||||
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
||||
|
@ -1059,7 +1060,8 @@ if (process.env.NEXT_PUBLIC_ZOOM_ENABLED_ENABLED === "true") {
|
|||
};
|
||||
}
|
||||
|
||||
export const authOptions: AuthOptions = {
|
||||
export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||
return await NextAuth(req, res, {
|
||||
adapter: adapter as Adapter,
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
|
@ -1113,6 +1115,5 @@ export const authOptions: AuthOptions = {
|
|||
return session;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default NextAuth(authOptions);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { prisma } from "@/lib/api/db";
|
||||
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) {
|
||||
const queryId = Number(req.query.id);
|
||||
|
@ -12,8 +12,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
|||
.status(401)
|
||||
.send("Invalid parameters.");
|
||||
|
||||
const token = await getToken({ req });
|
||||
const userId = token?.id;
|
||||
const token = await verifyToken({ req });
|
||||
const userId = typeof token === "string" ? undefined : token?.id;
|
||||
|
||||
if (req.method === "GET") {
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
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 deleteCollectionById from "@/lib/api/controllers/collections/collectionId/deleteCollectionById";
|
||||
import verifyUser from "@/lib/api/verifyUser";
|
||||
|
@ -10,18 +11,18 @@ export default async function collections(
|
|||
const user = await verifyUser({ req, res });
|
||||
if (!user) return;
|
||||
|
||||
if (req.method === "PUT") {
|
||||
const updated = await updateCollectionById(
|
||||
user.id,
|
||||
Number(req.query.id) as number,
|
||||
req.body
|
||||
);
|
||||
const collectionId = Number(req.query.id);
|
||||
|
||||
if (req.method === "GET") {
|
||||
const collections = await getCollectionById(user.id, collectionId);
|
||||
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 });
|
||||
} else if (req.method === "DELETE") {
|
||||
const deleted = await deleteCollectionById(
|
||||
user.id,
|
||||
Number(req.query.id) as number
|
||||
);
|
||||
const deleted = await deleteCollectionById(user.id, collectionId);
|
||||
return res.status(deleted.status).json({ response: deleted.response });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@ import getLinks from "@/lib/api/controllers/links/getLinks";
|
|||
import postLink from "@/lib/api/controllers/links/postLink";
|
||||
import { LinkRequestQuery } from "@/types/global";
|
||||
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) {
|
||||
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({
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
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) {
|
||||
const token = await getToken({ req });
|
||||
const requestingId = token?.id;
|
||||
const token = await verifyToken({ req });
|
||||
const requestingId = typeof token === "string" ? undefined : token?.id;
|
||||
|
||||
const lookupId = req.query.id as string;
|
||||
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -2,20 +2,22 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||
import getUserById from "@/lib/api/controllers/users/userId/getUserById";
|
||||
import updateUserById from "@/lib/api/controllers/users/userId/updateUserById";
|
||||
import deleteUserById from "@/lib/api/controllers/users/userId/deleteUserById";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import verifySubscription from "@/lib/api/verifySubscription";
|
||||
import verifyToken from "@/lib/api/verifyToken";
|
||||
|
||||
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||
|
||||
export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||
const token = await getToken({ req });
|
||||
const userId = token?.id;
|
||||
const token = await verifyToken({ req });
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ response: "You must be logged in." });
|
||||
if (typeof token === "string") {
|
||||
res.status(401).json({ response: token });
|
||||
return null;
|
||||
}
|
||||
|
||||
const userId = token?.id;
|
||||
|
||||
if (userId !== Number(req.query.id))
|
||||
return res.status(401).json({ response: "Permission denied." });
|
||||
|
||||
|
|
|
@ -23,13 +23,19 @@ import ViewDropdown from "@/components/ViewDropdown";
|
|||
import CardView from "@/components/LinkViews/Layouts/CardView";
|
||||
// import GridView from "@/components/LinkViews/Layouts/GridView";
|
||||
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() {
|
||||
const { settings } = useLocalSettingsStore();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { links } = useLinkStore();
|
||||
const { links, selectedLinks, setSelectedLinks, deleteLinksById } =
|
||||
useLinkStore();
|
||||
const { collections } = useCollectionStore();
|
||||
|
||||
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
||||
|
@ -78,12 +84,24 @@ export default function Index() {
|
|||
};
|
||||
|
||||
fetchOwner();
|
||||
|
||||
// When the collection changes, reset the selected links
|
||||
setSelectedLinks([]);
|
||||
}, [activeCollection]);
|
||||
|
||||
const [editCollectionModal, setEditCollectionModal] = useState(false);
|
||||
const [newCollectionModal, setNewCollectionModal] = useState(false);
|
||||
const [editCollectionSharingModal, setEditCollectionSharingModal] =
|
||||
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>(
|
||||
localStorage.getItem("viewMode") || ViewMode.Card
|
||||
|
@ -98,6 +116,35 @@ export default function Index() {
|
|||
// @ts-ignore
|
||||
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 (
|
||||
<MainLayout>
|
||||
<div
|
||||
|
@ -125,12 +172,13 @@ export default function Index() {
|
|||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onMouseDown={dropdownTriggerer}
|
||||
className="btn btn-ghost btn-sm btn-square text-neutral"
|
||||
>
|
||||
<i className="bi-three-dots text-xl" title="More"></i>
|
||||
</div>
|
||||
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1">
|
||||
{permissions === true ? (
|
||||
{permissions === true && (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
|
@ -143,7 +191,7 @@ export default function Index() {
|
|||
Edit Collection Info
|
||||
</div>
|
||||
</li>
|
||||
) : undefined}
|
||||
)}
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
|
@ -158,6 +206,20 @@ export default function Index() {
|
|||
: "View Team"}
|
||||
</div>
|
||||
</li>
|
||||
{permissions === true && (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
setNewCollectionModal(true);
|
||||
}}
|
||||
>
|
||||
Create Sub-Collection
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
|
@ -177,7 +239,7 @@ export default function Index() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{activeCollection ? (
|
||||
{activeCollection && (
|
||||
<div className={`min-w-[15rem]`}>
|
||||
<div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
|
||||
<div
|
||||
|
@ -213,31 +275,127 @@ export default function Index() {
|
|||
</div>
|
||||
<p className="text-neutral text-sm font-semibold">
|
||||
By {collectionOwner.name}
|
||||
{activeCollection.members.length > 0
|
||||
? ` and ${activeCollection.members.length} others`
|
||||
: undefined}
|
||||
{activeCollection.members.length > 0 &&
|
||||
` and ${activeCollection.members.length} others`}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined}
|
||||
)}
|
||||
|
||||
{activeCollection?.description ? (
|
||||
{activeCollection?.description && (
|
||||
<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="flex justify-between items-end gap-5">
|
||||
<div className="flex justify-between items-center gap-5">
|
||||
<p>Showing {activeCollection?._count?.links} results</p>
|
||||
<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} />
|
||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||
</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)) ? (
|
||||
<LinkComponent
|
||||
editMode={editMode}
|
||||
links={links.filter(
|
||||
(e) => e.collection.id === activeCollection?.id
|
||||
)}
|
||||
|
@ -246,28 +404,48 @@ export default function Index() {
|
|||
<NoLinksFound />
|
||||
)}
|
||||
</div>
|
||||
{activeCollection ? (
|
||||
{activeCollection && (
|
||||
<>
|
||||
{editCollectionModal ? (
|
||||
{editCollectionModal && (
|
||||
<EditCollectionModal
|
||||
onClose={() => setEditCollectionModal(false)}
|
||||
activeCollection={activeCollection}
|
||||
/>
|
||||
) : undefined}
|
||||
{editCollectionSharingModal ? (
|
||||
)}
|
||||
{editCollectionSharingModal && (
|
||||
<EditCollectionSharingModal
|
||||
onClose={() => setEditCollectionSharingModal(false)}
|
||||
activeCollection={activeCollection}
|
||||
/>
|
||||
) : undefined}
|
||||
{deleteCollectionModal ? (
|
||||
)}
|
||||
{newCollectionModal && (
|
||||
<NewCollectionModal
|
||||
onClose={() => setNewCollectionModal(false)}
|
||||
parent={activeCollection}
|
||||
/>
|
||||
)}
|
||||
{deleteCollectionModal && (
|
||||
<DeleteCollectionModal
|
||||
onClose={() => setDeleteCollectionModal(false)}
|
||||
activeCollection={activeCollection}
|
||||
/>
|
||||
) : undefined}
|
||||
)}
|
||||
{bulkDeleteLinksModal && (
|
||||
<BulkDeleteLinksModal
|
||||
onClose={() => {
|
||||
setBulkDeleteLinksModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{bulkEditLinksModal && (
|
||||
<BulkEditLinksModal
|
||||
onClose={() => {
|
||||
setBulkEditLinksModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : undefined}
|
||||
)}
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -11,7 +11,6 @@ import PageHeader from "@/components/PageHeader";
|
|||
|
||||
export default function Collections() {
|
||||
const { collections } = useCollectionStore();
|
||||
const [expandDropdown, setExpandDropdown] = useState(false);
|
||||
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
||||
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">
|
||||
{sortedCollections
|
||||
.filter((e) => e.ownerId === data?.user.id)
|
||||
.filter((e) => e.ownerId === data?.user.id && e.parentId === null)
|
||||
.map((e, i) => {
|
||||
return <CollectionCard key={i} collection={e} />;
|
||||
})}
|
||||
|
|
|
@ -2,7 +2,6 @@ import useLinkStore from "@/store/links";
|
|||
import useCollectionStore from "@/store/collections";
|
||||
import useTagStore from "@/store/tags";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import LinkCard from "@/components/LinkViews/LinkCard";
|
||||
import { useEffect, useState } from "react";
|
||||
import useLinks from "@/hooks/useLinks";
|
||||
import Link from "next/link";
|
||||
|
@ -16,6 +15,7 @@ import PageHeader from "@/components/PageHeader";
|
|||
import CardView from "@/components/LinkViews/Layouts/CardView";
|
||||
import ListView from "@/components/LinkViews/Layouts/ListView";
|
||||
import ViewDropdown from "@/components/ViewDropdown";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
// import GridView from "@/components/LinkViews/Layouts/GridView";
|
||||
|
||||
export default function Dashboard() {
|
||||
|
@ -168,7 +168,10 @@ export default function Dashboard() {
|
|||
>
|
||||
{links[0] ? (
|
||||
<div className="w-full">
|
||||
<LinkComponent links={links.slice(0, showLinks)} />
|
||||
<LinkComponent
|
||||
links={links.slice(0, showLinks)}
|
||||
showCheckbox={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
|
@ -200,6 +203,7 @@ export default function Dashboard() {
|
|||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onMouseDown={dropdownTriggerer}
|
||||
className="inline-flex items-center gap-2 text-sm btn btn-outline btn-neutral"
|
||||
id="import-dropdown"
|
||||
>
|
||||
|
@ -277,14 +281,12 @@ export default function Dashboard() {
|
|||
>
|
||||
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
|
||||
<div className="w-full">
|
||||
<div
|
||||
className={`grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5 w-full`}
|
||||
>
|
||||
{links
|
||||
<LinkComponent
|
||||
showCheckbox={false}
|
||||
links={links
|
||||
.filter((e) => e.pinnedBy && e.pinnedBy[0])
|
||||
.map((e, i) => <LinkCard key={i} link={e} count={i} />)
|
||||
.slice(0, showLinks)}
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
|
|
|
@ -3,24 +3,74 @@ import SortDropdown from "@/components/SortDropdown";
|
|||
import useLinks from "@/hooks/useLinks";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import useLinkStore from "@/store/links";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import { Sort, ViewMode } from "@/types/global";
|
||||
import { Member, Sort, ViewMode } from "@/types/global";
|
||||
import ViewDropdown from "@/components/ViewDropdown";
|
||||
import CardView from "@/components/LinkViews/Layouts/CardView";
|
||||
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 { useRouter } from "next/router";
|
||||
|
||||
export default function Links() {
|
||||
const { links } = useLinkStore();
|
||||
const { links, selectedLinks, deleteLinksById, setSelectedLinks } =
|
||||
useLinkStore();
|
||||
|
||||
const [viewMode, setViewMode] = useState<string>(
|
||||
localStorage.getItem("viewMode") || ViewMode.Card
|
||||
);
|
||||
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 });
|
||||
|
||||
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 = {
|
||||
[ViewMode.Card]: CardView,
|
||||
// [ViewMode.Grid]: GridView,
|
||||
|
@ -41,17 +91,105 @@ export default function Links() {
|
|||
/>
|
||||
|
||||
<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} />
|
||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||
</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] ? (
|
||||
<LinkComponent links={links} />
|
||||
<LinkComponent editMode={editMode} links={links} />
|
||||
) : (
|
||||
<NoLinksFound text="You Haven't Created Any Links Yet" />
|
||||
)}
|
||||
</div>
|
||||
{bulkDeleteLinksModal && (
|
||||
<BulkDeleteLinksModal
|
||||
onClose={() => {
|
||||
setBulkDeleteLinksModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{bulkEditLinksModal && (
|
||||
<BulkEditLinksModal
|
||||
onClose={() => {
|
||||
setBulkEditLinksModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,16 +2,22 @@ import SortDropdown from "@/components/SortDropdown";
|
|||
import useLinks from "@/hooks/useLinks";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import useLinkStore from "@/store/links";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import { Sort, ViewMode } from "@/types/global";
|
||||
import ViewDropdown from "@/components/ViewDropdown";
|
||||
import CardView from "@/components/LinkViews/Layouts/CardView";
|
||||
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 { useRouter } from "next/router";
|
||||
|
||||
export default function PinnedLinks() {
|
||||
const { links } = useLinkStore();
|
||||
const { links, selectedLinks, deleteLinksById, setSelectedLinks } =
|
||||
useLinkStore();
|
||||
|
||||
const [viewMode, setViewMode] = useState<string>(
|
||||
localStorage.getItem("viewMode") || ViewMode.Card
|
||||
|
@ -20,6 +26,49 @@ export default function PinnedLinks() {
|
|||
|
||||
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 = {
|
||||
[ViewMode.Card]: CardView,
|
||||
// [ViewMode.Grid]: GridView,
|
||||
|
@ -39,13 +88,87 @@ export default function PinnedLinks() {
|
|||
description={"Pinned Links from your Collections"}
|
||||
/>
|
||||
<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} />
|
||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||
</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]) ? (
|
||||
<LinkComponent links={links} />
|
||||
<LinkComponent editMode={editMode} links={links} />
|
||||
) : (
|
||||
<div
|
||||
style={{ flex: "1 1 auto" }}
|
||||
|
@ -62,6 +185,20 @@ export default function PinnedLinks() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
{bulkDeleteLinksModal && (
|
||||
<BulkDeleteLinksModal
|
||||
onClose={() => {
|
||||
setBulkDeleteLinksModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{bulkEditLinksModal && (
|
||||
<BulkEditLinksModal
|
||||
onClose={() => {
|
||||
setBulkEditLinksModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -170,6 +170,13 @@ export default function Login({
|
|||
{displayLoginCredential()}
|
||||
{displayLoginExternalButton()}
|
||||
{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>
|
||||
</form>
|
||||
</CenteredForm>
|
||||
|
|
|
@ -25,8 +25,6 @@ export default function Search() {
|
|||
tags: true,
|
||||
});
|
||||
|
||||
const [filterDropdown, setFilterDropdown] = useState(false);
|
||||
|
||||
const [viewMode, setViewMode] = useState<string>(
|
||||
localStorage.getItem("viewMode") || ViewMode.Card
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -11,6 +11,7 @@ import React from "react";
|
|||
import { MigrationFormat, MigrationRequest } from "@/types/global";
|
||||
import Link from "next/link";
|
||||
import Checkbox from "@/components/Checkbox";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
|
||||
export default function Account() {
|
||||
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
|
||||
|
@ -191,8 +192,8 @@ export default function Account() {
|
|||
) : undefined}
|
||||
</div>
|
||||
|
||||
<div className="sm:row-span-2 sm:justify-self-center mx-auto my-3">
|
||||
<p className="mb-2 text-center">Profile Photo</p>
|
||||
<div className="sm:row-span-2 sm:justify-self-center my-3">
|
||||
<p className="mb-2 sm:text-center">Profile Photo</p>
|
||||
<div className="w-28 h-28 flex items-center justify-center rounded-full relative">
|
||||
<ProfilePhoto
|
||||
priority={true}
|
||||
|
@ -245,6 +246,7 @@ export default function Account() {
|
|||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onMouseDown={dropdownTriggerer}
|
||||
className="flex gap-2 text-sm btn btn-outline btn-neutral group"
|
||||
id="import-dropdown"
|
||||
>
|
||||
|
@ -347,8 +349,8 @@ export default function Account() {
|
|||
<SubmitButton
|
||||
onClick={submit}
|
||||
loading={submitLoader}
|
||||
label="Save"
|
||||
className="mt-2 mx-auto lg:mx-0"
|
||||
label="Save Changes"
|
||||
className="mt-2 w-full sm:w-fit"
|
||||
/>
|
||||
|
||||
<div>
|
||||
|
@ -373,7 +375,7 @@ export default function Account() {
|
|||
|
||||
<Link
|
||||
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>
|
||||
</Link>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -77,8 +77,8 @@ export default function Password() {
|
|||
<SubmitButton
|
||||
onClick={submit}
|
||||
loading={submitLoader}
|
||||
label="Save"
|
||||
className="mt-2 mx-auto lg:mx-0"
|
||||
label="Save Changes"
|
||||
className="mt-2 w-full sm:w-fit"
|
||||
/>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import useLinkStore from "@/store/links";
|
||||
import { useRouter } from "next/router";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { FormEvent, use, useEffect, useState } from "react";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import useTagStore from "@/store/tags";
|
||||
import SortDropdown from "@/components/SortDropdown";
|
||||
|
@ -11,11 +11,16 @@ import ViewDropdown from "@/components/ViewDropdown";
|
|||
import CardView from "@/components/LinkViews/Layouts/CardView";
|
||||
// import GridView from "@/components/LinkViews/Layouts/GridView";
|
||||
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() {
|
||||
const router = useRouter();
|
||||
|
||||
const { links } = useLinkStore();
|
||||
const { links, selectedLinks, deleteLinksById, setSelectedLinks } =
|
||||
useLinkStore();
|
||||
const { tags, updateTag, removeTag } = useTagStore();
|
||||
|
||||
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
||||
|
@ -25,11 +30,31 @@ export default function Index() {
|
|||
|
||||
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 });
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTag(tags.find((e) => e.id === Number(router.query.id)));
|
||||
}, [router, tags]);
|
||||
const tag = tags.find((e) => e.id === Number(router.query.id));
|
||||
|
||||
if (tags.length > 0 && !tag?.id) {
|
||||
router.push("/dashboard");
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveTag(tag);
|
||||
}, [router, tags, Number(router.query.id), setActiveTag]);
|
||||
|
||||
useEffect(() => {
|
||||
setNewTagName(activeTag?.name);
|
||||
|
@ -90,6 +115,35 @@ export default function Index() {
|
|||
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>(
|
||||
localStorage.getItem("viewMode") || ViewMode.Card
|
||||
);
|
||||
|
@ -153,6 +207,7 @@ export default function Index() {
|
|||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onMouseDown={dropdownTriggerer}
|
||||
className="btn btn-ghost btn-sm btn-square text-neutral"
|
||||
>
|
||||
<i
|
||||
|
@ -193,16 +248,102 @@ export default function Index() {
|
|||
</div>
|
||||
|
||||
<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} />
|
||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||
</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
|
||||
editMode={editMode}
|
||||
links={links.filter((e) =>
|
||||
e.tags.some((e) => e.id === Number(router.query.id))
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{bulkDeleteLinksModal && (
|
||||
<BulkDeleteLinksModal
|
||||
onClose={() => {
|
||||
setBulkDeleteLinksModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{bulkEditLinksModal && (
|
||||
<BulkEditLinksModal
|
||||
onClose={() => {
|
||||
setBulkEditLinksModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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");
|
|
@ -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");
|
|
@ -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;
|
|
@ -0,0 +1,2 @@
|
|||
-- DropIndex
|
||||
DROP INDEX "AccessToken_name_userId_key";
|
|
@ -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;
|
|
@ -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';
|
|
@ -0,0 +1,2 @@
|
|||
-- DropIndex
|
||||
DROP INDEX "Collection_name_ownerId_key";
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "collectionOrder" INTEGER[] DEFAULT ARRAY[]::INTEGER[];
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "preventDuplicateLinks" BOOLEAN NOT NULL DEFAULT false;
|
|
@ -38,9 +38,12 @@ model User {
|
|||
tags Tag[]
|
||||
pinnedLinks Link[]
|
||||
collectionsJoined UsersAndCollections[]
|
||||
collectionOrder Int[] @default([])
|
||||
whitelistedUsers WhitelistedUser[]
|
||||
apiKeys ApiKey[]
|
||||
accessTokens AccessToken[]
|
||||
subscriptions Subscription?
|
||||
linksRouteTo LinksRouteTo @default(ORIGINAL)
|
||||
preventDuplicateLinks Boolean @default(false)
|
||||
archiveAsScreenshot Boolean @default(true)
|
||||
archiveAsPDF Boolean @default(true)
|
||||
archiveAsWaybackMachine Boolean @default(false)
|
||||
|
@ -49,6 +52,13 @@ model User {
|
|||
updatedAt DateTime @default(now()) @updatedAt
|
||||
}
|
||||
|
||||
enum LinksRouteTo {
|
||||
ORIGINAL
|
||||
PDF
|
||||
READABLE
|
||||
SCREENSHOT
|
||||
}
|
||||
|
||||
model WhitelistedUser {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @default("")
|
||||
|
@ -73,6 +83,9 @@ model Collection {
|
|||
name String
|
||||
description String @default("")
|
||||
color String @default("#0ea5e9")
|
||||
parentId Int?
|
||||
parent Collection? @relation("SubCollections", fields: [parentId], references: [id])
|
||||
subCollections Collection[] @relation("SubCollections")
|
||||
isPublic Boolean @default(false)
|
||||
owner User @relation(fields: [ownerId], references: [id])
|
||||
ownerId Int
|
||||
|
@ -80,8 +93,6 @@ model Collection {
|
|||
links Link[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@unique([name, ownerId])
|
||||
}
|
||||
|
||||
model UsersAndCollections {
|
||||
|
@ -142,16 +153,15 @@ model Subscription {
|
|||
updatedAt DateTime @default(now()) @updatedAt
|
||||
}
|
||||
|
||||
model ApiKey {
|
||||
model AccessToken {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int
|
||||
token String @unique
|
||||
revoked Boolean @default(false)
|
||||
expires DateTime
|
||||
lastUsedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
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
Ŝarĝante…
Reference in New Issue