Compare commits

..

3 Commits

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

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